Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/simulator-start-job-run-errors.md
Original file line number Diff line number Diff line change
@@ -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.
152 changes: 152 additions & 0 deletions packages/eas-cli/src/commands/simulator/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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 () => {});
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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 }))
Expand Down Expand Up @@ -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())
Expand Down
43 changes: 39 additions & 4 deletions packages/eas-cli/src/commands/simulator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)}`
);
}

Expand All @@ -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)}`
);
}

Expand Down Expand Up @@ -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<string> {
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
Expand Down Expand Up @@ -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 ||
Expand Down
7 changes: 7 additions & 0 deletions packages/eas-cli/src/graphql/generated.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

86 changes: 86 additions & 0 deletions packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { OperationResult, OperationResultSource } from '@urql/core';
import gql from 'graphql-tag';

import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
Expand All @@ -8,6 +9,8 @@ import {
DeviceRunSessionEventsByIdQuery,
DeviceRunSessionEventsByIdQueryVariables,
DeviceRunSessionFilterInput,
DeviceRunSessionJobRunErrorsByIdQuery,
DeviceRunSessionJobRunErrorsByIdQueryVariables,
DeviceRunSessionsByAppIdQuery,
DeviceRunSessionsByAppIdQueryVariables,
} from '../generated';
Expand Down Expand Up @@ -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<DeviceRunSessionJobRunErrorsByIdQuery['deviceRunSessions']['byId']['turtleJobRun']> {
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
Expand Down Expand Up @@ -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<T extends OperationResult>(
source: OperationResultSource<T>,
signal: AbortSignal
): Promise<T> {
signal.throwIfAborted();
return await new Promise<T>((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();
}
});
}
Loading
Loading