Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
223ba18
[agent-cli] Move route URL resolution out of openRoute into navigate/…
vonovak Oct 28, 2026
2c33f9c
[agent-cli] navigate judges attachment on the device it opens
vonovak Oct 28, 2026
8ef8fce
[agent-cli] Test navigate's device handling end to end
vonovak Oct 29, 2026
518bb8f
[agent-cli] Point llp/0005's navigate and EAS session rows at the e2e…
vonovak Oct 29, 2026
1fd73b7
[agent-cli] The wait-refresh test passes the deadline per read, as th…
vonovak Oct 29, 2026
2d146ca
[agent-cli] navigate --print-url prints the link navigate opens when …
vonovak Oct 29, 2026
67e7eff
[agent-cli] navigate tests the Android recovery that stops the app, o…
vonovak Oct 29, 2026
ca70444
[agent-cli] The stub adb answers pidof with no running app, and reads…
vonovak Oct 29, 2026
ac8a6af
[agent-cli] navigate reads its app when a twin of the bound emulator …
vonovak Oct 29, 2026
f2269fc
[agent-cli] navigate stops Expo Go, not the config package, when the …
vonovak Oct 29, 2026
5b93f17
[agent-cli] The print-url case reads the EAS binding with the device …
vonovak Oct 29, 2026
e62e64c
[agent-cli] The print-url case runs with the harness default again, a…
vonovak Oct 29, 2026
456a3b8
[agent-cli] navigate --print-url places a bound emulator beside a twi…
vonovak Oct 29, 2026
ca162d0
[agent-cli] The Expo Go Android navigate case asserts that adb revers…
vonovak Oct 29, 2026
0557701
[agent-cli] navigate says the app was stopped only when it was runnin…
vonovak Oct 30, 2026
42d24a1
[agent-cli] navigate --print-url prints its URL beside an unreadable …
vonovak Oct 30, 2026
748f5ee
[agent-cli] The unreadable-binding refusal row of --print-url runs on…
vonovak Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/@expo/agent-cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@

### 🐛 Bug fixes

