Skip to content

Commit fe47a59

Browse files
mikepitreclaude
andcommitted
test(expo): start Metro through the core runtime hook and copy core from clerk-ios 69c1d6a2
The new core starts the host runtime on up and run, launches dev clients through am start on Android, and ORs the React Native log subsystem into the iOS app.log filter. The Android lane layer comes from clerk-android 43a319c4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 884ff29 commit fe47a59

19 files changed

Lines changed: 373 additions & 137 deletions

File tree

‎packages/expo/.claude/skills/verify/specs/fixtures.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { dirname, join } from 'node:path';
44
import { test as base } from '@e2e-dev/mobile';
55
import { expect } from 'e2e';
66
import { ASSERTION_TIMEOUT_MS, loadRunContext } from '../src/core/e2e-config.ts';
7-
import { describeState, parseVerifyState } from '../src/core/state.ts';
7+
import { appStart, describeState, parseVerifyState } from '../src/core/state.ts';
88
import { agentDeviceStateDir } from '../src/core/workspace.ts';
99
import type { host as hostAdapter } from '../src/host.ts';
1010

@@ -33,6 +33,16 @@ const POLL_MS = 400;
3333

3434
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
3535

36+
function adb(target: RunTarget, args: readonly string[]): Promise<void> {
37+
const lease = JSON.parse(readFileSync(target.leaseFile, 'utf8')) as { deviceId: string };
38+
return new Promise((resolve, reject) => {
39+
execFile('adb', ['-s', lease.deviceId, ...args], (error, _stdout, stderr) => {
40+
if (error === null) resolve();
41+
else reject(new Error(`adb ${args[0]} failed: ${stderr.trim() || error.message}`));
42+
});
43+
});
44+
}
45+
3646
/** e2e names the worker's agent-device session `<session>-<slot>`, and verify runs one worker, so slot 0. */
3747
function typeIntoFocused(context: RunContext, target: RunTarget, text: string): Promise<void> {
3848
const lease = JSON.parse(readFileSync(target.leaseFile, 'utf8')) as { deviceId?: string };
@@ -122,7 +132,9 @@ export const test = base.extend<{ host: HostFixture<HostScreen> }>({
122132
};
123133
const launch = await call<BrokerLaunchResponse>('/launch', request);
124134
lastScope = launch.storageScope;
125-
await device.openApp(target.appId, { relaunch: true, launchArguments: launch.launchArguments });
135+
const start = appStart(target.platform, target.appId, target.entry, launch.launchArguments);
136+
if (start.kind === 'open-app') await device.openApp(target.appId, { relaunch: true, launchArguments: start.launchArguments });
137+
else for (const command of start.commands) await adb(target, command);
126138
const ready = (s: VerifyState) =>
127139
s.launchId === launch.launchId &&
128140
(s.lastError !== null || (s.environmentLoaded && (user === null || s.ticket === 'succeeded' || s.ticket === 'failed')));
Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
cdc1b4456e81d87286a1a595b7fd51ab5270ac789c279312ffece57456a0bce6 broker.ts
22
b04c0026c6ff1ef3d73fb1cddd6c39fcd423022abcba2b268b6c7d5bf13f6bc3 claims.ts
3-
e831b55c97eca37565cfe16cd4b747148c7559ef2e84cca650c5e0a0ead13547 clerk.ts
4-
fa75dc6271efe589c1bc448d59ec989247b5bb2d730f0aa444656562fba9ab6d cli.ts
5-
167b6ad5bb8e658676cdec880d1392b67b284788d34074f93f1a54decf621ea2 devices.ts
3+
8869a4c586ad03f57ba2087b6a8f32a5db315b43c3147a5205b646d845d09077 clerk.ts
4+
9180c6e543e7d2d0189e13762a86f9f80ed446bb5175736f10f8612c8548ef39 cli.ts
5+
bc31efb0cf2dec598e0d74fc4b5d533a7e54b42a017031b76ebe5a9a3dcc8c7d devices.ts
66
1958ae5fde67836aef8dcdbcda6780d6d2c671c28bf238e7336fa2725dc7e042 e2e-config.ts
77
5d18969f5131082474d1af2542b0f7524f32efe8dd9393765d0b8455319a8102 e2e.ts
88
af6ce3c27d93f6f50d833c43095c8bc3ff4a54f9cfda12b9f7e73d7d627cc405 evidence.ts
@@ -13,7 +13,7 @@ d3ee90fa760f73d1816eabb907f723431747a71cdad1f966ae31200f828ba6e5 ledgers.ts
1313
4436a99ad3c0c9b159aa22114b7794c9f808c802ec6c8ad52680f6f5c29dc8a5 publish.ts
1414
c5e65966dfbb8fe63c5471deed10d68f962eba32048b3367c6944f58dd490b74 secret.ts
1515
685eabf010c7f63d076cfd3da1a681b5cc8024720538c1bf3a99792b563d8b60 slot.ts
16-
598f40161b0442e91aff3298b158f8d1fbef81bf3f89a69281ea9f27904c9493 state.ts
17-
c004692ecd90a88c17c9317c64866141d7d68c5ce43b7cf9f2a2b6f7de7b0171 types.ts
18-
403426b9276dfc8c7a200621650e30861930e2f0b440503d16173f4a9059caca verbs.ts
19-
4675e3d330c4610bbde6b9ad44de57a2aca7fe5fd63e12868a8f157638a2cfe5 workspace.ts
16+
4fb28a1cfcdc0fe88834d63319d64671406cab5a10abf9ab50462919f82873e4 state.ts
17+
b68aae1847a0a6e57b2c07f0c4e1d5c82ad4b3524ff67dd0d423f972a7f2aabf types.ts
18+
fe401a6040202daecf255f4d6291b4454af3fefc3457df6f010977552a31bb7a verbs.ts
19+
ac1d82dfe84b7f635d1f229f4ce1f57458aa6eb00f0ab589fd9a1ea8def1241b workspace.ts

‎packages/expo/.claude/skills/verify/src/core/clerk.ts‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Secret } from './secret.ts';
22
import type { InstanceKeys } from './keys.ts';
33
import {
44
VerifyFailure,
5+
type DeletionTarget,
56
type InstanceName,
67
type PublishableKey,
78
type RunId,
@@ -52,7 +53,7 @@ export interface ClerkBackend {
5253
createUser(instance: InstanceName, email: TestEmail, phone: TestPhone | null): Promise<SeededUser>;
5354
mintTicket(user: SeededUser, expiresInSeconds: number): Promise<Secret<'ticket'>>;
5455
deleteByEmail(instance: InstanceName, email: TestEmail): Promise<{ readonly users: number; readonly organizations: number }>;
55-
previewDeleteByEmail(instance: InstanceName, email: TestEmail): Promise<{ readonly users: number; readonly organizations: number }>;
56+
previewDeleteByEmail(instance: InstanceName, email: TestEmail): Promise<readonly DeletionTarget[]>;
5657
findUserId(instance: InstanceName, email: TestEmail): Promise<string | null>;
5758
settings(instance: InstanceName): Promise<InstanceSettings>;
5859
}
@@ -88,12 +89,14 @@ export function createClerkBackend(keysFor: (instance: InstanceName) => Instance
8889
return json;
8990
}
9091

91-
async function ownedOrganizations(instance: InstanceName, userId: string): Promise<readonly string[]> {
92+
async function ownedOrganizations(instance: InstanceName, userId: string): Promise<readonly { readonly id: string; readonly name: string }[]> {
9293
const memberships = (await bapi(instance, 'GET', `/users/${userId}/organization_memberships?limit=100`).catch((error: unknown) => {
9394
if (error instanceof ClerkHttpError && error.codes.includes('organization_not_enabled_in_instance')) return { data: [] };
9495
throw error;
95-
})) as { data?: { organization?: { id?: string; created_by?: string } }[] };
96-
return (memberships.data ?? []).flatMap((m) => (typeof m.organization?.id === 'string' && m.organization.created_by === userId ? [m.organization.id] : []));
96+
})) as { data?: { organization?: { id?: string; name?: string; created_by?: string } }[] };
97+
return (memberships.data ?? []).flatMap(({ organization: org }) =>
98+
typeof org?.id === 'string' && org.created_by === userId ? [{ id: org.id, name: org.name ?? '' }] : [],
99+
);
97100
}
98101

