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
1d0c408
[agent-cli] the device registry: one iOS simulator per worktree, boun…
vonovak Oct 8, 2026
43b0667
[agent-cli] dev binds the worktree's iOS simulator, and every verb re…
vonovak Oct 8, 2026
4ee9d3d
[agent-cli] e2e seeds the binding where a verb meets a booted simulat…
vonovak Oct 8, 2026
28b50e0
[agent-cli] changelog link for #134
vonovak Oct 8, 2026
1ac06ff
[agent-cli] the review of #134: absent survives a missing adb, the Tr…
vonovak Oct 8, 2026
fc4bd36
[agent-cli] a created simulator is a mainstream iPhone on the newest …
vonovak Oct 8, 2026
b361ac0
[agent-cli] dev --ios binds nothing off macOS, and the module's tests…
vonovak Oct 8, 2026
b91d87d
[agent-cli] a bound simulator whose runtime is gone counts as gone, s…
vonovak Oct 8, 2026
b048411
test(agent-cli): model the iOS host in device binding cases
vonovak Oct 9, 2026
d03a59e
fix(agent-cli): revalidate iOS inventory after concurrent binding cha…
vonovak Oct 12, 2026
6fc83b1
fix(agent-cli): guard detached iOS reuse on macOS
vonovak Oct 12, 2026
5f0880b
test(agent-cli): restore bound iOS Expo Go smoke coverage
vonovak Oct 12, 2026
97c7ad4
[agent-cli] e2e builds each project's Expo home with path.join (llp/0…
vonovak Oct 13, 2026
86dd3f7
[agent-cli] llp/0035 matches the iOS binding as built: no eas rung op…
vonovak Oct 14, 2026
5eaa0b4
[agent-cli] A failed boot lets go only of the binding it wrote (llp/0…
vonovak Oct 14, 2026
08b5222
[agent-cli] A read verb refuses gone when its binding names another d…
vonovak Oct 14, 2026
cb0a2e8
[agent-cli] Choose the highest-numbered mainstream iPhone in any list…
vonovak Oct 14, 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
8 changes: 5 additions & 3 deletions .github/workflows/agent-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,8 @@ jobs:
working-directory: packages/@expo/agent-cli

# The iOS half of the installed-app check only runs where `hostPlatforms()` asks for iOS: macOS.
# One runner, two files — the stub `xcrun` (simctl and devicectl) sits first on PATH, so no real
# simulator or phone is touched. Kept apart from the shards because macOS minutes cost ten times
# a Linux minute and these two files are the only ones that need the platform.
# The stub `xcrun` (simctl and devicectl) sits first on PATH, so no real simulator or phone
# is touched. Also run the macOS-only device-binding cases that Linux/Windows shards skip.
tier0-e2e-macos:
if: github.event_name != 'schedule'
runs-on: macos-15
Expand All @@ -124,6 +123,9 @@ jobs:
- name: 🧪 E2E installed-app check on macOS
run: bun run test:e2e -- status-installed-test installed-readers-test
working-directory: packages/@expo/agent-cli
- 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

# 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
1 change: 1 addition & 0 deletions packages/@expo/agent-cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
### 🎉 New features

- `navigate` and `smoke` now suppress onboarding, the floating Tools button, and automatic dev-menu opening when launching a development build. Set `EXPO_NO_DEV_MENU=0` to opt out.
- `dev --ios` binds an iOS simulator of its own to the git worktree it runs in, and every verb of that worktree uses it. The simulator is one this CLI created, named `agent-cli <digest>`, or the one the worktree bound before; it is booted with `simctl bootstatus -b`, the build and install steps carry `--device <udid>`, and the open deep-links to it. `navigate`, `runtime:reload`, `runtime:stop` and `status --explain` read the binding, extend its 60-minute lease, and never pick the first booted simulator: with no binding they refuse with `NO_BOUND_DEVICE` (exit 20) and name `dev --ios --detach --wait-ready`. `dev --detach` on a running server boots the worktree's own simulator and refuses `not-reusable` when it has none; a device refusal in the detached child reaches the caller with its own code, exit and `Try:` line. `smoke --ios` gets its simulator from the binding too and leaves it bound. The bindings live under `<expo home>/agent-cli/bindings`, written under one machine-wide lock. Android keeps the first `adb` device until its own binding lands. ([#134](https://github.com/expo/expo-agent-cli/pull/134) by [@vonovak](https://github.com/vonovak))
- `status --explain` reports what the app installed on a device was built from. It reads the fingerprint a debug build embeds (`expo-constants`, SDK 58+) out of the app on an Android device or a booted iOS simulator, compares it with the project's fingerprint for that platform, and says `up to date`, `rebuild required` or `unknown`, with the commands that fix a stale one. On an older SDK it says the app cannot be checked and reads no device. Right about a build somebody else made, which the freshness line cannot be. Android reads a few hundred KB of the APK through `adb` instead of pulling it; `--device <name>` narrows the read to one device. ([#59](https://github.com/expo/expo-agent-cli/pull/59) by [@vonovak](https://github.com/vonovak))
- `status --explain --device <phone>` reads the app on a physical iPhone. `devicectl` exposes no app container, so the app is launched with a one-time URL and posts its embedded fingerprint back over the LAN; it needs a debug build whose `expo-dev-client` answers the check. Naming the phone is the consent, because the probe launches the app and `status` otherwise starts nothing. `--device-timeout <secs>` bounds how long the phone gets to answer. A phone that does not answer in time reads as unknown. ([#90](https://github.com/expo/expo-agent-cli/pull/90) by [@vonovak](https://github.com/vonovak))
- `smoke --eas` brings its own EAS Simulator session the way `smoke` boots a simulator: its dev server starts through `dev --eas` (tunnelled; a build the plan needs is the simulator profile on EAS), the session in progress on the platform is reused, and otherwise one is started with Expo Go or with the finished EAS build of this fingerprint — and stopped again when the run ends. A development build with no finished build on EAS is a failed `start-session` phase naming `dev --eas`; the gate never compiles. `dev:stop --eas` also ends this project's session, by id, and reports it under `session`. ([#37](https://github.com/expo/expo-agent-cli/pull/37) by [@kudo](https://github.com/kudo))
Expand Down
65 changes: 65 additions & 0 deletions packages/@expo/agent-cli/e2e/__tests__/dev-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@ import {
installStubBinAsync,
installStubFingerprintAsync,
killAsync,
pathEnvVars,
readDevLockAsync,
readStubExpoInvocations,
setupFixtureAsync,
spawnAgentCli,
waitForAsync,
waitForDevLockAsync,
expoHomeOf,
} from '../utils';
import { installStubXcrunAsync } from './installedAppStubs';

/** The `--port` that `dev` puts on every step that serves. Its value is whatever this machine has free. */
const PORT_ARGS = ['--port', expect.stringMatching(/^\d+$/)];
Expand Down Expand Up @@ -1073,4 +1076,66 @@ describe('@expo/agent-cli dev', () => {
}
});
});

