From 3955b883e98e9d174aac7a2c0346447560fd6d9b Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 21 Sep 2026 01:53:05 -0400 Subject: [PATCH 1/3] test: add an automated Android end to end check for the example Adds e2e/android.mjs plus a pnpm e2e:android script. The check builds and installs the example release variant, which embeds the JavaScript bundle and is signed with the checked-in debug keystore, cold launches it, opens the Sensor lab, drives the emulator hinge angle and asserts the readouts with a screenshot per asserted state. Sets hermesCommand in the example Android build so the release variant finds hermesc in the hoisted pnpm workspace at the repository root. Without it assembleRelease fails, because the React Native Gradle plugin resolves hermes-compiler under example/ rather than the workspace root. Adds .github/workflows/e2e-android.yml, which runs the same script on workflow_dispatch and on pull requests labeled e2e-android. That workflow has not been executed yet. --- .github/workflows/e2e-android.yml | 40 ++++++ docs/workflow.md | 53 ++++++++ e2e/android.mjs | 195 ++++++++++++++++++++++++++++++ example/android/app/build.gradle | 2 + package.json | 1 + 5 files changed, 291 insertions(+) create mode 100644 .github/workflows/e2e-android.yml create mode 100644 e2e/android.mjs diff --git a/.github/workflows/e2e-android.yml b/.github/workflows/e2e-android.yml new file mode 100644 index 0000000..461a72e --- /dev/null +++ b/.github/workflows/e2e-android.yml @@ -0,0 +1,40 @@ +name: E2E Android +on: + workflow_dispatch: + pull_request: + types: [labeled, synchronize] +permissions: + contents: read +jobs: + e2e: + if: github.event_name == 'workflow_dispatch' || 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 diff --git a/docs/workflow.md b/docs/workflow.md index 12a633b..0c6e143 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -103,6 +103,59 @@ 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 or device +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 emu sensor set hinge-angle0 ` 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 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 diff --git a/e2e/android.mjs b/e2e/android.mjs new file mode 100644 index 0000000..1fdc1d5 --- /dev/null +++ b/e2e/android.mjs @@ -0,0 +1,195 @@ +#!/usr/bin/env node +/** + * Android end-to-end check for the example app. + * + * The example is installed as a release build. The React Native template signs + * the release variant with the checked-in debug keystore and embeds the + * JavaScript bundle, so the run never needs a Metro server. That is the reason + * assembleRelease is used here instead of a bundled debug variant. + * + * Environment: + * E2E_ANDROID_SERIAL adb serial to drive. Required when more than one device + * is attached; otherwise the sole attached device is used. + * E2E_SKIP_BUILD=1 reuse the already installed APK and skip build/install. + * + * Screenshots are written to e2e/artifacts/. + */ +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) { + console.error(`FAIL ${message}`); + if (detail) console.error(detail.trim()); + const snapshot = ad(['snapshot', '-i']); + console.error('--- accessibility snapshot ---'); + console.error((snapshot.stdout || snapshot.stderr || '(snapshot unavailable)').trim()); + ad(['close']); + process.exit(1); +} + +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 ` 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() { + adb(['-s', serial, 'shell', 'cmd', 'device_state', 'state', 'reset']); +} + +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); +} + +function coldLaunch(label) { + const result = ad(['open', appId, '--relaunch', '--foreground']); + if (result.status !== 0) { + console.error(`FAIL ${label}: cold launch`); + console.error((result.stdout + result.stderr).trim()); + process.exit(1); + } + 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 = run(adbBin, ['-s', serial, 'install', '-r', apk], { stdio: 'inherit' }); + if (installed.status !== 0) { + console.error('FAIL adb install'); + process.exit(1); + } +} + +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'); + +ad(['close']); +console.log(`e2e:android passed on ${serial}. Screenshots in ${artifacts}`); diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 795bbaa..48ba69d 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -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("../../") diff --git a/package.json b/package.json index efbe5d6..4e60332 100644 --- a/package.json +++ b/package.json @@ -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" }, From 44ed84af801d5018cacfd5ec8557d5bbfb912b8a Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 21 Sep 2026 05:25:21 -0400 Subject: [PATCH 2/3] test: close the e2e session on every failure path and bound adb install --- docs/workflow.md | 5 ++- e2e/android.mjs | 99 ++++++++++++++++++++++++++---------------------- 2 files changed, 56 insertions(+), 48 deletions(-) diff --git a/docs/workflow.md b/docs/workflow.md index 0c6e143..3a9f7f7 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -105,8 +105,9 @@ For CocoaPods, use a Ruby version compatible with `example/Gemfile`, then run ### Automated Android check -`pnpm e2e:android` drives the example on an attached Android emulator or device -and needs no Metro server. It builds the example release variant, which the +`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. diff --git a/e2e/android.mjs b/e2e/android.mjs index 1fdc1d5..34a32ff 100644 --- a/e2e/android.mjs +++ b/e2e/android.mjs @@ -1,18 +1,11 @@ #!/usr/bin/env node /** - * Android end-to-end check for the example app. - * - * The example is installed as a release build. The React Native template signs - * the release variant with the checked-in debug keystore and embeds the - * JavaScript bundle, so the run never needs a Metro server. That is the reason - * assembleRelease is used here instead of a bundled debug variant. + * 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; otherwise the sole attached device is used. + * 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. - * - * Screenshots are written to e2e/artifacts/. */ import { spawnSync } from 'node:child_process'; import { mkdirSync } from 'node:fs'; @@ -43,13 +36,7 @@ function ad(args, options = {}) { } function fail(message, detail) { - console.error(`FAIL ${message}`); - if (detail) console.error(detail.trim()); - const snapshot = ad(['snapshot', '-i']); - console.error('--- accessibility snapshot ---'); - console.error((snapshot.stdout || snapshot.stderr || '(snapshot unavailable)').trim()); - ad(['close']); - process.exit(1); + throw new Error(detail?.trim() ? `${message}\n${detail.trim()}` : message); } function resolveSerial() { @@ -98,7 +85,8 @@ const avdName = resolveAvdName(); // A leftover `cmd device_state state ` 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() { - adb(['-s', serial, 'shell', 'cmd', 'device_state', 'state', 'reset']); + 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) { @@ -131,13 +119,11 @@ function press(label, target) { 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) { - console.error(`FAIL ${label}: cold launch`); - console.error((result.stdout + result.stderr).trim()); - process.exit(1); - } + if (result.status !== 0) fail(`${label}: cold launch`, result.stdout + result.stderr); console.log(`LAUNCH ${label}: ${appId} on ${serial} (${avdName})`); } @@ -158,38 +144,59 @@ if (process.env.E2E_SKIP_BUILD !== '1') { console.error('FAIL gradle assembleRelease'); process.exit(1); } - const installed = run(adbBin, ['-s', serial, 'install', '-r', apk], { stdio: 'inherit' }); + 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); } } -clearDeviceStateOverride(); -setHingeAngle(180); +function scenario() { + clearDeviceStateOverride(); + setHingeAngle(180); -coldLaunch('open field notes'); -expectText('field notes ready', 'Sensor lab ↗', 20000); -press('open sensor lab', 'text="Sensor lab ↗"'); + 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'); + 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(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'); + 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'); + // 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'); +} -ad(['close']); -console.log(`e2e:android passed on ${serial}. Screenshots in ${artifacts}`); +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; + } + } +} From ba1b2077e268ae82c53bb2699d22184a7ed9db05 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 21 Sep 2026 05:27:14 -0400 Subject: [PATCH 3/3] ci: gate the e2e job on the e2e-android label event --- .github/workflows/e2e-android.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e-android.yml b/.github/workflows/e2e-android.yml index 461a72e..73bc575 100644 --- a/.github/workflows/e2e-android.yml +++ b/.github/workflows/e2e-android.yml @@ -7,7 +7,10 @@ permissions: contents: read jobs: e2e: - if: github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'e2e-android') + 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: