From 6d105f787e76856867ad3171f5b9a87b91e92b51 Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Wed, 28 Oct 2026 21:32:52 +0100 Subject: [PATCH 1/4] [agent-cli] Resolve runtime target selection once per connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add TargetSelection (the run's scope), DeviceInventory (the device tools' facts) and one resolver. RuntimeConnection carries the classified targets. Every reader, wait and deferred command reads classifiedTargets.read. Clients, bundle checks and waits get the scope and the facts as arguments. READ_EVERY_TARGET and makeTargetSelection replace the old flag helpers. targetSelectionOf builds the scope from the flags and the device and reads nothing; resolveTargetSelectionAsync and dev:wait use it. refreshDeviceInventoryAsync is the one place that asks the device tools again, and toolsCanPlace is the one predicate that says when to. An attachment wait classifies every list it polls with scopedListClassifier. The device facts belong to the list they were read for: an unchanged list keeps them, and a changed list is read again within the wait's budget. A target that nothing places costs one tool read, not one per poll. Smoke keeps its device choice in smoke/targetSelection.ts. A successful choice stays until a boot or a session start replaces it. A failed lookup is not kept, so a later phase asks again. Placement does not change. llp/0005 §Which app a run reads holds the scope table and the rules every caller follows, each with its test. Connected-app polling moves beside the other waits, so reload stays under its cap. smokeAsync.ts leaves the long-file list. Co-Authored-By: Claude Opus 5.5 --- packages/@expo/agent-cli/CHANGELOG.md | 1 + .../llp/0005-runtime-loop-tools.rfc.md | 29 ++- packages/@expo/agent-cli/oxlint.config.mjs | 1 - .../src/deferred/dev-wait/waitAsync.ts | 29 ++- .../__tests__/networkCollector-test.ts | 9 +- .../runtime-network/networkCollector.ts | 4 +- .../runtime-network/runtimeNetworkAsync.ts | 11 +- .../@expo/agent-cli/src/device/simulators.ts | 19 +- .../src/lint/__tests__/foreignFlags-test.ts | 2 +- .../src/navigate/__tests__/device-test.ts | 12 +- .../@expo/agent-cli/src/navigate/openRoute.ts | 37 ++-- .../@expo/agent-cli/src/navigate/target.ts | 4 + .../src/runtime/__tests__/cdpClient-test.ts | 13 +- .../src/runtime/__tests__/preflight-test.ts | 13 +- .../runtime/__tests__/runtimeAsync-test.ts | 4 +- .../__tests__/runtimeErrorCollector-test.ts | 28 ++- .../src/runtime/__tests__/stopAsync-test.ts | 2 +- .../runtime/__tests__/targetPlatform-test.ts | 51 ++---- .../runtime/__tests__/targetSelection-test.ts | 40 +++++ .../__tests__/targetSelectionFixtures.ts | 11 ++ .../src/runtime/__tests__/waitReady-test.ts | 31 +++- .../agent-cli/src/runtime/bundleCheck.ts | 21 +-- .../@expo/agent-cli/src/runtime/cdpClient.ts | 48 +++-- .../src/runtime/interact/interactAsync.ts | 8 +- .../@expo/agent-cli/src/runtime/preflight.ts | 77 ++++---- .../src/runtime/reload/reloadAsync.ts | 56 +++--- .../src/runtime/resolveTargetSelection.ts | 51 ++++++ .../agent-cli/src/runtime/runtimeAsync.ts | 31 ++-- .../src/runtime/runtimeErrorCollector.ts | 4 +- .../@expo/agent-cli/src/runtime/stopAsync.ts | 6 +- .../agent-cli/src/runtime/targetPlatform.ts | 130 +------------- .../agent-cli/src/runtime/targetSelection.ts | 167 ++++++++++++++++++ .../@expo/agent-cli/src/runtime/waitReady.ts | 150 ++++++++++++---- .../src/smoke/__tests__/phases-test.ts | 48 +++-- .../src/smoke/__tests__/smokeDeps-test.ts | 19 ++ .../smoke/__tests__/targetSelection-test.ts | 162 +++++++++++++++++ packages/@expo/agent-cli/src/smoke/phases.ts | 16 +- .../@expo/agent-cli/src/smoke/smokeAsync.ts | 136 ++++++-------- .../agent-cli/src/smoke/targetSelection.ts | 68 +++++++ 39 files changed, 1044 insertions(+), 505 deletions(-) create mode 100644 packages/@expo/agent-cli/src/runtime/__tests__/targetSelection-test.ts create mode 100644 packages/@expo/agent-cli/src/runtime/__tests__/targetSelectionFixtures.ts create mode 100644 packages/@expo/agent-cli/src/runtime/resolveTargetSelection.ts create mode 100644 packages/@expo/agent-cli/src/runtime/targetSelection.ts create mode 100644 packages/@expo/agent-cli/src/smoke/__tests__/targetSelection-test.ts create mode 100644 packages/@expo/agent-cli/src/smoke/targetSelection.ts diff --git a/packages/@expo/agent-cli/CHANGELOG.md b/packages/@expo/agent-cli/CHANGELOG.md index 489bcea0d..1ecd7b225 100644 --- a/packages/@expo/agent-cli/CHANGELOG.md +++ b/packages/@expo/agent-cli/CHANGELOG.md @@ -56,6 +56,7 @@ - `navigate` percent-encodes the route and a full URL, so a link with spaces or diacritics opens instead of failing with APP_NOT_INSTALLED. ([#198](https://github.com/expo/expo-agent-cli/pull/198) by [@vonovak](https://github.com/vonovak)) - `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)) ### 💡 Others 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 cbf6722db..a4684690a 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 @@ -173,7 +173,7 @@ Each carries what, why, and how. The how is one ladder, in one order: `npx @expo The connection is asked before the bundle gate. With nothing connected the exit is 1, whatever the code on disk would have compiled to. Nothing can be read off a screen that is not there. The other runtime commands already gave 1 for this. The bundle gate's budget is twenty seconds. The connection answer takes a millisecond. -The preflight reads the target list once and hands back the populated connection: the URL, how it was found, the targets, the platform-scoped subset a command may read, and the device index. +The preflight reads the target list once and hands back the populated connection: the URL, how it was found, the targets, the platform-scoped subset a command may read, the run scope, and the device facts. Both refusals are exit 1 in every command, including under a gate-shaped flag. `runtime:errors --fail-on-error` with no app is 1, never 0. `1` is "fix the call". The fix is to start a dev server or open the app. @@ -181,6 +181,23 @@ Every command whose `need` is `debugger-target` waits out `APP_RECONNECT_GRACE_M Every command of this group takes `--port ` as sugar for `--dev-server-url http://127.0.0.1:`. `runtime:reload` and `runtime:stop` take `--platform ios|android` alongside `--ios`/`--android`. Passing both `--dev-server-url` and `--port` is `BAD_ARGS`. `--ios` and `--platform ios` together are fine. `--ios --platform android` is refused. A "no dev server" error names the URL that was tried when the caller already named one (`howToNameTheDevServer`). +## Which app a run reads + +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 | runtime targetSelection-test "a scope with no platform reads every target" | +| flag only | platform evidence matches | read | runtime targetSelection-test "a platform scope keeps an undetermined target out of the read set" | +| flag only | other platform | skip as other-platform | same | +| flag only | no platform evidence | undetermined | same | + +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: smoke targetSelection-test "infers both bundle platforms without a platform flag or selected device". +- 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". + ## Reloading the app `@expo/agent-cli runtime:reload` puts the running app back on the code that is on disk, and reports a reload only when one was observed [confirmed, Kudo, 2026-08-23]. It is a `runtime` action, not a top-level verb. Reloading is driving the running app. It keeps a module and a `--help` block of its own. @@ -295,6 +312,8 @@ Nine phases: The URL the first phase settled on is threaded into every phase after it. A gate whose phases talk to two dev servers is a gate whose phases are about two different things. +The device is chosen once per run too. A device that a boot or a session start supplies is the choice for the phases after it, with no second lookup, and a failed lookup is asked again by a later phase. Tests: smoke targetSelection-test "retries a failed device lookup, shares concurrent discovery, and retains the successful choice" and "publishes an acquired device to subsequent phases without another lookup"; smokeDeps-test "keeps the session returned by ensure for subsequent device reads". + `--start` must not name a platform. `expo start --ios` drives Simulator.app through AppleScript. On a Mac that has granted no Automation grant the Expo CLI does not catch the refusal and the dev server exits with it. The recovery is the one this command performs: start the dev server without opening anything, then open the app with `navigate`. `START_DEV_SERVER_ARGV` is pinned by a test. `--start` also carries `--port` through when the caller named a loopback one. | Code | Outcome | Meaning | @@ -343,7 +362,7 @@ And `status.next` is read as "run this", so it states a platform rather than lea **The first sweep still missed a handful, and a second one found them — F151.** All were the same kind: a surface that names the platform from the _project_ rather than from a device, which the first pass, aimed at the report surfaces, did not look at. `dev --detach`'s and `dev:logs`'s `smoke` follow-ups, the own-dev-server `PORT_IN_USE` how-line, `typecheck`'s two follow-ups, the interact commands' bundle-not-ready refusal, `runtime:reload`'s and `runtime:eval`'s recovery lines, `navigate`'s cloud look-command — and the worst of them, the `AGENTS.md` this CLI **writes into the user's project** (`src/agents/content.ts`), where an agent reads the block and runs the command verbatim. The rule now lives in one place (`src/smoke/suggest.ts`): a `smokeCommand(platform)` and the project-default it needs, so the two copies of that default collapse into one. -**The index that places a target must not be an answer about an empty list — F152.** Scoping by platform needs a device-name index whenever the target's own fields name none, which is every development build: its app id is the project's own, and its device name is a simulator's. Building that index costs two subprocesses, so it is built only when a target actually needs placing — and `smoke` asked that question **before the app it was waiting for existed**. The first probe of a run finds nothing attached, "nothing to place" is cached, and the app that attaches a moment later can never be placed by it. Every wait after that counted zero: `smoke --ios` against a development build spent its whole budget and reported "no app had attached" about an app that was listed in `/json/list` the entire time [observed — 55.8 s of a 60 s budget, 2026-09-06]. The cache is now kept only while it still places what is listed _now_, which costs an empty list nothing — it places vacuously — and re-asks exactly once, when there is finally something to place. +**The device facts that place a target must not be an answer about an empty list — F152.** Scoping by platform needs the device facts whenever the target's own fields name none, which is every development build: its app id is the project's own, and its device name is a simulator's. Reading them costs two subprocesses, so they are read only when a target actually needs placing — and `smoke` asked that question **before the app it was waiting for existed**. The first probe of a run finds nothing attached, "nothing to place" is cached, and the app that attaches a moment later can never be placed by it. Every wait after that counted zero: `smoke --ios` against a development build spent its whole budget and reported "no app had attached" about an app that was listed in `/json/list` the entire time [observed — 55.8 s of a 60 s budget, 2026-09-06]. The device facts belong to the target list they were read for, and a changed list is read again (§Which app a run reads). An empty list costs nothing, because it places vacuously. What made both sweeps necessary is that nothing counted the surface. So the countable-flags trick that already pins the flags this CLI writes onto other CLIs ([[0002-testing-and-evals]] §A flag is not shipped) now has a second instance: `src/lint/checkCommandMentions.ts` gained a **required-option** rule, `REQUIRED_OPTION_GROUPS`, that fails on any printed `smoke` with no platform. The next command that grows a required flag is one row of a table, not a wave of exit-1 suggestions a person finds one at a time. @@ -536,7 +555,7 @@ The target is resolved once and memoized, deliberately — a run that fails at t So a start that built forgets the target, and the next reader re-reads the project the build left behind. The memo still holds for every run that compiles nothing, which is almost all of them. -The general shape is the one this document keeps meeting: **a fact captured before the thing it describes existed.** The pre-install app probe (§Putting Expo Go on a simulator that has not got it), the device-name index, and this. Each was a value read at the cheapest moment rather than at the moment it was needed. +The general shape is the one this document keeps meeting: **a fact captured before the thing it describes existed.** The pre-install app probe (§Putting Expo Go on a simulator that has not got it), the device facts, and this. Each was a value read at the cheapest moment rather than at the moment it was needed. #### `--device` takes the bound device's id on both platforms @@ -621,11 +640,11 @@ Expo Go for Android now reaches the gate on the same footing as iOS in everythin Expo Go for Android ships a Hermes without any CDP debugger [observed, `HermesRuntime[RNBridgeless] does not support debugging over the Chrome DevTools Protocol`]. `Runtime.enable`, `Log.enable`, `Network.enable`, and `Debugger.enable` acknowledge. `Runtime.evaluate` answers `-32601`. The ack is what makes an empty window look like a healthy app. Collectors probe the runtime as they open their window, with `Log.enable` for the announcement and one `Runtime.evaluate` of `1` for the code. The verdict is `RuntimeDebuggerCapability`. `RUNTIME_EVALUATE_UNSUPPORTED` is reached by asking, not by knowing the platform. -The target selector skips only on transport failure, and ranks `-32601` targets behind answering ones. It no longer drops such targets. `CdpRuntimeErrorCollector` forwards every `CdpClientOptions` key, including `platform` and `deviceIndex`. +The target selector skips only on transport failure, and ranks `-32601` targets behind answering ones. It no longer drops such targets. `CdpRuntimeErrorCollector` forwards every `CdpClientOptions` key, including `targetSelection` and `deviceInventory`. `/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. -Scoping reaches `requireConnectedAppAsync`, `waitForAppConnectionAsync`, `CdpClient` target selection, and `preflightRuntimeAsync`'s `appTargets` 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. +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/oxlint.config.mjs b/packages/@expo/agent-cli/oxlint.config.mjs index fd924ec5d..ef7b33ae7 100644 --- a/packages/@expo/agent-cli/oxlint.config.mjs +++ b/packages/@expo/agent-cli/oxlint.config.mjs @@ -7,7 +7,6 @@ const longFiles = { 'src/dev/devAsync.ts': 1042, 'src/runtime/reload/reloadAsync.ts': 1032, 'src/smoke/phases.ts': 1078, - 'src/smoke/smokeAsync.ts': 801, 'src/status/statusAsync.ts': 806, }; diff --git a/packages/@expo/agent-cli/src/deferred/dev-wait/waitAsync.ts b/packages/@expo/agent-cli/src/deferred/dev-wait/waitAsync.ts index 14bd1d7fe..a842a32fe 100644 --- a/packages/@expo/agent-cli/src/deferred/dev-wait/waitAsync.ts +++ b/packages/@expo/agent-cli/src/deferred/dev-wait/waitAsync.ts @@ -23,8 +23,13 @@ import { resolveBundleCheckPlatformsAsync, type BundleCheckResult, } from '../../runtime/bundleCheck'; -import { howToNameTheDevServer } from '../../runtime/devServer'; +import { howToNameTheDevServer, probeDevServerAsync } from '../../runtime/devServer'; import { discoverDevServerAsync } from '../../runtime/discoverDevServer'; +import { targetSelectionOf } from '../../runtime/resolveTargetSelection'; +import { + deviceInventoryForSelectionAsync, + EMPTY_DEVICE_INVENTORY, +} from '../../runtime/targetSelection'; import { waitForAppConnectionAsync, waitForBundlerReadyAsync } from '../../runtime/waitReady'; import { CommandError } from '../../utils/errors'; import { buildDevWaitFollowUps } from './followups'; @@ -89,12 +94,18 @@ export async function devWaitAsync(projectRoot: string, options: DevWaitOptions) // @ref llp/0005-runtime-loop-tools.rfc.md §Android — friction run 6, F53. Which platform to build // for is a question about the *app*, and this command used to answer it with a fixed default: an // Android-only break with an iOS app also attached was reported as "compiles for ios". + const targetSelection = targetSelectionOf({ + flags: { + platform: options.platformExplicit && options.platform !== 'web' ? options.platform : null, + eas: false, + }, + device: null, + }); const { platforms: bundlePlatforms, source: bundlePlatformSource } = - await resolveBundleCheckPlatformsAsync( - options.platformExplicit ? options.platform : null, - discovery.targets, - options.platform - ); + await resolveBundleCheckPlatformsAsync(targetSelection, discovery.targets, { + deviceInventory: EMPTY_DEVICE_INVENTORY, + fallback: options.platform, + }); const bundles: BundleCheckResult[] = []; if (options.bundleCheck && readiness.ready && readiness.projectRootMatched !== false) { @@ -125,12 +136,14 @@ export async function devWaitAsync(projectRoot: string, options: DevWaitOptions) // nothing can attach to it, so the wait would spend the rest of the budget to learn what the // line above already knows. if (options.requireApp && readiness.ready && bundle?.outcome !== 'broken' && !timedOut) { + const { targets } = await probeDevServerAsync(discovery.devServerUrl); const attached = await waitForAppConnectionAsync(discovery.devServerUrl, { timeoutMs: remainingMs(), // Scoped when the caller named a native platform: `--require-app --platform android` used to // be satisfied by the iOS simulator attached to the same dev server (F51). - platform: - options.platformExplicit && options.platform !== 'web' ? options.platform : undefined, + targetSelection, + deviceInventory: await deviceInventoryForSelectionAsync(targets, targetSelection), + inventoryTargets: targets, }); appsConnected = attached.appsConnected; timedOut = attached.timedOut; diff --git a/packages/@expo/agent-cli/src/deferred/runtime-network/__tests__/networkCollector-test.ts b/packages/@expo/agent-cli/src/deferred/runtime-network/__tests__/networkCollector-test.ts index 1c27b4114..1821cac86 100644 --- a/packages/@expo/agent-cli/src/deferred/runtime-network/__tests__/networkCollector-test.ts +++ b/packages/@expo/agent-cli/src/deferred/runtime-network/__tests__/networkCollector-test.ts @@ -11,6 +11,7 @@ import { parseNetworkMessage, targetAdvertisesNetworkPanel, } from '../networkCollector'; +import { EVERY_TARGET_CLIENT } from '../../../runtime/__tests__/targetSelectionFixtures'; const TARGET = { id: '1', @@ -59,6 +60,7 @@ function mockCollector( ) { const requests: any[] = []; const collector = new CdpNetworkCollector({ + ...EVERY_TARGET_CLIENT, metroUrl: 'http://localhost:8081', targetSelector: async () => TARGET, durationMs, @@ -213,6 +215,7 @@ describe('CdpNetworkCollector.collectAsync', () => { it(`should reject when the debugger connection does not open in time`, async () => { const collector = new CdpNetworkCollector({ + ...EVERY_TARGET_CLIENT, metroUrl: 'http://localhost:8081', targetSelector: async () => TARGET, timeoutMs: 20, @@ -228,12 +231,16 @@ describe('CdpNetworkCollector.collectAsync', () => { json: async () => [], })) as unknown as typeof fetch; - const collector = new CdpNetworkCollector({ metroUrl: 'http://localhost:8081' }); + const collector = new CdpNetworkCollector({ + ...EVERY_TARGET_CLIENT, + metroUrl: 'http://localhost:8081', + }); await expect(collector.collectAsync()).rejects.toThrow(/No target found/); }); it(`should resolve with the requests seen so far when the app disconnects`, async () => { const collector = new CdpNetworkCollector({ + ...EVERY_TARGET_CLIENT, metroUrl: 'http://localhost:8081', targetSelector: async () => TARGET, durationMs: 5000, diff --git a/packages/@expo/agent-cli/src/deferred/runtime-network/networkCollector.ts b/packages/@expo/agent-cli/src/deferred/runtime-network/networkCollector.ts index cb059d087..7a5a38c6f 100644 --- a/packages/@expo/agent-cli/src/deferred/runtime-network/networkCollector.ts +++ b/packages/@expo/agent-cli/src/deferred/runtime-network/networkCollector.ts @@ -161,8 +161,8 @@ export class CdpNetworkCollector { metroUrl, targetSelector, createWebSocket, - platform: this.config.platform, - deviceIndex: this.config.deviceIndex, + targetSelection: this.config.targetSelection, + deviceInventory: this.config.deviceInventory, }); const ws: WebSocket = await client.createWebSocketAsync(); this.clientWebSocketDebuggerUrl = client.getWebSocketDebuggerUrl(); diff --git a/packages/@expo/agent-cli/src/deferred/runtime-network/runtimeNetworkAsync.ts b/packages/@expo/agent-cli/src/deferred/runtime-network/runtimeNetworkAsync.ts index 298d05069..662305885 100644 --- a/packages/@expo/agent-cli/src/deferred/runtime-network/runtimeNetworkAsync.ts +++ b/packages/@expo/agent-cli/src/deferred/runtime-network/runtimeNetworkAsync.ts @@ -11,7 +11,7 @@ import * as Log from '../../log'; import { PROGRAM_PREFIX } from '../../programName'; import type { CdpTarget } from '../../runtime/cdpClient'; // The family's one resolution step (`src/runtime/preflight.ts`): the dev server, the app and the -// platform index in one read. This command asked for all three separately when it shipped. +// device facts in one read. This command asked for all three separately when it shipped. import { preflightRuntimeAsync, type RuntimeContext } from '../../runtime/preflight'; import { CommandError } from '../../utils/errors'; import { buildRuntimeNetworkFollowUps } from './followups'; @@ -42,8 +42,9 @@ export async function runtimeNetworkAsync( const { durationMs, json } = options; const { devServerUrl, - deviceIndex, - appTargets: targets, + targetSelection, + deviceInventory, + classifiedTargets: { read: targets }, } = await preflightRuntimeAsync( { need: 'debugger-target', devServerUrl: options.devServerUrl, platform: options.platform }, context @@ -53,8 +54,8 @@ export async function runtimeNetworkAsync( const collector = new CdpNetworkCollector({ metroUrl: devServerUrl, durationMs, - platform: options.platform, - deviceIndex, + targetSelection, + deviceInventory, }); try { requests = await collector.collectAsync(); diff --git a/packages/@expo/agent-cli/src/device/simulators.ts b/packages/@expo/agent-cli/src/device/simulators.ts index 44a834a24..d47070efe 100644 --- a/packages/@expo/agent-cli/src/device/simulators.ts +++ b/packages/@expo/agent-cli/src/device/simulators.ts @@ -86,6 +86,23 @@ export function parseSimulators(stdout: string): SimulatorEntry[] { /** Every booted iOS simulator in `simctl list devices booted -j`, in the order `simctl` lists them. */ export function parseBootedIosSimulators(stdout: string): { udid: string; name: string }[] { + return parseBooted(stdout, (runtime) => runtime.includes('.iOS-')); +} + +/** + * Every booted simulator in `simctl list devices booted -j`, of every runtime. + * + * 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. + */ +export function parseBootedSimulators(stdout: string): { udid: string; name: string }[] { + return parseBooted(stdout, () => true); +} + +function parseBooted( + stdout: string, + keepRuntime: (runtime: string) => boolean +): { udid: string; name: string }[] { let parsed: { devices?: Record }; try { parsed = JSON.parse(stdout); @@ -95,7 +112,7 @@ export function parseBootedIosSimulators(stdout: string): { udid: string; name: const simulators: { udid: string; name: string }[] = []; for (const [runtime, devices] of Object.entries(parsed.devices ?? {})) { - if (!runtime.includes('.iOS-') || !Array.isArray(devices)) { + if (!keepRuntime(runtime) || !Array.isArray(devices)) { continue; } for (const device of devices) { diff --git a/packages/@expo/agent-cli/src/lint/__tests__/foreignFlags-test.ts b/packages/@expo/agent-cli/src/lint/__tests__/foreignFlags-test.ts index c767b77bd..d7f4b0d0c 100644 --- a/packages/@expo/agent-cli/src/lint/__tests__/foreignFlags-test.ts +++ b/packages/@expo/agent-cli/src/lint/__tests__/foreignFlags-test.ts @@ -187,7 +187,7 @@ describe('the flags this CLI writes onto a command line', () => { "-j src/deviceBinding/ios.ts", "-j src/deviceBinding/ios.ts", "-j src/deviceBinding/ios.ts", - "-j src/runtime/targetPlatform.ts", + "-j src/runtime/targetSelection.ts", "-l src/deviceBinding/emulator.ts", "-list-avds src/deviceBinding/emulator.ts", "-nP src/dev/portListener.ts", 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 21b0d4d33..bd4e03d2a 100644 --- a/packages/@expo/agent-cli/src/navigate/__tests__/device-test.ts +++ b/packages/@expo/agent-cli/src/navigate/__tests__/device-test.ts @@ -3,7 +3,7 @@ import { EventEmitter } from 'events'; import { vol } from 'memfs'; import path from 'path'; -import { parseBootedIosSimulators } from '../../device/simulators'; +import { parseBootedIosSimulators, parseBootedSimulators } from '../../device/simulators'; import { bindingPathFor } from '../../deviceBinding/registry'; import type { Binding } from '../../deviceBinding'; import { writeCloudBinding } from '../../deviceBinding/__tests__/fakeCloudBinding'; @@ -122,6 +122,16 @@ describe(parseBootedIosSimulators, () => { }); }); +describe(parseBootedSimulators, () => { + 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([ + 'Apple Watch', + 'iPhone 17', + 'iPad', + ]); + }); +}); + // @ref llp/0035-one-device-per-worktree.rfc.md §Readers describe(resolveDeviceAsync, () => { it(`should use the bound iOS simulator when --ios is given, and extend its lease`, async () => { diff --git a/packages/@expo/agent-cli/src/navigate/openRoute.ts b/packages/@expo/agent-cli/src/navigate/openRoute.ts index 78f0d1a24..c6402f9d9 100644 --- a/packages/@expo/agent-cli/src/navigate/openRoute.ts +++ b/packages/@expo/agent-cli/src/navigate/openRoute.ts @@ -11,6 +11,7 @@ // So the composition lives here and both callers use it. `navigateAsync` is this function plus the // event, the follow-ups and the two output channels; `smoke` is this function inside a phase. +import { resolveTargetSelectionAsync } from '../runtime/resolveTargetSelection'; import { classifyDevServerHost, isTunnelCurrent, @@ -43,11 +44,7 @@ import { readConfiguredAppId, resolveAppId } from '../runtime/appId'; import { stopAppOnDeviceAsync } from '../runtime/appProcess'; import { probeDevServerAsync, type DevServerSource } from '../runtime/devServer'; import { discoverDevServerAsync } from '../runtime/discoverDevServer'; -import { - buildDeviceNameIndexAsync, - buildDeviceNameIndexIfNeededAsync, - scopeTargets, -} from '../runtime/targetPlatform'; +import { readDeviceInventoryAsync } from '../runtime/targetSelection'; import { CommandError } from '../utils/errors'; import { reverseLoopbackPortAsync, type ReverseResult } from './adbReverse'; import { buildConnectUrls, type ConnectUrl } from './connectUrl'; @@ -391,15 +388,13 @@ export async function resolveRouteUrlAsync( // Scoped to the platform that was named, for the reason every other reading command is (F51): // a dev server with an iOS simulator already attached would otherwise decide an `--android` run's // target app from the simulator's registration. Nothing on the named platform leaves the decision - // to the project's own shape, which is the honest fallback. The index costs no subprocess unless - // a target cannot be placed by its app id, which Expo Go always can. - const decisionTargets = platform - ? scopeTargets( - devServer.targets, - platform, - await buildDeviceNameIndexIfNeededAsync(devServer.targets) - ).matched - : devServer.targets; + // to the project's own shape, which is the honest fallback. Expo Go costs no device-tool spawn. + const { classifiedTargets } = await resolveTargetSelectionAsync({ + projectRoot: null, + flags: { platform, eas: options.cloud === 'required' }, + targets: devServer.targets, + }); + const decisionTargets = classifiedTargets.read; const target = decideExpoGoTarget({ appIdOverride: appId, @@ -742,8 +737,8 @@ 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-name index that scoping needs is - * built once rather than per open. + * ask the same question of the same dev server, and the device facts that scoping needs are read + * once rather than per open. * * @returns how many targets this platform had when the wait ended: zero means the deadline won. */ @@ -751,10 +746,16 @@ async function attachWaiterAsync( devServerUrl: string, platform: NavigatePlatform ): Promise<(deadline: number) => Promise> { - const index = await buildDeviceNameIndexAsync(); + const deviceInventory = await readDeviceInventoryAsync(); const countAsync = async (): Promise => { const probe = await probeDevServerAsync(devServerUrl); - return probe.reachable ? scopeTargets(probe.targets, platform, index).matched.length : 0; + const { classifiedTargets } = await resolveTargetSelectionAsync({ + projectRoot: null, + flags: { platform, eas: false }, + targets: probe.targets, + deviceInventory, + }); + return probe.reachable ? classifiedTargets.read.length : 0; }; return async (deadline: number): Promise => { for (;;) { diff --git a/packages/@expo/agent-cli/src/navigate/target.ts b/packages/@expo/agent-cli/src/navigate/target.ts index d4d551034..7486be6ca 100644 --- a/packages/@expo/agent-cli/src/navigate/target.ts +++ b/packages/@expo/agent-cli/src/navigate/target.ts @@ -8,6 +8,10 @@ /** Expo Go application ids, per platform. */ export const EXPO_GO_APP_IDS = ['host.exp.Exponent', 'host.exp.exponent']; +export function isExpoGoAppId(appId: string | null | undefined): appId is string { + return appId != null && EXPO_GO_APP_IDS.includes(appId); +} + export interface ExpoGoDecisionInput { /** Application id passed with `--app-id`, which the user knows better than we do. */ appIdOverride?: string | null; diff --git a/packages/@expo/agent-cli/src/runtime/__tests__/cdpClient-test.ts b/packages/@expo/agent-cli/src/runtime/__tests__/cdpClient-test.ts index efa1501d1..daac4f5f8 100644 --- a/packages/@expo/agent-cli/src/runtime/__tests__/cdpClient-test.ts +++ b/packages/@expo/agent-cli/src/runtime/__tests__/cdpClient-test.ts @@ -15,6 +15,7 @@ import { } from '../cdpClient'; import { MockWebSocket } from './MockWebSocket'; import LIVE from './fixtures/live-promise-frames.json'; +import { EVERY_TARGET_CLIENT } from './targetSelectionFixtures'; const TARGET = { id: '1', @@ -29,6 +30,7 @@ const TARGET = { function createClient(responder: (request: any, socket: MockWebSocket) => void): CdpClient { return new CdpClient({ + ...EVERY_TARGET_CLIENT, metroUrl: 'http://localhost:8081', targetSelector: async () => TARGET, createWebSocket: (url) => new MockWebSocket(url, responder) as unknown as WebSocketImpl, @@ -213,7 +215,7 @@ describe('CdpClient.evaluateAsync', () => { json: async () => [], })) as unknown as typeof fetch; - const client = new CdpClient({ metroUrl: 'http://localhost:8081' }); + const client = new CdpClient({ ...EVERY_TARGET_CLIENT, metroUrl: 'http://localhost:8081' }); await expect(client.evaluateAsync('1')).rejects.toThrow(/Failed to fetch debugger targets/); }); @@ -223,7 +225,7 @@ describe('CdpClient.evaluateAsync', () => { json: async () => [], })) as unknown as typeof fetch; - const client = new CdpClient({ metroUrl: 'http://localhost:8081' }); + const client = new CdpClient({ ...EVERY_TARGET_CLIENT, metroUrl: 'http://localhost:8081' }); await expect(client.evaluateAsync('1')).rejects.toThrow(/No target found/); }); @@ -239,6 +241,7 @@ describe('CdpClient.evaluateAsync', () => { })) as unknown as typeof fetch; const client = new CdpClient({ + ...EVERY_TARGET_CLIENT, metroUrl: 'http://localhost:8081', targetSelector: async (targets) => targets[0] ?? null, createWebSocket: (url) => @@ -262,7 +265,11 @@ describe('CdpClient.evaluateAsync', () => { json: async () => [], })) as unknown as typeof fetch; - const client = new CdpClient({ metroUrl: 'http://localhost:8081', targetRetryMs: 300 }); + const client = new CdpClient({ + ...EVERY_TARGET_CLIENT, + metroUrl: 'http://localhost:8081', + targetRetryMs: 300, + }); await expect(client.evaluateAsync('1')).rejects.toThrow(/No target found/); }); }); 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 8d8827176..da1d74ca8 100644 --- a/packages/@expo/agent-cli/src/runtime/__tests__/preflight-test.ts +++ b/packages/@expo/agent-cli/src/runtime/__tests__/preflight-test.ts @@ -5,6 +5,7 @@ import { EXIT_OUTCOME_TIMEOUT } from '../../exitCodes'; import { APP_RECONNECT_GRACE_MS } from '../devServer'; import { preflightRuntimeAsync, reachTheAppLadder } from '../preflight'; +import { makeDeviceInventory } from './targetSelectionFixtures'; const TARGET = { id: '1', @@ -64,7 +65,7 @@ describe(preflightRuntimeAsync, () => { named: true, reachable: true, targets: [TARGET], - appTargets: [TARGET], + classifiedTargets: { read: [TARGET], otherPlatform: [], undetermined: [] }, }); }); @@ -86,7 +87,7 @@ describe(preflightRuntimeAsync, () => { platform: 'android', }); - expect(connection.appTargets).toEqual([ANDROID_TARGET]); + expect(connection.classifiedTargets.read).toEqual([ANDROID_TARGET]); // Both lists are kept: the report owes the reader what else was attached. expect(connection.targets).toHaveLength(2); }); @@ -208,7 +209,7 @@ describe(`${preflightRuntimeAsync.name} with no app connected`, () => { retryMs: 2000, }); - expect(connection.appTargets).toEqual([TARGET]); + expect(connection.classifiedTargets.read).toEqual([TARGET]); }); it(`should say how long it kept asking`, async () => { @@ -229,7 +230,7 @@ describe(`${preflightRuntimeAsync.name} with no app connected`, () => { const connection = await preflightRuntimeAsync({ need: 'dev-server', devServerUrl }); expect(connection.reachable).toBe(true); - expect(connection.appTargets).toEqual([]); + expect(connection.classifiedTargets.read).toEqual([]); }); // @ref llp/0010-agent-conventions.rfc.md §An empty target list is inconclusive — **F141.** A @@ -288,7 +289,7 @@ describe(`${preflightRuntimeAsync.name} with no app connected`, () => { const connection = await preflightRuntimeAsync({ need: 'debugger-target', devServerUrl }); - expect(connection.appTargets).toEqual([TARGET]); + expect(connection.classifiedTargets.read).toEqual([TARGET]); }); // A command that needs no runtime is not made to wait for one: `runtime:reload` can start the app @@ -367,7 +368,7 @@ describe(`${preflightRuntimeAsync.name} scoped to a platform`, () => { devServerUrl, platform: 'android', // Empty on purpose: the device tools have nothing to say about this device either. - deviceIndex: { iosNames: [], androidModels: [] }, + deviceInventory: makeDeviceInventory(), }).catch((e) => e); expect(error.code).toBe('NO_APP_CONNECTED'); diff --git a/packages/@expo/agent-cli/src/runtime/__tests__/runtimeAsync-test.ts b/packages/@expo/agent-cli/src/runtime/__tests__/runtimeAsync-test.ts index 540278903..6ec8f303f 100644 --- a/packages/@expo/agent-cli/src/runtime/__tests__/runtimeAsync-test.ts +++ b/packages/@expo/agent-cli/src/runtime/__tests__/runtimeAsync-test.ts @@ -10,6 +10,7 @@ import { import { runtimeErrorsAsync, runtimeEvalAsync } from '../runtimeAsync'; import { CdpRuntimeErrorCollector } from '../runtimeErrorCollector'; import { UNTRUSTED_OUTPUT_BEGIN } from '../untrusted'; +import { EVERY_TARGET_CLIENT } from './targetSelectionFixtures'; // The real error class and the real code are kept: the command branches on both. vi.mock('../cdpClient', async () => ({ @@ -81,7 +82,7 @@ describe(runtimeEvalAsync, () => { await expect(runtimeEvalAsync(evalOptions)).resolves.toBe(0); - expect(CdpClient).toHaveBeenCalledWith({ metroUrl: devServerUrl }); + expect(CdpClient).toHaveBeenCalledWith({ ...EVERY_TARGET_CLIENT, metroUrl: devServerUrl }); expect(evaluateAsync).toHaveBeenCalledWith('globalThis.count', { awaitPromise: true, timeoutMs: 5000, @@ -293,6 +294,7 @@ describe(runtimeErrorsAsync, () => { await expect(runtimeErrorsAsync(errorsOptions)).resolves.toBe(0); expect(CdpRuntimeErrorCollector).toHaveBeenCalledWith({ + ...EVERY_TARGET_CLIENT, metroUrl: devServerUrl, durationMs: 2000, // The reconnect grace period, so the command the CLI names straight after a reload does not diff --git a/packages/@expo/agent-cli/src/runtime/__tests__/runtimeErrorCollector-test.ts b/packages/@expo/agent-cli/src/runtime/__tests__/runtimeErrorCollector-test.ts index 527467015..cf3df832f 100644 --- a/packages/@expo/agent-cli/src/runtime/__tests__/runtimeErrorCollector-test.ts +++ b/packages/@expo/agent-cli/src/runtime/__tests__/runtimeErrorCollector-test.ts @@ -2,7 +2,14 @@ import { EventEmitter } from 'events'; import type WebSocketImpl from 'ws'; import { CdpRuntimeErrorCollector, parseRuntimeErrorMessage } from '../runtimeErrorCollector'; +import { makeTargetSelection } from './targetSelectionFixtures'; import { MockWebSocket } from './MockWebSocket'; +import { makeDeviceInventory, EVERY_TARGET_CLIENT } from './targetSelectionFixtures'; + +const ANDROID_AND_IOS_FACTS = makeDeviceInventory({ + booted: [{ name: 'iPhone 17 Pro', udid: 'SIM-1' }], + androidModels: ['sdk_gphone64_arm64'], +}); const TARGET = { id: '1', @@ -124,6 +131,7 @@ describe('CdpRuntimeErrorCollector.collectAsync', () => { it(`should capture exceptions and console errors, and skip other console output`, async () => { const requests: any[] = []; const collector = new CdpRuntimeErrorCollector({ + ...EVERY_TARGET_CLIENT, metroUrl: 'http://localhost:8081', targetSelector: async () => TARGET, durationMs: 20, @@ -207,6 +215,7 @@ describe('CdpRuntimeErrorCollector.collectAsync', () => { it(`should resolve with the errors seen so far when the app disconnects`, async () => { const collector = new CdpRuntimeErrorCollector({ + ...EVERY_TARGET_CLIENT, metroUrl: 'http://localhost:8081', targetSelector: async () => TARGET, durationMs: 5000, @@ -231,12 +240,16 @@ describe('CdpRuntimeErrorCollector.collectAsync', () => { json: async () => [], })) as unknown as typeof fetch; - const collector = new CdpRuntimeErrorCollector({ metroUrl: 'http://localhost:8081' }); + const collector = new CdpRuntimeErrorCollector({ + ...EVERY_TARGET_CLIENT, + metroUrl: 'http://localhost:8081', + }); await expect(collector.collectAsync()).rejects.toThrow(/No target found/); }); it(`should reject when the debugger connection does not open in time`, async () => { const collector = new CdpRuntimeErrorCollector({ + ...EVERY_TARGET_CLIENT, metroUrl: 'http://localhost:8081', targetSelector: async () => TARGET, timeoutMs: 20, @@ -248,6 +261,7 @@ describe('CdpRuntimeErrorCollector.collectAsync', () => { it(`should ignore a message that is not JSON instead of failing the window`, async () => { const collector = new CdpRuntimeErrorCollector({ + ...EVERY_TARGET_CLIENT, metroUrl: 'http://localhost:8081', targetSelector: async () => TARGET, durationMs: 20, @@ -295,10 +309,10 @@ describe('CdpRuntimeErrorCollector.collectAsync', () => { const collector = new CdpRuntimeErrorCollector({ metroUrl: 'http://localhost:8081', durationMs: 20, - platform: 'android', - // The index this machine's own device tools would report, which is the strongest of the three - // pieces of evidence `platformOfTarget` ranks. - deviceIndex: { iosNames: ['iPhone 17 Pro'], androidModels: ['sdk_gphone64_arm64'] }, + targetSelection: makeTargetSelection({ platform: { value: 'android', by: 'flag' } }), + // The facts this machine's own device tools would report, which are the strongest of the + // three pieces of evidence `platformOfTarget` ranks. + deviceInventory: ANDROID_AND_IOS_FACTS, createWebSocket: (url) => { opened.push(url); return mixedPlatformSocket(url); @@ -332,8 +346,8 @@ describe('CdpRuntimeErrorCollector.collectAsync', () => { const collector = new CdpRuntimeErrorCollector({ metroUrl: 'http://localhost:8081', durationMs: 20, - platform: 'android', - deviceIndex: { iosNames: ['iPhone 17 Pro'], androidModels: ['sdk_gphone64_arm64'] }, + targetSelection: makeTargetSelection({ platform: { value: 'android', by: 'flag' } }), + deviceInventory: ANDROID_AND_IOS_FACTS, createWebSocket: (url) => new MockWebSocket(url, () => {}) as unknown as WebSocketImpl, }); diff --git a/packages/@expo/agent-cli/src/runtime/__tests__/stopAsync-test.ts b/packages/@expo/agent-cli/src/runtime/__tests__/stopAsync-test.ts index a3b1b26ce..e9bbd55e8 100644 --- a/packages/@expo/agent-cli/src/runtime/__tests__/stopAsync-test.ts +++ b/packages/@expo/agent-cli/src/runtime/__tests__/stopAsync-test.ts @@ -309,7 +309,7 @@ describe(runtimeStopAsync, () => { // The mechanism is one line: `resolveAppId` took `targetAppIds.find(Boolean)` off the *unscoped* // target list, and Expo Go's two application ids differ by one capital letter (llp/0005 §The dev // server does not label its targets). The dev server happened to list iOS first. So this command - // now reads `appTargets` — the platform-scoped list the preflight already builds for every other + // now reads `classifiedTargets.read` — the platform-scoped list the preflight already builds for every other // command in the family — and the ids in its report are the ones on the device it acted on. it(`resolves the application id from the platform it is stopping, not the first one listed (F101)`, async () => { Object.defineProperty(process, 'platform', { value: 'linux' }); diff --git a/packages/@expo/agent-cli/src/runtime/__tests__/targetPlatform-test.ts b/packages/@expo/agent-cli/src/runtime/__tests__/targetPlatform-test.ts index 6df4a0096..27852cd4c 100644 --- a/packages/@expo/agent-cli/src/runtime/__tests__/targetPlatform-test.ts +++ b/packages/@expo/agent-cli/src/runtime/__tests__/targetPlatform-test.ts @@ -1,11 +1,6 @@ import type { CdpTarget } from '../cdpClient'; -import { - bootedSimulatorNames, - EMPTY_DEVICE_INDEX, - platformOfTarget, - platformsOfTargets, - scopeTargets, -} from '../targetPlatform'; +import { platformOfTarget, platformsOfTargets } from '../targetPlatform'; +import { makeDeviceInventory } from './targetSelectionFixtures'; /** * The live payload of one dev server with an iOS simulator and an Android emulator both attached @@ -32,20 +27,23 @@ describe(platformOfTarget, () => { it('prefers a booted simulator this machine just named', () => { const verdict = platformOfTarget( { appId: 'com.example.app', deviceName: 'iPhone 17 Pro' }, - { iosNames: ['iPhone 17 Pro'], androidModels: [] } + makeDeviceInventory({ booted: [{ name: 'iPhone 17 Pro', udid: 'SIM-1' }] }) ); expect(verdict.platform).toBe('ios'); expect(verdict.evidence).toContain('booted iOS simulator'); }); it('matches an attached Android device by its model, with or without the API suffix', () => { - const index = { iosNames: [], androidModels: ['Pixel 7'] }; + const deviceInventory = makeDeviceInventory({ androidModels: ['Pixel 7'] }); expect( - platformOfTarget({ appId: 'com.example.app', deviceName: 'Pixel 7' }, index).platform + platformOfTarget({ appId: 'com.example.app', deviceName: 'Pixel 7' }, deviceInventory) + .platform ).toBe('android'); expect( - platformOfTarget({ appId: 'com.example.app', deviceName: 'Pixel 7 - 15 - API 35' }, index) - .platform + platformOfTarget( + { appId: 'com.example.app', deviceName: 'Pixel 7 - 15 - API 35' }, + deviceInventory + ).platform ).toBe('android'); }); @@ -60,17 +58,6 @@ describe(platformOfTarget, () => { }); }); -describe(scopeTargets, () => { - it('keeps an undetermined target out of the matched set', () => { - const unknown = { appId: 'com.example.app', deviceName: 'Ada’s phone' } as CdpTarget; - const scoped = scopeTargets([...LIVE_TARGETS, unknown], 'android', EMPTY_DEVICE_INDEX); - - expect(scoped.matched).toEqual([androidTarget]); - expect(scoped.otherPlatform).toEqual([{ target: iosTarget, platform: 'ios' }]); - expect(scoped.undetermined).toEqual([unknown]); - }); -}); - describe(platformsOfTargets, () => { it('reports both platforms of a mixed dev server', () => { expect(platformsOfTargets(LIVE_TARGETS)).toEqual({ @@ -88,21 +75,3 @@ describe(platformsOfTargets, () => { }); }); }); - -describe(bootedSimulatorNames, () => { - it('reads every booted runtime, so a watch simulator is not mistaken for something else', () => { - const names = bootedSimulatorNames( - JSON.stringify({ - devices: { - 'com.apple.CoreSimulator.SimRuntime.iOS-26-5': [{ udid: 'A', name: 'iPhone 17 Pro' }], - 'com.apple.CoreSimulator.SimRuntime.watchOS-26-0': [{ udid: 'B', name: 'Apple Watch' }], - }, - }) - ); - expect(names).toEqual(['iPhone 17 Pro', 'Apple Watch']); - }); - - it('answers nothing for output that is not JSON', () => { - expect(bootedSimulatorNames('xcrun: error')).toEqual([]); - }); -}); 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..3a6de3b20 --- /dev/null +++ b/packages/@expo/agent-cli/src/runtime/__tests__/targetSelection-test.ts @@ -0,0 +1,40 @@ +import type { CdpTarget } from '../cdpClient'; +import { classifyTargets } from '../targetSelection'; +import { makeTargetSelection } from './targetSelectionFixtures'; +import { makeDeviceInventory } from './targetSelectionFixtures'; + +/** One dev server with an iOS simulator and an Android emulator attached [captured — 2026-08-25]. */ +const LIVE_TARGETS: CdpTarget[] = require('./fixtures/json-list-ios-and-android.json'); + +const ios = LIVE_TARGETS.find((target) => target.appId === 'host.exp.Exponent')!; +const android = LIVE_TARGETS.find((target) => target.appId === 'host.exp.exponent')!; +const unknown = { appId: 'com.example.app', deviceName: 'Ada’s phone' } as CdpTarget; + +describe(classifyTargets, () => { + it.each([ + { + name: 'a platform scope keeps an undetermined target out of the read set', + platform: 'android' as const, + read: [android], + otherPlatform: [{ target: ios, platform: 'ios' }], + undetermined: [unknown], + }, + { + name: 'a scope with no platform reads every target', + platform: null, + read: [...LIVE_TARGETS, unknown], + otherPlatform: [], + undetermined: [], + }, + ])('$name', ({ platform, read, otherPlatform, undetermined }) => { + expect( + classifyTargets( + [...LIVE_TARGETS, unknown], + makeTargetSelection({ + platform: platform == null ? null : { value: platform, by: 'flag' }, + }), + makeDeviceInventory() + ) + ).toEqual({ read, otherPlatform, undetermined }); + }); +}); diff --git a/packages/@expo/agent-cli/src/runtime/__tests__/targetSelectionFixtures.ts b/packages/@expo/agent-cli/src/runtime/__tests__/targetSelectionFixtures.ts new file mode 100644 index 000000000..5f621ea61 --- /dev/null +++ b/packages/@expo/agent-cli/src/runtime/__tests__/targetSelectionFixtures.ts @@ -0,0 +1,11 @@ +import { READ_EVERY_TARGET, type DeviceInventory, type TargetSelection } from '../targetSelection'; +export function makeDeviceInventory(overrides: Partial = {}): DeviceInventory { + return { booted: [], androidModels: [], ...overrides }; +} +export function makeTargetSelection(overrides: Partial = {}): TargetSelection { + return { ...READ_EVERY_TARGET, ...overrides }; +} +export const EVERY_TARGET_CLIENT = { + targetSelection: READ_EVERY_TARGET, + deviceInventory: makeDeviceInventory(), +}; 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 9a576c1ed..694796197 100644 --- a/packages/@expo/agent-cli/src/runtime/__tests__/waitReady-test.ts +++ b/packages/@expo/agent-cli/src/runtime/__tests__/waitReady-test.ts @@ -14,6 +14,12 @@ import { waitForFreshAppConnectionAsync, } from '../waitReady'; import { matchProjectRoot } from '../projectRootHeader'; +import { EVERY_TARGET_CLIENT } from './targetSelectionFixtures'; + +// A target nothing places makes the attachment wait ask the device tools; they answer nothing here. +vi.mock('../../utils/spawnCapture', () => ({ + spawnCaptureAsync: vi.fn(async () => ({ exitCode: 1, stdout: '', stderr: '' })), +})); type StatusHandler = (request: { url: string }) => { status?: number; @@ -419,7 +425,12 @@ describe(waitForAppConnectionAsync, () => { it(`returns as soon as a target is listed`, async () => { const url = await startServerAsync(() => ({ body: JSON.stringify([{ id: '1' }]) })); - const result = await waitForAppConnectionAsync(url, { timeoutMs: 2000, intervalMs: 10 }); + const result = await waitForAppConnectionAsync(url, { + ...EVERY_TARGET_CLIENT, + inventoryTargets: [], + timeoutMs: 2000, + intervalMs: 10, + }); expect(result).toMatchObject({ appsConnected: 1, timedOut: false }); }); @@ -431,7 +442,12 @@ describe(waitForAppConnectionAsync, () => { body: JSON.stringify(attached ? [{ id: '1' }] : []), })); - const result = await waitForAppConnectionAsync(url, { timeoutMs: 2000, intervalMs: 20 }); + const result = await waitForAppConnectionAsync(url, { + ...EVERY_TARGET_CLIENT, + inventoryTargets: [], + timeoutMs: 2000, + intervalMs: 20, + }); expect(result).toMatchObject({ appsConnected: 1, timedOut: false }); }); @@ -439,7 +455,12 @@ describe(waitForAppConnectionAsync, () => { it(`times out when no app ever attaches`, async () => { const url = await startServerAsync(() => ({ body: '[]' })); - const result = await waitForAppConnectionAsync(url, { timeoutMs: 120, intervalMs: 20 }); + const result = await waitForAppConnectionAsync(url, { + ...EVERY_TARGET_CLIENT, + inventoryTargets: [], + timeoutMs: 120, + intervalMs: 20, + }); expect(result).toMatchObject({ appsConnected: 0, timedOut: true }); }); @@ -458,6 +479,7 @@ describe(waitForFreshAppConnectionAsync, () => { })); const result = await waitForFreshAppConnectionAsync(url, { + ...EVERY_TARGET_CLIENT, timeoutMs: 2000, intervalMs: 20, knownTargetIds: ['device-1'], @@ -470,6 +492,7 @@ describe(waitForFreshAppConnectionAsync, () => { const url = await startServerAsync(() => ({ body: JSON.stringify([{ id: 'device-1' }]) })); const result = await waitForFreshAppConnectionAsync(url, { + ...EVERY_TARGET_CLIENT, timeoutMs: 120, intervalMs: 20, knownTargetIds: ['device-1'], @@ -485,6 +508,7 @@ describe(waitForFreshAppConnectionAsync, () => { const url = await startServerAsync(() => ({ body: JSON.stringify([{ id: 'device-9' }]) })); const result = await waitForFreshAppConnectionAsync(url, { + ...EVERY_TARGET_CLIENT, timeoutMs: 2000, intervalMs: 20, knownTargetIds: [], @@ -498,6 +522,7 @@ describe(waitForFreshAppConnectionAsync, () => { const url = await startServerAsync(() => ({ body: '[]' })); const result = await waitForFreshAppConnectionAsync(url, { + ...EVERY_TARGET_CLIENT, timeoutMs: 120, intervalMs: 20, knownTargetIds: ['device-1'], diff --git a/packages/@expo/agent-cli/src/runtime/bundleCheck.ts b/packages/@expo/agent-cli/src/runtime/bundleCheck.ts index b2fbeb623..0a2949cc8 100644 --- a/packages/@expo/agent-cli/src/runtime/bundleCheck.ts +++ b/packages/@expo/agent-cli/src/runtime/bundleCheck.ts @@ -35,6 +35,7 @@ import { stripVTControlCharacters } from 'util'; +import type { TargetSelection, DeviceInventory } from './targetSelection'; import type { CdpTarget } from './cdpClient'; import { normalizeDevServerUrl } from './devServer'; @@ -64,6 +65,7 @@ export const DEFAULT_BUNDLE_CHECK_PLATFORM: BundleCheckPlatform = 'ios'; export type BundlePlatformSource = /** The caller named it. */ | 'flag' + | 'device' /** It is the platform (or platforms) of the apps connected to the dev server. */ | 'connected-app' /** Nothing named one and no connected app could be placed, so {@link DEFAULT_BUNDLE_CHECK_PLATFORM} stands. */ @@ -82,19 +84,18 @@ export type BundlePlatformSource = * @param targets the dev server's debugger targets, as `/json/list` reported them. */ export async function resolveBundleCheckPlatformsAsync( - named: BundleCheckPlatform | null, + targetSelection: TargetSelection, targets: CdpTarget[], - fallback: BundleCheckPlatform = DEFAULT_BUNDLE_CHECK_PLATFORM + { + deviceInventory, + fallback = DEFAULT_BUNDLE_CHECK_PLATFORM, + }: { deviceInventory: DeviceInventory; fallback?: BundleCheckPlatform } ): Promise<{ platforms: BundleCheckPlatform[]; source: BundlePlatformSource }> { - if (named != null) { - return { platforms: [named], source: 'flag' }; + if (targetSelection.platform != null) { + return { platforms: [targetSelection.platform.value], source: targetSelection.platform.by }; } - const { buildDeviceNameIndexIfNeededAsync, platformsOfTargets } = - require('./targetPlatform') as typeof import('./targetPlatform'); - const { platforms } = platformsOfTargets( - targets, - await buildDeviceNameIndexIfNeededAsync(targets) - ); + const { platformsOfTargets } = require('./targetPlatform') as typeof import('./targetPlatform'); + const { platforms } = platformsOfTargets(targets, deviceInventory); return platforms.length > 0 ? { platforms, source: 'connected-app' } : { platforms: [fallback], source: 'default' }; diff --git a/packages/@expo/agent-cli/src/runtime/cdpClient.ts b/packages/@expo/agent-cli/src/runtime/cdpClient.ts index a70224778..46d838f01 100644 --- a/packages/@expo/agent-cli/src/runtime/cdpClient.ts +++ b/packages/@expo/agent-cli/src/runtime/cdpClient.ts @@ -18,11 +18,11 @@ import { wrapExpressionForPromises, } from './promiseSettling'; import { - buildDeviceNameIndexIfNeededAsync, - scopeTargets, - type DeviceNameIndex, - type ScopedTargets, -} from './targetPlatform'; + classifyTargets, + type DeviceInventory, + type ClassifiedTargets, + type TargetSelection, +} from './targetSelection'; /** * Metro's inspector proxy rejects WebSocket handshakes without a same-origin `Origin` @@ -191,17 +191,17 @@ export interface CdpClientOptions { */ targetRetryMs?: number; /** - * Talk only to an app on this platform. + * Talk only to an app this scope reads. * - * @ref ./targetPlatform — friction run 6's F51. Every reading command took the first target the + * @ref ./targetSelection — friction run 6's F51. Every reading command took the first target the * selector accepted, so a run told `--android` on a dev server that also had an iOS simulator * attached evaluated its expression, and earned its verdict, in the **simulator**. The list is * narrowed before the selector rather than inside it, so "which target answers the debugger" and * "which platform is this run about" stay two separate questions. */ - platform?: NavigatePlatform; - /** What this machine's device tools reported, for {@link platform}. */ - deviceIndex?: DeviceNameIndex; + targetSelection: TargetSelection; + /** What this machine's device tools reported, for placing targets in {@link targetSelection}. */ + deviceInventory: DeviceInventory; } /** The failure for a platform-scoped client that found no target of its platform. */ @@ -255,23 +255,18 @@ export class CdpClient { this.options.targetSelector ?? createDefaultTargetSelector({ createWebSocket: this.options.createWebSocket }); - // Read once: what this machine has attached does not change while a reconnect runs, and asking - // the device tools on every poll would be a subprocess every 250 ms. - const index = - this.options.platform == null - ? null - : (this.options.deviceIndex ?? - (await buildDeviceNameIndexIfNeededAsync(await this.listTargetsAsync()))); - // Re-read the list rather than re-run the selector over the same array: what changes during a // reconnect is which targets the dev server lists, not what this can make of a given one. const deadline = Date.now() + (this.options.targetRetryMs ?? 0); let target: CdpTarget | null = null; - let scope: ScopedTargets | null = null; + let classifiedTargets: ClassifiedTargets; for (;;) { - const listed = await this.listTargetsAsync(); - scope = index == null ? null : scopeTargets(listed, this.options.platform!, index); - target = await selector(scope ? scope.matched : listed); + classifiedTargets = classifyTargets( + await this.listTargetsAsync(), + this.options.targetSelection, + this.options.deviceInventory + ); + target = await selector(classifiedTargets.read); if (target || Date.now() >= deadline) { break; } @@ -280,11 +275,12 @@ export class CdpClient { if (!target) { // 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). - if (scope && scope.matched.length === 0) { + const { platform } = this.options.targetSelection; + if (platform != null && classifiedTargets.read.length === 0) { throw new NoTargetOnPlatformError( - this.options.platform!, - [...new Set(scope.otherPlatform.map((entry) => entry.platform))], - scope.undetermined.length + platform.value, + [...new Set(classifiedTargets.otherPlatform.map((entry) => entry.platform))], + classifiedTargets.undetermined.length ); } throw new Error('No target found.'); diff --git a/packages/@expo/agent-cli/src/runtime/interact/interactAsync.ts b/packages/@expo/agent-cli/src/runtime/interact/interactAsync.ts index 45faaf769..b65279303 100644 --- a/packages/@expo/agent-cli/src/runtime/interact/interactAsync.ts +++ b/packages/@expo/agent-cli/src/runtime/interact/interactAsync.ts @@ -142,7 +142,7 @@ async function openAppAsync( }, context: RuntimeContext ): Promise { - const { devServerUrl, targets, deviceIndex } = await preflightRuntimeAsync( + const { devServerUrl, targets, targetSelection, deviceInventory } = await preflightRuntimeAsync( { need: 'debugger-target', devServerUrl: options.devServerUrl, platform: options.platform }, context ); @@ -150,7 +150,9 @@ async function openAppAsync( let bundle: BundleCheckResult | null = null; if (wantBundleCheck) { - const { platforms } = await resolveBundleCheckPlatformsAsync(options.platform ?? null, targets); + const { platforms } = await resolveBundleCheckPlatformsAsync(targetSelection, targets, { + deviceInventory, + }); const results: BundleCheckResult[] = []; for (const platform of platforms) { results.push( @@ -188,7 +190,7 @@ async function openAppAsync( return { devServerUrl, - client: new CdpClient({ metroUrl: devServerUrl, platform: options.platform, deviceIndex }), + client: new CdpClient({ metroUrl: devServerUrl, targetSelection, deviceInventory }), bundle: json, refusal, }; diff --git a/packages/@expo/agent-cli/src/runtime/preflight.ts b/packages/@expo/agent-cli/src/runtime/preflight.ts index b88776952..74b874beb 100644 --- a/packages/@expo/agent-cli/src/runtime/preflight.ts +++ b/packages/@expo/agent-cli/src/runtime/preflight.ts @@ -29,6 +29,7 @@ // question is asked for the length of that window before it is answered, and a list that is still // empty afterwards is reported as inconclusive rather than as a call to fix. +import { resolveTargetSelectionAsync } from './resolveTargetSelection'; import { EXIT_OUTCOME_TIMEOUT } from '../exitCodes'; import type { NavigatePlatform } from '../navigate/device'; import { PROGRAM_PREFIX } from '../programName'; @@ -44,10 +45,11 @@ import { } from './devServer'; import { discoverDevServerAsync } from './discoverDevServer'; import { - buildDeviceNameIndexIfNeededAsync, - scopeTargets, - type DeviceNameIndex, -} from './targetPlatform'; + EMPTY_DEVICE_INVENTORY, + type DeviceInventory, + type ClassifiedTargets, + type TargetSelection, +} from './targetSelection'; /** * Where a runtime command was run, when it was run in a project. @@ -89,16 +91,12 @@ export interface RuntimeConnection { unreachableReason: string | null; /** Every debugger target it listed, unscoped — what the *report* owes a reader. */ targets: CdpTarget[]; - /** - * The targets this command may read: {@link targets} scoped to the platform the caller named. - * - * The same list when no platform was named. Kept apart from {@link targets} because a command - * told `--android` must not be answered by the iOS simulator on the same dev server (F51), while - * its report still has to say that the iOS one is there. - */ - appTargets: CdpTarget[]; - /** What this machine's device tools reported, when a platform had to be resolved. */ - deviceIndex?: DeviceNameIndex; + /** Connected targets classified once for every reader of this connection. */ + classifiedTargets: ClassifiedTargets; + /** The platform and device this command intends to read. */ + targetSelection: TargetSelection; + /** Known devices and worktree bindings used to classify {@link targets} for {@link targetSelection}. */ + deviceInventory: DeviceInventory; /** The platform the caller named, or undefined. */ platform?: NavigatePlatform; } @@ -152,11 +150,11 @@ export interface RuntimePreflightOptions { /** * What this machine's device tools reported, instead of asking them. * - * Commands never pass one — reading it from the targets that were just listed is the point of the - * preflight. It is here for a caller that already has the index, and for the tests, which must + * Commands never pass them — reading them for the targets that were just listed is the point of + * the preflight. They are here for a caller that already has them, and for the tests, which must * not spawn `simctl` and `adb` to ask about a platform. */ - deviceIndex?: DeviceNameIndex; + deviceInventory?: DeviceInventory; } /** How often the target list is re-read while the grace period runs. */ @@ -175,7 +173,7 @@ export async function preflightRuntimeAsync( platform, cloud = false, retryMs, - deviceIndex: knownDeviceIndex, + deviceInventory: knownDeviceInventory, }: RuntimePreflightOptions, context: RuntimeContext = {} ): Promise { @@ -209,6 +207,16 @@ export async function preflightRuntimeAsync( } } + const { targetSelection, deviceInventory, classifiedTargets } = await resolveTargetSelectionAsync( + { + projectRoot: context.projectRoot ?? null, + flags: { platform, eas: cloud }, + targets: reachable ? targets : [], + deviceInventory: reachable + ? knownDeviceInventory + : (knownDeviceInventory ?? EMPTY_DEVICE_INVENTORY), + } + ); const base: RuntimeConnection = { devServerUrl: url, devServerSource: discovery.source, @@ -216,13 +224,15 @@ export async function preflightRuntimeAsync( reachable, unreachableReason: reachable ? null : unreachableReason, targets, - appTargets: targets, + classifiedTargets, + targetSelection, + deviceInventory, platform, }; if (!reachable) { if (need === 'optional') { - return { ...base, targets: [], appTargets: [] }; + return { ...base, targets: [] }; } throw noDevServerError({ devServerUrl: url, @@ -234,26 +244,7 @@ export async function preflightRuntimeAsync( }); } - // The device index is built from the list that was already read, so the platform this command was - // told about is the platform of the app it reads — the two steps cannot disagree about which app - // that is (F51, F53). It costs a subprocess only when a target's platform cannot be read off its - // application id or its device name, which for Expo Go is never. - const deviceIndex = - platform == null - ? undefined - : (knownDeviceIndex ?? (await buildDeviceNameIndexIfNeededAsync(targets))); - - // Scoped for **every** caller that named a platform, whatever it needs, and that is F101. This - // used to sit below the `need` branch, so `appTargets` — documented as the list a command may read - // — was the unscoped list for `runtime:stop` (`optional`) and `runtime:reload` (`dev-server`). - // Live consequence: `runtime:stop --android` took the first application id the dev server listed, - // which was the iOS one, and force-stopped a package that is not installed on Android while - // reporting `stopped: true, wasRunning: true`. Reading the other platform's app is F51 whether or - // not the command also required one to exist. - const scoped = platform == null ? null : scopeTargets(targets, platform, deviceIndex!); - const connection: RuntimeConnection = scoped - ? { ...base, appTargets: scoped.matched, deviceIndex } - : base; + const connection = base; if (need !== 'debugger-target') { return connection; @@ -266,8 +257,8 @@ 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 (scoped && scoped.matched.length === 0) { - throw noAppOnPlatformError(url, platform!, scoped, cloud); + if (platform != null && classifiedTargets.read.length === 0) { + throw noAppOnPlatformError(url, platform, classifiedTargets, cloud); } return connection; } @@ -416,7 +407,7 @@ export function noAppConnectedError({ function noAppOnPlatformError( devServerUrl: string, platform: NavigatePlatform, - scoped: { otherPlatform: { platform: NavigatePlatform }[]; undetermined: unknown[] }, + scoped: Pick, cloud: boolean ): CommandError { const others = scoped.otherPlatform.map((entry) => entry.platform); diff --git a/packages/@expo/agent-cli/src/runtime/reload/reloadAsync.ts b/packages/@expo/agent-cli/src/runtime/reload/reloadAsync.ts index a530d7158..6639b8b15 100644 --- a/packages/@expo/agent-cli/src/runtime/reload/reloadAsync.ts +++ b/packages/@expo/agent-cli/src/runtime/reload/reloadAsync.ts @@ -68,7 +68,8 @@ import { type MessageSocketPeers, } from '../messageSocket'; import { preflightRuntimeAsync } from '../preflight'; -import { waitForFreshAppConnectionAsync } from '../waitReady'; +import { type TargetSelection, type DeviceInventory } from '../targetSelection'; +import { waitForFreshAppConnectionAsync, countConnectedAppsAsync } from '../waitReady'; import { markBundleSignalSync, waitForNewBundleAsync, @@ -330,8 +331,9 @@ export async function reloadAsync(projectRoot: string, options: ReloadOptions): // for is a question about the app being reloaded, and this used to answer it with a host-free // default: with an Android-only break and both apps attached, a `runtime:reload` with no flag // checked the **iOS** bundle, passed, and reloaded the Android app onto the broken one. + const { targetSelection, deviceInventory } = devServer; const { platforms: bundlePlatforms, source: bundlePlatformSource } = - await resolveBundleCheckPlatformsAsync(options.platform ?? null, devServer.targets); + await resolveBundleCheckPlatformsAsync(targetSelection, devServer.targets, { deviceInventory }); const bundles: BundleCheckResult[] = []; if (options.bundleCheck) { for (const platform of bundlePlatforms) { @@ -543,6 +545,8 @@ export async function reloadAsync(projectRoot: string, options: ReloadOptions): ? await waitForReloadEvidenceAsync(devServerUrl, projectRoot, { timeoutMs: remainingMs(), knownTargetIds, + targetSelection, + deviceInventory, bundleMark, // The flag the caller typed, never the resolved default: `null` here means "nobody said", // and `waitForNewBundleAsync` counts every platform for it (F126). @@ -567,9 +571,10 @@ export async function reloadAsync(projectRoot: string, options: ReloadOptions): // (`APP_RECONNECT_GRACE_MS`, F39's own), it either finds the runtime that came back — and the next // command in the chain finds it too — or it does not, and then zero is a fact about an app that is // genuinely not there and the report says so in words. + const reconnectBudget = Math.min(APP_RECONNECT_GRACE_MS, remainingMs()); const appsConnected = observed && connection.appsConnected === 0 - ? await countConnectedAppsAsync(devServerUrl, Math.min(APP_RECONNECT_GRACE_MS, remainingMs())) + ? await countConnectedAppsAsync(devServerUrl, reconnectBudget, devServer) : connection.appsConnected; // What the run may claim. The mechanism's own observation is the label when it has one, because @@ -953,7 +958,11 @@ export async function waitForReloadEvidenceAsync( knownTargetIds, bundleMark, platform, + targetSelection, + deviceInventory, }: { + targetSelection: TargetSelection; + deviceInventory: DeviceInventory; timeoutMs: number; knownTargetIds: readonly string[]; bundleMark: BundleSignalMark; @@ -969,6 +978,8 @@ export async function waitForReloadEvidenceAsync( const targets = waitForFreshAppConnectionAsync(devServerUrl, { timeoutMs, knownTargetIds, + targetSelection, + deviceInventory, signal: settled.signal, }).then((result) => { if (result.freshTargets > 0) { @@ -997,28 +1008,6 @@ export async function waitForReloadEvidenceAsync( }; } -/** - * How many debugger targets the dev server lists, asked until one appears or the budget runs out. - * - * @ref llp/0005-runtime-loop-tools.rfc.md §What proves a reload — F141. Deliberately *not* a wait - * for a **fresh** target: the question here is only "is there a runtime on this dev server now", - * which is the question every command after this reload will ask. Whether it is a new one was - * already answered above, and answering it twice would make a report of the second answer. - */ -async function countConnectedAppsAsync(devServerUrl: string, budgetMs: number): Promise { - const deadline = Date.now() + budgetMs; - for (;;) { - const probe = await probeDevServerAsync(devServerUrl); - if (probe.targets.length > 0 || Date.now() + APP_RECONNECT_POLL_MS >= deadline) { - return probe.targets.length; - } - await new Promise((resolve) => setTimeout(resolve, APP_RECONNECT_POLL_MS)); - } -} - -/** How often that question is asked again. */ -const APP_RECONNECT_POLL_MS = 200; - /** The first line of a message, for a reason that has to fit on one. */ function firstLine(text: string): string { return text.split('\n')[0]!.trim(); @@ -1153,18 +1142,17 @@ async function reloadOverRuntimeAsync( const { CdpClient, isMethodNotFoundError } = require('../cdpClient') as typeof import('../cdpClient'); - const { buildDeviceNameIndexIfNeededAsync } = - require('../targetPlatform') as typeof import('../targetPlatform'); + const { resolveTargetSelectionAsync } = + require('../resolveTargetSelection') as typeof import('../resolveTargetSelection'); let client: import('../cdpClient').CdpClient; try { - const deviceIndex = - options.platform == null - ? undefined - : await buildDeviceNameIndexIfNeededAsync( - (await probeDevServerAsync(devServerUrl)).targets - ); - client = new CdpClient({ metroUrl: devServerUrl, platform: options.platform, deviceIndex }); + 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( `could not open a debugger connection to the app: ${error instanceof Error ? firstLine(error.message) : String(error)}` diff --git a/packages/@expo/agent-cli/src/runtime/resolveTargetSelection.ts b/packages/@expo/agent-cli/src/runtime/resolveTargetSelection.ts new file mode 100644 index 000000000..cdf630edc --- /dev/null +++ b/packages/@expo/agent-cli/src/runtime/resolveTargetSelection.ts @@ -0,0 +1,51 @@ +// @ref llp/0005-runtime-loop-tools.rfc.md §One preflight for the runtime family +import type { NavigateDevice, NavigatePlatform } from '../navigate/device'; +import type { CdpTarget } from './cdpClient'; +import { + deviceInventoryForSelectionAsync, + classifyTargets, + type DeviceInventory, + type ClassifiedTargets, + type TargetSelection, +} from './targetSelection'; + +export interface ResolveTargetSelectionOptions { + projectRoot: string | null; + flags: { platform: NavigatePlatform | null | undefined; eas: boolean }; + device?: NavigateDevice | null; + targets: CdpTarget[]; + deviceInventory?: DeviceInventory; +} +export interface ResolvedTargetSelection { + targetSelection: TargetSelection; + deviceInventory: DeviceInventory; + classifiedTargets: ClassifiedTargets; +} + +/** The run's scope, from its flags and the device it opens. Reads nothing. */ +export function targetSelectionOf({ + flags, + device, +}: Pick): TargetSelection { + const platform = flags.platform ?? device?.platform; + return { + platform: + platform == null ? null : { value: platform, by: flags.platform != null ? 'flag' : 'device' }, + device: null, + }; +} + +/** Resolve one scope and place the caller's already-read target list. */ +export async function resolveTargetSelectionAsync( + options: ResolveTargetSelectionOptions +): Promise { + const targetSelection = targetSelectionOf(options); + const deviceInventory = + options.deviceInventory ?? + (await deviceInventoryForSelectionAsync(options.targets, targetSelection)); + return { + targetSelection, + deviceInventory, + classifiedTargets: classifyTargets(options.targets, targetSelection, deviceInventory), + }; +} diff --git a/packages/@expo/agent-cli/src/runtime/runtimeAsync.ts b/packages/@expo/agent-cli/src/runtime/runtimeAsync.ts index c210bec9d..faffae965 100644 --- a/packages/@expo/agent-cli/src/runtime/runtimeAsync.ts +++ b/packages/@expo/agent-cli/src/runtime/runtimeAsync.ts @@ -36,7 +36,7 @@ import { relativizeFrame, symbolicateFramesAsync, } from './symbolicate'; -import { EMPTY_DEVICE_INDEX, scopeTargets } from './targetPlatform'; +import { classifyTargets } from './targetSelection'; // The family's shared type, re-exported from where it was first defined so that the commands and // the modules that call them keep importing it from one place (`./preflight` owns it now, because @@ -55,9 +55,9 @@ export async function runtimeEvalAsync( ): Promise { const { expression, timeoutMs, awaitPromise, json } = options; // One preflight, one read of the target list (`./preflight`): the dev server, the app and the - // platform index come back together, so the platform this command was told about is the platform + // device facts come back together, so the platform this command was told about is the platform // of the app it reads and the two steps cannot disagree about which app that is (F51). - const { devServerUrl, deviceIndex } = await preflightRuntimeAsync( + const { devServerUrl, targetSelection, deviceInventory } = await preflightRuntimeAsync( { need: 'debugger-target', devServerUrl: options.devServerUrl, platform: options.platform }, context ); @@ -66,8 +66,8 @@ export async function runtimeEvalAsync( try { result = await new CdpClient({ metroUrl: devServerUrl, - platform: options.platform, - deviceIndex, + targetSelection, + deviceInventory, }).evaluateAsync(expression, { awaitPromise, timeoutMs, @@ -176,14 +176,11 @@ export function evaluateUnsupportedError(devServerUrl: string): CommandError { /** The platforms, other than the requested one, that have an app on the dev server. */ function otherPlatformsOf( - targets: Parameters[0], - platform: NativePlatform | null | undefined, - deviceIndex: Parameters[2] | null | undefined + targets: Parameters[0], + targetSelection: Parameters[1], + deviceInventory: Parameters[2] ): NativePlatform[] { - if (platform == null) { - return []; - } - const { otherPlatform } = scopeTargets(targets, platform, deviceIndex ?? EMPTY_DEVICE_INDEX); + const { otherPlatform } = classifyTargets(targets, targetSelection, deviceInventory); return [...new Set(otherPlatform.map((entry) => entry.platform))].sort(); } @@ -224,15 +221,15 @@ export async function runtimeErrorsAsync( }, context ); - const { devServerUrl, deviceIndex } = connection; + const { devServerUrl, targetSelection, deviceInventory } = connection; // @ref ../dev/logErrors — F105. Which *other* platforms have an app on this dev server, because // the log fallback below cannot tell their lines from this one's and a caveat that does not say so // is the overclaim. Computed from the read the preflight already made, so it costs nothing. const otherPlatformsConnected = otherPlatformsOf( connection.targets, - options.platform, - deviceIndex + targetSelection, + deviceInventory ); // Marked before the window opens, so the log read below is bounded the same way the debugger @@ -245,8 +242,8 @@ export async function runtimeErrorsAsync( metroUrl: devServerUrl, durationMs, targetRetryMs: APP_RECONNECT_GRACE_MS, - platform: options.platform, - deviceIndex, + targetSelection, + deviceInventory, }); try { errors = await collector.collectAsync(); diff --git a/packages/@expo/agent-cli/src/runtime/runtimeErrorCollector.ts b/packages/@expo/agent-cli/src/runtime/runtimeErrorCollector.ts index 8c22cbcf1..0356fae2f 100644 --- a/packages/@expo/agent-cli/src/runtime/runtimeErrorCollector.ts +++ b/packages/@expo/agent-cli/src/runtime/runtimeErrorCollector.ts @@ -184,8 +184,8 @@ export class CdpRuntimeErrorCollector { // @ref llp/0005-runtime-loop-tools.rfc.md §Android — F100. // Every `CdpClientOptions` key is forwarded, and that is the point: this used to name the four - // it wanted and so silently dropped `platform` and `deviceIndex`, which both callers pass. The - // result was `runtime:errors --android` reading the iOS runtime and reporting it as Android's — + // it wanted and so silently dropped the platform scope, which both callers pass. The result was + // `runtime:errors --android` reading the iOS runtime and reporting it as Android's — // not by accident, but because the default selector ranks a runtime that answers above one that // answers `-32601`, and Expo Go for Android is the second kind. A rest spread cannot go stale // the next time an option is added to the client. diff --git a/packages/@expo/agent-cli/src/runtime/stopAsync.ts b/packages/@expo/agent-cli/src/runtime/stopAsync.ts index 1cc95a2cc..19b75cdac 100644 --- a/packages/@expo/agent-cli/src/runtime/stopAsync.ts +++ b/packages/@expo/agent-cli/src/runtime/stopAsync.ts @@ -113,7 +113,7 @@ export async function runtimeStopAsync( // already stopped — so the connection is read and never insisted on. // // The platform is passed even though nothing here refuses on it (F101). `resolveDeviceAsync` has - // just settled which device this stop acts on, and the preflight's `appTargets` is the scoped list + // just settled which device this stop acts on, and the preflight's `classifiedTargets.read` is the scoped list // every other command in the family reads — without it, the id below came off whichever target // the dev server happened to list first, on either platform. const devServer = await preflightRuntimeAsync( @@ -125,7 +125,9 @@ export async function runtimeStopAsync( }, { projectRoot } ); - const connectedAppIds = devServer.appTargets.map((target) => target.appId).filter(Boolean); + const connectedAppIds = devServer.classifiedTargets.read + .map((target) => target.appId) + .filter(Boolean); const resolved = resolveAppId({ platform: device.platform, appIdOverride: options.appId, diff --git a/packages/@expo/agent-cli/src/runtime/targetPlatform.ts b/packages/@expo/agent-cli/src/runtime/targetPlatform.ts index 772ec1695..f104fcb5d 100644 --- a/packages/@expo/agent-cli/src/runtime/targetPlatform.ts +++ b/packages/@expo/agent-cli/src/runtime/targetPlatform.ts @@ -37,17 +37,7 @@ import type { NavigatePlatform } from '../navigate/device'; import type { CdpTarget } from './cdpClient'; - -/** What this machine's device tools say is attached, so a target can be matched back to one. */ -export interface DeviceNameIndex { - /** Names of booted iOS simulators, as `xcrun simctl` reports them. */ - iosNames: string[]; - /** Hardware models of attached Android devices, as `adb devices -l` reports them. */ - androidModels: string[]; -} - -/** An index that knows nothing, for a caller with no device tools to ask. */ -export const EMPTY_DEVICE_INDEX: DeviceNameIndex = { iosNames: [], androidModels: [] }; +import type { DeviceInventory } from './targetSelection'; /** Expo Go's application id, which differs between the platforms by one capital letter. */ const EXPO_GO_IOS_APP_ID = 'host.exp.Exponent'; @@ -69,24 +59,24 @@ export interface TargetPlatformVerdict { /** * Work out which platform one debugger target is running on. * - * @param index what this machine's device tools reported, or {@link EMPTY_DEVICE_INDEX} when they - * were not asked. The last two rules need no index at all, so a machine with no `adb` still tells - * an Expo Go Android target apart from an iOS one. + * @param facts what this machine's device tools reported, or nothing when they were not asked. The + * last two rules need no facts at all, so a machine with no `adb` still tells an Expo Go Android + * target apart from an iOS one. */ export function platformOfTarget( target: Pick, - index: DeviceNameIndex = EMPTY_DEVICE_INDEX + deviceInventory?: DeviceInventory ): TargetPlatformVerdict { const deviceName = typeof target.deviceName === 'string' ? target.deviceName : ''; - if (deviceName && index.iosNames.includes(deviceName)) { + if (deviceName && deviceInventory?.booted.some((simulator) => simulator.name === deviceName)) { return { platform: 'ios', evidence: `its device name "${deviceName}" is a booted iOS simulator`, }; } - const model = index.androidModels.find( + const model = deviceInventory?.androidModels.find( (candidate) => candidate === deviceName || deviceName.startsWith(`${candidate} - `) ); if (model) { @@ -122,41 +112,6 @@ export function platformOfTarget( }; } -/** The three groups one platform flag splits a target list into. */ -export interface ScopedTargets { - /** Targets shown to be on the platform that was asked for. */ - matched: CdpTarget[]; - /** Targets shown to be on another platform. */ - otherPlatform: { target: CdpTarget; platform: NavigatePlatform }[]; - /** Targets whose platform nothing decided. Never counted as either. */ - undetermined: CdpTarget[]; -} - -/** - * Split a target list by platform. - * - * `undetermined` is its own group rather than being folded into `matched`, 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. - */ -export function scopeTargets( - targets: CdpTarget[], - platform: NavigatePlatform, - index: DeviceNameIndex = EMPTY_DEVICE_INDEX -): ScopedTargets { - const scoped: ScopedTargets = { matched: [], otherPlatform: [], undetermined: [] }; - for (const target of targets) { - const verdict = platformOfTarget(target, index); - if (verdict.platform === platform) { - scoped.matched.push(target); - } else if (verdict.platform != null) { - scoped.otherPlatform.push({ target, platform: verdict.platform }); - } else { - scoped.undetermined.push(target); - } - } - return scoped; -} - /** * The platforms a set of targets is running on, and whether any of them could not be read. * @@ -165,12 +120,12 @@ export function scopeTargets( */ export function platformsOfTargets( targets: CdpTarget[], - index: DeviceNameIndex = EMPTY_DEVICE_INDEX + deviceInventory?: DeviceInventory ): { platforms: NavigatePlatform[]; undetermined: number } { const platforms: NavigatePlatform[] = []; let undetermined = 0; for (const target of targets) { - const { platform } = platformOfTarget(target, index); + const { platform } = platformOfTarget(target, deviceInventory); if (platform == null) { undetermined += 1; } else if (!platforms.includes(platform)) { @@ -179,70 +134,3 @@ export function platformsOfTargets( } return { platforms, undetermined }; } - -/** - * The device index, but only when the index-free rules cannot place every target. - * - * Two subprocesses are cheap and not free, and Expo Go — the case nearly every one of these - * commands runs against — is placed by its app id alone. So the tools are spawned exactly when - * their answer could change the outcome: a development build whose device name and app id say - * nothing this module recognises. - */ -export async function buildDeviceNameIndexIfNeededAsync( - targets: readonly Pick[] -): Promise { - const undetermined = targets.some((target) => platformOfTarget(target).platform == null); - return undetermined ? await buildDeviceNameIndexAsync() : EMPTY_DEVICE_INDEX; -} - -/** - * 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 three rules above, and the other two still answer for Expo Go and for React Native Android. - */ -export async function buildDeviceNameIndexAsync(): Promise { - const { parseAndroidDevices, runAdbAsync } = - require('../device/adb') as typeof import('../device/adb'); - const { spawnCaptureAsync } = - require('../utils/spawnCapture') as typeof import('../utils/spawnCapture'); - - const [simctl, adb] = await Promise.all([ - spawnCaptureAsync('xcrun', ['simctl', 'list', 'devices', 'booted', '-j']).catch(() => null), - runAdbAsync(['devices', '-l']).catch(() => null), - ]); - - return { - iosNames: simctl?.exitCode === 0 ? bootedSimulatorNames(simctl.stdout) : [], - androidModels: - adb?.exitCode === 0 - ? parseAndroidDevices(adb.stdout) - .map((device) => device.model) - .filter((model): model is string => model != null) - : [], - }; -} - -/** - * Every booted simulator name in `simctl list devices booted -j`. - * - * Every runtime, not only iOS: this index answers "is this name one of *our* simulators", and a - * watchOS simulator's name would otherwise fall through to a later rule that knows less. - */ -export function bootedSimulatorNames(stdout: string): string[] { - let parsed: { devices?: Record }; - try { - parsed = JSON.parse(stdout); - } catch { - return []; - } - const names: string[] = []; - for (const devices of Object.values(parsed.devices ?? {})) { - for (const device of Array.isArray(devices) ? devices : []) { - if (typeof device?.name === 'string' && device.name) { - names.push(device.name); - } - } - } - return names; -} diff --git a/packages/@expo/agent-cli/src/runtime/targetSelection.ts b/packages/@expo/agent-cli/src/runtime/targetSelection.ts new file mode 100644 index 000000000..aeec37a78 --- /dev/null +++ b/packages/@expo/agent-cli/src/runtime/targetSelection.ts @@ -0,0 +1,167 @@ +// @ref llp/0005-runtime-loop-tools.rfc.md §Android +// Which debugger targets one run reads, decided in one place. +// +// Three inputs, kept apart on purpose: the targets the dev server lists, the facts this machine's +// 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 { CdpTarget } from './cdpClient'; +import { platformOfTarget } from './targetPlatform'; + +/** What this machine's device tools report. Facts only: no decision of this run is in here. */ +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[]; +} + +/** Facts that know nothing, for a run that did not need to ask the device tools. */ +export const EMPTY_DEVICE_INVENTORY: DeviceInventory = { booted: [], androidModels: [] }; + +/** 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; +} + +/** A run that reads every target. */ +export const READ_EVERY_TARGET: TargetSelection = { platform: null, device: null }; + +/** Where one target falls for one scope. */ +export type TargetClassification = + | { group: 'read' } + | { group: 'other-platform'; platform: NavigatePlatform } + | { group: 'undetermined' }; + +/** A target list, grouped by {@link TargetClassification}. */ +export interface ClassifiedTargets { + /** The targets this run may read. */ + 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. */ + undetermined: CdpTarget[]; +} + +function classifyTarget( + target: Pick, + 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' }; + } + return platform == null ? { group: 'undetermined' } : { group: 'other-platform', platform }; +} + +/** + * Group a target list by where each target falls. + * + * `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. + */ +export function classifyTargets( + targets: CdpTarget[], + targetSelection: TargetSelection, + deviceInventory: DeviceInventory +): ClassifiedTargets { + const classifiedTargets: ClassifiedTargets = { read: [], otherPlatform: [], undetermined: [] }; + for (const target of targets) { + const classification = classifyTarget(target, targetSelection, deviceInventory); + switch (classification.group) { + case 'read': + classifiedTargets.read.push(target); + break; + case 'other-platform': + classifiedTargets.otherPlatform.push({ target, platform: classification.platform }); + break; + case 'undetermined': + classifiedTargets.undetermined.push(target); + break; + } + } + return classifiedTargets; +} + +/** Whether the device tools can change where these targets fall for this scope. */ +function toolsCanPlace( + targets: readonly Pick[], + _targetSelection: TargetSelection +): boolean { + // Unscoped reads still need platform evidence for their bundle checks. + return targets.some( + (target) => platformOfTarget(target, EMPTY_DEVICE_INVENTORY).platform == null + ); +} + +/** Ask the device tools again for a new list, when their answer can change where it falls. */ +export async function refreshDeviceInventoryAsync( + targets: readonly Pick[], + targetSelection: TargetSelection, + previous: DeviceInventory, + timeoutMs?: number +): Promise { + if (!toolsCanPlace(targets, targetSelection)) { + return previous; + } + const tools = await readDeviceInventoryAsync(timeoutMs); + return { ...previous, booted: tools.booted, androidModels: tools.androidModels }; +} + +/** The facts needed to place this list, read only when the device tools can place it. */ +export async function deviceInventoryForSelectionAsync( + targets: readonly Pick[], + targetSelection: TargetSelection +): Promise { + return refreshDeviceInventoryAsync(targets, targetSelection, EMPTY_DEVICE_INVENTORY); +} + +/** + * 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. + */ +export async function readDeviceInventoryAsync(timeoutMs?: number): Promise { + const { parseAndroidDevices, runAdbAsync } = + require('../device/adb') as typeof import('../device/adb'); + const { parseBootedSimulators } = + require('../device/simulators') as typeof import('../device/simulators'); + const { spawnCaptureAsync } = + require('../utils/spawnCapture') as typeof import('../utils/spawnCapture'); + + const [simctl, adb] = await Promise.all([ + spawnCaptureAsync('xcrun', ['simctl', 'list', 'devices', 'booted', '-j'], { timeoutMs }).catch( + () => null + ), + runAdbAsync(['devices', '-l'], { timeoutMs }).catch(() => null), + ]); + + return { + booted: simctl?.exitCode === 0 ? parseBootedSimulators(simctl.stdout) : [], + androidModels: + adb?.exitCode === 0 + ? parseAndroidDevices(adb.stdout) + .map((device) => device.model) + .filter((model): model is string => model != null) + : [], + }; +} + +/** Whether a new page is on a device this scope skips. */ +export function isSkippedTarget( + target: Pick, + targetSelection: TargetSelection, + deviceInventory: DeviceInventory +): boolean { + return ['other-worktree', 'other-device'].includes( + classifyTarget(target, targetSelection, deviceInventory).group + ); +} diff --git a/packages/@expo/agent-cli/src/runtime/waitReady.ts b/packages/@expo/agent-cli/src/runtime/waitReady.ts index 967a7e378..0250b8415 100644 --- a/packages/@expo/agent-cli/src/runtime/waitReady.ts +++ b/packages/@expo/agent-cli/src/runtime/waitReady.ts @@ -13,14 +13,17 @@ // that the dev server that answered belongs to this project — the caveat `discoverDevServerAsync` // documents but cannot close. -import type { NavigatePlatform } from '../navigate/device'; +import type { CdpTarget } from './cdpClient'; import { normalizeDevServerUrl, probeDevServerAsync } from './devServer'; import { decodeProjectRoot, matchProjectRoot, PROJECT_ROOT_HEADER } from './projectRootHeader'; import { - buildDeviceNameIndexIfNeededAsync, - scopeTargets, - type DeviceNameIndex, -} from './targetPlatform'; + isSkippedTarget, + classifyTargets, + refreshDeviceInventoryAsync, + type ClassifiedTargets, + type DeviceInventory, + type TargetSelection, +} from './targetSelection'; /** The body `GET /status` answers with once the bundler has finished. */ export const PACKAGER_STATUS_READY = 'packager-status:running'; @@ -253,15 +256,17 @@ export interface WaitForAppConnectionOptions { /** How long to wait between two target-list reads. */ intervalMs?: number; /** - * Count only apps on this platform, instead of every app attached. + * Count only apps this scope reads, instead of every app attached. Unset reads every app. * * The fix for friction run 6's F51: with an iOS simulator and an Android emulator on one dev * server, `--android` waits used to be satisfied by the simulator, and every phase after them * read the wrong runtime. */ - platform?: NavigatePlatform; - /** What this machine's device tools reported, for {@link platform}. */ - deviceIndex?: DeviceNameIndex; + targetSelection: TargetSelection; + /** Initial device facts, read for `inventoryTargets`. */ + deviceInventory: DeviceInventory; + /** The target list `deviceInventory` was read for. A poll that lists it again keeps the facts. */ + inventoryTargets: readonly Pick[]; } export interface AppConnectionResult { @@ -286,6 +291,39 @@ export interface AppConnectionResult { undeterminedApps?: number; } +function listKey(targets: readonly Pick[]): string { + return targets.map((target) => target.id).join('\n'); +} + +/** + * Classify every list a poll reads, and ask the device tools again for a changed list. + * + * @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. + */ +export function scopedListClassifier( + targetSelection: TargetSelection, + deviceInventory: DeviceInventory, + deadline: number, + inventoryTargets: readonly Pick[] +): (targets: CdpTarget[]) => Promise { + let inventoryListKey = listKey(inventoryTargets); + return async (targets) => { + const key = listKey(targets); + if (key !== inventoryListKey && Date.now() < deadline) { + inventoryListKey = key; + deviceInventory = await refreshDeviceInventoryAsync( + targets, + targetSelection, + deviceInventory, + Math.max(1, deadline - Date.now()) + ); + } + return classifyTargets(targets, targetSelection, deviceInventory); + }; +} + /** * Wait for at least one app to attach to the dev server. * @@ -295,30 +333,33 @@ export interface AppConnectionResult { */ export async function waitForAppConnectionAsync( devServerUrl: string, - { timeoutMs, intervalMs = 250, platform, deviceIndex }: WaitForAppConnectionOptions + { + timeoutMs, + intervalMs = 250, + targetSelection, + deviceInventory, + inventoryTargets, + }: WaitForAppConnectionOptions ): Promise { const startedAt = Date.now(); const deadline = startedAt + timeoutMs; - // Read once, before the loop: what is attached changes during the wait, what this machine has - // does not, and asking the device tools on every poll would be a subprocess every 250 ms. - const index = - platform == null - ? null - : (deviceIndex ?? - (await buildDeviceNameIndexIfNeededAsync( - (await probeDevServerAsync(devServerUrl)).targets - ))); - + const classify = scopedListClassifier( + targetSelection, + deviceInventory, + deadline, + inventoryTargets + ); for (;;) { const probe = await probeDevServerAsync(devServerUrl); - const scoped = platform == null ? null : scopeTargets(probe.targets, platform, index!); - const counted = scoped ? scoped.matched.length : probe.targets.length; - const extra = scoped - ? { - otherPlatformApps: scoped.otherPlatform.length, - undeterminedApps: scoped.undetermined.length, - } - : {}; + const classifiedTargets = await classify(probe.targets); + const counted = classifiedTargets.read.length; + const extra = + targetSelection.platform != null + ? { + otherPlatformApps: classifiedTargets.otherPlatform.length, + undeterminedApps: classifiedTargets.undetermined.length, + } + : {}; if (counted > 0) { return { @@ -335,7 +376,10 @@ export async function waitForAppConnectionAsync( } } -export interface WaitForFreshAppConnectionOptions extends WaitForAppConnectionOptions { +export interface WaitForFreshAppConnectionOptions extends Omit< + WaitForAppConnectionOptions, + 'inventoryTargets' +> { /** * Debugger target ids the dev server listed *before* the app was reloaded. * @@ -383,7 +427,14 @@ export interface FreshAppConnectionResult extends AppConnectionResult { */ export async function waitForFreshAppConnectionAsync( devServerUrl: string, - { timeoutMs, intervalMs = 250, knownTargetIds, signal }: WaitForFreshAppConnectionOptions + { + timeoutMs, + intervalMs = 250, + knownTargetIds, + signal, + targetSelection, + deviceInventory, + }: WaitForFreshAppConnectionOptions ): Promise { const startedAt = Date.now(); const deadline = startedAt + timeoutMs; @@ -392,8 +443,11 @@ export async function waitForFreshAppConnectionAsync( for (;;) { const probe = await probeDevServerAsync(devServerUrl); - appsConnected = probe.targets.length; - const fresh = probe.targets.filter((target) => !known.has(target.id)); + const own = probe.targets.filter( + (target) => !isSkippedTarget(target, targetSelection, deviceInventory) + ); + appsConnected = own.length; + const fresh = own.filter((target) => !known.has(target.id)); if (fresh.length > 0) { return { appsConnected, @@ -420,3 +474,35 @@ function excerpt(body: string): string { const line = body.split('\n')[0]!; return line.length > BODY_EXCERPT_LENGTH ? `${line.slice(0, BODY_EXCERPT_LENGTH)}…` : line; } + +/** + * How many debugger targets the dev server lists, asked until one appears or the budget runs out. + * + * @ref llp/0005-runtime-loop-tools.rfc.md §What proves a reload — F141. Deliberately *not* a wait + * for a **fresh** target: the question here is only "is there a runtime on this dev server now", + * which is the question every command after this reload will ask. Whether it is a new one was + * already answered above, and answering it twice would make a report of the second answer. + */ +export async function countConnectedAppsAsync( + devServerUrl: string, + budgetMs: number, + { + targetSelection, + deviceInventory, + }: { targetSelection: TargetSelection; deviceInventory: DeviceInventory } +): Promise { + const deadline = Date.now() + budgetMs; + for (;;) { + const probe = await probeDevServerAsync(devServerUrl); + const count = probe.targets.filter( + (target) => !isSkippedTarget(target, targetSelection, deviceInventory) + ).length; + if (count > 0 || Date.now() + APP_RECONNECT_POLL_MS >= deadline) { + return count; + } + await new Promise((resolve) => setTimeout(resolve, APP_RECONNECT_POLL_MS)); + } +} + +/** How often that question is asked again. */ +const APP_RECONNECT_POLL_MS = 200; diff --git a/packages/@expo/agent-cli/src/smoke/__tests__/phases-test.ts b/packages/@expo/agent-cli/src/smoke/__tests__/phases-test.ts index 043b8014c..d4af7fc8d 100644 --- a/packages/@expo/agent-cli/src/smoke/__tests__/phases-test.ts +++ b/packages/@expo/agent-cli/src/smoke/__tests__/phases-test.ts @@ -217,6 +217,32 @@ function deps(overrides: Partial = {}): SmokeDeps { }; } +/** A machine with no device until the run boots one; its probe then finds the device booted. */ +function bootableDevice( + boot: SmokeDeps['bootDevice'] = async () => ({ + ok: true, + deviceId: 'SIM-BOOTED', + backend: 'local-ios' as const, + reason: null, + }) +): Pick { + let found: Awaited> = { + deviceId: null, + backend: null, + reason: 'no simulator', + }; + return { + probeDevice: async () => found, + bootDevice: async () => { + const booted = await boot(); + if (booted.ok && booted.deviceId) { + found = { deviceId: booted.deviceId, backend: booted.backend, reason: null }; + } + return booted; + }, + }; +} + /** One phase's status, by id, so a case reads as a table row. */ function statusOf(run: SmokeRun, id: SmokePhaseId): SmokePhaseStatus | undefined { return run.phases.find((phase) => phase.id === id)?.status; @@ -402,26 +428,22 @@ describe(runSmokePhasesAsync, () => { // Start what is missing, stop only what this run started, and leave what was already there. describe('the environment this run brings', () => { /** A machine with nothing on it: no dev server, and no device. */ - function bareMachine(overrides: Partial = {}): Partial { + function bareMachine({ + bootDevice, + ...overrides + }: Partial = {}): Partial { let reachable = false; - let device: { deviceId: string | null; backend: 'local-ios' | null } = { - deviceId: null, - backend: null, - }; + const device = bootableDevice(bootDevice); return { discoverDevServer: async () => { const found = discovery({ reachable }); reachable = true; return found; }, - probeDevice: async () => ({ ...device, reason: device.deviceId ? null : 'no simulator' }), - bootDevice: async () => { - device = { deviceId: 'SIM-BOOTED', backend: 'local-ios' }; - return { ok: true, deviceId: 'SIM-BOOTED', backend: 'local-ios' as const, reason: null }; - }, + ...device, // Nothing is attached until the run opens the app on the simulator it booted. waitForAppConnection: async () => ({ - appsConnected: device.deviceId ? 1 : 0, + appsConnected: (await device.probeDevice()).deviceId ? 1 : 0, timedOut: false, waitedMs: 3, }), @@ -2564,7 +2586,7 @@ describe(`${runSmokePhasesAsync.name} and installing the app`, () => { */ const needsBoot = () => ({ bootstrap: true, - probeDevice: async () => ({ deviceId: null, backend: null, reason: 'nothing booted' }), + ...bootableDevice(), waitForAppConnection: vi .fn<() => Promise<{ appsConnected: number; timedOut: boolean; waitedMs: number }>>() .mockResolvedValueOnce({ appsConnected: 0, timedOut: true, waitedMs: 1 }) @@ -2852,7 +2874,7 @@ describe(`${runSmokePhasesAsync.name} and what it says while it runs`, () => { { discoverDevServer: async () => discovery({ reachable: looks++ > 0 }), waitForAppConnection: async () => ({ appsConnected: 0, timedOut: true, waitedMs: 1 }), - probeDevice: async () => ({ deviceId: null, backend: null, reason: 'no simulator' }), + ...bootableDevice(), installNeededOnDevice: async () => true, }, // The three acts are only performed by a run allowed to bring its own environment, and this 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 797cf95af..67f6d6214 100644 --- a/packages/@expo/agent-cli/src/smoke/__tests__/smokeDeps-test.ts +++ b/packages/@expo/agent-cli/src/smoke/__tests__/smokeDeps-test.ts @@ -18,6 +18,7 @@ import { lookUpEasSimulatorBuildAsync } from '../../plan/easBuildLookup'; import { PROGRAM_PREFIX } from '../../programName'; import { resolveEasCli } from '../../utils/easCli'; import { spawnCaptureAsync } from '../../utils/spawnCapture'; +import { ensureGitExcludedAsync } from '../../utils/gitExclude'; import { resolveSmokeOptions } from '../resolveOptions'; import { buildSmokeDeps, cleanupLeft, deviceBoundByStart } from '../smokeAsync'; import type { SmokeEnvironmentJson } from '../types'; @@ -309,4 +310,22 @@ describe('ensureEasSession with no EAS build to install', () => { expect(spawnCaptureAsync).not.toHaveBeenCalled(); expect(readBindingFile(file)).toEqual({ kind: 'none' }); }); + + it('keeps the session returned by ensure for subsequent device reads', async () => { + vi.mocked(ensureGitExcludedAsync).mockResolvedValue({ result: 'already' }); + vi.mocked(probeCloudSessionAsync).mockResolvedValue( + cloudProbe.active({ id: 'session-ready', platform: 'ios' }) + ); + const deps = eas(); + expect(await deps.ensureEasSession('http://127.0.0.1:8081')).toMatchObject({ + ok: true, + sessionId: 'session-ready', + }); + vi.mocked(probeCloudSessionAsync).mockResolvedValue(cloudProbe.unknown()); + expect(await deps.probeDevice()).toEqual({ + deviceId: 'session-ready', + backend: 'cloud', + reason: null, + }); + }); }); diff --git a/packages/@expo/agent-cli/src/smoke/__tests__/targetSelection-test.ts b/packages/@expo/agent-cli/src/smoke/__tests__/targetSelection-test.ts new file mode 100644 index 000000000..8a7664f5b --- /dev/null +++ b/packages/@expo/agent-cli/src/smoke/__tests__/targetSelection-test.ts @@ -0,0 +1,162 @@ +import { createServer, type Server } from 'http'; +import { vol } from 'memfs'; +import type { AddressInfo } from 'net'; + +import { fakeTools, seedIosBinding } from '../../deviceBinding/__tests__/fakeTools'; +import { resolveBundleCheckPlatformsAsync } from '../../runtime/bundleCheck'; +import type { CdpTarget } from '../../runtime/cdpClient'; +import { resolveTargetSelectionAsync } from '../../runtime/resolveTargetSelection'; +import { spawnCaptureAsync } from '../../utils/spawnCapture'; +import { resolveSmokeOptions } from '../resolveOptions'; +import { buildSmokeDeps } from '../smokeAsync'; +import { buildSmokeTargetSelection } from '../targetSelection'; + +vi.mock('../../utils/spawnCapture', () => ({ spawnCaptureAsync: vi.fn() })); +vi.mock('../../log'); + +const projectRoot = '/project'; +const realPlatform = process.platform; +const servers: Server[] = []; + +function target(overrides: Partial = {}): CdpTarget { + return { + id: 'page-1', + title: 'App', + description: '', + type: 'node', + appId: 'com.example', + deviceName: 'My iPhone', + devtoolsFrontendUrl: '', + webSocketDebuggerUrl: 'ws://localhost/inspector', + ...overrides, + }; +} + +async function serverAsync(targets: () => CdpTarget[]): Promise { + const server = createServer((request, response) => { + response.end( + request.url === '/json/list' ? JSON.stringify(targets()) : 'packager-status:running' + ); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + servers.push(server); + return `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +} + +beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + vol.reset(); + vol.fromJSON({ '/project/package.json': '{"dependencies":{"expo":"57.0.0"}}' }); + vi.stubEnv('AGENT_CLI_NO_DEVICE', ''); +}); + +afterEach(async () => { + Object.defineProperty(process, 'platform', { value: realPlatform }); + vi.unstubAllEnvs(); + vi.resetAllMocks(); + for (const server of servers.splice(0)) { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } + vol.reset(); +}); + +function inventory() { + const tools = fakeTools({ simulators: [{ udid: 'IOS-1', name: 'My iPhone', state: 'Booted' }] }); + vi.mocked(spawnCaptureAsync).mockImplementation((command, args) => + command === 'xcrun' ? tools.simctl(args.slice(1)) : tools.adb(args) + ); + return tools; +} + +it('reclassifies an unchanged target after a failed inventory read recovers', async () => { + const url = await serverAsync(() => [target()]); + const selection = buildSmokeTargetSelection(projectRoot, resolveSmokeOptions(['--ios'])); + vi.mocked(spawnCaptureAsync).mockResolvedValue({ + exitCode: 1, + stdout: '', + stderr: 'unavailable', + }); + expect((await selection.targetSelectionAsync(url)).classifiedTargets.undetermined).toHaveLength( + 1 + ); + inventory(); + expect( + (await selection.targetSelectionAsync(url)).classifiedTargets.read.map((app) => app.id) + ).toEqual(['page-1']); +}); + +it('infers both bundle platforms without a platform flag or selected device', async () => { + inventory(); + const selection = await resolveTargetSelectionAsync({ + projectRoot, + flags: { platform: null, eas: false }, + targets: [target(), target({ id: 'android', appId: 'host.exp.exponent', deviceName: 'Pixel' })], + }); + expect( + await resolveBundleCheckPlatformsAsync( + selection.targetSelection, + selection.classifiedTargets.read, + selection + ) + ).toEqual({ platforms: ['ios', 'android'], source: 'connected-app' }); +}); + +it('retries a failed device lookup, shares concurrent discovery, and retains the successful choice', async () => { + seedIosBinding(projectRoot, 'IOS-1', 'My iPhone'); + const selection = buildSmokeTargetSelection(projectRoot, resolveSmokeOptions(['--ios'])); + vi.mocked(spawnCaptureAsync).mockResolvedValue({ + exitCode: 1, + stdout: '', + stderr: 'unavailable', + }); + expect((await selection.deviceAsync()).device).toBeNull(); + const tools = inventory(); + const [first, second] = await Promise.all([selection.deviceAsync(), selection.deviceAsync()]); + expect(first.device?.deviceId).toBe('IOS-1'); + expect(second.device).toEqual(first.device); + expect(tools.calls.filter((args) => args[0] === 'list')).toHaveLength(1); + tools.simulators.length = 0; + expect((await selection.deviceAsync()).device).toEqual(first.device); + expect(tools.calls.filter((args) => args[0] === 'list')).toHaveLength(1); +}); + +it('publishes an acquired device to subsequent phases without another lookup', async () => { + seedIosBinding(projectRoot, 'IOS-1', 'My iPhone'); + const tools = inventory(); + tools.simulators[0]!.state = 'Shutdown'; + const deps = buildSmokeDeps(projectRoot, resolveSmokeOptions(['--ios'])); + const boot = await deps.bootDevice(); + expect(boot.ok).toBe(true); + tools.simulators.length = 0; + expect(await deps.probeDevice()).toMatchObject({ + deviceId: 'IOS-1', + backend: 'local-ios', + reason: null, + }); +}); + +it('places a development-build target that appears after the first empty wait probe', async () => { + inventory(); + let reads = 0; + const url = await serverAsync(() => (++reads === 1 ? [] : [target()])); + const deps = buildSmokeDeps(projectRoot, resolveSmokeOptions(['--ios'])); + expect(await deps.waitForAppConnection(url, 600)).toMatchObject({ + appsConnected: 1, + timedOut: false, + }); +}); + +it('asks the device tools once for a list that does not change', async () => { + const tools = inventory(); + const url = await serverAsync(() => [ + target({ appId: 'com.example.app', deviceName: 'Ada’s phone' }), + ]); + const deps = buildSmokeDeps(projectRoot, resolveSmokeOptions(['--ios'])); + expect(await deps.waitForAppConnection(url, 600)).toMatchObject({ + appsConnected: 0, + timedOut: true, + undeterminedApps: 1, + }); + expect(tools.calls.filter((args) => args[0] === 'list')).toHaveLength(1); +}); diff --git a/packages/@expo/agent-cli/src/smoke/phases.ts b/packages/@expo/agent-cli/src/smoke/phases.ts index ddef91fae..ac39116ef 100644 --- a/packages/@expo/agent-cli/src/smoke/phases.ts +++ b/packages/@expo/agent-cli/src/smoke/phases.ts @@ -302,6 +302,8 @@ export interface SmokeDeps { * * Answers the *backend* too, because the picture is taken through a different tool for each and * the phase that takes it must not guess. Null when nothing was found. + * The dependencies retain successful choices and adopt devices from boot/session creation; + * an unsuccessful lookup may be retried by a later phase. */ probeDevice(): Promise<{ deviceId: string | null; @@ -1170,16 +1172,9 @@ async function runPhasesAsync( // ---- Phase 4: is there a device, and does this run boot one? ------------------------------ // - // The probe is resolved here rather than inside the two phases that used to ask for one, so the - // whole run is about one device (F98, pointed one step earlier). Cached, so an attach-only run - // pays for it exactly where it used to: at the first phase that needs a device. - - let probed: { - deviceId: string | null; - backend: DeviceBackend | null; - reason: string | null; - } | null = null; - const deviceAsync = async () => (probed ??= await deps.probeDevice()); + // The dependencies own the chosen device, including one returned by boot/session creation. + // Asking again lets a failed lookup recover without a second cache in the phase runner. + const deviceAsync = () => deps.probeDevice(); /** * The app probe the boot decision needed, kept for the `app` phase that asks the same question. @@ -1298,7 +1293,6 @@ async function runPhasesAsync( environment.boundDevice = boot.bound ? { id: boot.deviceId, ...boot.bound, released: false, shutDown: false } : null; - probed = { deviceId: boot.deviceId, backend: boot.backend, reason: null }; bootInstallNeeded = boot.installNeeded === true; } else { environment.device = 'failed'; diff --git a/packages/@expo/agent-cli/src/smoke/smokeAsync.ts b/packages/@expo/agent-cli/src/smoke/smokeAsync.ts index 7b10074d2..e8f0016bd 100644 --- a/packages/@expo/agent-cli/src/smoke/smokeAsync.ts +++ b/packages/@expo/agent-cli/src/smoke/smokeAsync.ts @@ -38,9 +38,9 @@ import { captureScreenshotAsync, defaultScreenshotPath } from '../device/screens import { event } from '../events'; import { buildSmokeFollowUps, followUpsEnabled, reportFollowUps } from '../followups'; import * as Log from '../log'; -import { resolveDeviceAsync, type DeviceBackend } from '../navigate/device'; +import { navigateDeviceOf, type DeviceBackend } from '../navigate/device'; import { openRouteAsync } from '../navigate/openRoute'; -import { EXPO_GO_APP_IDS } from '../navigate/target'; +import { isExpoGoAppId } from '../navigate/target'; import { assertEasProjectConfiguredAsync } from '../needsHuman/easProject'; import { PROGRAM_PREFIX } from '../programName'; import { checkExpoGoCompatibilityAsync, decidesAgainstExpoGo } from '../project/expoGo'; @@ -59,12 +59,7 @@ import { type CommandSocketChurn, } from '../runtime/reload/reloadAsync'; import { CdpRuntimeErrorCollector } from '../runtime/runtimeErrorCollector'; -import { - buildDeviceNameIndexIfNeededAsync, - platformOfTarget, - scopeTargets, - type DeviceNameIndex, -} from '../runtime/targetPlatform'; +import { buildSmokeTargetSelection } from './targetSelection'; import { waitForAppConnectionAsync, waitForBundlerReadyAsync } from '../runtime/waitReady'; import { formatSmokeResult, smokeResultToJson } from './format'; import { @@ -364,37 +359,10 @@ export function buildSmokeDeps(projectRoot: string, options: SmokeOptions): Smok return read.kind === 'binding' ? read.binding : null; }; - /** - * What this machine's device tools reported, kept for as long as it still answers. - * - * @ref llp/0005-runtime-loop-tools.rfc.md §Which platform is the caller's to say — F51, F152. - * - * The index exists to place a target whose own fields name no platform, and it is built by - * spawning `xcrun` and `adb`, so it is asked for exactly when its answer could change the - * outcome (`buildDeviceNameIndexIfNeededAsync`). **A gate asks that question before the app it is - * waiting for exists**, and a cached "nothing to place" is then an answer about an empty list: - * the first probe of this run finds no targets, `EMPTY_DEVICE_INDEX` is cached, and the - * development build that attaches a moment later — whose app id is the project's own and whose - * device name is a simulator's — cannot be placed by it. Every wait after that counted zero, so - * `smoke --ios` against a development build spent its whole budget and reported "no app had - * attached" about an app that had [observed — `smoke-test.ts` "opens the dev-client loading link - * before the route link", 55.8 s of a 60 s budget with the app listed throughout, 2026-09-06]. - * - * So the cache is kept only while it still places what is listed **now**. An empty list places - * vacuously and costs nothing, which is the case the laziness was for; a list the cache cannot - * read is the case it was never an answer to. - */ - let deviceIndex: DeviceNameIndex | null = null; - const indexAsync = async (devServerUrl: string): Promise => { - const probe = await probeDevServerAsync(devServerUrl); - const placesEverything = - deviceIndex != null && - probe.targets.every((target) => platformOfTarget(target, deviceIndex!).platform != null); - if (placesEverything) { - return deviceIndex!; - } - return (deviceIndex = await buildDeviceNameIndexIfNeededAsync(probe.targets)); - }; + const { targetSelectionAsync, deviceAsync, setDevice } = buildSmokeTargetSelection( + projectRoot, + options + ); return { discoverDevServer: (explicitUrl) => @@ -555,6 +523,7 @@ export function buildSmokeDeps(projectRoot: string, options: SmokeOptions): Smok // with the app named. A development build needs a finished simulator build of this fingerprint // on EAS; the gate never compiles one — `dev --eas` does, and is named. ensureEasSession: async (devServerUrl) => { + setDevice(null); const { ensureEasSessionAsync } = require('../dev/easSession') as typeof import('../dev/easSession'); const target = await targetAsync(); @@ -568,6 +537,9 @@ export function buildSmokeDeps(projectRoot: string, options: SmokeOptions): Smok keepSession: false, noBuildReason: `no EAS Simulator session is up, and EAS has no finished "${EAS_SIMULATOR_PROFILE}" build of this project's ${options.platform} fingerprint to start one with — "${PROGRAM_PREFIX} dev --${options.platform} --eas" builds it and starts the session; this gate compiles nothing`, }); + if (result.ok && result.sessionId) { + setDevice({ backend: 'cloud', platform: options.platform, deviceId: result.sessionId }); + } return smokeSessionResult(result); }, stopEasSession: async (sessionId) => { @@ -610,6 +582,7 @@ export function buildSmokeDeps(projectRoot: string, options: SmokeOptions): Smok }; }, bootDevice: async () => { + setDevice(null); const platform = options.platform; if (devicesDisabled()) { return { @@ -622,6 +595,7 @@ export function buildSmokeDeps(projectRoot: string, options: SmokeOptions): Smok } try { const acquired = await acquireDeviceAsync(projectRoot, platform); + setDevice(navigateDeviceOf(acquired.device)); bootBinding = acquired.binding; Log.progress(acquireLine(acquired)); return { @@ -756,12 +730,16 @@ export function buildSmokeDeps(projectRoot: string, options: SmokeOptions): Smok // Scoped to this run's platform: a `smoke --android` that counted the iOS simulator already // attached to this dev server would go on to read that simulator's runtime, and report the // verdict as Android's [friction run 6, F51]. - waitForAppConnection: async (devServerUrl, timeoutMs) => - waitForAppConnectionAsync(devServerUrl, { + waitForAppConnection: async (devServerUrl, timeoutMs) => { + const { targetSelection, deviceInventory, targets } = + await targetSelectionAsync(devServerUrl); + return waitForAppConnectionAsync(devServerUrl, { timeoutMs, - platform: options.platform, - deviceIndex: await indexAsync(devServerUrl), - }), + targetSelection, + deviceInventory, + inventoryTargets: targets, + }); + }, // The same ladder `navigate` walks, through the same function — local device first, then this // project's EAS Simulator session (llp/0005 §Cloud simulator). A second resolution @@ -773,19 +751,12 @@ export function buildSmokeDeps(projectRoot: string, options: SmokeOptions): Smok // bound, that line is the `dev` command that binds one, and a skip row without it left a // reader with a booted simulator and no way forward [observed — issue #171]. probeDevice: async () => { - try { - const device = await resolveDeviceAsync(options.platform, { - cloud: options.cloud, - projectRoot, - }); - return { deviceId: device.deviceId, backend: device.backend, reason: null }; - } catch (error: unknown) { - return { - deviceId: null, - backend: null, - reason: error instanceof Error ? whatAndHow(error.message) : String(error), - }; - } + const { device, reason } = await deviceAsync(); + return { + deviceId: device?.deviceId ?? null, + backend: device?.backend ?? null, + reason: reason == null ? null : whatAndHow(reason), + }; }, // The dev server this run settled on, not the flag: a run that discovered one in its first @@ -827,14 +798,10 @@ export function buildSmokeDeps(projectRoot: string, options: SmokeOptions): Smok // attached to the same dev server must not decide an `--android` run's answer. checkAppFitsProject: async (devServerUrl) => { const nothing = { mismatch: null, kind: null, note: null }; - const probe = await probeDevServerAsync(devServerUrl); - // `matched` only, never `undetermined`: a target whose platform nothing decided must not earn - // this run's verdict either way (@ref src/runtime/targetPlatform §scopeTargets). Erring that - // way keeps the check from turning an unreadable target into a refusal. - const scoped = scopeTargets(probe.targets, options.platform, await indexAsync(devServerUrl)); - const expoGo = scoped.matched - .map((target) => target.appId) - .find((appId): appId is string => appId != null && EXPO_GO_APP_IDS.includes(appId)); + // `read` only: a target nothing placed must not earn this run's verdict either way (@ref + // src/runtime/targetSelection §classifyTargets), so an unreadable target never becomes a refusal. + const { classifiedTargets } = await targetSelectionAsync(devServerUrl); + const expoGo = classifiedTargets.read.map((target) => target.appId).find(isExpoGoAppId); // Everything below is about Expo Go. A development build is this project's own app and its // version is the project's own business, so there is nothing here to ask about it. if (expoGo == null) { @@ -861,23 +828,15 @@ export function buildSmokeDeps(projectRoot: string, options: SmokeOptions): Smok // The same resolution `probeDevice` performs, and for the same reason: the device this run is // about. Any failure to find one is nothing to check rather than something to report — the // app answered, so whatever it is running on is beyond this check's reach. - let localDeviceId: string | null = null; - try { - const device = await resolveDeviceAsync(options.platform, { - cloud: options.cloud, - projectRoot, - }); - // @ref llp/0005-runtime-loop-tools.rfc.md §Android — both local backends now, because both - // have a reader for the version on the device (@ref src/device/expoGoVersion). A cloud - // session is still excluded: the check reads the device, and that device is not this - // machine's (llp/0005 §Cloud simulator). - localDeviceId = - device.backend === 'local-ios' || device.backend === 'local-android' - ? device.deviceId - : null; - } catch { - localDeviceId = null; - } + const { device } = await deviceAsync(); + // @ref llp/0005-runtime-loop-tools.rfc.md §Android — both local backends now, because both + // have a reader for the version on the device (@ref src/device/expoGoVersion). A cloud + // session is still excluded: the check reads the device, and that device is not this + // machine's (llp/0005 §Cloud simulator). + const localDeviceId = + device?.backend === 'local-ios' || device?.backend === 'local-android' + ? device.deviceId + : null; if (localDeviceId == null) { return nothing; } @@ -1021,9 +980,12 @@ export function buildSmokeDeps(projectRoot: string, options: SmokeOptions): Smok }; } + const { targetSelection, deviceInventory } = await targetSelectionAsync(devServerUrl); const evidence = await waitForReloadEvidenceAsync(devServerUrl, projectRoot, { timeoutMs, knownTargetIds, + targetSelection, + deviceInventory, bundleMark, platform: options.platform, }); @@ -1056,10 +1018,11 @@ export function buildSmokeDeps(projectRoot: string, options: SmokeOptions): Smok evaluate: async (devServerUrl) => { try { + const { targetSelection, deviceInventory } = await targetSelectionAsync(devServerUrl); await new CdpClient({ metroUrl: devServerUrl, - platform: options.platform, - deviceIndex: await indexAsync(devServerUrl), + targetSelection, + deviceInventory, }).evaluateAsync(LIVENESS_EXPRESSION, { awaitPromise: false, timeoutMs: LIVENESS_TIMEOUT_MS, @@ -1085,11 +1048,12 @@ export function buildSmokeDeps(projectRoot: string, options: SmokeOptions): Smok collectErrors: async (devServerUrl, windowMs) => { try { + const { targetSelection, deviceInventory } = await targetSelectionAsync(devServerUrl); const records = await new CdpRuntimeErrorCollector({ metroUrl: devServerUrl, durationMs: windowMs, - platform: options.platform, - deviceIndex: await indexAsync(devServerUrl), + targetSelection, + deviceInventory, }).collectAsync(); return { ok: true, records, reason: null }; } catch (error: unknown) { diff --git a/packages/@expo/agent-cli/src/smoke/targetSelection.ts b/packages/@expo/agent-cli/src/smoke/targetSelection.ts new file mode 100644 index 000000000..575cc85af --- /dev/null +++ b/packages/@expo/agent-cli/src/smoke/targetSelection.ts @@ -0,0 +1,68 @@ +// @ref llp/0005-runtime-loop-tools.rfc.md §The smoke gate +import { resolveDeviceAsync, type NavigateDevice } from '../navigate/device'; +import type { CdpTarget } from '../runtime/cdpClient'; +import { probeDevServerAsync } from '../runtime/devServer'; +import { + resolveTargetSelectionAsync, + type ResolvedTargetSelection, +} from '../runtime/resolveTargetSelection'; +import type { SmokeOptions } from './resolveOptions'; + +export interface SmokeTargetSelection { + /** The scope of this run, and the target list it placed. */ + targetSelectionAsync( + devServerUrl: string + ): Promise; + deviceAsync(): Promise<{ device: NavigateDevice | null; reason: string | null }>; + setDevice(device: NavigateDevice | null): void; +} + +/** + * Own the run's chosen device; targets and inventory are observations, not cached decisions. + * + * | Value | Lifetime | + * | ----------------------- | -------------------------------------------------------------------------- | + * | A device choice | Until a boot or a session start replaces it; both clear it when they begin | + * | A lookup in progress | Shared by the phases that ask while it runs | + * | A failed lookup | That attempt only; a later phase asks again | + * | A target classification | The one target list it was made from | + */ +export function buildSmokeTargetSelection( + projectRoot: string, + options: SmokeOptions +): SmokeTargetSelection { + let memo: Promise<{ device: NavigateDevice | null; reason: string | null }> | null = null; + return { + deviceAsync: () => { + if (memo) return memo; + const pending = resolveDeviceAsync(options.platform, { + cloud: options.cloud, + projectRoot, + }).then( + (device) => ({ device, reason: null }), + (error) => { + // A boot/session may have supplied a device while this lookup was in progress. + if (memo === pending) memo = null; + return { + device: null, + reason: error instanceof Error ? error.message : String(error), + }; + } + ); + memo = pending; + return pending; + }, + setDevice: (device) => { + memo = device ? Promise.resolve({ device, reason: null }) : null; + }, + targetSelectionAsync: async (devServerUrl) => { + const { targets } = await probeDevServerAsync(devServerUrl); + const resolved = await resolveTargetSelectionAsync({ + projectRoot, + flags: { platform: options.platform, eas: options.cloud === 'required' }, + targets, + }); + return { ...resolved, targets }; + }, + }; +} From 129227880e8162fcfb87e1d5b2feb25ed55be474 Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Thu, 29 Oct 2026 10:29:39 +0100 Subject: [PATCH 2/4] [agent-cli] Test which apps a run reads end to end The two classifier rows and the smoke bundle-platform case become e2e cases: runtime:reload with no platform checks the bundle on both platforms, and runtime:eval --android reads only the app it places on Android. Co-Authored-By: Claude Opus 5.5 --- .../e2e/__tests__/runtime-preflight-test.ts | 59 +++++++++++++++++++ .../e2e/__tests__/runtime-reload-test.ts | 36 +++++++++++ .../runtime/__tests__/targetSelection-test.ts | 40 ------------- .../smoke/__tests__/targetSelection-test.ts | 18 ------ 4 files changed, 95 insertions(+), 58 deletions(-) delete mode 100644 packages/@expo/agent-cli/src/runtime/__tests__/targetSelection-test.ts 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 f97834b1e..8df1d66bb 100644 --- a/packages/@expo/agent-cli/e2e/__tests__/runtime-preflight-test.ts +++ b/packages/@expo/agent-cli/e2e/__tests__/runtime-preflight-test.ts @@ -252,6 +252,65 @@ describe('a dev server with an app connected', () => { }); }); +// @ref llp/0005-runtime-loop-tools.rfc.md §Android +// A run that names a platform reads only the apps it can place there. An app nothing places on +// either platform (a development build on someone's phone) is not evidence about `--android`. +describe('a run that names a platform', () => { + const ANDROID_TARGET = { + ...EXPO_GO_TARGET, + id: '2', + appId: 'host.exp.exponent', + description: 'host.exp.exponent', + deviceName: 'sdk_gphone64_arm64 - 15 - API 35', + webSocketDebuggerUrl: 'ws://127.0.0.1:8081/inspector/debug?device=2&page=1', + }; + const UNPLACED_TARGET = { + ...EXPO_GO_TARGET, + id: '3', + appId: 'com.example.app', + description: 'com.example.app', + deviceName: 'Ada’s phone', + webSocketDebuggerUrl: 'ws://127.0.0.1:8081/inspector/debug?device=3&page=1', + }; + + async function runAndroidAsync(stub: StubDevServer) { + return await executeAgentCliAsync( + projectRoot, + ['runtime:eval', '1 + 1', '--android', '--dev-server-url', stub.url, '--json'], + { reject: false, env: { ...stubExpoEnv(projectRoot), AGENT_CLI_NO_DEVICE: '1' } } + ); + } + + it('reads the app it places on that platform', async () => { + const stub = await startStubDevServerAsync({ + targets: [UNPLACED_TARGET, ANDROID_TARGET], + inspectorEvaluate: () => undefined, + }); + try { + const result = await runAndroidAsync(stub); + + expect(JSON.parse(result.stdout).error?.code).not.toBe('NO_APP_CONNECTED'); + } finally { + await stub.close(); + } + }); + + it('does not read an app nothing places', async () => { + const stub = await startStubDevServerAsync({ + targets: [UNPLACED_TARGET], + inspectorEvaluate: () => undefined, + }); + try { + const result = await runAndroidAsync(stub); + + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout).error.code).toBe('NO_APP_CONNECTED'); + } finally { + await stub.close(); + } + }); +}); + // @ref llp/0005-runtime-loop-tools.rfc.md §What proves a reload — **F141.** An app that is // re-registering holds no debugger target for about half a second, and the whole family reads that // list. The grace period that waits it out was `runtime:errors`' alone, because F39 was found on the diff --git a/packages/@expo/agent-cli/e2e/__tests__/runtime-reload-test.ts b/packages/@expo/agent-cli/e2e/__tests__/runtime-reload-test.ts index 61cbc9a14..671105262 100644 --- a/packages/@expo/agent-cli/e2e/__tests__/runtime-reload-test.ts +++ b/packages/@expo/agent-cli/e2e/__tests__/runtime-reload-test.ts @@ -127,6 +127,42 @@ describe('@expo/agent-cli runtime:reload', () => { } }); + describe('which attached apps a run reads', () => { + const IOS_TARGET = { ...EXPO_GO_TARGET, deviceName: 'iPhone 17 Pro' }; + const ANDROID_TARGET = { + id: '2', + appId: 'host.exp.exponent', + deviceName: 'sdk_gphone64_arm64 - 15 - API 35', + webSocketDebuggerUrl: 'ws://127.0.0.1:8081/inspector/debug?device=2&page=1', + }; + it('checks the bundle on both platforms when no platform is named', async () => { + const projectRoot = await setupFixtureAsync('go-app'); + const stub = await startStubDevServerAsync({ + projectRoot, + targets: [IOS_TARGET, ANDROID_TARGET], + messagePeers: { 'socket#1': 'role=ios', 'socket#2': 'role=android' }, + }); + const releaseLock = await lockToStubAsync(projectRoot, stub); + + try { + const result = await executeAgentCliAsync( + projectRoot, + ['runtime:reload', '--method', 'dev-server', '--json'], + { env: { ...stubExpoEnv(projectRoot), AGENT_CLI_NO_DEVICE: '1' } } + ); + + expect(result.exitCode).toBe(0); + const report: ReloadReport & { bundlePlatforms: string[]; bundlePlatformSource: string } = + JSON.parse(result.stdout); + expect(report.bundlePlatforms).toEqual(['ios', 'android']); + expect(report.bundlePlatformSource).toBe('connected-app'); + } finally { + releaseLock(); + await stub.close(); + } + }); + }); + it('prints one JSON object with a stable set of keys', async () => { const projectRoot = await setupFixtureAsync('go-app'); const stub = await startStubDevServerAsync({ projectRoot, targets: [EXPO_GO_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 deleted file mode 100644 index 3a6de3b20..000000000 --- a/packages/@expo/agent-cli/src/runtime/__tests__/targetSelection-test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { CdpTarget } from '../cdpClient'; -import { classifyTargets } from '../targetSelection'; -import { makeTargetSelection } from './targetSelectionFixtures'; -import { makeDeviceInventory } from './targetSelectionFixtures'; - -/** One dev server with an iOS simulator and an Android emulator attached [captured — 2026-08-25]. */ -const LIVE_TARGETS: CdpTarget[] = require('./fixtures/json-list-ios-and-android.json'); - -const ios = LIVE_TARGETS.find((target) => target.appId === 'host.exp.Exponent')!; -const android = LIVE_TARGETS.find((target) => target.appId === 'host.exp.exponent')!; -const unknown = { appId: 'com.example.app', deviceName: 'Ada’s phone' } as CdpTarget; - -describe(classifyTargets, () => { - it.each([ - { - name: 'a platform scope keeps an undetermined target out of the read set', - platform: 'android' as const, - read: [android], - otherPlatform: [{ target: ios, platform: 'ios' }], - undetermined: [unknown], - }, - { - name: 'a scope with no platform reads every target', - platform: null, - read: [...LIVE_TARGETS, unknown], - otherPlatform: [], - undetermined: [], - }, - ])('$name', ({ platform, read, otherPlatform, undetermined }) => { - expect( - classifyTargets( - [...LIVE_TARGETS, unknown], - makeTargetSelection({ - platform: platform == null ? null : { value: platform, by: 'flag' }, - }), - makeDeviceInventory() - ) - ).toEqual({ read, otherPlatform, undetermined }); - }); -}); 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 8a7664f5b..ac3381822 100644 --- a/packages/@expo/agent-cli/src/smoke/__tests__/targetSelection-test.ts +++ b/packages/@expo/agent-cli/src/smoke/__tests__/targetSelection-test.ts @@ -3,9 +3,7 @@ import { vol } from 'memfs'; import type { AddressInfo } from 'net'; import { fakeTools, seedIosBinding } from '../../deviceBinding/__tests__/fakeTools'; -import { resolveBundleCheckPlatformsAsync } from '../../runtime/bundleCheck'; import type { CdpTarget } from '../../runtime/cdpClient'; -import { resolveTargetSelectionAsync } from '../../runtime/resolveTargetSelection'; import { spawnCaptureAsync } from '../../utils/spawnCapture'; import { resolveSmokeOptions } from '../resolveOptions'; import { buildSmokeDeps } from '../smokeAsync'; @@ -86,22 +84,6 @@ it('reclassifies an unchanged target after a failed inventory read recovers', as ).toEqual(['page-1']); }); -it('infers both bundle platforms without a platform flag or selected device', async () => { - inventory(); - const selection = await resolveTargetSelectionAsync({ - projectRoot, - flags: { platform: null, eas: false }, - targets: [target(), target({ id: 'android', appId: 'host.exp.exponent', deviceName: 'Pixel' })], - }); - expect( - await resolveBundleCheckPlatformsAsync( - selection.targetSelection, - selection.classifiedTargets.read, - selection - ) - ).toEqual({ platforms: ['ios', 'android'], source: 'connected-app' }); -}); - it('retries a failed device lookup, shares concurrent discovery, and retains the successful choice', async () => { seedIosBinding(projectRoot, 'IOS-1', 'My iPhone'); const selection = buildSmokeTargetSelection(projectRoot, resolveSmokeOptions(['--ios'])); From a27b0089279d2872657c03a0afbd0095e83e5bfa Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Thu, 29 Oct 2026 15:08:12 +0100 Subject: [PATCH 3/4] [agent-cli] Point llp/0005's scope rows at the e2e cases that pin them The classifier rows and the smoke bundle-platform case moved to e2e; the scope table and the bundle inference bullet now cite those e2e cases. Co-Authored-By: Claude Opus 5.5 --- .../agent-cli/llp/0005-runtime-loop-tools.rfc.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 a4684690a..4dd781f63 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,17 +185,17 @@ 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 | runtime targetSelection-test "a scope with no platform reads every target" | -| flag only | platform evidence matches | read | runtime targetSelection-test "a platform scope keeps an undetermined target out of the read set" | -| flag only | other platform | skip as other-platform | same | -| flag only | no platform evidence | undetermined | same | +| 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" | 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: smoke targetSelection-test "infers both bundle platforms without a platform flag or selected device". +- 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". ## Reloading the app From 9073a3c578bf67048823ae369ca166657f222b60 Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Thu, 29 Oct 2026 20:50:10 +0100 Subject: [PATCH 4/4] =?UTF-8?q?[agent-cli]=20dev:wait=20--platform=20web?= =?UTF-8?q?=20checks=20the=20web=20bundle,=20and=20an=20unscoped=20wait=20?= =?UTF-8?q?places=20apps=20by=20device=20facts=20(llp/0005=20=C2=A7Android?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev:wait is deferred code: src/deferred is excluded from vitest and its mocks no longer resolve, so there is no runnable test. A named platform now skips inference; the unscoped check reads deviceInventoryForSelectionAsync. Co-Authored-By: Claude Opus 5.5 --- .../src/deferred/dev-wait/waitAsync.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/@expo/agent-cli/src/deferred/dev-wait/waitAsync.ts b/packages/@expo/agent-cli/src/deferred/dev-wait/waitAsync.ts index a842a32fe..d26890917 100644 --- a/packages/@expo/agent-cli/src/deferred/dev-wait/waitAsync.ts +++ b/packages/@expo/agent-cli/src/deferred/dev-wait/waitAsync.ts @@ -26,10 +26,7 @@ import { import { howToNameTheDevServer, probeDevServerAsync } from '../../runtime/devServer'; import { discoverDevServerAsync } from '../../runtime/discoverDevServer'; import { targetSelectionOf } from '../../runtime/resolveTargetSelection'; -import { - deviceInventoryForSelectionAsync, - EMPTY_DEVICE_INVENTORY, -} from '../../runtime/targetSelection'; +import { deviceInventoryForSelectionAsync } from '../../runtime/targetSelection'; import { waitForAppConnectionAsync, waitForBundlerReadyAsync } from '../../runtime/waitReady'; import { CommandError } from '../../utils/errors'; import { buildDevWaitFollowUps } from './followups'; @@ -101,11 +98,14 @@ export async function devWaitAsync(projectRoot: string, options: DevWaitOptions) }, device: null, }); - const { platforms: bundlePlatforms, source: bundlePlatformSource } = - await resolveBundleCheckPlatformsAsync(targetSelection, discovery.targets, { - deviceInventory: EMPTY_DEVICE_INVENTORY, - fallback: options.platform, - }); + // A named platform, web included, is built as named. The selection above is for the attach wait + // and for inference, and it cannot carry web. + const { platforms: bundlePlatforms, source: bundlePlatformSource } = options.platformExplicit + ? { platforms: [options.platform], source: 'flag' as const } + : await resolveBundleCheckPlatformsAsync(targetSelection, discovery.targets, { + deviceInventory: await deviceInventoryForSelectionAsync(discovery.targets, targetSelection), + fallback: options.platform, + }); const bundles: BundleCheckResult[] = []; if (options.bundleCheck && readiness.ready && readiness.projectRootMatched !== false) {