diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fcfeeaaba..5bd73517ef 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. ([#4308](https://github.com/expo/eas-cli/pull/4308) 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..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 = {} @@ -444,6 +453,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', 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'; @@ -843,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(); + }); }); diff --git a/packages/eas-cli/src/commands/simulator/index.ts b/packages/eas-cli/src/commands/simulator/index.ts index b1f950bd5f..6029faa54d 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 (iOS only). HTTPS is decrypted, so recordings contain credentials in cleartext and certificate-pinned apps fail to connect.', + }), '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,12 @@ 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 +246,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 +278,11 @@ export default class Simulator extends EasCommand { simulatorEnvWritten ? `, saved to ${SIMULATOR_DOTENV_FILE_NAME}` : '' }) ${link(deviceRunSessionUrl)}` ); + if (flags['network-capture']) { + Log.warn( + 'Network capture was requested. HTTPS is decrypted, so recordings contain credentials in cleartext. Relaunch an installed app 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..8bb6e105d8 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -5338,6 +5338,12 @@ 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.