Compare commits

..
Author SHA1 Message Date
Stanisław Chmiela e507735166 [worker] Add a packaged startup wrapper 2026-10-22 13:17:51 +02:00
Ash Wu 9b54c203ee [build-tools] retry download_build network failures and report their cause (#4690) 2026-10-22 15:30:47 +08:00
Ramon Claudio a0fe3ff2c3 [eas-cli][build-tools] Bump Node.js to 22.23.4 (#4714)
* [eas-cli] Bump Node.js to 22.23.4

* [eas-cli] Add changeset

* [build-tools] Bump Node.js to 22.23.4
2026-10-21 18:38:36 -04:00
Brent VatneandClaude Opus 5.5 fb383d818b [eas-cli] make command descriptions lowercase without trailing periods (#4385)
* [eas-cli] make command descriptions lowercase without trailing periods

Most command descriptions in eas --help already start lowercase and end
without a period; align the remaining 11 capitalized and 12 period-terminated
ones with that style. Proper nouns, [EXPERIMENTAL] prefixes, and later
paragraphs of multi-line descriptions are unchanged.

* [eas-cli] Drop trailing periods from simulator command descriptions

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-21 14:27:33 -07:00
Stanisław Chmiela c07c5edba9 [build-tools] Add max_duration_seconds to sandboxes (#4702)
# Why

I want to set up billing and limits for sandboxes so we need to be able to say how long can a sandbox run for.

# How

Added simple `max_duration_seconds` like we have in agent runs and device run sessions.

# Test Plan

CI should pass.
2026-10-21 17:18:26 +02:00
Brent Vatne 56646b7436 [eas-cli] Show simulator session errors in simulator:get (#4694)
Look up why a failed simulator session failed in both simulator:start and
simulator:get. For unknown errors, read the failed step's cause from the job
run log, using only the masked log message, with the download bounded by size
and time. Strip terminal control characters before printing.
2026-10-20 14:00:29 -07:00
Brent Vatne f775b10549 [eas-cli] Show why a simulator session failed to start (#4693)
* [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.

* [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.

* [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.

* [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.
2026-10-20 13:51:21 -07:00
Brent Vatne c756c5d0b5 [worker] Mask secrets in every field of uploaded log records (#4698)
* [worker] Mask secrets in logged errors

The upload transform masked only each record's msg, so a serialized err
(message, stack and other fields) was uploaded with secrets intact. That
includes unknown build phase failures, which log { err } with an empty msg.
Mask the strings inside err on a copy, since bunyan shares the record with
other streams.

* [worker] Mask secrets in every field of uploaded log records

Mask all fields except bunyan's metadata, not only err: other fields such as
error, data and the job dump also carried secrets. Round-trip each value
through JSON first so a toJSON method can't bring back the original, mask keys
and numbers, and replace values nested too deeply instead of uploading them
unmasked.
2026-10-20 13:51:17 -07:00
Brent VatneandClaude Opus 5.5 9f674b1d19 [eas-build-job][build-tools] Check Android launch inputs before the session starts (#4696)
* [build-tools] Check Android launch inputs before the emulator boots

Agent-device sessions checked Android launch inputs only when launching,
after the emulator booted and the build installed. Check NUL characters
and the command length at the start of the step, as on iOS, leaving room
for the launch component that is only known after install.

* Add changeset for early Android launch input checks

* [eas-build-job] Share the Android launch input checks

Move the Android shell quoting and the early launch input checks into
@expo/eas-build-job, so www can check launch inputs when a session is
created, for every session type.

* [eas-build-job] Check only the known part of the Android launch command early

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-20 13:40:44 -07:00
Expo CI 6f33a4d2a4 bump STAGING and PRODUCTION to 25.3.0 in cli-versions.json 2026-10-20 19:22:35 +00:00
61 changed files with 2614 additions and 147 deletions

No files matched your search

@@ -0,0 +1,6 @@
---
'@expo/eas-build-job': minor
'@expo/build-tools': patch
---
Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions.
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log.
+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': none
---
Make command descriptions in `eas --help` consistently lowercase without trailing periods.
+6
View File
@@ -0,0 +1,6 @@
---
'eas-cli': none
'@expo/build-tools': none
---
Bump Node.js to 22.23.4 to fix the CVEs in the [October 2026 Node.js security releases](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases).
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/build-tools': minor
---
Stop sandboxes after an optional maximum duration measured from readiness.
@@ -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. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs.
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/worker': patch
---
Mask secrets in every field of uploaded job log records, such as logged errors, not only in log messages.
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "25.2.0",
"PRODUCTION": "25.2.0"
"STAGING": "25.3.0",
"PRODUCTION": "25.3.0"
}
+1 -1
View File
@@ -6,4 +6,4 @@ MISE_NODE_COREPACK = "true"
actionlint = "1.7.12"
gcloud = "570.0.0"
jq = "1.8.1"
node = "22.23.3"
node = "22.23.4"
+1 -1
View File
@@ -26,7 +26,7 @@
"build:egress-guard": "resources/egress-guard/build.sh",
"test:egress-guard": "resources/egress-guard/tests/run-policy-tests.sh && resources/egress-guard/tests/run-guard-tests.sh",
"typecheck": "tsc",
"generate-appium-commands": "mise exec [email protected]2.0 -- node scripts/generate-appium-commands.js",
"generate-appium-commands": "mise exec [email protected]3.4 -- node scripts/generate-appium-commands.js",
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
"jest-unit": "jest --config jest/unit-config.ts",
"jest-integration": "jest --config jest/integration-config.ts",
@@ -11,6 +11,7 @@ import * as tar from 'tar';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { Datadog } from '../../../datadog';
import * as files from '../../../utils/files';
import { createDownloadBuildFunction, downloadBuildAsync } from '../downloadBuild';
@@ -30,11 +31,17 @@ const APPLICATION_ARCHIVE_URL = `https://expo.dev/artifacts/eas/${randomUUID()}.
function createMockGraphqlClient({
applicationArchiveUrl,
error,
transientError,
}: {
applicationArchiveUrl?: string | null;
error?: Error;
transientError?: Error;
}): Client {
const toPromise = jest.fn().mockResolvedValue(
const toPromise = jest.fn();
if (transientError) {
toPromise.mockResolvedValueOnce({ error: transientError, data: undefined });
}
toPromise.mockResolvedValue(
error
? { error, data: undefined }
: {
@@ -62,7 +69,7 @@ function createSuccessfulResponse({
url,
headers,
}: {
body: Buffer;
body: Buffer | Readable;
url: string;
headers?: Record<string, string>;
}): Response {
@@ -72,7 +79,7 @@ function createSuccessfulResponse({
return {
ok: true,
body: Readable.from(body),
body: body instanceof Readable ? body : Readable.from(body),
url,
headers: {
get: (name: string): string | null => normalizedHeaders[name.toLowerCase()] ?? null,
@@ -80,6 +87,47 @@ function createSuccessfulResponse({
} as unknown as Response;
}
function createUndiciNetworkError(code: string): CombinedError {
return new CombinedError({
networkError: Object.assign(new TypeError('fetch failed'), {
cause: Object.assign(new Error(`read ${code}`), { code }),
}),
});
}
const { FetchError: ActualFetchError } = jest.requireActual(
'node-fetch'
) as typeof import('node-fetch');
function createSocketFetchError(url: string, code: string): Error {
return new ActualFetchError(`request to ${url} failed, reason: connect ${code}`, 'system', {
code,
} as any);
}
function useFakeBackoffTimers(): void {
// Streams and fs callbacks need the real nextTick and setImmediate; only the backoff timers are faked.
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
}
const MAX_IO_TURNS_BEFORE_BACKOFF = 1000;
async function waitForBackoffTimerAsync(): Promise<void> {
for (let turns = 0; jest.getTimerCount() === 0; turns++) {
if (turns === MAX_IO_TURNS_BEFORE_BACKOFF) {
throw new Error('No backoff timer was scheduled.');
}
await new Promise(resolve => setImmediate(resolve));
}
}
async function fireBackoffTimersAsync(count: number): Promise<void> {
for (let i = 0; i < count; i++) {
await waitForBackoffTimerAsync();
await jest.advanceTimersToNextTimerAsync();
}
}
async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
const sourceDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'flat-app-source-'));
const archivePath = path.join(os.tmpdir(), `${randomUUID()}.tar.gz`);
@@ -108,6 +156,11 @@ async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
}
}
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
describe('downloadBuild', () => {
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
const buildId = randomUUID();
@@ -465,13 +518,13 @@ describe('downloadBuild', () => {
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('throws SystemError when GraphQL request fails with a network error', async () => {
it('throws SystemError with the cause code after retrying a failing build lookup', async () => {
useFakeBackoffTimers();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: new CombinedError({
networkError: new Error('Network request failed'),
}),
error: createUndiciNetworkError('ECONNRESET'),
});
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
@@ -480,19 +533,81 @@ describe('downloadBuild', () => {
robotAccessToken: null,
extensions: ['app'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
await fireBackoffTimersAsync(5);
await expect(promise).rejects.toBeInstanceOf(SystemError);
await rejection;
await expect(promise).rejects.toMatchObject({
message: `Could not fetch build ${buildId}: [Network] Network request failed`,
message: `Could not fetch build ${buildId}: [Network] fetch failed (ECONNRESET)`,
});
expect(graphqlClient.query).toHaveBeenCalledTimes(6);
expect(datadogLog).toHaveBeenCalledWith('download_build.build_lookup failed on attempt 6', {
event: 'network_retry',
operation: 'download_build.build_lookup',
outcome: 'failed',
attempts: '6',
code: 'ECONNRESET',
});
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('throws SystemError when GraphQL request returns 5xx', async () => {
it.each([
['a network error', createUndiciNetworkError('ECONNRESET'), 'ECONNRESET'],
[
'a transient GraphQL error',
new CombinedError({
graphQLErrors: [
{
message: 'Too many requests',
extensions: { isTransient: true, errorCode: 'TOO_MANY_REQUESTS' },
},
],
response: { status: 200 },
}),
'TOO_MANY_REQUESTS',
],
])('downloads after a build lookup that failed once with %s', async (_, transientError, code) => {
useFakeBackoffTimers();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: Object.assign(new Error('Internal Server Error'), {
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
transientError,
});
jest
.mocked(fetch)
.mockResolvedValue(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
await fireBackoffTimersAsync(1);
const { artifactPath } = await promise;
expect(path.basename(artifactPath)).toBe('app.apk');
expect(graphqlClient.query).toHaveBeenCalledTimes(2);
expect(datadogLog).toHaveBeenCalledWith('download_build.build_lookup recovered on attempt 2', {
event: 'network_retry',
operation: 'download_build.build_lookup',
outcome: 'recovered',
attempts: '2',
code,
});
});
it('throws SystemError when GraphQL request returns 5xx', async () => {
useFakeBackoffTimers();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: new CombinedError({
graphQLErrors: [{ message: 'Internal Server Error' }],
response: { status: 500 },
}),
});
@@ -504,12 +619,14 @@ describe('downloadBuild', () => {
robotAccessToken: null,
extensions: ['app'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
await fireBackoffTimersAsync(5);
await expect(promise).rejects.toBeInstanceOf(SystemError);
await rejection;
await expect(promise).rejects.toMatchObject({
message: `Could not fetch build ${buildId}: Internal Server Error`,
message: `Could not fetch build ${buildId}: [GraphQL] Internal Server Error`,
});
expect(graphqlClient.query).toHaveBeenCalledTimes(6);
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
@@ -534,10 +651,193 @@ describe('downloadBuild', () => {
errorCode: 'EAS_DOWNLOAD_BUILD_FETCH_FAILED',
message: `Could not fetch build ${buildId}: [GraphQL] Build not found`,
});
expect(graphqlClient.query).toHaveBeenCalledTimes(1);
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('downloads after an archive request that failed once with a socket error', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockRejectedValueOnce(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ECONNRESET'))
.mockResolvedValueOnce(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
await fireBackoffTimersAsync(1);
const { artifactPath } = await promise;
expect(path.basename(artifactPath)).toBe('app.apk');
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(2);
});
it('throws SystemError with the cause code after retrying a failing build archive request', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest
.mocked(fetch)
.mockRejectedValue(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ETIMEDOUT'));
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
await fireBackoffTimersAsync(5);
await rejection;
await expect(promise).rejects.toMatchObject({
message: `Could not download the application archive: request to ${APPLICATION_ARCHIVE_URL} failed, reason: connect ETIMEDOUT`,
});
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
expect(datadogLog).toHaveBeenCalledWith('download_build.archive_download failed on attempt 6', {
event: 'network_retry',
operation: 'download_build.archive_download',
outcome: 'failed',
attempts: '6',
code: 'ETIMEDOUT',
});
});
function createBrokenBody(code: string): Readable {
return Readable.from(
(async function* () {
yield Buffer.from('he');
throw Object.assign(new Error(`body failed with ${code}`), { code });
})()
);
}
it.each(['ERR_STREAM_PREMATURE_CLOSE', 'ECONNRESET'])(
'downloads again when the archive body breaks off with %s after the headers arrived',
async code => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({});
const brokenBody = createBrokenBody(code);
jest
.mocked(fetch)
.mockResolvedValueOnce(
createSuccessfulResponse({ body: brokenBody, url: 'https://cdn.test/app.apk' })
)
.mockResolvedValueOnce(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
await fireBackoffTimersAsync(1);
const { artifactPath } = await promise;
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(2);
expect(datadogLog).toHaveBeenCalledWith(
'download_build.archive_download recovered on attempt 2',
expect.objectContaining({ outcome: 'recovered', code })
);
}
);
it('throws SystemError when the build archive body keeps breaking off', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest.mocked(fetch).mockImplementation(async () =>
createSuccessfulResponse({
body: createBrokenBody('ECONNRESET'),
url: 'https://cdn.test/app.apk',
})
);
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
await fireBackoffTimersAsync(5);
await rejection;
await expect(promise).rejects.toMatchObject({
message: 'Could not download the application archive: body failed with ECONNRESET',
});
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
expect(datadogLog).toHaveBeenCalledWith(
'download_build.archive_download failed on attempt 6',
expect.objectContaining({ outcome: 'failed', code: 'ECONNRESET' })
);
});
it('does not start another archive attempt after an abort during the backoff', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockRejectedValueOnce(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ECONNRESET'));
const controller = new AbortController();
const reason = new Error('step timed out');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
signal: controller.signal,
});
const rejection = expect(promise).rejects.toBe(reason);
await waitForBackoffTimerAsync();
controller.abort(reason);
await jest.advanceTimersToNextTimerAsync();
await rejection;
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(1);
});
it('rethrows the raw error of a failing user-supplied archive URL after the retries', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockRejectedValue(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ENOTFOUND'));
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(ActualFetchError);
await fireBackoffTimersAsync(5);
await rejection;
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
});
it('should throw an error if no matching files are found', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
@@ -605,6 +905,21 @@ describe('createDownloadBuildFunction', () => {
};
}
function createApplicationResponse(buildId: string): Response {
return new ActualResponse(
JSON.stringify({
data: {
build: {
id: buildId,
gitCommitHash: null,
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
},
},
}),
{ status: 200 }
);
}
function createStep(ctx: any, callInputs: Record<string, unknown>) {
return createDownloadBuildFunction(ctx).createBuildStepFromFunctionCall(
createGlobalContextMock({
@@ -620,18 +935,7 @@ describe('createDownloadBuildFunction', () => {
const graphqlClient = createMockGraphqlClient({});
jest.mocked(fetch).mockImplementation(async url => {
if (url === workerApiUrl) {
return new ActualResponse(
JSON.stringify({
data: {
build: {
id: buildId,
gitCommitHash: null,
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
},
},
}),
{ status: 200 }
);
return createApplicationResponse(buildId);
}
return {
ok: false,
@@ -661,6 +965,33 @@ describe('createDownloadBuildFunction', () => {
expect(graphqlClient.query).not.toHaveBeenCalled();
});
it('retries the worker API lookup after a 5xx response', async () => {
useFakeBackoffTimers();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockResolvedValueOnce(new ActualResponse('Service Unavailable', { status: 503 }))
.mockResolvedValueOnce(createApplicationResponse(buildId))
.mockResolvedValueOnce(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const execution = createStep(createSessionCtx(graphqlClient), {
build_id: buildId,
extensions: ['apk'],
}).executeAsync();
await fireBackoffTimersAsync(1);
await execution;
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(3);
expect(jest.mocked(fetch).mock.calls.map(([url]) => url)).toEqual([
workerApiUrl,
workerApiUrl,
APPLICATION_ARCHIVE_URL,
]);
});
it('does not call the worker API for an application_archive_url input', async () => {
const graphqlClient = createMockGraphqlClient({});
jest.mocked(fetch).mockResolvedValue({
@@ -687,6 +687,18 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it.each([
['NUL argument', { launch_args: ['a\u0000b'] }],
['command length', { launch_args: ['--es', 'quotes', "'".repeat(20_000)] }],
['NUL URL', { open_url: 'example:\u0000screen' }],
])('rejects Android launch %s before startup work', async (_name, inputs) => {
const step = runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', ...inputs });
await expect(step).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('keeps Android launch inputs on its existing direct path', async () => {
const launchArgs = ['a'.repeat(9000)];
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', launch_args: launchArgs });
@@ -1,4 +1,4 @@
import { SystemError } from '@expo/eas-build-job';
import { SystemError, UserError } from '@expo/eas-build-job';
import { Client, fetchExchange } from '@urql/core';
import * as sandboxDaemon from '../../utils/sandboxDaemon';
@@ -6,6 +6,90 @@ import * as sandboxDaemon from '../../utils/sandboxDaemon';
import { createStartSandboxBuildFunction, markSandboxReadyAsync } from '../startSandbox';
describe('sandbox build functions', () => {
it.each([
{ completion: 'duration', duration: 0.02 },
{ completion: 'cancellation', duration: 60 },
{ completion: 'duration', duration: 0.0001 },
])(
'stops after $completion with $duration seconds only after readiness',
async ({ completion, duration }) => {
const controller = new AbortController();
let ready!: () => void;
const stopAsync = jest.fn(async () => {});
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockResolvedValue({
ready: new Promise<void>(resolve => {
ready = resolve;
}),
stopAsync,
});
const mutation = jest.fn(() => ({ toPromise: async () => ({ data: {} }) }));
const logger = { info: jest.fn(), child: jest.fn() };
logger.child.mockReturnValue(logger);
const fn = createStartSandboxBuildFunction({
mcpServerUrl: 'ws://localhost:8787',
graphqlClient: { mutation },
} as any);
try {
const pending = fn.fn!({ logger } as any, {
inputs: {
sandbox_id: { value: 'sandbox-id' },
max_duration_seconds: { value: duration },
},
outputs: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
signal: controller.signal,
});
await new Promise(resolve => setTimeout(resolve, 40));
expect(mutation).not.toHaveBeenCalled();
expect(stopAsync).not.toHaveBeenCalled();
ready();
if (completion === 'cancellation') {
await new Promise(resolve => setImmediate(resolve));
expect(stopAsync).not.toHaveBeenCalled();
controller.abort();
}
await pending;
expect(mutation).toHaveBeenCalledTimes(1);
expect(stopAsync).toHaveBeenCalledTimes(1);
const timeoutMessage = `Sandbox reached its maximum runtime of ${duration} seconds. Stopping.`;
if (completion === 'duration') {
expect(logger.info).toHaveBeenCalledWith(timeoutMessage);
} else {
expect(logger.info).not.toHaveBeenCalledWith(timeoutMessage);
}
} finally {
start.mockRestore();
}
}
);
it.each([0, -1, Infinity, NaN])(
'rejects invalid maximum duration %s before starting the daemon',
async duration => {
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync');
const fn = createStartSandboxBuildFunction({
mcpServerUrl: 'ws://localhost:8787',
} as any);
try {
const result = fn.fn!({} as any, {
inputs: {
sandbox_id: { value: 'sandbox-id' },
max_duration_seconds: { value: duration },
},
outputs: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
});
await expect(result).rejects.toBeInstanceOf(UserError);
await expect(result).rejects.toThrow(
`Invalid sandbox maximum duration: ${duration}. Provide a finite number greater than zero, or omit max_duration_seconds.`
);
expect(start).not.toHaveBeenCalled();
} finally {
start.mockRestore();
}
}
);
it('aborts an active ready request and keeps its authentication headers', async () => {
const controller = new AbortController();
let requestStarted!: () => void;
@@ -7,7 +7,7 @@ import {
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import { Client } from '@urql/core';
import { Client, CombinedError } from '@urql/core';
import contentDisposition from 'content-disposition';
import { glob } from 'fast-glob';
import { graphql } from 'gql.tada';
@@ -26,10 +26,16 @@ import { CustomBuildContext } from '../../customBuildContext';
import { formatBytes } from '../../utils/artifacts';
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
import {
describeError,
isTransferFailure,
isTransientGraphqlError,
} from '../../utils/networkErrors';
import { retryOnNetworkFailureAsync } from '../../utils/retryOnNetworkFailure';
import { pluralize } from '../../utils/strings';
import {
type DeviceRunSessionWorkerApi,
DeviceRunSessionWorkerApiError,
getDeviceRunSessionWorkerApi,
} from '../utils/deviceRunSessionWorkerApi';
@@ -152,21 +158,34 @@ async function fetchApplicationArchiveUrlAsync({
signal,
});
}
const result = await graphqlClient
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
.toPromise();
let data;
try {
data = await retryOnNetworkFailureAsync(
async () => {
const queryResult = await graphqlClient
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
.toPromise();
if (queryResult.error) {
throw queryResult.error;
}
return queryResult.data;
},
{
operation: 'download_build.build_lookup',
signal,
isRetryable: error => error instanceof CombinedError && isTransientGraphqlError(error),
}
);
} catch (error) {
signal?.throwIfAborted();
const message = `Could not fetch build ${buildId}: ${describeError(error)}`;
throw error instanceof CombinedError && !isTransientGraphqlError(error)
? new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error })
: new SystemError(message, { cause: error });
}
signal?.throwIfAborted();
if (result.error) {
const { error } = result;
const message = `Could not fetch build ${buildId}: ${error.message}`;
throw error.networkError || result.error.response?.status >= 500
? new SystemError(message, { cause: error })
: new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error });
}
const applicationArchiveUrl = result.data?.builds.byId?.artifacts?.applicationArchiveUrl;
const applicationArchiveUrl = data?.builds.byId?.artifacts?.applicationArchiveUrl;
if (!applicationArchiveUrl) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
@@ -195,13 +214,18 @@ async function fetchDeviceRunSessionApplicationArchiveUrlAsync({
}): Promise<string> {
let application;
try {
application = await deviceRunSessionWorkerApi.getApplicationAsync({ signal });
application = await retryOnNetworkFailureAsync(
() => deviceRunSessionWorkerApi.getApplicationAsync({ signal }),
{
operation: 'download_build.session_build_lookup',
signal,
isRetryable: error => error instanceof DeviceRunSessionWorkerApiError && error.isTransient,
}
);
} catch (err) {
signal?.throwIfAborted();
throw new SystemError(
`Could not fetch build ${buildId} for this device run session: ${
err instanceof Error ? err.message : String(err)
}`,
`Could not fetch build ${buildId} for this device run session: ${describeError(err)}`,
{ cause: err }
);
}
@@ -281,18 +305,34 @@ export async function downloadBuildAsync(
);
signal?.throwIfAborted();
const response = await retryOnDNSFailure(fetch)(downloadUrl, { headers, signal });
if (!response.ok) {
const textResult = await asyncResult(response.text());
throw new Error(`Unexpected response from server (${response.status}): ${textResult.value}`);
let archivePath: string;
try {
archivePath = await retryOnNetworkFailureAsync(
async () => {
const response = await fetch(downloadUrl, { headers, signal });
if (!response.ok) {
const textResult = await asyncResult(response.text());
throw new Error(
`Unexpected response from server (${response.status}): ${textResult.value}`
);
}
const archiveFilename = resolveArchiveFilename({ response, extensions });
const attemptArchivePath = path.join(downloadDestinationDirectory, archiveFilename);
// On abort, node-fetch emits an error on the body stream, which fails the pipeline.
await streamPipeline(response.body, fs.createWriteStream(attemptArchivePath));
return attemptArchivePath;
},
{ operation: 'download_build.archive_download', signal, isRetryable: isTransferFailure }
);
} catch (error) {
signal?.throwIfAborted();
if (params.buildId && isTransferFailure(error)) {
throw new SystemError(`Could not download the application archive: ${describeError(error)}`, {
cause: error,
});
}
throw error;
}
const archiveFilename = resolveArchiveFilename({ response, extensions });
const archivePath = path.join(downloadDestinationDirectory, archiveFilename);
// On abort, node-fetch emits an error on the body stream, which fails the pipeline.
await streamPipeline(response.body, fs.createWriteStream(archivePath));
signal?.throwIfAborted();
const { size } = await fs.promises.stat(archivePath);
@@ -1,4 +1,4 @@
import { UserError } from '@expo/eas-build-job';
import { UserError, quoteAndroidShellCommand } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import {
BuildFunction,
@@ -11,8 +11,6 @@ import spawn from '@expo/turtle-spawn';
const IOS_URL_SCHEME_APPROVAL_DOMAIN = 'com.apple.launchservices.schemeapproval';
const IOS_URL_SCHEME_APPROVAL_KEY_PREFIX = 'com.apple.CoreSimulator.CoreSimulatorBridge-->';
// adb closes the connection for longer shell commands (about 65.5 KB with adb 37 on API 35).
const ANDROID_SHELL_COMMAND_MAX_BYTES = 65_000;
export function createLaunchApplicationFunction(): BuildFunction {
return new BuildFunction({
@@ -135,33 +133,11 @@ export async function launchApplicationAsync({
}
}
// `adb shell` joins its arguments with spaces and the device shell parses the result again, so
// each argument is quoted to reach the command as one literal word.
async function spawnAndroidShellCommandAsync(
command: string[],
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
): Promise<void> {
if (command.some(argument => argument.includes('\0'))) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
'Android "launch_args" and "open_url" cannot contain NUL characters.'
);
}
const quotedCommand = command.map(quoteAndroidShellArgument);
if (Buffer.byteLength(quotedCommand.join(' ')) > ANDROID_SHELL_COMMAND_MAX_BYTES) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
`The Android launch command is longer than ${ANDROID_SHELL_COMMAND_MAX_BYTES} bytes after shell quoting. Shorten "launch_args" or "open_url".`
);
}
await spawn('adb', ['shell', ...quotedCommand], { env, logger });
}
function quoteAndroidShellArgument(argument: string): string {
if (/^[\w@%+=:,./-]+$/.test(argument)) {
return argument;
}
return `'${argument.replaceAll("'", `'\\''`)}'`;
await spawn('adb', ['shell', ...quoteAndroidShellCommand(command)], { env, logger });
}
async function preapproveIosUrlSchemeAsync({
@@ -1,4 +1,4 @@
import { UserError } from '@expo/eas-build-job';
import { UserError, validateAndroidLaunchOptions } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
@@ -164,6 +164,8 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
}
if (isIos) {
validateServeSimLaunchOptions({ launchArgs, openUrl });
} else {
validateAndroidLaunchOptions({ launchArgs, openUrl });
}
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
const capture = parseNetworkCaptureInputs(
@@ -1,4 +1,4 @@
import { SystemError } from '@expo/eas-build-job';
import { SystemError, UserError } from '@expo/eas-build-job';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { graphql } from 'gql.tada';
@@ -32,6 +32,11 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'max_duration_seconds',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: async (stepCtx, { inputs, signal, env }) => {
// The daemon needs this credential, but shell commands do not.
@@ -44,6 +49,18 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
throw new SystemError('MCP server URL is required to start the sandbox daemon.');
}
const sandboxId = String(inputs.sandbox_id.value);
const maxDurationSeconds = inputs.max_duration_seconds?.value;
if (
maxDurationSeconds !== undefined &&
(typeof maxDurationSeconds !== 'number' ||
!Number.isFinite(maxDurationSeconds) ||
maxDurationSeconds <= 0)
) {
throw new UserError(
'EAS_SANDBOX_INVALID_DURATION',
`Invalid sandbox maximum duration: ${String(maxDurationSeconds)}. Provide a finite number greater than zero, or omit max_duration_seconds.`
);
}
const daemon = await startSandboxDaemonAsync({
credential: sandboxToken,
serverUrl: mcpServerUrl,
@@ -63,7 +80,21 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
stepCtx.logger.info('Sandbox daemon started.');
await withLogPhaseAsync(stepCtx.logger, AWAIT_SANDBOX_STEP_NAME, async () => {
await waitForCancellationAsync(signal);
const timeoutSignal =
maxDurationSeconds === undefined
? undefined
: AbortSignal.timeout(Math.ceil(maxDurationSeconds * 1000));
await waitForCompletionAsync(
AbortSignal.any([
...(signal ? [signal] : []),
...(timeoutSignal ? [timeoutSignal] : []),
])
);
if (timeoutSignal?.aborted) {
stepCtx.logger.info(
`Sandbox reached its maximum runtime of ${maxDurationSeconds} seconds. Stopping.`
);
}
});
} finally {
await daemon.stopAsync();
@@ -89,11 +120,11 @@ export async function markSandboxReadyAsync(
}
}
async function waitForCancellationAsync(signal?: AbortSignal): Promise<void> {
if (signal?.aborted) {
async function waitForCompletionAsync(signal: AbortSignal): Promise<void> {
if (signal.aborted) {
return;
}
await new Promise<void>(resolve => {
signal?.addEventListener('abort', () => resolve(), { once: true });
signal.addEventListener('abort', () => resolve(), { once: true });
});
}
@@ -330,7 +330,8 @@ describe(DeviceRunSessionWorkerApi, () => {
});
it('reports a request that failed before www responded', async () => {
jest.mocked(turtleFetch).mockRejectedValueOnce(new Error('socket hang up'));
const reason = new Error('socket hang up');
jest.mocked(turtleFetch).mockRejectedValueOnce(reason);
const error = await createApi()
.getSessionAsync()
@@ -338,6 +339,7 @@ describe(DeviceRunSessionWorkerApi, () => {
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
expect(error.status).toBeUndefined();
expect(error.cause).toBe(reason);
expect(error.message).toContain('failed before www responded: socket hang up');
});
@@ -88,11 +88,16 @@ export class DeviceRunSessionWorkerApiError extends Error {
constructor(
message: string,
/** The HTTP status of the response, or undefined when no response arrived. */
readonly status: number | undefined
readonly status: number | undefined,
options?: ErrorOptions
) {
super(message);
super(message, options);
this.name = 'DeviceRunSessionWorkerApiError';
}
get isTransient(): boolean {
return this.status === undefined || this.status >= 500;
}
}
/**
@@ -218,7 +223,8 @@ export class DeviceRunSessionWorkerApi {
`${routeDescription} failed before www responded: ${
reason instanceof Error ? reason.message : String(reason)
}. Check the network connection of the worker.`,
undefined
undefined,
{ cause: reason }
);
}
const response = responseResult.value;
@@ -1,4 +1,16 @@
import { isConnectFailure, isConnectionInterruptedError } from '../networkErrors';
import { CombinedError } from '@urql/core';
import {
describeError,
findErrorCode,
findGraphqlErrorCode,
isConnectFailure,
isConnectionInterruptedError,
isTransferFailure,
isTransientGraphqlError,
} from '../networkErrors';
const { FetchError } = jest.requireActual('node-fetch') as typeof import('node-fetch');
it.each([
'EAI_AGAIN',
@@ -34,3 +46,77 @@ it.each(['ETIMEDOUT', 'CERT_HAS_EXPIRED', undefined])('does not automatically re
expect(isConnectFailure(error)).toBe(false);
expect(isConnectionInterruptedError(error)).toBe(false);
});
it('finds the errno code on the cause of an undici fetch failure', () => {
const error = new CombinedError({
networkError: new TypeError('fetch failed', {
cause: Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }),
}),
});
expect(findErrorCode(error)).toBe('ECONNRESET');
expect(findErrorCode(new Error('Request failed'))).toBeUndefined();
expect(findErrorCode('Request failed')).toBeUndefined();
});
it('finds the www error code on a GraphQL error', () => {
const error = new CombinedError({
graphQLErrors: [
{ message: 'Too many requests', extensions: { errorCode: 'TOO_MANY_REQUESTS' } },
],
});
expect(findGraphqlErrorCode(error)).toBe('TOO_MANY_REQUESTS');
expect(
findGraphqlErrorCode(new CombinedError({ graphQLErrors: ['Build not found'] }))
).toBeUndefined();
expect(findGraphqlErrorCode(new Error('Request failed'))).toBeUndefined();
});
it.each([
['Request failed', 'ECONNRESET', 'Request failed (ECONNRESET)'],
['read ECONNRESET', 'ECONNRESET', 'read ECONNRESET'],
['Request failed', undefined, 'Request failed'],
])('describes %p with code %p as %p', (message, code, expected) => {
expect(describeError(Object.assign(new Error(message), { code }))).toBe(expected);
});
it('treats a missing response, a 5xx response or an isTransient GraphQL error as transient', () => {
expect(
isTransientGraphqlError(new CombinedError({ networkError: new Error('fetch failed') }))
).toBe(true);
expect(
isTransientGraphqlError(
new CombinedError({ graphQLErrors: ['Internal Server Error'], response: { status: 503 } })
)
).toBe(true);
expect(
isTransientGraphqlError(
new CombinedError({
graphQLErrors: [{ message: 'Database unavailable', extensions: { isTransient: true } }],
response: { status: 200 },
})
)
).toBe(true);
expect(
isTransientGraphqlError(
new CombinedError({ graphQLErrors: ['Build not found'], response: { status: 200 } })
)
).toBe(false);
});
it.each([
[
'a node-fetch socket failure',
new FetchError('request failed', 'system', { code: 'ECONNRESET' } as any),
true,
],
['a node-fetch body size failure', new FetchError('too large', 'max-size'), false],
['a body reset', Object.assign(new Error('aborted'), { code: 'ECONNRESET' }), true],
[
'a body closed early',
Object.assign(new Error('closed'), { code: 'ERR_STREAM_PREMATURE_CLOSE' }),
true,
],
['an HTTP error response', new Error('Unexpected response from server (500)'), false],
])('classifies %s as a transfer failure or not', (_label, error, expected) => {
expect(isTransferFailure(error)).toBe(expected);
});
@@ -1,3 +1,5 @@
import { CombinedError } from '@urql/core';
const CONNECT_FAILURE_CODES = new Set([
'EAI_AGAIN',
'ECONNREFUSED',
@@ -30,3 +32,50 @@ export function isConnectionInterruptedError(error: unknown): boolean {
}
return false;
}
/** Errno code of the error or its causes; undici keeps it on cause, urql on networkError. */
export function findErrorCode(error: unknown): string | undefined {
let current: unknown = error;
while (current instanceof Error) {
if ('code' in current && typeof current.code === 'string') {
return current.code;
}
current = current instanceof CombinedError ? current.networkError : current.cause;
}
return undefined;
}
export function findGraphqlErrorCode(error: unknown): string | undefined {
if (!(error instanceof CombinedError)) {
return undefined;
}
const code = error.graphQLErrors.at(0)?.extensions.errorCode;
return typeof code === 'string' ? code : undefined;
}
export function describeError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
const code = findErrorCode(error);
return code && !message.includes(code) ? `${message} (${code})` : message;
}
export function isTransientGraphqlError(error: CombinedError): boolean {
return (
Boolean(error.networkError) ||
error.response?.status >= 500 ||
error.graphQLErrors.some(graphqlError => graphqlError.extensions.isTransient === true)
);
}
// node-fetch throws type 'system' for a socket failure before the headers; after them it forwards
// ECONNRESET or ERR_STREAM_PREMATURE_CLOSE to the body. HTTP error responses never throw.
export function isTransferFailure(error: unknown): boolean {
return (
(error instanceof Error &&
error.name === 'FetchError' &&
'type' in error &&
error.type === 'system') ||
isConnectionInterruptedError(error) ||
findErrorCode(error) === 'ERR_STREAM_PREMATURE_CLOSE'
);
}
@@ -0,0 +1,53 @@
import { Datadog } from '../datadog';
import { findErrorCode, findGraphqlErrorCode } from './networkErrors';
import { promiseRetryWithCondition } from './promiseRetryWithCondition';
export async function retryOnNetworkFailureAsync<T>(
attemptAsync: () => Promise<T>,
{
operation,
signal,
isRetryable,
}: {
operation: string;
signal?: AbortSignal;
isRetryable: (error: unknown) => boolean;
}
): Promise<T> {
const shouldRetry = (error: unknown): boolean => !signal?.aborted && isRetryable(error);
let attempts = 1;
let lastError: unknown;
const report = (outcome: 'recovered' | 'failed', error: unknown): void => {
Datadog.log(`${operation} ${outcome} on attempt ${attempts}`, {
event: 'network_retry',
operation,
outcome,
attempts: String(attempts),
code: findErrorCode(error) ?? findGraphqlErrorCode(error) ?? 'unknown',
});
};
try {
const result = await promiseRetryWithCondition(
async () => {
signal?.throwIfAborted();
return await attemptAsync();
},
shouldRetry,
{ retries: 5, minTimeout: 1_000, factor: 2 },
({ attemptNumber, error }) => {
attempts = attemptNumber;
lastError = error;
}
)();
if (attempts > 1) {
report('recovered', lastError);
}
return result;
} catch (error) {
if (attempts > 1 && !signal?.aborted) {
report('failed', error);
}
throw error;
}
}
@@ -1,4 +1,9 @@
import { DeviceRunSessionEvent, parseDeviceRunSessionEvents } from '../deviceRunSession';
import {
DeviceRunSessionEvent,
parseDeviceRunSessionEvents,
quoteAndroidShellCommand,
validateAndroidLaunchOptions,
} from '../deviceRunSession';
describe('parseDeviceRunSessionEvents', () => {
it('parses, deduplicates, and sorts events from the dedicated event log', () => {
@@ -33,6 +38,56 @@ describe('parseDeviceRunSessionEvents', () => {
});
});
describe(quoteAndroidShellCommand, () => {
it('quotes only arguments the device shell would split or expand', () => {
expect(
quoteAndroidShellCommand(['am', 'start', '--es', 'text', 'hello world', "it's", '', '-n'])
).toEqual(['am', 'start', '--es', 'text', "'hello world'", "'it'\\''s'", "''", '-n']);
});
it('rejects NUL characters', () => {
expect(() => quoteAndroidShellCommand(['am', 'start', 'a\0b'])).toThrow(
expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' })
);
});
});
describe(validateAndroidLaunchOptions, () => {
it('rejects only commands that are too long without the launch component', () => {
const fixedLength = 'am start --es key -n'.length;
const launchArgs = (valueLength: number) => ['--es', 'key', 'a'.repeat(valueLength)];
expect(() =>
validateAndroidLaunchOptions({ launchArgs: launchArgs(65_000 - fixedLength) })
).not.toThrow();
expect(() =>
validateAndroidLaunchOptions({ launchArgs: launchArgs(65_000 - fixedLength + 1) })
).toThrow(expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' }));
});
it('counts bytes, not characters', () => {
// "é" is two bytes in UTF-8 and is quoted, which adds two more.
const fixedLength = "am start --es key '' -n".length;
const launchArgs = (extraLength: number) => [
'--es',
'key',
'a'.repeat(extraLength) + 'é'.repeat(32_488),
];
expect(fixedLength + 1 + 2 * 32_488).toBe(65_000);
expect(() => validateAndroidLaunchOptions({ launchArgs: launchArgs(1) })).not.toThrow();
expect(() => validateAndroidLaunchOptions({ launchArgs: launchArgs(2) })).toThrow(
expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' })
);
});
it('checks the URL to open', () => {
expect(() => validateAndroidLaunchOptions({ openUrl: 'exp://example.test/a\0b' })).toThrow(
expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' })
);
});
});
function createEvent(overrides: Partial<DeviceRunSessionEvent> = {}): string {
return JSON.stringify(createEventValue(overrides));
}
@@ -1,5 +1,7 @@
import { z } from 'zod';
import { UserError } from './errors';
/**
* Canonical record format for the activity events emitted during a remote device run (simulator)
* session. Producers (e.g. the worker and agent-device) write these as NDJSON to the
@@ -46,3 +48,54 @@ export function parseDeviceRunSessionEvents(eventLog: string): DeviceRunSessionE
(left, right) => new Date(left.ts).getTime() - new Date(right.ts).getTime()
);
}
// adb closes the connection for longer shell commands (about 65.5 KB with adb 37 on API 35).
export const ANDROID_SHELL_COMMAND_MAX_BYTES = 65_000;
/**
* Check Android launch inputs before a session starts. The launch component is only known once the
* build is installed, so this rejects only commands that are too long without it; the launch itself
* checks the exact command.
*/
export function validateAndroidLaunchOptions({
launchArgs = [],
openUrl,
}: {
launchArgs?: string[];
openUrl?: string;
}): void {
quoteAndroidShellCommand(['am', 'start', ...launchArgs, '-n']);
if (openUrl) {
quoteAndroidShellCommand(['am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl]);
}
}
/**
* `adb shell` joins its arguments with spaces and the device shell parses the result again, so
* each argument is quoted to reach the command as one literal word.
*/
export function quoteAndroidShellCommand(command: string[]): string[] {
if (command.some(argument => argument.includes('\0'))) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
'Android "launch_args" and "open_url" cannot contain NUL characters.'
);
}
const quotedCommand = command.map(quoteAndroidShellArgument);
if (
new TextEncoder().encode(quotedCommand.join(' ')).byteLength > ANDROID_SHELL_COMMAND_MAX_BYTES
) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
`The Android launch command is longer than ${ANDROID_SHELL_COMMAND_MAX_BYTES} bytes after shell quoting. Shorten "launch_args" or "open_url".`
);
}
return quotedCommand;
}
function quoteAndroidShellArgument(argument: string): string {
if (/^[\w@%+=:,./-]+$/.test(argument)) {
return argument;
}
return `'${argument.replaceAll("'", `'\\''`)}'`;
}
+1 -1
View File
@@ -252,7 +252,7 @@
},
"update": {
"node": {
"version": "22.23.3"
"version": "22.23.4"
},
"s3": {
"templates": {
+1 -1
View File
@@ -33,7 +33,7 @@ const PROJECT_PAGES: Record<string, string> = {
export default class Browse extends EasCommand {
static override description =
'Transition from the terminal to the web browser to view and interact with your project on https://expo.dev';
'transition from the terminal to the web browser to view and interact with your project on https://expo.dev';
static override args = {
page: Args.string({
@@ -13,7 +13,7 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJobReceiptAsync';
export default class ChannelDelete extends EasCommand {
static override description = 'Delete a channel';
static override description = 'delete a channel';
static override args = {
name: Args.string({
@@ -50,7 +50,7 @@ type ChannelRolloutArgsAndFlags = {
Partial<CreateRolloutNonInteractiveOptions>;
export default class ChannelRollout extends EasCommand {
static override description = 'Roll a new branch out on a channel incrementally.';
static override description = 'roll a new branch out on a channel incrementally';
static override args = {
channel: Args.string({
@@ -7,7 +7,7 @@ import { SetUpBuildCredentialsCommandAction } from '../../credentials/manager/Se
import { selectPlatformAsync } from '../../platform';
export default class InitializeBuildCredentials extends EasCommand {
static override description = 'Set up credentials for building your project.';
static override description = 'set up credentials for building your project';
static override flags = {
platform: Flags.option({
@@ -22,7 +22,7 @@ interface DeployAliasDeleteFlags {
}
export default class WorkerAliasDelete extends EasCommand {
static override description = 'Delete deployment aliases.';
static override description = 'delete deployment aliases';
static override aliases = ['worker:alias:delete'];
static override state = 'preview';
@@ -39,7 +39,7 @@ interface RawDeployAliasFlags {
}
export default class WorkerAlias extends EasCommand {
static override description = 'Assign deployment aliases.';
static override description = 'assign deployment aliases';
static override aliases = ['worker:alias', 'deploy:promote'];
static override state = 'preview';
@@ -23,7 +23,7 @@ interface RawDeployDeleteFlags {
}
export default class WorkerDelete extends EasCommand {
static override description = 'Delete a deployment.';
static override description = 'delete a deployment';
static override aliases = ['worker:delete'];
static override state = 'preview';
+1 -1
View File
@@ -100,7 +100,7 @@ async function withSuppressedOutputAsync<T>(fn: () => Promise<T>): Promise<T> {
/* eslint-enable no-console */
export default class Go extends EasCommand {
static override description = 'Create a custom Expo Go and submit to TestFlight';
static override description = 'create a custom Expo Go and submit to TestFlight';
static override hidden = true;
static override flags = {
@@ -22,7 +22,7 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class ObserveEvent extends EasCommand {
static override description =
'display a single Observe event (metric, log, or error) by its ID. IDs are included in event data when the `--json` flag is passed to `eas observe:session`, `eas observe:metrics`, or `eas observe:events`.';
'display a single Observe event (metric, log, or error) by its ID. IDs are included in event data when the `--json` flag is passed to `eas observe:session`, `eas observe:metrics`, or `eas observe:events`';
static override args = {
id: Args.string({
@@ -36,7 +36,7 @@ const DEFAULT_EVENTS_LIMIT = 10;
export default class ObserveEvents extends EasCommand {
static override description =
'display individual events emitted by the app via `logEvent`, filtered by the event name in the argument. With no arguments, a list of the available event names and associated event counts is returned.';
'display individual events emitted by the app via `logEvent`, filtered by the event name in the argument. With no arguments, a list of the available event names and associated event counts is returned';
static override args = {
eventName: Args.string({
+1 -1
View File
@@ -134,7 +134,7 @@ export default class New extends EasCommand {
static override aliases = ['new'];
static override description =
'Create a new project configured with Expo Application Services (EAS)';
'create a new project configured with Expo Application Services (EAS)';
static override args = {
path: Args.string({
@@ -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,79 @@ 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', {
signal: expect.any(AbortSignal),
});
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();
});
});
@@ -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,
@@ -57,6 +59,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 +108,8 @@ 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 () => {});
@@ -191,6 +196,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({
@@ -1990,6 +1996,292 @@ 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<string, unknown>[] {
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<string, unknown>[]): void {
mockFetch.mockResolvedValue({
text: async () => logLines.map(line => JSON.stringify(line)).join('\n'),
} as unknown as Awaited<ReturnType<typeof fetch>>);
}
beforeEach(() => {
mockByIdAsync.mockResolvedValue(
makeDeviceRunSession({ status: DeviceRunSessionStatus.Errored, remoteConfig: null })
);
});
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 () => {
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: [logFileUrl],
});
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(mockFetch).not.toHaveBeenCalled();
});
it('reads the failed step error from the job run logs for an unknown error', async () => {
mockJobRunErrorsByIdAsync.mockResolvedValue({
id: 'job-123',
errors: [unknownError],
logFileUrls: [logFileUrl],
});
mockLogFile([
{
level: 50,
buildStepId: 'step-001',
buildStepDisplayName: 'Install application',
msg: 'Retrying install',
err: { message: 'Retrying install' },
},
{
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']);
await expect(command.runAsync()).rejects.toThrow(
expect.objectContaining({
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('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('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']),
...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: B\nFinish recordings: D`,
})
);
});
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('keeps the job run error when no step failed, ignoring errors that steps ignored', async () => {
mockJobRunErrorsByIdAsync.mockResolvedValue({
id: 'job-123',
errors: [
{
errorCode: 'UNKNOWN_ERROR',
message:
'Unknown error. See logs of the Complete job build phase for more information.',
docsUrl: null,
},
],
logFileUrls: [logFileUrl],
});
mockLogFile([
{
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}\nUnknown error. See logs of the Complete job build phase for more information.`,
})
);
});
it('falls back to the unknown error message when the job run logs cannot be read', async () => {
mockJobRunErrorsByIdAsync.mockResolvedValue({
id: 'job-123',
errors: [unknownError],
logFileUrls: [logFileUrl],
});
mockFetch.mockRejectedValue(new Error('Request failed: 503 (Service Unavailable)'));
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 () => {
mockByIdAsync
.mockResolvedValueOnce(makeDeviceRunSession({ remoteConfig: null }))
@@ -2289,6 +2581,31 @@ 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())
@@ -35,7 +35,7 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class SimulatorControl extends EasCommand {
static override aliases = ['sim:control'];
static override description =
'[EXPERIMENTAL] run a serve-sim action on a remote iOS simulator session on EAS. Run without an action to list the actions the session supports. To save a screenshot locally, run `eas simulator:control screenshot.capture --output ./screenshot.png`.';
'[EXPERIMENTAL] run a serve-sim action on a remote iOS simulator session on EAS. Run without an action to list the actions the session supports. To save a screenshot locally, run `eas simulator:control screenshot.capture --output ./screenshot.png`';
static override strict = false;
static override examples = [
+25 -2
View File
@@ -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,
@@ -30,7 +39,7 @@ type DeviceRunSessionArtifact = DeviceRunSessionById['artifacts'][number];
export default class SimulatorGet extends EasCommand {
static override aliases = ['sim:get'];
static override description =
"[EXPERIMENTAL] get info about a remote simulator session on EAS by its simulator session ID. Preview links follow the session's saved sharing preference.";
"[EXPERIMENTAL] get info about a remote simulator session on EAS by its simulator session ID. Preview links follow the session's saved sharing preference";
static override flags = {
id: Flags.string({
@@ -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) {
@@ -41,6 +41,10 @@ import {
writeSimulatorEnvAsync,
} from '../../simulator/env';
import { resolveExpoGoSdkVersionAsync } from '../../simulator/expoGo';
import {
fetchSimulatorSessionErrorsAsync,
formatSimulatorSessionError,
} from '../../simulator/jobRunErrors';
import {
simulatorRequestFailureReason,
simulatorRequestProperties,
@@ -79,7 +83,7 @@ export default class Simulator extends EasCommand {
static override aliases = ['simulator:start', 'sim:start'];
static override hiddenAliases = ['sim'];
static override description =
'[EXPERIMENTAL] start a remote simulator session on EAS and get instructions to connect to it. The default browser preview link requires sign-in.';
'[EXPERIMENTAL] start a remote simulator session on EAS and get instructions to connect to it. The default browser preview link requires sign-in';
static override flags = {
platform: Flags.option({
@@ -461,7 +465,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)}`
);
}
@@ -472,7 +476,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)}`
);
}
@@ -634,6 +638,18 @@ 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,
signal: AbortSignal
): Promise<string> {
const errors = await fetchSimulatorSessionErrorsAsync(graphqlClient, deviceRunSessionId, {
signal,
});
return errors.map(error => `\n${formatSimulatorSessionError(error)}`).join('');
}
async function resolvePlatformAsync(
platform: PlatformFlagValue | undefined,
nonInteractive: boolean
@@ -715,7 +731,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 ||
@@ -9,7 +9,7 @@ import { promptAsync } from '../../prompts';
export default class WorkflowRunCancel extends EasCommand {
static override description =
'Cancel one or more workflow runs. If no workflow run IDs are provided, you will be prompted to select IN_PROGRESS runs to cancel.';
'cancel one or more workflow runs. If no workflow run IDs are provided, you will be prompted to select IN_PROGRESS runs to cancel';
static override strict = false;
@@ -9,7 +9,7 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class WorkflowList extends EasCommand {
static override hidden = true;
static override description = 'List workflows for the current project';
static override description = 'list workflows for the current project';
static override flags = {
...EasJsonOnlyFlag,
@@ -26,7 +26,7 @@ function printLogsForAllSteps(logs: JobLogs): void {
export default class WorkflowLogView extends EasCommand {
static override description =
'view logs for a workflow run, selecting a job and step to view. You can pass in either a workflow run ID or a job ID. If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.';
'view logs for a workflow run, selecting a job and step to view. You can pass in either a workflow run ID or a job ID. If no ID is passed in, you will be prompted to select from recent workflow runs for the current project';
static override flags = {
...EasJsonOnlyFlag,
@@ -93,7 +93,7 @@ export function resolveWorkflowRunSshInput({
export default class WorkflowRun extends EasCommand {
static override description =
'run an EAS workflow. The entire local project directory will be packaged and uploaded to EAS servers for the workflow run, unless the --ref flag is used.';
'run an EAS workflow. The entire local project directory will be packaged and uploaded to EAS servers for the workflow run, unless the --ref flag is used';
static override args = {
file: Args.string({
@@ -32,7 +32,7 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class WorkflowStatus extends EasCommand {
static override description =
'show the status of an existing workflow run. If no run ID is provided, you will be prompted to select from recent workflow runs for the current project.';
'show the status of an existing workflow run. If no run ID is provided, you will be prompted to select from recent workflow runs for the current project';
static override args = {
WORKFLOW_RUN_ID: Args.string({
@@ -59,7 +59,7 @@ const processedOutputs: (job: WorkflowJobResult) => FormatFieldsItem[] = job =>
export default class WorkflowView extends EasCommand {
static override description =
'view details for a workflow run, including jobs. If no run ID is provided, you will be prompted to select from recent workflow runs for the current project.';
'view details for a workflow run, including jobs. If no run ID is provided, you will be prompted to select from recent workflow runs for the current project';
static override flags = {
...EasJsonOnlyFlag,
+7
View File
@@ -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<string>, errors: Array<{ __typename?: 'JobRunError', errorCode: string, message: string, docsUrl?: string | null }> } | null } } };
export type DeviceRunSessionByIdQueryVariables = Exact<{
deviceRunSessionId: Scalars['ID']['input'];
}>;
@@ -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,52 @@ 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
}
logFileUrls
}
}
}
}
`,
{ 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 +273,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();
}
});
}
@@ -0,0 +1,142 @@
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<void> {
requests = 0;
closedRequests = 0;
server = http.createServer((request, response) => {
requests += 1;
request.socket.on('close', () => {
closedRequests += 1;
});
handler(request, response);
});
await new Promise<void>(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',
logFileUrls: ['https://logs.example.com/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);
});
});
@@ -0,0 +1,479 @@
import http from 'http';
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');
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;
let Log: typeof LogType;
beforeAll(() => {
delete process.env.https_proxy;
jest.isolateModules(() => {
jobRunErrors = jest.requireActual('../jobRunErrors');
Log = jest.requireMock('../../log').default;
});
});
// 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://[email protected]/acme/app failed',
stack: 'Error: git clone https://[email protected]/acme/app failed',
},
},
];
expect(jobRunErrors.selectFailedStepErrors(phaseFailureLog)).toEqual([]);
});
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([]);
});
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',
]);
});
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', () => {
let server: http.Server;
let closedRequests: number;
async function startServerAsync(handler: http.RequestListener): Promise<string> {
closedRequests = 0;
server = http.createServer((request, response) => {
request.socket.on('close', () => {
closedRequests += 1;
});
handler(request, response);
});
await new Promise<void>(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<void> {
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([
{
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('strips escape sequences and control characters from job run error messages', async () => {
server = http.createServer();
await new Promise<void>(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) => {
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<void>(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<void>(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([]);
});
});
@@ -0,0 +1,172 @@
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;
message: string;
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 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 = 4;
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,
{
signal,
queryTimeoutMs = DEFAULT_QUERY_TIMEOUT_MS,
logDownloadTimeoutMs = DEFAULT_LOG_DOWNLOAD_TIMEOUT_MS,
maxLogSizeBytes = DEFAULT_MAX_LOG_SIZE_BYTES,
}: FetchSimulatorSessionErrorsOptions = {}
): Promise<SimulatorSessionError[]> {
if (signal?.aborted) {
return [];
}
let jobRun;
try {
jobRun = await DeviceRunSessionQuery.jobRunErrorsByIdAsync(graphqlClient, deviceRunSessionId, {
signal: withTimeout(signal, queryTimeoutMs),
});
} 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.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) {
return logErrors.map(message => ({ errorCode: errors[0].errorCode, message, docsUrl: null }));
}
}
return errors.map(({ errorCode, message, docsUrl }) => ({
errorCode,
message: formatMessage(message),
docsUrl,
}));
}
export function formatSimulatorSessionError(error: SimulatorSessionError): string {
return `${error.message}${error.docsUrl ? ` ${learnMore(error.docsUrl)}` : ''}`;
}
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<string[]> {
// 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, {
size: maxLogSizeBytes,
signal: AbortSignal.any([signal, controller.signal]),
});
const { logLines } = parseLogLines(await response.text());
return selectFailedStepErrors(logLines);
} catch (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();
}
}
// 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 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);
}
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(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 && text) {
selected = [...stderrCause, line];
stderrCause = [];
}
}
return selected;
}
function formatLogLine(line: JobRunLogLine): string {
return formatMessage(
line.buildStepDisplayName ? `${line.buildStepDisplayName}: ${line.msg}` : String(line.msg)
);
}
// 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;
}
+15
View File
@@ -42,3 +42,18 @@ Github action builds a docker image for every PR with any change in `infra/eas-b
### VM template
Github action builds a new vm template for every PR with any change in `infra/eas-build-worker-images/ios/{{ templateName }}`.
### Packaged startup
Existing services run `src/services/worker/dist/main.js`. In the worker archive,
that path points to `bin/bootstrap.cjs`. The bootstrap loads only Node built-ins
and starts `bin/start-worker.sh`. The shell then uses `exec` to start the normal
worker with the original Node binary, options, and arguments.
Put setup that must precede worker imports or Node startup options in the shell.
The bootstrap forwards SIGINT, SIGTERM, IPC messages, and the worker exit status.
It remains the supervised parent process; test service shutdown on both platforms
before deployment. A failure in the startup shell prevents the worker from
loading. Direct execution of `packages/worker/dist/main.js` bypasses this hook.
Run `yarn workspace @expo/worker test:startup` for the process-level checks.
+53
View File
@@ -0,0 +1,53 @@
// This entry point must only load Node built-ins before the startup shell runs.
const { spawn } = require('node:child_process');
const path = require('node:path');
const worker = spawn(
'/bin/bash',
[
path.join(__dirname, 'start-worker.sh'),
process.execPath,
...process.execArgv,
path.join(__dirname, '../dist/main.js'),
...process.argv.slice(2),
],
{ stdio: process.send ? ['inherit', 'inherit', 'inherit', 'ipc'] : 'inherit' }
);
const signals = ['SIGINT', 'SIGTERM'];
for (const signal of signals) {
process.on(signal, () => worker.kill(signal));
}
// Retain the readiness channel used by process managers in development.
if (process.send) {
worker.on('message', message => {
if (process.connected) {
process.send(message, () => {});
}
});
process.on('message', message => {
if (worker.connected) {
worker.send(message, () => {});
}
});
process.on('disconnect', () => {
if (worker.connected) {
worker.disconnect();
}
});
}
worker.on('error', error => {
process.stderr.write(`Worker startup failed: ${error.stack ?? error}\n`, () => process.exit(1));
});
worker.on('exit', (code, signal) => {
if (signal) {
for (const forwarded of signals) {
process.removeAllListeners(forwarded);
}
process.kill(process.pid, signal);
} else {
process.exit(code ?? 1);
}
});
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
# Startup setup belongs here, before the normal worker's Node process starts.
exec "$@"
+3 -2
View File
@@ -11,11 +11,12 @@
"compile": "tsc -p tsconfig.build.json",
"prepublishOnly": "npm run build",
"test": "yarn test:unit",
"test:unit": "./resources/jest --config jest.config.unit.ts",
"test:unit": "yarn test:startup && ./resources/jest --config jest.config.unit.ts",
"test:unit:watch": "./resources/jest --config jest.config.unit.ts --watch",
"test:integration": "./resources/jest --runInBand --config jest.config.integration.ts",
"test:integration:watch": "./resources/jest --runInBand --config jest.config.integration.ts --watchAll",
"typecheck": "tsc"
"typecheck": "tsc",
"test:startup": "node --test tests/*.test.cjs"
},
"dependencies": {
"@babel/core": "7.24.7",
+2 -2
View File
@@ -120,10 +120,10 @@ fi
# Create backward-compatible symlink for orchestrator
# The orchestrator expects ./src/services/worker/dist/main.js
# but the new structure has ./packages/worker/dist/main.js
# Route it through the packaged startup wrapper before loading worker modules.
# We're only symlinking the specific file to discover any other legacy path usage
mkdir -p "$target_root_dir/src/services/worker/dist"
ln -s ../../../../packages/worker/dist/main.js "$target_root_dir/src/services/worker/dist/main.js"
ln -s ../../../../packages/worker/bin/bootstrap.cjs "$target_root_dir/src/services/worker/dist/main.js"
tar zcf $OUTPUT_FILE -C $target_root_dir .
@@ -185,6 +185,100 @@ describe('logger', () => {
expect(logs.map(log => log.msg)).toEqual(['token: ***************, prefix: ***']);
});
describe('structured fields', () => {
async function logWithSecretAsync(
log: (
logger: Awaited<ReturnType<typeof createBuildLoggerWithSecretsFilter>>['logger']
) => void
): Promise<any[]> {
const { logger, outputStream } = await createBuildLoggerWithSecretsFilter({
environmentSecrets: [
{ name: 'TOKEN', value: 'secret-token', type: EnvironmentSecretType.STRING },
{ name: 'PIN', value: '123456789', type: EnvironmentSecretType.STRING },
],
});
const logs: any[] = [];
outputStream.pipe(
new Writable({
objectMode: true,
write(chunk: any, _encoding: BufferEncoding, callback: TransformCallback) {
logs.push(chunk);
callback(null, chunk);
},
})
);
log(logger);
await waitForStreamFlush();
// The upload streams send JSON.stringify(record).
for (const record of logs) {
expect(JSON.stringify(record)).not.toContain('secret-token');
expect(JSON.stringify(record)).not.toContain('123456789');
}
return logs;
}
it('masks errors, including when the message is empty', async () => {
const err = new Error('git clone https://[email protected]/acme/app failed');
const logs = await logWithSecretAsync(logger => {
logger.error({ err }, '');
logger.error({ err });
});
expect(logs).toHaveLength(2);
for (const log of logs) {
expect(log.err.message).toBe('git clone https://************@github.com/acme/app failed');
expect(log.err.stack).toContain(
'git clone https://************@github.com/acme/app failed'
);
}
expect(logs[0].msg).toBe('');
expect(logs[1].msg).toBe('git clone https://************@github.com/acme/app failed');
});
it('masks other fields, keys and numbers, and keeps metadata fields', async () => {
const job = { env: { TOKEN_COPY: 'secret-token', 'secret-token': 'key', PIN: 123456789 } };
const logs = await logWithSecretAsync(logger => {
logger.info({ job, source: 'stdout', buildStepId: 'step-1' }, 'Builder is ready');
});
expect(logs[0].job).toEqual({
env: { TOKEN_COPY: '************', '************': 'key', PIN: '*********' },
});
expect(logs[0].source).toBe('stdout');
expect(logs[0].buildStepId).toBe('step-1');
// The caller's object, which bunyan also hands to other streams, is not changed.
expect(job.env.TOKEN_COPY).toBe('secret-token');
});
it('masks what a toJSON method returns', async () => {
const logs = await logWithSecretAsync(logger => {
logger.error(
{
err: {
token: 'secret-token',
toJSON: () => ({ token: 'secret-token', at: new Date(0) }),
},
},
'failed'
);
});
expect(logs[0].err).toEqual({ token: '************', at: '1970-01-01T00:00:00.000Z' });
});
it('truncates deeply nested values instead of uploading them unmasked', async () => {
let nested: any = { token: 'secret-token' };
for (let index = 0; index < 30; index++) {
nested = { nested };
}
const logs = await logWithSecretAsync(logger => {
logger.error({ err: nested }, 'failed');
});
expect(JSON.stringify(logs[0].err)).toContain('[Truncated]');
});
});
it('adds logId to each log', async () => {
const { logger, outputStream } = await createBuildLoggerWithSecretsFilter({});
+74 -17
View File
@@ -59,30 +59,45 @@ export class WorkerLogBuffer extends Writable implements LogBuffer {
}
}
// Bunyan's own fields and fields that identify a log line, which never hold user data.
const UNMASKED_FIELDS = new Set([
'name',
'pid',
'level',
'time',
'v',
'phase',
'source',
'marker',
'result',
'buildStepId',
'buildStepInternalId',
]);
const MAX_MASKED_DEPTH = 20;
function createTransformStream(secretPatterns: ReadonlySet<string>): Transform {
return new Transform({
readableObjectMode: true,
writableObjectMode: true,
transform(_chunk: any, _encoding: BufferEncoding, callback: TransformCallback) {
let chunk = _chunk;
if (chunk && typeof chunk === 'object') {
// Remove hostname from logs since we don't need it.
const { hostname: _hostname, ...rest } = chunk;
chunk = {
...rest,
// Add logId to each log.
logId: uuidv7(),
};
if (!_chunk || typeof _chunk !== 'object') {
callback(null, _chunk);
return;
}
if (chunk && typeof chunk === 'object' && chunk.msg) {
callback(null, {
...chunk,
msg: maskSecrets(chunk.msg as string, secretPatterns),
});
} else {
callback(null, chunk);
// Remove hostname from logs since we don't need it. Bunyan shares the record with other
// streams, so build a copy instead of changing it.
const { hostname: _hostname, ...rest } = _chunk;
const chunk: Record<string, unknown> = {};
for (const [key, value] of Object.entries(rest)) {
// Secrets can be in any field, such as a serialized error's message and stack.
chunk[key] =
secretPatterns.size === 0 || UNMASKED_FIELDS.has(key)
? value
: maskSecretsInValue(value, secretPatterns);
}
// Add logId to each log.
chunk.logId = uuidv7();
callback(null, chunk);
},
});
}
@@ -101,6 +116,48 @@ function maskSecrets(message: string, secrets: Iterable<string>): string {
return characters.join('');
}
function maskSecretsInValue(value: unknown, secrets: ReadonlySet<string>): unknown {
if (value === null || typeof value !== 'object') {
return maskSecretsInJsonValue(value, secrets, 0);
}
// Mask what the upload streams will serialize, so a toJSON method can't bring back the original.
let json: unknown;
try {
json = JSON.parse(JSON.stringify(value));
} catch {
return '[Unserializable]';
}
return maskSecretsInJsonValue(json, secrets, 0);
}
function maskSecretsInJsonValue(
value: unknown,
secrets: ReadonlySet<string>,
depth: number
): unknown {
if (typeof value === 'string') {
return maskSecrets(value, secrets);
}
if (typeof value === 'number') {
return secrets.has(String(value)) ? maskSecrets(String(value), secrets) : value;
}
if (value === null || typeof value !== 'object') {
return value;
}
if (depth >= MAX_MASKED_DEPTH) {
return '[Truncated]';
}
if (Array.isArray(value)) {
return value.map(item => maskSecretsInJsonValue(item, secrets, depth + 1));
}
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
maskSecrets(key, secrets),
maskSecretsInJsonValue(item, secrets, depth + 1),
])
);
}
function createDiscardStream(): Writable {
return new Writable({
objectMode: true,
+100
View File
@@ -0,0 +1,100 @@
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const { once } = require('node:events');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { test } = require('node:test');
function archive(t, main, shell) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'worker-startup-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const worker = path.join(root, 'packages/worker');
fs.mkdirSync(path.join(worker, 'dist'), { recursive: true });
fs.cpSync(path.join(__dirname, '../bin'), path.join(worker, 'bin'), { recursive: true });
fs.writeFileSync(path.join(worker, 'dist/main.js'), main);
if (shell) {
fs.writeFileSync(path.join(worker, 'bin/start-worker.sh'), shell);
}
// Exercise the path that existing images run, including Node's symlink resolution.
const legacy = path.join(root, 'src/services/worker/dist/main.js');
fs.mkdirSync(path.dirname(legacy), { recursive: true });
fs.symlinkSync('../../../../packages/worker/bin/bootstrap.cjs', legacy);
return legacy;
}
function launch(t, entry, args = []) {
const child = spawn(process.execPath, [entry, ...args], {
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
});
t.after(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGKILL');
}
});
return child;
}
test(
'startup settings reach the worker before its first module runs',
{ timeout: 10000 },
async t => {
const entry = archive(
t,
'process.send({ ready: true, args: process.argv.slice(2), setting: process.env.STARTUP_TEST }); process.on("message", () => process.exit(7));',
'#!/bin/bash\nset -euo pipefail\nexport STARTUP_TEST=installed\nexec "$@"\n'
);
const child = launch(t, entry, ['argument with spaces']);
const exited = once(child, 'exit');
assert.deepEqual((await once(child, 'message'))[0], {
ready: true,
args: ['argument with spaces'],
setting: 'installed',
});
child.send('stop');
assert.deepEqual(await exited, [7, null]);
}
);
for (const signal of ['SIGINT', 'SIGTERM']) {
test(`forwards ${signal} and preserves termination status`, { timeout: 10000 }, async t => {
const entry = archive(t, 'process.send("ready"); setInterval(() => {}, 1000);');
const child = launch(t, entry);
const exited = once(child, 'exit');
await once(child, 'message');
child.kill(signal);
assert.deepEqual(await exited, [null, signal]);
});
}
test('startup failure prevents loading the worker', { timeout: 10000 }, async t => {
const entry = archive(
t,
'process.send("must not start");',
'#!/bin/bash\nset -euo pipefail\nexit 42\nexec "$@"\n'
);
const child = launch(t, entry);
const messages = [];
child.on('message', message => messages.push(message));
assert.deepEqual(await once(child, 'exit'), [42, null]);
assert.deepEqual(messages, []);
});
test('runs without IPC and retains stdout, stderr, and exit code', { timeout: 10000 }, async t => {
const entry = archive(
t,
'console.log("worker output"); console.error("worker error"); process.exit(9);'
);
const child = spawn(process.execPath, [entry], { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
child.stdout.on('data', data => {
stdout += data;
});
child.stderr.on('data', data => {
stderr += data;
});
assert.deepEqual(await once(child, 'close'), [9, null]);
assert.equal(stdout, 'worker output\n');
assert.equal(stderr, 'worker error\n');
});