diff --git a/.changeset/simulator-start-job-run-errors.md b/.changeset/simulator-start-job-run-errors.md new file mode 100644 index 0000000000..5a842a89a5 --- /dev/null +++ b/.changeset/simulator-start-job-run-errors.md @@ -0,0 +1,5 @@ +--- +'eas-cli': patch +--- + +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 15b2321121..c943fab62f 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'), @@ -105,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 () => {}); @@ -191,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({ @@ -1920,6 +1923,131 @@ 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'; + + beforeEach(() => { + mockByIdAsync.mockResolvedValue( + makeDeviceRunSession({ status: DeviceRunSessionStatus.Errored, remoteConfig: null }) + ); + }); + + it('keeps the message unchanged when the job run has no errors', async () => { + mockJobRunErrorsByIdAsync.mockResolvedValue({ id: 'job-123', errors: [] }); + + 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', + }, + ], + }); + + 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`, + }) + ); + }); + + it('prints an unknown error as reported', async () => { + mockJobRunErrorsByIdAsync.mockResolvedValue({ + id: 'job-123', + errors: [ + { + errorCode: 'UNKNOWN_ERROR', + message: 'Unknown error. See logs for more information.', + docsUrl: null, + }, + ], + }); + + 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('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 () => { mockByIdAsync .mockResolvedValueOnce(makeDeviceRunSession({ remoteConfig: null })) @@ -2219,6 +2347,30 @@ 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, + }, + ], + }); + + 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()) diff --git a/packages/eas-cli/src/commands/simulator/index.ts b/packages/eas-cli/src/commands/simulator/index.ts index e7972570fb..40ef69662d 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)}${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)}` + `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,6 +619,39 @@ export default class Simulator extends EasCommand { } } +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, + signal: AbortSignal +): Promise { + if (signal.aborted) { + return ''; + } + let jobRun; + try { + 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 => { + 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(''); +} + async function resolvePlatformAsync( platform: PlatformFlagValue | undefined, nonInteractive: boolean @@ -700,7 +733,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, signal)}` + ); } if ( session.status === DeviceRunSessionStatus.Stopped || diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index 6b0f621c5f..b090fda0d1 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, 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 b89b385a67..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'; @@ -8,6 +9,8 @@ import { DeviceRunSessionEventsByIdQuery, DeviceRunSessionEventsByIdQueryVariables, DeviceRunSessionFilterInput, + DeviceRunSessionJobRunErrorsByIdQuery, + DeviceRunSessionJobRunErrorsByIdQueryVariables, DeviceRunSessionsByAppIdQuery, DeviceRunSessionsByAppIdQueryVariables, } from '../generated'; @@ -42,6 +45,51 @@ 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, + { signal }: { signal: AbortSignal } + ): Promise { + const { data, error } = await firstResultUntilAbortedAsync( + graphqlClient.query< + DeviceRunSessionJobRunErrorsByIdQuery, + DeviceRunSessionJobRunErrorsByIdQueryVariables + >( + gql` + query DeviceRunSessionJobRunErrorsByIdQuery($deviceRunSessionId: ID!) { + deviceRunSessions { + byId(deviceRunSessionId: $deviceRunSessionId) { + id + turtleJobRun { + id + errors { + errorCode + message + docsUrl + } + } + } + } + } + `, + { 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( graphqlClient: ExpoGraphqlClient, deviceRunSessionId: string @@ -224,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); + }); +});