From 3e2d3b60ccdb8c72a5f2edb50113dba8b70a0903 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Fri, 28 Aug 2026 09:30:53 -0700 Subject: [PATCH 1/6] [eas-cli] add --network-capture to eas simulator --- CHANGELOG.md | 1 + .../simulator/__tests__/index.test.ts | 31 +++++++++++++++++++ .../eas-cli/src/commands/simulator/index.ts | 15 +++++++++ packages/eas-cli/src/graphql/generated.ts | 6 ++++ 4 files changed, 53 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fcfeeaaba..a1a8a19bca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ This is the log of notable changes to EAS CLI and related packages. - [build-tools] Support `EAS_BUN_FILTER_WORKSPACE` to install only the selected workspace's dependencies with `bun install --filter`. ([#4292](https://github.com/expo/eas-cli/pull/4292) by [@konrad-armatys](https://github.com/konrad-armatys)) - [build-tools] Support `EAS_PNPM_FILTER_WORKSPACE` to install only the selected workspace's dependencies with `pnpm install --filter`. ([#4302](https://github.com/expo/eas-cli/pull/4302) by [@robingullo](https://github.com/robingullo)) - [build-tools] Add the `eas/install_mitmproxy` step and start serve-sim with `--network-capture` when a simulator session requests network capture. ([#4307](https://github.com/expo/eas-cli/pull/4307) by [@gwdp](https://github.com/gwdp)) +- [eas-cli] Add `--network-capture` to `eas simulator` to record HTTP(S) traffic from apps on the device. ([#4300](https://github.com/expo/eas-cli/pull/4300) by [@gwdp](https://github.com/gwdp)) ### 🐛 Bug fixes diff --git a/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts index 771c4ef5a3..b1cfb8cd4b 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts @@ -444,6 +444,37 @@ describe(Simulator, () => { }); }); + it('passes --network-capture to the createDeviceRunSession mutation', async () => { + const { command } = createCommand([ + '--platform', + 'ios', + '--non-interactive', + '--network-capture', + ]); + await command.runAsync(); + + expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(graphqlClient, { + appId: 'project-123', + name: undefined, + networkCapture: true, + packageVersion: undefined, + platform: AppPlatform.Ios, + type: DeviceRunSessionType.AgentDevice, + }); + }); + + it('rejects --network-capture on android, instead of creating a session that records nothing', async () => { + const { command } = createCommand([ + '--platform', + 'android', + '--non-interactive', + '--network-capture', + ]); + + await expect(command.runAsync()).rejects.toThrow(/only supported on iOS/); + expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled(); + }); + it(`throws when ${EAS_SIMULATOR_SESSION_ID} is already present with --no-force`, async () => { process.env[EAS_SIMULATOR_SESSION_ID] = 'existing-session'; diff --git a/packages/eas-cli/src/commands/simulator/index.ts b/packages/eas-cli/src/commands/simulator/index.ts index b1f950bd5f..d518588825 100644 --- a/packages/eas-cli/src/commands/simulator/index.ts +++ b/packages/eas-cli/src/commands/simulator/index.ts @@ -112,6 +112,10 @@ export default class Simulator extends EasCommand { description: 'Version of the package backing the simulator session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.', }), + 'network-capture': Flags.boolean({ + description: + 'Record HTTP(S) traffic from apps on the device. HTTPS is decrypted, so recordings contain credentials in cleartext and certificate-pinned apps fail to connect. An app installed by this command is launched before capture starts, so relaunch it to record its traffic.', + }), 'max-duration-minutes': Flags.integer({ description: 'Maximum duration of the simulator session in minutes before it is automatically stopped. Only customizable on paid plans. Defaults to a value derived from the job run priority when omitted.', @@ -212,6 +216,11 @@ export default class Simulator extends EasCommand { } const platform = await resolvePlatformAsync(flags.platform, nonInteractive); + if (flags['network-capture'] && platform !== AppPlatform.Ios) { + throw new EasCommandError( + 'Network capture is only supported on iOS simulator sessions. Re-run without --network-capture, or pass --platform ios.' + ); + } const expoGoSdkVersion = flags['expo-go'] ? await resolveExpoGoSdkVersionAsync({ projectDir, sdkVersion: sdkVersionFromFlag }) : undefined; @@ -236,6 +245,7 @@ export default class Simulator extends EasCommand { platform, type: DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[flags.type], packageVersion: flags['package-version'], + networkCapture: flags['network-capture'], deviceIdentifier, ...(buildId ? { buildId } : {}), ...(applicationArchiveUrlFromFlag @@ -267,6 +277,11 @@ export default class Simulator extends EasCommand { simulatorEnvWritten ? `, saved to ${SIMULATOR_DOTENV_FILE_NAME}` : '' }) ${link(deviceRunSessionUrl)}` ); + if (flags['network-capture']) { + Log.warn( + 'Network capture is on. HTTPS is decrypted, so recordings contain credentials in cleartext. Relaunch an app installed by this command to record its traffic.' + ); + } } catch (err) { createSpinner.fail('Failed to create simulator session'); sessionInterrupt?.dispose(); diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index bb936114f3..24efb28ad9 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -5343,6 +5343,12 @@ export type CreateDeviceRunSessionInput = { * buildId, applicationArchiveUrl, or expoGo. */ openUrl?: InputMaybe; + /** + * Record HTTP(S) traffic from apps on the device for the whole session. HTTPS is + * decrypted by a proxy on the worker, so recordings contain credentials and cookies + * in cleartext. If omitted, no traffic is recorded. + */ + networkCapture?: InputMaybe; /** * The version of the package backing the device run session (e.g. "0.1.3-alpha.3"). * If omitted, consumers treat the session as pinned to "latest". From f34dba282cb4fb20a5163c005a253f4071be149f Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Fri, 28 Aug 2026 17:58:33 -0700 Subject: [PATCH 2/6] [eas-cli] fix changelog pr link --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1a8a19bca..5bd73517ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ This is the log of notable changes to EAS CLI and related packages. - [build-tools] Support `EAS_BUN_FILTER_WORKSPACE` to install only the selected workspace's dependencies with `bun install --filter`. ([#4292](https://github.com/expo/eas-cli/pull/4292) by [@konrad-armatys](https://github.com/konrad-armatys)) - [build-tools] Support `EAS_PNPM_FILTER_WORKSPACE` to install only the selected workspace's dependencies with `pnpm install --filter`. ([#4302](https://github.com/expo/eas-cli/pull/4302) by [@robingullo](https://github.com/robingullo)) - [build-tools] Add the `eas/install_mitmproxy` step and start serve-sim with `--network-capture` when a simulator session requests network capture. ([#4307](https://github.com/expo/eas-cli/pull/4307) by [@gwdp](https://github.com/gwdp)) -- [eas-cli] Add `--network-capture` to `eas simulator` to record HTTP(S) traffic from apps on the device. ([#4300](https://github.com/expo/eas-cli/pull/4300) by [@gwdp](https://github.com/gwdp)) +- [eas-cli] Add `--network-capture` to `eas simulator` to record HTTP(S) traffic from apps on the device. ([#4308](https://github.com/expo/eas-cli/pull/4308) by [@gwdp](https://github.com/gwdp)) ### 🐛 Bug fixes From 36385db398ddb168553335973c9f51f9e0c51f2f Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Fri, 28 Aug 2026 17:59:59 -0700 Subject: [PATCH 3/6] [eas-cli] fix changelog pr number --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bd73517ef..77eeb6be3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ This is the log of notable changes to EAS CLI and related packages. - [build-tools] Support `EAS_PNPM_FILTER_WORKSPACE` to install only the selected workspace's dependencies with `pnpm install --filter`. ([#4302](https://github.com/expo/eas-cli/pull/4302) by [@robingullo](https://github.com/robingullo)) - [build-tools] Add the `eas/install_mitmproxy` step and start serve-sim with `--network-capture` when a simulator session requests network capture. ([#4307](https://github.com/expo/eas-cli/pull/4307) by [@gwdp](https://github.com/gwdp)) - [eas-cli] Add `--network-capture` to `eas simulator` to record HTTP(S) traffic from apps on the device. ([#4308](https://github.com/expo/eas-cli/pull/4308) by [@gwdp](https://github.com/gwdp)) +- [eas-cli] Add `--network-capture` to `eas simulator` to record HTTP(S) traffic from apps on the device. ([#4308](https://github.com/expo/eas-cli/pull/4308) by [@gwdp](https://github.com/gwdp)) ### 🐛 Bug fixes From d03db8f6f0b1aac62689de7de97ea68e4ac9fa0b Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Fri, 28 Aug 2026 18:59:11 -0700 Subject: [PATCH 4/6] [eas-cli] address self review on network capture flag --- .../src/commands/simulator/__tests__/index.test.ts | 2 +- packages/eas-cli/src/commands/simulator/index.ts | 5 +++-- packages/eas-cli/src/graphql/generated.ts | 12 ++++++------ 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts index b1cfb8cd4b..9835b3f24b 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts @@ -463,7 +463,7 @@ describe(Simulator, () => { }); }); - it('rejects --network-capture on android, instead of creating a session that records nothing', async () => { + it('rejects --network-capture on android', async () => { const { command } = createCommand([ '--platform', 'android', diff --git a/packages/eas-cli/src/commands/simulator/index.ts b/packages/eas-cli/src/commands/simulator/index.ts index d518588825..6029faa54d 100644 --- a/packages/eas-cli/src/commands/simulator/index.ts +++ b/packages/eas-cli/src/commands/simulator/index.ts @@ -114,7 +114,7 @@ export default class Simulator extends EasCommand { }), 'network-capture': Flags.boolean({ description: - 'Record HTTP(S) traffic from apps on the device. HTTPS is decrypted, so recordings contain credentials in cleartext and certificate-pinned apps fail to connect. An app installed by this command is launched before capture starts, so relaunch it to record its traffic.', + 'Record HTTP(S) traffic from apps on the device (iOS only). HTTPS is decrypted, so recordings contain credentials in cleartext and certificate-pinned apps fail to connect.', }), 'max-duration-minutes': Flags.integer({ description: @@ -221,6 +221,7 @@ export default class Simulator extends EasCommand { 'Network capture is only supported on iOS simulator sessions. Re-run without --network-capture, or pass --platform ios.' ); } + const expoGoSdkVersion = flags['expo-go'] ? await resolveExpoGoSdkVersionAsync({ projectDir, sdkVersion: sdkVersionFromFlag }) : undefined; @@ -279,7 +280,7 @@ export default class Simulator extends EasCommand { ); if (flags['network-capture']) { Log.warn( - 'Network capture is on. HTTPS is decrypted, so recordings contain credentials in cleartext. Relaunch an app installed by this command to record its traffic.' + 'Network capture was requested. HTTPS is decrypted, so recordings contain credentials in cleartext. Relaunch an installed app to record its traffic.' ); } } catch (err) { diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index 24efb28ad9..8bb6e105d8 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -5338,17 +5338,17 @@ export type CreateDeviceRunSessionInput = { * session is unnamed and clients fall back to identifying it by id. */ name?: InputMaybe; + /** + * Record HTTP(S) traffic from apps on the device for the whole session. iOS only. + * HTTPS is decrypted, so recordings contain credentials and cookies in cleartext. + * If omitted, no traffic is recorded. + */ + networkCapture?: InputMaybe; /** * Expo or development-client URL to open after launching the installed application. Requires * buildId, applicationArchiveUrl, or expoGo. */ openUrl?: InputMaybe; - /** - * Record HTTP(S) traffic from apps on the device for the whole session. HTTPS is - * decrypted by a proxy on the worker, so recordings contain credentials and cookies - * in cleartext. If omitted, no traffic is recorded. - */ - networkCapture?: InputMaybe; /** * The version of the package backing the device run session (e.g. "0.1.3-alpha.3"). * If omitted, consumers treat the session as pinned to "latest". From d1255d3f73a74f0ededc8b54067d95f0b142d0e0 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Fri, 28 Aug 2026 19:37:30 -0700 Subject: [PATCH 5/6] [eas-cli] cover the simulator command end to end --- .../simulator/__tests__/index.test.ts | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) diff --git a/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts index 9835b3f24b..500d583190 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts @@ -25,6 +25,7 @@ import { resetSimulatorEnvAsync, } from '../../../simulator/env'; import { resolveExpoGoSdkVersionAsync } from '../../../simulator/expoGo'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json'; import Simulator from '../index'; jest.mock('fs-extra'); @@ -35,6 +36,7 @@ jest.mock('../../../log', () => ({ __esModule: true, default: { debug: jest.fn(), + error: jest.fn(), log: jest.fn(), newLine: jest.fn(), warn: jest.fn(), @@ -48,6 +50,11 @@ jest.mock('../../../simulator/env', () => ({ resetSimulatorEnvAsync: jest.fn(), })); jest.mock('../../../simulator/expoGo'); +jest.mock('../../../utils/json'); +jest.mock('../../../utils/promise', () => ({ + ...jest.requireActual('../../../utils/promise'), + sleepAsync: jest.fn().mockResolvedValue(undefined), +})); jest.mock('../../../prompts'); jest.mock('../../../ora', () => ({ ora: jest.fn(() => { @@ -84,6 +91,8 @@ const mockResetSimulatorEnvAsync = jest.mocked(resetSimulatorEnvAsync); const mockResolveExpoGoSdkVersionAsync = jest.mocked(resolveExpoGoSdkVersionAsync); const mockOra = jest.mocked(ora); const mockPromptAsync = jest.mocked(promptAsync); +const mockEnableJsonOutput = jest.mocked(enableJsonOutput); +const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput); function makeCreatedDeviceRunSession( overrides: Partial = {} @@ -874,4 +883,277 @@ describe(Simulator, () => { expect(mockPromptAsync).not.toHaveBeenCalled(); expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled(); }); + + it('fails the create spinner and rethrows when session creation fails', async () => { + mockCreateDeviceRunSessionAsync.mockRejectedValue(new Error('create boom')); + + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + + await expect(command.runAsync()).rejects.toThrow('create boom'); + expect(mockByIdAsync).not.toHaveBeenCalled(); + }); + + it('throws when the session errors before it becomes ready', async () => { + mockByIdAsync.mockResolvedValue( + makeDeviceRunSession({ status: DeviceRunSessionStatus.Errored, remoteConfig: null }) + ); + + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + + await expect(command.runAsync()).rejects.toThrow(/errored before the .* session was ready/); + expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalled(); + }); + + it('throws when the turtle job run finishes before the session is ready', async () => { + mockByIdAsync.mockResolvedValue( + makeDeviceRunSession({ + remoteConfig: null, + turtleJobRun: { id: 'job-123', status: JobRunStatus.Errored }, + }) + ); + + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + + await expect(command.runAsync()).rejects.toThrow( + /Turtle job run for simulator session .* errored before/ + ); + }); + + it('keeps polling until the remote config appears', async () => { + mockByIdAsync + .mockResolvedValueOnce(makeDeviceRunSession({ remoteConfig: null })) + .mockResolvedValueOnce(makeDeviceRunSession()); + + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + await command.runAsync(); + + expect(mockByIdAsync).toHaveBeenCalledTimes(2); + }); + + it('stops the session and rethrows when polling for readiness fails', async () => { + mockByIdAsync.mockRejectedValue(new Error('poll boom')); + + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + + await expect(command.runAsync()).rejects.toThrow('poll boom'); + expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith( + graphqlClient, + 'session-123' + ); + }); + + it('times out when the session never becomes ready', async () => { + mockByIdAsync.mockResolvedValue(makeDeviceRunSession({ remoteConfig: null })); + const realNow = Date.now(); + jest + .spyOn(Date, 'now') + .mockReturnValueOnce(realNow) + .mockReturnValue(realNow + 60 * 60 * 1000); + + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + + await expect(command.runAsync()).rejects.toThrow(/Timed out after \d+s waiting for/); + expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalled(); + jest.mocked(Date.now).mockRestore(); + }); + + it('prints JSON only and skips the human-readable output with --json', async () => { + const { command } = createCommand(['--platform', 'ios', '--non-interactive', '--json']); + await command.runAsync(); + + expect(mockEnableJsonOutput).toHaveBeenCalled(); + expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith( + expect.objectContaining({ id: 'session-123', deviceRunSessionUrl }) + ); + }); + + it('warns but continues when the dotenv file cannot be written', async () => { + jest.mocked(fs.writeFile).mockRejectedValue(new Error('disk full') as never); + + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + await command.runAsync(); + + expect(Log.warn).toHaveBeenCalledWith( + expect.stringContaining( + `Failed to write simulator environment variables to ${SIMULATOR_DOTENV_FILE_NAME}` + ) + ); + }); + + it('retries after a transient poll failure while the session is running', async () => { + mockByIdAsync + .mockResolvedValueOnce(makeDeviceRunSession()) + .mockRejectedValueOnce(new Error('transient')) + .mockResolvedValueOnce(makeDeviceRunSession({ status: DeviceRunSessionStatus.Stopped })); + + const { command } = createCommand(['--platform', 'ios']); + await command.runAsync(); + + expect(Log.debug).toHaveBeenCalledWith( + expect.stringContaining('Failed to poll simulator session') + ); + expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123'); + }); + + it('throws when the session errors while it is running', async () => { + mockByIdAsync + .mockResolvedValueOnce(makeDeviceRunSession()) + .mockResolvedValueOnce(makeDeviceRunSession({ status: DeviceRunSessionStatus.Errored })); + + const { command } = createCommand(['--platform', 'ios']); + + await expect(command.runAsync()).rejects.toThrow('Simulator session session-123 errored.'); + }); + + it('keeps waiting while the session is still in progress', async () => { + mockByIdAsync + .mockResolvedValueOnce(makeDeviceRunSession()) + .mockResolvedValueOnce(makeDeviceRunSession()) + .mockResolvedValueOnce( + makeDeviceRunSession({ turtleJobRun: { id: 'job-123', status: JobRunStatus.Finished } }) + ); + + const { command } = createCommand(['--platform', 'ios']); + await command.runAsync(); + + expect(mockByIdAsync).toHaveBeenCalledTimes(3); + }); + + it('rethrows when clearing the dotenv file fails after the session ends', async () => { + mockByIdAsync + .mockResolvedValueOnce(makeDeviceRunSession()) + .mockResolvedValueOnce(makeDeviceRunSession({ status: DeviceRunSessionStatus.Stopped })); + mockResetSimulatorEnvAsync.mockRejectedValue(new Error('unlink failed')); + + const { command } = createCommand(['--platform', 'ios']); + + await expect(command.runAsync()).rejects.toThrow('unlink failed'); + expect(Log.error).toHaveBeenCalledWith(`Failed to clean up ${SIMULATOR_DOTENV_FILE_NAME}`); + }); + + it('warns and reports failure when the session cannot be stopped', async () => { + mockByIdAsync.mockResolvedValueOnce(makeDeviceRunSession()).mockImplementationOnce(async () => { + process.emit('SIGINT'); + return makeDeviceRunSession(); + }); + mockEnsureDeviceRunSessionStoppedAsync.mockRejectedValue(new Error('stop boom')); + + const { command } = createCommand(['--platform', 'ios']); + await command.runAsync(); + + expect(Log.warn).toHaveBeenCalledWith( + 'Failed to stop simulator session session-123: stop boom' + ); + }); + + it('stops the session when interrupted while it is running', async () => { + mockByIdAsync.mockResolvedValueOnce(makeDeviceRunSession()).mockImplementationOnce(async () => { + process.emit('SIGINT'); + return makeDeviceRunSession(); + }); + + const { command } = createCommand(['--platform', 'ios']); + await command.runAsync(); + + expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith( + graphqlClient, + 'session-123' + ); + expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123'); + }); + + it('stops the session and reports when the stop cannot be confirmed after an interrupt before ready', async () => { + const exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + mockByIdAsync.mockImplementation(async () => { + process.emit('SIGINT'); + return makeDeviceRunSession({ remoteConfig: null }); + }); + mockEnsureDeviceRunSessionStoppedAsync.mockRejectedValue(new Error('stop boom')); + + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + await command.runAsync(); + + expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith( + graphqlClient, + 'session-123' + ); + expect(exitSpy).toHaveBeenCalledWith(130); + exitSpy.mockRestore(); + }); + + it('stops the session when interrupted after it is ready', async () => { + const exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + jest + .mocked(fs.writeFile) + .mockImplementationOnce((async () => undefined) as never) + .mockImplementationOnce((async () => { + process.emit('SIGINT'); + }) as never); + + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + await command.runAsync(); + + expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith( + graphqlClient, + 'session-123' + ); + expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123'); + exitSpy.mockRestore(); + }); + + it('force exits when a second interrupt arrives', async () => { + const exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + mockByIdAsync.mockResolvedValueOnce(makeDeviceRunSession()).mockImplementationOnce(async () => { + process.emit('SIGINT'); + process.emit('SIGINT'); + return makeDeviceRunSession(); + }); + + const { command } = createCommand(['--platform', 'ios']); + await command.runAsync(); + + expect(Log.error).toHaveBeenCalledWith( + 'Aborted before the simulator session could be stopped. Run `eas simulator:stop --id session-123` to terminate it and avoid unexpected charges.' + ); + expect(exitSpy).toHaveBeenCalledWith(130); + exitSpy.mockRestore(); + }); + + it('stringifies a non-Error dotenv write failure', async () => { + jest.mocked(fs.writeFile).mockRejectedValue('disk full' as never); + + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + await command.runAsync(); + + expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('disk full')); + }); + + it('stringifies a non-Error poll failure while the session is running', async () => { + mockByIdAsync + .mockResolvedValueOnce(makeDeviceRunSession()) + .mockRejectedValueOnce('network down') + .mockResolvedValueOnce(makeDeviceRunSession({ status: DeviceRunSessionStatus.Stopped })); + + const { command } = createCommand(['--platform', 'ios']); + await command.runAsync(); + + expect(Log.debug).toHaveBeenCalledWith(expect.stringContaining('network down')); + }); + + it('stringifies a non-Error stop failure', async () => { + const exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + mockByIdAsync.mockImplementation(async () => { + process.emit('SIGINT'); + return makeDeviceRunSession({ remoteConfig: null }); + }); + mockEnsureDeviceRunSessionStoppedAsync.mockRejectedValue('gateway timeout'); + + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + await command.runAsync(); + + expect(Log.warn).toHaveBeenCalledWith( + 'Failed to stop simulator session session-123: gateway timeout' + ); + exitSpy.mockRestore(); + }); }); From 4d00802c668f4df9dad9ed47a509970e253c20c8 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Tue, 1 Sep 2026 21:48:23 -0700 Subject: [PATCH 6/6] [eas-cli] drop the duplicated changelog entry --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77eeb6be3e..5bd73517ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,6 @@ This is the log of notable changes to EAS CLI and related packages. - [build-tools] Support `EAS_PNPM_FILTER_WORKSPACE` to install only the selected workspace's dependencies with `pnpm install --filter`. ([#4302](https://github.com/expo/eas-cli/pull/4302) by [@robingullo](https://github.com/robingullo)) - [build-tools] Add the `eas/install_mitmproxy` step and start serve-sim with `--network-capture` when a simulator session requests network capture. ([#4307](https://github.com/expo/eas-cli/pull/4307) by [@gwdp](https://github.com/gwdp)) - [eas-cli] Add `--network-capture` to `eas simulator` to record HTTP(S) traffic from apps on the device. ([#4308](https://github.com/expo/eas-cli/pull/4308) by [@gwdp](https://github.com/gwdp)) -- [eas-cli] Add `--network-capture` to `eas simulator` to record HTTP(S) traffic from apps on the device. ([#4308](https://github.com/expo/eas-cli/pull/4308) by [@gwdp](https://github.com/gwdp)) ### 🐛 Bug fixes