// @ref llp/0035-one-device-per-worktree.rfc.md §iOS tests
//
// Two worktrees of one app, one stub `simctl` whose simulator state both runs share, and one
// registry: each `dev --ios` creates its own simulator, binds it, boots it with `bootstatus -b`,
// and pins its build to it. The `--device` on each `run:ios` is the id in that worktree's binding,
// and the two ids differ.
describe.skipIf(process.platform !== 'darwin')('two worktrees, two simulators', () => {
/** Every binding in the shared registry, by the root it belongs to. */
function readBindings(home: string): Record<string, { udid: string; name: string }> {
const dir = path.join(home, 'agent-cli', 'bindings');
const bindings: Record<string, { udid: string; name: string }> = {};
for (const name of fs
.readdirSync(dir)
.filter((entry) => entry.endsWith('-ios-local-ios.json'))) {
const binding = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
bindings[binding.projectRoot] = { udid: binding.device.udid, name: binding.device.name };
}
return bindings;
}

it('binds a simulator of its own to each worktree and builds onto it', async () => {
const [first, second] = await Promise.all([
setupAsync('dev-client-app'),
setupAsync('dev-client-app'),
]);
// One `simctl`, installed once: both runs create and boot against its one simulator state.
const { binDir, calls: readXcrun } = await installStubXcrunAsync(first!);
const home = expoHomeOf(first);
const env = {
...pathEnvVars(`${binDir}${path.delimiter}${process.env.PATH ?? process.env.Path ?? ''}`),
__UNSAFE_EXPO_HOME_DIRECTORY: home,
AGENT_CLI_NO_DEVICE: '0',
};

const results = await Promise.all(
[first!, second!].map((root) =>
executeAgentCliAsync(root, ['dev', '--ios', '--local'], { env })
)
);

expect(results.map((result) => result.exitCode)).toEqual([0, 0]);
const bindings = readBindings(home);
const udids = [first!, second!].map((root) => bindings[fs.realpathSync.native(root)]!.udid);
expect(udids).toEqual(expect.arrayContaining(['E2E-CREATED-1', 'E2E-CREATED-2']));
expect(new Set(udids).size).toBe(2);
for (const [index, root] of [first!, second!].entries()) {
expect(invocationArgs(root)).toEqual([
['prebuild', '--platform', 'ios'],
['run:ios', ...PORT_ARGS, '--device', udids[index]],
]);
}
const booted = readXcrun()
.filter((argv) => argv[1] === 'bootstatus')
.map((argv) => argv[2]);
expect(new Set(booted)).toEqual(new Set(udids));
expect(readXcrun().every((argv) => argv[1] !== 'bootstatus' || argv.includes('-b'))).toBe(
true
);
expect(readXcrun().some((argv) => argv[1] === 'boot')).toBe(false);
});
});
});
53 changes: 36 additions & 17 deletions packages/@expo/agent-cli/e2e/__tests__/installed-readers-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ import { listConnectedIosDevicesAsync } from '../../src/device/devicectl';
import { readInstalledFingerprintAndroidAsync } from '../../src/installedApp/android';
import { readInstalledFingerprintIosAsync } from '../../src/installedApp/ios';
import { readInstalledFingerprintIosDeviceAsync } from '../../src/installedApp/iosDevice';
import { readInstalledFingerprintIosSimulatorAsync } from '../../src/installedApp/iosSimulator';
import { getTemporaryPath, pathEnvVars } from '../utils';
import { getTemporaryPath, pathEnvVars, expoHomeOf } from '../utils';
import {
EMBEDDED_HASH,
EMULATOR_NAME,
Expand Down Expand Up @@ -106,15 +105,23 @@ describe('the simulator reader over a stub xcrun', () => {
});
afterEach(() => restore());

it('asks simctl for the app container and reads the file inside it', async () => {
/** The stubs on `PATH`, and the registry in the fixture's own Expo home. */
function useStubs(binDir: string): void {
restore = withEnv({
...pathEnvVars(`${binDir}${path.delimiter}${process.env.PATH ?? process.env.Path ?? ''}`),
__UNSAFE_EXPO_HOME_DIRECTORY: expoHomeOf(root),
});
}

it('reads the bound simulator: one listing, then the app container and the file inside it', async () => {
const xcrun = await installStubXcrunAsync(root, { booted: { fingerprint: EMBEDDED_HASH } });
restore = withEnv(
pathEnvVars(`${xcrun.binDir}${path.delimiter}${process.env.PATH ?? process.env.Path ?? ''}`)
);
useStubs(xcrun.binDir);

const result = await readInstalledFingerprintIosSimulatorAsync({
const result = await readInstalledFingerprintIosAsync({
projectRoot: root,
appId: APP_ID,
expectedHash: EMBEDDED_HASH,
scheme: null,
});

expect(result).toMatchObject({
Expand All @@ -123,20 +130,27 @@ describe('the simulator reader over a stub xcrun', () => {
device: { name: SIMULATOR_NAME, identifier: SIMULATOR_UDID },
});
expect(xcrun.calls()).toEqual([
['simctl', 'list', 'devices', 'booted', '-j'],
['simctl', 'list', 'devices', '-j'],
['simctl', 'get_app_container', SIMULATOR_UDID, APP_ID],
]);
});

it('answers no-device when no simulator is booted', async () => {
it('answers no-device with the dev command when nothing is bound', async () => {
const xcrun = await installStubXcrunAsync(root);
restore = withEnv(
pathEnvVars(`${xcrun.binDir}${path.delimiter}${process.env.PATH ?? process.env.Path ?? ''}`)
);
useStubs(xcrun.binDir);

await expect(
readInstalledFingerprintIosSimulatorAsync({ appId: APP_ID, expectedHash: EMBEDDED_HASH })
).resolves.toEqual({ status: 'no-device' });
readInstalledFingerprintIosAsync({
projectRoot: root,
appId: APP_ID,
expectedHash: EMBEDDED_HASH,
scheme: null,
})
).resolves.toEqual({
status: 'no-device',
hint: expect.stringContaining('dev --ios --detach --wait-ready'),
});
expect(xcrun.calls().some((args) => args[0] === 'simctl')).toBe(false);
});
});

Expand All @@ -155,9 +169,12 @@ describe('the phone probe over a stub devicectl', () => {

async function stubPhoneAsync(phone: { fingerprint: string | null; postDelayMs?: number }) {
const xcrun = await installStubXcrunAsync(root, { phone });
restore = withEnv(
pathEnvVars(`${xcrun.binDir}${path.delimiter}${process.env.PATH ?? process.env.Path ?? ''}`)
);
restore = withEnv({
...pathEnvVars(
`${xcrun.binDir}${path.delimiter}${process.env.PATH ?? process.env.Path ?? ''}`
),
__UNSAFE_EXPO_HOME_DIRECTORY: expoHomeOf(root),
});
return xcrun;
}

Expand Down Expand Up @@ -229,6 +246,7 @@ describe('the phone probe over a stub devicectl', () => {
it('names a connected phone in a hint and leaves it alone until --device names it', async () => {
const xcrun = await stubPhoneAsync({ fingerprint: EMBEDDED_HASH });
const unnamed = await readInstalledFingerprintIosAsync({
projectRoot: root,
appId: APP_ID,
expectedHash: EMBEDDED_HASH,
scheme: 'installedapp',
Expand All @@ -241,6 +259,7 @@ describe('the phone probe over a stub devicectl', () => {
expect(xcrun.calls().some((args) => args[3] === 'launch')).toBe(false);

const named = await readInstalledFingerprintIosAsync({
projectRoot: root,
appId: APP_ID,
expectedHash: EMBEDDED_HASH,
device: PHONE_NAME,
Expand Down
61 changes: 59 additions & 2 deletions packages/@expo/agent-cli/e2e/__tests__/installedAppStubs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
import fs from 'node:fs';
import path from 'node:path';

import { installStubBinAsync } from '../utils';
import { digestOf } from '../../src/devLock/address';
import { canonicalizeExistingPath } from '../../src/utils/dir';
import { installStubBinAsync, expoHomeOf } from '../utils';

export const APK_FIXTURE = path.resolve(__dirname, '../../src/__fixtures__/zip/fixture-stored.zip');
/** An archive whose `app.fingerprint` is not the JSON `expo-constants` writes: a pre-JSON build. */
Expand Down Expand Up @@ -38,6 +40,35 @@ export const PHONE_UDID = '00001110-001111110110101A';

export type StubCalls = () => string[][];

/**
* Bind `udid` to the fixture worktree in the registry the harness's Expo home holds
* (`<projectRoot>/.expo-home`, @ref ../utils §spawnAgentCli), as `dev` would have: `created`, the
* lease 60 min ahead, the canonical root. Every verb of the fixture then drives this simulator
* (llp/0035 §Readers), so a case that hands a verb a booted simulator seeds this first.
*/
export async function bindingFixture(projectRoot: string, udid: string, name = SIMULATOR_NAME) {
const root = canonicalizeExistingPath(projectRoot);
const now = Date.now();
const file = path.join(
expoHomeOf(projectRoot),
'agent-cli',
'bindings',
`${digestOf(root)}-ios-local-ios.json`
);
await fs.promises.mkdir(path.dirname(file), { recursive: true });
await fs.promises.writeFile(
file,
JSON.stringify({
version: 1,
device: { backend: 'local-ios', platform: 'ios', udid, name, origin: 'created' },
projectRoot: root,
boundAt: new Date(now).toISOString(),
expiresAt: new Date(now + 60 * 60_000).toISOString(),
})
);
return file;
}

function readCalls(recordPath: string): string[][] {
return fs.existsSync(recordPath)
? fs
Expand Down Expand Up @@ -126,7 +157,8 @@ const STUB_RUNTIME = 'com.apple.CoreSimulator.SimRuntime.iOS-26-0';
*
* `simctl list devices` answers with one booted simulator when `booted` is given, whose app
* container holds `booted.fingerprint` at the static-linking path; otherwise with `simulators`
* (each `Shutdown` unless it says otherwise); otherwise none. The listing is state the stub keeps:
* (each `Shutdown` unless it says otherwise); otherwise none. A booted simulator is also bound to
* the fixture through {@link bindingFixture}. The listing is state the stub keeps:
* `create` adds a `Shutdown` simulator whose udid is `E2E-CREATED-<n>`, `delete` removes one,
* `boot`, `bootstatus -b` and `shutdown` change its state, as a real `simctl` does. `list runtimes -j` names one
* iOS runtime.
Expand All @@ -144,11 +176,14 @@ export async function installStubXcrunAsync(
booted,
simulators,
runningAppIds,
expoGoMissing = false,
phone,
}: {
booted?: { fingerprint: string };
simulators?: { udid: string; name: string; state?: 'Booted' | 'Shutdown' }[];
runningAppIds?: string[];
/** Model a fresh Expo Go install; opening requires installation on that booted simulator. */
expoGoMissing?: boolean;
/** `postDelayMs` holds the POST back, the way a slow cold launch does. */
phone?: { fingerprint: string | null; postDelayMs?: number };
} = {}
Expand Down Expand Up @@ -177,6 +212,13 @@ export async function installStubXcrunAsync(
)
);
await fs.promises.rm(counterPath, { force: true });
// A booted simulator the stub lists is one `dev` bound to this fixture, or no verb would drive it.
const bound = booted
? { udid: SIMULATOR_UDID, name: SIMULATOR_NAME }
: simulators?.find((simulator) => simulator.state === 'Booted');
if (bound) {
await bindingFixture(root, bound.udid, bound.name);
}
const binDir = path.join(root, '.stub-bin');
const scriptPath = path.join(binDir, 'xcrun-stub.js');
await fs.promises.mkdir(binDir, { recursive: true });
Expand Down Expand Up @@ -224,7 +266,21 @@ export async function installStubXcrunAsync(
` if (sim) { sim.state = 'Booted'; save(); }`,
` process.exit(0);`,
`}`,
`if (${expoGoMissing} && args[0] === 'simctl' && args[1] === 'install') {`,
` const sim = find(args[2]);`,
` if (!sim || sim.state !== 'Booted' || !fs.existsSync(args[3])) process.exit(1);`,
` sim.expoGoInstalled = true;`,
` save();`,
` process.exit(0);`,
`}`,
`if (${expoGoMissing} && args[0] === 'simctl' && args[1] === 'spawn') {`,
` const sim = find(args[2]);`,
` const approval = args[5] === 'com.apple.launchservices.schemeapproval' && ['com.apple.CoreSimulator.CoreSimulatorBridge-->exp', 'com.apple.CoreSimulator.CoreSimulatorBridge-->exps'].includes(args[6]) && args[7] === '-string' && args[8] === 'host.exp.Exponent';`,
` const onboarding = args[5] === 'host.exp.Exponent' && args[6] === 'EXDevMenuIsOnboardingFinished' && args[7] === '-bool' && args[8] === 'true';`,
` if (sim?.state === 'Booted' && sim.expoGoInstalled && args[3] === 'defaults' && args[4] === 'write' && (approval || onboarding)) process.exit(0);`,
`}`,
`if (args[0] === 'simctl' && args[1] === 'openurl') {`,
` if (${expoGoMissing} && !find(args[2])?.expoGoInstalled) process.exit(115);`,
` fs.writeFileSync(marker, '');`,
` process.exit(0);`,
`}`,
Expand All @@ -238,6 +294,7 @@ export async function installStubXcrunAsync(
` process.exit(0);`,
`}`,
`if (args[1] === 'get_app_container') {`,
` if (${expoGoMissing} && !find(args[2])?.expoGoInstalled) process.exit(1);`,
` process.stdout.write(${JSON.stringify(container)} + '\\n');`,
` process.exit(0);`,
`}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
stubExpoEnv,
type StubDevServer,
} from '../utils';
import { bindingFixture } from './installedAppStubs';

/** A port nothing listens on, for the "no dev server" state. */
const DEAD_URL = 'http://127.0.0.1:1';
Expand Down Expand Up @@ -290,6 +291,7 @@ describe('a dev server whose app is re-registering', () => {
* device method *starts* the app, so its runtime registers because a device tool ran.
*/
async function installStubXcrunAsync(root: string, startedMarker: string | null): Promise<void> {
await bindingFixture(root, 'PREFLIGHT-SIM', 'iPhone 17 Pro');
const scriptPath = path.join(root, '.stub-bin', 'xcrun-preflight.js');
await fs.promises.mkdir(path.dirname(scriptPath), { recursive: true });
await fs.promises.writeFile(
Expand Down
Loading
Loading