diff --git a/.github/workflows/e2e-android.yml b/.github/workflows/e2e-android.yml new file mode 100644 index 0000000..73bc575 --- /dev/null +++ b/.github/workflows/e2e-android.yml @@ -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 diff --git a/docs/workflow.md b/docs/workflow.md index 12a633b..3a9f7f7 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -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 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..34a32ff --- /dev/null +++ b/e2e/android.mjs @@ -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 ` 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; + } + } +} 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" },