From c2a3dc5bce73c1d201bff84059611249f10a7d6e Mon Sep 17 00:00:00 2001 From: Brent Vatne Date: Mon, 19 Oct 2026 17:05:49 -0700 Subject: [PATCH 1/4] [eas-cli] Show why a simulator session failed to start simulator:start only printed the session status when a session errored before it was ready. The reason is already on the session's job run, so fetch turtleJobRun.errors and print each message (and docs link) on its own line. --- .changeset/simulator-start-job-run-errors.md | 5 ++ .../simulator/__tests__/control.test.ts | 2 +- .../commands/simulator/__tests__/get.test.ts | 1 + .../simulator/__tests__/index.test.ts | 66 +++++++++++++++++-- .../eas-cli/src/commands/simulator/index.ts | 14 +++- packages/eas-cli/src/graphql/generated.ts | 2 +- .../graphql/queries/DeviceRunSessionQuery.ts | 5 ++ 7 files changed, 85 insertions(+), 10 deletions(-) create mode 100644 .changeset/simulator-start-job-run-errors.md diff --git a/.changeset/simulator-start-job-run-errors.md b/.changeset/simulator-start-job-run-errors.md new file mode 100644 index 0000000000..0150d92e10 --- /dev/null +++ b/.changeset/simulator-start-job-run-errors.md @@ -0,0 +1,5 @@ +--- +'eas-cli': patch +--- + +Show why a simulator session failed to start in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status. diff --git a/packages/eas-cli/src/commands/simulator/__tests__/control.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/control.test.ts index 53af67182c..2dfb10f356 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/control.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/control.test.ts @@ -81,7 +81,7 @@ function makeDeviceRunSession( previewToken: 'preview-token', previewApiUrl: 'https://sim.example.test', }, - turtleJobRun: { id: 'job-123', status: JobRunStatus.InProgress }, + turtleJobRun: { id: 'job-123', status: JobRunStatus.InProgress, errors: [] }, ...overrides, }; } diff --git a/packages/eas-cli/src/commands/simulator/__tests__/get.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/get.test.ts index d8dca493f7..df0e5afe00 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/get.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/get.test.ts @@ -86,6 +86,7 @@ function makeDeviceRunSession(overrides: Partial = {}): De turtleJobRun: { id: 'job-123', status: JobRunStatus.InProgress, + errors: [], }, ...overrides, }; 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..14f60167f6 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts @@ -57,6 +57,7 @@ jest.mock('../../../log', () => ({ withTick: jest.fn(), }, link: jest.fn((url: string) => url), + learnMore: jest.fn((url: string) => `Learn more: ${url}`), })); jest.mock('../../../simulator/env', () => ({ ...jest.requireActual('../../../simulator/env'), @@ -169,6 +170,7 @@ function makeDeviceRunSession(overrides: Partial = {}): De turtleJobRun: { id: 'job-123', status: JobRunStatus.InProgress, + errors: [], }, ...overrides, }; @@ -1478,7 +1480,7 @@ describe(Simulator, () => { if (elapsedMs < 20 * 60 * 1_000) { return makeDeviceRunSession({ remoteConfig: null, - turtleJobRun: { id: 'job-123', status }, + turtleJobRun: { id: 'job-123', status, errors: [] }, }); } expect(mockOra.mock.results[1].value.text).toContain( @@ -1506,6 +1508,7 @@ describe(Simulator, () => { turtleJobRun: { id: 'job-123', status: elapsedMs < 20 * 60 * 1_000 ? JobRunStatus.InQueue : JobRunStatus.InProgress, + errors: [], }, }) ); @@ -1529,13 +1532,13 @@ describe(Simulator, () => { .mockResolvedValueOnce( makeDeviceRunSession({ remoteConfig: null, - turtleJobRun: { id: 'job-123', status: JobRunStatus.InQueue }, + turtleJobRun: { id: 'job-123', status: JobRunStatus.InQueue, errors: [] }, }) ) .mockResolvedValueOnce( makeDeviceRunSession({ remoteConfig: null, - turtleJobRun: { id: 'job-123', status }, + turtleJobRun: { id: 'job-123', status, errors: [] }, }) ); const { command } = createCommand(['--platform', 'ios', '--non-interactive']); @@ -1909,7 +1912,7 @@ describe(Simulator, () => { mockByIdAsync.mockResolvedValue( makeDeviceRunSession({ remoteConfig: null, - turtleJobRun: { id: 'job-123', status: JobRunStatus.Errored }, + turtleJobRun: { id: 'job-123', status: JobRunStatus.Errored, errors: [] }, }) ); @@ -1920,6 +1923,57 @@ describe(Simulator, () => { ); }); + it('includes the job run error when the session errors before it becomes ready', async () => { + mockByIdAsync.mockResolvedValue( + makeDeviceRunSession({ + status: DeviceRunSessionStatus.Errored, + remoteConfig: null, + turtleJobRun: { + id: 'job-123', + status: JobRunStatus.Errored, + errors: [ + { + errorCode: 'EAS_IOS_SIMULATOR_NOT_FOUND', + message: 'No available iOS Simulator is named "iPhone 16 Pro".', + docsUrl: null, + }, + ], + }, + }) + ); + + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + + await expect(command.runAsync()).rejects.toThrow( + /session was ready\. .*\nNo available iOS Simulator is named "iPhone 16 Pro"\.$/ + ); + }); + + it('includes the job run error and docs link when the job run errors before the session is ready', async () => { + mockByIdAsync.mockResolvedValue( + makeDeviceRunSession({ + remoteConfig: null, + turtleJobRun: { + id: 'job-123', + status: JobRunStatus.Errored, + errors: [ + { + errorCode: 'EAS_SIMULATOR_BUILD_NOT_INSTALLABLE', + message: 'The build cannot be installed on a simulator.', + docsUrl: 'https://docs.expo.dev/simulator', + }, + ], + }, + }) + ); + + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + + await expect(command.runAsync()).rejects.toThrow( + /\nThe build cannot be installed on a simulator\. .*https:\/\/docs\.expo\.dev\/simulator/ + ); + }); + it('keeps polling until the remote config appears', async () => { mockByIdAsync .mockResolvedValueOnce(makeDeviceRunSession({ remoteConfig: null })) @@ -2224,7 +2278,9 @@ describe(Simulator, () => { .mockResolvedValueOnce(makeDeviceRunSession()) .mockResolvedValueOnce(makeDeviceRunSession()) .mockResolvedValueOnce( - makeDeviceRunSession({ turtleJobRun: { id: 'job-123', status: JobRunStatus.Finished } }) + makeDeviceRunSession({ + turtleJobRun: { id: 'job-123', status: JobRunStatus.Finished, errors: [] }, + }) ); const { command } = createCommand(['--platform', 'ios']); diff --git a/packages/eas-cli/src/commands/simulator/index.ts b/packages/eas-cli/src/commands/simulator/index.ts index e7972570fb..0d615a5a3a 100644 --- a/packages/eas-cli/src/commands/simulator/index.ts +++ b/packages/eas-cli/src/commands/simulator/index.ts @@ -27,7 +27,7 @@ import { import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessionMutation'; import { DeviceRunSessionAvailabilityQuery } from '../../graphql/queries/DeviceRunSessionAvailabilityQuery'; import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery'; -import Log, { link } from '../../log'; +import Log, { learnMore, link } from '../../log'; import { Ora, ora } from '../../ora'; import { promptAsync } from '../../prompts'; import { parseEgressAllowList, runLocalEgressAsync } from '../../simulator/egress'; @@ -446,7 +446,7 @@ export default class Simulator extends EasCommand { session.status === DeviceRunSessionStatus.Stopped ) { throw new Error( - `Simulator session ${deviceRunSessionId} ${session.status.toLowerCase()} before the ${flags.type} session was ready. ${link(deviceRunSessionUrl)}` + `Simulator session ${deviceRunSessionId} ${session.status.toLowerCase()} before the ${flags.type} session was ready. ${link(deviceRunSessionUrl)}${formatJobRunErrors(session.turtleJobRun?.errors)}` ); } @@ -457,7 +457,7 @@ export default class Simulator extends EasCommand { jobRunStatus === JobRunStatus.Finished ) { throw new Error( - `Turtle job run for simulator session ${deviceRunSessionId} ${jobRunStatus.toLowerCase()} before the ${flags.type} session was ready. ${link(deviceRunSessionUrl)}` + `Turtle job run for simulator session ${deviceRunSessionId} ${jobRunStatus.toLowerCase()} before the ${flags.type} session was ready. ${link(deviceRunSessionUrl)}${formatJobRunErrors(session.turtleJobRun?.errors)}` ); } @@ -619,6 +619,14 @@ export default class Simulator extends EasCommand { } } +function formatJobRunErrors( + errors: { message: string; docsUrl?: string | null }[] | undefined +): string { + return (errors ?? []) + .map(error => `\n${error.message}${error.docsUrl ? ` ${learnMore(error.docsUrl)}` : ''}`) + .join(''); +} + async function resolvePlatformAsync( platform: PlatformFlagValue | undefined, nonInteractive: boolean diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index 6b0f621c5f..e0ccbf663d 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -16029,7 +16029,7 @@ export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSes | { __typename: 'ArgentRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, toolsUrl: string, toolsAuthToken?: string | null, webPreviewUrl?: string | null, webPreviewToken?: string | null, previewApiUrl?: string | null } | { __typename: 'ServeSimRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, previewUrl: string, previewToken?: string | null, previewApiUrl?: string | null } | { __typename: 'WebPreviewOnlyRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, previewApiUrl?: string | null, previewUrl: string, previewToken?: string | null } - | null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } } }; + | null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus, errors: Array<{ __typename?: 'JobRunError', errorCode: string, message: string, docsUrl?: string | null }> } | null } } }; export type DeviceRunSessionsByAppIdQueryVariables = Exact<{ appId: Scalars['String']['input']; diff --git a/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts b/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts index b89b385a67..abd406950f 100644 --- a/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts +++ b/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts @@ -139,6 +139,11 @@ export const DeviceRunSessionQuery = { turtleJobRun { id status + errors { + errorCode + message + docsUrl + } } } } From a27eb2362d1bb800b5b029ca52eb7fe3071bc616 Mon Sep 17 00:00:00 2001 From: Brent Vatne Date: Mon, 19 Oct 2026 17:20:48 -0700 Subject: [PATCH 2/4] [eas-cli] Read simulator job run errors only on failure, and from logs for unknown errors Fetch job run errors with a separate query when a session fails instead of on every poll of the shared session query. When the job run only reports an unknown error, print the failed step errors from its logs. Also show the errors when a session fails after it is ready. --- .changeset/simulator-start-job-run-errors.md | 2 +- .../simulator/__tests__/control.test.ts | 2 +- .../commands/simulator/__tests__/get.test.ts | 1 - .../simulator/__tests__/index.test.ts | 219 +++++++++++++----- .../eas-cli/src/commands/simulator/index.ts | 57 ++++- packages/eas-cli/src/graphql/generated.ts | 9 +- .../graphql/queries/DeviceRunSessionQuery.ts | 42 +++- 7 files changed, 264 insertions(+), 68 deletions(-) diff --git a/.changeset/simulator-start-job-run-errors.md b/.changeset/simulator-start-job-run-errors.md index 0150d92e10..6888adf3ef 100644 --- a/.changeset/simulator-start-job-run-errors.md +++ b/.changeset/simulator-start-job-run-errors.md @@ -2,4 +2,4 @@ 'eas-cli': patch --- -Show why a simulator session failed to start in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status. +Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name or the step that failed, instead of only the session status. diff --git a/packages/eas-cli/src/commands/simulator/__tests__/control.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/control.test.ts index 2dfb10f356..53af67182c 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/control.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/control.test.ts @@ -81,7 +81,7 @@ function makeDeviceRunSession( previewToken: 'preview-token', previewApiUrl: 'https://sim.example.test', }, - turtleJobRun: { id: 'job-123', status: JobRunStatus.InProgress, errors: [] }, + turtleJobRun: { id: 'job-123', status: JobRunStatus.InProgress }, ...overrides, }; } diff --git a/packages/eas-cli/src/commands/simulator/__tests__/get.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/get.test.ts index df0e5afe00..d8dca493f7 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/get.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/get.test.ts @@ -86,7 +86,6 @@ function makeDeviceRunSession(overrides: Partial = {}): De turtleJobRun: { id: 'job-123', status: JobRunStatus.InProgress, - errors: [], }, ...overrides, }; 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 14f60167f6..d07006f4a7 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts @@ -106,6 +106,7 @@ const mockEnsureDeviceRunSessionStoppedAsync = jest.mocked( ); const mockAvailabilityByAppIdAsync = jest.mocked(DeviceRunSessionAvailabilityQuery.byAppIdAsync); const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync); +const mockJobRunErrorsByIdAsync = jest.mocked(DeviceRunSessionQuery.jobRunErrorsByIdAsync); const mockLoadSimulatorEnvAsync = jest.mocked(loadSimulatorEnvAsync); const mockLogEvent = jest.fn(); const mockFlushAsync = jest.fn(async () => {}); @@ -170,7 +171,6 @@ function makeDeviceRunSession(overrides: Partial = {}): De turtleJobRun: { id: 'job-123', status: JobRunStatus.InProgress, - errors: [], }, ...overrides, }; @@ -193,6 +193,7 @@ describe(Simulator, () => { jest.restoreAllMocks(); jest.clearAllMocks(); delete process.env[EAS_SIMULATOR_SESSION_ID]; + mockJobRunErrorsByIdAsync.mockResolvedValue(null); mockAvailabilityByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: true }); mockCreateDeviceRunSessionAsync.mockResolvedValue(makeCreatedDeviceRunSession()); mockEnsureDeviceRunSessionStoppedAsync.mockResolvedValue({ @@ -1480,7 +1481,7 @@ describe(Simulator, () => { if (elapsedMs < 20 * 60 * 1_000) { return makeDeviceRunSession({ remoteConfig: null, - turtleJobRun: { id: 'job-123', status, errors: [] }, + turtleJobRun: { id: 'job-123', status }, }); } expect(mockOra.mock.results[1].value.text).toContain( @@ -1508,7 +1509,6 @@ describe(Simulator, () => { turtleJobRun: { id: 'job-123', status: elapsedMs < 20 * 60 * 1_000 ? JobRunStatus.InQueue : JobRunStatus.InProgress, - errors: [], }, }) ); @@ -1532,13 +1532,13 @@ describe(Simulator, () => { .mockResolvedValueOnce( makeDeviceRunSession({ remoteConfig: null, - turtleJobRun: { id: 'job-123', status: JobRunStatus.InQueue, errors: [] }, + turtleJobRun: { id: 'job-123', status: JobRunStatus.InQueue }, }) ) .mockResolvedValueOnce( makeDeviceRunSession({ remoteConfig: null, - turtleJobRun: { id: 'job-123', status, errors: [] }, + turtleJobRun: { id: 'job-123', status }, }) ); const { command } = createCommand(['--platform', 'ios', '--non-interactive']); @@ -1912,7 +1912,7 @@ describe(Simulator, () => { mockByIdAsync.mockResolvedValue( makeDeviceRunSession({ remoteConfig: null, - turtleJobRun: { id: 'job-123', status: JobRunStatus.Errored, errors: [] }, + turtleJobRun: { id: 'job-123', status: JobRunStatus.Errored }, }) ); @@ -1923,55 +1923,147 @@ describe(Simulator, () => { ); }); - it('includes the job run error when the session errors before it becomes ready', async () => { - mockByIdAsync.mockResolvedValue( - makeDeviceRunSession({ - status: DeviceRunSessionStatus.Errored, - remoteConfig: null, - turtleJobRun: { - id: 'job-123', - status: JobRunStatus.Errored, - errors: [ - { - errorCode: 'EAS_IOS_SIMULATOR_NOT_FOUND', - message: 'No available iOS Simulator is named "iPhone 16 Pro".', - docsUrl: null, - }, - ], - }, - }) - ); + describe('job run errors', () => { + const erroredSessionMessage = + 'Simulator session session-123 errored before the agent-device session was ready. https://expo.dev/accounts/testuser/projects/testapp/simulator-sessions/session-123'; - const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + beforeEach(() => { + mockByIdAsync.mockResolvedValue( + makeDeviceRunSession({ status: DeviceRunSessionStatus.Errored, remoteConfig: null }) + ); + }); - await expect(command.runAsync()).rejects.toThrow( - /session was ready\. .*\nNo available iOS Simulator is named "iPhone 16 Pro"\.$/ - ); - }); + it('keeps the message unchanged when the job run has no errors', async () => { + mockJobRunErrorsByIdAsync.mockResolvedValue({ id: 'job-123', errors: [], logFileUrls: [] }); - it('includes the job run error and docs link when the job run errors before the session is ready', async () => { - mockByIdAsync.mockResolvedValue( - makeDeviceRunSession({ - remoteConfig: null, - turtleJobRun: { - id: 'job-123', - status: JobRunStatus.Errored, - errors: [ - { - errorCode: 'EAS_SIMULATOR_BUILD_NOT_INSTALLABLE', - message: 'The build cannot be installed on a simulator.', - docsUrl: 'https://docs.expo.dev/simulator', - }, - ], + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + + await expect(command.runAsync()).rejects.toThrow( + expect.objectContaining({ message: erroredSessionMessage }) + ); + }); + + it('lists each job run error on its own line with its docs link', async () => { + mockJobRunErrorsByIdAsync.mockResolvedValue({ + id: 'job-123', + errors: [ + { + errorCode: 'EAS_IOS_SIMULATOR_NOT_FOUND', + message: 'No available iOS Simulator is named "iPhone 16 Pro".', + docsUrl: null, + }, + { + errorCode: 'EAS_SIMULATOR_BUILD_NOT_INSTALLABLE', + message: 'The build cannot be installed on a simulator.', + docsUrl: 'https://docs.expo.dev/simulator', + }, + ], + logFileUrls: ['https://logs.example.com/job-123'], + }); + const fetchSpy = jest.spyOn(global, 'fetch'); + + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + + await expect(command.runAsync()).rejects.toThrow( + expect.objectContaining({ + message: `${erroredSessionMessage}\nNo available iOS Simulator is named "iPhone 16 Pro".\nThe build cannot be installed on a simulator. Learn more: https://docs.expo.dev/simulator`, + }) + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('reads the failed step errors from the job run logs for an unknown error', async () => { + mockJobRunErrorsByIdAsync.mockResolvedValue({ + id: 'job-123', + errors: [ + { + errorCode: 'UNKNOWN_ERROR', + message: 'Unknown error. See logs for more information.', + docsUrl: null, + }, + ], + logFileUrls: ['https://logs.example.com/job-123'], + }); + const logs = [ + { msg: 'Installing app', buildStepDisplayName: 'Install application' }, + { + msg: 'adb shell am start exited with non-zero code: 1', + buildStepDisplayName: 'Launch application', + err: { message: 'adb shell am start exited with non-zero code: 1' }, }, - }) - ); + { msg: 'Build step "Launch application" failed', marker: 'end-step', result: 'fail' }, + ]; + jest + .spyOn(global, 'fetch') + .mockResolvedValue(new Response(logs.map(line => JSON.stringify(line)).join('\n'))); - const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); - await expect(command.runAsync()).rejects.toThrow( - /\nThe build cannot be installed on a simulator\. .*https:\/\/docs\.expo\.dev\/simulator/ - ); + await expect(command.runAsync()).rejects.toThrow( + expect.objectContaining({ + message: `${erroredSessionMessage}\nLaunch application: adb shell am start exited with non-zero code: 1`, + }) + ); + }); + + it('reads the job run logs for an unknown custom build error', async () => { + mockJobRunErrorsByIdAsync.mockResolvedValue({ + id: 'job-123', + errors: [ + { + errorCode: 'UNKNOWN_CUSTOM_BUILD_ERROR', + message: 'Unknown custom build error. See logs for more information.', + docsUrl: null, + }, + ], + logFileUrls: ['https://logs.example.com/job-123'], + }); + jest + .spyOn(global, 'fetch') + .mockResolvedValue( + new Response(JSON.stringify({ msg: 'boom', err: { message: 'simctl boot failed' } })) + ); + + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + + await expect(command.runAsync()).rejects.toThrow( + expect.objectContaining({ message: `${erroredSessionMessage}\nsimctl boot failed` }) + ); + }); + + it('falls back to the unknown error message when the job run logs cannot be read', async () => { + mockJobRunErrorsByIdAsync.mockResolvedValue({ + id: 'job-123', + errors: [ + { + errorCode: 'UNKNOWN_ERROR', + message: 'Unknown error. See logs for more information.', + docsUrl: null, + }, + ], + logFileUrls: ['https://logs.example.com/job-123'], + }); + jest.spyOn(global, 'fetch').mockRejectedValue(new Error('network down')); + + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + + await expect(command.runAsync()).rejects.toThrow( + expect.objectContaining({ + message: `${erroredSessionMessage}\nUnknown error. See logs for more information.`, + }) + ); + }); + + it('keeps the message unchanged when the job run errors cannot be fetched', async () => { + mockJobRunErrorsByIdAsync.mockRejectedValue(new Error('graphql boom')); + + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + + await expect(command.runAsync()).rejects.toThrow( + expect.objectContaining({ message: erroredSessionMessage }) + ); + expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalled(); + }); }); it('keeps polling until the remote config appears', async () => { @@ -2273,14 +2365,37 @@ describe(Simulator, () => { await expect(command.runAsync()).rejects.toThrow('Simulator session session-123 errored.'); }); + it('includes the job run error when the session errors while it is running', async () => { + mockByIdAsync + .mockResolvedValueOnce(makeDeviceRunSession()) + .mockResolvedValueOnce(makeDeviceRunSession({ status: DeviceRunSessionStatus.Errored })); + mockJobRunErrorsByIdAsync.mockResolvedValue({ + id: 'job-123', + errors: [ + { + errorCode: 'EAS_SIMULATOR_SESSION_CRASHED', + message: 'The simulator stopped responding.', + docsUrl: null, + }, + ], + logFileUrls: [], + }); + + const { command } = createCommand(['--platform', 'ios']); + + await expect(command.runAsync()).rejects.toThrow( + expect.objectContaining({ + message: 'Simulator session session-123 errored.\nThe simulator stopped responding.', + }) + ); + }); + 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, errors: [] }, - }) + makeDeviceRunSession({ turtleJobRun: { id: 'job-123', status: JobRunStatus.Finished } }) ); const { command } = createCommand(['--platform', 'ios']); diff --git a/packages/eas-cli/src/commands/simulator/index.ts b/packages/eas-cli/src/commands/simulator/index.ts index 0d615a5a3a..1352891181 100644 --- a/packages/eas-cli/src/commands/simulator/index.ts +++ b/packages/eas-cli/src/commands/simulator/index.ts @@ -12,6 +12,7 @@ import { getDeviceRunSessionPreviewUrl, getDeviceRunSessionUrl } from '../../bui import EasCommand from '../../commandUtils/EasCommand'; import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; import { EasCommandError } from '../../commandUtils/errors'; +import { parseLogLines } from '../../commandUtils/logs/parseLogs'; import { EasNonInteractiveAndJsonFlags, resolveNonInteractiveAndJsonFlags, @@ -446,7 +447,7 @@ export default class Simulator extends EasCommand { session.status === DeviceRunSessionStatus.Stopped ) { throw new Error( - `Simulator session ${deviceRunSessionId} ${session.status.toLowerCase()} before the ${flags.type} session was ready. ${link(deviceRunSessionUrl)}${formatJobRunErrors(session.turtleJobRun?.errors)}` + `Simulator session ${deviceRunSessionId} ${session.status.toLowerCase()} before the ${flags.type} session was ready. ${link(deviceRunSessionUrl)}${await formatJobRunErrorsAsync(graphqlClient, deviceRunSessionId)}` ); } @@ -457,7 +458,7 @@ export default class Simulator extends EasCommand { jobRunStatus === JobRunStatus.Finished ) { throw new Error( - `Turtle job run for simulator session ${deviceRunSessionId} ${jobRunStatus.toLowerCase()} before the ${flags.type} session was ready. ${link(deviceRunSessionUrl)}${formatJobRunErrors(session.turtleJobRun?.errors)}` + `Turtle job run for simulator session ${deviceRunSessionId} ${jobRunStatus.toLowerCase()} before the ${flags.type} session was ready. ${link(deviceRunSessionUrl)}${await formatJobRunErrorsAsync(graphqlClient, deviceRunSessionId)}` ); } @@ -619,14 +620,54 @@ export default class Simulator extends EasCommand { } } -function formatJobRunErrors( - errors: { message: string; docsUrl?: string | null }[] | undefined -): string { - return (errors ?? []) +// ErrorCode.UNKNOWN_ERROR and ErrorCode.UNKNOWN_CUSTOM_BUILD_ERROR from @expo/eas-build-job. +const UNKNOWN_ERROR_CODES = new Set(['UNKNOWN_ERROR', 'UNKNOWN_CUSTOM_BUILD_ERROR']); +const MAX_LOGGED_JOB_RUN_ERRORS = 3; + +// Returns the reasons the session's job run failed, one per line, or an empty string when none are available. +async function formatJobRunErrorsAsync( + graphqlClient: ExpoGraphqlClient, + deviceRunSessionId: string +): Promise { + let jobRun; + try { + jobRun = await DeviceRunSessionQuery.jobRunErrorsByIdAsync(graphqlClient, deviceRunSessionId); + } catch (err) { + Log.debug(`Failed to fetch simulator session errors: ${err}`); + return ''; + } + const errors = jobRun?.errors ?? []; + // Uncategorized failures only say "Unknown error. See logs", so read the real cause from the job run logs. + const logFileUrl = jobRun?.logFileUrls[0]; + if (logFileUrl && errors.every(error => UNKNOWN_ERROR_CODES.has(error.errorCode))) { + const logErrors = await fetchJobRunLogErrorsAsync(logFileUrl); + if (logErrors.length > 0) { + return logErrors.map(message => `\n${message}`).join(''); + } + } + return errors .map(error => `\n${error.message}${error.docsUrl ? ` ${learnMore(error.docsUrl)}` : ''}`) .join(''); } +async function fetchJobRunLogErrorsAsync(logFileUrl: string): Promise { + try { + const response = await fetch(logFileUrl); + const { logLines } = parseLogLines(await response.text()); + const messages = logLines + .filter(line => typeof line.err?.message === 'string') + .map(line => + line.buildStepDisplayName + ? `${line.buildStepDisplayName}: ${line.err.message}` + : line.err.message + ); + return [...new Set(messages)].slice(-MAX_LOGGED_JOB_RUN_ERRORS); + } catch (err) { + Log.debug(`Failed to read simulator session logs: ${err}`); + return []; + } +} + async function resolvePlatformAsync( platform: PlatformFlagValue | undefined, nonInteractive: boolean @@ -708,7 +749,9 @@ async function waitForSessionEndOrInterruptAsync({ jobRunStatus === JobRunStatus.Errored ) { spinner.fail(`Simulator session errored. ${link(deviceRunSessionUrl)}`); - throw new Error(`Simulator session ${deviceRunSessionId} errored.`); + throw new Error( + `Simulator session ${deviceRunSessionId} errored.${await formatJobRunErrorsAsync(graphqlClient, deviceRunSessionId)}` + ); } if ( session.status === DeviceRunSessionStatus.Stopped || diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index e0ccbf663d..c81351929d 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -16018,6 +16018,13 @@ export type DeviceRunSessionEventsByIdQueryVariables = Exact<{ export type DeviceRunSessionEventsByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, artifacts: Array<{ __typename?: 'DeviceRunSessionArtifact', id: string, downloadUrl: string, metadata?: any | null }> } } }; +export type DeviceRunSessionJobRunErrorsByIdQueryVariables = Exact<{ + deviceRunSessionId: Scalars['ID']['input']; +}>; + + +export type DeviceRunSessionJobRunErrorsByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, turtleJobRun?: { __typename?: 'JobRun', id: string, logFileUrls: Array, errors: Array<{ __typename?: 'JobRunError', errorCode: string, message: string, docsUrl?: string | null }> } | null } } }; + export type DeviceRunSessionByIdQueryVariables = Exact<{ deviceRunSessionId: Scalars['ID']['input']; }>; @@ -16029,7 +16036,7 @@ export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSes | { __typename: 'ArgentRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, toolsUrl: string, toolsAuthToken?: string | null, webPreviewUrl?: string | null, webPreviewToken?: string | null, previewApiUrl?: string | null } | { __typename: 'ServeSimRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, previewUrl: string, previewToken?: string | null, previewApiUrl?: string | null } | { __typename: 'WebPreviewOnlyRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, previewApiUrl?: string | null, previewUrl: string, previewToken?: string | null } - | null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus, errors: Array<{ __typename?: 'JobRunError', errorCode: string, message: string, docsUrl?: string | null }> } | null } } }; + | null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } } }; export type DeviceRunSessionsByAppIdQueryVariables = Exact<{ appId: Scalars['String']['input']; diff --git a/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts b/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts index abd406950f..9d750bd00d 100644 --- a/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts +++ b/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts @@ -8,6 +8,8 @@ import { DeviceRunSessionEventsByIdQuery, DeviceRunSessionEventsByIdQueryVariables, DeviceRunSessionFilterInput, + DeviceRunSessionJobRunErrorsByIdQuery, + DeviceRunSessionJobRunErrorsByIdQueryVariables, DeviceRunSessionsByAppIdQuery, DeviceRunSessionsByAppIdQueryVariables, } from '../generated'; @@ -42,6 +44,41 @@ export const DeviceRunSessionQuery = { ); return data.deviceRunSessions.byId; }, + async jobRunErrorsByIdAsync( + graphqlClient: ExpoGraphqlClient, + deviceRunSessionId: string + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .query< + DeviceRunSessionJobRunErrorsByIdQuery, + DeviceRunSessionJobRunErrorsByIdQueryVariables + >( + gql` + query DeviceRunSessionJobRunErrorsByIdQuery($deviceRunSessionId: ID!) { + deviceRunSessions { + byId(deviceRunSessionId: $deviceRunSessionId) { + id + turtleJobRun { + id + errors { + errorCode + message + docsUrl + } + logFileUrls + } + } + } + } + `, + { deviceRunSessionId }, + { requestPolicy: 'network-only' } + ) + .toPromise() + ); + return data.deviceRunSessions.byId.turtleJobRun; + }, async byIdAsync( graphqlClient: ExpoGraphqlClient, deviceRunSessionId: string @@ -139,11 +176,6 @@ export const DeviceRunSessionQuery = { turtleJobRun { id status - errors { - errorCode - message - docsUrl - } } } } From 0ba2abc609a41e5c9ad8fd3b96e2d1fd713f65b5 Mon Sep 17 00:00:00 2001 From: Brent Vatne Date: Mon, 19 Oct 2026 19:17:25 -0700 Subject: [PATCH 3/4] [eas-cli] Show only structured job run errors in simulator:start Drop the job run log fallback from this change, which printed unmasked err.message lines, read logs for stopped sessions and had no timeout or size limit. The bounded, masked log fallback lands in the simulator:get follow-up. --- .changeset/simulator-start-job-run-errors.md | 2 +- .../simulator/__tests__/index.test.ts | 69 +------------------ .../eas-cli/src/commands/simulator/index.ts | 34 +-------- packages/eas-cli/src/graphql/generated.ts | 2 +- .../graphql/queries/DeviceRunSessionQuery.ts | 1 - 5 files changed, 5 insertions(+), 103 deletions(-) diff --git a/.changeset/simulator-start-job-run-errors.md b/.changeset/simulator-start-job-run-errors.md index 6888adf3ef..5a842a89a5 100644 --- a/.changeset/simulator-start-job-run-errors.md +++ b/.changeset/simulator-start-job-run-errors.md @@ -2,4 +2,4 @@ 'eas-cli': patch --- -Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name or the step that failed, instead of only the session status. +Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status. 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 d07006f4a7..fe7166ed2c 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts @@ -1934,7 +1934,7 @@ describe(Simulator, () => { }); it('keeps the message unchanged when the job run has no errors', async () => { - mockJobRunErrorsByIdAsync.mockResolvedValue({ id: 'job-123', errors: [], logFileUrls: [] }); + mockJobRunErrorsByIdAsync.mockResolvedValue({ id: 'job-123', errors: [] }); const { command } = createCommand(['--platform', 'ios', '--non-interactive']); @@ -1958,9 +1958,7 @@ describe(Simulator, () => { docsUrl: 'https://docs.expo.dev/simulator', }, ], - logFileUrls: ['https://logs.example.com/job-123'], }); - const fetchSpy = jest.spyOn(global, 'fetch'); const { command } = createCommand(['--platform', 'ios', '--non-interactive']); @@ -1969,10 +1967,9 @@ describe(Simulator, () => { message: `${erroredSessionMessage}\nNo available iOS Simulator is named "iPhone 16 Pro".\nThe build cannot be installed on a simulator. Learn more: https://docs.expo.dev/simulator`, }) ); - expect(fetchSpy).not.toHaveBeenCalled(); }); - it('reads the failed step errors from the job run logs for an unknown error', async () => { + it('prints an unknown error as reported', async () => { mockJobRunErrorsByIdAsync.mockResolvedValue({ id: 'job-123', errors: [ @@ -1982,68 +1979,7 @@ describe(Simulator, () => { docsUrl: null, }, ], - logFileUrls: ['https://logs.example.com/job-123'], }); - const logs = [ - { msg: 'Installing app', buildStepDisplayName: 'Install application' }, - { - msg: 'adb shell am start exited with non-zero code: 1', - buildStepDisplayName: 'Launch application', - err: { message: 'adb shell am start exited with non-zero code: 1' }, - }, - { msg: 'Build step "Launch application" failed', marker: 'end-step', result: 'fail' }, - ]; - jest - .spyOn(global, 'fetch') - .mockResolvedValue(new Response(logs.map(line => JSON.stringify(line)).join('\n'))); - - const { command } = createCommand(['--platform', 'ios', '--non-interactive']); - - await expect(command.runAsync()).rejects.toThrow( - expect.objectContaining({ - message: `${erroredSessionMessage}\nLaunch application: adb shell am start exited with non-zero code: 1`, - }) - ); - }); - - it('reads the job run logs for an unknown custom build error', async () => { - mockJobRunErrorsByIdAsync.mockResolvedValue({ - id: 'job-123', - errors: [ - { - errorCode: 'UNKNOWN_CUSTOM_BUILD_ERROR', - message: 'Unknown custom build error. See logs for more information.', - docsUrl: null, - }, - ], - logFileUrls: ['https://logs.example.com/job-123'], - }); - jest - .spyOn(global, 'fetch') - .mockResolvedValue( - new Response(JSON.stringify({ msg: 'boom', err: { message: 'simctl boot failed' } })) - ); - - const { command } = createCommand(['--platform', 'ios', '--non-interactive']); - - await expect(command.runAsync()).rejects.toThrow( - expect.objectContaining({ message: `${erroredSessionMessage}\nsimctl boot failed` }) - ); - }); - - it('falls back to the unknown error message when the job run logs cannot be read', async () => { - mockJobRunErrorsByIdAsync.mockResolvedValue({ - id: 'job-123', - errors: [ - { - errorCode: 'UNKNOWN_ERROR', - message: 'Unknown error. See logs for more information.', - docsUrl: null, - }, - ], - logFileUrls: ['https://logs.example.com/job-123'], - }); - jest.spyOn(global, 'fetch').mockRejectedValue(new Error('network down')); const { command } = createCommand(['--platform', 'ios', '--non-interactive']); @@ -2378,7 +2314,6 @@ describe(Simulator, () => { docsUrl: null, }, ], - logFileUrls: [], }); const { command } = createCommand(['--platform', 'ios']); diff --git a/packages/eas-cli/src/commands/simulator/index.ts b/packages/eas-cli/src/commands/simulator/index.ts index 1352891181..7c0b792f6e 100644 --- a/packages/eas-cli/src/commands/simulator/index.ts +++ b/packages/eas-cli/src/commands/simulator/index.ts @@ -12,7 +12,6 @@ import { getDeviceRunSessionPreviewUrl, getDeviceRunSessionUrl } from '../../bui import EasCommand from '../../commandUtils/EasCommand'; import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; import { EasCommandError } from '../../commandUtils/errors'; -import { parseLogLines } from '../../commandUtils/logs/parseLogs'; import { EasNonInteractiveAndJsonFlags, resolveNonInteractiveAndJsonFlags, @@ -620,10 +619,6 @@ export default class Simulator extends EasCommand { } } -// ErrorCode.UNKNOWN_ERROR and ErrorCode.UNKNOWN_CUSTOM_BUILD_ERROR from @expo/eas-build-job. -const UNKNOWN_ERROR_CODES = new Set(['UNKNOWN_ERROR', 'UNKNOWN_CUSTOM_BUILD_ERROR']); -const MAX_LOGGED_JOB_RUN_ERRORS = 3; - // Returns the reasons the session's job run failed, one per line, or an empty string when none are available. async function formatJobRunErrorsAsync( graphqlClient: ExpoGraphqlClient, @@ -636,38 +631,11 @@ async function formatJobRunErrorsAsync( Log.debug(`Failed to fetch simulator session errors: ${err}`); return ''; } - const errors = jobRun?.errors ?? []; - // Uncategorized failures only say "Unknown error. See logs", so read the real cause from the job run logs. - const logFileUrl = jobRun?.logFileUrls[0]; - if (logFileUrl && errors.every(error => UNKNOWN_ERROR_CODES.has(error.errorCode))) { - const logErrors = await fetchJobRunLogErrorsAsync(logFileUrl); - if (logErrors.length > 0) { - return logErrors.map(message => `\n${message}`).join(''); - } - } - return errors + return (jobRun?.errors ?? []) .map(error => `\n${error.message}${error.docsUrl ? ` ${learnMore(error.docsUrl)}` : ''}`) .join(''); } -async function fetchJobRunLogErrorsAsync(logFileUrl: string): Promise { - try { - const response = await fetch(logFileUrl); - const { logLines } = parseLogLines(await response.text()); - const messages = logLines - .filter(line => typeof line.err?.message === 'string') - .map(line => - line.buildStepDisplayName - ? `${line.buildStepDisplayName}: ${line.err.message}` - : line.err.message - ); - return [...new Set(messages)].slice(-MAX_LOGGED_JOB_RUN_ERRORS); - } catch (err) { - Log.debug(`Failed to read simulator session logs: ${err}`); - return []; - } -} - async function resolvePlatformAsync( platform: PlatformFlagValue | undefined, nonInteractive: boolean diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index c81351929d..b090fda0d1 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -16023,7 +16023,7 @@ export type DeviceRunSessionJobRunErrorsByIdQueryVariables = Exact<{ }>; -export type DeviceRunSessionJobRunErrorsByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, turtleJobRun?: { __typename?: 'JobRun', id: string, logFileUrls: Array, errors: Array<{ __typename?: 'JobRunError', errorCode: string, message: string, docsUrl?: string | null }> } | null } } }; +export type DeviceRunSessionJobRunErrorsByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, turtleJobRun?: { __typename?: 'JobRun', id: string, errors: Array<{ __typename?: 'JobRunError', errorCode: string, message: string, docsUrl?: string | null }> } | null } } }; export type DeviceRunSessionByIdQueryVariables = Exact<{ deviceRunSessionId: Scalars['ID']['input']; diff --git a/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts b/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts index 9d750bd00d..ff0c53246d 100644 --- a/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts +++ b/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts @@ -66,7 +66,6 @@ export const DeviceRunSessionQuery = { message docsUrl } - logFileUrls } } } From a4f6ea878b387dd3de5c00028151214517daaf64 Mon Sep 17 00:00:00 2001 From: Brent Vatne Date: Mon, 19 Oct 2026 20:03:36 -0700 Subject: [PATCH 4/4] [eas-cli] Bound and quiet the simulator job run errors lookup Give the lookup a 10s deadline and the session's Ctrl+C signal, and cancel its request by unsubscribing from urql, so it can't hold up stopping the session. Don't retry it or print the transient-error message, since it's best-effort. Cap each error message at 500 characters. --- .../simulator/__tests__/index.test.ts | 46 ++++++ .../eas-cli/src/commands/simulator/index.ts | 30 +++- .../graphql/queries/DeviceRunSessionQuery.ts | 96 +++++++++--- .../__tests__/DeviceRunSessionQuery-test.ts | 141 ++++++++++++++++++ 4 files changed, 283 insertions(+), 30 deletions(-) create mode 100644 packages/eas-cli/src/graphql/queries/__tests__/DeviceRunSessionQuery-test.ts 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 fe7166ed2c..c943fab62f 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts @@ -2000,6 +2000,52 @@ describe(Simulator, () => { ); expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalled(); }); + + it('looks up the job run errors with an abortable signal', async () => { + mockJobRunErrorsByIdAsync.mockResolvedValue({ id: 'job-123', errors: [] }); + + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + + await expect(command.runAsync()).rejects.toThrow(); + expect(mockJobRunErrorsByIdAsync).toHaveBeenCalledWith(expect.anything(), 'session-123', { + signal: expect.any(AbortSignal), + }); + }); + + it('gives up on a job run error lookup that does not settle and still stops the session', async () => { + jest.spyOn(AbortSignal, 'timeout').mockReturnValue(AbortSignal.abort(new Error('timed out'))); + mockJobRunErrorsByIdAsync.mockImplementation( + async (_client, _id, { signal }) => + await new Promise((_resolve, reject) => { + if (signal.aborted) { + reject(signal.reason); + } + signal.addEventListener('abort', () => { + reject(signal.reason); + }); + }) + ); + + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + + await expect(command.runAsync()).rejects.toThrow( + expect.objectContaining({ message: erroredSessionMessage }) + ); + expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalled(); + }); + + it('truncates very long job run errors', async () => { + mockJobRunErrorsByIdAsync.mockResolvedValue({ + id: 'job-123', + errors: [{ errorCode: 'EAS_SIMULATOR_ERROR', message: 'x'.repeat(2000), docsUrl: null }], + }); + + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + + await expect(command.runAsync()).rejects.toThrow( + expect.objectContaining({ message: `${erroredSessionMessage}\n${'x'.repeat(500)}…` }) + ); + }); }); it('keeps polling until the remote config appears', async () => { diff --git a/packages/eas-cli/src/commands/simulator/index.ts b/packages/eas-cli/src/commands/simulator/index.ts index 7c0b792f6e..40ef69662d 100644 --- a/packages/eas-cli/src/commands/simulator/index.ts +++ b/packages/eas-cli/src/commands/simulator/index.ts @@ -446,7 +446,7 @@ export default class Simulator extends EasCommand { session.status === DeviceRunSessionStatus.Stopped ) { throw new Error( - `Simulator session ${deviceRunSessionId} ${session.status.toLowerCase()} before the ${flags.type} session was ready. ${link(deviceRunSessionUrl)}${await formatJobRunErrorsAsync(graphqlClient, deviceRunSessionId)}` + `Simulator session ${deviceRunSessionId} ${session.status.toLowerCase()} before the ${flags.type} session was ready. ${link(deviceRunSessionUrl)}${await formatJobRunErrorsAsync(graphqlClient, deviceRunSessionId, sessionInterrupt.signal)}` ); } @@ -457,7 +457,7 @@ export default class Simulator extends EasCommand { jobRunStatus === JobRunStatus.Finished ) { throw new Error( - `Turtle job run for simulator session ${deviceRunSessionId} ${jobRunStatus.toLowerCase()} before the ${flags.type} session was ready. ${link(deviceRunSessionUrl)}${await formatJobRunErrorsAsync(graphqlClient, deviceRunSessionId)}` + `Turtle job run for simulator session ${deviceRunSessionId} ${jobRunStatus.toLowerCase()} before the ${flags.type} session was ready. ${link(deviceRunSessionUrl)}${await formatJobRunErrorsAsync(graphqlClient, deviceRunSessionId, sessionInterrupt.signal)}` ); } @@ -619,20 +619,36 @@ export default class Simulator extends EasCommand { } } -// Returns the reasons the session's job run failed, one per line, or an empty string when none are available. +const JOB_RUN_ERRORS_TIMEOUT_MS = 10_000; +const MAX_JOB_RUN_ERROR_LENGTH = 500; + +// Returns the reasons the session's job run failed, one per line, or an empty string when none are +// available. Gives up after a deadline or when `signal` aborts, so it can't hold up stopping the session. async function formatJobRunErrorsAsync( graphqlClient: ExpoGraphqlClient, - deviceRunSessionId: string + deviceRunSessionId: string, + signal: AbortSignal ): Promise { + if (signal.aborted) { + return ''; + } let jobRun; try { - jobRun = await DeviceRunSessionQuery.jobRunErrorsByIdAsync(graphqlClient, deviceRunSessionId); + jobRun = await DeviceRunSessionQuery.jobRunErrorsByIdAsync(graphqlClient, deviceRunSessionId, { + signal: AbortSignal.any([signal, AbortSignal.timeout(JOB_RUN_ERRORS_TIMEOUT_MS)]), + }); } catch (err) { Log.debug(`Failed to fetch simulator session errors: ${err}`); return ''; } return (jobRun?.errors ?? []) - .map(error => `\n${error.message}${error.docsUrl ? ` ${learnMore(error.docsUrl)}` : ''}`) + .map(error => { + const message = + error.message.length > MAX_JOB_RUN_ERROR_LENGTH + ? `${error.message.slice(0, MAX_JOB_RUN_ERROR_LENGTH)}…` + : error.message; + return `\n${message}${error.docsUrl ? ` ${learnMore(error.docsUrl)}` : ''}`; + }) .join(''); } @@ -718,7 +734,7 @@ async function waitForSessionEndOrInterruptAsync({ ) { spinner.fail(`Simulator session errored. ${link(deviceRunSessionUrl)}`); throw new Error( - `Simulator session ${deviceRunSessionId} errored.${await formatJobRunErrorsAsync(graphqlClient, deviceRunSessionId)}` + `Simulator session ${deviceRunSessionId} errored.${await formatJobRunErrorsAsync(graphqlClient, deviceRunSessionId, signal)}` ); } if ( diff --git a/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts b/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts index ff0c53246d..1dad63122c 100644 --- a/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts +++ b/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts @@ -1,3 +1,4 @@ +import { OperationResult, OperationResultSource } from '@urql/core'; import gql from 'graphql-tag'; import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; @@ -44,38 +45,49 @@ export const DeviceRunSessionQuery = { ); return data.deviceRunSessions.byId; }, + /** + * Best-effort lookup of why the session's job run failed. It doesn't retry or print errors, and it + * rejects with `signal.reason` and cancels the request when `signal` aborts. + */ async jobRunErrorsByIdAsync( graphqlClient: ExpoGraphqlClient, - deviceRunSessionId: string + deviceRunSessionId: string, + { signal }: { signal: AbortSignal } ): Promise { - const data = await withErrorHandlingAsync( - graphqlClient - .query< - DeviceRunSessionJobRunErrorsByIdQuery, - DeviceRunSessionJobRunErrorsByIdQueryVariables - >( - gql` - query DeviceRunSessionJobRunErrorsByIdQuery($deviceRunSessionId: ID!) { - deviceRunSessions { - byId(deviceRunSessionId: $deviceRunSessionId) { + const { data, error } = await firstResultUntilAbortedAsync( + graphqlClient.query< + DeviceRunSessionJobRunErrorsByIdQuery, + DeviceRunSessionJobRunErrorsByIdQueryVariables + >( + gql` + query DeviceRunSessionJobRunErrorsByIdQuery($deviceRunSessionId: ID!) { + deviceRunSessions { + byId(deviceRunSessionId: $deviceRunSessionId) { + id + turtleJobRun { id - turtleJobRun { - id - errors { - errorCode - message - docsUrl - } + errors { + errorCode + message + docsUrl } } } } - `, - { deviceRunSessionId }, - { requestPolicy: 'network-only' } - ) - .toPromise() + } + `, + { deviceRunSessionId }, + // Retries would keep a timer alive after the caller gives up. + { requestPolicy: 'network-only', noRetry: true } + ), + signal ); + if (error) { + throw error; + } + if (!data) { + throw new Error('Returned query result data is null!'); + } return data.deviceRunSessions.byId.turtleJobRun; }, async byIdAsync( @@ -260,3 +272,41 @@ export const DeviceRunSessionQuery = { return data.app.byId.deviceRunSessionsPaginated; }, }; + +// Like `toPromise()`, but unsubscribing on abort makes urql cancel the request. +async function firstResultUntilAbortedAsync( + source: OperationResultSource, + signal: AbortSignal +): Promise { + signal.throwIfAborted(); + return await new Promise((resolve, reject) => { + let subscription: { unsubscribe: () => void } | undefined; + let settled = false; + const settle = (finish: () => void): void => { + if (settled) { + return; + } + settled = true; + signal.removeEventListener('abort', onAbort); + subscription?.unsubscribe(); + finish(); + }; + const onAbort = (): void => { + settle(() => { + reject(signal.reason); + }); + }; + signal.addEventListener('abort', onAbort, { once: true }); + subscription = source.subscribe(result => { + if (!result.stale && !result.hasNext) { + settle(() => { + resolve(result); + }); + } + }); + // The result can arrive synchronously, before `subscription` is assigned. + if (settled) { + subscription.unsubscribe(); + } + }); +} diff --git a/packages/eas-cli/src/graphql/queries/__tests__/DeviceRunSessionQuery-test.ts b/packages/eas-cli/src/graphql/queries/__tests__/DeviceRunSessionQuery-test.ts new file mode 100644 index 0000000000..e37d6fb272 --- /dev/null +++ b/packages/eas-cli/src/graphql/queries/__tests__/DeviceRunSessionQuery-test.ts @@ -0,0 +1,141 @@ +import http from 'http'; +import { AddressInfo } from 'net'; + +import { createGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import Log from '../../../log'; +import { DeviceRunSessionQuery } from '../DeviceRunSessionQuery'; + +jest.mock('../../../api', () => ({ + getExpoApiBaseUrl: () => process.env.DEVICE_RUN_SESSION_QUERY_TEST_URL, +})); + +describe('DeviceRunSessionQuery.jobRunErrorsByIdAsync', () => { + let server: http.Server; + let requests: number; + let closedRequests: number; + + async function startServerAsync(handler: http.RequestListener): Promise { + requests = 0; + closedRequests = 0; + server = http.createServer((request, response) => { + requests += 1; + request.socket.on('close', () => { + closedRequests += 1; + }); + handler(request, response); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as AddressInfo; + process.env.DEVICE_RUN_SESSION_QUERY_TEST_URL = `http://127.0.0.1:${port}`; + } + + function respondWithJson(body: unknown): http.RequestListener { + return (_request, response) => { + response.setHeader('content-type', 'application/json'); + response.end(JSON.stringify(body)); + }; + } + + afterEach(async () => { + jest.restoreAllMocks(); + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + }); + + it('returns the job run errors', async () => { + const turtleJobRun = { + __typename: 'JobRun', + id: 'job-123', + errors: [ + { + __typename: 'JobRunError', + errorCode: 'UNKNOWN_ERROR', + message: 'Unknown error.', + docsUrl: null, + }, + ], + }; + await startServerAsync( + respondWithJson({ + data: { + deviceRunSessions: { + __typename: 'DeviceRunSessionQuery', + byId: { __typename: 'DeviceRunSession', id: 'session-123', turtleJobRun }, + }, + }, + }) + ); + const client = createGraphqlClient({ accessToken: 'token', sessionSecret: null }); + + await expect( + DeviceRunSessionQuery.jobRunErrorsByIdAsync(client, 'session-123', { + signal: new AbortController().signal, + }) + ).resolves.toEqual(turtleJobRun); + }); + + it('rejects without printing anything on a transient GraphQL error', async () => { + const logErrorSpy = jest.spyOn(Log, 'error').mockImplementation(() => {}); + await startServerAsync( + respondWithJson({ + data: null, + errors: [{ message: 'Try again', extensions: { isTransient: true } }], + }) + ); + const client = createGraphqlClient({ accessToken: 'token', sessionSecret: null }); + + await expect( + DeviceRunSessionQuery.jobRunErrorsByIdAsync(client, 'session-123', { + signal: new AbortController().signal, + }) + ).rejects.toThrow('Try again'); + expect(logErrorSpy).not.toHaveBeenCalled(); + expect(requests).toBe(1); + }); + + it('does not retry a network error', async () => { + await startServerAsync(request => { + request.socket.destroy(); + }); + const client = createGraphqlClient({ accessToken: 'token', sessionSecret: null }); + + await expect( + DeviceRunSessionQuery.jobRunErrorsByIdAsync(client, 'session-123', { + signal: new AbortController().signal, + }) + ).rejects.toThrow(); + expect(requests).toBe(1); + }); + + it('rejects and cancels the request when the signal aborts', async () => { + const controller = new AbortController(); + await startServerAsync(() => { + controller.abort(new Error('interrupted')); + }); + const client = createGraphqlClient({ accessToken: 'token', sessionSecret: null }); + + await expect( + DeviceRunSessionQuery.jobRunErrorsByIdAsync(client, 'session-123', { + signal: controller.signal, + }) + ).rejects.toThrow('interrupted'); + for (let attempt = 0; attempt < 50 && closedRequests === 0; attempt++) { + await new Promise(resolve => setTimeout(resolve, 20)); + } + expect(closedRequests).toBe(1); + }); + + it('rejects without sending a request when the signal has already aborted', async () => { + await startServerAsync(() => {}); + const client = createGraphqlClient({ accessToken: 'token', sessionSecret: null }); + const controller = new AbortController(); + controller.abort(new Error('interrupted')); + + await expect( + DeviceRunSessionQuery.jobRunErrorsByIdAsync(client, 'session-123', { + signal: controller.signal, + }) + ).rejects.toThrow('interrupted'); + expect(requests).toBe(0); + }); +});