Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/e2e-android.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: E2E Android
on:
workflow_dispatch:
pull_request:
types: [labeled, synchronize]
permissions:
contents: read
jobs:
e2e:
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.action == 'labeled' && github.event.label.name == 'e2e-android') ||
(github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'e2e-android'))
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
- uses: ./.github/actions/setup
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
distribution: zulu
java-version: '17'
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- run: npm install -g agent-device@0.20.10
- uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
with:
api-level: 34
target: google_apis
arch: x86_64
profile: pixel_fold
ram-size: 4096M
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -camera-back none
script: E2E_ANDROID_SERIAL=emulator-$EMULATOR_PORT pnpm run e2e:android
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
if: always()
with:
name: e2e-android-screenshots
path: e2e/artifacts
if-no-files-found: warn
54 changes: 54 additions & 0 deletions docs/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,60 @@ For CocoaPods, use a Ruby version compatible with `example/Gemfile`, then run
`bundle install` and `bundle exec pod install --project-directory=ios` from
`example/`. Set `LANG=en_US.UTF-8` and `LC_ALL=en_US.UTF-8`.

### Automated Android check

`pnpm e2e:android` drives the example on an attached Android emulator, which
must be an emulator because the hinge sensor is driven through the emulator
console, and needs no Metro server. It builds the example release variant, which the
React Native template signs with the checked-in debug keystore and ships with
the JavaScript bundle embedded, installs it, then cold launches it.

```sh
export ANDROID_HOME="$HOME/Library/Android/sdk"
E2E_ANDROID_SERIAL=emulator-5590 pnpm e2e:android
```

Set `E2E_ANDROID_SERIAL` when more than one device is attached; with a single
attached device the script uses it. Set `E2E_SKIP_BUILD=1` to reuse the APK that
is already installed. The Gradle build is limited to the ABI the target device
reports.

The check opens the Sensor lab and asserts the mode pill reads `NATIVE HINGE`.
It then drives the hinge with
`adb -s <serial> emu sensor set hinge-angle0 <degrees>` and asserts the native
angle reads `45.0°` with status `partiallyOpen` at 45 degrees, and `180.0°` with
status `fullyOpen` at 180 degrees. It then cold
launches the app again, which lands on Field Notes, and asserts the
`NATIVE 180.0°` readout there. Every asserted state is screenshotted into
`e2e/artifacts/`, which the existing `artifacts/` ignore rule already covers. A
failed assertion exits non-zero and prints the accessibility snapshot.

The return to Field Notes is a relaunch rather than a press on the Sensor lab's
own Back to Field Notes control, because that control occupies the bottom 44dp
of an edge-to-edge window and the system taskbar covers it on a foldable inner
display.

The target needs a hinge sensor. The `pixel_fold` and `pixel_9_pro_fold` AVD
profiles define one hinge over a 0 to 180 degree range with posture bands of
0-30, 30-150 and 150-180. On an ordinary phone emulator there is no
`hinge-angle0` sensor and the check fails at the first angle assertion.

The status assertions need the emulator to publish device states. Confirm with
`adb -s <serial> shell cmd device_state print-states`, which should list
`CLOSED`, `HALF_OPENED` and `OPENED`. An emulator that lists only `DEFAULT` has
not brought up its posture configuration, so the library reports a live angle
with the status left at `unknown` and the status assertions fail. Restarting
that emulator restores the device states.

The check runs `cmd device_state state reset` before it drives the sensor. A
leftover posture override from an earlier session pins the committed state, and
the hinge angle then stops driving it. Posture also lands a few seconds after
the angle does, so the status assertions allow 15 seconds while the angle
assertions allow 5.

`.github/workflows/e2e-android.yml` runs the same script on `workflow_dispatch`
and on pull requests carrying the `e2e-android` label.

## Pull requests