99102
async function usersByEmail(instance: InstanceName, email: TestEmail): Promise<readonly { id: string }[]> {
@@ -120,18 +123,20 @@ export function createClerkBackend(keysFor: (instance: InstanceName) => Instance
120123
return (await usersByEmail(instance, email))[0]?.id ?? null;
121124
},
122125
async previewDeleteByEmail(instance, email) {
123-
let organizations = 0;
124-
const users = await usersByEmail(instance, email);
125-
for (const user of users) organizations += (await ownedOrganizations(instance, user.id)).length;
126-
return { users: users.length, organizations };
126+
const targets: DeletionTarget[] = [];
127+
for (const user of await usersByEmail(instance, email)) {
128+
targets.push({ kind: 'user', instance, id: user.id, email });
129+
for (const org of await ownedOrganizations(instance, user.id)) targets.push({ kind: 'organization', instance, ...org });
130+
}
131+
return targets;
127132
},
128133
async deleteByEmail(instance, email) {
129134
let users = 0;
130135
let organizations = 0;
131136
for (const user of await usersByEmail(instance, email)) {
132137
for (const org of await ownedOrganizations(instance, user.id)) {
133138
try {
134-
await bapi(instance, 'DELETE', `/organizations/${org}`);
139+
await bapi(instance, 'DELETE', `/organizations/${org.id}`);
135140
organizations += 1;
136141
} catch (error) {
137142
if (!(error instanceof ClerkHttpError && error.status === 404)) throw error;

‎packages/expo/.claude/skills/verify/src/core/cli.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ type FlagSpec = Readonly<Record<string, 'value' | 'bool' | 'list'>>;
4040
const FLAGS: Readonly<Record<Verb, FlagSpec>> = {
4141
doctor: { platform: 'value', backend: 'value' },
4242
up: { platform: 'value', backend: 'value', wait: 'value' },
43-
run: { platform: 'value', backend: 'value', all: 'bool', skip: 'list', grep: 'value', 'no-video': 'bool' },
43+
run: { platform: 'value', backend: 'value', all: 'bool', skip: 'list', grep: 'value', 'no-video': 'bool', wait: 'value' },
4444
screen: { platform: 'value', png: 'bool' },
4545
attach: { pr: 'value', screenshot: 'list' },
4646
down: { platform: 'value', stale: 'bool', 'dry-run': 'bool' },
@@ -193,7 +193,7 @@ function renderRun(result: RunResult, skillDir: string): string[] {
193193
lines.push(`evidence ${rel(process.cwd(), result.dir)}`);
194194
if (r.videos.length > 0) lines.push(` video ${r.videos.map((v) => basename(v)).join(', ')}`);
195195
if (r.screenshots.length > 0) lines.push(` screenshots ${r.screenshots.map((s) => basename(s.path)).join(', ')}`);
196-
if (r.lastState !== null) lines.push(` last state ${describeState(r.lastState)}`);
196+
if (r.lastState !== null) lines.push(` last state ${describeState(r.lastState)} (the last test only; every state is in states.jsonl)`);
197197
if (r.appLog !== null) lines.push(` app log ${basename(r.appLog)}`);
198198
if (r.tainted.length > 0) lines.push(` TAINTED ${r.tainted.map((t) => rel(result.dir, t)).join(', ')} (attach is blocked)`);
199199
lines.push(`next ${result.next.startsWith('bin/verify ') ? result.next : rel(process.cwd(), result.next)}`);
@@ -208,7 +208,6 @@ function render(value: VerbResult, skillDir: string): string[] {
208208
}
209209
case 'up':
210210
return [
211-
...value.builds.map((b) => `build ${b.key} ${b.source} ${b.reused ? 'reused' : `${b.seconds}s`}`),
212211
...value.leases.map((l) => `device ${l.device} ${l.backend} ${l.renewed ? 'renewed' : 'leased by this worktree'} installed ${l.installedBuild ?? 'nothing'}`),
213212
];
214213
case 'run':
@@ -233,7 +232,8 @@ function render(value: VerbResult, skillDir: string): string[] {
233232
...(value.dryRun ? ['dry run: nothing was changed'] : []),
234233
`${value.dryRun ? 'would release' : 'released'} ${value.released.map((l) => l.device).join(', ') || 'nothing'}`,
235234
`${value.dryRun ? 'would delete' : 'deleted'} ${value.deletedUsers} users, ${value.deletedOrganizations} organizations`,
236-
...(value.stoppedProcesses.length > 0 ? [`${value.dryRun ? 'would stop' : 'stopped'} ${value.stoppedProcesses.join(', ')}`] : []),
235+
...(value.dryRun ? value.wouldDelete : []).map((t) => (t.kind === 'user' ? ` user ${t.instance} ${t.id} ${t.email}` : ` organization ${t.instance} ${t.id} ${t.name}`)),
236+
`${value.dryRun ? 'would stop' : 'stopped'} ${value.stoppedProcesses.join(', ') || 'nothing'}${value.stoppedProcesses.some((p) => p.startsWith('agent-device ')) ? '' : '; no agent-device daemon running'}`,
237237
`kept ${value.keptRuns.length} runs in .verify/runs/`,
238238
];
239239
default: {

‎packages/expo/.claude/skills/verify/src/core/devices.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ export interface LeaseOutcome {
3131
export function backendFor(host: HostAdapter, platform: Platform, kind: BackendKind): DeviceBackend {
3232
const backend = host.backends.find((b) => b.platform === platform && b.kind === kind);
3333
if (backend === undefined) {
34-
const fix = kind === 'eas' ? 'run locally on a Mac; the EAS backend arrives with CLOUD-IOS' : `use --backend ${host.backends.find((b) => b.platform === platform)?.kind ?? 'local'}`;
34+
const others = host.backends.filter((b) => b.platform === platform);
35+
const fix = others.length === 0 ? `${host.repo} has no ${platform} backend` : `use ${others.map((b) => `--backend ${b.kind} (needs ${b.requirement})`).join(' or ')}`;
3536
throw new VerifyFailure('UNSUPPORTED', `${host.repo} has no ${kind} backend for ${platform}`, fix);
3637
}
3738
return backend;
@@ -42,7 +43,8 @@ export function selectBackend(host: HostAdapter, platform: Platform, requested:
4243
if (held !== null) return backendFor(host, platform, held.backend);
4344
const backend = host.backends.find((b) => b.platform === platform && b.supports(os));
4445
if (backend === undefined) {
45-
throw new VerifyFailure('UNSUPPORTED', `no ${platform} backend runs on ${os}`, 'run on a Mac with Xcode; the EAS backend arrives with CLOUD-IOS');
46+
const needs = host.backends.filter((b) => b.platform === platform).map((b) => b.requirement);
47+
throw new VerifyFailure('UNSUPPORTED', `no ${platform} backend runs on ${os}`, needs.length === 0 ? `${host.repo} has no ${platform} backend` : `run on ${needs.join(' or ')}`);
4648
}
4749
return backend;
4850
}
@@ -90,7 +92,7 @@ async function ensureBuild(host: HostAdapter, platform: Platform, workspace: Wor
9092
if (existing !== null) return { app: existing, view: { platform, key, source: existing.source, reused: true, seconds: 0 } };
9193
const source = host.buildSources(platform, process.platform)[0];
9294
if (source !== 'local') {
93-
throw new VerifyFailure('UNSUPPORTED', `${host.repo} cannot build ${platform} on ${process.platform} yet`, 'run bin/verify up on a Mac with Xcode; remote builds arrive with CLOUD-IOS');
95+
throw new VerifyFailure('UNSUPPORTED', `${host.repo} cannot build ${platform} on ${process.platform} yet (build sources here: ${host.buildSources(platform, process.platform).join(', ') || 'none'})`, `run bin/verify up on a machine where ${host.repo} builds ${platform} locally`);
9496
}
9597
const started = Date.now();
9698
progress(`build ${key} ${source} building...`);
@@ -133,6 +135,7 @@ export async function ensureLease(
133135
await finishOrphanLedgers(workspace.home, resolve(workspace.worktree), options.clerk, options.progress);
134136

135137
const { app, view: build } = await ensureBuild(host, platform, workspace, options.progress);
138+
options.progress(`build ${build.key} ${build.source} ${build.reused ? 'reused' : `built in ${build.seconds}s`}`);
136139

137140
let lease: Lease | null = held;
138141
let renewed = false;

‎packages/expo/.claude/skills/verify/src/core/state.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
HOST_CONTRACT_VERSION,
44
STATE_TEXT_PREFIX,
55
VerifyFailure,
6+
type HostEntry,
67
type HostLaunch,
78
type LaunchId,
89
type Platform,
@@ -102,6 +103,7 @@ export function parseVerifyState(text: string): VerifyState {
102103
export function describeState(state: VerifyState): string {
103104
const parts = [`screen=${state.screen}`, `signedIn=${state.signedIn}`];
104105
if (state.sessionStatus) parts.push(`session=${state.sessionStatus}`);
106+
parts.push(`orgId=${state.orgId ?? 'null'}`);
105107
if (state.pendingTasks.length > 0) parts.push(`tasks=${state.pendingTasks.join(',')}`);
106108
if (state.signInStatus) parts.push(`signInStatus=${state.signInStatus}`);
107109
if (state.signUpStatus) parts.push(`signUpStatus=${state.signUpStatus}`);
@@ -153,3 +155,28 @@ export function encodeLaunchArguments(platform: Platform, launch: HostLaunch<str
153155
}
154156
});
155157
}
158+
159+
export type AppStart =
160+
| { readonly kind: 'open-app'; readonly launchArguments: readonly string[] }
161+
| { readonly kind: 'adb'; readonly commands: readonly (readonly string[])[] };
162+
163+
function shellQuote(value: string): string {
164+
return `'${value.replaceAll("'", `'\\''`)}'`;
165+
}
166+
167+
export function appStart(platform: Platform, appId: string, entry: HostEntry, launchArguments: readonly string[]): AppStart {
168+
if (entry.kind === 'binary') return { kind: 'open-app', launchArguments };
169+
const all = [...entry.launchArguments, ...launchArguments];
170+
if (platform === 'ios') return { kind: 'open-app', launchArguments: all };
171+
if (entry.androidActivity === null) {
172+
throw new VerifyFailure('NOT_READY', 'a dev-client entry on Android needs androidActivity', 'set androidActivity in the entry src/host.ts returns');
173+
}
174+
const start = ['am', 'start', '-W', '-n', `${appId}/${entry.androidActivity}`, ...(entry.openLink === null ? [] : ['-d', entry.openLink]), ...all];
175+
return {
176+
kind: 'adb',
177+
commands: [
178+
['shell', `am force-stop ${shellQuote(appId)}`],
179+
['shell', start.map(shellQuote).join(' ')],
180+
],
181+
};
182+
}

0 commit comments

Comments
 (0)