From e679aab0ecbf606856a6684565c3b9ac401b09f9 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sun, 18 Oct 2026 12:43:18 -0700 Subject: [PATCH] [eas-cli] Import start settings and apply actions after simulator readiness --- .changeset/simulator-start-import.md | 5 + .../simulator/__tests__/index.test.ts | 599 +++++++++++++++++- .../eas-cli/src/commands/simulator/control.ts | 24 +- .../eas-cli/src/commands/simulator/index.ts | 393 +++++++++--- .../simulator/__tests__/hostActions.test.ts | 47 +- .../simulator/__tests__/startConfig.test.ts | 188 ++++++ .../simulator/__tests__/startSetup.test.ts | 124 ++++ .../eas-cli/src/simulator/controlActions.ts | 10 +- .../eas-cli/src/simulator/controlConfig.ts | 72 ++- packages/eas-cli/src/simulator/hostActions.ts | 27 +- .../eas-cli/src/simulator/sessionConfig.ts | 41 ++ packages/eas-cli/src/simulator/startConfig.ts | 95 +++ packages/eas-cli/src/simulator/startSetup.ts | 28 + 13 files changed, 1499 insertions(+), 154 deletions(-) create mode 100644 .changeset/simulator-start-import.md create mode 100644 packages/eas-cli/src/simulator/__tests__/startConfig.test.ts create mode 100644 packages/eas-cli/src/simulator/__tests__/startSetup.test.ts create mode 100644 packages/eas-cli/src/simulator/sessionConfig.ts create mode 100644 packages/eas-cli/src/simulator/startConfig.ts create mode 100644 packages/eas-cli/src/simulator/startSetup.ts diff --git a/.changeset/simulator-start-import.md b/.changeset/simulator-start-import.md new file mode 100644 index 0000000000..56f9372cd2 --- /dev/null +++ b/.changeset/simulator-start-import.md @@ -0,0 +1,5 @@ +--- +'eas-cli': minor +--- + +Start simulator sessions from saved settings and apply control actions after readiness. 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 15b2321121..07c53d9f89 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts @@ -32,11 +32,14 @@ import { resetSimulatorEnvAsync, } from '../../../simulator/env'; import { resolveExpoGoSdkVersionAsync } from '../../../simulator/expoGo'; +import { applyStartConfigAsync } from '../../../simulator/startSetup'; +import { ControlConfigExecutionError } from '../../../simulator/controlConfig'; import * as promiseUtils from '../../../utils/promise'; import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json'; import Simulator from '../index'; jest.mock('fs-extra'); +jest.mock('../../../simulator/startSetup'); jest.mock('../../../analytics/AnalyticsManager', () => ({ ...jest.requireActual('../../../analytics/AnalyticsManager'), isAnalyticsOptedOutAsync: jest.fn(), @@ -190,6 +193,7 @@ describe(Simulator, () => { beforeEach(() => { jest.restoreAllMocks(); jest.clearAllMocks(); + jest.mocked(applyStartConfigAsync).mockReset().mockResolvedValue([]); delete process.env[EAS_SIMULATOR_SESSION_ID]; mockAvailabilityByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: true }); mockCreateDeviceRunSessionAsync.mockResolvedValue(makeCreatedDeviceRunSession()); @@ -197,7 +201,7 @@ describe(Simulator, () => { id: 'session-123', status: DeviceRunSessionStatus.Stopped, }); - mockByIdAsync.mockResolvedValue(makeDeviceRunSession()); + mockByIdAsync.mockReset().mockResolvedValue(makeDeviceRunSession()); mockLoadSimulatorEnvAsync.mockResolvedValue(); mockResetSimulatorEnvAsync.mockResolvedValue(); mockResolveExpoGoSdkVersionAsync.mockResolvedValue('55.0.0'); @@ -232,6 +236,590 @@ describe(Simulator, () => { return { command, getContextAsync }; } + describe('imported session configuration', () => { + const configPath = '/test/simulator.json'; + const localEgressSession = makeDeviceRunSession({ + remoteConfig: { + __typename: 'WebPreviewOnlyRunSessionRemoteConfig', + previewUrl: 'https://expo.dev/preview', + previewApiUrl: 'https://sim.example.test', + previewToken: 'private-token', + egressUrl: 'https://egress.example.com', + egressToken: 'token', + egressFingerprint: 'fp', + egressPort: 8899, + }, + }); + function config(start: object = { platform: 'ios' }, actions?: object[]): void { + jest + .mocked(fs.readJson) + .mockResolvedValue({ version: 1, start, ...(actions ? { actions } : {}) } as never); + mockByIdAsync.mockResolvedValue( + makeDeviceRunSession({ + remoteConfig: { + __typename: 'WebPreviewOnlyRunSessionRemoteConfig', + previewUrl: 'https://expo.dev/preview', + previewApiUrl: 'https://sim.example.test', + previewToken: 'private-token', + }, + }) + ); + } + + it('enables JSON output before import preflight can fail', async () => { + config({ unknown: true }); + const { command, getContextAsync } = createCommand(['--import', configPath, '--json']); + await expect(command.runAsync()).rejects.toThrow('Unknown or invocation-only'); + expect(mockEnableJsonOutput).toHaveBeenCalledTimes(1); + expect(mockEnableJsonOutput.mock.invocationCallOrder[0]).toBeLessThan( + jest.mocked(fs.readJson).mock.invocationCallOrder[0] + ); + expect(getContextAsync).not.toHaveBeenCalled(); + expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled(); + }); + + it('creates from saved flags with explicit CLI overrides and unchanged output without actions', async () => { + config({ + platform: 'ios', + name: 'Saved name', + type: 'web-preview-only', + device: 'Saved phone', + tag: ['saved'], + }); + const { command } = createCommand(['--import', configPath, '--name', 'CLI name', '--json']); + await command.runAsync(); + expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith( + graphqlClient, + expect.objectContaining({ + name: 'CLI name', + type: DeviceRunSessionType.WebPreviewOnly, + ios: { deviceIdentifier: 'Saved phone' }, + tags: ['saved'], + }) + ); + expect(applyStartConfigAsync).not.toHaveBeenCalled(); + expect(jest.mocked(printJsonOnlyOutput)).toHaveBeenCalledWith( + expect.not.objectContaining({ setup: expect.anything() }) + ); + }); + + it('applies actions after readiness and includes parsed setup results in one JSON object', async () => { + config(undefined, [{ action: 'appearance.set', params: { value: 'dark' } }]); + jest.mocked(applyStartConfigAsync).mockImplementationOnce(async () => { + expect(fs.writeFile).not.toHaveBeenCalled(); + return [{ action: 'appearance.set', output: '{"value":"dark"}', file: null }]; + }); + const { command } = createCommand(['--import', configPath, '--json']); + await command.runAsync(); + expect(jest.mocked(applyStartConfigAsync).mock.invocationCallOrder[0]).toBeGreaterThan( + mockByIdAsync.mock.invocationCallOrder[0] + ); + expect(applyStartConfigAsync).toHaveBeenCalledWith( + { apiUrl: 'https://sim.example.test', token: 'private-token' }, + expect.objectContaining({ path: configPath }), + { signal: expect.any(AbortSignal), onProgress: undefined } + ); + expect(printJsonOnlyOutput).toHaveBeenCalledTimes(1); + expect(printJsonOnlyOutput).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'session-123', + setup: { + config: configPath, + results: [{ action: 'appearance.set', output: { value: 'dark' }, file: null }], + }, + }) + ); + expect(mockEnsureDeviceRunSessionStoppedAsync).not.toHaveBeenCalled(); + expect(fs.writeFile).toHaveBeenCalledTimes(1); + expect(fs.writeFile).toHaveBeenCalledWith( + simulatorDotenvPath, + SIMULATOR_DOTENV_FILE_HEADER + `${EAS_SIMULATOR_SESSION_ID}='session-123'\n` + ); + }); + + it.each([false, true])( + 'prints normal ready output before importing, detached=%s', + async detached => { + config({ platform: 'ios' }, [{ action: 'tap', params: { x: 0.5, y: 0.5 } }]); + const ready = makeDeviceRunSession({ + remoteConfig: { + __typename: 'WebPreviewOnlyRunSessionRemoteConfig', + previewUrl: 'https://expo.dev/preview', + previewApiUrl: 'https://sim.example.test', + previewToken: 'private-token', + }, + }); + mockByIdAsync + .mockResolvedValueOnce(ready) + .mockResolvedValueOnce({ ...ready, status: DeviceRunSessionStatus.Stopped }); + jest + .mocked(applyStartConfigAsync) + .mockImplementationOnce(async (_access, _config, options) => { + const output = jest.mocked(Log.log).mock.calls.map(([line]) => String(line)); + const preview = output.findIndex(line => line.includes(deviceRunSessionPreviewUrl)); + const importing = output.findIndex(line => line.includes('Importing 1 action from')); + expect(preview).toBeGreaterThanOrEqual(0); + expect(importing).toBeGreaterThan(preview); + options.onProgress?.({ step: 1, total: 1, action: 'tap', status: 'running' }); + const result = { action: 'tap', output: '', file: null }; + options.onProgress?.({ step: 1, total: 1, action: 'tap', status: 'completed', result }); + return [result]; + }); + await createCommand([ + '--import', + configPath, + ...(detached ? ['--non-interactive'] : []), + ]).command.runAsync(); + const spinners = jest.mocked(ora).mock.results.map(result => result.value); + expect( + spinners.some(spinner => + spinner.succeed.mock.calls.some(([text]: [string]) => text === '[1/1] tap completed') + ) + ).toBe(true); + } + ); + + it('identifies failed and skipped imported steps after printing the preview', async () => { + config({ platform: 'ios' }, [ + { action: 'appearance.get' }, + { action: 'tap' }, + { action: 'clipboard.read' }, + ]); + jest + .mocked(applyStartConfigAsync) + .mockRejectedValueOnce( + new ControlConfigExecutionError( + 2, + 'tap', + [{ action: 'appearance.get', output: 'dark', file: null }], + new Error('refused') + ) + ); + await expect( + createCommand(['--import', configPath, '--non-interactive']).command.runAsync() + ).rejects.toThrow('refused'); + expect(jest.mocked(Log.log)).toHaveBeenCalledWith('1 completed · 1 failed · 1 skipped'); + expect(jest.mocked(Log.log)).toHaveBeenCalledWith(' [3/3] clipboard.read skipped'); + const spinners = jest.mocked(ora).mock.results.map(result => result.value); + expect( + spinners.some(spinner => + spinner.fail.mock.calls.some(([text]: [string]) => text === '[2/3] tap failed') + ) + ).toBe(true); + }); + + it('stops only the newly created session on setup failure and retains completed JSON results', async () => { + config(undefined, [{ action: 'appearance.set' }, { action: 'appearance.get' }]); + process.env[EAS_SIMULATOR_SESSION_ID] = 'previous-session'; + jest + .mocked(applyStartConfigAsync) + .mockRejectedValue( + new ControlConfigExecutionError( + 2, + 'appearance.get', + [{ action: 'appearance.set', output: '"dark"', file: null }], + new Error('refused') + ) + ); + const prior = process.exitCode; + const listeners = process.listenerCount('SIGINT'); + try { + await createCommand(['--import', configPath, '--json']).command.runAsync(); + expect(process.exitCode).toBe(1); + expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith( + graphqlClient, + 'session-123' + ); + expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledTimes(1); + expect(fs.writeFile).not.toHaveBeenCalled(); + expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123'); + expect(printJsonOnlyOutput).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'session-123', + stopped: true, + setup: expect.objectContaining({ + results: [{ action: 'appearance.set', output: 'dark', file: null }], + error: expect.objectContaining({ step: 2, action: 'appearance.get' }), + }), + }) + ); + expect(process.listenerCount('SIGINT')).toBe(listeners); + } finally { + process.exitCode = prior; + } + }); + + it.each([false, true])( + 'stops JSON startup interrupted during its final dotenv write, imported=%s', + async imported => { + jest.spyOn(process, 'exit').mockImplementation(code => { + throw new Error(`process.exit(${code})`); + }); + config(undefined, imported ? [{ action: 'appearance.get' }] : undefined); + jest.mocked(fs.writeFile).mockImplementationOnce(async () => { + process.emit('SIGINT'); + }); + const listeners = process.listeners('SIGINT'); + await expect( + createCommand(['--import', configPath, '--json']).command.runAsync() + ).rejects.toThrow('process.exit(130)'); + expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith( + graphqlClient, + 'session-123' + ); + expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123'); + expect(printJsonOnlyOutput).not.toHaveBeenCalled(); + expect(process.listeners('SIGINT')).toEqual(listeners); + } + ); + + it('reports unconfirmed cleanup rather than implying the session stopped', async () => { + config(undefined, [{ action: 'appearance.set' }]); + jest.mocked(applyStartConfigAsync).mockRejectedValue(new Error('setup failed')); + mockEnsureDeviceRunSessionStoppedAsync.mockRejectedValue(new Error('network failed')); + const prior = process.exitCode; + try { + await createCommand(['--import', configPath, '--json']).command.runAsync(); + expect(printJsonOnlyOutput).toHaveBeenCalledWith( + expect.objectContaining({ stopped: false }) + ); + expect(mockResetSimulatorEnvAsync).not.toHaveBeenCalled(); + expect(Log.warn).toHaveBeenCalledWith( + expect.stringContaining('simulator:stop --id session-123') + ); + } finally { + process.exitCode = prior; + } + }); + + it('rejects Android actions and invalid files before creating a session', async () => { + config({ platform: 'android' }, [{ action: 'appearance.set' }]); + await expect( + createCommand(['--import', configPath, '--non-interactive']).command.runAsync() + ).rejects.toThrow('not supported on Android'); + expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled(); + config({ typo: 'value' }); + const { command, getContextAsync } = createCommand(['--import', configPath]); + await expect(command.runAsync()).rejects.toThrow('Unknown or invocation-only'); + expect(getContextAsync).not.toHaveBeenCalled(); + }); + + it('rejects explicit capture fields when the CLI disables saved capture', async () => { + config({ platform: 'ios', 'network-capture': true }); + await expect( + createCommand([ + '--import', + configPath, + '--no-network-capture', + '--network-capture-field', + 'header', + ]).command.runAsync() + ).rejects.toThrow('--network-capture-field requires --network-capture'); + expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled(); + }); + + it('allows Android start settings without actions', async () => { + config({ platform: 'android' }); + await createCommand(['--import', configPath, '--json']).command.runAsync(); + expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith( + graphqlClient, + expect.objectContaining({ platform: AppPlatform.Android }) + ); + expect(applyStartConfigAsync).not.toHaveBeenCalled(); + }); + + it('uses the existing platform prompt and attached-session lifecycle in interactive mode', async () => { + config({}, [{ action: 'appearance.set' }]); + const ready = makeDeviceRunSession({ + remoteConfig: { + __typename: 'WebPreviewOnlyRunSessionRemoteConfig', + previewUrl: 'https://expo.dev/preview', + previewApiUrl: 'https://sim.example.test', + previewToken: 'private-token', + }, + }); + mockPromptAsync.mockResolvedValueOnce({ selectedPlatform: AppPlatform.Ios }); + mockByIdAsync + .mockResolvedValueOnce(ready) + .mockResolvedValueOnce({ ...ready, status: DeviceRunSessionStatus.Stopped }); + const listeners = process.listeners('SIGINT'); + await createCommand(['--import', configPath]).command.runAsync(); + expect(mockPromptAsync).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Select platform' }) + ); + expect(applyStartConfigAsync).toHaveBeenCalledTimes(1); + expect(mockByIdAsync).toHaveBeenCalledTimes(2); + expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123'); + expect(process.listeners('SIGINT')).toEqual(listeners); + expect(printJsonOnlyOutput).not.toHaveBeenCalled(); + }); + + it.each([['--non-interactive'], ['--json']])( + 'refuses an incomplete platform without prompting: %j', + async mode => { + config({}, [{ action: 'appearance.set' }]); + await expect( + createCommand(['--import', configPath, mode]).command.runAsync() + ).rejects.toThrow('platform flag must be set'); + expect(mockPromptAsync).not.toHaveBeenCalled(); + expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled(); + } + ); + + it.each(['CI', 'pipe'])('inherits non-interactive defaults for %s', async mode => { + config({}, [{ action: 'appearance.set' }]); + const priorCi = process.env.CI; + const priorTty = process.stdin.isTTY; + try { + if (mode === 'CI') { + process.env.CI = 'true'; + } else { + Object.defineProperty(process.stdin, 'isTTY', { value: false, writable: true }); + } + await expect(createCommand(['--import', configPath]).command.runAsync()).rejects.toThrow( + 'platform flag must be set' + ); + expect(mockPromptAsync).not.toHaveBeenCalled(); + expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled(); + } finally { + if (priorCi === undefined) { + delete process.env.CI; + } else { + process.env.CI = priorCi; + } + Object.defineProperty(process.stdin, 'isTTY', { value: priorTty, writable: true }); + } + }); + + it('connects interactive local egress before applying imported actions and closes it on session end', async () => { + config({ platform: 'ios', egress: 'local' }, [{ action: 'appearance.set' }]); + const ready = localEgressSession; + mockByIdAsync + .mockResolvedValueOnce(ready) + .mockResolvedValueOnce({ ...ready, status: DeviceRunSessionStatus.Stopped }); + let connected = false; + let tunnelSignal: AbortSignal | undefined; + jest.mocked(runLocalEgressAsync).mockImplementationOnce(async ({ signal, onConnected }) => { + tunnelSignal = signal; + await new Promise(resolve => setImmediate(resolve)); + connected = true; + onConnected?.(); + await new Promise(resolve => { + signal.addEventListener( + 'abort', + () => { + resolve(); + }, + { once: true } + ); + }); + }); + jest.mocked(applyStartConfigAsync).mockImplementationOnce(async () => { + expect(connected).toBe(true); + return []; + }); + await createCommand(['--import', configPath]).command.runAsync(); + expect(tunnelSignal?.aborted).toBe(true); + }); + + it.each([['--non-interactive'], ['--json']])( + 'rejects actions with detached local egress before creating a session: %j', + async mode => { + config({ platform: 'ios', egress: 'local' }, [{ action: 'appearance.get' }]); + await expect( + createCommand(['--import', configPath, mode]).command.runAsync() + ).rejects.toThrow('Imported actions with local egress require interactive mode'); + expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled(); + expect(applyStartConfigAsync).not.toHaveBeenCalled(); + expect(runLocalEgressAsync).not.toHaveBeenCalled(); + expect(mockPromptAsync).not.toHaveBeenCalled(); + } + ); + + it('cleans up and preserves an egress connection failure before setup', async () => { + config({ platform: 'ios', egress: 'local' }, [{ action: 'appearance.get' }]); + mockByIdAsync.mockResolvedValue(localEgressSession); + const failure = new Error('egress could not bind'); + jest.mocked(runLocalEgressAsync).mockRejectedValueOnce(failure); + const listeners = process.listeners('SIGINT'); + await expect(createCommand(['--import', configPath]).command.runAsync()).rejects.toBe( + failure + ); + expect(applyStartConfigAsync).not.toHaveBeenCalled(); + expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith( + graphqlClient, + 'session-123' + ); + expect(process.listeners('SIGINT')).toEqual(listeners); + }); + + it('stops setup instead of running without requested local egress settings', async () => { + config({ platform: 'ios', egress: 'local' }, [{ action: 'appearance.get' }]); + await expect(createCommand(['--import', configPath]).command.runAsync()).rejects.toThrow( + 'did not provide local egress connection settings' + ); + expect(applyStartConfigAsync).not.toHaveBeenCalled(); + expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith( + graphqlClient, + 'session-123' + ); + }); + + it.each(['action', 'egress'])( + 'preserves a %s failure during connected setup and closes the tunnel', + async source => { + config({ platform: 'ios', egress: 'local' }, [{ action: 'appearance.get' }]); + mockByIdAsync.mockResolvedValue(localEgressSession); + const failure = new Error(`${source} failed during setup`); + let rejectTunnel: (error: Error) => void = () => {}; + let tunnelClosed = false; + jest.mocked(runLocalEgressAsync).mockImplementationOnce(async ({ signal, onConnected }) => { + onConnected?.(); + try { + await new Promise((resolve, reject) => { + rejectTunnel = reject; + signal.addEventListener( + 'abort', + () => { + resolve(); + }, + { once: true } + ); + }); + } finally { + tunnelClosed = true; + } + }); + jest + .mocked(applyStartConfigAsync) + .mockImplementationOnce(async (_access, _config, { signal }) => { + if (source === 'action') { + throw failure; + } + rejectTunnel(failure); + await new Promise((_resolve, reject) => { + signal.addEventListener( + 'abort', + () => { + reject(new Error('action aborted')); + }, + { once: true } + ); + }); + return []; + }); + const listeners = process.listeners('SIGINT'); + await expect(createCommand(['--import', configPath]).command.runAsync()).rejects.toBe( + failure + ); + expect(tunnelClosed).toBe(true); + expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith( + graphqlClient, + 'session-123' + ); + expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123'); + expect(printJsonOnlyOutput).not.toHaveBeenCalled(); + expect(process.listeners('SIGINT')).toEqual(listeners); + } + ); + + it('bounds the wait for local egress and closes the tunnel on timeout', async () => { + config({ platform: 'ios', egress: 'local' }, [{ action: 'appearance.get' }]); + mockByIdAsync.mockResolvedValue(localEgressSession); + let tunnelSignal: AbortSignal | undefined; + jest.mocked(runLocalEgressAsync).mockImplementationOnce(async ({ signal }) => { + tunnelSignal = signal; + await new Promise(resolve => { + signal.addEventListener( + 'abort', + () => { + resolve(); + }, + { once: true } + ); + }); + }); + jest.useFakeTimers(); + try { + const result = expect( + createCommand(['--import', configPath]).command.runAsync() + ).rejects.toThrow('did not connect within 120 seconds'); + await jest.advanceTimersByTimeAsync(120_000); + await result; + expect(tunnelSignal?.aborted).toBe(true); + expect(applyStartConfigAsync).not.toHaveBeenCalled(); + expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith( + graphqlClient, + 'session-123' + ); + } finally { + jest.useRealTimers(); + } + }); + + it('closes local egress and stops the session on Ctrl-C before the tunnel connects', async () => { + config({ platform: 'ios', egress: 'local' }, [{ action: 'appearance.get' }]); + mockByIdAsync.mockResolvedValue(localEgressSession); + const interrupted = new Error('exit 130'); + jest.spyOn(process, 'exit').mockImplementation(() => { + throw interrupted; + }); + let tunnelClosed = false; + jest.mocked(runLocalEgressAsync).mockImplementationOnce(async ({ signal }) => { + await new Promise(resolve => { + signal.addEventListener( + 'abort', + () => { + resolve(); + }, + { once: true } + ); + setImmediate(() => { + process.emit('SIGINT'); + }); + }); + tunnelClosed = true; + }); + const listeners = process.listeners('SIGINT'); + await expect(createCommand(['--import', configPath]).command.runAsync()).rejects.toBe( + interrupted + ); + expect(tunnelClosed).toBe(true); + expect(applyStartConfigAsync).not.toHaveBeenCalled(); + expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith( + graphqlClient, + 'session-123' + ); + expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123'); + expect(process.listeners('SIGINT')).toEqual(listeners); + }); + + it('stops on Ctrl-C during setup and removes the session handler', async () => { + config(undefined, [{ action: 'appearance.set' }]); + const interrupted = new Error('exit 130'); + const exit = jest.spyOn(process, 'exit').mockImplementation(() => { + throw interrupted; + }); + const listeners = process.listenerCount('SIGINT'); + jest + .mocked(applyStartConfigAsync) + .mockImplementation(async (_access, _config, { signal }) => { + process.emit('SIGINT'); + signal.throwIfAborted(); + return []; + }); + await expect( + createCommand(['--import', configPath, '--non-interactive']).command.runAsync() + ).rejects.toBe(interrupted); + expect(exit).toHaveBeenCalledWith(130); + expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith( + graphqlClient, + 'session-123' + ); + expect(process.listenerCount('SIGINT')).toBe(listeners); + }); + }); + it('fails with the waitlist link without creating a session when the account is gated', async () => { mockAvailabilityByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: false }); @@ -1755,7 +2343,14 @@ describe(Simulator, () => { 'stops %s after an interactive egress failure and removes its interrupt handler', async (typeFlag, type, remoteConfig) => { const failure = new Error('listen EADDRINUSE'); - jest.mocked(runLocalEgressAsync).mockRejectedValueOnce(failure); + jest.mocked(runLocalEgressAsync).mockImplementationOnce(async () => { + expect(fs.writeFile).toHaveBeenCalledWith( + simulatorDotenvPath, + expect.stringContaining("EAS_SIMULATOR_EGRESS_TOKEN='pw'") + ); + expect(Log.log).toHaveBeenCalledWith(expect.stringContaining(deviceRunSessionUrl)); + throw failure; + }); mockByIdAsync.mockResolvedValue( makeDeviceRunSession({ type, diff --git a/packages/eas-cli/src/commands/simulator/control.ts b/packages/eas-cli/src/commands/simulator/control.ts index c74b5d4f0d..896f3ea838 100644 --- a/packages/eas-cli/src/commands/simulator/control.ts +++ b/packages/eas-cli/src/commands/simulator/control.ts @@ -15,6 +15,7 @@ import { ora } from '../../ora'; import { ControlAction, executeControlActionAsync, + parseControlActionOutput, prepareControlActionAsync, } from '../../simulator/controlActions'; import { @@ -23,6 +24,7 @@ import { PreparedControlConfig, executeControlConfigAsync, prepareControlConfigAsync, + serializeControlConfigResults, } from '../../simulator/controlConfig'; import { EAS_SIMULATOR_SESSION_ID, @@ -262,7 +264,7 @@ export default class SimulatorControl extends EasCommand { id: session.id, session, config: config.path, - results: configResultsJson(error.completed), + results: serializeControlConfigResults(error.completed), error: { step: error.step, action: error.action, message: error.message }, }); process.exitCode = 1; @@ -286,7 +288,7 @@ export default class SimulatorControl extends EasCommand { id: session.id, session, config: config.path, - results: configResultsJson(results), + results: serializeControlConfigResults(results), }); } else { Log.newLine(); @@ -300,7 +302,7 @@ export default class SimulatorControl extends EasCommand { if (result.file) { Log.log(` Saved: ${result.file}`); } else if (result.output) { - const output = parseJsonOutput(result.output); + const output = parseControlActionOutput(result.output); const text = typeof output === 'string' ? output : JSON.stringify(output, null, 2); Log.log( text @@ -421,7 +423,7 @@ export default class SimulatorControl extends EasCommand { id: session.id, session, action, - output: parseJsonOutput(output), + output: parseControlActionOutput(output), file: savedFile, }); return; @@ -442,12 +444,6 @@ function formatActionCount(count: number): string { return `${count} action${count === 1 ? '' : 's'}`; } -function configResultsJson( - results: readonly ControlConfigResult[] -): { action: string; output: unknown; file: string | null }[] { - return results.map(result => ({ ...result, output: parseJsonOutput(result.output) })); -} - function assertControllableSession( session: Awaited> ): PreviewApiAccess { @@ -469,11 +465,3 @@ function assertControllableSession( } return access; } - -function parseJsonOutput(output: string): unknown { - try { - return JSON.parse(output); - } catch { - return output; - } -} diff --git a/packages/eas-cli/src/commands/simulator/index.ts b/packages/eas-cli/src/commands/simulator/index.ts index e7972570fb..39f9bce111 100644 --- a/packages/eas-cli/src/commands/simulator/index.ts +++ b/packages/eas-cli/src/commands/simulator/index.ts @@ -30,6 +30,12 @@ import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQue import Log, { link } from '../../log'; import { Ora, ora } from '../../ora'; import { promptAsync } from '../../prompts'; +import { + ControlConfigExecutionError, + serializeControlConfigResults, +} from '../../simulator/controlConfig'; +import { resolveStartConfigAsync } from '../../simulator/startConfig'; +import { applyStartConfigAsync } from '../../simulator/startSetup'; import { parseEgressAllowList, runLocalEgressAsync } from '../../simulator/egress'; import { parseCorsOriginList, parseFrameAncestorList } from '../../simulator/frameAncestors'; import { parseNetworkCaptureFields } from '../../simulator/networkCapture'; @@ -55,12 +61,15 @@ import { formatRemoteSessionInstructions, formatSimulatorUnavailableMessage, getLocalEgressConfig, + getPreviewApiAccess, getRemoteSessionEnvironmentVariables, sanitizeRemoteConfigForJson, } from '../../simulator/utils'; import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json'; import { sleepAsync } from '../../utils/promise'; +// Allow the cold-cache client download (up to 60s) and a further minute to connect. +const EGRESS_SETUP_TIMEOUT_MS = 120_000; const POLL_INTERVAL_MS = 5_000; // 5 seconds const STARTUP_TIMEOUT_MS = 15 * 60 * 1_000; // 15 minutes, excluding time in the queue const OUT_CONFIG_TYPE_VALUES = { @@ -81,7 +90,16 @@ export default class Simulator extends EasCommand { static override description = '[EXPERIMENTAL] start a remote simulator session on EAS and get instructions to connect to it. The default browser preview link requires sign-in.'; + static override examples = [ + '$ eas simulator:start --import ./simulator.json', + '$ eas simulator:start --import ./simulator.json --device "iPhone 17 Pro" --json', + ]; + static override flags = { + import: Flags.string({ + description: + 'Read version 1 JSON with start settings (using flag names) and optional actions. Explicit CLI values replace saved values, including arrays. Actions run after readiness and after interactive local egress connects. Actions with local egress require interactive mode; otherwise import them separately with simulator:control after connecting simulator:egress. Local action paths are relative to the file. Setup failure stops the newly created session. Example: {"version":1,"start":{"platform":"ios"},"actions":[{"action":"appearance.set","params":{"value":"dark"}}]}', + }), platform: Flags.option({ char: 'p', description: 'Device platform', @@ -143,6 +161,7 @@ export default class Simulator extends EasCommand { 'Version of the package backing the simulator session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.', }), 'network-capture': Flags.boolean({ + allowNo: true, 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.', }), @@ -200,6 +219,7 @@ export default class Simulator extends EasCommand { default: OUT_CONFIG_TYPE_VALUES.Dotenv, })(), 'public-link': Flags.boolean({ + allowNo: true, description: 'Enable the simulator preview Share button and print a shareable link containing an access token. Anyone with the link can control the simulator. With --json, include previewApiUrl and its token for API clients when available.', }), @@ -214,12 +234,13 @@ export default class Simulator extends EasCommand { }; async runAsync(): Promise { - const { flags } = await this.parse(Simulator); - const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags); - - if (jsonFlag) { + // Import preflight can fail before parsing; keep diagnostics off JSON stdout. + if (this.argv.includes('--json')) { enableJsonOutput(); } + const imported = await resolveStartConfigAsync(this.argv, Simulator.flags); + const { flags } = await this.parse(Simulator, imported.argv); + const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags); const { projectId, @@ -288,6 +309,9 @@ export default class Simulator extends EasCommand { } catch (err) { throw new EasCommandError(err instanceof Error ? err.message : String(err)); } + if (networkCaptureFields.length && !flags['network-capture']) { + throw new EasCommandError('--network-capture-field requires --network-capture.'); + } const platform = await resolvePlatformAsync(flags.platform, nonInteractive); const egress = flags.egress === 'local' ? DeviceRunSessionEgress.Local : undefined; @@ -315,6 +339,18 @@ export default class Simulator extends EasCommand { ); } + if (platform === AppPlatform.Android && imported.config?.actions?.length) { + throw new EasCommandError( + 'Imported control actions are not supported on Android yet. Remove actions to import only start settings, or use --platform ios.' + ); + } + + if (nonInteractive && egress && imported.config?.actions?.length) { + throw new EasCommandError( + 'Imported actions with local egress require interactive mode. Start without actions, connect eas simulator:egress, then run eas simulator:control --import with the same file.' + ); + } + if (platform === AppPlatform.Android) { Log.warn( 'Android emulator support in EAS Simulator is still in development. Some features available on iOS may not work on Android yet. Full parity with iOS is coming soon.' @@ -507,101 +543,267 @@ export default class Simulator extends EasCommand { ); } - if (flags['out-config-type'] === OUT_CONFIG_TYPE_VALUES.Dotenv) { - await writeSimulatorEnvSafelyAsync(projectDir, { - ...getRemoteSessionEnvironmentVariables(remoteConfig, { egressAllow, platform }), - [EAS_SIMULATOR_SESSION_ID]: deviceRunSessionId, - }); - } - - if (sessionInterrupt.signal.aborted) { - await stopDeviceRunSessionAfterInterruptAsync({ - graphqlClient, - deviceRunSessionId, - projectDir, - spinner: pollSpinner, - sessionInterrupt, - }); - return; - } - - if (jsonFlag) { - sessionInterrupt.dispose(); - printJsonOnlyOutput({ - id: deviceRunSessionId, - name, - type: flags.type, - deviceRunSessionUrl, - remoteConfig: sanitizeRemoteConfigForJson( - remoteConfig, - deviceRunSessionPreviewUrl, - flags['public-link'], - { frameAncestors, corsOrigins } - ), - }); - return; - } - - Log.newLine(); - Log.log( - formatRemoteSessionInstructions(remoteConfig, flags['out-config-type'], { - egressAllow, - egressClientRunsInline: !nonInteractive, - sessionUrl: deviceRunSessionUrl, - privatePreviewUrl: deviceRunSessionPreviewUrl, - publicLink: flags['public-link'], - frameAncestors, - corsOrigins, - platform, - }) - ); - Log.newLine(); - const localEgress = getLocalEgressConfig(remoteConfig, egressAllow, platform); - - if (nonInteractive) { - sessionInterrupt.dispose(); - // The instructions above already tell the reader to run eas simulator:egress. - Log.log( - `When you are done, stop the session with: eas simulator:stop --id ${deviceRunSessionId}` - ); - return; - } - - // In interactive mode the egress client runs right here, for as long as the - // session does. A session with a disconnected tunnel still bills, so Ctrl+C stops both. let egressPromise: Promise | undefined; let egressError: Error | undefined; const egressAbortController = new AbortController(); - if (localEgress) { - sessionInterrupt.signal.addEventListener( - 'abort', - () => { - egressAbortController.abort(); - }, - { once: true } - ); - egressPromise = runLocalEgressAsync({ - ...localEgress, - signal: egressAbortController.signal, - onConnected: () => { - Log.succeed('Egress tunnel connected.'); - }, - onDisconnected: () => { - Log.warn( - 'Egress tunnel disconnected; reconnecting. Proxied HTTP(S) requests are unavailable until it reconnects.' + let markEgressReady: () => void; + const egressReady = new Promise(resolve => { + markEgressReady = resolve; + }); + const onSessionAbort = (): void => { + egressAbortController.abort(); + }; + const startLocalEgress = (): void => { + if (localEgress && !nonInteractive) { + sessionInterrupt.signal.addEventListener('abort', onSessionAbort, { once: true }); + egressPromise = runLocalEgressAsync({ + ...localEgress, + signal: egressAbortController.signal, + onConnected: () => { + markEgressReady(); + Log.succeed('Egress tunnel connected.'); + }, + onDisconnected: () => { + Log.warn( + 'Egress tunnel disconnected; reconnecting. Proxied HTTP(S) requests are unavailable until it reconnects.' + ); + }, + }).catch(err => { + egressError = err instanceof Error ? err : new Error(String(err)); + Log.error( + `${err instanceof Error ? err.message : String(err)} Stopping the simulator session.` ); - }, - }).catch(err => { - egressError = err instanceof Error ? err : new Error(String(err)); - Log.error( - `${err instanceof Error ? err.message : String(err)} Stopping the simulator session.` + sessionInterrupt.abort(); + }); + } + }; + const writeReadySimulatorEnvAsync = async (): Promise => { + if (flags['out-config-type'] === OUT_CONFIG_TYPE_VALUES.Dotenv) { + await writeSimulatorEnvSafelyAsync(projectDir, { + ...getRemoteSessionEnvironmentVariables(remoteConfig, { egressAllow, platform }), + [EAS_SIMULATOR_SESSION_ID]: deviceRunSessionId, + }); + } + }; + try { + if (!jsonFlag) { + await writeReadySimulatorEnvAsync(); + } + + if (sessionInterrupt.signal.aborted && !egressError) { + await stopDeviceRunSessionAfterInterruptAsync({ + graphqlClient, + deviceRunSessionId, + projectDir, + spinner: pollSpinner, + sessionInterrupt, + }); + return; + } + + if (!jsonFlag) { + Log.newLine(); + Log.log( + formatRemoteSessionInstructions(remoteConfig, flags['out-config-type'], { + egressAllow, + egressClientRunsInline: !nonInteractive, + sessionUrl: deviceRunSessionUrl, + privatePreviewUrl: deviceRunSessionPreviewUrl, + publicLink: flags['public-link'], + frameAncestors, + corsOrigins, + platform, + }) ); - sessionInterrupt.abort(); - }); - } + Log.newLine(); + if (nonInteractive) { + Log.log( + `When you are done, stop the session with: eas simulator:stop --id ${deviceRunSessionId}` + ); + } + } + + let setup: + | { config: string; results: ReturnType } + | undefined; + if (imported.config?.actions?.length) { + const total = imported.config.actions.length; + if (!jsonFlag) { + Log.log( + `Importing ${total} action${total === 1 ? '' : 's'} from ${imported.config.path}` + ); + } + startLocalEgress(); + const setupSpinner = ora('Importing simulator configuration').start(); + try { + if (egress && !localEgress) { + throw new Error('The ready session did not provide local egress connection settings.'); + } + if (egressPromise) { + setupSpinner.text = 'Waiting for local egress before applying configuration'; + let timer: NodeJS.Timeout | undefined; + try { + await Promise.race([ + egressReady, + egressPromise, + sessionInterrupt.abortPromise, + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + reject(new Error('Local egress did not connect within 120 seconds.')); + }, EGRESS_SETUP_TIMEOUT_MS); + timer.unref(); + }), + ]); + } finally { + clearTimeout(timer); + } + if (egressError) { + throw egressError; + } + sessionInterrupt.signal.throwIfAborted(); + } + const access = getPreviewApiAccess(remoteConfig); + if (!access) { + throw new Error( + 'This session does not expose an authenticated preview API for control actions.' + ); + } + const results = await applyStartConfigAsync(access, imported.config, { + signal: sessionInterrupt.signal, + onProgress: jsonFlag + ? undefined + : progress => { + const label = `[${progress.step}/${progress.total}] ${progress.action}`; + if (progress.status === 'running') { + setupSpinner.start(label); + } else { + setupSpinner.succeed(`${label} completed`); + if (progress.result.file) { + Log.log(`Saved ${progress.result.file}`); + } else if (progress.result.output) { + Log.log(progress.result.output); + } + } + }, + }); + sessionInterrupt.signal.throwIfAborted(); + setup = { config: imported.config.path, results: serializeControlConfigResults(results) }; + setupSpinner.succeed( + `Import complete: ${total} action${total === 1 ? '' : 's'} completed` + ); + } catch (caught) { + egressAbortController.abort(); + await egressPromise; + const error = egressError ?? caught; + if (sessionInterrupt.signal.aborted && !egressError) { + await stopDeviceRunSessionAfterInterruptAsync({ + graphqlClient, + deviceRunSessionId, + projectDir, + spinner: setupSpinner, + sessionInterrupt, + }); + return; + } + if (error instanceof ControlConfigExecutionError) { + setupSpinner.fail(`[${error.step}/${total}] ${error.action} failed`); + if (!jsonFlag) { + const skipped = total - error.step; + Log.log(`${error.completed.length} completed · 1 failed · ${skipped} skipped`); + for (const [index, action] of imported.config.actions.entries()) { + if (index >= error.step) { + Log.log(` [${index + 1}/${total}] ${action.action} skipped`); + } + } + } + } else { + setupSpinner.fail('Simulator configuration import failed'); + } + const stopped = await ensureDeviceRunSessionStoppedSafelyAsync( + graphqlClient, + deviceRunSessionId + ); + if (stopped) { + if (!jsonFlag) { + Log.succeed('Stopped the simulator session after setup failed.'); + } + await resetSimulatorEnvAsync(projectDir, deviceRunSessionId).catch(error => { + Log.warn( + `The session stopped, but ${SIMULATOR_DOTENV_FILE_NAME} could not be cleaned up: ${error instanceof Error ? error.message : String(error)}` + ); + }); + } else { + Log.warn( + `Could not confirm cleanup. Run eas simulator:stop --id ${deviceRunSessionId} to stop the session.` + ); + } + sessionInterrupt.dispose(); + if (jsonFlag) { + printJsonOnlyOutput({ + id: deviceRunSessionId, + deviceRunSessionUrl, + stopped, + setup: { + config: imported.config.path, + results: + error instanceof ControlConfigExecutionError + ? serializeControlConfigResults(error.completed) + : [], + error: { + ...(error instanceof ControlConfigExecutionError + ? { step: error.step, action: error.action } + : {}), + message: error instanceof Error ? error.message : String(error), + }, + }, + }); + process.exitCode = 1; + return; + } + throw error; + } + } + + if (jsonFlag) { + // Keep the previous session's saved credentials until setup succeeds. + await writeReadySimulatorEnvAsync(); + if (sessionInterrupt.signal.aborted) { + await stopDeviceRunSessionAfterInterruptAsync({ + graphqlClient, + deviceRunSessionId, + projectDir, + spinner: pollSpinner, + sessionInterrupt, + }); + return; + } + sessionInterrupt.dispose(); + printJsonOnlyOutput({ + id: deviceRunSessionId, + name, + type: flags.type, + deviceRunSessionUrl, + ...(setup ? { setup } : {}), + remoteConfig: sanitizeRemoteConfigForJson( + remoteConfig, + deviceRunSessionPreviewUrl, + flags['public-link'], + { frameAncestors, corsOrigins } + ), + }); + return; + } + + if (nonInteractive) { + sessionInterrupt.dispose(); + return; + } + + if (!imported.config?.actions?.length) { + startLocalEgress(); + } - try { await waitForSessionEndOrInterruptAsync({ graphqlClient, deviceRunSessionId, @@ -609,13 +811,14 @@ export default class Simulator extends EasCommand { projectDir, sessionInterrupt, }); + if (egressError) { + throw egressError; + } } finally { + sessionInterrupt.signal.removeEventListener('abort', onSessionAbort); egressAbortController.abort(); await egressPromise; } - if (egressError) { - throw egressError; - } } } diff --git a/packages/eas-cli/src/simulator/__tests__/hostActions.test.ts b/packages/eas-cli/src/simulator/__tests__/hostActions.test.ts index 476c18b09f..f0ec7cd6a4 100644 --- a/packages/eas-cli/src/simulator/__tests__/hostActions.test.ts +++ b/packages/eas-cli/src/simulator/__tests__/hostActions.test.ts @@ -1,5 +1,5 @@ import * as fs from 'fs-extra'; -import { once } from 'node:events'; +import { getEventListeners, once } from 'node:events'; import http from 'node:http'; import { AddressInfo } from 'node:net'; import os from 'node:os'; @@ -370,6 +370,51 @@ describe('host action connection', () => { apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; }); + it('does not open a connection when already canceled', async () => { + const controller = new AbortController(); + const reason = new Error('canceled before setup'); + controller.abort(reason); + await expect( + connectHostActionsAsync({ apiUrl, token: TOKEN }, { signal: controller.signal }) + ).rejects.toBe(reason); + expect(requests).toEqual([]); + }); + + it('cancels an upgrade waiting for readiness and releases the abort listener', async () => { + sendReady = false; + const controller = new AbortController(); + const accepted = once(wss, 'connection'); + const pending = connectHostActionsAsync( + { apiUrl, token: TOKEN }, + { signal: controller.signal } + ); + const [socket] = await accepted; + const closed = once(socket, 'close'); + const reason = new Error('canceled during setup'); + const rejected = expect(pending).rejects.toBe(reason); + controller.abort(reason); + await rejected; + await closed; + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0); + }); + + it('cancels a pending action on an established connection', async () => { + const controller = new AbortController(); + connection = await connectHostActionsAsync( + { apiUrl, token: TOKEN }, + { signal: controller.signal } + ); + reply = () => null; + const receivedRequest = once([...wss.clients][0], 'message'); + const pending = connection.requestAsync('appearance.get'); + const reason = new Error('canceled during action'); + const rejected = expect(pending).rejects.toBe(reason); + await receivedRequest; + controller.abort(reason); + await rejected; + expect(connection.signal.aborted).toBe(true); + }); + afterEach(async () => { connection?.close(); connection = undefined; diff --git a/packages/eas-cli/src/simulator/__tests__/startConfig.test.ts b/packages/eas-cli/src/simulator/__tests__/startConfig.test.ts new file mode 100644 index 0000000000..ca1c5ad192 --- /dev/null +++ b/packages/eas-cli/src/simulator/__tests__/startConfig.test.ts @@ -0,0 +1,188 @@ +import { Parser } from '@oclif/core'; +import fs from 'fs-extra'; +import os from 'node:os'; +import path from 'node:path'; + +import Simulator from '../../commands/simulator'; +import { prepareControlConfigAsync } from '../controlConfig'; +import { resolveStartConfigAsync } from '../startConfig'; + +describe('simulator start configuration', () => { + let folder: string; + let file: string; + beforeEach(async () => { + folder = await fs.mkdtemp(path.join(os.tmpdir(), 'simulator-start-config-')); + file = path.join(folder, 'simulator.json'); + }); + afterEach(async () => fs.remove(folder)); + + async function resolve( + start: object, + argv: string[] = [], + actions?: object[] + ): Promise< + Awaited> & { + parsed: Awaited>; + } + > { + await fs.writeJson(file, { version: 1, start, ...(actions ? { actions } : {}) }); + const resolved = await resolveStartConfigAsync(['--import', file, ...argv], Simulator.flags); + return { ...resolved, parsed: await Parser.parse(resolved.argv, { flags: Simulator.flags }) }; + } + + it('uses saved values rather than command defaults and preserves explicit CLI overrides', async () => { + const { parsed } = await resolve( + { + platform: 'ios', + type: 'web-preview-only', + force: false, + device: 'Saved phone', + tag: ['saved'], + 'public-link': true, + }, + ['--device', 'CLI phone', '--tag', 'one', '--tag', 'two', '--json'] + ); + expect(parsed.flags).toMatchObject({ + platform: 'ios', + type: 'web-preview-only', + force: false, + device: 'CLI phone', + tag: ['one', 'two'], + 'public-link': true, + json: true, + }); + }); + + it('lets explicit CLI defaults override different saved values', async () => { + const { parsed } = await resolve({ type: 'web-preview-only', force: false }, [ + '--type', + 'agent-device', + '--force', + ]); + expect(parsed.flags.type).toBe('agent-device'); + expect(parsed.flags.force).toBe(true); + }); + + it('lets the CLI disable saved public links and network capture with its saved fields', async () => { + const { parsed } = await resolve( + { 'public-link': true, 'network-capture': true, 'network-capture-field': ['header'] }, + ['--no-public-link', '--no-network-capture'] + ); + expect(parsed.flags['public-link']).toBe(false); + expect(parsed.flags['network-capture']).toBe(false); + expect(parsed.flags['network-capture-field']).toBeUndefined(); + }); + + it('validates dependencies after merging and replaces array flags instead of appending', async () => { + const { parsed } = await resolve( + { 'network-capture': true, 'network-capture-field': ['header'] }, + ['--network-capture-field', 'request-body'] + ); + expect(parsed.flags['network-capture-field']).toEqual(['request-body']); + }); + + it('lets an explicit app source replace a saved exclusive source and its Expo Go SDK', async () => { + const expoGo = await resolve({ 'build-id': 'saved-build' }, ['--expo-go']); + expect(expoGo.parsed.flags['expo-go']).toBe(true); + expect(expoGo.parsed.flags['build-id']).toBeUndefined(); + const build = await resolve({ 'expo-go': true, 'sdk-version': '55.0.0' }, [ + '--build-id', + 'cli-build', + ]); + expect(build.parsed.flags['build-id']).toBe('cli-build'); + expect(build.parsed.flags['expo-go']).toBeUndefined(); + expect(build.parsed.flags['sdk-version']).toBeUndefined(); + await expect(resolve({}, ['--build-id', 'cli-build', '--expo-go'])).rejects.toThrow( + 'cannot also be provided' + ); + }); + + it('rejects an empty import path instead of silently creating a default session', async () => { + await expect(resolveStartConfigAsync(['--import='], Simulator.flags)).rejects.toThrow( + 'non-empty configuration file path' + ); + }); + + it('honors short flags and negated CLI booleans', async () => { + const { parsed } = await resolve({ platform: 'android', force: true }, [ + '-p', + 'ios', + '--no-force', + ]); + expect(parsed.flags.platform).toBe('ios'); + expect(parsed.flags.force).toBe(false); + }); + + it.each([ + { start: { unknown: 'x' }, message: 'Unknown or invocation-only' }, + { start: { json: true }, message: 'invocation-only' }, + { start: { 'non-interactive': true }, message: 'invocation-only' }, + { start: { import: 'nested.json' }, message: 'invocation-only' }, + { start: { 'expo-go': 'yes' }, message: 'must be a boolean' }, + { start: { device: ['one', 'two'] }, message: 'multiple values' }, + { start: { device: { name: 'phone' } }, message: 'string or number' }, + { start: { platform: 'windows' }, message: 'ios' }, + { start: { 'max-duration-minutes': -1 }, message: 'greater than or equal to 0' }, + { start: { 'build-id': 'abc', 'expo-go': true }, message: 'cannot also be provided' }, + ])('rejects invalid start values: $start', async ({ start, message }) => { + await expect(resolve(start)).rejects.toThrow(message); + }); + + it('rejects a missing local input before creating a session', async () => { + await expect( + resolve({ platform: 'ios' }, [], [{ action: 'app.install', file: './missing.ipa' }]) + ).rejects.toThrow('does not exist on this computer'); + }); + + it('rejects invalid output parents and device overrides during local preflight', async () => { + await expect( + resolve({}, [], [{ action: 'screenshot.capture', output: './missing/shot.png' }]) + ).rejects.toThrow('Config step 1'); + await expect( + resolve({}, [], [{ action: 'appearance.get', params: { udid: 'other-device' } }]) + ).rejects.toThrow('remove params.udid'); + }); + + it('allows the same file in control import and resolves files relative to it', async () => { + await fs.writeFile(path.join(folder, 'input.txt'), 'contents'); + const { config } = await resolve( + { platform: 'ios' }, + [], + [{ action: 'upload', file: './input.txt' }] + ); + const prepared = await prepareControlConfigAsync( + { + upload: { + type: 'object', + properties: { udid: { type: 'string' }, path: { type: 'string' } }, + required: ['udid', 'path'], + upload: 'path', + }, + }, + file, + 'device' + ); + expect(prepared.actions[0].plan.localFile?.path).toBe(path.join(folder, 'input.txt')); + expect(config?.actions?.[0].params).toEqual({}); + }); + + it('reports an unreadable file before trying to create a session', async () => { + await expect(resolveStartConfigAsync(['--import', file], Simulator.flags)).rejects.toThrow( + `Could not read simulator configuration ${file}` + ); + }); + + it('preserves no-import argv and reports malformed or unknown config fields', async () => { + expect(await resolveStartConfigAsync(['--platform', 'ios'], Simulator.flags)).toEqual({ + argv: ['--platform', 'ios'], + }); + await fs.writeJson(file, { version: 2, start: {} }); + await expect(resolveStartConfigAsync(['--import', file], Simulator.flags)).rejects.toThrow( + 'Invalid simulator configuration' + ); + await fs.writeJson(file, { version: 1, typo: {} }); + await expect(resolveStartConfigAsync(['--import', file], Simulator.flags)).rejects.toThrow( + 'Invalid simulator configuration' + ); + }); +}); diff --git a/packages/eas-cli/src/simulator/__tests__/startSetup.test.ts b/packages/eas-cli/src/simulator/__tests__/startSetup.test.ts new file mode 100644 index 0000000000..cd16435da3 --- /dev/null +++ b/packages/eas-cli/src/simulator/__tests__/startSetup.test.ts @@ -0,0 +1,124 @@ +import fs from 'fs-extra'; +import { once } from 'node:events'; +import http from 'node:http'; +import { AddressInfo } from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { WebSocketServer } from 'ws'; + +import { readSimulatorSessionConfigAsync } from '../sessionConfig'; +import { applyStartConfigAsync } from '../startSetup'; + +// Real HTTP/WebSocket transport and file IO verify the shared runner wiring, not just mocks. +describe(applyStartConfigAsync, () => { + let server: http.Server; + let sockets: WebSocketServer; + let folder: string; + let apiUrl: string; + let actions: { action: string; params: Record }[]; + const catalog = { + set: { + type: 'object', + properties: { udid: { type: 'string' }, value: { type: 'string' } }, + required: ['udid', 'value'], + }, + screenshot: { + type: 'object', + properties: { udid: { type: 'string' } }, + required: ['udid'], + output: 'base64', + }, + }; + beforeEach(async () => { + actions = []; + folder = await fs.mkdtemp(path.join(os.tmpdir(), 'start-setup-')); + server = http.createServer((req, res) => { + if (req.url !== '/api' || req.headers.authorization !== 'Bearer token') { + res.writeHead(401).end(); + return; + } + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ device: 'owned-device' })); + }); + sockets = new WebSocketServer({ + server, + path: '/exec-ws', + handleProtocols: protocols => + protocols.has('serve-sim.token.token') ? 'serve-sim.token.token' : false, + }); + sockets.on('connection', socket => { + socket.send(JSON.stringify({ ready: true })); + socket.on('message', bytes => { + const message = JSON.parse(bytes.toString()); + actions.push(message); + const stdout = + message.action === 'actions.describe' + ? JSON.stringify(catalog) + : message.action === 'screenshot' + ? Buffer.from('image bytes').toString('base64') + : 'done'; + socket.send(JSON.stringify({ id: message.id, stdout, stderr: '', exitCode: 0 })); + }); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + afterEach(async () => { + for (const socket of sockets.clients) { + socket.terminate(); + } + sockets.close(); + server.close(); + await once(server, 'close'); + await fs.remove(folder); + }); + + it('runs the captured config after the file changes, writes outputs relative to it and closes transport', async () => { + const file = path.join(folder, 'session.json'); + await fs.writeJson(file, { + version: 1, + start: { platform: 'ios' }, + actions: [ + { action: 'set', params: { value: 'dark' } }, + { action: 'screenshot', output: './screen.png' }, + ], + }); + const config = await readSimulatorSessionConfigAsync(file); + await fs.writeJson(file, { version: 999 }); + const closed = new Promise(resolve => + sockets.once('connection', socket => socket.once('close', resolve)) + ); + const progress = jest.fn(); + const results = await applyStartConfigAsync({ apiUrl, token: 'token' }, config, { + signal: new AbortController().signal, + onProgress: progress, + }); + await closed; + expect(results.map(result => result.action)).toEqual(['set', 'screenshot']); + expect(await fs.readFile(path.join(folder, 'screen.png'), 'utf8')).toBe('image bytes'); + expect(actions.every(action => action.params.udid === 'owned-device')).toBe(true); + expect(progress).toHaveBeenCalledWith( + expect.objectContaining({ step: 2, total: 2, status: 'completed' }) + ); + }); + + it('validates all actions before sending the first one and closes on validation failure', async () => { + const file = path.join(folder, 'session.json'); + await fs.writeJson(file, { + version: 1, + actions: [{ action: 'set', params: { value: 'dark' } }, { action: 'unknown' }], + }); + const config = await readSimulatorSessionConfigAsync(file); + const closed = new Promise(resolve => + sockets.once('connection', socket => socket.once('close', resolve)) + ); + await expect( + applyStartConfigAsync({ apiUrl, token: 'token' }, config, { + signal: new AbortController().signal, + }) + ).rejects.toThrow('Unknown simulator action'); + await closed; + expect(actions.map(action => action.action)).toEqual(['actions.describe']); + }); +}); diff --git a/packages/eas-cli/src/simulator/controlActions.ts b/packages/eas-cli/src/simulator/controlActions.ts index 5f81b2f0e1..cb6501732c 100644 --- a/packages/eas-cli/src/simulator/controlActions.ts +++ b/packages/eas-cli/src/simulator/controlActions.ts @@ -97,7 +97,7 @@ export async function prepareControlActionAsync( }); } -async function assertLocalFileAsync(localPath: string): Promise { +export async function assertLocalFileAsync(localPath: string): Promise { const stat = await fs.stat(localPath).catch(() => null); if (!stat) { throw new Error(`${localPath} does not exist on this computer.`); @@ -150,3 +150,11 @@ export async function executeControlActionAsync( } } } + +export function parseControlActionOutput(output: string): unknown { + try { + return JSON.parse(output); + } catch { + return output; + } +} diff --git a/packages/eas-cli/src/simulator/controlConfig.ts b/packages/eas-cli/src/simulator/controlConfig.ts index abf6b8dac1..188c92f6c3 100644 --- a/packages/eas-cli/src/simulator/controlConfig.ts +++ b/packages/eas-cli/src/simulator/controlConfig.ts @@ -2,29 +2,17 @@ import Ajv2020, { AnySchema } from 'ajv/dist/2020'; import addFormats from 'ajv-formats'; import * as fs from 'fs-extra'; import path from 'path'; -import { z } from 'zod'; import { ControlActionResult, PreparedControlAction, + assertLocalFileAsync, executeControlActionAsync, + parseControlActionOutput, prepareControlActionAsync, } from './controlActions'; import { HostActionCatalog, HostActionConnection, createHostActionUploadId } from './hostActions'; - -const Config = z.strictObject({ - version: z.literal(1), - actions: z - .array( - z.strictObject({ - action: z.string().min(1), - params: z.record(z.string(), z.json()).default({}), - file: z.string().min(1).optional(), - output: z.string().min(1).optional(), - }) - ) - .min(1), -}); +import { SimulatorSessionConfig, readSimulatorSessionConfigAsync } from './sessionConfig'; type ConfigAction = { readonly plan: PreparedControlAction; @@ -63,31 +51,20 @@ export class ControlConfigExecutionError extends Error { export async function prepareControlConfigAsync( catalog: HostActionCatalog, filePath: string, - udid: string + udid: string, + input?: SimulatorSessionConfig ): Promise { - const absolutePath = path.resolve(filePath); - let input: unknown; - try { - input = await fs.readJson(absolutePath); - } catch (error) { + const config = input ?? (await readSimulatorSessionConfigAsync(filePath)); + const absolutePath = config.path; + if (!config.actions?.length) { throw new Error( - `Could not read simulator configuration ${absolutePath}: ${errorMessage(error)}`, - { - cause: error, - } + `Invalid simulator configuration ${absolutePath}: actions must contain at least one action.` ); } - const parsed = Config.safeParse(input); - if (!parsed.success) { - const detail = parsed.error.issues - .map(issue => `${issue.path.join('.')}: ${issue.message}`) - .join('; '); - throw new Error(`Invalid simulator configuration ${absolutePath}: ${detail}`); - } const ajv = addFormats(new Ajv2020({ strict: false, allErrors: true })); const actions: ConfigAction[] = []; - for (const [index, action] of parsed.data.actions.entries()) { + for (const [index, action] of config.actions.entries()) { try { if (Object.hasOwn(action.params, 'udid')) { throw new Error('The simulator is selected by the session; remove params.udid.'); @@ -127,6 +104,29 @@ export async function prepareControlConfigAsync( return Object.freeze({ path: absolutePath, udid, actions: Object.freeze(actions) }); } +/** Check local inputs before requesting a paid session; runtime validation still uses the live catalog. */ +export async function preflightControlConfigFilesAsync( + config: SimulatorSessionConfig +): Promise { + for (const [index, action] of (config.actions ?? []).entries()) { + try { + if (Object.hasOwn(action.params, 'udid')) { + throw new Error('The simulator is selected by the session; remove params.udid.'); + } + if (action.file) { + await assertLocalFileAsync(path.resolve(path.dirname(config.path), action.file)); + } + if (action.output) { + await assertWritableOutputAsync(path.resolve(path.dirname(config.path), action.output)); + } + } catch (error) { + throw new Error(`Config step ${index + 1} (${action.action}): ${errorMessage(error)}`, { + cause: error, + }); + } + } +} + async function assertWritableOutputAsync(outputPath: string): Promise { const stat = await fs.stat(outputPath).catch(() => null); if (stat && !stat.isFile() && !stat.isDirectory()) { @@ -200,3 +200,9 @@ export async function executeControlConfigAsync( function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } + +export function serializeControlConfigResults( + results: readonly ControlConfigResult[] +): { action: string; output: unknown; file: string | null }[] { + return results.map(result => ({ ...result, output: parseControlActionOutput(result.output) })); +} diff --git a/packages/eas-cli/src/simulator/hostActions.ts b/packages/eas-cli/src/simulator/hostActions.ts index 0ec9cd65b1..17f6bf5703 100644 --- a/packages/eas-cli/src/simulator/hostActions.ts +++ b/packages/eas-cli/src/simulator/hostActions.ts @@ -88,16 +88,22 @@ function previewEndpoint(apiUrl: string, endpoint: string): URL { return new URL(endpoint, base); } -async function fetchSimulatorUdidAsync({ apiUrl, token }: PreviewApiAccess): Promise { +async function fetchSimulatorUdidAsync( + { apiUrl, token }: PreviewApiAccess, + signal?: AbortSignal +): Promise { let text: string; try { const response = await fetch(previewEndpoint(apiUrl, 'api').toString(), { headers: { Authorization: `Bearer ${token}` }, redirect: 'error', - signal: AbortSignal.timeout(PREVIEW_API_TIMEOUT_MS), + signal: signal + ? AbortSignal.any([signal, AbortSignal.timeout(PREVIEW_API_TIMEOUT_MS)]) + : AbortSignal.timeout(PREVIEW_API_TIMEOUT_MS), }); text = await response.text(); } catch (error) { + signal?.throwIfAborted(); if (error instanceof RequestError && error.response.status === 404) { throw new Error(PREVIEW_UNAVAILABLE_MESSAGE); } @@ -161,9 +167,11 @@ function refusedError(error: string): Error { export async function connectHostActionsAsync( access: PreviewApiAccess, - { timeoutMs = HOST_ACTION_TIMEOUT_MS }: { timeoutMs?: number } = {} + { timeoutMs = HOST_ACTION_TIMEOUT_MS, signal }: { timeoutMs?: number; signal?: AbortSignal } = {} ): Promise { - const udid = await fetchSimulatorUdidAsync(access); + signal?.throwIfAborted(); + const udid = await fetchSimulatorUdidAsync(access, signal); + signal?.throwIfAborted(); const socketUrl = previewEndpoint(access.apiUrl, 'exec-ws'); socketUrl.protocol = socketUrl.protocol === 'https:' ? 'wss:' : 'ws:'; const ws = new WebSocket( @@ -218,6 +226,14 @@ export async function connectHostActionsAsync( loseConnection(error); }; + const onAbort = (): void => { + fail( + signal?.reason instanceof Error ? signal.reason : new Error('Simulator setup canceled.') + ); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + ws.once('close', () => signal?.removeEventListener('abort', onAbort)); + ws.on('message', data => { let message: unknown; try { @@ -278,6 +294,9 @@ export async function connectHostActionsAsync( ) ); }); + if (signal?.aborted) { + onAbort(); + } }); return { diff --git a/packages/eas-cli/src/simulator/sessionConfig.ts b/packages/eas-cli/src/simulator/sessionConfig.ts new file mode 100644 index 0000000000..971226ee7c --- /dev/null +++ b/packages/eas-cli/src/simulator/sessionConfig.ts @@ -0,0 +1,41 @@ +import * as fs from 'fs-extra'; +import path from 'path'; +import { z } from 'zod'; + +const Action = z.strictObject({ + action: z.string().min(1), + params: z.record(z.string(), z.json()).default({}), + file: z.string().min(1).optional(), + output: z.string().min(1).optional(), +}); + +const Config = z.strictObject({ + version: z.literal(1), + start: z.record(z.string(), z.json()).optional(), + actions: z.array(Action).min(1).optional(), +}); + +export type SimulatorSessionConfig = z.infer & { path: string }; + +export async function readSimulatorSessionConfigAsync( + filePath: string +): Promise { + const absolutePath = path.resolve(filePath); + let input: unknown; + try { + input = await fs.readJson(absolutePath); + } catch (error) { + throw new Error( + `Could not read simulator configuration ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error } + ); + } + const parsed = Config.safeParse(input); + if (!parsed.success) { + const detail = parsed.error.issues + .map(issue => `${issue.path.join('.')}: ${issue.message}`) + .join('; '); + throw new Error(`Invalid simulator configuration ${absolutePath}: ${detail}`); + } + return { ...parsed.data, path: absolutePath }; +} diff --git a/packages/eas-cli/src/simulator/startConfig.ts b/packages/eas-cli/src/simulator/startConfig.ts new file mode 100644 index 0000000000..488f778072 --- /dev/null +++ b/packages/eas-cli/src/simulator/startConfig.ts @@ -0,0 +1,95 @@ +import { Interfaces, Parser } from '@oclif/core'; + +import { preflightControlConfigFilesAsync } from './controlConfig'; +import { SimulatorSessionConfig, readSimulatorSessionConfigAsync } from './sessionConfig'; + +/** Reparse the effective values using the command's own validation, options and defaults. */ +export async function resolveStartConfigAsync( + argv: string[], + definitions: Interfaces.FlagInput +): Promise<{ argv: string[]; config?: SimulatorSessionConfig }> { + if (!argv.some(arg => arg === '--import' || arg.startsWith('--import='))) { + return { argv }; + } + // Relationships are checked after file values and explicit CLI overrides are combined. + const relaxed = Object.fromEntries( + Object.entries(definitions).map(([name, flag]) => [ + name, + { + ...flag, + dependsOn: undefined, + exclusive: undefined, + exactlyOne: undefined, + atLeastOne: undefined, + relationships: undefined, + required: false, + }, + ]) + ); + const parsed = await Parser.parse(argv, { flags: relaxed }); + if (parsed.flags.import === undefined) { + return { argv }; + } + if (!parsed.flags.import.trim()) { + throw new Error('--import requires a non-empty configuration file path.'); + } + const config = await readSimulatorSessionConfigAsync(parsed.flags.import); + const values: Record = { ...config.start }; + for (const key of Object.keys(values)) { + if (!Object.hasOwn(definitions, key) || ['import', 'json', 'non-interactive'].includes(key)) { + throw new Error(`Unknown or invocation-only start setting "${key}" in ${config.path}.`); + } + } + const explicit = Object.entries(parsed.flags).filter( + ([name, value]) => value !== undefined && !parsed.metadata.flags[name]?.setFromDefault + ); + const explicitNames = new Set(explicit.map(([name]) => name)); + const removed = new Set(); + for (const [name, value] of explicit) { + for (const excluded of definitions[name].exclusive ?? []) { + if (!explicitNames.has(excluded)) { + delete values[excluded]; + removed.add(excluded); + } + } + values[name] = value; + } + // SDK selection belongs to the saved Expo Go app source, not a replacement build. + if (removed.has('expo-go') && !explicitNames.has('sdk-version')) { + delete values['sdk-version']; + } + if ( + explicitNames.has('network-capture') && + values['network-capture'] === false && + !explicitNames.has('network-capture-field') + ) { + delete values['network-capture-field']; + } + const effectiveArgv: string[] = []; + for (const [name, value] of Object.entries(values)) { + const flag = definitions[name]; + if (flag.type === 'boolean') { + if (typeof value !== 'boolean') { + throw new Error(`Start setting "${name}" must be a boolean.`); + } + if (value) { + effectiveArgv.push(`--${name}`); + } else if (flag.allowNo) { + effectiveArgv.push(`--no-${name}`); + } + } else { + const items = Array.isArray(value) ? value : [value]; + if (Array.isArray(value) && !flag.multiple) { + throw new Error(`Start setting "${name}" does not accept multiple values.`); + } + for (const item of items) { + if (typeof item !== 'string' && typeof item !== 'number') { + throw new Error(`Start setting "${name}" must contain a string or number.`); + } + effectiveArgv.push(`--${name}=${item}`); + } + } + } + await preflightControlConfigFilesAsync(config); + return { argv: effectiveArgv, config }; +} diff --git a/packages/eas-cli/src/simulator/startSetup.ts b/packages/eas-cli/src/simulator/startSetup.ts new file mode 100644 index 0000000000..c958bcc525 --- /dev/null +++ b/packages/eas-cli/src/simulator/startSetup.ts @@ -0,0 +1,28 @@ +import { + ControlConfigProgress, + ControlConfigResult, + executeControlConfigAsync, + prepareControlConfigAsync, +} from './controlConfig'; +import { connectHostActionsAsync, fetchHostActionCatalogAsync } from './hostActions'; +import { SimulatorSessionConfig } from './sessionConfig'; +import { PreviewApiAccess } from './utils'; + +export async function applyStartConfigAsync( + access: PreviewApiAccess, + config: SimulatorSessionConfig, + { + signal, + onProgress, + }: { signal: AbortSignal; onProgress?: (progress: ControlConfigProgress) => void } +): Promise { + const connection = await connectHostActionsAsync(access, { signal }); + try { + const catalog = await fetchHostActionCatalogAsync(connection); + const prepared = await prepareControlConfigAsync(catalog, config.path, connection.udid, config); + signal.throwIfAborted(); + return await executeControlConfigAsync(connection, prepared, { onProgress }); + } finally { + connection.close(); + } +}