Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ecbdca6
[agent-cli] Reload selected apps without broadcasting to skipped targets
vonovak Oct 28, 2026
11890f8
[agent-cli] Test reloads beside skipped apps end to end
vonovak Oct 29, 2026
80bb1ce
[agent-cli] Give the smoke reload test a real worktree beside a clone
vonovak Oct 29, 2026
267ec98
[agent-cli] Point llp/0005's rung-1 rules at the e2e cases that pin them
vonovak Oct 29, 2026
8f608ec
[agent-cli] Run the reload tests beside a local simulator on macOS only
vonovak Oct 29, 2026
890763b
[agent-cli] Keep the reload tests and the smoke line cap in step with…
vonovak Oct 29, 2026
eab99a4
[agent-cli] runtime:reload exits 22 at once after a relaunch that not…
vonovak Oct 29, 2026
1ac0d3d
[agent-cli] runtime:reload gives Page.reload 8 s to prove itself, and…
vonovak Oct 29, 2026
bd06b1a
[agent-cli] Name the at-once exit after an unprovable relaunch in the…
vonovak Oct 29, 2026
0232cae
[agent-cli] The late-target reload case asserts the Page.reload and t…
vonovak Oct 29, 2026
08025a7
[agent-cli] runtime:reload reports a mark the budget or a lost connec…
vonovak Oct 29, 2026
3d4c104
[agent-cli] smoke takes a bundle on its platform as proof after a Pag…
vonovak Oct 29, 2026
0be889f
[agent-cli] runtime:reload keeps its first placement and sends no bro…
vonovak Oct 29, 2026
6d1051e
[agent-cli] The context proof sets its marker in one helper, which ke…
vonovak Oct 29, 2026
61b2b2e
[agent-cli] smoke names a skipped app once: the Page.reload reason co…
vonovak Oct 29, 2026
3b5d158
[agent-cli] runtime:reload counts a Page.reload whose answer was lost…
vonovak Oct 29, 2026
679538b
[agent-cli] runtime:reload --eas exits at once after a cloud relaunch…
vonovak Oct 29, 2026
1b7d82b
[agent-cli] The context proof's mark helper returns marked or unmarka…
vonovak Oct 30, 2026
341900f
[agent-cli] Rung 1's policy names mechanism none when the read before…
vonovak Oct 30, 2026
e70cb96
[agent-cli] runtime:reload and smoke read the page-id rule off the at…
vonovak Oct 30, 2026
aa3b02b
[agent-cli] smoke keeps the placement its app phase made when a devic…
vonovak Oct 30, 2026
2ef6b0b
[agent-cli] runtime:reload and smoke read rung 1's outcome through on…
vonovak Oct 30, 2026
2dde4b6
[agent-cli] A target check that another own app's proof stopped repor…
vonovak Oct 30, 2026
f2f7591
[agent-cli] Rung 1's comment names evidence.ts, where hasEvidenceFor …
vonovak Oct 30, 2026
93ae0b0
[agent-cli] runtime:reload decides a lost device-tool answer with the…
vonovak Oct 30, 2026
f5175d1
[agent-cli] CI runs the reload cases that need a local simulator on t…
vonovak Oct 30, 2026
3c27cff
[agent-cli] The relaunch-within-timeout reload case runs on macOS onl…
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 .github/workflows/agent-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ jobs:
- name: 🧪 E2E iOS device binding and Expo Go install
run: bun run test:e2e -- dev-test smoke-test -t "two worktrees, two simulators|binding the device to boot"
working-directory: packages/@expo/agent-cli
- name: 🧪 E2E reload beside a local simulator
run: bun run test:e2e -- runtime-target-selection-test
working-directory: packages/@expo/agent-cli

# Windows unit coverage: `resolveSpawnTarget` and the win32 branches of the path helpers.
# The win32 e2e run — including the skills linker's directory-junction path, which nothing
Expand Down
2 changes: 2 additions & 0 deletions packages/@expo/agent-cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@
- Runtime reads no longer read an app on a simulator that another worktree bound with a live lease. They skip it and name it on stderr; with only such apps connected, they refuse with `NO_APP_CONNECTED` and name the worktree, also when the app list changes after the check. When the skipped app is on a local simulator that is not the run's device (an EAS Simulator session's run, for one), the refusal says no app on the run's device is connected, not that no app of this worktree is. `runtime:reload` lists them in `skippedApps` and does not check their bundle. `smoke` now also skips another worktree's app, lists it in `skippedApps`, and names it and its worktree when no other app is connected. ([#204](https://github.com/expo/expo-agent-cli/pull/204) by [@vonovak](https://github.com/vonovak))
- 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))

### 💡 Others