- `navigate --print-url` prints its URL when the worktree's binding file cannot be read and the dev server lists no app. It still refuses with `NO_BOUND_DEVICE` once an app is listed, because the binding decides which app counts.
- `navigate` says the app was stopped, and reports `attachRecovered: true`, only when the app was running. When nothing was running it still opens the link a second time, and says nothing was stopped.
- `runtime:stop --eas` on an Android session asks the controller once more when its `close` ran out of time ("process remained running after close"), instead of exiting 1. About 1 stop in 3 on an EAS Expo Go session hit this. ([#211](https://github.com/expo/expo-agent-cli/pull/211) by [@vonovak](https://github.com/vonovak))
- The entry-bundle check that runs before `runtime:tree`, `runtime:tap`, `runtime:type`, `runtime:reload` and `smoke` sends its HEAD to the dev server it was given, never to the tunnel host the manifest names. Through the Expo tunnel that HEAD closed every websocket on the tunnel, the app's debugger socket too, so on a dev server started with `--tunnel` (every EAS Simulator session) every runtime read answered "No target found". ([#197](https://github.com/expo/expo-agent-cli/pull/197) by [@vonovak](https://github.com/vonovak))
- `navigate` and `runtime:reload` read the URL scheme of a dynamic `app.config.js`/`.ts` by evaluating it with `@expo/config` after the project's env files are loaded, the way `expo config` does, and say when that fails instead of reporting "no scheme field was found"; `inspect:config-plugins` reads the declared plugins out of the evaluated config, so a project whose config is code no longer marks every plugin `declared: false`. ([#173](https://github.com/expo/expo-agent-cli/pull/173) by [@kudo](https://github.com/kudo))
Expand All @@ -62,6 +64,7 @@
- On a worktree bound to a device, runtime reads and `smoke` read only that device's app, with or without `--ios` or `--android`, and with one app connected too. A local simulator's app with the same bundle id is not read as the EAS Simulator session's app. A released binding does not scope a run; a shut-down bound simulator does, so the read finds no app on it and refuses. On Android the app on the bound device is found by model, compared in the spelling `adb devices -l` uses (`Pixel 9` is `model:Pixel_9`), and by `adb reverse` when two attached devices share that model. An EAS Simulator Android session skips the app on an attached local emulator, and a host with no `adb` still reads the session's app. `runtime:reload` checks the bound device's platform, reports `bundlePlatformSource: 'bound-device'` and a `scope` field (`platform`, `platformFrom`, `bound`, and `device` with `backend`, `id` and `name`), and the read names the scope on stderr. A binding file of an EAS Simulator session that cannot be read refuses with `NO_BOUND_DEVICE` like a local one. When two attached Android devices share the bound model, the refusal's `How:` line depends on whether another device reverses the port, none does, or `adb reverse --list` could not be read. ([#206](https://github.com/expo/expo-agent-cli/pull/206) by [@vonovak](https://github.com/vonovak))

- `runtime:reload` and `smoke` no longer reload an app the run skips (another worktree's app, or an app beside the bound device): they send no broadcast, send `Page.reload` to this worktree's app, and report `verifiedBy: 'debugger-context-replaced'` when its JavaScript context was replaced. Nothing of an app outside the run's scope proves the reload, peer churn beside such an app included; the relaunch never takes its app id, and `appsConnected` counts only the run's own apps. A broadcast that also reaches such an app says so, and a bundle after a broadcast that reached an app on no known platform proves nothing. When nothing could prove the reload, or peer churn did not count, the failure says which, and it names `--eas` only on a cloud run. `Page.reload` gets at most 8 s to prove itself, so `--method auto` keeps the rest of `--timeout` for the relaunch; the first own app that proves the reload stops the other checks, and each of those reports `stopped` in `attempts[].outcomes` and in smoke's `reload.targetOutcomes`. A `Page.reload` whose answer was lost reports `not-delivered`, not `delivered-unproved`. When the target list or a device tool fails on the read just before rung 1, the run keeps the placement it made first and sends no broadcast. After a `Page.reload` that answered with no new context, `smoke` still watches for a bundle on its platform, as `runtime:reload` does. An own app that the spent budget, an abort or a lost connection stopped before it was marked reports `not-delivered`, not `unmarkable`, so the failure does not say that no app could keep the marker. After a local or cloud relaunch of an app that was already listed, with no bundle that may count, `runtime:reload` exits 22 at once instead of after `--timeout`: the app keeps its page id, so nothing could arrive to prove it. A local relaunch whose wait saw nothing no longer says the listed runtime is the old one. ([#207](https://github.com/expo/expo-agent-cli/pull/207) by [@vonovak](https://github.com/vonovak))
- `navigate` judges an attached app on the device it opens, with or without a platform flag, so an app of the other platform on the same dev server no longer makes it skip the launcher open and wait 120 s, and `--ios` that falls back to an EAS Simulator iOS session no longer reloads the session's attached app. When the app does not attach on Android, the recovery stops this project's app, not an app another device runs on the same dev server. `navigate --print-url` reads the same scope, so it prints the link `navigate` opens when the worktree is bound to an EAS session and only an Expo Go app on a local simulator is attached. ([#200](https://github.com/expo/expo-agent-cli/pull/200) by [@vonovak](https://github.com/vonovak))

### 💡 Others

Expand Down
73 changes: 32 additions & 41 deletions packages/@expo/agent-cli/e2e/__tests__/cloud-simulator-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,14 +332,17 @@ describe('@expo/agent-cli navigate --eas', () => {
}
});

// The other half, and the one that keeps this inside llp/0008: an alert this command did not
// cause is not this command's to answer. `alert get` finding nothing is the common case — the
// controller refuses it outright — and no `accept` may follow.
it(`accepts nothing when the device has no alert on it`, async () => {
// The wait after the accept starts after the first wait's budget ran out, and it still asks the
// device tools about the app that appears in it.
it(`counts the app that attaches after the dialog is accepted`, async () => {
const projectRoot = await setupAsync('go-app');
await writeSessionFileAsync(projectRoot, 'sess-e2e');
await installStubXcrunAsync(projectRoot, { simulators: [BOOTED_SIMULATOR] });
const accepted = () =>
easInvocations(projectRoot).some((argv) => argv.slice(3).join(' ') === 'alert accept');
const stub = await startStubDevServerAsync({
targets: [],
targets: () =>
accepted() ? [{ id: '1', appId: 'host.exp.Exponent', deviceName: 'iPhone 17' }] : [],
projectRoot,
manifestOrigin: 'https://stub-tunnel.example',
});
Expand All @@ -354,33 +357,31 @@ describe('@expo/agent-cli navigate --eas', () => {
const result = await executeAgentCliAsync(
projectRoot,
['navigate', '/', '--eas', '--attach-timeout', '2s', '--json', '--no-followups'],
{ reject: false }
{
reject: false,
env: { STUB_SIM_ALERT: `Open in "Expo Go"?\nCancel / Open` },
}
);

const verbs = easInvocations(projectRoot)
.filter((argv) => argv[0] === 'simulator:exec')
.map((argv) => argv.slice(3));
expect(verbs).toContainEqual(['alert', 'get']);
expect(verbs).not.toContainEqual(['alert', 'accept']);
expect(JSON.parse(result.stdout).attachAlert).toMatchObject({
found: false,
accepted: false,
expect(result.exitCode, result.all).toBe(0);
expect(JSON.parse(result.stdout)).toMatchObject({
attached: true,
attachAlert: { found: true, accepted: true },
});
} finally {
await releaseLock();
await stub.close();
}
});

// The development build an EAS Simulator iOS session runs registers the project's own app id and
// the cloud simulator's model name, which no local tool reports. It is the session's app, so the
// run counts it as attached and opens only the route link, never the launcher URL.
it(`counts the session's development build as the iOS app, and opens only the route link`, async () => {
const projectRoot = await setupAsync('dev-client-app');
// The other half, and the one that keeps this inside llp/0008: an alert this command did not
// cause is not this command's to answer. `alert get` finding nothing is the common case — the
// controller refuses it outright — and no `accept` may follow.
it(`accepts nothing when the device has no alert on it`, async () => {
const projectRoot = await setupAsync('go-app');
await writeSessionFileAsync(projectRoot, 'sess-e2e');
await installStubXcrunAsync(projectRoot, { simulators: [BOOTED_SIMULATOR] });
const stub = await startStubDevServerAsync({
targets: [{ id: '1', appId: 'com.example.demo', deviceName: 'iPhone 17' }],
targets: [],
projectRoot,
manifestOrigin: 'https://stub-tunnel.example',
});
Expand All @@ -394,29 +395,19 @@ describe('@expo/agent-cli navigate --eas', () => {
try {
const result = await executeAgentCliAsync(
projectRoot,
[
'navigate',
'/',
'--ios',
'--eas',
'--scheme',
'demoapp',
'--app-id',
'com.example.demo',
'--attach-timeout',
'5s',
'--json',
'--no-followups',
],
['navigate', '/', '--eas', '--attach-timeout', '2s', '--json', '--no-followups'],
{ reject: false }
);

const opens = easInvocations(projectRoot)
.filter((argv) => argv[0] === 'simulator:exec' && argv[3] === 'open')
.map((argv) => argv[4]);
expect(opens).toEqual(['demoapp://']);
expect(result.exitCode).toBe(0);
expect(JSON.parse(result.stdout)).toMatchObject({ launch: null, attached: true });
const verbs = easInvocations(projectRoot)
.filter((argv) => argv[0] === 'simulator:exec')
.map((argv) => argv.slice(3));
expect(verbs).toContainEqual(['alert', 'get']);
expect(verbs).not.toContainEqual(['alert', 'accept']);
expect(JSON.parse(result.stdout).attachAlert).toMatchObject({
found: false,
accepted: false,
});
} finally {
await releaseLock();
await stub.close();
Expand Down
24 changes: 21 additions & 3 deletions packages/@expo/agent-cli/e2e/__tests__/installedAppStubs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,10 @@ function readCalls(recordPath: string): string[][] {
* archive, e.g. one whose `app.fingerprint` is not the JSON `expo-constants` writes. `STUB_ADB_NO_DD=1` fails `stat`, the way a device
* without the tools does, and `pull` then copies the fixture to the path asked for.
* `STUB_ADB_HANG_READ=<file>` makes `exec-out` write its pid there and never exit.
* `shell pidof` answers that no app is running, or prints the pid in `STUB_ADB_PIDOF`; `shell am force-stop` exits 0.
* `STUB_ADB_REVERSE={"<serial>": <port>}` makes `-s <serial> reverse --list` list that port, in the
* `<serial> tcp:<port> tcp:<port>` shape of eas-cli's androidLocalEgress test fixture.
* `<serial> tcp:<port> tcp:<port>` shape of eas-cli's androidLocalEgress test fixture. A
* `reverse tcp:<port> tcp:<port>` the stub accepts is listed from then on, as on a device.
*
* Installed under a fake SDK: `ANDROID_HOME` beats `PATH`, so a runner with a real SDK still
* reaches this stub (@ref src/device/adb §resolveAdb). The returned `env` names it, and the
Expand Down Expand Up @@ -232,10 +234,13 @@ export async function installStubAdbAsync(
` process.stdout.write('device\\n');`,
` process.exit(0);`,
`}`,
`const reversed = ${JSON.stringify(path.join(root, '.adb-reversed.jsonl'))};`,
`if (args[2] === 'reverse' && args[3] === '--list') {`,
` if (process.env.STUB_ADB_REVERSE_FAIL) process.exit(1);`,
` const port = JSON.parse(process.env.STUB_ADB_REVERSE || '{}')[args[1]];`,
` if (port) process.stdout.write(args[1] + ' tcp:' + port + ' tcp:' + port + '\\n');`,
` const lines = fs.existsSync(reversed) ? fs.readFileSync(reversed, 'utf8').split('\\n') : [];`,
` const ports = lines.filter(Boolean).map((line) => JSON.parse(line)).filter(([serial]) => serial === args[1]).map(([, port]) => port);`,
` ports.push(JSON.parse(process.env.STUB_ADB_REVERSE || '{}')[args[1]]);`,
` for (const port of new Set(ports.filter(Boolean))) process.stdout.write(args[1] + ' tcp:' + port + ' tcp:' + port + '\\n');`,
` process.exit(0);`,
`}`,
`if (args.includes('getprop')) { process.stdout.write('1\\n'); process.exit(0); }`,
Expand Down Expand Up @@ -264,6 +269,19 @@ export async function installStubAdbAsync(
` process.stdout.write(apk.subarray(skip * bs, (skip + count) * bs));`,
` process.exit(0);`,
`}`,
`if (args[2] === 'reverse' && /^tcp:[0-9]+$/.test(args[3]) && args[3] === args[4]) {`,
` if (!serials.includes(args[1])) process.exit(1);`,
` fs.appendFileSync(reversed, JSON.stringify([args[1], Number(args[3].slice(4))]) + '\\n');`,
` process.exit(0);`,
`}`,
`if (args[2] === 'shell' && args[3] === 'am' && args[4] === 'start' && args.includes('-d')) {`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This accepts am start -d, but the new recovery rows also run pidof and am force-stop. Those still fall through to the unexpected exit 2 below. stopAppOnDeviceAsync treats a non-zero force-stop as failure, so recovered stays false and the second open never happens.

The new tests still pass. They only check that a force-stop argv was recorded, not that stderr says the app was stopped and the link opened again. The unit test that drove a successful force-stop was removed. Accept pidof (exit 1, empty stdout) and am force-stop (exit 0), and assert that recovery sentence on the exit-22 rows.

@vonovak vonovak Oct 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 67e7eff

` process.exit(serials.includes(args[1]) ? 0 : 1);`,
`}`,
`if (args[2] === 'shell' && args[3] === 'pidof') {`,
` if (process.env.STUB_ADB_PIDOF) { process.stdout.write(process.env.STUB_ADB_PIDOF + '\\n'); process.exit(0); }`,
` process.exit(1);`,
`}`,
`if (args[2] === 'shell' && args[3] === 'am' && args[4] === 'force-stop') process.exit(0);`,
`process.stderr.write('stub adb: unexpected ' + args.join(' ') + '\\n');`,
`process.exit(2);`,
].join('\n')
Expand Down
Loading
Loading