Before committing, run all checks above and the native builds affected by the
Expand Down
202 changes: 202 additions & 0 deletions e2e/android.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
#!/usr/bin/env node
/**
* Android end-to-end check for the example app. Installs the release variant, which embeds the
* JavaScript bundle and is signed with the checked-in debug keystore, so no Metro server is needed.
*
* Environment:
* E2E_ANDROID_SERIAL adb serial to drive; required when more than one device is attached.
* E2E_SKIP_BUILD=1 reuse the already installed APK and skip build/install.
*/
import { spawnSync } from 'node:child_process';
import { mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const androidDir = path.join(root, 'example', 'android');
const apk = path.join(androidDir, 'app', 'build', 'outputs', 'apk', 'release', 'app-release.apk');
const artifacts = path.join(root, 'e2e', 'artifacts');
const appId = 'hinges.example';
const session = 'hinges-e2e-android';
const sdk = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT;
const adbBin = sdk ? path.join(sdk, 'platform-tools', 'adb') : 'adb';

function run(command, args, options = {}) {
const result = spawnSync(command, args, { encoding: 'utf8', ...options });
return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
}

// adb blocks indefinitely against a wedged server or a flapping emulator, so bound every call.
function adb(args) {
return run(adbBin, args, { timeout: 60000 });
}

function ad(args, options = {}) {
return run('agent-device', [...args, '--platform', 'android', '--device', avdName, '--session', session], options);
}

function fail(message, detail) {
throw new Error(detail?.trim() ? `${message}\n${detail.trim()}` : message);
}

function resolveSerial() {
const listed = adb(['devices']);
if (listed.status !== 0) {
console.error(`FAIL adb devices exited with ${listed.status}`);
console.error((listed.stderr || '').trim());
process.exit(1);
}
const attached = listed.stdout
.split('\n')
.slice(1)
.map((line) => line.trim().split('\t'))
.filter((parts) => parts[1] === 'device')
.map((parts) => parts[0]);
const requested = process.env.E2E_ANDROID_SERIAL;
if (requested) {
if (!attached.includes(requested)) {
console.error(`FAIL E2E_ANDROID_SERIAL=${requested} is not attached. Attached: ${attached.join(', ') || 'none'}`);
process.exit(1);
}
return requested;
}
if (attached.length !== 1) {
console.error(`FAIL set E2E_ANDROID_SERIAL. Attached devices: ${attached.join(', ') || 'none'}`);
process.exit(1);
}
return attached[0];
}

const serial = resolveSerial();

// agent-device selects Android emulators by AVD name, while the hinge sensor is driven by adb serial.
function resolveAvdName() {
const named = adb(['-s', serial, 'emu', 'avd', 'name']);
const name = named.stdout.split('\n')[0].trim();
if (named.status !== 0 || !name || name === 'KO') {
console.error(`FAIL ${serial} is not an emulator with a readable AVD name; the hinge sensor cannot be driven`);
process.exit(1);
}
return name;
}

const avdName = resolveAvdName();

// A leftover `cmd device_state state <id>` override pins the committed posture, so the hinge angle
// stops driving it and the status assertions read whatever the previous session left behind.
function clearDeviceStateOverride() {
const result = adb(['-s', serial, 'shell', 'cmd', 'device_state', 'state', 'reset']);
if (result.status !== 0) fail('could not reset the device state override', result.stdout + result.stderr);
}

function setHingeAngle(degrees) {
const result = adb(['-s', serial, 'emu', 'sensor', 'set', 'hinge-angle0', String(degrees)]);
if (result.status !== 0 || !result.stdout.includes('OK')) {
fail(`could not set hinge-angle0 to ${degrees}`, result.stdout + result.stderr);
}
console.log(`hinge-angle0 set to ${degrees} degrees on ${serial}`);
}

function expectText(label, text, timeoutMs = 5000) {
const result = ad(['wait', 'text', text, String(timeoutMs)]);
if (result.status !== 0) {
fail(`${label}: ${JSON.stringify(text)} not visible within ${timeoutMs}ms`, result.stdout + result.stderr);
}
console.log(`PASS ${label}: ${JSON.stringify(text)}`);
}

let captured = 0;
function capture(name) {
captured += 1;
const file = path.join(artifacts, `${String(captured).padStart(2, '0')}-${name}.png`);
const result = ad(['screenshot', file]);
if (result.status !== 0) fail(`screenshot ${name}`, result.stdout + result.stderr);
console.log(`SHOT ${file}`);
}

function press(label, target) {
const result = ad(['press', target, '--settle']);
if (result.status !== 0) fail(`${label}: could not press ${target}`, result.stdout + result.stderr);
}

let sessionOpened = false;
function coldLaunch(label) {
sessionOpened = true;
const result = ad(['open', appId, '--relaunch', '--foreground']);
if (result.status !== 0) fail(`${label}: cold launch`, result.stdout + result.stderr);
console.log(`LAUNCH ${label}: ${appId} on ${serial} (${avdName})`);
}

mkdirSync(artifacts, { recursive: true });

if (process.env.E2E_SKIP_BUILD !== '1') {
const abi = adb(['-s', serial, 'shell', 'getprop', 'ro.product.cpu.abi']).stdout.trim();
if (!/^[a-z0-9_-]+$/.test(abi)) {
console.error(`FAIL could not read ro.product.cpu.abi from ${serial}`);
process.exit(1);
}
console.log(`Building the example release APK for ${serial} (${abi})`);
const built = run(path.join(androidDir, 'gradlew'), [':app:assembleRelease', `-PreactNativeArchitectures=${abi}`], {
cwd: androidDir,
stdio: 'inherit',
});
if (built.status !== 0) {
console.error('FAIL gradle assembleRelease');
process.exit(1);
}
const installed = adb(['-s', serial, 'install', '-r', apk]);
console.log((installed.stdout + installed.stderr).trim());
if (installed.status !== 0) {
console.error('FAIL adb install');
process.exit(1);
}
}

function scenario() {
clearDeviceStateOverride();
setHingeAngle(180);

coldLaunch('open field notes');
expectText('field notes ready', 'Sensor lab ↗', 20000);
press('open sensor lab', 'text="Sensor lab ↗"');

expectText('sensor lab reads the native hinge', 'NATIVE HINGE', 15000);
capture('sensor-lab-native-hinge');

setHingeAngle(45);
expectText('native angle at 45 degrees', '45.0°');
expectText('status at 45 degrees', 'partiallyOpen', 15000);
capture('sensor-lab-hinge-45');

setHingeAngle(180);
expectText('native angle at 180 degrees', '180.0°');
expectText('status at 180 degrees', 'fullyOpen', 15000);
capture('sensor-lab-hinge-180');

// Field Notes is the launch screen, and the example's own Back to Field Notes control sits in the
// bottom 44dp of an edge-to-edge window, under the system taskbar, so it is not hittable here.
coldLaunch('return to field notes');
expectText('field notes native readout', 'NATIVE 180.0°', 20000);
capture('field-notes-native-180');
}

try {
scenario();
console.log(`e2e:android passed on ${serial}. Screenshots in ${artifacts}`);
} catch (error) {
console.error(`FAIL ${error.message}`);
if (sessionOpened) {
const snapshot = ad(['snapshot', '-i']);
console.error('--- accessibility snapshot ---');
console.error((snapshot.stdout || snapshot.stderr || '(snapshot unavailable)').trim());
}
process.exitCode = 1;
} finally {
if (sessionOpened) {
const closed = ad(['close']);
if (closed.status !== 0) {
console.error(`FAIL agent-device close\n${(closed.stdout + closed.stderr).trim()}`);
process.exitCode = 1;
}
}
}
2 changes: 2 additions & 0 deletions example/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ react {
reactNativeDir = file("../../../node_modules/react-native")
codegenDir = file("../../../node_modules/@react-native/codegen")
cliFile = file("../../../node_modules/react-native/cli.js")
// The hoisted pnpm workspace keeps hermes-compiler at the repository root, not under example/.
hermesCommand = file("../../../node_modules/hermes-compiler/hermesc/%OS-BIN%/hermesc").toString()
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '../..'
// root = file("../../")
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"lint:fix": "oxlint --fix .",
"format": "oxfmt .",
"format:check": "oxfmt --check .",
"e2e:android": "node e2e/android.mjs",
"docs:build": "pnpm --filter hinges-docs build",
"docs:start": "pnpm --filter hinges-docs start"
},
Expand Down
Loading