- `status` now summarizes itself as the check for Expo Go support and rebuild needs, the help notes explain `dev --plan`, `status` impact, and non-interactive `agents:setup`, and a failed `runtime:errors` suggests `dev:logs` to find bundling errors. ([#57](https://github.com/expo/expo-agent-cli/pull/57) by [@kudo](https://github.com/kudo))
Expand Down
47 changes: 47 additions & 0 deletions packages/@expo/agent-cli/e2e/__tests__/cloud-simulator-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,53 @@ describe('@expo/agent-cli runtime:reload --eas with no platform', () => {
}
}
);

// A relaunched session app comes back under its page id, and the local app's iOS bundle cannot
// prove it, so nothing can arrive: the run says so at once instead of waiting out --timeout.
it.skipIf(process.platform !== 'darwin')(
`does not wait out --timeout after a cloud relaunch that nothing can prove`,
async () => {
const projectRoot = await setupAsync('go-app');
await writeSessionFileAsync(projectRoot, 'sess-e2e');
await installStubXcrunAsync(projectRoot, { simulators: [BOOTED_SIMULATOR] });
const stub = await startStubDevServerAsync({
targets: [
makeTarget({
id: 'local',
appId: 'host.exp.Exponent',
deviceName: BOOTED_SIMULATOR.name,
}),
makeTarget({ id: 'session', appId: 'host.exp.Exponent', deviceName: 'iPhone 17' }),
],
projectRoot,
messageSocket: 'none',
manifestOrigin: 'https://stub-tunnel.example',
});
const releaseLock = await holdDevLockAsync(projectRoot, {
url: stub.url,
port: stub.port,
pid: process.pid,
startedAt: new Date().toISOString(),
projectRoot,
});
try {
const started = Date.now();
const result = await executeAgentCliAsync(
projectRoot,
['runtime:reload', '--eas', '--ios', '--method', 'device', '--timeout', '10s', '--json'],
{ reject: false }
);
const elapsed = Date.now() - started;
const report = JSON.parse(result.stdout);
expect(report.attempts.at(-1), result.all).toMatchObject({ method: 'device', ok: true });
expect(result.exitCode).toBe(22);
expect(elapsed).toBeLessThan(8000);
} finally {
await releaseLock();
await stub.close();
}
}
);
});

// @ref llp/0005-runtime-loop-tools.rfc.md §Which app a run reads — `--eas` with no platform.
Expand Down
92 changes: 75 additions & 17 deletions packages/@expo/agent-cli/e2e/__tests__/runtime-preflight-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,23 +487,7 @@ describe('a worktree bound to an EAS Simulator session, with only a local app co
let stub: StubDevServer;
let cloudBinding: string;
beforeAll(async () => {
await installStubXcrunAsync(projectRoot, null);
const root = canonicalizeExistingPath(projectRoot);
const bindings = path.join(expoHomeOf(projectRoot), 'agent-cli', 'bindings');
await fs.promises.rm(path.join(bindings, `${digestOf(root)}-ios-local-ios.json`), {
force: true,
});
cloudBinding = path.join(bindings, `${digestOf(root)}-ios-cloud.json`);
await fs.promises.writeFile(
cloudBinding,
JSON.stringify({
version: 1,
device: { backend: 'cloud', platform: 'ios', id: '01a160c1', origin: 'started' },
projectRoot: root,
boundAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(),
})
);
cloudBinding = await bindToSessionAsync();
stub = await startStubDevServerAsync({
targets: [{ ...EXPO_GO_TARGET, appId: 'dev.vonovak.cyclable' }],
inspectorEvaluate: () => undefined,
Expand Down Expand Up @@ -532,3 +516,77 @@ describe('a worktree bound to an EAS Simulator session, with only a local app co
expect(result.stderr).not.toContain('Scoped to ios');
});
});

// @ref llp/0005-runtime-loop-tools.rfc.md §Target owner
// N2-17: the same session binding, with the session's app ("iPhone 17") beside an app on the booted
// local simulator ("iPhone 17 Pro"). The dev server relays a broadcast to both, so runtime:reload
// sends none: it reloads the session's app over Page.reload and proves it there.
describe('a worktree bound to an EAS Simulator session, beside a local app', () => {
let stub: StubDevServer;
let cloudBinding: string;
beforeAll(async () => {
cloudBinding = await bindToSessionAsync();
const devBuild = { ...EXPO_GO_TARGET, appId: 'dev.vonovak.cyclable' };
stub = await startStubDevServerAsync({
targets: [
devBuild,
{
...devBuild,
id: '2',
deviceName: 'iPhone 17',
webSocketDebuggerUrl: 'ws://127.0.0.1:8081/inspector/debug?device=2&page=1',
},
],
});
});
afterAll(async () => {
await stub.close();
fs.rmSync(cloudBinding, { force: true });
});

it('runtime:reload sends no broadcast and reloads only the session app', async () => {
const result = await executeAgentCliAsync(
projectRoot,
['runtime:reload', '--dev-server-url', stub.url, '--no-bundle-check', '--json'],
{ reject: false, env: { ...stubExpoEnv(projectRoot), AGENT_CLI_NO_DEVICE: '0' } }
);

expect(stub.broadcasts()).toBe(0);
expect(stub.pageReloads()).toEqual(['2']);
expect(result.exitCode).toBe(0);
expect(JSON.parse(result.stdout)).toMatchObject({
reloaded: true,
verifiedBy: 'debugger-context-replaced',
bundlesAfterReload: {
observed: null,
reason: expect.stringContaining('new JavaScript context'),
},
skippedApps: [{ deviceName: 'iPhone 17 Pro', group: 'other-device' }],
});
});
});

/**
* Bind this worktree to an EAS Simulator iOS session, with "iPhone 17 Pro" booted on this machine
* and not bound to it. Returns the binding file.
*/
async function bindToSessionAsync(): Promise<string> {
await installStubXcrunAsync(projectRoot, null);
const root = canonicalizeExistingPath(projectRoot);
const bindings = path.join(expoHomeOf(projectRoot), 'agent-cli', 'bindings');
await fs.promises.rm(path.join(bindings, `${digestOf(root)}-ios-local-ios.json`), {
force: true,
});
const cloudBinding = path.join(bindings, `${digestOf(root)}-ios-cloud.json`);
await fs.promises.writeFile(
cloudBinding,
JSON.stringify({
version: 1,
device: { backend: 'cloud', platform: 'ios', id: '01a160c1', origin: 'started' },
projectRoot: root,
boundAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(),
})
);
return cloudBinding;
}
Loading