Skip to content

Commit 3bb522d

Browse files
mikepitreclaude
andcommitted
test(expo): add the unit tests of the remote session
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 9dcd1ae commit 3bb522d

17 files changed

Lines changed: 2133 additions & 6 deletions

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ jobs:
119119
WATCHED: >-
120120
integration/expo-native
121121
.github/workflows/ci.yml
122+
.github/workflows/verify-remote.yml
122123
.github/actionlint.yaml
123124
integration/templates/expo-native
124125
packages/expo

‎.prettierignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,6 @@ integration/expo-native/e2e.config.ts
4040
integration/expo-native/test/*.ts
4141
!integration/expo-native/test/freshness.test.ts
4242
!integration/expo-native/test/host.test.ts
43+
!integration/expo-native/test/remote-host.test.ts
4344
integration/expo-native/.verify/
4445
integration/expo-native/specs/explored/

‎integration/expo-native/test/android.test.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { describe, it } from 'node:test';
99
import { isRunning, run } from '../src/core/exec.ts';
1010
import type { EvidencePath, LocalLease } from '../src/core/types.ts';
1111
import { readClaim, takeSlot } from '../src/core/claims.ts';
12-
import { LOG_FILTER, RECORD_SIZE, emulatorArgs, laneSettingsCommand, laneSettingsHold, laneSettingsReadCommand, logFilter, logcatSince } from '../src/platform/android/emulator.ts';
12+
import { LANE_FAILED, LANE_READY, LOG_FILTER, RECORD_SIZE, collectRecordingArgs, emulatorArgs, laneSettingsCommand, laneSettingsHold, laneSettingsReadCommand, logFilter, logcatSince, stopScreenrecordArgs, waitForLane } from '../src/platform/android/emulator.ts';
1313
import { lanePort, localAndroidBackend, laneSerial, parseAdbDevices, startScreenrecord, terminateGroup } from '../src/platform/android/local.ts';
1414
import { ensureLaneAvd, jdkCheck, localAvailability, resolveJavaHome, sdkRoot, systemImage, type Machine } from '../src/platform/android/sdk.ts';
1515

@@ -523,3 +523,29 @@ describe('whether this machine can run the emulator', () => {
523523
assert.ok(unset.at(join(unset.home, '.android', 'avd')));
524524
});
525525
});
526+
527+
describe('what a remote session runs on its emulator', () => {
528+
it('holds the install until the lane boot has finished, and fails the build with the boot\'s own error', async () => {
529+
const work = mkdtempSync(join(tmpdir(), 'verify-recipe-'));
530+
const wait = waitForLane(work);
531+
const waiting = run(wait.command, wait.args);
532+
await new Promise((resolve) => setTimeout(resolve, 300));
533+
writeFileSync(join(work, LANE_READY), 'emulator-5560\n');
534+
assert.equal((await waiting).code, 0);
535+
536+
const failed = mkdtempSync(join(tmpdir(), 'verify-recipe-'));
537+
writeFileSync(join(failed, LANE_FAILED), 'no KVM here\n');
538+
const refuse = waitForLane(failed);
539+
const result = await run(refuse.command, refuse.args);
540+
assert.equal(result.code, 1);
541+
assert.match(result.stdout, /the emulator did not boot: no KVM here/);
542+
});
543+
544+
it('stops screenrecord on the device and waits for it to exit, then pulls the file and deletes it there', () => {
545+
assert.match(stopScreenrecordArgs.join(' '), /^shell pkill -INT screenrecord; .*while pidof screenrecord/);
546+
assert.deepEqual(collectRecordingArgs('/data/local/tmp/verify-session.mp4', '/work/recording.mp4'), [
547+
['pull', '/data/local/tmp/verify-session.mp4', '/work/recording.mp4'],
548+
['shell', 'rm', '-f', '/data/local/tmp/verify-session.mp4'],
549+
]);
550+
});
551+
});

‎integration/expo-native/test/cli.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ describe('parseArgv', () => {
2020
it('parses every verb', () => {
2121
assert.deepEqual(parseArgv(['doctor']).command, { verb: 'doctor', live: false });
2222
assert.deepEqual(parseArgv(['doctor', '--platform', 'ios', '--backend', 'local']).command, { verb: 'doctor', platform: 'ios', backend: 'local', live: false });
23+
assert.deepEqual(parseArgv(['doctor', '--backend', 'remote', '--live', '--runner', 'ubuntu-latest']).command, { verb: 'doctor', backend: 'remote', runner: 'ubuntu-latest', live: true });
2324
assert.deepEqual(parseArgv(['up', '--backend', 'auto']).command, { verb: 'up', waitSeconds: 0 });
24-
assert.throws(() => parseArgv(['up', '--backend', 'elsewhere']), /auto or local/);
25+
assert.throws(() => parseArgv(['up', '--backend', 'elsewhere']), /auto, local, or remote/);
26+
assert.throws(() => parseArgv(['up', '--runner', 'bad label; rm']), /runner label/);
2527
assert.deepEqual(parseArgv(['up', '--wait', '300']).command, { verb: 'up', waitSeconds: 300 });
2628
assert.deepEqual(parseArgv(['up']).command, { verb: 'up', waitSeconds: 0 });
2729
assert.deepEqual(parseArgv(['run', 'auth-start', 'sign-up/request-code']).command, {
@@ -69,6 +71,7 @@ describe('parseArgv', () => {
6971
['frobnicate'],
7072
['Doctor'],
7173
['doctor', '--wait', '3'],
74+
['doctor', '--backend', 'remote', '--runner', 'ubuntu-latest'],
7275
['up', '--png'],
7376
['run'],
7477
['run', 'x', '--all'],

‎integration/expo-native/test/device-command.test.ts‎

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,37 @@
11
import '../testing/git-env.ts';
22
import assert from 'node:assert/strict';
3-
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
3+
import { mkdirSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs';
44
import { tmpdir } from 'node:os';
55
import { join } from 'node:path';
6-
import { describe, it } from 'node:test';
6+
import { afterEach, describe, it } from 'node:test';
77
import { deviceCommand, deviceToolCommand } from '../src/core/device-command.ts';
88
import { run } from '../src/core/exec.ts';
9-
import { VerifyFailure, type LocalLease } from '../src/core/types.ts';
9+
import { coreVersion } from '../src/core/manifest.ts';
10+
import { remoteBackend } from '../src/core/remote/backend.ts';
11+
import type { GitHub } from '../src/core/remote/github.ts';
12+
import type { SessionHealth } from '../src/core/remote/protocol.ts';
13+
import { VerifyFailure, type LocalLease, type RemoteLease } from '../src/core/types.ts';
14+
15+
const realFetch = globalThis.fetch;
16+
afterEach(() => void (globalThis.fetch = realFetch));
17+
18+
function remoteLease(platform: 'ios' | 'android'): RemoteLease {
19+
const tokenFile = join(mkdtempSync(join(tmpdir(), 'verify-command-')), 'token');
20+
writeFileSync(tokenFile, 'd'.repeat(64));
21+
return { backend: 'remote', provider: 'github-actions', platform, session: `${platform}abc123`, providerRef: '7', baseUrl: 'https://quick-fox.trycloudflare.com', tokenFile, deviceId: 'emulator-5554', deviceName: 'Pixel', runner: 'linux', expiresAt: new Date(Date.now() + 3_600_000).toISOString(), builtSha: null, acquiredAt: '', installedBuild: null };
22+
}
23+
24+
function tunnel(reply: (path: string) => { status: number; body: unknown }): { readonly calls: { path: string; method: string; authorization: string | null; body: unknown }[] } {
25+
const calls: { path: string; method: string; authorization: string | null; body: unknown }[] = [];
26+
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
27+
const url = new URL(String(input));
28+
assert.equal(url.host, 'quick-fox.trycloudflare.com');
29+
calls.push({ path: url.pathname, method: init?.method ?? 'GET', authorization: new Headers(init?.headers).get('authorization'), body: typeof init?.body === 'string' ? JSON.parse(init.body) : null });
30+
const { status, body } = reply(url.pathname);
31+
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
32+
}) as typeof fetch;
33+
return { calls };
34+
}
1035

1136
describe('the device tool', () => {
1237
it('is adb on the device for shell and for reverse to a TCP port, and nothing that reaches the files or sockets of the machine adb runs on', () => {
@@ -51,3 +76,63 @@ describe('deviceCommand', () => {
5176
await assert.rejects(deviceCommand(lease, ['pull', '/sdcard/x', 'x'], { runner: async () => assert.fail('ran') }), (error: VerifyFailure) => error.code === 'UNSUPPORTED');
5277
});
5378
});
79+
80+
describe('a session started from another core', () => {
81+
const health = (core: string | undefined): SessionHealth =>
82+
({ core, device: { id: 'emulator-5554', name: 'Pixel', ready: true }, daemon: true, build: { state: 'none' }, capAt: '', ending: null }) as SessionHealth;
83+
const backend = () =>
84+
remoteBackend(
85+
{ platform: 'android', repo: 'clerk/clerk-android', workflow: 'verify-remote.yml', sessionsDir: mkdtempSync(join(tmpdir(), 'verify-core-')), runner: 'linux', plumbingRunner: 'ubuntu-latest', device: 'Pixel', idleMinutes: 15, capMinutes: 60, requirement: '' },
86+
{ env: {}, runner: run, github: async () => assert.fail('a session that answers is not looked up at GitHub') },
87+
);
88+
89+
it('is held when its agent has this checkout\'s core', async () => {
90+
tunnel(() => ({ status: 200, body: health(coreVersion()) }));
91+
assert.equal(await backend().check(remoteLease('android')), 'held');
92+
});
93+
94+
it('is refused, not renewed, when its agent has another core, with the fix that gets both onto one commit', async () => {
95+
for (const core of ['0123456789ab', undefined]) {
96+
tunnel(() => ({ status: 200, body: health(core) }));
97+
await assert.rejects(backend().check(remoteLease('android')), (error: VerifyFailure) => error.code === 'NOT_READY' && error.message.includes(coreVersion()) && error.fix === 'commit and push the changes under src/core, then {cli} down and {cli} up');
98+
}
99+
});
100+
});
101+
102+
describe('acquiring a session whose agent has another core', () => {
103+
it('asks that session to stop through its tunnel and keeps no lease or token', async () => {
104+
const sessionsDir = mkdtempSync(join(tmpdir(), 'verify-acquire-'));
105+
const tunnelCalls: string[] = [];
106+
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
107+
const url = new URL(String(input));
108+
tunnelCalls.push(`${init?.method ?? 'GET'} ${url.host}${url.pathname}`);
109+
const body = url.pathname === '/__sim/stop' ? { ok: true } : ({ core: '0123456789ab', device: { id: 'emulator-5560', name: 'Pixel', ready: false }, daemon: false, build: { state: 'none' }, capAt: '', ending: null } satisfies SessionHealth);
110+
return new Response(JSON.stringify(body), { status: 200 });
111+
}) as typeof fetch;
112+
const hubCalls: string[] = [];
113+
const hub: GitHub = {
114+
repo: 'clerk/clerk-android',
115+
workflow: 'verify-remote.yml',
116+
tokenSource: 'none',
117+
async api(method, path) {
118+
hubCalls.push(`${method} ${path}`);
119+
if (path.endsWith('/dispatches')) return { status: 200, json: { workflow_run_id: 9 }, headers: new Headers() };
120+
if (path.includes('/jobs')) return { status: 200, json: { jobs: [{ name: 'session', status: 'in_progress', conclusion: null, steps: [{ name: 'verify-remote tunnel quick-fox.trycloudflare.com', status: 'completed', conclusion: 'success' }] }] }, headers: new Headers() };
121+
const stopped = tunnelCalls.some((call) => call.endsWith('/__sim/stop'));
122+
return { status: 200, json: { status: stopped ? 'completed' : 'in_progress', conclusion: stopped ? 'success' : null, html_url: 'https://github.com/x' }, headers: new Headers() };
123+
},
124+
};
125+
const backend = remoteBackend(
126+
{ platform: 'android', repo: 'clerk/clerk-android', workflow: 'verify-remote.yml', sessionsDir, runner: 'linux', plumbingRunner: 'ubuntu-latest', device: 'Pixel', idleMinutes: 15, capMinutes: 60, requirement: '' },
127+
{ env: {}, runner: async () => ({ code: 0, stdout: 'my-branch\n', stderr: '' }), github: async () => hub },
128+
);
129+
const progress: string[] = [];
130+
await assert.rejects(
131+
backend.acquire({ platform: 'android', worktree: '/w', waitSeconds: 0, app: { sourceSha: 'f'.repeat(40) } as never, retryWith: '', progress: (line) => progress.push(line) }),
132+
(error: VerifyFailure) => error.code === 'NOT_READY' && error.message.includes('0123456789ab') && error.fix.includes('{cli} down and {cli} up'),
133+
);
134+
assert.deepEqual(tunnelCalls, ['GET quick-fox.trycloudflare.com/__sim/health', 'POST quick-fox.trycloudflare.com/__sim/stop']);
135+
assert.equal(hubCalls.some((call) => call.endsWith('/cancel')), false, 'a session that stops when asked is not cancelled');
136+
assert.deepEqual(readdirSync(sessionsDir), ['owner'], 'the token of the session that was ended is gone');
137+
});
138+
});

‎integration/expo-native/test/evidence.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ function partialRecord(dir: EvidencePath, run: RunId): Omit<EvidenceRecord, 'sea
2929
dirty: false,
3030
platform: 'ios',
3131
backend: 'local',
32+
remote: null,
3233
device: 'verify-ios-1',
3334
build: 'ios-000000000000' as BuildKey,
3435
results: [
@@ -327,7 +328,7 @@ describe('attach', () => {
327328
`## Summary\n\nFixes the button.\n\n<!-- verify-evidence:ios -->\n\nverify run \`${run}\` on \`verify-ios-1\` at \`abc\`, 1 of 1 passed.\n\n![video.mp4](./video.mp4)\n\n![profile](./screenshots/profile.png)\n\n<!-- /verify-evidence:ios -->`,
328329
);
329330
assert.equal(existsSync(edit.args[edit.args.indexOf('--body-file') + 1]!), false, 'the copy of the description is not kept');
330-
assert.deepEqual(first, { verb: 'attach', prUrl: PR_URL, posted: [join(dir, 'video.mp4'), join(dir, 'screenshots', 'profile.png')], alreadyPosted: false });
331+
assert.deepEqual(first, { verb: 'attach', via: 'gh', prUrl: PR_URL, posted: [join(dir, 'video.mp4'), join(dir, 'screenshots', 'profile.png')], alreadyPosted: false });
331332
assert.deepEqual(second, { ...first, alreadyPosted: true });
332333
assert.ok(existsSync(join(dir, 'posted-9.json')));
333334
await postToPullRequest(publishable, dir, host, 10, 'all', gh.runner);

0 commit comments

Comments
 (0)