diff --git a/packages/@expo/agent-cli/CHANGELOG.md b/packages/@expo/agent-cli/CHANGELOG.md index 1ecd7b225..49ac9ac25 100644 --- a/packages/@expo/agent-cli/CHANGELOG.md +++ b/packages/@expo/agent-cli/CHANGELOG.md @@ -57,6 +57,7 @@ - `smoke` asks an app it opened or reloaded again for 5 s after its first "method not found", instead of reporting a runtime with no debugger. ([#201](https://github.com/expo/expo-agent-cli/pull/201) by [@vonovak](https://github.com/vonovak)) - `status` with an EAS Simulator session on record and no app connected now names the session's platform (`navigate / --

--eas`), and with an app connected its `smoke --

--eas` step says the session is recorded, not checked, and that if it ended, smoke starts a new billed session only for Expo Go or a finished simulator build. ([#208](https://github.com/expo/expo-agent-cli/pull/208) by [@vonovak](https://github.com/vonovak)) - `smoke` asks for its device again in a later phase when an earlier lookup failed, instead of keeping that failure for the rest of the run. ([#202](https://github.com/expo/expo-agent-cli/pull/202) by [@vonovak](https://github.com/vonovak)) +- Runtime reads on an EAS Simulator session now read the session's app when it is the one unclaimed debugger target, also with `--eas` and no platform flag, and skip the apps on local simulators. When `adb` gave no answer, an Android-shaped app stays unread instead of blocking the session's app, and the refusal for two unclaimed apps says to close the apps that are not the session's. ([#203](https://github.com/expo/expo-agent-cli/pull/203) by [@vonovak](https://github.com/vonovak)) ### 💡 Others diff --git a/packages/@expo/agent-cli/e2e/__tests__/cloud-simulator-test.ts b/packages/@expo/agent-cli/e2e/__tests__/cloud-simulator-test.ts index 55af3dda0..357ece149 100644 --- a/packages/@expo/agent-cli/e2e/__tests__/cloud-simulator-test.ts +++ b/packages/@expo/agent-cli/e2e/__tests__/cloud-simulator-test.ts @@ -26,6 +26,8 @@ import { startStubDevServerAsync, type ExecuteResult, } from '../utils'; +import { makeTarget } from '../../src/runtime/__tests__/targetSelectionFixtures'; +import { BOOTED_SIMULATOR, installStubXcrunAsync } from './installedAppStubs'; /** A debugger target that looks like Expo Go, which its app id alone places on iOS. */ const EXPO_GO_TARGET = { @@ -370,6 +372,57 @@ describe('@expo/agent-cli navigate --eas', () => { } }); + // The development build an EAS Simulator iOS session runs registers the project's own app id and + // the cloud simulator's model name, which no local tool reports. It is the session's app, so the + // run counts it as attached and opens only the route link, never the launcher URL. + it(`counts the session's development build as the iOS app, and opens only the route link`, async () => { + const projectRoot = await setupAsync('dev-client-app'); + await writeSessionFileAsync(projectRoot, 'sess-e2e'); + await installStubXcrunAsync(projectRoot, { simulators: [BOOTED_SIMULATOR] }); + const stub = await startStubDevServerAsync({ + targets: [{ id: '1', appId: 'com.example.demo', deviceName: 'iPhone 17' }], + projectRoot, + manifestOrigin: 'https://stub-tunnel.example', + }); + const releaseLock = await holdDevLockAsync(projectRoot, { + url: stub.url, + port: stub.port, + pid: process.pid, + startedAt: new Date().toISOString(), + projectRoot, + }); + try { + const result = await executeAgentCliAsync( + projectRoot, + [ + 'navigate', + '/', + '--ios', + '--eas', + '--scheme', + 'demoapp', + '--app-id', + 'com.example.demo', + '--attach-timeout', + '5s', + '--json', + '--no-followups', + ], + { reject: false } + ); + + const opens = easInvocations(projectRoot) + .filter((argv) => argv[0] === 'simulator:exec' && argv[3] === 'open') + .map((argv) => argv[4]); + expect(opens).toEqual(['demoapp://']); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ launch: null, attached: true }); + } finally { + await releaseLock(); + await stub.close(); + } + }); + // A cloud simulator is on EAS's network, so a loopback host in the link resolves to a machine in // a datacenter. Refused before anything opens, rather than opened onto an error screen. it(`refuses a dev server only this machine can reach`, async () => { @@ -393,6 +446,118 @@ describe('@expo/agent-cli navigate --eas', () => { }); }); +// @ref llp/0005-runtime-loop-tools.rfc.md §Which app a run reads — `--eas` with no platform. +describe('@expo/agent-cli runtime:reload --eas with no platform', () => { + // A booted local simulator exists only on macOS. + it.skipIf(process.platform !== 'darwin')( + `reloads the session's app, not the app on a booted local simulator`, + async () => { + const projectRoot = await setupAsync('dev-client-app'); + await writeSessionFileAsync(projectRoot, 'sess-e2e'); + await installStubXcrunAsync(projectRoot, { simulators: [BOOTED_SIMULATOR] }); + const evaluated: string[] = []; + const stub = await startStubDevServerAsync({ + targets: [ + makeTarget({ + id: 'local', + appId: 'host.exp.Exponent', + deviceName: BOOTED_SIMULATOR.name, + webSocketDebuggerUrl: 'ws://127.0.0.1:1/inspector/debug?device=local&page=1', + reactNative: { capabilities: { nativePageReloads: true }, logicalDeviceId: 'local' }, + }), + makeTarget({ + id: 'session', + appId: 'com.example.demo', + deviceName: 'iPhone 17', + webSocketDebuggerUrl: 'ws://127.0.0.1:1/inspector/debug?device=session&page=1', + reactNative: { capabilities: { nativePageReloads: true }, logicalDeviceId: 'session' }, + }), + ], + projectRoot, + inspectorEvaluate: (expression, device) => { + if (expression.includes('reloadAppAsync')) evaluated.push(device); + return undefined; + }, + }); + try { + const result = await executeAgentCliAsync( + projectRoot, + [ + 'runtime:reload', + '--eas', + '--method', + 'runtime', + '--dev-server-url', + stub.url, + '--timeout', + '2s', + '--json', + '--no-followups', + ], + { reject: false } + ); + expect(new Set(evaluated), result.all).toEqual(new Set(['session'])); + } finally { + await stub.close(); + } + } + ); +}); + +// @ref llp/0005-runtime-loop-tools.rfc.md §Which app a run reads — `--eas` with no platform. +describe('@expo/agent-cli runtime:reload --eas with apps nothing claims', () => { + const unclaimed = (id: string, deviceName: string) => + makeTarget({ + id, + appId: 'com.example.demo', + deviceName, + webSocketDebuggerUrl: `ws://127.0.0.1:1/inspector/debug?device=${id}&page=1`, + reactNative: { capabilities: { nativePageReloads: true }, logicalDeviceId: id }, + }); + + it.each([ + { name: 'no platform', flags: [], apps: ['iPhone 17', 'iPad Air'] }, + { name: '--ios', flags: ['--ios'], apps: ['iPhone 17', 'iPad Air'] }, + { name: '--android', flags: ['--android'], apps: ['iPhone 17'] }, + ])(`reads neither unclaimed app with $name`, async ({ flags, apps }) => { + const projectRoot = await setupAsync('dev-client-app'); + await writeSessionFileAsync(projectRoot, 'sess-e2e'); + await installStubXcrunAsync(projectRoot, { simulators: [BOOTED_SIMULATOR] }); + const evaluated: string[] = []; + const stub = await startStubDevServerAsync({ + targets: apps.map((name, index) => unclaimed(String(index + 1), name)), + projectRoot, + inspectorEvaluate: (expression, device) => { + if (expression.includes('reloadAppAsync')) evaluated.push(device); + return undefined; + }, + }); + try { + const result = await executeAgentCliAsync( + projectRoot, + [ + 'runtime:reload', + '--eas', + ...flags, + '--method', + 'runtime', + '--dev-server-url', + stub.url, + '--timeout', + '2s', + '--json', + '--no-followups', + ], + { reject: false } + ); + expect(evaluated, result.all).toEqual([]); + expect(result.exitCode).not.toBe(0); + } finally { + await stub.close(); + } + }); +}); + describe('@expo/agent-cli runtime:stop --eas', () => { // The controller's `close ` ends the named app and leaves the billed machine up. The // pinned argv is the point: `--shutdown` would tear down the session, and `simulator:stop` would diff --git a/packages/@expo/agent-cli/e2e/__tests__/runtime-preflight-test.ts b/packages/@expo/agent-cli/e2e/__tests__/runtime-preflight-test.ts index 8df1d66bb..311d6cbbf 100644 --- a/packages/@expo/agent-cli/e2e/__tests__/runtime-preflight-test.ts +++ b/packages/@expo/agent-cli/e2e/__tests__/runtime-preflight-test.ts @@ -281,15 +281,38 @@ describe('a run that names a platform', () => { ); } - it('reads the app it places on that platform', async () => { + it(`reads the app it places on that platform, not the other platform's`, async () => { + const evaluated = new Set(); const stub = await startStubDevServerAsync({ - targets: [UNPLACED_TARGET, ANDROID_TARGET], - inspectorEvaluate: () => undefined, + targets: [UNPLACED_TARGET, EXPO_GO_TARGET, ANDROID_TARGET], + inspectorEvaluate: (_expression, device) => { + evaluated.add(device); + return undefined; + }, }); try { const result = await runAndroidAsync(stub); expect(JSON.parse(result.stdout).error?.code).not.toBe('NO_APP_CONNECTED'); + expect([...evaluated]).toEqual(['2']); + } finally { + await stub.close(); + } + }); + + it('does not read an app nothing places on a run without --eas', async () => { + const stub = await startStubDevServerAsync({ + targets: [{ ...UNPLACED_TARGET, deviceName: 'iPhone 17' }], + inspectorEvaluate: () => undefined, + }); + try { + const result = await executeAgentCliAsync( + projectRoot, + ['runtime:eval', '1 + 1', '--ios', '--dev-server-url', stub.url, '--json'], + { reject: false, env: { ...stubExpoEnv(projectRoot), AGENT_CLI_NO_DEVICE: '1' } } + ); + + expect(JSON.parse(result.stdout).error.code).toBe('NO_APP_CONNECTED'); } finally { await stub.close(); } diff --git a/packages/@expo/agent-cli/e2e/__tests__/smoke-binding-test.ts b/packages/@expo/agent-cli/e2e/__tests__/smoke-binding-test.ts new file mode 100644 index 000000000..be5aafc5a --- /dev/null +++ b/packages/@expo/agent-cli/e2e/__tests__/smoke-binding-test.ts @@ -0,0 +1,89 @@ +// These cases run the CLI and real configuration/binding readers. Device tools and the inspector +// are protocol doubles; successful evaluation proves selection and wiring, not Hermes behavior. +import fs from 'node:fs'; +import path from 'node:path'; + +import { installStubEasAsync, writeCloudBindingAsync } from '../stubEas'; +import { makeTarget } from '../../src/runtime/__tests__/targetSelectionFixtures'; +import { + executeAgentCliAsync, + expoHomeOf, + readStubExpoInvocations, + setupFixtureAsync, + startStubDevServerAsync, +} from '../utils'; +import { installStubXcrunAsync } from './installedAppStubs'; + +it('smoke evaluates the cloud app selected by deviceBackend config without --eas', async () => { + const root = await fs.promises.realpath(await setupFixtureAsync('go-app')); + const file = path.join(root, 'package.json'); + const pkg = JSON.parse(await fs.promises.readFile(file, 'utf8')); + pkg.expo = { ...pkg.expo, 'agent-cli': { deviceBackend: 'eas' } }; + await fs.promises.writeFile(file, JSON.stringify(pkg)); + await installStubXcrunAsync(root, { simulators: [] }); + await installStubEasAsync(root); + await writeCloudBindingAsync(expoHomeOf(root), root, { id: 'sess-e2e' }); + const ready = path.join(root, '.expo-started'); + const expoBin = path.join(root, 'node_modules/expo/bin/cli'); + const original = await fs.promises.readFile(expoBin, 'utf8'); + await fs.promises.writeFile( + expoBin, + original.replace( + '#!/usr/bin/env node', + `#!/usr/bin/env node +if (process.argv[2] === 'start') require('fs').writeFileSync(${JSON.stringify(ready)}, 'ready');` + ) + ); + const server = await startStubDevServerAsync({ + projectRoot: root, + readyWithFile: ready, + targets: [ + makeTarget({ + id: 'cloud', + deviceName: 'iPhone 17', + reactNative: { capabilities: { nativePageReloads: true }, logicalDeviceId: 'cloud' }, + }), + ], + inspectorEvaluate: () => 1, + }); + try { + const result = await executeAgentCliAsync( + root, + [ + 'smoke', + '--ios', + '--dev-server-url', + server.url, + '--no-reload', + '--no-screenshot', + '--window', + '100ms', + '--timeout', + '5s', + '--json', + '--no-followups', + ], + { + reject: false, + env: { + STUB_EXPO_DELAY_MS: '30000', + STUB_EXPO_TUNNEL_HOST: 'stub-tunnel.example', + STUB_EXPO_DEV_SERVER_PORT: String(server.port), + }, + } + ); + expect(result.exitCode, result.all).toBe(0); + expect(readStubExpoInvocations(root).find(({ args }) => args[0] === 'start')?.args).toContain( + '--tunnel' + ); + const report = JSON.parse(result.stdout); + expect(report.started, result.all).toBe(true); + expect(report.runtimeSupported).toBe(true); + expect(report.phases).toEqual( + expect.arrayContaining([expect.objectContaining({ id: 'runtime', status: 'ok' })]) + ); + } finally { + await executeAgentCliAsync(root, ['dev:stop', '--json'], { reject: false }); + await server.close(); + } +}); diff --git a/packages/@expo/agent-cli/e2e/utils.ts b/packages/@expo/agent-cli/e2e/utils.ts index f01999f53..e6e8b9bf4 100644 --- a/packages/@expo/agent-cli/e2e/utils.ts +++ b/packages/@expo/agent-cli/e2e/utils.ts @@ -1,7 +1,7 @@ import { spawn, type ChildProcess } from 'node:child_process'; import crypto from 'node:crypto'; import fs from 'node:fs'; -import { createServer, type Server } from 'node:http'; +import { createServer, type IncomingMessage, type Server } from 'node:http'; import net from 'node:net'; import type { AddressInfo } from 'node:net'; import os from 'node:os'; @@ -672,6 +672,8 @@ export type StubDevServerOptions = { * are connected. Defaults to none, which is a dev server nothing has attached to yet. */ targets?: unknown[]; + /** Begin listening only after the stub Expo start creates this file. */ + readyWithFile?: string; /** * Delay before `GET /status` answers, in milliseconds. * @@ -746,7 +748,7 @@ export type StubDevServerOptions = { * `undefined` answers `{type: "undefined"}`, which is what an expression whose value is nothing * comes back as — and is what the target selector's own probe expects. */ - inspectorEvaluate?: (expression: string) => unknown; + inspectorEvaluate?: (expression: string, device: string) => unknown; /** * Clients `getpeers` reports before any reload. Defaults to one that looks like an iOS app. * @@ -864,6 +866,7 @@ export type StubDevServer = { */ export async function startStubDevServerAsync({ targets = [], + readyWithFile, statusDelayMs = 0, projectRoot = '/stub-project', bundle = 'compiles', @@ -1057,7 +1060,8 @@ export async function startStubDevServerAsync({ // `no-debugger` socket keeps its `-32601` answer instead: the responder is a runtime that talks, // and that socket's whole point is a runtime that cannot. if (inspectorEvaluate && inspectorSocket !== 'no-debugger') { - inspectorServer?.on('connection', (socket: WebSocket) => { + inspectorServer?.on('connection', (socket: WebSocket, request: IncomingMessage) => { + const device = new URL(request.url ?? '/', 'http://stub').searchParams.get('device') ?? ''; socket.on('message', (data) => { let message: { id?: number; method?: string; params?: { expression?: string } }; try { @@ -1069,7 +1073,7 @@ export async function startStubDevServerAsync({ return; } const expression = message.params?.expression ?? ''; - const value = inspectorEvaluate(expression); + const value = inspectorEvaluate(expression, device); // The reload asked for over the debugger, which `runtime:reload --method runtime` sends. // Its probe half carries the diagnostic string below and the call does not, which is the // only thing that tells them apart: every expression this CLI sends is wrapped, and the @@ -1151,11 +1155,25 @@ export async function startStubDevServerAsync({ // Read back rather than chosen, and captured for the manifest: the bundle URL a dev server // publishes is absolute, so the stub has to know the ephemeral port it ended up on. port = (server.address() as AddressInfo).port; + let startup: ReturnType | undefined; + let relistenError: Error | undefined; + if (readyWithFile) { + await new Promise((resolve) => server.close(() => resolve())); + startup = setInterval(() => { + if (fs.existsSync(readyWithFile)) { + clearInterval(startup); + // The port is free only while nothing else took it; close() reports a lost race. + server.once('error', (error) => (relistenError = error)); + server.listen(port, '127.0.0.1'); + } + }, 10).unref(); + } return { url: `http://127.0.0.1:${port}`, port, close: () => - new Promise((resolve) => { + new Promise((resolve, reject) => { + clearInterval(startup); // Any request left waiting on `statusDelayMs` holds the server open otherwise. for (const client of [ ...(messageServer?.clients ?? []), @@ -1166,7 +1184,13 @@ export async function startStubDevServerAsync({ messageServer?.close(); inspectorServer?.close(); server.closeAllConnections?.(); - server.close(() => resolve()); + server.close(() => + relistenError + ? reject( + new Error(`stub dev server could not listen again on ${port}: ${relistenError}`) + ) + : resolve() + ); }), }; } diff --git a/packages/@expo/agent-cli/llp/0005-runtime-loop-tools.rfc.md b/packages/@expo/agent-cli/llp/0005-runtime-loop-tools.rfc.md index 4dd781f63..90705d323 100644 --- a/packages/@expo/agent-cli/llp/0005-runtime-loop-tools.rfc.md +++ b/packages/@expo/agent-cli/llp/0005-runtime-loop-tools.rfc.md @@ -185,18 +185,26 @@ Every command of this group takes `--port ` as sugar for `--dev-server-url ht A run reads only the apps in its scope. The scope comes from the caller's flags and from the device the run opens. A target's own fields and the facts this machine's device tools report place it (§Android). A target that nothing places is undetermined: it is never read, and it never earns a platform's verdict. -| Scope source | Target | Result | Test | -| ------------ | ------------------------- | ---------------------- | --------------------------------------------------------------------------------------- | -| none | any | read | e2e runtime-reload-test "checks the bundle on both platforms when no platform is named" | -| flag only | platform evidence matches | read | e2e runtime-preflight-test "reads the app it places on that platform" | -| flag only | other platform | skip as other-platform | same | -| flag only | no platform evidence | undetermined | e2e runtime-preflight-test "does not read an app nothing places" | +| Scope source | Target | Result | Test | +| ------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| none | any | read | e2e runtime-reload-test "checks the bundle on both platforms when no platform is named" | +| flag only | platform evidence matches | read | e2e runtime-preflight-test "reads the app it places on that platform, not the other platform's" | +| flag only | other platform | skip as other-platform | e2e runtime-preflight-test "reads the app it places on that platform, not the other platform's" | +| flag only | no platform evidence | undetermined | e2e runtime-preflight-test "does not read an app nothing places" | +| EAS session, iOS or no platform | name of a booted local simulator, or an attached Android model | skip as other-device | runtime targetSelection-test "reads the one unclaimed target beside a booted local simulator" | +| EAS session, iOS or no platform | the one target nothing places, when the scope's device tools answered or the host cannot run them | read | runtime targetSelection-test "reads the one unclaimed target on an iOS session"; runtime targetInventory-test "allows cloud attribution without local iOS support on %s"; e2e cloud-simulator-test "reloads the session's app, not the app on a booted local simulator" | +| EAS session, iOS or no platform | two targets nothing places | undetermined | e2e cloud-simulator-test "reads neither unclaimed app with $name" | +| EAS session, no platform | the one target nothing places, beside a target read on Android | undetermined | runtime targetSelection-test "reads no unclaimed target beside an Android session app, with no platform" | +| EAS session, iOS or no platform | any unclaimed target, when a device tool of the scope's platform gave no answer | undetermined | runtime targetSelection-test "reads no Expo Go target while the local simulators are unknown"; runtime targetInventory-test "cloud attribution after $name" | +| EAS session, no platform | an Android-shaped target, when `adb` gave no answer | undetermined | runtime targetInventory-test "holds an Android-shaped target beside the session app" | + +An EAS Simulator session's app registers the project's own app id and the cloud simulator's model name, which no local tool reports. So a cloud scope reads no target that a local device tool claims, and the one target that nothing claims is the session's. A device tool that gave no answer cannot show that a target is off this machine. Only the tool of the scope's platform counts, and both count when the run names no platform. `--eas` with no platform flag is a cloud scope: the session names one device, so the rule places its app on either platform. Test: runtime targetSelection-test "resolveTargetSelectionAsync with --eas and no platform". When such a scope holds no app, the refusal names the apps nothing places and says to close the apps that are not the session's. A platform flag only chooses which device tool has to answer; it does not choose between two unclaimed apps. Test: runtime preflight-test "should refuse two apps nothing places, and say how to name the session's platform". Rejected: refusing `--eas` with no platform. It makes the user name a platform the session already has. Three rules hold for every caller of the scope: - A classification belongs to one target list and the device facts read for it. A later list is classified again, even when it names the same target ids. Test: smoke targetSelection-test "reclassifies an unchanged target after a failed inventory read recovers". - A connection with no platform scope gives bundle inference every platform it can identify. Test: e2e runtime-reload-test "checks the bundle on both platforms when no platform is named". -- An attachment wait keeps its device facts while the target list is unchanged, and reads them again for a changed list, within the wait's budget. So an empty first list cannot keep a later development build from being placed, and a target that nothing places costs one read of the device tools, not one per poll. Tests: smoke targetSelection-test "places a development-build target that appears after the first empty wait probe" and "asks the device tools once for a list that does not change". +- An attachment wait keeps its device facts while the target list is unchanged, and reads them again for a changed list, within the wait's budget. So an empty first list cannot keep a later development build from being placed, and a target that nothing places costs one read of the device tools, not one per poll. A device tool of the scope's platform that gave no answer is asked again, at most once a second, because a local simulator can boot during the wait. A read that loses a tool's answer keeps the facts it gave before, so one failed read cannot un-place an app that an earlier read placed, and the wait reads that list again. The read the wait starts from counts as the first. Tests: smoke targetSelection-test "places a development-build target that appears after the first empty wait probe", "asks the device tools once for a list that does not change", "refreshes failed local exclusion evidence before counting a cloud attachment" "asks a failing simctl again at most once a second while the wait polls"; runtime waitReady-test "keeps the facts that placed the last list when a device tool gives no answer" and "reads a changed list again after a device tool gave no answer on it". ## Reloading the app @@ -644,6 +652,8 @@ The target selector skips only on transport failure, and ranks `-32601` targets `/json/list` does not name a platform. Two live targets on one dev server differ in `appId` (`host.exp.Exponent` vs `host.exp.exponent`), `deviceName`, and `title`. `description`, `type`, and `reactNative.capabilities` are identical. `src/runtime/targetPlatform.ts` infers the platform, strongest evidence first: a device name this machine's own device tools just reported (`simctl list devices booted`, `adb devices -l`), then React Native Android's ` - - API ` shape, then Expo Go's two app ids. A target none of them place is `null` and is never counted as either. +A run on an EAS Simulator session reads the session's app and skips every app on a local device, by the rows in §Which app a run reads. An Android session's target is placed by its name shape. `navigate`, `smoke` and the runtime reads apply the rule. `smoke` applies it also when a config's EAS device turns on `--eas` after the run started. Tests: smoke targetSelection-test "does not count a local Expo Go app as the cloud session $case"; smokeDeps-test "evaluate on an EAS Simulator iOS session"; e2e cloud-simulator-test "counts the session's development build as the iOS app, and opens only the route link". + Scoping reaches `requireConnectedAppAsync`, `waitForAppConnectionAsync`, `CdpClient` target selection, and the preflight's classified targets for every caller that names a platform. Only a command that requires a runtime still refuses on an empty scope. For `reload` an empty scope is a rung of its ladder. For `stop` it is already-stopped success. `resolveAppId` drops the other platform's Expo Go id before it takes the first one. An id it cannot place is kept: a development build's package name says nothing about a platform. The bundle-check platform is derived from the apps that are actually connected (`resolveBundleCheckPlatformsAsync`). A named `--platform` wins. One connected platform is that platform. Two are both. Nothing connected leaves the fixed default, and the report says so. A broken bundle decides the run whichever platform it was found on. diff --git a/packages/@expo/agent-cli/src/device/simulators.ts b/packages/@expo/agent-cli/src/device/simulators.ts index d47070efe..1145fd655 100644 --- a/packages/@expo/agent-cli/src/device/simulators.ts +++ b/packages/@expo/agent-cli/src/device/simulators.ts @@ -84,9 +84,13 @@ export function parseSimulators(stdout: string): SimulatorEntry[] { return simulators; } -/** Every booted iOS simulator in `simctl list devices booted -j`, in the order `simctl` lists them. */ +/** + * Every booted iOS simulator in `simctl list devices booted -j`, in the order `simctl` lists them. + * + * An entry without a udid or a name is skipped, so one bad entry does not hide the valid ones. + */ export function parseBootedIosSimulators(stdout: string): { udid: string; name: string }[] { - return parseBooted(stdout, (runtime) => runtime.includes('.iOS-')); + return parseBooted(stdout, (runtime) => runtime.includes('.iOS-'))?.simulators ?? []; } /** @@ -94,34 +98,54 @@ export function parseBootedIosSimulators(stdout: string): { udid: string; name: * * Not only iOS: a target on a watchOS simulator is on one of this machine's simulators, and it would * otherwise fall through to a platform rule that knows less. + * Returns null for an incomplete or invalid listing, an entry without a udid or a name included: + * callers must not infer absence from it. */ -export function parseBootedSimulators(stdout: string): { udid: string; name: string }[] { - return parseBooted(stdout, () => true); +export function parseBootedSimulators(stdout: string): { udid: string; name: string }[] | null { + const listing = parseBooted(stdout, () => true); + return listing?.complete ? listing.simulators : null; } +/** The valid entries, and whether every entry was valid; null for a listing that does not parse. */ function parseBooted( stdout: string, keepRuntime: (runtime: string) => boolean -): { udid: string; name: string }[] { +): { simulators: { udid: string; name: string }[]; complete: boolean } | null { let parsed: { devices?: Record }; try { parsed = JSON.parse(stdout); } catch { - return []; + return null; + } + + if ( + !parsed || + !parsed.devices || + typeof parsed.devices !== 'object' || + Array.isArray(parsed.devices) + ) { + return null; } const simulators: { udid: string; name: string }[] = []; - for (const [runtime, devices] of Object.entries(parsed.devices ?? {})) { - if (!keepRuntime(runtime) || !Array.isArray(devices)) { - continue; - } + let complete = true; + for (const [runtime, devices] of Object.entries(parsed.devices)) { + if (!keepRuntime(runtime)) continue; + if (!Array.isArray(devices)) return null; for (const device of devices) { - if (device?.udid) { - simulators.push({ udid: device.udid, name: device.name ?? '' }); + if ( + typeof device?.udid !== 'string' || + !device.udid || + typeof device.name !== 'string' || + !device.name + ) { + complete = false; + continue; } + simulators.push({ udid: device.udid, name: device.name }); } } - return simulators; + return { simulators, complete }; } /** diff --git a/packages/@expo/agent-cli/src/navigate/__tests__/device-test.ts b/packages/@expo/agent-cli/src/navigate/__tests__/device-test.ts index bd4e03d2a..b825cf039 100644 --- a/packages/@expo/agent-cli/src/navigate/__tests__/device-test.ts +++ b/packages/@expo/agent-cli/src/navigate/__tests__/device-test.ts @@ -116,15 +116,34 @@ describe(parseBootedIosSimulators, () => { ]); }); + it(`should skip an entry with no name and keep the valid ones`, () => { + expect(parseBootedIosSimulators(ENTRY_WITH_NO_NAME_JSON)).toEqual([ + { udid: 'IOS-1', name: 'iPhone 17' }, + ]); + }); + it(`should list nothing for output that is not simctl JSON`, () => { expect(parseBootedIosSimulators('not json')).toEqual([]); expect(parseBootedIosSimulators('')).toEqual([]); }); }); +const ENTRY_WITH_NO_NAME_JSON = JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-26-0': [ + { udid: 'BAD' }, + { udid: 'IOS-1', name: 'iPhone 17' }, + ], + }, +}); + describe(parseBootedSimulators, () => { + it(`should return null when one entry has no name, so no caller infers absence`, () => { + expect(parseBootedSimulators(ENTRY_WITH_NO_NAME_JSON)).toBeNull(); + }); + it(`should list every booted runtime, so a watch simulator is not mistaken for something else`, () => { - expect(parseBootedSimulators(BOOTED_SIMCTL_JSON).map((simulator) => simulator.name)).toEqual([ + expect(parseBootedSimulators(BOOTED_SIMCTL_JSON)?.map((simulator) => simulator.name)).toEqual([ 'Apple Watch', 'iPhone 17', 'iPad', diff --git a/packages/@expo/agent-cli/src/navigate/openRoute.ts b/packages/@expo/agent-cli/src/navigate/openRoute.ts index c6402f9d9..953c9b9ed 100644 --- a/packages/@expo/agent-cli/src/navigate/openRoute.ts +++ b/packages/@expo/agent-cli/src/navigate/openRoute.ts @@ -736,22 +736,22 @@ const ROUTE_ATTACH_FLOOR_MS = 2_000; /** * A function that waits until an app of this platform holds a debugger target, or the deadline. * - * Built once and shared by the two waits of one run — the launcher's and the route link's — so both - * ask the same question of the same dev server, and the device facts that scoping needs are read - * once rather than per open. + * The launcher's wait and the route link's wait ask the same question of the same dev server. The + * device facts that scoping needs are read once per wait, not once per poll. * * @returns how many targets this platform had when the wait ended: zero means the deadline won. */ async function attachWaiterAsync( devServerUrl: string, - platform: NavigatePlatform + device: NavigateDevice ): Promise<(deadline: number) => Promise> { const deviceInventory = await readDeviceInventoryAsync(); const countAsync = async (): Promise => { const probe = await probeDevServerAsync(devServerUrl); const { classifiedTargets } = await resolveTargetSelectionAsync({ projectRoot: null, - flags: { platform, eas: false }, + flags: { platform: device.platform, eas: device.backend === 'cloud' }, + device, targets: probe.targets, deviceInventory, }); @@ -857,7 +857,7 @@ async function launchDevClientFirstAsync({ }; } - const waitAsync = await attachWaiterAsync(devServerUrl, device.platform); + const waitAsync = await attachWaiterAsync(devServerUrl, device); const targets = await waitAsync(startedAt + budgetMs); return { url: launcherUrl, @@ -949,7 +949,7 @@ async function confirmAttachAsync({ } const startedAt = Date.now(); - const waitAsync = await attachWaiterAsync(devServerUrl, device.platform); + const waitAsync = await attachWaiterAsync(devServerUrl, device); let targets = await waitAsync(startedAt + budgetMs); let recovered = false; diff --git a/packages/@expo/agent-cli/src/runtime/__tests__/preflight-test.ts b/packages/@expo/agent-cli/src/runtime/__tests__/preflight-test.ts index da1d74ca8..55deeb410 100644 --- a/packages/@expo/agent-cli/src/runtime/__tests__/preflight-test.ts +++ b/packages/@expo/agent-cli/src/runtime/__tests__/preflight-test.ts @@ -65,7 +65,7 @@ describe(preflightRuntimeAsync, () => { named: true, reachable: true, targets: [TARGET], - classifiedTargets: { read: [TARGET], otherPlatform: [], undetermined: [] }, + classifiedTargets: { read: [TARGET], otherPlatform: [], undetermined: [], skipped: [] }, }); }); @@ -378,6 +378,30 @@ describe(`${preflightRuntimeAsync.name} scoped to a platform`, () => { }); }); +// @ref llp/0005-runtime-loop-tools.rfc.md §Which app a run reads — `--eas` with no platform. +describe(`${preflightRuntimeAsync.name} on an EAS Simulator session with no platform`, () => { + it(`should refuse two apps nothing places, and say how to name the session's platform`, async () => { + mockDevServer([ + { ...TARGET, appId: 'com.example.app', deviceName: 'iPhone 17' }, + { ...TARGET, id: '2', appId: 'com.example.app', deviceName: 'iPad Air' }, + ]); + + const error = await preflightRuntimeAsync({ + need: 'debugger-target', + devServerUrl, + cloud: true, + deviceInventory: makeDeviceInventory(), + }).catch((e) => e); + + expect(error.code).toBe('NO_APP_CONNECTED'); + expect(error.message).toContain('names 2 apps'); + expect(error.message).toContain('--ios --eas'); + expect(error.message).toContain('--android --eas'); + expect(error.message).toContain('close the apps'); + expect(error.data).toMatchObject({ debuggerTargets: 2, platform: null }); + }); +}); + // The other half of `howToNameTheDevServer`: a caller who let this CLI find the dev server is told // how to name one, and a caller who named it is not told to pass the flag they just passed // [friction run 4]. diff --git a/packages/@expo/agent-cli/src/runtime/__tests__/targetInventory-test.ts b/packages/@expo/agent-cli/src/runtime/__tests__/targetInventory-test.ts new file mode 100644 index 000000000..25084540e --- /dev/null +++ b/packages/@expo/agent-cli/src/runtime/__tests__/targetInventory-test.ts @@ -0,0 +1,113 @@ +import { fakeTools } from '../../deviceBinding/__tests__/fakeTools'; +import { spawnCaptureAsync, type SpawnCaptureResult } from '../../utils/spawnCapture'; +import { resolveTargetSelectionAsync } from '../resolveTargetSelection'; +import { makeTarget } from './targetSelectionFixtures'; + +vi.mock('../../utils/spawnCapture', () => ({ spawnCaptureAsync: vi.fn() })); + +const realPlatform = process.platform; +const target = makeTarget(); + +afterEach(() => { + Object.defineProperty(process, 'platform', { value: realPlatform }); + vi.resetAllMocks(); +}); + +it.each([ + { name: 'successful empty inventory', stdout: '{"devices":{}}', readable: true }, + { name: 'rejected lookup', reject: true }, + { name: 'failed command', exitCode: 1, stdout: '{"devices":{}}' }, + { name: 'timed out command', exitCode: null, stdout: '{"devices":{}}' }, + { name: 'invalid JSON', stdout: '{' }, + { name: 'null response', stdout: 'null' }, + { name: 'missing devices', stdout: '{}' }, + { name: 'invalid devices container', stdout: '{"devices":[]}' }, + { name: 'invalid runtime list', stdout: '{"devices":{"ios":null}}' }, + { name: 'incomplete device entry', stdout: '{"devices":{"ios":[{"udid":"LOCAL"}]}}' }, +])('cloud attribution after $name', async ({ stdout = '', exitCode = 0, reject, readable }) => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + const tools = fakeTools(); + vi.mocked(spawnCaptureAsync).mockImplementation(async (command, args) => { + if (command !== 'xcrun') return tools.adb(args); + if (reject) throw new Error('xcrun unavailable'); + return { stdout, stderr: '', exitCode } satisfies SpawnCaptureResult; + }); + const { classifiedTargets } = await resolveTargetSelectionAsync({ + projectRoot: null, + flags: { platform: 'ios', eas: true }, + targets: [target], + }); + expect(classifiedTargets.read).toEqual(readable ? [target] : []); + expect(classifiedTargets.undetermined).toEqual(readable ? [] : [target]); +}); + +it.each(['linux', 'win32'])( + 'allows cloud attribution without local iOS support on %s', + async (platform) => { + Object.defineProperty(process, 'platform', { value: platform }); + vi.mocked(spawnCaptureAsync).mockRejectedValue(new Error('tool unavailable')); + const { classifiedTargets } = await resolveTargetSelectionAsync({ + projectRoot: null, + flags: { platform: 'ios', eas: true }, + targets: [target], + }); + expect(classifiedTargets.read).toEqual([target]); + // A valid listing supplied by a tool shim still takes precedence over host capability. + const tools = fakeTools({ + simulators: [{ udid: 'LOCAL', name: target.deviceName, state: 'Booted' }], + }); + vi.mocked(spawnCaptureAsync).mockImplementation((command, args) => + command === 'xcrun' ? tools.simctl(args.slice(1)) : tools.adb(args) + ); + const observed = await resolveTargetSelectionAsync({ + projectRoot: null, + flags: { platform: 'ios', eas: true }, + targets: [target], + }); + expect(observed.classifiedTargets.read).toEqual([]); + expect(observed.classifiedTargets.skipped).toEqual([ + { target, classification: { group: 'other-device' } }, + ]); + } +); + +// @ref llp/0005-runtime-loop-tools.rfc.md §Which app a run reads — `--eas` with no platform. +describe('cloud attribution with no platform when adb gave no answer', () => { + const emulator = makeTarget({ + id: '2', + appId: 'dev.example.app', + deviceName: 'sdk_gphone64_x86_64 - 15 - API 35', + }); + + async function classifyAsync(adbAnswers: boolean, targets: (typeof target)[]) { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + vi.mocked(spawnCaptureAsync).mockImplementation(async (command) => + command === 'xcrun' + ? { stdout: '{"devices":{}}', stderr: '', exitCode: 0 } + : { stdout: 'List of devices attached\n', stderr: '', exitCode: adbAnswers ? 0 : 1 } + ); + const { classifiedTargets } = await resolveTargetSelectionAsync({ + projectRoot: null, + flags: { platform: null, eas: true }, + targets, + }); + return classifiedTargets; + } + + it('holds an Android-shaped target beside the session app', async () => { + const classified = await classifyAsync(false, [emulator, target]); + expect(classified.read).toEqual([]); + expect(classified.undetermined).toEqual([emulator, target]); + }); + + it('holds a lone Android-shaped target', async () => { + const classified = await classifyAsync(false, [emulator]); + expect(classified.read).toEqual([]); + expect(classified.undetermined).toEqual([emulator]); + }); + + it('still reads a lone unclaimed iOS target', async () => { + const classified = await classifyAsync(false, [target]); + expect(classified.read).toEqual([target]); + }); +}); diff --git a/packages/@expo/agent-cli/src/runtime/__tests__/targetSelection-test.ts b/packages/@expo/agent-cli/src/runtime/__tests__/targetSelection-test.ts new file mode 100644 index 000000000..8fbec7830 --- /dev/null +++ b/packages/@expo/agent-cli/src/runtime/__tests__/targetSelection-test.ts @@ -0,0 +1,117 @@ +import type { CdpTarget } from '../cdpClient'; +import { resolveTargetSelectionAsync } from '../resolveTargetSelection'; +import { classifyTargets } from '../targetSelection'; +import { makeTargetSelection } from './targetSelectionFixtures'; +import { makeDeviceInventory } from './targetSelectionFixtures'; + +// @ref llp/0005-runtime-loop-tools.rfc.md §Android — the EAS Simulator iOS session rule. +// The target an EAS Simulator iOS session registers: the project's own app id, and the cloud +// simulator's model name, which no local tool on this machine reports. +describe('classifyTargets on an EAS Simulator session', () => { + const cloud = { appId: 'dev.vonovak.cyclable', deviceName: 'iPhone 17' } as CdpTarget; + const local = { appId: 'dev.vonovak.cyclable', deviceName: 'iPhone 17 Pro' } as CdpTarget; + const expoGo = { appId: 'host.exp.Exponent', deviceName: 'iPhone 17' } as CdpTarget; + const deviceInventory = makeDeviceInventory({ + booted: [{ name: 'iPhone 17 Pro', udid: 'LOCAL-1' }], + }); + + it.each([ + { + name: 'reads the one unclaimed target on an iOS session', + targets: [cloud], + platform: 'ios' as const, + eas: true, + read: [cloud], + undetermined: [], + }, + { + name: 'reads the one unclaimed target beside a booted local simulator', + targets: [local, cloud], + platform: 'ios' as const, + eas: true, + read: [cloud], + skipped: [{ target: local, classification: { group: 'other-device' } }], + undetermined: [], + }, + { + name: 'reads an Expo Go target that no booted local simulator has', + targets: [expoGo], + platform: 'ios' as const, + eas: true, + read: [expoGo], + undetermined: [], + }, + { + name: 'reads no Expo Go target while the local simulators are unknown', + targets: [expoGo], + platform: 'ios' as const, + eas: true, + inventory: makeDeviceInventory({ bootedStatus: 'unknown' }), + read: [], + undetermined: [expoGo], + }, + ])('$name', ({ targets, platform, eas, read, undetermined, skipped = [], inventory }) => { + expect( + classifyTargets( + targets, + makeTargetSelection({ + platform: { value: platform, by: 'flag' }, + device: eas ? { backend: 'cloud', deviceId: null } : null, + }), + inventory ?? deviceInventory + ) + ).toEqual({ + read, + skipped, + otherPlatform: [], + undetermined, + }); + }); + + // An Android session's own app is already read, so the session is not iOS, and the unclaimed + // target may be a development build on a physical phone. + it('reads no unclaimed target beside an Android session app, with no platform', () => { + const androidSession = { + appId: 'dev.vonovak.cyclable', + deviceName: 'sdk_gphone64_x86_64 - 15 - API 35', + } as CdpTarget; + const unclaimed = { appId: 'dev.vonovak.cyclable', deviceName: 'iPhone 17' } as CdpTarget; + + expect( + classifyTargets( + [androidSession, unclaimed], + makeTargetSelection({ platform: null, device: { backend: 'cloud', deviceId: null } }), + makeDeviceInventory({ booted: [{ name: 'iPhone 17 Pro', udid: 'LOCAL-1' }] }) + ) + ).toEqual({ + read: [androidSession], + skipped: [], + otherPlatform: [], + undetermined: [unclaimed], + }); + }); +}); + +// @ref llp/0005-runtime-loop-tools.rfc.md §Which app a run reads — `--eas` with no platform. +describe('resolveTargetSelectionAsync with --eas and no platform', () => { + const session = { appId: 'dev.vonovak.cyclable', deviceName: 'iPhone 17' } as CdpTarget; + const localExpoGo = { appId: 'host.exp.Exponent', deviceName: 'iPhone 17 Pro' } as CdpTarget; + + it.each([ + { + name: 'holds every unclaimed target while the local simulators are unknown', + inventory: makeDeviceInventory({ bootedStatus: 'unknown' }), + read: [], + undetermined: [localExpoGo, session], + skipped: [], + }, + ])('$name', async ({ inventory, read, undetermined, skipped }) => { + const { classifiedTargets } = await resolveTargetSelectionAsync({ + projectRoot: null, + flags: { platform: null, eas: true }, + targets: [localExpoGo, session], + deviceInventory: inventory, + }); + expect(classifiedTargets).toEqual({ read, undetermined, skipped, otherPlatform: [] }); + }); +}); diff --git a/packages/@expo/agent-cli/src/runtime/__tests__/targetSelectionFixtures.ts b/packages/@expo/agent-cli/src/runtime/__tests__/targetSelectionFixtures.ts index 5f621ea61..d363e757b 100644 --- a/packages/@expo/agent-cli/src/runtime/__tests__/targetSelectionFixtures.ts +++ b/packages/@expo/agent-cli/src/runtime/__tests__/targetSelectionFixtures.ts @@ -1,11 +1,31 @@ +import type { CdpTarget } from '../cdpClient'; import { READ_EVERY_TARGET, type DeviceInventory, type TargetSelection } from '../targetSelection'; export function makeDeviceInventory(overrides: Partial = {}): DeviceInventory { - return { booted: [], androidModels: [], ...overrides }; + return { + booted: [], + bootedStatus: 'observed', + androidModels: [], + ...overrides, + }; } export function makeTargetSelection(overrides: Partial = {}): TargetSelection { return { ...READ_EVERY_TARGET, ...overrides }; } export const EVERY_TARGET_CLIENT = { targetSelection: READ_EVERY_TARGET, - deviceInventory: makeDeviceInventory(), + deviceInventory: makeDeviceInventory({ bootedStatus: 'unknown', androidModels: null }), }; + +export function makeTarget(overrides: Partial = {}): CdpTarget { + return { + id: '1', + appId: 'dev.example.app', + deviceName: 'iPhone 17', + description: '', + type: 'node', + title: 'React Native', + devtoolsFrontendUrl: '/devtools', + webSocketDebuggerUrl: 'ws://127.0.0.1:1/inspector/debug?device=1&page=1', + ...overrides, + }; +} diff --git a/packages/@expo/agent-cli/src/runtime/__tests__/waitReady-test.ts b/packages/@expo/agent-cli/src/runtime/__tests__/waitReady-test.ts index 694796197..9ee2f2c5b 100644 --- a/packages/@expo/agent-cli/src/runtime/__tests__/waitReady-test.ts +++ b/packages/@expo/agent-cli/src/runtime/__tests__/waitReady-test.ts @@ -14,7 +14,12 @@ import { waitForFreshAppConnectionAsync, } from '../waitReady'; import { matchProjectRoot } from '../projectRootHeader'; -import { EVERY_TARGET_CLIENT } from './targetSelectionFixtures'; +import { spawnCaptureAsync } from '../../utils/spawnCapture'; +import { + EVERY_TARGET_CLIENT, + makeDeviceInventory, + makeTargetSelection, +} from './targetSelectionFixtures'; // A target nothing places makes the attachment wait ask the device tools; they answer nothing here. vi.mock('../../utils/spawnCapture', () => ({ @@ -466,6 +471,50 @@ describe(waitForAppConnectionAsync, () => { }); }); +describe(`${waitForAppConnectionAsync.name} device facts`, () => { + // simctl answers only on macOS; elsewhere the iOS facts are unsupported, not unknown. + const realPlatform = process.platform; + beforeEach(() => Object.defineProperty(process, 'platform', { value: 'darwin' })); + afterEach(() => Object.defineProperty(process, 'platform', { value: realPlatform })); + + it('keeps the facts that placed the last list when a device tool gives no answer', async () => { + const developmentBuild = { id: '1', appId: 'com.example.dev', deviceName: 'iPhone 15' }; + const url = await startServerAsync(() => ({ body: JSON.stringify([developmentBuild]) })); + + const result = await waitForAppConnectionAsync(url, { + targetSelection: makeTargetSelection({ platform: { value: 'ios', by: 'flag' } }), + deviceInventory: makeDeviceInventory({ booted: [{ name: 'iPhone 15', udid: 'U1' }] }), + inventoryTargets: [], + timeoutMs: 300, + intervalMs: 20, + }); + + expect(result).toMatchObject({ appsConnected: 1, timedOut: false }); + }); + + it('reads a changed list again after a device tool gave no answer on it', async () => { + vi.mocked(spawnCaptureAsync) + .mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: '' } as never) + .mockResolvedValue({ + exitCode: 0, + stdout: JSON.stringify({ devices: { ios: [{ udid: 'U1', name: 'iPhone 17' }] } }), + stderr: '', + } as never); + const developmentBuild = { id: '1', appId: 'com.acme.app', deviceName: 'iPhone 17' }; + const url = await startServerAsync(() => ({ body: JSON.stringify([developmentBuild]) })); + + const result = await waitForAppConnectionAsync(url, { + targetSelection: makeTargetSelection({ platform: { value: 'ios', by: 'flag' } }), + deviceInventory: makeDeviceInventory(), + inventoryTargets: [], + timeoutMs: 3000, + intervalMs: 20, + }); + + expect(result).toMatchObject({ appsConnected: 1, timedOut: false }); + }); +}); + describe(waitForFreshAppConnectionAsync, () => { // The whole reason this exists, and the shape of friction run 4's F39/F45: the reloading app's // *old* target is still listed for the first half-second after a reload broadcast [observed — diff --git a/packages/@expo/agent-cli/src/runtime/cdpClient.ts b/packages/@expo/agent-cli/src/runtime/cdpClient.ts index 46d838f01..bed710127 100644 --- a/packages/@expo/agent-cli/src/runtime/cdpClient.ts +++ b/packages/@expo/agent-cli/src/runtime/cdpClient.ts @@ -204,19 +204,21 @@ export interface CdpClientOptions { deviceInventory: DeviceInventory; } -/** The failure for a platform-scoped client that found no target of its platform. */ +/** The failure for a scoped client that found no target in its scope. */ export class NoTargetOnPlatformError extends Error { readonly isNoTargetOnPlatform = true; constructor( - readonly platform: NavigatePlatform, + readonly platform: NavigatePlatform | null, readonly otherPlatforms: NavigatePlatform[], readonly undetermined: number ) { super( - otherPlatforms.length > 0 - ? `No ${platform} app is connected to the dev server; the ${otherPlatforms.join(' and ')} app that is connected is a different runtime.` - : `No app connected to the dev server could be shown to be running on ${platform}${undetermined > 0 ? `, and ${undetermined} could not be placed at all` : ''}.` + platform == null + ? `No app connected to the dev server could be placed in this run's scope: ${undetermined} could not be placed at all.` + : otherPlatforms.length > 0 + ? `No ${platform} app is connected to the dev server; the ${otherPlatforms.join(' and ')} app that is connected is a different runtime.` + : `No app connected to the dev server could be shown to be running on ${platform}${undetermined > 0 ? `, and ${undetermined} could not be placed at all` : ''}.` ); this.name = 'NoTargetOnPlatformError'; } @@ -276,9 +278,12 @@ export class CdpClient { // Two different failures, and only one of them is "the app is not there". A scoped run that // found apps on the other platform has to say so, or the reader debugs a healthy app (F51). const { platform } = this.options.targetSelection; - if (platform != null && classifiedTargets.read.length === 0) { + if ( + classifiedTargets.read.length === 0 && + (platform != null || classifiedTargets.undetermined.length > 0) + ) { throw new NoTargetOnPlatformError( - platform.value, + platform?.value ?? null, [...new Set(classifiedTargets.otherPlatform.map((entry) => entry.platform))], classifiedTargets.undetermined.length ); diff --git a/packages/@expo/agent-cli/src/runtime/preflight.ts b/packages/@expo/agent-cli/src/runtime/preflight.ts index 74b874beb..fa3aa408b 100644 --- a/packages/@expo/agent-cli/src/runtime/preflight.ts +++ b/packages/@expo/agent-cli/src/runtime/preflight.ts @@ -257,8 +257,18 @@ export async function preflightRuntimeAsync( // Only a command that *requires* a runtime refuses on an empty scope. For `reload` an empty scope // is a rung of its ladder — it can start the app — and for `stop` it is the state llp/0010 §The // seventh and eighth calls a success. - if (platform != null && classifiedTargets.read.length === 0) { - throw noAppOnPlatformError(url, platform, classifiedTargets, cloud); + if ( + classifiedTargets.read.length === 0 && + (platform != null || classifiedTargets.undetermined.length > 0) + ) { + throw platform == null + ? noSessionAppError(url, classifiedTargets.undetermined.length) + : noAppOnPlatformError( + url, + platform, + classifiedTargets, + targetSelection.device?.backend === 'cloud' + ); } return connection; } @@ -396,6 +406,32 @@ export function noAppConnectedError({ return withData(error, { devServerUrl, devServerReachable: true, platform }); } +/** + * The failure for an EAS Simulator session with no platform flag whose app nothing places. + * + * Two or more unclaimed apps leave no way to say which one is the session's, and a device tool that + * gave no answer cannot show that an app is off this machine. The session's platform flag narrows + * which tool has to answer. + */ +function noSessionAppError(devServerUrl: string, unplaced: number): CommandError { + const apps = `${unplaced} app${unplaced === 1 ? '' : 's'}`; + const error = new CommandError( + 'NO_APP_CONNECTED', + [ + `No app connected to the Expo dev server at ${devServerUrl} could be shown to be the EAS Simulator session's app.`, + `Why: its debugger target list names ${apps} that nothing on this machine places. One unclaimed app is the session's only when the local device tools answered and no other app is unclaimed.`, + `How: close the apps on this dev server that are not the session's, then run this command again. A platform flag ("--ios --eas" or "--android --eas") only chooses which device tool has to answer; it does not choose between two unclaimed apps.`, + ].join('\n') + ); + error.suggestedCommand = `${PROGRAM_PREFIX} status --json`; + return withData(error, { + devServerUrl, + devServerReachable: true, + debuggerTargets: unplaced, + platform: null, + }); +} + /** * The failure for a dev server whose apps are all on some other platform, or unreadable. * @@ -424,7 +460,9 @@ function noAppOnPlatformError( : [ `No app connected to the Expo dev server at ${devServerUrl} could be shown to be running on ${platform}.`, `Why: its debugger target list names ${scoped.undetermined.length} app${scoped.undetermined.length === 1 ? '' : 's'}, and nothing in ${scoped.undetermined.length === 1 ? 'its target' : 'their targets'} says which platform ${scoped.undetermined.length === 1 ? 'it is' : 'they are'} on — the dev server does not label them, so this is read from the device name and the app id. A development build on a physical device can look like this.`, - `How: drop --${platform} to read the app that is connected, which is the honest command when only one is. To have the platform recognised, connect the device this machine's device tools can see ("adb devices -l", "xcrun simctl list devices booted").`, + cloud + ? `How: close the apps on this dev server that are not the EAS Simulator session's, then run this command again. --${platform} only chooses which device tool has to answer; it does not choose between two unclaimed apps.` + : `How: drop --${platform} to read the app that is connected, which is the honest command when only one is. To have the platform recognised, connect the device this machine's device tools can see ("adb devices -l", "xcrun simctl list devices booted").`, ].join('\n') ); error.suggestedCommand = diff --git a/packages/@expo/agent-cli/src/runtime/reload/__tests__/reloadAsync-test.ts b/packages/@expo/agent-cli/src/runtime/reload/__tests__/reloadAsync-test.ts index c300295a3..f79637b51 100644 --- a/packages/@expo/agent-cli/src/runtime/reload/__tests__/reloadAsync-test.ts +++ b/packages/@expo/agent-cli/src/runtime/reload/__tests__/reloadAsync-test.ts @@ -202,6 +202,33 @@ function mockSpawnQueue( }) as any); } +/** + * Answer this machine's device tools as a host with no local device, outside the queue. + * + * A cloud scope asks them whether a listed app is on a local device before it reads the app. + */ +function withNoLocalDevices() { + const queued = vi.mocked(spawn).getMockImplementation()!; + vi.mocked(spawn).mockImplementation(((command: string, args: string[], ...rest: unknown[]) => { + const answer = + command === 'xcrun' && args[1] === 'list' + ? JSON.stringify({ devices: {} }) + : args[0] === 'devices' + ? 'List of devices attached\n' + : null; + if (answer == null) return (queued as any)(command, args, ...rest); + const child = Object.assign(new EventEmitter(), { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + }); + process.nextTick(() => { + child.stdout.emit('data', answer); + child.emit('close', 0, null); + }); + return child as any; + }) as any); +} + const BOOTED_SIMULATOR = JSON.stringify({ devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-26-0': [ @@ -1390,6 +1417,7 @@ describe('reloading an app on a cloud simulator session', () => { [{ stdout: SESSION_LISTING }, { stdout: '{"success":true}' }, { stdout: '{"success":true}' }], (index) => index === 2 && appendBundledLine() ); + withNoLocalDevices(); await reloadAsync(projectRoot, cloudOptions({ json: true })); @@ -1404,6 +1432,7 @@ describe('reloading an app on a cloud simulator session', () => { [{ stdout: SESSION_LISTING }, { stdout: '{"success":true}' }, { stdout: '{"success":true}' }], (index) => index === 2 && appendBundledLine() ); + withNoLocalDevices(); await reloadAsync(projectRoot, cloudOptions()); @@ -1550,6 +1579,7 @@ describe('the rung the ladder picks', () => { ).socket ); mockSpawnQueue([{ stdout: SESSION_LISTING }]); + withNoLocalDevices(); await expect( reloadAsync( @@ -1563,7 +1593,11 @@ describe('the rung the ladder picks', () => { commandSocketClients: 1, }); // No controller verb was spawned, so no billed session was touched. - expect(spawn).not.toHaveBeenCalled(); + expect( + vi + .mocked(spawn) + .mock.calls.filter(([command]) => command !== 'xcrun' && !String(command).endsWith('adb')) + ).toEqual([]); }); // The other half of one ladder: the verification is the same on every rung. A `Bundled` line in diff --git a/packages/@expo/agent-cli/src/runtime/reload/reloadAsync.ts b/packages/@expo/agent-cli/src/runtime/reload/reloadAsync.ts index 6639b8b15..06447e187 100644 --- a/packages/@expo/agent-cli/src/runtime/reload/reloadAsync.ts +++ b/packages/@expo/agent-cli/src/runtime/reload/reloadAsync.ts @@ -67,7 +67,7 @@ import { type DevServerMessageSocket, type MessageSocketPeers, } from '../messageSocket'; -import { preflightRuntimeAsync } from '../preflight'; +import { preflightRuntimeAsync, type RuntimeConnection } from '../preflight'; import { type TargetSelection, type DeviceInventory } from '../targetSelection'; import { waitForFreshAppConnectionAsync, countConnectedAppsAsync } from '../waitReady'; import { @@ -467,7 +467,7 @@ export async function reloadAsync(projectRoot: string, options: ReloadOptions): // reaches for on its own. Putting it in `auto` would have traded the relaunch's known cost for // a less predictable one. if (method == null && options.method === 'runtime') { - const attempt = await reloadOverRuntimeAsync(devServerUrl, options, observedApps); + const attempt = await reloadOverRuntimeAsync(devServer, observedApps); attempts.push(attempt); if (attempt.ok) { // No mechanism proof: the debugger has no peer list to churn, so the claim is "the call was @@ -1123,8 +1123,11 @@ const RUNTIME_RELOAD_ANSWER_MS = 4000; * the call is reported as "sent" rather than as a failure. */ async function reloadOverRuntimeAsync( - devServerUrl: string, - options: ReloadOptions, + { + devServerUrl, + targetSelection, + deviceInventory, + }: Pick, connectedApps: number ): Promise { const failed = (reason: string): ReloadAttempt => ({ @@ -1142,16 +1145,9 @@ async function reloadOverRuntimeAsync( const { CdpClient, isMethodNotFoundError } = require('../cdpClient') as typeof import('../cdpClient'); - const { resolveTargetSelectionAsync } = - require('../resolveTargetSelection') as typeof import('../resolveTargetSelection'); let client: import('../cdpClient').CdpClient; try { - const { targetSelection, deviceInventory } = await resolveTargetSelectionAsync({ - projectRoot: null, - flags: { platform: options.platform, eas: false }, - targets: (await probeDevServerAsync(devServerUrl)).targets, - }); client = new CdpClient({ metroUrl: devServerUrl, targetSelection, deviceInventory }); } catch (error: unknown) { return failed( diff --git a/packages/@expo/agent-cli/src/runtime/resolveTargetSelection.ts b/packages/@expo/agent-cli/src/runtime/resolveTargetSelection.ts index cdf630edc..a6e885ef9 100644 --- a/packages/@expo/agent-cli/src/runtime/resolveTargetSelection.ts +++ b/packages/@expo/agent-cli/src/runtime/resolveTargetSelection.ts @@ -3,6 +3,7 @@ import type { NavigateDevice, NavigatePlatform } from '../navigate/device'; import type { CdpTarget } from './cdpClient'; import { deviceInventoryForSelectionAsync, + selectedDeviceOf, classifyTargets, type DeviceInventory, type ClassifiedTargets, @@ -31,7 +32,11 @@ export function targetSelectionOf({ return { platform: platform == null ? null : { value: platform, by: flags.platform != null ? 'flag' : 'device' }, - device: null, + device: device + ? selectedDeviceOf(device) + : flags.eas + ? { backend: 'cloud', deviceId: null } + : null, }; } diff --git a/packages/@expo/agent-cli/src/runtime/targetPlatform.ts b/packages/@expo/agent-cli/src/runtime/targetPlatform.ts index f104fcb5d..e19f297d9 100644 --- a/packages/@expo/agent-cli/src/runtime/targetPlatform.ts +++ b/packages/@expo/agent-cli/src/runtime/targetPlatform.ts @@ -76,7 +76,7 @@ export function platformOfTarget( }; } - const model = deviceInventory?.androidModels.find( + const model = deviceInventory?.androidModels?.find( (candidate) => candidate === deviceName || deviceName.startsWith(`${candidate} - `) ); if (model) { diff --git a/packages/@expo/agent-cli/src/runtime/targetSelection.ts b/packages/@expo/agent-cli/src/runtime/targetSelection.ts index aeec37a78..44fe9f78f 100644 --- a/packages/@expo/agent-cli/src/runtime/targetSelection.ts +++ b/packages/@expo/agent-cli/src/runtime/targetSelection.ts @@ -5,7 +5,7 @@ // device tools report, and the run's scope, which is the caller's decision. `classifyTargets` is the // one function that combines them, so every runtime read places a target the same way. -import type { NavigatePlatform } from '../navigate/device'; +import type { NavigateDevice, NavigatePlatform } from '../navigate/device'; import type { CdpTarget } from './cdpClient'; import { platformOfTarget } from './targetPlatform'; @@ -13,18 +13,29 @@ import { platformOfTarget } from './targetPlatform'; export interface DeviceInventory { /** Booted simulators of every runtime, as `xcrun simctl` reports them. */ booted: { name: string; udid: string }[]; - /** Hardware models of attached Android devices, as `adb devices -l` reports them. */ - androidModels: string[]; + /** Unknown includes an unrequested or failed lookup; unsupported means a non-macOS host. */ + bootedStatus: 'unknown' | 'observed' | 'unsupported'; + /** Hardware models of attached Android devices, as `adb devices -l` reports them; null when it did not exit 0. */ + androidModels: string[] | null; } /** Facts that know nothing, for a run that did not need to ask the device tools. */ -export const EMPTY_DEVICE_INVENTORY: DeviceInventory = { booted: [], androidModels: [] }; +export const EMPTY_DEVICE_INVENTORY: DeviceInventory = { + booted: [], + bootedStatus: 'unknown', + androidModels: null, +}; + +export type SelectedDevice = { backend: 'cloud'; deviceId: string | null }; +export function selectedDeviceOf(device: NavigateDevice): SelectedDevice | null { + return device.backend === 'cloud' ? { backend: 'cloud', deviceId: device.deviceId } : null; +} /** The decision of which targets this run reads, made once by the caller. */ export interface TargetSelection { /** The platform this run reads, and what named it; null reads every platform. */ platform: { value: NavigatePlatform; by: 'flag' | 'device' } | null; - device: null; + device: SelectedDevice | null; } /** A run that reads every target. */ @@ -34,7 +45,8 @@ export const READ_EVERY_TARGET: TargetSelection = { platform: null, device: null export type TargetClassification = | { group: 'read' } | { group: 'other-platform'; platform: NavigatePlatform } - | { group: 'undetermined' }; + | { group: 'undetermined' } + | { group: 'other-device' }; /** A target list, grouped by {@link TargetClassification}. */ export interface ClassifiedTargets { @@ -42,8 +54,54 @@ export interface ClassifiedTargets { read: CdpTarget[]; /** Targets shown to be on another platform than the scope's. */ otherPlatform: { target: CdpTarget; platform: NavigatePlatform }[]; - /** Targets whose platform nothing decided, in a platform scope. Never read. */ + /** Targets nothing placed in this scope: no platform evidence, or no device claim. Never read. */ undetermined: CdpTarget[]; + skipped: { + target: CdpTarget; + classification: Extract; + }[]; +} + +/** Whether a device tool whose answer can place a target in this scope gave no answer. */ +export function scopeToolsUnknown( + targetSelection: TargetSelection, + deviceInventory: DeviceInventory +): boolean { + return ( + targetSelection.platform?.value !== 'android' && deviceInventory.bootedStatus === 'unknown' + ); +} + +/** Whether the cloud placement rule decides this scope. An Android session's names place it. */ +function isCloudScope(targetSelection: TargetSelection): boolean { + return ( + targetSelection.device?.backend === 'cloud' && targetSelection.platform?.value !== 'android' + ); +} + +/** Whether a local device tool names this target's device: a booted simulator or an attached model. */ +function claimedLocally( + target: Pick, + deviceInventory: DeviceInventory +): boolean { + const deviceName = typeof target.deviceName === 'string' ? target.deviceName : ''; + return ( + deviceInventory.booted.some((simulator) => simulator.name === deviceName) || + (deviceInventory.androidModels ?? []).some( + (model) => model === deviceName || deviceName.startsWith(`${model} - `) + ) + ); +} + +/** Whether a target looks Android while `adb` gave no answer, so it may be a local emulator. */ +function androidUnproven( + target: Pick, + deviceInventory: DeviceInventory +): boolean { + return ( + deviceInventory.androidModels == null && + platformOfTarget(target, deviceInventory).platform === 'android' + ); } function classifyTarget( @@ -51,14 +109,27 @@ function classifyTarget( targetSelection: TargetSelection, deviceInventory: DeviceInventory ): TargetClassification { - if (targetSelection.platform == null) { - return { group: 'read' }; - } const { platform } = platformOfTarget(target, deviceInventory); - if (platform === targetSelection.platform.value) { - return { group: 'read' }; + if ( + targetSelection.platform != null && + platform != null && + platform !== targetSelection.platform.value + ) + return { group: 'other-platform', platform }; + if (isCloudScope(targetSelection)) { + // A cloud scope reads no target a local device tool claims. An app id says nothing about which + // device runs it, so only an answered tool can show that no local device does. + if (claimedLocally(target, deviceInventory)) return { group: 'other-device' }; + if ( + scopeToolsUnknown(targetSelection, deviceInventory) || + androidUnproven(target, deviceInventory) + ) + return { group: 'undetermined' }; + return platform == null ? { group: 'undetermined' } : { group: 'read' }; } - return platform == null ? { group: 'undetermined' } : { group: 'other-platform', platform }; + return targetSelection.platform != null && platform == null + ? { group: 'undetermined' } + : { group: 'read' }; } /** @@ -66,13 +137,27 @@ function classifyTarget( * * `undetermined` is its own group rather than being folded into `read`, and that is the whole * point: a run that cannot tell what it is talking to must not earn a platform's verdict from it. + * + * One rule needs the whole list, so it lives here and not in `platformOfTarget`: on an EAS + * Simulator session, with or without a platform flag, exactly one undetermined target is the + * session's app. Its device name is the cloud simulator's model, which no local tool reports, and a + * development build's app id is often the same on both platforms. The run named the session, so the + * one target nothing on this machine claims is its app, provided the scope's device tools answered + * or this host cannot run them. A failed inspection cannot establish absence, so an Android-shaped target stays undetermined while `adb` gave no answer. Two such targets stay + * undetermined: nothing says which one is the session's. An Android session needs no rule, + * because its device name shape places it. */ export function classifyTargets( targets: CdpTarget[], targetSelection: TargetSelection, deviceInventory: DeviceInventory ): ClassifiedTargets { - const classifiedTargets: ClassifiedTargets = { read: [], otherPlatform: [], undetermined: [] }; + const classifiedTargets: ClassifiedTargets = { + read: [], + otherPlatform: [], + undetermined: [], + skipped: [], + }; for (const target of targets) { const classification = classifyTarget(target, targetSelection, deviceInventory); switch (classification.group) { @@ -82,22 +167,41 @@ export function classifyTargets( case 'other-platform': classifiedTargets.otherPlatform.push({ target, platform: classification.platform }); break; + case 'other-device': + classifiedTargets.skipped.push({ target, classification }); + break; case 'undetermined': classifiedTargets.undetermined.push(target); break; } } + const [only, ...more] = classifiedTargets.undetermined; + if ( + isCloudScope(targetSelection) && + !scopeToolsUnknown(targetSelection, deviceInventory) && + only && + !more.length && + !androidUnproven(only, deviceInventory) && + (targetSelection.platform != null || classifiedTargets.read.length === 0) + ) { + return { + ...classifiedTargets, + read: targets.filter((target) => target === only || classifiedTargets.read.includes(target)), + undetermined: [], + }; + } return classifiedTargets; } /** Whether the device tools can change where these targets fall for this scope. */ function toolsCanPlace( targets: readonly Pick[], - _targetSelection: TargetSelection + targetSelection: TargetSelection ): boolean { // Unscoped reads still need platform evidence for their bundle checks. - return targets.some( - (target) => platformOfTarget(target, EMPTY_DEVICE_INVENTORY).platform == null + return ( + (isCloudScope(targetSelection) && targets.length > 0) || + targets.some((target) => platformOfTarget(target, EMPTY_DEVICE_INVENTORY).platform == null) ); } @@ -112,7 +216,12 @@ export async function refreshDeviceInventoryAsync( return previous; } const tools = await readDeviceInventoryAsync(timeoutMs); - return { ...previous, booted: tools.booted, androidModels: tools.androidModels }; + return { + ...previous, + booted: tools.booted, + bootedStatus: tools.bootedStatus, + androidModels: tools.androidModels, + }; } /** The facts needed to place this list, read only when the device tools can place it. */ @@ -126,8 +235,8 @@ export async function deviceInventoryForSelectionAsync( /** * Ask this machine's device tools what is attached. * - * Never throws and never fails a command: a missing `xcrun` or `adb` only costs the strongest of - * the platform rules, and the other two still answer for Expo Go and for React Native Android. + * Tool failures leave their evidence unknown. Expo Go ids and React Native Android names still + * supply platform evidence, but a failed simulator lookup cannot justify cloud attribution. */ export async function readDeviceInventoryAsync(timeoutMs?: number): Promise { const { parseAndroidDevices, runAdbAsync } = @@ -144,14 +253,17 @@ export async function readDeviceInventoryAsync(timeoutMs?: number): Promise null), ]); + const booted = simctl?.exitCode === 0 ? parseBootedSimulators(simctl.stdout) : null; return { - booted: simctl?.exitCode === 0 ? parseBootedSimulators(simctl.stdout) : [], + booted: booted ?? [], + bootedStatus: + booted != null ? 'observed' : process.platform === 'darwin' ? 'unknown' : 'unsupported', androidModels: adb?.exitCode === 0 ? parseAndroidDevices(adb.stdout) .map((device) => device.model) .filter((model): model is string => model != null) - : [], + : null, }; } diff --git a/packages/@expo/agent-cli/src/runtime/waitReady.ts b/packages/@expo/agent-cli/src/runtime/waitReady.ts index 0250b8415..c521d5edd 100644 --- a/packages/@expo/agent-cli/src/runtime/waitReady.ts +++ b/packages/@expo/agent-cli/src/runtime/waitReady.ts @@ -19,6 +19,7 @@ import { decodeProjectRoot, matchProjectRoot, PROJECT_ROOT_HEADER } from './proj import { isSkippedTarget, classifyTargets, + scopeToolsUnknown, refreshDeviceInventoryAsync, type ClassifiedTargets, type DeviceInventory, @@ -281,16 +282,19 @@ export interface AppConnectionResult { /** How long the wait took, in milliseconds. */ waitedMs: number; /** - * Apps attached that are on another platform, when one was named. + * Apps attached that are on another platform, when the scope names a platform or a device. * * Reported rather than dropped, because it is the difference between "no app is running" and * "the app that is running is the other platform's" — which are opposite next steps. */ otherPlatformApps?: number; - /** Apps attached whose platform nothing in the target decided. Never counted as either. */ + /** Apps attached that nothing placed in the scope. Never counted. */ undeterminedApps?: number; } +/** How often a wait asks again for a device tool answer that is unknown. */ +const INVENTORY_RETRY_MS = 1000; + function listKey(targets: readonly Pick[]): string { return targets.map((target) => target.id).join('\n'); } @@ -300,7 +304,10 @@ function listKey(targets: readonly Pick[]): string { * * @ref llp/0005-runtime-loop-tools.rfc.md §Which app a run reads * An unchanged list keeps its facts, so a target nothing places costs one tool read, not one per - * poll. A changed list, such as a first app after an empty one, is read again within the budget. + * poll. A changed list, such as a first app after an empty one, is read again within the budget, and + * so is a tool answer that is unknown, at most once every {@link INVENTORY_RETRY_MS}; the + * caller's inventory counts as the first read. A read that loses a tool's answer keeps the facts + * that placed the last list, and leaves the list to be read again. */ export function scopedListClassifier( targetSelection: TargetSelection, @@ -309,16 +316,28 @@ export function scopedListClassifier( inventoryTargets: readonly Pick[] ): (targets: CdpTarget[]) => Promise { let inventoryListKey = listKey(inventoryTargets); + let attemptedKey = inventoryListKey; + let readAt = Date.now(); return async (targets) => { const key = listKey(targets); - if (key !== inventoryListKey && Date.now() < deadline) { - inventoryListKey = key; - deviceInventory = await refreshDeviceInventoryAsync( + const due = Date.now() - readAt >= INVENTORY_RETRY_MS; + const unknown = scopeToolsUnknown(targetSelection, deviceInventory); + if ( + ((key !== inventoryListKey && (key !== attemptedKey || due)) || (unknown && due)) && + Date.now() < deadline + ) { + attemptedKey = key; + readAt = Date.now(); + const fresh = await refreshDeviceInventoryAsync( targets, targetSelection, deviceInventory, Math.max(1, deadline - Date.now()) ); + if (!scopeToolsUnknown(targetSelection, fresh) || unknown) { + deviceInventory = fresh; + inventoryListKey = key; + } } return classifyTargets(targets, targetSelection, deviceInventory); }; @@ -354,7 +373,7 @@ export async function waitForAppConnectionAsync( const classifiedTargets = await classify(probe.targets); const counted = classifiedTargets.read.length; const extra = - targetSelection.platform != null + targetSelection.platform != null || targetSelection.device != null ? { otherPlatformApps: classifiedTargets.otherPlatform.length, undeterminedApps: classifiedTargets.undetermined.length, diff --git a/packages/@expo/agent-cli/src/smoke/__tests__/smokeDeps-test.ts b/packages/@expo/agent-cli/src/smoke/__tests__/smokeDeps-test.ts index 67f6d6214..da5b40c1d 100644 --- a/packages/@expo/agent-cli/src/smoke/__tests__/smokeDeps-test.ts +++ b/packages/@expo/agent-cli/src/smoke/__tests__/smokeDeps-test.ts @@ -1,3 +1,4 @@ +import { makeTarget } from '../../runtime/__tests__/targetSelectionFixtures'; // @ref llp/0035-one-device-per-worktree.rfc.md §iOS smoke // @ref llp/0035-one-device-per-worktree.rfc.md §Wiring // Smoke binds and releases only its own platform through the registry. @@ -329,3 +330,54 @@ describe('ensureEasSession with no EAS build to install', () => { }); }); }); + +// @ref llp/0005-runtime-loop-tools.rfc.md §Android — the EAS Simulator iOS session rule +describe('evaluate on an EAS Simulator iOS session', () => { + const realPlatform = process.platform; + const realFetch = globalThis.fetch; + const CLOUD_TARGET = { + id: '1', + appId: 'dev.vonovak.cyclable', + deviceName: 'iPhone 17', + description: '', + type: 'node', + title: 'React Native', + devtoolsFrontendUrl: '/devtools', + webSocketDebuggerUrl: 'ws://127.0.0.1:1/inspector/debug?device=1&page=1', + }; + + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + globalThis.fetch = (async () => ({ + ok: true, + json: async () => [CLOUD_TARGET], + })) as unknown as typeof fetch; + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: realPlatform }); + globalThis.fetch = realFetch; + }); + + it("waitForAppConnection on --ios --eas beside a local simulator's app", async () => { + vi.mocked(spawnCaptureAsync).mockResolvedValue({ + exitCode: 0, + stdout: JSON.stringify({ + devices: { ios: [{ name: 'iPhone 17 Pro', udid: 'LOCAL', state: 'Booted' }] }, + }), + stderr: '', + }); + globalThis.fetch = (async () => ({ + ok: true, + json: async () => [ + makeTarget({ id: 'local', deviceName: 'iPhone 17 Pro' }), + makeTarget({ id: 'session' }), + ], + })) as unknown as typeof fetch; + const result = await buildSmokeDeps( + projectRoot, + resolveSmokeOptions(['--ios', '--eas']) + ).waitForAppConnection('http://127.0.0.1:8081', 10); + expect(result).toMatchObject({ appsConnected: 1, undeterminedApps: 0 }); + }); +}); diff --git a/packages/@expo/agent-cli/src/smoke/__tests__/targetSelection-test.ts b/packages/@expo/agent-cli/src/smoke/__tests__/targetSelection-test.ts index ac3381822..13d6273b0 100644 --- a/packages/@expo/agent-cli/src/smoke/__tests__/targetSelection-test.ts +++ b/packages/@expo/agent-cli/src/smoke/__tests__/targetSelection-test.ts @@ -142,3 +142,61 @@ it('asks the device tools once for a list that does not change', async () => { }); expect(tools.calls.filter((args) => args[0] === 'list')).toHaveLength(1); }); + +it('refreshes failed local exclusion evidence before counting a cloud attachment', async () => { + const url = await serverAsync(() => [target()]); + const tools = fakeTools(); + let simulatorReads = 0; + vi.mocked(spawnCaptureAsync).mockImplementation(async (command, args) => { + if (command !== 'xcrun') return tools.adb(args); + if (++simulatorReads === 1) return { exitCode: 1, stdout: '', stderr: 'unavailable' }; + return tools.simctl(args.slice(1)); + }); + const deps = buildSmokeDeps(projectRoot, resolveSmokeOptions(['--ios', '--eas'])); + expect(await deps.waitForAppConnection(url, 1500)).toMatchObject({ + appsConnected: 1, + timedOut: false, + undeterminedApps: 0, + }); + expect(simulatorReads).toBeGreaterThan(1); +}); + +it('asks a failing simctl again at most once a second while the wait polls', async () => { + const url = await serverAsync(() => [target()]); + const tools = fakeTools(); + let simulatorLists = 0; + vi.mocked(spawnCaptureAsync).mockImplementation(async (command, args) => { + if (command !== 'xcrun') return tools.adb(args); + if (args[1] === 'list') simulatorLists++; + return { exitCode: 1, stdout: '', stderr: 'unavailable' }; + }); + const deps = buildSmokeDeps(projectRoot, resolveSmokeOptions(['--ios', '--eas'])); + expect(await deps.waitForAppConnection(url, 2200)).toMatchObject({ + appsConnected: 0, + timedOut: true, + }); + expect(simulatorLists).toBeGreaterThan(1); + expect(simulatorLists).toBeLessThanOrEqual(3); +}); + +it.each([ + { case: 'after an empty first list', firstList: [] as string[] }, + { case: 'after a local simulator boots during the wait', firstList: ['My iPhone'] }, +])('does not count a local Expo Go app as the cloud session $case', async ({ firstList }) => { + const tools = inventory(); + const expoGo = (deviceName: string) => + target({ id: deviceName, appId: 'host.exp.Exponent', deviceName }); + let reads = 0; + const url = await serverAsync(() => { + if (++reads <= 2) return firstList.map(expoGo); + if (!tools.simulators.some((simulator) => simulator.name === 'Second iPhone')) { + tools.simulators.push({ udid: 'IOS-2', name: 'Second iPhone', state: 'Booted' }); + } + return [...firstList, 'Second iPhone'].map(expoGo); + }); + const deps = buildSmokeDeps(projectRoot, resolveSmokeOptions(['--ios', '--eas'])); + expect(await deps.waitForAppConnection(url, 600)).toMatchObject({ + appsConnected: 0, + timedOut: true, + }); +});