mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 03:38:08 -10:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c64f922588 | ||
|
|
a0fe3ff2c3 | ||
|
|
fb383d818b | ||
|
|
c07c5edba9 | ||
|
|
56646b7436 | ||
|
|
f775b10549 | ||
|
|
c756c5d0b5 | ||
|
|
9f674b1d19 | ||
|
|
6f33a4d2a4 |
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 @@
|
||||
---
|
||||
'eas-cli': none
|
||||
---
|
||||
|
||||
Make command descriptions in `eas --help` consistently lowercase without trailing periods.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "25.2.0",
|
||||
"PRODUCTION": "25.2.0"
|
||||
"STAGING": "25.3.0",
|
||||
"PRODUCTION": "25.3.0"
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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("'", `'\\''`)}'`;
|
||||
}
|
||||
@@ -252,7 +252,7 @@
|
||||
},
|
||||
"update": {
|
||||
"node": {
|
||||
"version": "22.23.3"
|
||||
"version": "22.23.4"
|
||||
},
|
||||
"s3": {
|
||||
"templates": {
|
||||
|
||||
@@ -3,8 +3,56 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import nock from 'nock';
|
||||
|
||||
import { computeFileMd5Base64Async } from '../uploads';
|
||||
import { computeFileMd5Base64Async, uploadWithPresignedPostWithRetryAsync } from '../uploads';
|
||||
|
||||
describe(uploadWithPresignedPostWithRetryAsync, () => {
|
||||
const presignedPost = {
|
||||
url: 'https://storage.example.com/upload',
|
||||
fields: { key: 'object-key' },
|
||||
};
|
||||
const contents = Array.from({ length: 50000 }, (_, i) => `line ${i}\n`).join('');
|
||||
let tmpDir: string;
|
||||
let file: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-cli-presigned-post-test-'));
|
||||
file = path.join(tmpDir, 'bundle.js');
|
||||
await fs.writeFile(file, contents);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.remove(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
it('sends the whole file and reports activity while it uploads', async () => {
|
||||
let body = '';
|
||||
nock('https://storage.example.com')
|
||||
.post('/upload', requestBody => {
|
||||
body = requestBody;
|
||||
return true;
|
||||
})
|
||||
.reply(204);
|
||||
const onAssetUploadBegin = jest.fn();
|
||||
|
||||
await uploadWithPresignedPostWithRetryAsync(file, presignedPost, onAssetUploadBegin);
|
||||
|
||||
expect(body).toContain('filename="bundle.js"');
|
||||
expect(body).toContain(contents);
|
||||
expect(onAssetUploadBegin.mock.calls.length).toBeGreaterThan(2);
|
||||
expect(onAssetUploadBegin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ file, fileSize: contents.length, uploadedBytes: 0 })
|
||||
);
|
||||
expect(onAssetUploadBegin).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ file, fileSize: contents.length, uploadedBytes: contents.length })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(computeFileMd5Base64Async, () => {
|
||||
let tmpDir: string;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -61,6 +61,7 @@ jest.mock('../../../graphql/queries/UpdateQuery');
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
|
||||
jest.mock('../../../ora', () => ({
|
||||
isSpinnerEnabled: () => false,
|
||||
ora: () => ({
|
||||
start: () => ({ succeed: () => {}, fail: () => {}, stop: () => {} }),
|
||||
}),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { EasJson, EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
|
||||
import { Errors, Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import nullthrows from 'nullthrows';
|
||||
import path from 'path';
|
||||
|
||||
import { ensureBranchExistsAsync } from '../../branch/queries';
|
||||
import { ensureRepoIsCleanAsync } from '../../build/utils/repository';
|
||||
@@ -28,7 +29,7 @@ import {
|
||||
} from '../../graphql/generated';
|
||||
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
|
||||
import Log, { learnMore, link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { isSpinnerEnabled, ora } from '../../ora';
|
||||
import { RequestedPlatform } from '../../platform';
|
||||
import { maybeUploadAssetMapAsync } from '../../project/maybeUploadAssetMapAsync';
|
||||
import {
|
||||
@@ -56,6 +57,7 @@ import {
|
||||
resolveInputDirectoryAsync,
|
||||
uploadAssetsAsync,
|
||||
} from '../../project/publish';
|
||||
import { createUploadProgressReporter } from '../../project/uploadProgressReporter';
|
||||
import { resolveWorkflowPerPlatformAsync } from '../../project/workflow';
|
||||
import { resolveUpdateGroupsSupersedingActiveRolloutsAsync } from '../../update/active-rollout';
|
||||
import { ensureEASUpdateIsConfiguredAsync } from '../../update/configure';
|
||||
@@ -389,13 +391,41 @@ export default class UpdatePublish extends EasCommand {
|
||||
// - Start with NO_ACTIVITY_TIMEOUT. 180 seconds is chosen because the cloud function that processes
|
||||
// uploaded assets has a timeout of 60 seconds and uploading can take some time on a slow connection.
|
||||
// - Each time one or more assets reports as ready, reset the timeout.
|
||||
// - Each time an asset upload begins, reset the timeout. This includes retries.
|
||||
// - Each time an asset upload begins or sends more bytes, reset the timeout. This includes retries.
|
||||
// - Start upload. Internally, uploadAssetsAsync uploads them all first and then checks for successful
|
||||
// processing every (5 + n) seconds with a linear backoff of n + 1 second.
|
||||
// - At the same time as upload is started, start timeout checker which checks every 1 second to see
|
||||
// if timeout has been reached. When timeout expires, send a cancellation signal to currently running
|
||||
// upload function call to instruct it to stop uploading or checking for successful processing.
|
||||
const NO_ACTIVITY_TIMEOUT = 180 * 1000; // 180 seconds
|
||||
let uploadCountText = 'Uploading...';
|
||||
let activeUploadLines: string[] = [];
|
||||
let unfinishedAssetPaths: string[] = [];
|
||||
const fullySentAssetPaths = new Set<string>();
|
||||
const updateAssetSpinnerText = (): void => {
|
||||
const processingAssetNames = unfinishedAssetPaths
|
||||
.filter(assetPath => fullySentAssetPaths.has(assetPath))
|
||||
.map(assetPath => path.basename(assetPath));
|
||||
assetSpinner.text = [
|
||||
uploadCountText,
|
||||
...activeUploadLines,
|
||||
...(processingAssetNames.length > 0
|
||||
? [
|
||||
`Waiting for the server to process ${processingAssetNames.slice(0, 3).join(', ')}` +
|
||||
(processingAssetNames.length > 3
|
||||
? ` and ${processingAssetNames.length - 3} more`
|
||||
: ''),
|
||||
]
|
||||
: []),
|
||||
].join('\n');
|
||||
};
|
||||
const reportUploadProgress = createUploadProgressReporter({
|
||||
isInteractive: isSpinnerEnabled(),
|
||||
onActiveUploadsChanged: lines => {
|
||||
activeUploadLines = lines;
|
||||
updateAssetSpinnerText();
|
||||
},
|
||||
});
|
||||
let lastUploadedStorageKeys = new Set<string>();
|
||||
let lastAssetUploadResults: {
|
||||
asset: RawAsset & { storageKey: string };
|
||||
@@ -422,11 +452,23 @@ export default class UpdatePublish extends EasCommand {
|
||||
|
||||
const totalAssets = assetUploadResults.length;
|
||||
const missingAssetCount = assetUploadResults.filter(a => !a.finished).length;
|
||||
assetSpinner.text = `Uploading (${totalAssets - missingAssetCount}/${totalAssets})`;
|
||||
uploadCountText = `Uploading (${totalAssets - missingAssetCount}/${totalAssets})`;
|
||||
unfinishedAssetPaths = assetUploadResults
|
||||
.filter(r => !r.finished)
|
||||
.map(r => r.asset.path);
|
||||
updateAssetSpinnerText();
|
||||
},
|
||||
() => {
|
||||
progress => {
|
||||
// when an upload is retried, reset the timeout as we know this will now need more time
|
||||
timeAtWhichToTimeout = Date.now() + NO_ACTIVITY_TIMEOUT; // reset timeout to NO_ACTIVITY_TIMEOUT
|
||||
if (progress) {
|
||||
if (progress.uploadedBytes >= progress.fileSize) {
|
||||
fullySentAssetPaths.add(progress.file);
|
||||
} else {
|
||||
fullySentAssetPaths.delete(progress.file);
|
||||
}
|
||||
reportUploadProgress(progress);
|
||||
}
|
||||
}
|
||||
),
|
||||
(async () => {
|
||||
|
||||
@@ -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
@@ -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,126 @@
|
||||
import Log from '../../log';
|
||||
import { UploadProgress } from '../../uploads';
|
||||
import { createUploadProgressReporter } from '../uploadProgressReporter';
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
function makeProgress(file: string, uploadedBytes: number, elapsedMs: number): UploadProgress {
|
||||
return { file, fileSize: 7 * MB, uploadedBytes, elapsedMs };
|
||||
}
|
||||
|
||||
describe(createUploadProgressReporter, () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('interactive', () => {
|
||||
it('shows a line for each active upload once it has run for 10 seconds', () => {
|
||||
const onActiveUploadsChanged = jest.fn();
|
||||
const report = createUploadProgressReporter({ isInteractive: true, onActiveUploadsChanged });
|
||||
|
||||
report(makeProgress('/dist/ios.hbc', MB, 5_000));
|
||||
expect(onActiveUploadsChanged).toHaveBeenLastCalledWith([]);
|
||||
|
||||
report(makeProgress('/dist/ios.hbc', MB, 20_000));
|
||||
report(makeProgress('/dist/android.hbc', 6 * MB, 10_000));
|
||||
|
||||
expect(onActiveUploadsChanged).toHaveBeenLastCalledWith([
|
||||
'ios.hbc: 1.0 MB of 7.0 MB, 51.2 KB/s, about 2 minutes left',
|
||||
'android.hbc: 6.0 MB of 7.0 MB, 614 KB/s',
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes an upload once all of its bytes are sent', () => {
|
||||
const onActiveUploadsChanged = jest.fn();
|
||||
const report = createUploadProgressReporter({ isInteractive: true, onActiveUploadsChanged });
|
||||
|
||||
report(makeProgress('/dist/ios.hbc', MB, 20_000));
|
||||
report(makeProgress('/dist/ios.hbc', 7 * MB, 30_000));
|
||||
|
||||
expect(onActiveUploadsChanged).toHaveBeenLastCalledWith([]);
|
||||
});
|
||||
|
||||
it('shows at most 5 uploads', () => {
|
||||
const onActiveUploadsChanged = jest.fn();
|
||||
const report = createUploadProgressReporter({ isInteractive: true, onActiveUploadsChanged });
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
report(makeProgress(`/dist/asset-${i}.png`, MB, 20_000));
|
||||
}
|
||||
|
||||
const lines = onActiveUploadsChanged.mock.calls.at(-1)?.[0];
|
||||
expect(lines).toHaveLength(6);
|
||||
expect(lines[5]).toBe('and 2 more uploads');
|
||||
});
|
||||
|
||||
it('does not log', () => {
|
||||
const warnSpy = jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
const logSpy = jest.spyOn(Log, 'log').mockImplementation(() => {});
|
||||
const report = createUploadProgressReporter({
|
||||
isInteractive: true,
|
||||
onActiveUploadsChanged: () => {},
|
||||
});
|
||||
|
||||
report(makeProgress('/dist/ios.hbc', MB, 20_000));
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-interactive', () => {
|
||||
it('warns when an upload is slow and then logs its progress every minute', () => {
|
||||
let now = 0;
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
const warnSpy = jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
const logSpy = jest.spyOn(Log, 'log').mockImplementation(() => {});
|
||||
const report = createUploadProgressReporter({
|
||||
isInteractive: false,
|
||||
onActiveUploadsChanged: () => {},
|
||||
});
|
||||
|
||||
report(makeProgress('/dist/ios.hbc', MB, 20_000));
|
||||
now = 30_000;
|
||||
report(makeProgress('/dist/ios.hbc', 2 * MB, 50_000));
|
||||
now = 60_000;
|
||||
report(makeProgress('/dist/ios.hbc', 3 * MB, 80_000));
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Uploading ios.hbc (7.0 MB) is slow (51.2 KB/s). It may take about 2 minutes.'
|
||||
);
|
||||
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
'Still uploading ios.hbc: 3.0 MB of 7.0 MB, 38.4 KB/s, about 2 minutes left.'
|
||||
);
|
||||
});
|
||||
|
||||
it('warns again when a retried upload is still slow', () => {
|
||||
const warnSpy = jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
const report = createUploadProgressReporter({
|
||||
isInteractive: false,
|
||||
onActiveUploadsChanged: () => {},
|
||||
});
|
||||
|
||||
report(makeProgress('/dist/ios.hbc', MB, 20_000));
|
||||
report(makeProgress('/dist/ios.hbc', 0, 0));
|
||||
report(makeProgress('/dist/ios.hbc', MB, 20_000));
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not log when an upload is fast', () => {
|
||||
const warnSpy = jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
const logSpy = jest.spyOn(Log, 'log').mockImplementation(() => {});
|
||||
const report = createUploadProgressReporter({
|
||||
isInteractive: false,
|
||||
onActiveUploadsChanged: () => {},
|
||||
});
|
||||
|
||||
report(makeProgress('/dist/ios.hbc', 6 * MB, 10_000));
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
truncateString as truncateUpdateMessage,
|
||||
updatePublishPlatformToAppPlatform,
|
||||
} from '../update/utils';
|
||||
import { PresignedPost, uploadWithPresignedPostWithRetryAsync } from '../uploads';
|
||||
import { PresignedPost, UploadProgress, uploadWithPresignedPostWithRetryAsync } from '../uploads';
|
||||
import {
|
||||
expoCommandAsync,
|
||||
shouldUseVersionedExpoCLI,
|
||||
@@ -496,7 +496,7 @@ export async function uploadAssetsAsync(
|
||||
onAssetUploadResultsChanged: (
|
||||
assetUploadResults: { asset: RawAsset & { storageKey: string }; finished: boolean }[]
|
||||
) => void,
|
||||
onAssetUploadBegin: () => void
|
||||
onAssetUploadBegin: (progress?: UploadProgress) => void
|
||||
): Promise<AssetUploadResult> {
|
||||
let assets: RawAsset[] = [];
|
||||
let platform: keyof FilteredCollectedAssets;
|
||||
@@ -552,7 +552,7 @@ export async function uploadAssetsAsync(
|
||||
// Sign URLs in chunks (endpoint caps at 100), then upload all from one pool so it stays saturated.
|
||||
const uploadBatchAsync = async (
|
||||
assetsToUpload: AssetWithStorageKey[],
|
||||
onBegin: () => void
|
||||
onBegin: (progress?: UploadProgress) => void
|
||||
): Promise<void> => {
|
||||
const signedAssets: { asset: AssetWithStorageKey; specification: string }[] = [];
|
||||
for (const assetChunk of chunk(assetsToUpload, 100)) {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import path from 'path';
|
||||
|
||||
import Log from '../log';
|
||||
import { UploadProgress } from '../uploads';
|
||||
import { formatBytes } from '../utils/files';
|
||||
|
||||
const MIN_UPLOAD_MS_BEFORE_REPORTING = 10_000;
|
||||
const SLOW_UPLOAD_SECONDS_LEFT = 60;
|
||||
const SLOW_UPLOAD_LOG_INTERVAL_MS = 60_000;
|
||||
const MAX_ACTIVE_UPLOAD_LINES = 5;
|
||||
|
||||
function getBytesPerSecond(progress: UploadProgress): number {
|
||||
return progress.uploadedBytes / (progress.elapsedMs / 1000);
|
||||
}
|
||||
|
||||
function getSecondsLeft(progress: UploadProgress): number {
|
||||
return (progress.fileSize - progress.uploadedBytes) / getBytesPerSecond(progress);
|
||||
}
|
||||
|
||||
function formatMinutesLeft(progress: UploadProgress): string {
|
||||
const minutes = Math.ceil(getSecondsLeft(progress) / 60);
|
||||
return minutes === 1 ? '1 minute' : `${minutes} minutes`;
|
||||
}
|
||||
|
||||
function formatUploadProgress(progress: UploadProgress): string {
|
||||
const message =
|
||||
`${path.basename(progress.file)}: ${formatBytes(progress.uploadedBytes)} of ${formatBytes(progress.fileSize)}, ` +
|
||||
`${formatBytes(getBytesPerSecond(progress))}/s`;
|
||||
return getSecondsLeft(progress) > SLOW_UPLOAD_SECONDS_LEFT
|
||||
? `${message}, about ${formatMinutesLeft(progress)} left`
|
||||
: message;
|
||||
}
|
||||
|
||||
function isSlowUpload(progress: UploadProgress): boolean {
|
||||
return (
|
||||
progress.elapsedMs >= MIN_UPLOAD_MS_BEFORE_REPORTING &&
|
||||
getSecondsLeft(progress) > SLOW_UPLOAD_SECONDS_LEFT
|
||||
);
|
||||
}
|
||||
|
||||
export function createUploadProgressReporter({
|
||||
isInteractive,
|
||||
onActiveUploadsChanged,
|
||||
}: {
|
||||
isInteractive: boolean;
|
||||
onActiveUploadsChanged: (lines: string[]) => void;
|
||||
}): (progress: UploadProgress) => void {
|
||||
const activeUploads = new Map<string, UploadProgress>();
|
||||
const lastSlowUploadLogTimes = new Map<string, number>();
|
||||
|
||||
return progress => {
|
||||
if (progress.uploadedBytes === 0) {
|
||||
lastSlowUploadLogTimes.delete(progress.file);
|
||||
}
|
||||
if (progress.uploadedBytes >= progress.fileSize) {
|
||||
activeUploads.delete(progress.file);
|
||||
} else {
|
||||
activeUploads.set(progress.file, progress);
|
||||
}
|
||||
|
||||
if (isInteractive) {
|
||||
const lines = [...activeUploads.values()]
|
||||
.filter(upload => upload.elapsedMs >= MIN_UPLOAD_MS_BEFORE_REPORTING)
|
||||
.map(formatUploadProgress);
|
||||
onActiveUploadsChanged(
|
||||
lines.length > MAX_ACTIVE_UPLOAD_LINES
|
||||
? [
|
||||
...lines.slice(0, MAX_ACTIVE_UPLOAD_LINES),
|
||||
`and ${lines.length - MAX_ACTIVE_UPLOAD_LINES} more uploads`,
|
||||
]
|
||||
: lines
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSlowUpload(progress)) {
|
||||
return;
|
||||
}
|
||||
const lastLogTime = lastSlowUploadLogTimes.get(progress.file);
|
||||
if (lastLogTime === undefined) {
|
||||
Log.warn(
|
||||
`Uploading ${path.basename(progress.file)} (${formatBytes(progress.fileSize)}) is slow ` +
|
||||
`(${formatBytes(getBytesPerSecond(progress))}/s). It may take about ${formatMinutesLeft(progress)}.`
|
||||
);
|
||||
lastSlowUploadLogTimes.set(progress.file, Date.now());
|
||||
} else if (Date.now() - lastLogTime >= SLOW_UPLOAD_LOG_INTERVAL_MS) {
|
||||
Log.log(`Still uploading ${formatUploadProgress(progress)}.`);
|
||||
lastSlowUploadLogTimes.set(progress.file, Date.now());
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -20,6 +20,13 @@ export interface PresignedPost {
|
||||
fields: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface UploadProgress {
|
||||
file: string;
|
||||
fileSize: number;
|
||||
uploadedBytes: number;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
export async function uploadFileAtPathToGCSAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
type: UploadSessionType,
|
||||
@@ -88,7 +95,7 @@ export async function uploadAppScopedFileAtPathToGCSAsync(
|
||||
export async function uploadWithPresignedPostWithRetryAsync(
|
||||
file: string,
|
||||
presignedPost: PresignedPost,
|
||||
onAssetUploadBegin: () => void
|
||||
onAssetUploadBegin: (progress?: UploadProgress) => void
|
||||
): Promise<Response> {
|
||||
return await promiseRetry(
|
||||
async retry => {
|
||||
@@ -96,7 +103,7 @@ export async function uploadWithPresignedPostWithRetryAsync(
|
||||
let response: Response;
|
||||
try {
|
||||
onAssetUploadBegin();
|
||||
response = await uploadWithPresignedPostAsync(file, presignedPost);
|
||||
response = await uploadWithPresignedPostAsync(file, presignedPost, onAssetUploadBegin);
|
||||
} catch (e: any) {
|
||||
return retry(e);
|
||||
}
|
||||
@@ -127,7 +134,8 @@ export async function uploadWithPresignedPostWithRetryAsync(
|
||||
|
||||
async function uploadWithPresignedPostAsync(
|
||||
file: string,
|
||||
presignedPost: PresignedPost
|
||||
presignedPost: PresignedPost,
|
||||
onProgress: (progress: UploadProgress) => void
|
||||
): Promise<Response> {
|
||||
const fileStat = await fs.stat(file);
|
||||
const fileSize = fileStat.size;
|
||||
@@ -135,7 +143,15 @@ async function uploadWithPresignedPostAsync(
|
||||
for (const [fieldKey, fieldValue] of Object.entries(presignedPost.fields)) {
|
||||
form.append(fieldKey, fieldValue);
|
||||
}
|
||||
form.append('file', fs.createReadStream(file), { knownLength: fileSize });
|
||||
const readStream = fs.createReadStream(file);
|
||||
form.append('file', readStream, { knownLength: fileSize });
|
||||
const startTime = Date.now();
|
||||
let uploadedBytes = 0;
|
||||
onProgress({ file, fileSize, uploadedBytes, elapsedMs: 0 });
|
||||
readStream.addListener('data', (chunk: Buffer | string) => {
|
||||
uploadedBytes += Buffer.byteLength(chunk);
|
||||
onProgress({ file, fileSize, uploadedBytes, elapsedMs: Date.now() - startTime });
|
||||
});
|
||||
const formHeaders = form.getHeaders();
|
||||
return await fetch(presignedPost.url, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -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({});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user