Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
033280f
[agent-cli] An EAS Simulator scope reads its unclaimed target and ski…
vonovak Oct 28, 2026
64d1dab
[agent-cli] Test the EAS Simulator scope's unclaimed apps end to end
vonovak Oct 29, 2026
a43df91
[agent-cli] A no-platform EAS scope reads no unclaimed app beside an …
vonovak Oct 29, 2026
41d59d1
[agent-cli] Point the EAS scope rows at their e2e cases, and pin the …
vonovak Oct 29, 2026
a8f0bfa
[agent-cli] Quote the other-platform test title exactly in llp/0005
vonovak Oct 29, 2026
c72bc1d
[agent-cli] Give the no-platform EAS classifier test its own targets
vonovak Oct 29, 2026
198a5cc
[agent-cli] Run the booted-simulator EAS reload test on macOS only
vonovak Oct 29, 2026
d9d2365
[agent-cli] A simulator entry with no name no longer hides the other …
vonovak Oct 29, 2026
e599a28
[agent-cli] An EAS run holds an Android-shaped app while adb is silen…
vonovak Oct 29, 2026
c3a3948
[agent-cli] A silent adb is androidModels null, not a second status f…
vonovak Oct 29, 2026
18bc039
[agent-cli] a device tool that gives no answer keeps the facts that p…
vonovak Oct 30, 2026
f82b87d
[agent-cli] The simctl listing parser returns the valid entries and w…
vonovak Oct 30, 2026
e5e39f9
[agent-cli] The kept-facts wait test builds its scope from the fixture
vonovak Oct 30, 2026
6851f00
[agent-cli] An attachment wait reads a changed list again after a dev…
vonovak Oct 30, 2026
7b7a87e
[agent-cli] The kept-facts wait tests run as on macOS, where simctl a…
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
1 change: 1 addition & 0 deletions packages/@expo/agent-cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
- `smoke` asks an app it opened or reloaded again for 5 s after its first "method not found", instead of reporting a runtime with no debugger. ([#201](https://github.com/expo/expo-agent-cli/pull/201) by [@vonovak](https://github.com/vonovak))
- `status` with an EAS Simulator session on record and no app connected now names the session's platform (`navigate / --<p> --eas`), and with an app connected its `smoke --<p> --eas` step says the session is recorded, not checked, and that if it ended, smoke starts a new billed session only for Expo Go or a finished simulator build. ([#208](https://github.com/expo/expo-agent-cli/pull/208) by [@vonovak](https://github.com/vonovak))
- `smoke` asks for its device again in a later phase when an earlier lookup failed, instead of keeping that failure for the rest of the run. ([#202](https://github.com/expo/expo-agent-cli/pull/202) by [@vonovak](https://github.com/vonovak))
- Runtime reads on an EAS Simulator session now read the session's app when it is the one unclaimed debugger target, also with `--eas` and no platform flag, and skip the apps on local simulators. When `adb` gave no answer, an Android-shaped app stays unread instead of blocking the session's app, and the refusal for two unclaimed apps says to close the apps that are not the session's. ([#203](https://github.com/expo/expo-agent-cli/pull/203) by [@vonovak](https://github.com/vonovak))

### 💡 Others

Expand Down
165 changes: 165 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 @@ -26,6 +26,8 @@ import {
startStubDevServerAsync,
type ExecuteResult,
} from '../utils';
import { makeTarget } from '../../src/runtime/__tests__/targetSelectionFixtures';
import { BOOTED_SIMULATOR, installStubXcrunAsync } from './installedAppStubs';

/** A debugger target that looks like Expo Go, which its app id alone places on iOS. */
const EXPO_GO_TARGET = {
Expand Down Expand Up @@ -370,6 +372,57 @@ describe('@expo/agent-cli navigate --eas', () => {
}
});

// The development build an EAS Simulator iOS session runs registers the project's own app id and
// the cloud simulator's model name, which no local tool reports. It is the session's app, so the
// run counts it as attached and opens only the route link, never the launcher URL.
it(`counts the session's development build as the iOS app, and opens only the route link`, async () => {
const projectRoot = await setupAsync('dev-client-app');
await writeSessionFileAsync(projectRoot, 'sess-e2e');
await installStubXcrunAsync(projectRoot, { simulators: [BOOTED_SIMULATOR] });
const stub = await startStubDevServerAsync({
targets: [{ id: '1', appId: 'com.example.demo', deviceName: 'iPhone 17' }],
projectRoot,
manifestOrigin: 'https://stub-tunnel.example',
});
const releaseLock = await holdDevLockAsync(projectRoot, {
url: stub.url,
port: stub.port,
pid: process.pid,
startedAt: new Date().toISOString(),
projectRoot,
});
try {
const result = await executeAgentCliAsync(
projectRoot,
[
'navigate',
'/',
'--ios',
'--eas',
'--scheme',
'demoapp',
'--app-id',
'com.example.demo',
'--attach-timeout',
'5s',
'--json',
'--no-followups',
],
{ reject: false }
);

const opens = easInvocations(projectRoot)
.filter((argv) => argv[0] === 'simulator:exec' && argv[3] === 'open')
.map((argv) => argv[4]);
expect(opens).toEqual(['demoapp://']);
expect(result.exitCode).toBe(0);
expect(JSON.parse(result.stdout)).toMatchObject({ launch: null, attached: true });
} finally {
await releaseLock();
await stub.close();
}
});

// A cloud simulator is on EAS's network, so a loopback host in the link resolves to a machine in
// a datacenter. Refused before anything opens, rather than opened onto an error screen.
it(`refuses a dev server only this machine can reach`, async () => {
Expand All @@ -393,6 +446,118 @@ describe('@expo/agent-cli navigate --eas', () => {
});
});

// @ref llp/0005-runtime-loop-tools.rfc.md §Which app a run reads — `--eas` with no platform.
describe('@expo/agent-cli runtime:reload --eas with no platform', () => {
// A booted local simulator exists only on macOS.
it.skipIf(process.platform !== 'darwin')(
`reloads the session's app, not the app on a booted local simulator`,
async () => {
const projectRoot = await setupAsync('dev-client-app');
await writeSessionFileAsync(projectRoot, 'sess-e2e');
await installStubXcrunAsync(projectRoot, { simulators: [BOOTED_SIMULATOR] });
const evaluated: string[] = [];
const stub = await startStubDevServerAsync({
targets: [
makeTarget({
id: 'local',
appId: 'host.exp.Exponent',
deviceName: BOOTED_SIMULATOR.name,
webSocketDebuggerUrl: 'ws://127.0.0.1:1/inspector/debug?device=local&page=1',
reactNative: { capabilities: { nativePageReloads: true }, logicalDeviceId: 'local' },
}),
makeTarget({
id: 'session',
appId: 'com.example.demo',
deviceName: 'iPhone 17',
webSocketDebuggerUrl: 'ws://127.0.0.1:1/inspector/debug?device=session&page=1',
reactNative: { capabilities: { nativePageReloads: true }, logicalDeviceId: 'session' },
}),
],
projectRoot,
inspectorEvaluate: (expression, device) => {
if (expression.includes('reloadAppAsync')) evaluated.push(device);
return undefined;
},
});
try {
const result = await executeAgentCliAsync(
projectRoot,
[
'runtime:reload',
'--eas',
'--method',
'runtime',
'--dev-server-url',
stub.url,
'--timeout',
'2s',
'--json',
'--no-followups',
],
{ reject: false }
);
expect(new Set(evaluated), result.all).toEqual(new Set(['session']));
} finally {
await stub.close();
}
}
);
});

// @ref llp/0005-runtime-loop-tools.rfc.md §Which app a run reads — `--eas` with no platform.
describe('@expo/agent-cli runtime:reload --eas with apps nothing claims', () => {
const unclaimed = (id: string, deviceName: string) =>
makeTarget({
id,
appId: 'com.example.demo',
deviceName,
webSocketDebuggerUrl: `ws://127.0.0.1:1/inspector/debug?device=${id}&page=1`,
reactNative: { capabilities: { nativePageReloads: true }, logicalDeviceId: id },
});

it.each([
{ name: 'no platform', flags: [], apps: ['iPhone 17', 'iPad Air'] },
{ name: '--ios', flags: ['--ios'], apps: ['iPhone 17', 'iPad Air'] },
{ name: '--android', flags: ['--android'], apps: ['iPhone 17'] },
])(`reads neither unclaimed app with $name`, async ({ flags, apps }) => {
const projectRoot = await setupAsync('dev-client-app');
await writeSessionFileAsync(projectRoot, 'sess-e2e');
await installStubXcrunAsync(projectRoot, { simulators: [BOOTED_SIMULATOR] });
const evaluated: string[] = [];
const stub = await startStubDevServerAsync({
targets: apps.map((name, index) => unclaimed(String(index + 1), name)),
projectRoot,
inspectorEvaluate: (expression, device) => {
if (expression.includes('reloadAppAsync')) evaluated.push(device);
return undefined;
},
});
try {
const result = await executeAgentCliAsync(
projectRoot,
[
'runtime:reload',
'--eas',
...flags,
'--method',
'runtime',
'--dev-server-url',
stub.url,
'--timeout',
'2s',
'--json',
'--no-followups',
],
{ reject: false }
);
expect(evaluated, result.all).toEqual([]);
expect(result.exitCode).not.toBe(0);
} finally {
await stub.close();
}
});
});

describe('@expo/agent-cli runtime:stop --eas', () => {
// The controller's `close <app-id>` ends the named app and leaves the billed machine up. The
// pinned argv is the point: `--shutdown` would tear down the session, and `simulator:stop` would
Expand Down
29 changes: 26 additions & 3 deletions packages/@expo/agent-cli/e2e/__tests__/runtime-preflight-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,15 +281,38 @@ describe('a run that names a platform', () => {
);
}

it('reads the app it places on that platform', async () => {
it(`reads the app it places on that platform, not the other platform's`, async () => {
const evaluated = new Set<string>();
const stub = await startStubDevServerAsync({
targets: [UNPLACED_TARGET, ANDROID_TARGET],
inspectorEvaluate: () => undefined,
targets: [UNPLACED_TARGET, EXPO_GO_TARGET, ANDROID_TARGET],
inspectorEvaluate: (_expression, device) => {
evaluated.add(device);
return undefined;
},
});
try {
const result = await runAndroidAsync(stub);

expect(JSON.parse(result.stdout).error?.code).not.toBe('NO_APP_CONNECTED');
expect([...evaluated]).toEqual(['2']);
} finally {
await stub.close();
}
});

it('does not read an app nothing places on a run without --eas', async () => {
const stub = await startStubDevServerAsync({
targets: [{ ...UNPLACED_TARGET, deviceName: 'iPhone 17' }],
inspectorEvaluate: () => undefined,
});
try {
const result = await executeAgentCliAsync(
projectRoot,
['runtime:eval', '1 + 1', '--ios', '--dev-server-url', stub.url, '--json'],
{ reject: false, env: { ...stubExpoEnv(projectRoot), AGENT_CLI_NO_DEVICE: '1' } }
);

expect(JSON.parse(result.stdout).error.code).toBe('NO_APP_CONNECTED');
} finally {
await stub.close();
}
Expand Down
89 changes: 89 additions & 0 deletions packages/@expo/agent-cli/e2e/__tests__/smoke-binding-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// These cases run the CLI and real configuration/binding readers. Device tools and the inspector
// are protocol doubles; successful evaluation proves selection and wiring, not Hermes behavior.
import fs from 'node:fs';
import path from 'node:path';

import { installStubEasAsync, writeCloudBindingAsync } from '../stubEas';
import { makeTarget } from '../../src/runtime/__tests__/targetSelectionFixtures';
import {
executeAgentCliAsync,
expoHomeOf,
readStubExpoInvocations,
setupFixtureAsync,
startStubDevServerAsync,
} from '../utils';
import { installStubXcrunAsync } from './installedAppStubs';

it('smoke evaluates the cloud app selected by deviceBackend config without --eas', async () => {
const root = await fs.promises.realpath(await setupFixtureAsync('go-app'));
const file = path.join(root, 'package.json');
const pkg = JSON.parse(await fs.promises.readFile(file, 'utf8'));
pkg.expo = { ...pkg.expo, 'agent-cli': { deviceBackend: 'eas' } };
await fs.promises.writeFile(file, JSON.stringify(pkg));
await installStubXcrunAsync(root, { simulators: [] });
await installStubEasAsync(root);
await writeCloudBindingAsync(expoHomeOf(root), root, { id: 'sess-e2e' });
const ready = path.join(root, '.expo-started');
const expoBin = path.join(root, 'node_modules/expo/bin/cli');
const original = await fs.promises.readFile(expoBin, 'utf8');
await fs.promises.writeFile(
expoBin,
original.replace(
'#!/usr/bin/env node',
`#!/usr/bin/env node
if (process.argv[2] === 'start') require('fs').writeFileSync(${JSON.stringify(ready)}, 'ready');`
)
);
const server = await startStubDevServerAsync({
projectRoot: root,
readyWithFile: ready,
targets: [
makeTarget({
id: 'cloud',
deviceName: 'iPhone 17',
reactNative: { capabilities: { nativePageReloads: true }, logicalDeviceId: 'cloud' },
}),
],
inspectorEvaluate: () => 1,
});
try {
const result = await executeAgentCliAsync(
root,
[
'smoke',
'--ios',
'--dev-server-url',
server.url,
'--no-reload',
'--no-screenshot',
'--window',
'100ms',
'--timeout',
'5s',
'--json',
'--no-followups',
],
{
reject: false,
env: {
STUB_EXPO_DELAY_MS: '30000',
STUB_EXPO_TUNNEL_HOST: 'stub-tunnel.example',
STUB_EXPO_DEV_SERVER_PORT: String(server.port),
},
}
);
expect(result.exitCode, result.all).toBe(0);
expect(readStubExpoInvocations(root).find(({ args }) => args[0] === 'start')?.args).toContain(
'--tunnel'
);
const report = JSON.parse(result.stdout);
expect(report.started, result.all).toBe(true);
expect(report.runtimeSupported).toBe(true);
expect(report.phases).toEqual(
expect.arrayContaining([expect.objectContaining({ id: 'runtime', status: 'ok' })])
);
} finally {
await executeAgentCliAsync(root, ['dev:stop', '--json'], { reject: false });
await server.close();
}
});
Loading