diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 76e114b..99f042e 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -44,6 +44,13 @@ TypeScript module. The app does not authenticate users yet. The terminal provides shortcuts for iOS, Android, and web. Incoming-link tests should use a development build; Expo Go has limited linking support. +For a local development build, provide a disposable package identifier without +committing the eventual store identity: + +```bash +RCV_ANDROID_PACKAGE=com.rankedchoices.dev npx expo run:android +``` + ## Checks ```bash @@ -52,6 +59,29 @@ npm run typecheck npm run lint ``` +With the PHP server, Metro, and an Android emulator running, the Phase 1 device +scenario opens the canonical route as an incoming link, changes the ranking, +submits, and waits for locally calculated results: + +```bash +ADB="$ANDROID_HOME/platform-tools/adb" npm run test:android:e2e +``` + +Expo Go is the default target. A development build can exercise the custom +scheme with: + +```bash +RCV_E2E_APP_PACKAGE=com.rankedchoices.dev \ +RCV_E2E_INCOMING_URL=rankedchoices://ballot/pizza \ +RCV_E2E_COLD_START=0 \ +npm run test:android:e2e +``` + +Start the JavaScript runtime in the development client before running the +custom-scheme form. Expo Go supports the scenario's default cold start; the +development-client launcher must hand off to the running app before a route +link can be delivered. + ## Configuration `EXPO_PUBLIC_API_BASE_URL` must point to the directory containing the PHP API diff --git a/apps/mobile/app.config.js b/apps/mobile/app.config.js new file mode 100644 index 0000000..205c12d --- /dev/null +++ b/apps/mobile/app.config.js @@ -0,0 +1,13 @@ +module.exports = ({ config }) => ({ + ...config, + android: { + ...config.android, + ...(process.env.RCV_ANDROID_PACKAGE ? { package: process.env.RCV_ANDROID_PACKAGE } : {}), + }, + ios: { + ...config.ios, + ...(process.env.RCV_IOS_BUNDLE_IDENTIFIER + ? { bundleIdentifier: process.env.RCV_IOS_BUNDLE_IDENTIFIER } + : {}), + }, +}); diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json index 6ba61bf..194cc31 100644 --- a/apps/mobile/package-lock.json +++ b/apps/mobile/package-lock.json @@ -13,6 +13,7 @@ "expo": "~57.0.11", "expo-constants": "~57.0.9", "expo-crypto": "~57.0.1", + "expo-dev-client": "~57.0.12", "expo-linking": "~57.0.5", "expo-router": "~57.0.11", "expo-splash-screen": "~57.0.5", @@ -6328,6 +6329,59 @@ "expo": "*" } }, + "node_modules/expo-dev-client": { + "version": "57.0.12", + "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-57.0.12.tgz", + "integrity": "sha512-KAVV7YyIEQNLB5452nm1BHa7ePthD+HytkEfZha6DILjWowhr0eQ9roUnUKMBMsXnfb4J5cErSzsV8zMTKAUcQ==", + "license": "MIT", + "dependencies": { + "expo-dev-launcher": "~57.0.12", + "expo-dev-menu": "~57.0.12", + "expo-dev-menu-interface": "~57.0.0", + "expo-manifests": "~57.0.1", + "expo-updates-interface": "~57.0.1" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-dev-launcher": { + "version": "57.0.12", + "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-57.0.12.tgz", + "integrity": "sha512-Au00VBTezkzdlwbwxb6pcWGDmF/QlA0kTAYZZY1JFi8A351FLBEv9kcVdgNU+xasr8qloaD3wWwS429eqA6lWA==", + "license": "MIT", + "dependencies": { + "@expo/schema-utils": "^57.0.2", + "expo-dev-menu": "~57.0.12", + "expo-manifests": "~57.0.1" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-dev-menu": { + "version": "57.0.12", + "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-57.0.12.tgz", + "integrity": "sha512-ub513/edOV/QIPTxLU86+TynUNCIlOAMAUrhHy7hJAHRLCJWn2qVpOxnfcGZimSGk7lthpRAf67oyAK0VE2egA==", + "license": "MIT", + "dependencies": { + "expo-dev-menu-interface": "~57.0.0" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-dev-menu-interface": { + "version": "57.0.0", + "resolved": "https://registry.npmjs.org/expo-dev-menu-interface/-/expo-dev-menu-interface-57.0.0.tgz", + "integrity": "sha512-F47VdzOHYc19FhI/jBgctpO8a5UskTIxG6a1E5t3W5gF8VImuvBQffdXXfLHhsuCl7dS3v3U0R45cleeVXO1Zg==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-file-system": { "version": "57.0.2", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.2.tgz", @@ -6363,6 +6417,12 @@ "react-native": "*" } }, + "node_modules/expo-json-utils": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-57.0.1.tgz", + "integrity": "sha512-cgTe1NqzQdYs/WN+3nIY5IZg8s0pb0xaTUbhYvxQDn137GbwRfHoGM2se3m3Vsl4Qu+B9G4RPEK5WJDEU2Do7g==", + "license": "MIT" + }, "node_modules/expo-keep-awake": { "version": "57.0.1", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-57.0.1.tgz", @@ -6387,6 +6447,18 @@ "react-native": "*" } }, + "node_modules/expo-manifests": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-57.0.1.tgz", + "integrity": "sha512-qB/mDG2dYdl+EvUeQuqP8KFYCFgFCQjJYdWIHo8SFBgDzMYmdF286DFY2M1M9Okr99wkb5M4tgA3aCcwv3aEQA==", + "license": "MIT", + "dependencies": { + "expo-json-utils": "~57.0.1" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "57.0.9", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.9.tgz", @@ -6556,6 +6628,15 @@ "react-native": "*" } }, + "node_modules/expo-updates-interface": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-57.0.1.tgz", + "integrity": "sha512-+LUWwJ0gf/TEKMVdQAw/Gjih4dvrk+URgy24X9qEGKuuMDZqjBRm9T4yQyBVALGL5TTdPUaB6ILxx3lshm3pwQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo/node_modules/@expo/cli": { "version": "57.0.13", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.13.tgz", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 59d061f..559156c 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -3,11 +3,12 @@ "main": "expo-router/entry", "version": "1.0.0", "dependencies": { - "@react-native-async-storage/async-storage": "2.2.0", "@rankedchoices/rcv-core": "file:../../packages/rcv-core", + "@react-native-async-storage/async-storage": "2.2.0", "expo": "~57.0.11", "expo-constants": "~57.0.9", "expo-crypto": "~57.0.1", + "expo-dev-client": "~57.0.12", "expo-linking": "~57.0.5", "expo-router": "~57.0.11", "expo-splash-screen": "~57.0.5", @@ -29,12 +30,13 @@ }, "scripts": { "start": "expo start", - "android": "expo start --android", - "ios": "expo start --ios", + "android": "expo run:android", + "ios": "expo run:ios", "web": "expo start --web", "lint": "expo lint", "typecheck": "tsc --noEmit", - "test": "vitest run" + "test": "vitest run", + "test:android:e2e": "node scripts/android-phase1-e2e.mjs" }, "private": true } diff --git a/apps/mobile/scripts/android-phase1-e2e.mjs b/apps/mobile/scripts/android-phase1-e2e.mjs new file mode 100644 index 0000000..d6713ad --- /dev/null +++ b/apps/mobile/scripts/android-phase1-e2e.mjs @@ -0,0 +1,80 @@ +import { execFileSync } from 'node:child_process'; + +const adb = process.env.ADB ?? 'adb'; +const ballotKey = process.env.RCV_E2E_BALLOT_KEY ?? 'pizza'; +const appPackage = process.env.RCV_E2E_APP_PACKAGE ?? 'host.exp.exponent'; +const incomingUrl = + process.env.RCV_E2E_INCOMING_URL ?? + `exp://127.0.0.1:8081/--/ballot/${encodeURIComponent(ballotKey)}`; +const coldStart = process.env.RCV_E2E_COLD_START !== '0'; + +function run(...args) { + return execFileSync(adb, args, { encoding: 'utf8' }); +} + +function sleep(milliseconds) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds); +} + +function dump() { + run('shell', 'uiautomator', 'dump', '/sdcard/rcv-window.xml'); + return run('shell', 'cat', '/sdcard/rcv-window.xml'); +} + +function waitFor(pattern, description, timeout = 30_000) { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const xml = dump(); + if (pattern.test(xml)) return xml; + sleep(500); + } + throw new Error(`Timed out waiting for ${description}.`); +} + +function center(bounds) { + const match = bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/); + if (!match) throw new Error(`Invalid Android bounds: ${bounds}`); + return [Math.round((Number(match[1]) + Number(match[3])) / 2), Math.round((Number(match[2]) + Number(match[4])) / 2)]; +} + +function tapMatching(xml, pattern, description) { + const match = xml.match(pattern); + if (!match) throw new Error(`Could not locate ${description}.`); + const [x, y] = center(match[1]); + run('shell', 'input', 'tap', String(x), String(y)); +} + +const startArguments = [ + 'shell', + 'am', + 'start', + ...(coldStart ? ['-S'] : []), + '-W', + '-a', + 'android.intent.action.VIEW', + '-d', + incomingUrl, + appPackage, +]; +run(...startArguments); + +let xml = waitFor(/text="Shortcode: [^"]+"/, 'the incoming ballot link'); +tapMatching( + xml, + /content-desc="Move [^"]+ down"[^>]*bounds="([^"]+)"/, + 'an accessible move-down control', +); +waitFor(/content-desc="Move [^"]+ up"/, 'the updated candidate ranking'); + +for (let attempt = 0; attempt < 6; attempt += 1) { + xml = dump(); + if (/content-desc="Submit vote"/.test(xml)) break; + run('shell', 'input', 'swipe', '540', '2200', '540', '350', '400'); +} +xml = dump(); +tapMatching(xml, /content-desc="Submit vote"[^>]*bounds="([^"]+)"/, 'the submit button'); + +waitFor(/text="Vote recorded"/, 'the accepted vote state'); +waitFor(/text="Current results"/, 'the locally calculated election results'); + +console.log(`Phase 1 Android E2E passed for ${incomingUrl}`);