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
30 changes: 30 additions & 0 deletions apps/mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
13 changes: 13 additions & 0 deletions apps/mobile/app.config.js
Original file line number Diff line number Diff line change
@@ -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 }
: {}),
},
});
81 changes: 81 additions & 0 deletions apps/mobile/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 6 additions & 4 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
}
80 changes: 80 additions & 0 deletions apps/mobile/scripts/android-phase1-e2e.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
Loading