From c2a3dc5bce73c1d201bff84059611249f10a7d6e Mon Sep 17 00:00:00 2001 From: Brent Vatne Date: Mon, 19 Oct 2026 17:05:49 -0700 Subject: [PATCH 1/9] [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/9] [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 8b55d1095ec1ccf50fbaeb6c94f1a764c467e8b7 Mon Sep 17 00:00:00 2001 From: Brent Vatne Date: Mon, 19 Oct 2026 17:35:05 -0700 Subject: [PATCH 3/9] [eas-cli] Show simulator session errors in simulator:get Move the job run error lookup and unknown-error log fallback into simulator/jobRunErrors so simulator:start and simulator:get share it. simulator:get fetches the errors only for errored sessions, prints them in a Session errors section, and always includes an errors array in --json. --- .changeset/simulator-get-job-run-errors.md | 5 ++ .../commands/simulator/__tests__/get.test.ts | 73 +++++++++++++++++++ .../eas-cli/src/commands/simulator/get.ts | 25 ++++++- .../eas-cli/src/commands/simulator/index.ts | 50 ++----------- .../eas-cli/src/simulator/jobRunErrors.ts | 61 ++++++++++++++++ 5 files changed, 170 insertions(+), 44 deletions(-) create mode 100644 .changeset/simulator-get-job-run-errors.md create mode 100644 packages/eas-cli/src/simulator/jobRunErrors.ts diff --git a/.changeset/simulator-get-job-run-errors.md b/.changeset/simulator-get-job-run-errors.md new file mode 100644 index 0000000000..6bb4860758 --- /dev/null +++ b/.changeset/simulator-get-job-run-errors.md @@ -0,0 +1,5 @@ +--- +'eas-cli': patch +--- + +Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. 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..de45096a7c 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/get.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/get.test.ts @@ -40,6 +40,7 @@ jest.mock('../../../utils/json'); type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId']; const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync); +const mockJobRunErrorsByIdAsync = jest.mocked(DeviceRunSessionQuery.jobRunErrorsByIdAsync); const mockEnableJsonOutput = jest.mocked(enableJsonOutput); const mockLoadSimulatorEnvironmentVariablesAsync = jest.mocked(loadSimulatorEnvAsync); const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput); @@ -108,6 +109,7 @@ describe(SimulatorGet, () => { beforeEach(() => { jest.clearAllMocks(); mockLoadSimulatorEnvironmentVariablesAsync.mockResolvedValue(); + mockJobRunErrorsByIdAsync.mockResolvedValue(null); }); function createCommand(argv: string[]): { @@ -162,6 +164,77 @@ describe(SimulatorGet, () => { previewApiUrl: null, }, artifacts: session.artifacts, + errors: [], + }); + expect(mockJobRunErrorsByIdAsync).not.toHaveBeenCalled(); + }); + + describe('errored sessions', () => { + const jobRunErrors = { + id: 'job-123', + errors: [ + { + errorCode: 'EAS_IOS_SIMULATOR_NOT_FOUND', + message: 'No available iOS Simulator is named "iPhone 16 Pro".', + docsUrl: null, + }, + ], + logFileUrls: [], + }; + + it('includes the job run errors in JSON output', async () => { + mockByIdAsync.mockResolvedValue( + makeDeviceRunSession({ status: DeviceRunSessionStatus.Errored, remoteConfig: null }) + ); + mockJobRunErrorsByIdAsync.mockResolvedValue(jobRunErrors); + + const { command } = createCommand(['--id', 'session-123', '--json']); + await command.runAsync(); + + expect(mockJobRunErrorsByIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-123'); + expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith( + expect.objectContaining({ + errors: [ + { + errorCode: 'EAS_IOS_SIMULATOR_NOT_FOUND', + message: 'No available iOS Simulator is named "iPhone 16 Pro".', + docsUrl: null, + }, + ], + }) + ); + }); + + it('prints the job run errors when only the job run errored', async () => { + mockByIdAsync.mockResolvedValue( + makeDeviceRunSession({ + status: DeviceRunSessionStatus.Stopped, + remoteConfig: null, + turtleJobRun: { id: 'job-123', status: JobRunStatus.Errored }, + }) + ); + mockJobRunErrorsByIdAsync.mockResolvedValue(jobRunErrors); + + const { command } = createCommand(['--id', 'session-123']); + await command.runAsync(); + + expect(Log.gray).toHaveBeenCalledWith('Session errors:'); + expect(Log.error).toHaveBeenCalledWith( + ' No available iOS Simulator is named "iPhone 16 Pro".' + ); + }); + + it('prints no errors section when the job run errors cannot be fetched', async () => { + mockByIdAsync.mockResolvedValue( + makeDeviceRunSession({ status: DeviceRunSessionStatus.Errored, remoteConfig: null }) + ); + mockJobRunErrorsByIdAsync.mockRejectedValue(new Error('graphql boom')); + + const { command } = createCommand(['--id', 'session-123']); + await command.runAsync(); + + expect(Log.gray).not.toHaveBeenCalledWith('Session errors:'); + expect(Log.error).not.toHaveBeenCalled(); }); }); diff --git a/packages/eas-cli/src/commands/simulator/get.ts b/packages/eas-cli/src/commands/simulator/get.ts index 8eb4117653..bc5fa743a5 100644 --- a/packages/eas-cli/src/commands/simulator/get.ts +++ b/packages/eas-cli/src/commands/simulator/get.ts @@ -6,7 +6,11 @@ import { EasNonInteractiveAndJsonFlags, resolveNonInteractiveAndJsonFlags, } from '../../commandUtils/flags'; -import { DeviceRunSessionByIdQuery, DeviceRunSessionStatus } from '../../graphql/generated'; +import { + DeviceRunSessionByIdQuery, + DeviceRunSessionStatus, + JobRunStatus, +} from '../../graphql/generated'; import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery'; import Log, { link } from '../../log'; import { ora } from '../../ora'; @@ -15,6 +19,11 @@ import { SIMULATOR_DOTENV_FILE_NAME, loadSimulatorEnvAsync, } from '../../simulator/env'; +import { + SimulatorSessionError, + fetchSimulatorSessionErrorsAsync, + formatSimulatorSessionError, +} from '../../simulator/jobRunErrors'; import { deviceRunSessionTypeToFlagValue, formatRemoteSessionInstructions, @@ -87,6 +96,11 @@ export default class SimulatorGet extends EasCommand { session.app.slug, session.id ); + const errors: SimulatorSessionError[] = + session.status === DeviceRunSessionStatus.Errored || + session.turtleJobRun?.status === JobRunStatus.Errored + ? await fetchSimulatorSessionErrorsAsync(graphqlClient, session.id) + : []; if (jsonFlag) { printJsonOnlyOutput({ @@ -109,6 +123,7 @@ export default class SimulatorGet extends EasCommand { ) : session.remoteConfig, artifacts: session.artifacts, + errors, }); return; } @@ -116,6 +131,14 @@ export default class SimulatorGet extends EasCommand { Log.newLine(); Log.log(formatSessionFields(session, deviceRunSessionUrl)); + if (errors.length > 0) { + Log.newLine(); + Log.gray('Session errors:'); + for (const error of errors) { + Log.error(` ${formatSimulatorSessionError(error)}`); + } + } + if (session.status === DeviceRunSessionStatus.InProgress) { Log.newLine(); if (session.remoteConfig) { diff --git a/packages/eas-cli/src/commands/simulator/index.ts b/packages/eas-cli/src/commands/simulator/index.ts index 1352891181..9843eeb6cf 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, @@ -28,7 +27,7 @@ import { import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessionMutation'; import { DeviceRunSessionAvailabilityQuery } from '../../graphql/queries/DeviceRunSessionAvailabilityQuery'; import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery'; -import Log, { learnMore, link } from '../../log'; +import Log, { link } from '../../log'; import { Ora, ora } from '../../ora'; import { promptAsync } from '../../prompts'; import { parseEgressAllowList, runLocalEgressAsync } from '../../simulator/egress'; @@ -42,6 +41,10 @@ import { writeSimulatorEnvAsync, } from '../../simulator/env'; import { resolveExpoGoSdkVersionAsync } from '../../simulator/expoGo'; +import { + fetchSimulatorSessionErrorsAsync, + formatSimulatorSessionError, +} from '../../simulator/jobRunErrors'; import { simulatorRequestFailureReason, simulatorRequestProperties, @@ -620,52 +623,13 @@ 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, 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 []; - } + const errors = await fetchSimulatorSessionErrorsAsync(graphqlClient, deviceRunSessionId); + return errors.map(error => `\n${formatSimulatorSessionError(error)}`).join(''); } async function resolvePlatformAsync( diff --git a/packages/eas-cli/src/simulator/jobRunErrors.ts b/packages/eas-cli/src/simulator/jobRunErrors.ts new file mode 100644 index 0000000000..3abb7448ec --- /dev/null +++ b/packages/eas-cli/src/simulator/jobRunErrors.ts @@ -0,0 +1,61 @@ +import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; +import { parseLogLines } from '../commandUtils/logs/parseLogs'; +import { DeviceRunSessionQuery } from '../graphql/queries/DeviceRunSessionQuery'; +import Log, { learnMore } from '../log'; + +export type SimulatorSessionError = { + errorCode: string; + message: string; + docsUrl?: string | null; +}; + +// 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 why the session's job run failed, or an empty list when that is not available. +export async function fetchSimulatorSessionErrorsAsync( + 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) { + const errorCode = errors[0]?.errorCode ?? 'UNKNOWN_ERROR'; + return logErrors.map(message => ({ errorCode, message })); + } + } + return errors.map(({ errorCode, message, docsUrl }) => ({ errorCode, message, docsUrl })); +} + +export function formatSimulatorSessionError(error: SimulatorSessionError): string { + return `${error.message}${error.docsUrl ? ` ${learnMore(error.docsUrl)}` : ''}`; +} + +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 []; + } +} From 0ba2abc609a41e5c9ad8fd3b96e2d1fd713f65b5 Mon Sep 17 00:00:00 2001 From: Brent Vatne Date: Mon, 19 Oct 2026 19:17:25 -0700 Subject: [PATCH 4/9] [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 512896a3938b7921309effefe95ee1a9f4598018 Mon Sep 17 00:00:00 2001 From: Brent Vatne Date: Mon, 19 Oct 2026 19:17:50 -0700 Subject: [PATCH 5/9] [eas-cli] Make the simulator job run log fallback bounded, cancellable and mask-safe - Print only the masked log message of error-level lines, never err.message. - Prefer the failed step's lines, with its last stderr line that names a cause. - Read logs only when every job run error is an unknown error. - Cancel the errors query on Ctrl+C or after 10s by unsubscribing from urql. - Download through the proxy-aware fetch with a 20 MiB limit and its own 10s timeout, and always close the connection. --- .changeset/simulator-get-job-run-errors.md | 2 +- .../commands/simulator/__tests__/get.test.ts | 4 +- .../simulator/__tests__/index.test.ts | 206 ++++++++++--- .../eas-cli/src/commands/simulator/index.ts | 13 +- .../graphql/queries/DeviceRunSessionQuery.ts | 52 +++- .../__tests__/DeviceRunSessionQuery-test.ts | 104 +++++++ .../simulator/__tests__/jobRunErrors.test.ts | 284 ++++++++++++++++++ .../eas-cli/src/simulator/jobRunErrors.ts | 127 ++++++-- 8 files changed, 723 insertions(+), 69 deletions(-) create mode 100644 packages/eas-cli/src/graphql/queries/__tests__/DeviceRunSessionQuery-test.ts create mode 100644 packages/eas-cli/src/simulator/__tests__/jobRunErrors.test.ts diff --git a/.changeset/simulator-get-job-run-errors.md b/.changeset/simulator-get-job-run-errors.md index 6bb4860758..169c4ebd2b 100644 --- a/.changeset/simulator-get-job-run-errors.md +++ b/.changeset/simulator-get-job-run-errors.md @@ -2,4 +2,4 @@ 'eas-cli': patch --- -Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. +Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs. 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 de45096a7c..47f9dbf03f 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/get.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/get.test.ts @@ -191,7 +191,9 @@ describe(SimulatorGet, () => { const { command } = createCommand(['--id', 'session-123', '--json']); await command.runAsync(); - expect(mockJobRunErrorsByIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-123'); + expect(mockJobRunErrorsByIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-123', { + signal: expect.any(AbortSignal), + }); expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith( expect.objectContaining({ errors: [ 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..7d7c46d0ee 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts @@ -6,6 +6,7 @@ import { GraphQLError } from 'graphql'; import { SimulatorEvent, isAnalyticsOptedOutAsync } from '../../../analytics/AnalyticsManager'; import { getAgentTelemetryContext } from '../../../analytics/agent'; import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import fetch from '../../../fetch'; import { AppPlatform, CreateDeviceRunSessionMutation, @@ -44,6 +45,7 @@ jest.mock('../../../analytics/AnalyticsManager', () => ({ jest.mock('../../../analytics/agent'); jest.mock('../../../graphql/mutations/DeviceRunSessionMutation'); jest.mock('../../../graphql/queries/DeviceRunSessionAvailabilityQuery'); +jest.mock('../../../fetch'); jest.mock('../../../graphql/queries/DeviceRunSessionQuery'); jest.mock('../../../log', () => ({ __esModule: true, @@ -107,6 +109,7 @@ const mockEnsureDeviceRunSessionStoppedAsync = jest.mocked( const mockAvailabilityByAppIdAsync = jest.mocked(DeviceRunSessionAvailabilityQuery.byAppIdAsync); const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync); const mockJobRunErrorsByIdAsync = jest.mocked(DeviceRunSessionQuery.jobRunErrorsByIdAsync); +const mockFetch = jest.mocked(fetch); const mockLoadSimulatorEnvAsync = jest.mocked(loadSimulatorEnvAsync); const mockLogEvent = jest.fn(); const mockFlushAsync = jest.fn(async () => {}); @@ -1926,6 +1929,51 @@ describe(Simulator, () => { 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 logFileUrl = 'https://logs.example.com/job-123'; + const unknownError = { + errorCode: 'UNKNOWN_ERROR', + message: 'Unknown error. See logs for more information.', + docsUrl: null, + }; + + // Shaped like the worker's bunyan output: the failing call logs at error level with `err`, + // then the step runner logs an end-step marker with result "fail". + function failedStepLogLines( + stepId: string, + displayName: string, + messages: string[] + ): Record[] { + return [ + { + level: 30, + buildStepId: stepId, + buildStepDisplayName: displayName, + marker: 'start-step', + msg: `Executing build step "${displayName}"`, + }, + ...messages.map(msg => ({ + level: 50, + buildStepId: stepId, + buildStepDisplayName: displayName, + msg, + err: { message: msg }, + })), + { + level: 50, + buildStepId: stepId, + buildStepDisplayName: displayName, + marker: 'end-step', + result: 'fail', + msg: `Build step "${displayName}" failed`, + }, + ]; + } + + function mockLogFile(logLines: Record[]): void { + mockFetch.mockResolvedValue({ + text: async () => logLines.map(line => JSON.stringify(line)).join('\n'), + } as unknown as Awaited>); + } beforeEach(() => { mockByIdAsync.mockResolvedValue( @@ -1933,14 +1981,19 @@ describe(Simulator, () => { ); }); - it('keeps the message unchanged when the job run has no errors', async () => { - mockJobRunErrorsByIdAsync.mockResolvedValue({ id: 'job-123', errors: [], logFileUrls: [] }); + it('keeps the message unchanged without reading logs when the job run has no errors', async () => { + mockJobRunErrorsByIdAsync.mockResolvedValue({ + id: 'job-123', + errors: [], + logFileUrls: [logFileUrl], + }); const { command } = createCommand(['--platform', 'ios', '--non-interactive']); await expect(command.runAsync()).rejects.toThrow( expect.objectContaining({ message: erroredSessionMessage }) ); + expect(mockFetch).not.toHaveBeenCalled(); }); it('lists each job run error on its own line with its docs link', async () => { @@ -1958,9 +2011,8 @@ describe(Simulator, () => { docsUrl: 'https://docs.expo.dev/simulator', }, ], - logFileUrls: ['https://logs.example.com/job-123'], + logFileUrls: [logFileUrl], }); - const fetchSpy = jest.spyOn(global, 'fetch'); const { command } = createCommand(['--platform', 'ios', '--non-interactive']); @@ -1969,33 +2021,45 @@ 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(); + expect(mockFetch).not.toHaveBeenCalled(); }); - it('reads the failed step errors from the job run logs for an unknown error', async () => { + it('reads the failed step error 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'], + errors: [unknownError], + logFileUrls: [logFileUrl], }); - const logs = [ - { msg: 'Installing app', buildStepDisplayName: 'Install application' }, + mockLogFile([ { - 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' }, + level: 50, + buildStepId: 'step-001', + buildStepDisplayName: 'Install application', + msg: 'Retrying install', + err: { message: 'Retrying install' }, }, - { 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'))); + { + level: 30, + buildStepId: 'step-001', + marker: 'end-step', + result: 'success', + msg: 'Finished', + }, + ...failedStepLogLines('step-002', 'Launch application', [ + 'adb shell am start exited with non-zero code: 1', + ]), + { + level: 20, + buildStepId: 'step-002', + msg: 'Getting value for output parameter "x" failed.', + err: { message: 'not set' }, + }, + { + level: 40, + msg: 'Could not stop the emulator during remote session teardown.', + err: { message: 'teardown' }, + }, + ]); const { command } = createCommand(['--platform', 'ios', '--non-interactive']); @@ -2004,9 +2068,79 @@ describe(Simulator, () => { message: `${erroredSessionMessage}\nLaunch application: adb shell am start exited with non-zero code: 1`, }) ); + expect(mockFetch).toHaveBeenCalledWith( + logFileUrl, + expect.objectContaining({ size: 20 * 1024 * 1024, signal: expect.any(AbortSignal) }) + ); }); - it('reads the job run logs for an unknown custom build error', async () => { + it('prints the masked log message instead of the unmasked err.message', async () => { + mockJobRunErrorsByIdAsync.mockResolvedValue({ + id: 'job-123', + errors: [unknownError], + logFileUrls: [logFileUrl], + }); + mockLogFile([ + { + level: 50, + buildStepId: 'step-001', + buildStepDisplayName: 'Run script', + msg: 'curl -H "Authorization: ********" exited with non-zero code: 22', + err: { message: 'curl -H "Authorization: secret-token" exited with non-zero code: 22' }, + }, + { + level: 50, + buildStepId: 'step-001', + marker: 'end-step', + result: 'fail', + msg: 'Build step "Run script" failed', + }, + ]); + + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + + await expect(command.runAsync()).rejects.toThrow( + expect.objectContaining({ + message: `${erroredSessionMessage}\nRun script: curl -H "Authorization: ********" exited with non-zero code: 22`, + }) + ); + }); + + it('keeps the latest occurrence of repeated errors and at most three of them', async () => { + mockJobRunErrorsByIdAsync.mockResolvedValue({ + id: 'job-123', + errors: [unknownError], + logFileUrls: [logFileUrl], + }); + mockLogFile(failedStepLogLines('step-001', 'Boot', ['A', 'B', 'C', 'D', 'A'])); + + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + + await expect(command.runAsync()).rejects.toThrow( + expect.objectContaining({ + message: `${erroredSessionMessage}\nBoot: C\nBoot: D\nBoot: A`, + }) + ); + }); + + it('truncates very long log errors', async () => { + mockJobRunErrorsByIdAsync.mockResolvedValue({ + id: 'job-123', + errors: [unknownError], + logFileUrls: [logFileUrl], + }); + mockLogFile(failedStepLogLines('step-001', 'Boot', ['x'.repeat(2000)])); + + const { command } = createCommand(['--platform', 'ios', '--non-interactive']); + + await expect(command.runAsync()).rejects.toThrow( + expect.objectContaining({ + message: `${erroredSessionMessage}\n${`Boot: ${'x'.repeat(2000)}`.slice(0, 500)}…`, + }) + ); + }); + + it('uses error lines outside of steps for an unknown custom build error', async () => { mockJobRunErrorsByIdAsync.mockResolvedValue({ id: 'job-123', errors: [ @@ -2016,13 +2150,11 @@ describe(Simulator, () => { docsUrl: null, }, ], - logFileUrls: ['https://logs.example.com/job-123'], + logFileUrls: [logFileUrl], }); - jest - .spyOn(global, 'fetch') - .mockResolvedValue( - new Response(JSON.stringify({ msg: 'boom', err: { message: 'simctl boot failed' } })) - ); + mockLogFile([ + { level: 50, msg: 'simctl boot failed', err: { message: 'simctl boot failed' } }, + ]); const { command } = createCommand(['--platform', 'ios', '--non-interactive']); @@ -2034,16 +2166,10 @@ describe(Simulator, () => { 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'], + errors: [unknownError], + logFileUrls: [logFileUrl], }); - jest.spyOn(global, 'fetch').mockRejectedValue(new Error('network down')); + mockFetch.mockRejectedValue(new Error('Request failed: 503 (Service Unavailable)')); const { command } = createCommand(['--platform', 'ios', '--non-interactive']); diff --git a/packages/eas-cli/src/commands/simulator/index.ts b/packages/eas-cli/src/commands/simulator/index.ts index 9843eeb6cf..ec55e5415a 100644 --- a/packages/eas-cli/src/commands/simulator/index.ts +++ b/packages/eas-cli/src/commands/simulator/index.ts @@ -450,7 +450,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)}` ); } @@ -461,7 +461,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)}` ); } @@ -626,9 +626,12 @@ 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. async function formatJobRunErrorsAsync( graphqlClient: ExpoGraphqlClient, - deviceRunSessionId: string + deviceRunSessionId: string, + signal: AbortSignal ): Promise { - const errors = await fetchSimulatorSessionErrorsAsync(graphqlClient, deviceRunSessionId); + const errors = await fetchSimulatorSessionErrorsAsync(graphqlClient, deviceRunSessionId, { + signal, + }); return errors.map(error => `\n${formatSimulatorSessionError(error)}`).join(''); } @@ -714,7 +717,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 9d750bd00d..1009187b53 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,13 +45,15 @@ export const DeviceRunSessionQuery = { ); return data.deviceRunSessions.byId; }, + /** 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< + firstResultUntilAbortedAsync( + graphqlClient.query< DeviceRunSessionJobRunErrorsByIdQuery, DeviceRunSessionJobRunErrorsByIdQueryVariables >( @@ -74,8 +77,9 @@ export const DeviceRunSessionQuery = { `, { deviceRunSessionId }, { requestPolicy: 'network-only' } - ) - .toPromise() + ), + signal + ) ); return data.deviceRunSessions.byId.turtleJobRun; }, @@ -261,3 +265,41 @@ export const DeviceRunSessionQuery = { return data.app.byId.deviceRunSessionsPaginated; }, }; + +// Like `toPromise()`, but unsubscribing on abort makes urql cancel the request and any pending retry. +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..25fcefef42 --- /dev/null +++ b/packages/eas-cli/src/graphql/queries/__tests__/DeviceRunSessionQuery-test.ts @@ -0,0 +1,104 @@ +import http from 'http'; +import { AddressInfo } from 'net'; + +import { createGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { DeviceRunSessionQuery } from '../DeviceRunSessionQuery'; + +jest.mock('../../../api', () => ({ + getExpoApiBaseUrl: () => process.env.DEVICE_RUN_SESSION_QUERY_TEST_URL, +})); + +describe('DeviceRunSessionQuery.jobRunErrorsByIdAsync', () => { + let server: http.Server; + let closedRequests: number; + + async function startServerAsync(handler: http.RequestListener): Promise { + closedRequests = 0; + server = http.createServer((request, response) => { + 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}`; + } + + afterEach(async () => { + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + }); + + it('returns the job run errors', async () => { + const turtleJobRun = { + __typename: 'JobRun', + id: 'job-123', + logFileUrls: ['https://logs.example.com/job-123'], + errors: [ + { + __typename: 'JobRunError', + errorCode: 'UNKNOWN_ERROR', + message: 'Unknown error.', + docsUrl: null, + }, + ], + }; + await startServerAsync((_request, response) => { + response.setHeader('content-type', 'application/json'); + response.end( + JSON.stringify({ + 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 and cancels the request when the signal aborts', async () => { + await startServerAsync(() => {}); + const client = createGraphqlClient({ accessToken: 'token', sessionSecret: null }); + const controller = new AbortController(); + setTimeout(() => { + controller.abort(new Error('interrupted')); + }, 100); + + 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 () => { + const requests: string[] = []; + await startServerAsync(request => { + requests.push(request.url ?? ''); + }); + 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).toEqual([]); + }); +}); diff --git a/packages/eas-cli/src/simulator/__tests__/jobRunErrors.test.ts b/packages/eas-cli/src/simulator/__tests__/jobRunErrors.test.ts new file mode 100644 index 0000000000..df68491ea1 --- /dev/null +++ b/packages/eas-cli/src/simulator/__tests__/jobRunErrors.test.ts @@ -0,0 +1,284 @@ +import http from 'http'; +import { AddressInfo } from 'net'; + +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery'; +import type * as JobRunErrors from '../jobRunErrors'; + +jest.mock('../../graphql/queries/DeviceRunSessionQuery'); +jest.mock('../../log'); + +const mockJobRunErrorsByIdAsync = jest.mocked(DeviceRunSessionQuery.jobRunErrorsByIdAsync); +const graphqlClient = {} as ExpoGraphqlClient; +const unknownError = { + errorCode: 'UNKNOWN_ERROR', + message: 'Unknown error. See logs for more information.', + docsUrl: null, +}; + +// src/fetch.ts reads https_proxy when it loads, so load it only after clearing the proxy. +let jobRunErrors: typeof JobRunErrors; +beforeAll(() => { + delete process.env.https_proxy; + jest.isolateModules(() => { + jobRunErrors = jest.requireActual('../jobRunErrors'); + }); +}); + +// Lines 116-121 of a real failed Android session log, trimmed. The step's stderr has the precise +// cause at level 30; the level-50 error line only has the exit code. +const failedLaunchLog = [ + { + level: 30, + buildStepId: 'install_build', + marker: 'end-step', + result: 'success', + msg: 'Finished build step "Install build" successfully', + }, + { + level: 30, + buildStepId: 'install_build', + source: 'stderr', + msg: 'Error: Unable to find emulator skin, using default', + }, + { + level: 30, + buildStepId: 'step-002', + buildStepDisplayName: 'Launch application', + marker: 'start-step', + msg: 'Executing build step "Launch application"', + }, + { + level: 30, + buildStepId: 'step-002', + buildStepDisplayName: 'Launch application', + source: 'stdout', + msg: 'Starting: Intent { act=android.intent.action.MAIN pkg=world }', + }, + { + level: 30, + buildStepId: 'step-002', + buildStepDisplayName: 'Launch application', + source: 'stderr', + msg: 'Error: Activity not started, unable to resolve Intent { act=android.intent.action.MAIN pkg=world }', + }, + { + level: 50, + buildStepId: 'step-002', + buildStepDisplayName: 'Launch application', + msg: 'adb shell am start -n dev.brent.euxy/.MainActivity exited with non-zero code: 1', + err: { + message: 'adb shell am start -n dev.brent.euxy/.MainActivity exited with non-zero code: 1', + }, + }, + { + level: 50, + buildStepId: 'step-002', + buildStepDisplayName: 'Launch application', + marker: 'end-step', + result: 'fail', + msg: 'Build step "Launch application" failed', + }, +]; + +describe('selectFailedStepErrors', () => { + it("shows the failed step's stderr cause before its error line", () => { + expect(jobRunErrors.selectFailedStepErrors(failedLaunchLog)).toEqual([ + 'Launch application: Error: Activity not started, unable to resolve Intent { act=android.intent.action.MAIN pkg=world }', + 'Launch application: adb shell am start -n dev.brent.euxy/.MainActivity exited with non-zero code: 1', + ]); + }); + + it('ignores stderr from steps that did not fail', () => { + const messages = jobRunErrors.selectFailedStepErrors(failedLaunchLog); + expect(messages.join('\n')).not.toContain('emulator skin'); + }); + + it('never prints the unmasked err.message of a phase failure with an empty message', () => { + const phaseFailureLog = [ + { level: 30, phase: 'PREPARE_PROJECT', msg: 'Start phase: PREPARE_PROJECT' }, + { + level: 50, + phase: 'PREPARE_PROJECT', + msg: '', + err: { + message: 'git clone https://secret-token@github.com/acme/app failed', + stack: 'Error: git clone https://secret-token@github.com/acme/app failed', + }, + }, + ]; + expect(jobRunErrors.selectFailedStepErrors(phaseFailureLog)).toEqual([]); + }); + + it('prints the masked message of an error logged outside a step', () => { + expect( + jobRunErrors.selectFailedStepErrors([ + { + level: 50, + msg: 'simctl boot failed for ********', + err: { message: 'simctl boot failed for secret-udid' }, + }, + ]) + ).toEqual(['simctl boot failed for ********']); + }); +}); + +describe('fetchSimulatorSessionErrorsAsync', () => { + let server: http.Server; + let closedRequests: number; + + async function startServerAsync(handler: http.RequestListener): Promise { + closedRequests = 0; + server = http.createServer((request, response) => { + 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; + return `http://127.0.0.1:${port}/logs`; + } + + beforeEach(() => { + jest.clearAllMocks(); + }); + + async function waitForCloseAsync(): Promise { + for (let attempt = 0; attempt < 50 && closedRequests === 0; attempt++) { + await new Promise(resolve => setTimeout(resolve, 20)); + } + } + + afterEach(async () => { + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + }); + + function mockJobRun(logFileUrl: string): void { + mockJobRunErrorsByIdAsync.mockResolvedValue({ + id: 'job-123', + errors: [unknownError], + logFileUrls: [logFileUrl], + }); + } + + it('returns the failed step errors from the downloaded log', async () => { + mockJobRun( + await startServerAsync((_request, response) => { + response.end(failedLaunchLog.map(line => JSON.stringify(line)).join('\n')); + }) + ); + + await expect( + jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123') + ).resolves.toEqual([ + expect.objectContaining({ message: expect.stringContaining('Activity not started') }), + expect.objectContaining({ message: expect.stringContaining('exited with non-zero code: 1') }), + ]); + }); + + it('falls back to the job run error when the log request fails', async () => { + mockJobRun( + await startServerAsync((_request, response) => { + response.statusCode = 404; + response.end(JSON.stringify({ level: 50, msg: 'not the cause', err: { message: 'x' } })); + }) + ); + + await expect( + jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123') + ).resolves.toEqual([unknownError]); + }); + + it('falls back and closes the connection when the log is too large', async () => { + mockJobRun( + await startServerAsync((_request, response) => { + const chunk = 'x'.repeat(64 * 1024); + const timer = setInterval(() => response.write(chunk), 5); + response.on('close', () => { + clearInterval(timer); + }); + }) + ); + + await expect( + jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123', { + maxLogSizeBytes: 256 * 1024, + }) + ).resolves.toEqual([unknownError]); + await waitForCloseAsync(); + expect(closedRequests).toBe(1); + }); + + it('falls back and closes the connection when the log download stalls', async () => { + mockJobRun( + await startServerAsync((_request, response) => { + response.write('{"level":30,'); + }) + ); + + const startedAt = Date.now(); + await expect( + jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123', { + logDownloadTimeoutMs: 200, + }) + ).resolves.toEqual([unknownError]); + expect(Date.now() - startedAt).toBeLessThan(2_000); + await waitForCloseAsync(); + expect(closedRequests).toBe(1); + }); + + it('stops the log download when the signal aborts', async () => { + mockJobRun( + await startServerAsync((_request, response) => { + response.write('{"level":30,'); + }) + ); + const controller = new AbortController(); + setTimeout(() => { + controller.abort(); + }, 100); + + await expect( + jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123', { + signal: controller.signal, + }) + ).resolves.toEqual([unknownError]); + await waitForCloseAsync(); + expect(closedRequests).toBe(1); + }); + + it('does not look up errors when the signal has already aborted', async () => { + server = http.createServer(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const controller = new AbortController(); + controller.abort(); + + await expect( + jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123', { + signal: controller.signal, + }) + ).resolves.toEqual([]); + expect(mockJobRunErrorsByIdAsync).not.toHaveBeenCalled(); + }); + + it('passes an abortable signal with a deadline to the errors query', async () => { + server = http.createServer(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + mockJobRunErrorsByIdAsync.mockImplementation( + async (_client, _id, { signal }) => + await new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { + reject(signal.reason); + }); + }) + ); + + await expect( + jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123', { + queryTimeoutMs: 100, + }) + ).resolves.toEqual([]); + }); +}); diff --git a/packages/eas-cli/src/simulator/jobRunErrors.ts b/packages/eas-cli/src/simulator/jobRunErrors.ts index 3abb7448ec..cbabc79256 100644 --- a/packages/eas-cli/src/simulator/jobRunErrors.ts +++ b/packages/eas-cli/src/simulator/jobRunErrors.ts @@ -1,5 +1,7 @@ import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; import { parseLogLines } from '../commandUtils/logs/parseLogs'; +import { RawLogLine } from '../commandUtils/logs/types'; +import fetch from '../fetch'; import { DeviceRunSessionQuery } from '../graphql/queries/DeviceRunSessionQuery'; import Log, { learnMore } from '../log'; @@ -9,18 +11,46 @@ export type SimulatorSessionError = { docsUrl?: string | null; }; +export type FetchSimulatorSessionErrorsOptions = { + /** Aborts the lookup, e.g. on Ctrl+C. */ + signal?: AbortSignal; + queryTimeoutMs?: number; + logDownloadTimeoutMs?: number; + maxLogSizeBytes?: number; +}; + // 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; +const DEFAULT_QUERY_TIMEOUT_MS = 10_000; +const DEFAULT_LOG_DOWNLOAD_TIMEOUT_MS = 10_000; +const DEFAULT_MAX_LOG_SIZE_BYTES = 20 * 1024 * 1024; +const MAX_LOGGED_ERRORS = 3; +const MAX_LOGGED_ERROR_LENGTH = 500; +// Bunyan's "error" level. +const ERROR_LOG_LEVEL = 50; +const STDERR_CAUSE_PATTERN = /\b(error|exception|fatal)\b/i; + +type JobRunLogLine = RawLogLine & { level?: number; source?: string }; // Returns why the session's job run failed, or an empty list when that is not available. export async function fetchSimulatorSessionErrorsAsync( graphqlClient: ExpoGraphqlClient, - deviceRunSessionId: string + deviceRunSessionId: string, + { + signal, + queryTimeoutMs = DEFAULT_QUERY_TIMEOUT_MS, + logDownloadTimeoutMs = DEFAULT_LOG_DOWNLOAD_TIMEOUT_MS, + maxLogSizeBytes = DEFAULT_MAX_LOG_SIZE_BYTES, + }: FetchSimulatorSessionErrorsOptions = {} ): Promise { + if (signal?.aborted) { + return []; + } let jobRun; try { - jobRun = await DeviceRunSessionQuery.jobRunErrorsByIdAsync(graphqlClient, deviceRunSessionId); + jobRun = await DeviceRunSessionQuery.jobRunErrorsByIdAsync(graphqlClient, deviceRunSessionId, { + signal: withTimeout(signal, queryTimeoutMs), + }); } catch (err) { Log.debug(`Failed to fetch simulator session errors: ${err}`); return []; @@ -28,11 +58,17 @@ export async function fetchSimulatorSessionErrorsAsync( 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 ( + logFileUrl && + errors.length > 0 && + errors.every(error => UNKNOWN_ERROR_CODES.has(error.errorCode)) + ) { + const logErrors = await fetchJobRunLogErrorsAsync(logFileUrl, { + signal: withTimeout(signal, logDownloadTimeoutMs), + maxLogSizeBytes, + }); if (logErrors.length > 0) { - const errorCode = errors[0]?.errorCode ?? 'UNKNOWN_ERROR'; - return logErrors.map(message => ({ errorCode, message })); + return logErrors.map(message => ({ errorCode: errors[0].errorCode, message })); } } return errors.map(({ errorCode, message, docsUrl }) => ({ errorCode, message, docsUrl })); @@ -42,20 +78,77 @@ export function formatSimulatorSessionError(error: SimulatorSessionError): strin return `${error.message}${error.docsUrl ? ` ${learnMore(error.docsUrl)}` : ''}`; } -async function fetchJobRunLogErrorsAsync(logFileUrl: string): Promise { +function withTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal { + const timeoutSignal = AbortSignal.timeout(timeoutMs); + return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; +} + +async function fetchJobRunLogErrorsAsync( + logFileUrl: string, + { signal, maxLogSizeBytes }: { signal: AbortSignal; maxLogSizeBytes: number } +): Promise { + // node-fetch stops reading on its own only when the body ends, so abort to close the connection + // after an error, an oversized body or a timeout. + const controller = new AbortController(); try { - const response = await fetch(logFileUrl); + const response = await fetch(logFileUrl, { + size: maxLogSizeBytes, + signal: AbortSignal.any([signal, controller.signal]), + }); 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); + return selectFailedStepErrors(logLines); } catch (err) { Log.debug(`Failed to read simulator session logs: ${err}`); return []; + } finally { + controller.abort(); + } +} + +// Picks the error lines of the steps that failed, each preceded by the step's last stderr line that +// looks like a cause, falling back to any error line. Uses the log message rather than err.message +// because only the message has secrets masked. +export function selectFailedStepErrors(logLines: JobRunLogLine[]): string[] { + const isErrorLine = (line: JobRunLogLine): boolean => + !!line.err && (line.level ?? 0) >= ERROR_LOG_LEVEL && !!line.msg?.trim(); + const failedStepIds = new Set( + logLines + .filter(line => line.marker === 'end-step' && line.result === 'fail' && line.buildStepId) + .map(line => line.buildStepId) + ); + + const failedStepEntries: JobRunLogLine[] = []; + const lastStderrCauseByStepId = new Map(); + for (const line of logLines) { + if (!line.buildStepId || !failedStepIds.has(line.buildStepId)) { + continue; + } + if (line.source === 'stderr' && STDERR_CAUSE_PATTERN.test(line.msg ?? '')) { + lastStderrCauseByStepId.set(line.buildStepId, line); + } else if (isErrorLine(line)) { + const stderrCause = lastStderrCauseByStepId.get(line.buildStepId); + if (stderrCause) { + failedStepEntries.push(stderrCause); + lastStderrCauseByStepId.delete(line.buildStepId); + } + failedStepEntries.push(line); + } } + const selectedLines = failedStepEntries.some(isErrorLine) + ? failedStepEntries + : logLines.filter(isErrorLine); + + // Keep the latest occurrence of each message, since the last failure is usually the cause. + const messages = selectedLines.map(formatLogLine); + const latestFirst = [...new Set([...messages].reverse())]; + return latestFirst.slice(0, MAX_LOGGED_ERRORS).reverse(); +} + +function formatLogLine(line: JobRunLogLine): string { + const message = line.buildStepDisplayName + ? `${line.buildStepDisplayName}: ${line.msg}` + : String(line.msg); + return message.length > MAX_LOGGED_ERROR_LENGTH + ? `${message.slice(0, MAX_LOGGED_ERROR_LENGTH)}…` + : message; } From 894a8254a13a323949b207311af927fc8833129f Mon Sep 17 00:00:00 2001 From: Brent Vatne Date: Mon, 19 Oct 2026 19:18:06 -0700 Subject: [PATCH 6/9] Keep the simulator:start changeset from the base branch --- .changeset/simulator-start-job-run-errors.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From a4f6ea878b387dd3de5c00028151214517daaf64 Mon Sep 17 00:00:00 2001 From: Brent Vatne Date: Mon, 19 Oct 2026 20:03:36 -0700 Subject: [PATCH 7/9] [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); + }); +}); From 61d700f7bc836c7eac3363fd7b7cdff1bf8017f6 Mon Sep 17 00:00:00 2001 From: Brent Vatne Date: Mon, 19 Oct 2026 20:03:47 -0700 Subject: [PATCH 8/9] [eas-cli] Show the failed step's cause, and nothing from steps that didn't fail - Drop the whole-log fallback, which could show an error a successful step ignored instead of the job run's own message. - List the first failed step first, with its stderr cause and the line after it; later failed steps only add their error line. - Make the errors lookup quiet and not retried, like the base branch. - Keep the signed log URL out of debug output, and cap job run messages. --- .../simulator/__tests__/index.test.ts | 36 ++++-- .../graphql/queries/DeviceRunSessionQuery.ts | 60 +++++---- .../__tests__/DeviceRunSessionQuery-test.ts | 80 ++++++++---- .../simulator/__tests__/jobRunErrors.test.ts | 118 +++++++++++++++++- .../eas-cli/src/simulator/jobRunErrors.ts | 85 +++++++------ 5 files changed, 281 insertions(+), 98 deletions(-) diff --git a/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts index 7d7c46d0ee..7ae6d7d35c 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/index.test.ts @@ -2106,19 +2106,22 @@ describe(Simulator, () => { ); }); - it('keeps the latest occurrence of repeated errors and at most three of them', async () => { + it('shows the first failed step, then only the error line of later failed steps', async () => { mockJobRunErrorsByIdAsync.mockResolvedValue({ id: 'job-123', errors: [unknownError], logFileUrls: [logFileUrl], }); - mockLogFile(failedStepLogLines('step-001', 'Boot', ['A', 'B', 'C', 'D', 'A'])); + mockLogFile([ + ...failedStepLogLines('step-001', 'Boot', ['A', 'B']), + ...failedStepLogLines('step-002', 'Finish recordings', ['C', 'D']), + ]); const { command } = createCommand(['--platform', 'ios', '--non-interactive']); await expect(command.runAsync()).rejects.toThrow( expect.objectContaining({ - message: `${erroredSessionMessage}\nBoot: C\nBoot: D\nBoot: A`, + message: `${erroredSessionMessage}\nBoot: B\nFinish recordings: D`, }) ); }); @@ -2140,26 +2143,43 @@ describe(Simulator, () => { ); }); - it('uses error lines outside of steps for an unknown custom build error', async () => { + it('keeps the job run error when no step failed, ignoring errors that steps ignored', async () => { mockJobRunErrorsByIdAsync.mockResolvedValue({ id: 'job-123', errors: [ { - errorCode: 'UNKNOWN_CUSTOM_BUILD_ERROR', - message: 'Unknown custom build error. See logs for more information.', + errorCode: 'UNKNOWN_ERROR', + message: + 'Unknown error. See logs of the Complete job build phase for more information.', docsUrl: null, }, ], logFileUrls: [logFileUrl], }); mockLogFile([ - { level: 50, msg: 'simctl boot failed', err: { message: 'simctl boot failed' } }, + { + level: 50, + buildStepId: 'step-001', + buildStepDisplayName: 'Upload artifact', + msg: 'Failed to upload screen-recording. Ignoring error.', + err: { message: 'upload failed' }, + }, + { + level: 30, + buildStepId: 'step-001', + marker: 'end-step', + result: 'success', + msg: 'Finished', + }, + { level: 50, phase: 'COMPLETE_JOB', msg: '', err: { message: 'upload outputs failed' } }, ]); const { command } = createCommand(['--platform', 'ios', '--non-interactive']); await expect(command.runAsync()).rejects.toThrow( - expect.objectContaining({ message: `${erroredSessionMessage}\nsimctl boot failed` }) + expect.objectContaining({ + message: `${erroredSessionMessage}\nUnknown error. See logs of the Complete job build phase for more information.`, + }) ); }); diff --git a/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts b/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts index 1009187b53..43f8e9d3da 100644 --- a/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts +++ b/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts @@ -45,42 +45,50 @@ export const DeviceRunSessionQuery = { ); return data.deviceRunSessions.byId; }, - /** Rejects with `signal.reason` and cancels the request when `signal` aborts. */ + /** + * 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, { signal }: { signal: AbortSignal } ): Promise { - const data = await withErrorHandlingAsync( - firstResultUntilAbortedAsync( - 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 - } - logFileUrls + errors { + errorCode + message + docsUrl } + logFileUrls } } } - `, - { deviceRunSessionId }, - { requestPolicy: 'network-only' } - ), - signal - ) + } + `, + { 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( @@ -266,7 +274,7 @@ export const DeviceRunSessionQuery = { }, }; -// Like `toPromise()`, but unsubscribing on abort makes urql cancel the request and any pending retry. +// Like `toPromise()`, but unsubscribing on abort makes urql cancel the request. async function firstResultUntilAbortedAsync( source: OperationResultSource, signal: AbortSignal diff --git a/packages/eas-cli/src/graphql/queries/__tests__/DeviceRunSessionQuery-test.ts b/packages/eas-cli/src/graphql/queries/__tests__/DeviceRunSessionQuery-test.ts index 25fcefef42..3debcbd29b 100644 --- a/packages/eas-cli/src/graphql/queries/__tests__/DeviceRunSessionQuery-test.ts +++ b/packages/eas-cli/src/graphql/queries/__tests__/DeviceRunSessionQuery-test.ts @@ -2,6 +2,7 @@ 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', () => ({ @@ -10,11 +11,14 @@ jest.mock('../../../api', () => ({ 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; }); @@ -25,7 +29,15 @@ describe('DeviceRunSessionQuery.jobRunErrorsByIdAsync', () => { 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)); }); @@ -44,19 +56,16 @@ describe('DeviceRunSessionQuery.jobRunErrorsByIdAsync', () => { }, ], }; - await startServerAsync((_request, response) => { - response.setHeader('content-type', 'application/json'); - response.end( - JSON.stringify({ - data: { - deviceRunSessions: { - __typename: 'DeviceRunSessionQuery', - byId: { __typename: 'DeviceRunSession', id: 'session-123', turtleJobRun }, - }, + await startServerAsync( + respondWithJson({ + data: { + deviceRunSessions: { + __typename: 'DeviceRunSessionQuery', + byId: { __typename: 'DeviceRunSession', id: 'session-123', turtleJobRun }, }, - }) - ); - }); + }, + }) + ); const client = createGraphqlClient({ accessToken: 'token', sessionSecret: null }); await expect( @@ -66,13 +75,45 @@ describe('DeviceRunSessionQuery.jobRunErrorsByIdAsync', () => { ).resolves.toEqual(turtleJobRun); }); - it('rejects and cancels the request when the signal aborts', async () => { - await startServerAsync(() => {}); + 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(); - setTimeout(() => { + await startServerAsync(() => { controller.abort(new Error('interrupted')); - }, 100); + }); + const client = createGraphqlClient({ accessToken: 'token', sessionSecret: null }); await expect( DeviceRunSessionQuery.jobRunErrorsByIdAsync(client, 'session-123', { @@ -86,10 +127,7 @@ describe('DeviceRunSessionQuery.jobRunErrorsByIdAsync', () => { }); it('rejects without sending a request when the signal has already aborted', async () => { - const requests: string[] = []; - await startServerAsync(request => { - requests.push(request.url ?? ''); - }); + await startServerAsync(() => {}); const client = createGraphqlClient({ accessToken: 'token', sessionSecret: null }); const controller = new AbortController(); controller.abort(new Error('interrupted')); @@ -99,6 +137,6 @@ describe('DeviceRunSessionQuery.jobRunErrorsByIdAsync', () => { signal: controller.signal, }) ).rejects.toThrow('interrupted'); - expect(requests).toEqual([]); + expect(requests).toBe(0); }); }); diff --git a/packages/eas-cli/src/simulator/__tests__/jobRunErrors.test.ts b/packages/eas-cli/src/simulator/__tests__/jobRunErrors.test.ts index df68491ea1..213a4327b6 100644 --- a/packages/eas-cli/src/simulator/__tests__/jobRunErrors.test.ts +++ b/packages/eas-cli/src/simulator/__tests__/jobRunErrors.test.ts @@ -3,6 +3,7 @@ import { AddressInfo } from 'net'; import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery'; +import type LogType from '../../log'; import type * as JobRunErrors from '../jobRunErrors'; jest.mock('../../graphql/queries/DeviceRunSessionQuery'); @@ -18,10 +19,12 @@ const unknownError = { // src/fetch.ts reads https_proxy when it loads, so load it only after clearing the proxy. let jobRunErrors: typeof JobRunErrors; +let Log: typeof LogType; beforeAll(() => { delete process.env.https_proxy; jest.isolateModules(() => { jobRunErrors = jest.requireActual('../jobRunErrors'); + Log = jest.requireMock('../../log').default; }); }); @@ -110,16 +113,102 @@ describe('selectFailedStepErrors', () => { expect(jobRunErrors.selectFailedStepErrors(phaseFailureLog)).toEqual([]); }); - it('prints the masked message of an error logged outside a step', () => { + it('ignores errors outside of failed steps, such as errors a step ignored', () => { expect( jobRunErrors.selectFailedStepErrors([ + { + level: 50, + buildStepId: 'step-001', + buildStepDisplayName: 'Upload artifact', + msg: 'Failed to upload screen-recording. Ignoring error.', + err: { message: 'upload failed' }, + }, + { + level: 30, + buildStepId: 'step-001', + marker: 'end-step', + result: 'success', + msg: 'Finished', + }, { level: 50, msg: 'simctl boot failed for ********', err: { message: 'simctl boot failed for secret-udid' }, }, ]) - ).toEqual(['simctl boot failed for ********']); + ).toEqual([]); + }); + + it('keeps the stderr line that continues the cause', () => { + expect( + jobRunErrors.selectFailedStepErrors([ + { + level: 30, + buildStepId: 'step-001', + buildStepDisplayName: 'Boot simulator', + source: 'stderr', + msg: 'An error was encountered processing the command (domain=com.apple.CoreSimulator.SimError, code=405):', + }, + { + level: 30, + buildStepId: 'step-001', + buildStepDisplayName: 'Boot simulator', + source: 'stderr', + msg: 'Invalid device: iPhone 16 Pro', + }, + { + level: 50, + buildStepId: 'step-001', + buildStepDisplayName: 'Boot simulator', + msg: 'xcrun simctl boot exited with non-zero code: 149', + err: { message: 'xcrun simctl boot exited with non-zero code: 149' }, + }, + { + level: 50, + buildStepId: 'step-001', + marker: 'end-step', + result: 'fail', + msg: 'Build step "Boot simulator" failed', + }, + ]) + ).toEqual([ + 'Boot simulator: An error was encountered processing the command (domain=com.apple.CoreSimulator.SimError, code=405):', + 'Boot simulator: Invalid device: iPhone 16 Pro', + 'Boot simulator: xcrun simctl boot exited with non-zero code: 149', + ]); + }); + + it('keeps the cause of the first failed step when later steps also fail', () => { + expect( + jobRunErrors.selectFailedStepErrors([ + ...failedLaunchLog, + { + level: 30, + buildStepId: 'step-003', + buildStepDisplayName: 'Finish recordings', + source: 'stderr', + msg: 'Error: No devices are booted.', + }, + { + level: 50, + buildStepId: 'step-003', + buildStepDisplayName: 'Finish recordings', + msg: 'xcrun simctl io recordVideo exited with non-zero code: 1', + err: { message: 'x' }, + }, + { + level: 50, + buildStepId: 'step-003', + marker: 'end-step', + result: 'fail', + msg: 'Build step "Finish recordings" failed', + }, + ]) + ).toEqual([ + 'Launch application: Error: Activity not started, unable to resolve Intent { act=android.intent.action.MAIN pkg=world }', + 'Launch application: adb shell am start -n dev.brent.euxy/.MainActivity exited with non-zero code: 1', + 'Finish recordings: xcrun simctl io recordVideo exited with non-zero code: 1', + ]); }); }); @@ -173,11 +262,32 @@ describe('fetchSimulatorSessionErrorsAsync', () => { await expect( jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123') ).resolves.toEqual([ - expect.objectContaining({ message: expect.stringContaining('Activity not started') }), - expect.objectContaining({ message: expect.stringContaining('exited with non-zero code: 1') }), + { + errorCode: 'UNKNOWN_ERROR', + message: expect.stringContaining('Activity not started'), + docsUrl: null, + }, + { + errorCode: 'UNKNOWN_ERROR', + message: expect.stringContaining('exited with non-zero code: 1'), + docsUrl: null, + }, ]); }); + it('does not log the signed log URL when the download fails', async () => { + const logFileUrl = await startServerAsync((request, response) => { + request.socket.destroy(); + response.end(); + }); + mockJobRun(`${logFileUrl}?X-Goog-Signature=secret-signature`); + + await expect( + jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123') + ).resolves.toEqual([unknownError]); + expect(JSON.stringify(jest.mocked(Log.debug).mock.calls)).not.toContain('secret-signature'); + }); + it('falls back to the job run error when the log request fails', async () => { mockJobRun( await startServerAsync((_request, response) => { diff --git a/packages/eas-cli/src/simulator/jobRunErrors.ts b/packages/eas-cli/src/simulator/jobRunErrors.ts index cbabc79256..2050cb410a 100644 --- a/packages/eas-cli/src/simulator/jobRunErrors.ts +++ b/packages/eas-cli/src/simulator/jobRunErrors.ts @@ -24,7 +24,7 @@ const UNKNOWN_ERROR_CODES = new Set(['UNKNOWN_ERROR', 'UNKNOWN_CUSTOM_BUILD_ERRO const DEFAULT_QUERY_TIMEOUT_MS = 10_000; const DEFAULT_LOG_DOWNLOAD_TIMEOUT_MS = 10_000; const DEFAULT_MAX_LOG_SIZE_BYTES = 20 * 1024 * 1024; -const MAX_LOGGED_ERRORS = 3; +const MAX_LOGGED_ERRORS = 4; const MAX_LOGGED_ERROR_LENGTH = 500; // Bunyan's "error" level. const ERROR_LOG_LEVEL = 50; @@ -68,10 +68,14 @@ export async function fetchSimulatorSessionErrorsAsync( maxLogSizeBytes, }); if (logErrors.length > 0) { - return logErrors.map(message => ({ errorCode: errors[0].errorCode, message })); + return logErrors.map(message => ({ errorCode: errors[0].errorCode, message, docsUrl: null })); } } - return errors.map(({ errorCode, message, docsUrl }) => ({ errorCode, message, docsUrl })); + return errors.map(({ errorCode, message, docsUrl }) => ({ + errorCode, + message: truncate(message), + docsUrl, + })); } export function formatSimulatorSessionError(error: SimulatorSessionError): string { @@ -98,56 +102,59 @@ async function fetchJobRunLogErrorsAsync( const { logLines } = parseLogLines(await response.text()); return selectFailedStepErrors(logLines); } catch (err) { - Log.debug(`Failed to read simulator session logs: ${err}`); + // node-fetch's error messages include the signed log URL, so leave the message out. + Log.debug( + `Failed to read simulator session logs: ${err instanceof Error ? err.name : 'unknown error'}` + ); return []; } finally { controller.abort(); } } -// Picks the error lines of the steps that failed, each preceded by the step's last stderr line that -// looks like a cause, falling back to any error line. Uses the log message rather than err.message -// because only the message has secrets masked. +// Describes the steps that failed, the first one first: its last error line, preceded by the stderr +// line that names the cause and the line after it. Later failed steps only add their error line. +// Uses the log message rather than err.message because only the message has secrets masked. export function selectFailedStepErrors(logLines: JobRunLogLine[]): string[] { - const isErrorLine = (line: JobRunLogLine): boolean => - !!line.err && (line.level ?? 0) >= ERROR_LOG_LEVEL && !!line.msg?.trim(); - const failedStepIds = new Set( - logLines - .filter(line => line.marker === 'end-step' && line.result === 'fail' && line.buildStepId) - .map(line => line.buildStepId) - ); + const failedStepIds = [ + ...new Set( + logLines + .filter(line => line.marker === 'end-step' && line.result === 'fail' && line.buildStepId) + .map(line => line.buildStepId as string) + ), + ]; + const messages = failedStepIds.flatMap((stepId, index) => { + const lines = selectStepFailureLines(logLines.filter(line => line.buildStepId === stepId)); + return (index === 0 ? lines : lines.slice(-1)).map(formatLogLine); + }); + return [...new Set(messages)].slice(0, MAX_LOGGED_ERRORS); +} - const failedStepEntries: JobRunLogLine[] = []; - const lastStderrCauseByStepId = new Map(); - for (const line of logLines) { - if (!line.buildStepId || !failedStepIds.has(line.buildStepId)) { - continue; - } - if (line.source === 'stderr' && STDERR_CAUSE_PATTERN.test(line.msg ?? '')) { - lastStderrCauseByStepId.set(line.buildStepId, line); - } else if (isErrorLine(line)) { - const stderrCause = lastStderrCauseByStepId.get(line.buildStepId); - if (stderrCause) { - failedStepEntries.push(stderrCause); - lastStderrCauseByStepId.delete(line.buildStepId); +function selectStepFailureLines(stepLines: JobRunLogLine[]): JobRunLogLine[] { + let selected: JobRunLogLine[] = []; + let stderrCause: JobRunLogLine[] = []; + for (const [index, line] of stepLines.entries()) { + if (line.source === 'stderr') { + if (STDERR_CAUSE_PATTERN.test(line.msg ?? '')) { + stderrCause = [line]; + } else if (stderrCause.length === 1 && stepLines[index - 1] === stderrCause[0]) { + stderrCause.push(line); } - failedStepEntries.push(line); + } else if (line.err && (line.level ?? 0) >= ERROR_LOG_LEVEL && line.msg?.trim()) { + selected = [...stderrCause, line]; + stderrCause = []; } } - const selectedLines = failedStepEntries.some(isErrorLine) - ? failedStepEntries - : logLines.filter(isErrorLine); - - // Keep the latest occurrence of each message, since the last failure is usually the cause. - const messages = selectedLines.map(formatLogLine); - const latestFirst = [...new Set([...messages].reverse())]; - return latestFirst.slice(0, MAX_LOGGED_ERRORS).reverse(); + return selected; } function formatLogLine(line: JobRunLogLine): string { - const message = line.buildStepDisplayName - ? `${line.buildStepDisplayName}: ${line.msg}` - : String(line.msg); + return truncate( + line.buildStepDisplayName ? `${line.buildStepDisplayName}: ${line.msg}` : String(line.msg) + ); +} + +function truncate(message: string): string { return message.length > MAX_LOGGED_ERROR_LENGTH ? `${message.slice(0, MAX_LOGGED_ERROR_LENGTH)}…` : message; From 669c0eb9c8184f1ba23c562ce14e55f69cd37fd7 Mon Sep 17 00:00:00 2001 From: Brent Vatne Date: Tue, 20 Oct 2026 13:47:50 -0700 Subject: [PATCH 9/9] [eas-cli] Strip terminal control characters from simulator session errors Job run error messages and log lines from the session are printed in the terminal, so remove escape sequences and control characters before printing them. Match the stderr cause and skip empty error lines on the stripped text, so a colored "Error:" line is still found. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../simulator/__tests__/jobRunErrors.test.ts | 85 +++++++++++++++++++ .../eas-cli/src/simulator/jobRunErrors.ts | 27 ++++-- 2 files changed, 104 insertions(+), 8 deletions(-) diff --git a/packages/eas-cli/src/simulator/__tests__/jobRunErrors.test.ts b/packages/eas-cli/src/simulator/__tests__/jobRunErrors.test.ts index 213a4327b6..4ca683db6a 100644 --- a/packages/eas-cli/src/simulator/__tests__/jobRunErrors.test.ts +++ b/packages/eas-cli/src/simulator/__tests__/jobRunErrors.test.ts @@ -210,6 +210,65 @@ describe('selectFailedStepErrors', () => { 'Finish recordings: xcrun simctl io recordVideo exited with non-zero code: 1', ]); }); + + it('strips escape sequences and control characters from step names and messages', () => { + expect( + jobRunErrors.selectFailedStepErrors([ + { + level: 50, + buildStepId: 'step-001', + buildStepDisplayName: 'Boot\u001b[2J simulator', + msg: '\u001b[31mxcrun simctl boot\u001b[0m exited\r\nwith code: 149\u0007', + err: { message: 'x' }, + }, + { + level: 50, + buildStepId: 'step-001', + marker: 'end-step', + result: 'fail', + msg: 'Build step "Boot simulator" failed', + }, + ]) + ).toEqual(['Boot simulator: xcrun simctl boot exited with code: 149']); + }); + + it('finds a colored stderr cause and skips error lines that are only escape sequences', () => { + expect( + jobRunErrors.selectFailedStepErrors([ + { + level: 30, + buildStepId: 'step-001', + buildStepDisplayName: 'Launch application', + source: 'stderr', + msg: '\u001b[31mError\u001b[0m: Activity not started', + }, + { + level: 50, + buildStepId: 'step-001', + buildStepDisplayName: 'Launch application', + msg: 'adb shell am start exited with non-zero code: 1', + err: { message: 'x' }, + }, + { + level: 50, + buildStepId: 'step-001', + buildStepDisplayName: 'Launch application', + msg: '\u001b[0m\u001b[K', + err: { message: 'x' }, + }, + { + level: 50, + buildStepId: 'step-001', + marker: 'end-step', + result: 'fail', + msg: 'Build step "Launch application" failed', + }, + ]) + ).toEqual([ + 'Launch application: Error: Activity not started', + 'Launch application: adb shell am start exited with non-zero code: 1', + ]); + }); }); describe('fetchSimulatorSessionErrorsAsync', () => { @@ -288,6 +347,32 @@ describe('fetchSimulatorSessionErrorsAsync', () => { expect(JSON.stringify(jest.mocked(Log.debug).mock.calls)).not.toContain('secret-signature'); }); + it('strips escape sequences and control characters from job run error messages', async () => { + server = http.createServer(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + mockJobRunErrorsByIdAsync.mockResolvedValue({ + id: 'job-123', + errors: [ + { + errorCode: 'EAS_IOS_SIMULATOR_NOT_FOUND', + message: 'No available iOS Simulator is named "\u001b[2JiPhone\u001b]0;x\u0007\nFake"', + docsUrl: null, + }, + ], + logFileUrls: [], + }); + + await expect( + jobRunErrors.fetchSimulatorSessionErrorsAsync(graphqlClient, 'session-123') + ).resolves.toEqual([ + { + errorCode: 'EAS_IOS_SIMULATOR_NOT_FOUND', + message: 'No available iOS Simulator is named "iPhone Fake"', + docsUrl: null, + }, + ]); + }); + it('falls back to the job run error when the log request fails', async () => { mockJobRun( await startServerAsync((_request, response) => { diff --git a/packages/eas-cli/src/simulator/jobRunErrors.ts b/packages/eas-cli/src/simulator/jobRunErrors.ts index 2050cb410a..cdf84ddba9 100644 --- a/packages/eas-cli/src/simulator/jobRunErrors.ts +++ b/packages/eas-cli/src/simulator/jobRunErrors.ts @@ -1,9 +1,12 @@ +import { stripVTControlCharacters } from 'util'; + import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; import { parseLogLines } from '../commandUtils/logs/parseLogs'; import { RawLogLine } from '../commandUtils/logs/types'; import fetch from '../fetch'; import { DeviceRunSessionQuery } from '../graphql/queries/DeviceRunSessionQuery'; import Log, { learnMore } from '../log'; +import { sanitizeTerminalText } from '../utils/terminalText'; export type SimulatorSessionError = { errorCode: string; @@ -73,7 +76,7 @@ export async function fetchSimulatorSessionErrorsAsync( } return errors.map(({ errorCode, message, docsUrl }) => ({ errorCode, - message: truncate(message), + message: formatMessage(message), docsUrl, })); } @@ -134,13 +137,14 @@ function selectStepFailureLines(stepLines: JobRunLogLine[]): JobRunLogLine[] { let selected: JobRunLogLine[] = []; let stderrCause: JobRunLogLine[] = []; for (const [index, line] of stepLines.entries()) { + const text = toPlainText(line.msg ?? ''); if (line.source === 'stderr') { - if (STDERR_CAUSE_PATTERN.test(line.msg ?? '')) { + if (STDERR_CAUSE_PATTERN.test(text)) { stderrCause = [line]; } else if (stderrCause.length === 1 && stepLines[index - 1] === stderrCause[0]) { stderrCause.push(line); } - } else if (line.err && (line.level ?? 0) >= ERROR_LOG_LEVEL && line.msg?.trim()) { + } else if (line.err && (line.level ?? 0) >= ERROR_LOG_LEVEL && text) { selected = [...stderrCause, line]; stderrCause = []; } @@ -149,13 +153,20 @@ function selectStepFailureLines(stepLines: JobRunLogLine[]): JobRunLogLine[] { } function formatLogLine(line: JobRunLogLine): string { - return truncate( + return formatMessage( line.buildStepDisplayName ? `${line.buildStepDisplayName}: ${line.msg}` : String(line.msg) ); } -function truncate(message: string): string { - return message.length > MAX_LOGGED_ERROR_LENGTH - ? `${message.slice(0, MAX_LOGGED_ERROR_LENGTH)}…` - : message; +// Log text comes from commands run in the session, so strip escape sequences and control +// characters that could clear the terminal or spoof output. +function toPlainText(message: string): string { + return sanitizeTerminalText(stripVTControlCharacters(message)); +} + +function formatMessage(message: string): string { + const text = toPlainText(message); + return text.length > MAX_LOGGED_ERROR_LENGTH + ? `${text.slice(0, MAX_LOGGED_ERROR_LENGTH)}…` + : text; }