Compare commits

..
Author SHA1 Message Date
Vojtech NovakandClaude Fable 5.1 23952dfb64 [build-tools] Free evicted Android log files early and close uncapped files
Serialize each file's I/O through one queue so a fully evicted file is
truncated as soon as its queued writes land, live or finished. Evict the
least recently written file first. Seal uncapped files when their stream
finishes and close the handle even when sealing fails.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-05 22:58:13 +01:00
Vojtech NovakandClaude Fable 5.1 6ab793bbc9 [build-tools] Describe tail retention in the Android log changeset
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-05 21:52:57 +01:00
Vojtech NovakandClaude Fable 5.1 96b3e76c63 [build-tools] Name truncated Android logs as tail retention
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-05 21:52:44 +01:00
Vojtech NovakandClaude Fable 5.1 9f087b164e [build-tools] Retain the last 20 MiB of each Android log source
Each capture file is a fixed-size ring on disk. A source keeps its newest
bytes across launches: a later launch evicts the head of an earlier one, so
the final attempt always keeps its tail. Finalization rewrites each ring in
log order and truncates evicted bytes.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-05 21:52:44 +01:00
Vojtech NovakandClaude Fable 5.1 2d8ad58854 [build-tools] Test the shared upload helper once and drop duplicate log cases
Give uploadDeviceRunSessionFileAsync its own suite for empty, missing,
read-error and signal forwarding, and keep one failure case per caller.
Move the early-503 replay to the HTTP suite with a small file. Fold the
memoization and failure-isolation cases into one, test the truncation
metadata through the mocked upload, and give the unused-pipe case
assertions. Drop the capture-side Infinity case that the factory suite
already covers.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-05 21:25:03 +01:00
Vojtech NovakandClaude Fable 5.1 95bcffb731 [build-tools] Collapse log-source pass-through grids to one case each
The step and CLI layers hand the parsed source list on unchanged, so
the array value never changes the path. Keep one explicit empty-list
case per step, which also guards the disable-logging contract, and one
repeated-flag case for the CLI. Drop capture-factory ordering checks
that cannot fail, since the capture is an argument of the boot call.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-05 21:22:45 +01:00
Vojtech NovakandClaude Fable 5.1 1099ac72ac [build-tools] Trim overlapping startAsync log tests
Drop the launch-gate dimension from the source-selection grid; the gate
and the log sources never meet in one expression. Keep one pipe-routing
case and reduce the output test to filename and detach assertions that
no other case covers. Assert prepareLogcatOutputAsync directly instead
of through the -logcat argument.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-05 21:19:37 +01:00
Vojtech Novak 4dbdaed113 [build-tools] Default session logs safely and isolate FIFO transport 2026-11-05 15:50:50 +01:00
Vojtech Novak 85ea9a2a0b [build-tools] Simplify Android diagnostic collection 2026-11-05 15:18:05 +01:00
Vojtech Novak b03bd85fec [build-tools] Upload finalized Android logs directly 2026-11-05 00:36:55 +01:00
Vojtech Novak 2d5c88d2bc [build-tools] Bound Android session log capture to 20 MiB
Revert the gzip and 100 MiB snapshot changes. Retain the first 20 MiB per selected source across session retries and drain excess output without saving it.
2026-11-05 00:25:44 +01:00
Vojtech Novak 4ce581cf7b [build-tools] Use a 100 MiB Android diagnostic snapshot cap 2026-11-05 00:25:30 +01:00
Vojtech Novak e412e2923a [build-tools] Compress Android diagnostic log snapshots 2026-11-05 00:25:30 +01:00
Vojtech Novak c6231366e1 [build-tools] Apply input defaults in session host tests 2026-11-05 00:25:30 +01:00
Vojtech Novak 04f8663856 [simulator] Record only requested Android session logs 2026-11-05 00:25:30 +01:00
Vojtech Novak 81243a6631 [simulator] Make Android logcat uploads opt-in 2026-11-05 00:25:09 +01:00
Vojtech Novak e38cc79811 [build-tools] Preserve Android log snapshot descriptor ownership 2026-11-05 00:25:09 +01:00
Vojtech Novak b5de1517ac [build-tools] Upload Android session diagnostic logs 2026-11-05 00:25:09 +01:00
William Grosset dd3808f345 [eas-cli] Move Simulators hint date to November 12 (#4817) 2026-11-04 14:39:50 -07:00
Gabe Debes 85c77cc85b [build-tools] Stop web-preview startup when the session is stopped (#4669)
* [build-tools] Observe web preview Stop requests during startup

* test(build-tools): Cover parent cancellation before web preview starts
2026-11-04 09:59:15 -07:00
Gabe Debes b38126abb3 [build-tools] Stop Appium startup when the session is stopped (#4668)
* [build-tools] Observe Appium Stop requests during startup

* test(build-tools): Cover Appium parent cancellation and cleanup errors
2026-11-04 09:59:14 -07:00
Gabe Debes c68978acd8 [build-tools] Stop Argent startup when the session is stopped (#4667)
* [build-tools] Observe Argent Stop requests during startup

* test(build-tools): Cover Argent parent cancellation and cleanup errors
2026-11-04 09:59:14 -07:00
Gabe Debes 2dda59d57c [build-tools] Observe agent-device Stop requests during startup (#4666) 2026-11-04 09:59:13 -07:00
Expo CI 156e38eb54 bump STAGING and PRODUCTION to 27.4.1 in cli-versions.json 2026-11-04 00:28:01 +00:00
53 changed files with 3788 additions and 334 deletions

No files matched your search

@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Stop an agent-device session that is still starting up when it is stopped.
+6
View File
@@ -0,0 +1,6 @@
---
'@expo/build-tools': patch
---
Stop an Appium session that is still starting up when it is stopped.
Report temporary-directory cleanup failures when Stop interrupts Appium installation.
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Stop an Argent session that is still starting up when it is stopped.
+8
View File
@@ -0,0 +1,8 @@
---
'@expo/build-tools': patch
'eas-cli': patch
---
Record and upload Android emulator process output as a simulator session artifact by default. Add explicit source selection and a simulator CLI flag to opt in to recording and uploading device-wide logcat. Unselected sources are not recorded by simulator sessions.
Simulator sessions retain the last 20 MiB of each selected Android log source across launches and retries. Each log file is a fixed-size ring on disk, so the end of the final attempt is always kept; older output is dropped first, and artifacts remain uncompressed.
+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': patch
---
Show the Expo Simulators hint after a successful build from November 12 instead of November 10.
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Stop a web-preview session that is still starting up when it is stopped.
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "27.4.0",
"PRODUCTION": "27.4.0"
"STAGING": "27.4.1",
"PRODUCTION": "27.4.1"
}
@@ -0,0 +1,22 @@
import { type watchDeviceRunSessionStartup } from '../../steps/utils/deviceRunSessionStartup';
import { DeviceRunSessionStoppedError } from '../../steps/utils/remoteDeviceRunSession';
/**
* A `watchDeviceRunSessionStartup` stand-in that never polls. A test calls `stop` on the
* returned watch (`jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value`) to act like
* a Stop request during startup.
*/
export function createStartupWatchMock({
startup,
}: Parameters<typeof watchDeviceRunSessionStartup>[0]): ReturnType<
typeof watchDeviceRunSessionStartup
> & { stop: () => void } {
const stopped = new DeviceRunSessionStoppedError('Stopped during startup.');
return {
stopped,
stop: () => startup.abort(stopped),
isStopped: () => startup.signal.reason === stopped,
completeAsync: jest.fn(async () => startup.signal.throwIfAborted()),
finishAsync: jest.fn(async () => {}),
};
}
@@ -62,6 +62,7 @@ import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/sta
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
import { createStartServeSimLogsBuildFunction } from './functions/startServeSimLogs';
import { createCollectServeSimLogsBuildFunction } from './functions/collectServeSimLogs';
import { createCollectAndroidEmulatorLogsBuildFunction } from './functions/collectAndroidEmulatorLogs';
import { createStartSandboxBuildFunction } from './functions/startSandbox';
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
@@ -129,6 +130,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createStartServeSimMetricsBuildFunction(),
createStartServeSimLogsBuildFunction(),
createCollectServeSimLogsBuildFunction(ctx),
createCollectAndroidEmulatorLogsBuildFunction(ctx),
createStartSandboxBuildFunction(ctx),
createRunAgentBuildFunction(ctx),
createCollectServeSimMetricsBuildFunction(ctx),
@@ -0,0 +1,48 @@
import { BuildRuntimePlatform } from '@expo/steps';
import {
createCustomBuildContextMock,
createGlobalContextMock,
} from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { Sentry } from '../../../sentry';
import { collectAndroidEmulatorLogsAsync } from '../../utils/androidEmulatorLogs';
import { createCollectAndroidEmulatorLogsBuildFunction } from '../collectAndroidEmulatorLogs';
jest.mock('../../../sentry');
jest.mock('../../utils/androidEmulatorLogs');
function createStep(callInputs: Record<string, unknown> = {}) {
const logger = createMockLogger();
const ctx = createCustomBuildContextMock();
const global = createGlobalContextMock({ logger });
const fn = createCollectAndroidEmulatorLogsBuildFunction(ctx);
const step = fn.createBuildStepFromFunctionCall(global, {
callInputs,
env: { DEVICE_RUN_SESSION_ID: 'session' },
});
return { ctx, global, logger, fn, step };
}
it('collects captured logs without a second source selection', async () => {
const { ctx, global, fn, step } = createStep();
expect(fn.supportedRuntimePlatforms).toEqual([BuildRuntimePlatform.LINUX]);
await step.executeAsync();
expect(collectAndroidEmulatorLogsAsync).toHaveBeenCalledWith(ctx, {
global,
deviceRunSessionId: 'session',
logger: expect.anything(),
});
});
it('reports collection failure without changing the session result', async () => {
jest
.mocked(collectAndroidEmulatorLogsAsync)
.mockRejectedValueOnce(new Error('collection failed'));
const { step } = createStep();
await expect(step.executeAsync()).resolves.toBeUndefined();
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not collect Android emulator logs',
expect.any(Error)
);
});
@@ -2,6 +2,7 @@ import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import type { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
@@ -45,6 +46,26 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
waitForFileAsync: jest.fn(),
}));
// Agent-device also polls for Stop while it starts up. That watch sets neither an idle timeout nor
// a duration; keep it pending until startup retires it, and give each lifetime wait its own mock.
const lifetimeWait = jest.fn<
ReturnType<typeof waitForDeviceRunSessionStoppedAsync>,
Parameters<typeof waitForDeviceRunSessionStoppedAsync>
>();
function routeStartupWatch(): void {
lifetimeWait.mockReset();
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async args => {
if ('idleTimeout' in args || 'maxDurationSeconds' in args) {
return await lifetimeWait(args);
}
if (!args.signal?.aborted) {
await new Promise<void>(resolve =>
args.signal?.addEventListener('abort', () => resolve(), { once: true })
);
}
});
}
const finishHost = jest.fn();
const closePreview = jest.fn();
const stopTool = jest.fn();
@@ -56,6 +77,7 @@ const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnTh
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockResolvedValue();
routeStartupWatch();
// The daemon package is external. No installation or daemon process runs in these tests.
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
// Only the emulator logcat folder: the Appium step writes into its own temp folder.
@@ -101,7 +123,7 @@ beforeEach(() => {
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue(events);
jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue(events);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
lifetimeWait.mockResolvedValue(undefined);
});
afterEach(() => {
@@ -118,13 +140,20 @@ describe.each([
corsOrigins?: string[]
) {
const fn = createFunction({} as CustomBuildContext);
const global = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX });
const defaults = new Map(
fn.inputProviders?.map(provider => {
const input = provider(global, 'Test session');
return [input.id, input.defaultValue];
})
);
await fn.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
// Inputs a step does not get default to undefined, like in a real step call.
// Match step execution: omitted inputs use their declared defaults.
inputs: new Proxy(
{
public_preview: { value: publicPreview },
@@ -132,7 +161,7 @@ describe.each([
cors_origins: { value: corsOrigins },
} as Record<string, { value: unknown }>,
{
get: (target, id: string) => target[id] ?? { value: undefined },
get: (target, id: string) => target[id] ?? { value: defaults.get(id) },
}
),
outputs: {},
@@ -163,7 +192,7 @@ describe.each([
expect(finishHost).toHaveBeenCalledTimes(1);
expect(closePreview).not.toHaveBeenCalled();
expect(finishHost.mock.invocationCallOrder[0]).toBeGreaterThan(
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
lifetimeWait.mock.invocationCallOrder[0]
);
expect(stopTool).toHaveBeenCalledTimes(1);
expect(stopEvents).toHaveBeenCalledTimes(1);
@@ -308,7 +337,7 @@ describe.each([
} else if (phase === 'config') {
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
} else {
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
lifetimeWait.mockRejectedValueOnce(error);
}
await expect(runAsync()).rejects.toBe(error);
expect(finishHost).toHaveBeenCalledTimes(1);
@@ -83,6 +83,26 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
// Agent-device also polls for Stop while it starts up. That watch sets neither an idle timeout nor
// a duration; keep it pending until startup retires it, and give each lifetime wait its own mock.
const lifetimeWait = jest.fn<
ReturnType<typeof waitForDeviceRunSessionStoppedAsync>,
Parameters<typeof waitForDeviceRunSessionStoppedAsync>
>();
function routeStartupWatch(): void {
lifetimeWait.mockReset();
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async args => {
if ('idleTimeout' in args || 'maxDurationSeconds' in args) {
return await lifetimeWait(args);
}
if (!args.signal?.aborted) {
await new Promise<void>(resolve =>
args.signal?.addEventListener('abort', () => resolve(), { once: true })
);
}
});
}
const logger = {
info: jest.fn(),
warn: jest.fn(),
@@ -126,6 +146,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockResolvedValue();
routeStartupWatch();
lifetime = registerLocalEgressResources(stopEgress);
stopEgress.mockResolvedValue(undefined);
stopPreview.mockResolvedValue(undefined);
@@ -168,7 +189,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
finishAsync: stopPreview,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
lifetimeWait.mockImplementation(async () => {
expect(lifetime.aborted).toBe(false);
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(1);
});
@@ -181,7 +202,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
it('releases resources without monitoring when the session stopped during startup', async () => {
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValueOnce('stopped');
await run();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
expect(lifetimeWait).not.toHaveBeenCalled();
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
expect(stopPreview).toHaveBeenCalledTimes(1);
expect(stopEgress).toHaveBeenCalledTimes(1);
@@ -190,16 +211,18 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
it('reports credentials and starts monitoring before waiting, then releases resources', async () => {
await run();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
ctx,
deviceRunSessionId: 'session-id',
logger,
remoteConfig: expect.objectContaining({
...fields,
[controllerField]: expect.any(String),
[controllerField === 'previewUrl' ? 'previewToken' : 'webPreviewToken']: 'preview-secret',
}),
});
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
ctx,
deviceRunSessionId: 'session-id',
logger,
remoteConfig: expect.objectContaining({
...fields,
[controllerField]: expect.any(String),
[controllerField === 'previewUrl' ? 'previewToken' : 'webPreviewToken']: 'preview-secret',
}),
})
);
expect(monitorLocalEgressAsync).toHaveBeenCalledWith({
port: handoff.port,
env,
@@ -213,7 +236,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
await stopLocalEgressResourcesAsync(logger);
stopEgress.mockClear();
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(null);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
lifetimeWait.mockResolvedValue(undefined);
await run();
const { remoteConfig } = jest.mocked(uploadRemoteSessionConfigAsync).mock.calls[0][0];
expect(remoteConfig).toHaveProperty(controllerField);
@@ -232,7 +255,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
} else if (phase === 'report') {
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValue(error);
} else if (phase === 'wait') {
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValue(error);
lifetimeWait.mockRejectedValue(error);
} else {
stopPreview.mockRejectedValue(error);
}
@@ -246,11 +269,10 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
);
it('passes cancellation to the monitor and releases the lifetime', async () => {
const abort = new AbortController();
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => abort.abort());
lifetimeWait.mockImplementation(async () => abort.abort());
await run(abort.signal);
expect(monitorLocalEgressAsync).toHaveBeenCalledWith(
expect.objectContaining({ signal: abort.signal })
);
// Agent-device hands the monitor its startup signal, which follows the step signal.
expect(jest.mocked(monitorLocalEgressAsync).mock.calls[0][0].signal?.aborted).toBe(true);
expect(lifetime.aborted).toBe(true);
expect(stopEgress).toHaveBeenCalledTimes(1);
});
@@ -12,6 +12,7 @@ import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEve
import { uploadDeviceRunSessionArtifactAsync } from '../../utils/deviceRunSessionArtifacts';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import {
DeviceRunSessionStoppedError,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
@@ -557,7 +558,8 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
type Device = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['device'];
function startSession(
device: Device | ((tasks: StartupTasks) => Device),
signal?: AbortSignal
signal?: AbortSignal,
onStarted?: () => Promise<void>
) {
const logger = {
info: jest.fn(),
@@ -585,6 +587,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
capture: NO_CAPTURE,
tasks,
device: sessionDevice,
onStarted,
});
}
@@ -857,6 +860,106 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
});
it('reports late host cleanup failure after startup Stop', async () => {
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
const failed = new AggregateError([stopped, new Error('host cleanup failed')]);
let entered!: () => void;
const starting = new Promise<void>(resolve => {
entered = resolve;
});
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
entered();
return await new Promise((_resolve, reject) => {
options.signal!.addEventListener('abort', () => setImmediate(() => reject(failed)), {
once: true,
});
});
});
let tasks!: StartupTasks;
const running = startSession(startup => {
tasks = startup;
return { booted: Promise.resolve(), ready: Promise.resolve() };
});
running.catch(() => {});
await starting;
tasks.abort(stopped);
await expect(running).rejects.toBe(failed);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('drains resources and preserves the explicit startup Stop reason', async () => {
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
let tasks!: StartupTasks;
const session = startSession(startup => {
tasks = startup;
return { booted: Promise.resolve(), ready: tasks.untilAborted(new Promise(() => {})) };
});
session.catch(() => {});
await flushAsync();
tasks.abort(stopped);
await expect(session).rejects.toBe(stopped);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
});
it('does not hide a teardown failure after an explicit startup Stop', async () => {
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
const failed = new Error('daemon teardown failed');
let tasks!: StartupTasks;
const session = startSession(startup => {
tasks = startup;
return { booted: Promise.resolve(), ready: tasks.untilAborted(new Promise(() => {})) };
});
session.catch(() => {});
await flushAsync();
mockDaemonStopAsync.mockRejectedValueOnce(failed);
tasks.abort(stopped);
await expect(session).rejects.toBe(failed);
});
it('retires startup monitoring after reporting and before lifetime waiting', async () => {
const retired = deferred();
const retiring = jest.fn(() => retired.promise);
const session = startSession(
{ booted: Promise.resolve(), ready: Promise.resolve() },
undefined,
retiring
);
await flushAsync();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
expect(retiring).toHaveBeenCalledTimes(1);
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
retired.resolve();
await session;
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledTimes(1);
});
it('aborts a pending ready report when startup is stopped', async () => {
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
let tasks!: StartupTasks;
jest.mocked(uploadRemoteSessionConfigAsync).mockImplementation(
async ({ signal }) =>
new Promise((_resolve, reject) => {
signal!.addEventListener('abort', () => reject(new Error('request aborted')), {
once: true,
});
})
);
const session = startSession(startup => {
tasks = startup;
return { booted: Promise.resolve(), ready: Promise.resolve() };
});
session.catch(() => {});
await flushAsync();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({ signal: tasks.signal })
);
tasks.abort(stopped);
await expect(session).rejects.toBe(stopped);
expect(startAgentDeviceEventCollectionAsync).not.toHaveBeenCalled();
});
it('starts the Android host before application readiness and waits before reporting ready', async () => {
const ready = deferred();
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
@@ -4,13 +4,18 @@ import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { AndroidEmulatorLogCapture } from '../../../utils/androidEmulatorLogCapture';
import { getAndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogs';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { stopLocalEgressResourcesAsync } from '../../utils/localEgress';
import {
resolveLocalEgressServeSimBootEnvironmentAsync,
startLocalEgressGuardRelayAsync,
} from '../../utils/localEgressGuard';
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
import {
selectXcodeDeveloperDirectoryAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import { downloadBuildAsync } from '../downloadBuild';
import { installBuildAsync } from '../installBuild';
import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact';
@@ -32,10 +37,12 @@ jest.mock('../../utils/localEgressGuard', () => ({
startLocalEgressGuardRelayAsync: jest.fn(),
}));
jest.mock('@expo/turtle-spawn');
jest.mock('../../utils/androidEmulatorLogs');
jest.mock('../../../utils/IosSimulatorUtils');
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
jest.mock('../startIosSimulator', () => ({
...jest.requireActual('../startIosSimulator'),
@@ -104,43 +111,28 @@ function runStep(
inputValues: Record<string, unknown> = {},
signal?: AbortSignal,
stepCtx: CustomBuildContext = ctx,
warm = false
warm = false,
env: NodeJS.ProcessEnv = {}
): Promise<void> {
const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx, { warm });
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const global = createGlobalContextMock({
runtimePlatform,
staticContextContent: { job: { secrets: { robotAccessToken: 'robot-token' } } },
});
const inputs = Object.fromEntries(
[
'device_identifier',
'system_image_package',
'lcd_width',
'lcd_height',
'lcd_density',
'install_app_path',
'build_id',
'application_archive_url',
'launch_app_identifier',
'launch_args',
'open_url',
'network_capture',
'network_capture_fields',
'public_preview',
'private_preview_url',
'frame_ancestors',
'cors_origins',
'package_version',
'max_idle_time_minutes',
'max_duration_seconds',
].map(id => [id, { value: inputValues[id] }])
(buildFunction.inputProviders ?? []).map(provider => {
const input = provider(global, 'test');
input.set(inputValues[input.id]);
return [input.id, { value: input.rawValue }];
})
);
return buildFunction.fn!(
{
logger,
global: {
runtimePlatform,
staticContext: { job: { secrets: { robotAccessToken: 'robot-token' } } },
},
global,
} as unknown as BuildStepContext,
{ inputs, outputs: {}, env: {}, signal } as never
{ inputs, outputs: {}, env, signal } as never
) as Promise<void>;
}
@@ -149,7 +141,31 @@ function sessionDevice(): Device {
}
describe(createStartAgentDeviceSessionBuildFunction, () => {
function mockStalledDownload(): { aborted: () => boolean; settled: () => boolean } {
let aborted = false;
let settled = false;
jest.mocked(downloadBuildAsync).mockImplementation(
({ signal }) =>
new Promise((_resolve, reject) => {
signal!.addEventListener('abort', () => {
aborted = true;
// Settles a bit later, like a request that winds down.
setImmediate(() => {
settled = true;
reject(new Error('The user aborted a request.'));
});
});
})
);
return { aborted: () => aborted, settled: () => settled };
}
beforeEach(() => {
jest
.mocked(getAndroidEmulatorLogCapture)
.mockImplementation(
jest.requireActual('../../utils/androidEmulatorLogs').getAndroidEmulatorLogCapture
);
deviceLaunch = undefined;
jest.clearAllMocks();
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(null);
@@ -161,6 +177,13 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ name: 'iPhone 17', udid: 'SELECTED-UDID' }] as never);
jest.mocked(getAgentDeviceRemoteSessionEnvOrThrow).mockReturnValue(sessionEnv);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async ({ signal }) => {
if (!signal?.aborted) {
await new Promise<void>(resolve =>
signal?.addEventListener('abort', () => resolve(), { once: true })
);
}
});
jest
.mocked(runAgentDeviceRemoteSessionAsync)
.mockImplementation(async (_ctx, { device, tasks }) => {
@@ -183,6 +206,43 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
jest.mocked(readIosApplicationIdentifierAsync).mockResolvedValue('dev.example.app');
});
it.each([false, true])(
'observes Stop during warm claim and preserves cleanup failure=%s',
async failCleanup => {
const stopped = deferred();
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise);
let entered!: () => void;
const claiming = new Promise<void>(resolve => {
entered = resolve;
});
const cleanup = new Error('warm cleanup failed');
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async claim => {
entered();
await new Promise<void>((_resolve, reject) => {
claim.signal!.addEventListener(
'abort',
() =>
reject(
failCleanup
? new AggregateError([claim.signal!.reason, cleanup])
: claim.signal!.reason
),
{ once: true }
);
});
});
const running = runStep(BuildRuntimePlatform.DARWIN, {}, undefined, ctx, true);
running.catch(() => {});
await claiming;
stopped.resolve();
if (failCleanup) {
await expect(running).rejects.toMatchObject({ errors: expect.arrayContaining([cleanup]) });
} else {
await expect(running).resolves.toBeUndefined();
}
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
}
);
it('hands guarded iOS boot to serve-sim and starts reporting before host startup', async () => {
const bootEnv = {
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
@@ -263,6 +323,33 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
});
it('treats Stop during guarded device shutdown as normal cancellation', async () => {
const stopped = deferred();
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise);
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
});
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Booted' } as never);
jest.mocked(spawn).mockImplementation(
(_command, _args, options) =>
new Promise((_resolve, reject) => {
options!.signal!.addEventListener('abort', () => reject(new Error('shutdown aborted')), {
once: true,
});
}) as ReturnType<typeof spawn>
);
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' });
step.catch(() => {});
await flushAsync();
expect(spawn).toHaveBeenCalled();
stopped.resolve();
await expect(step).resolves.toBeUndefined();
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
expect(startLocalEgressGuardRelayAsync).not.toHaveBeenCalled();
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
});
it('provides Simulator preparation and local app startup options to the remote session', async () => {
const hostReady = deferred();
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
@@ -343,6 +430,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
expect(getAndroidEmulatorLogCapture).not.toHaveBeenCalled();
expect(sessionDevice()).toEqual({
iosSimulatorUdid: 'SELECTED-UDID',
bootEnv: undefined,
@@ -350,6 +438,48 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
});
});
it('defaults an omitted session log input to emulator output through the step boundary', async () => {
const buildFunction = createStartAgentDeviceSessionBuildFunction(ctx);
const global = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX });
const step = buildFunction.createBuildStepFromFunctionCall(global, {
callInputs: {},
env: { DEVICE_RUN_SESSION_ID: 'session' },
});
await step.executeAsync();
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({ logSources: ['emulator-output'] })
);
});
it('passes an explicit empty source list and the workflow capture to Android boot', async () => {
const capture = new AndroidEmulatorLogCapture();
jest.mocked(getAndroidEmulatorLogCapture).mockReturnValue(capture);
const env = { DEVICE_RUN_SESSION_ID: 'session' };
await runStep(
BuildRuntimePlatform.LINUX,
{ android_log_sources: [] },
undefined,
ctx,
false,
env
);
expect(getAndroidEmulatorLogCapture).toHaveBeenCalledWith(expect.anything(), env);
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({ logSources: [], logCapture: capture })
);
});
it('rejects invalid Android log sources before boot or downloading', async () => {
await expect(
runStep(BuildRuntimePlatform.LINUX, {
android_log_sources: ['unknown'],
build_id: 'build-id',
})
).rejects.toThrow();
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
});
it('boots the Android Emulator with the device inputs', async () => {
await runStep(BuildRuntimePlatform.LINUX, {
device_identifier: 'pixel_7',
@@ -376,6 +506,22 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
);
});
it('stops the download and waits for it when Android boot fails', async () => {
const download = mockStalledDownload();
jest.mocked(startAndroidEmulatorAsync).mockRejectedValue(new Error('boot failed'));
await expect(
runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' }, undefined, ctx, false, {
DEVICE_RUN_SESSION_ID: 'session',
})
).rejects.toThrow('boot failed');
expect(download.aborted()).toBe(true);
// The step returned only after the download stopped.
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
});
it('does not start work for an already cancelled step', async () => {
const controller = new AbortController();
controller.abort(new Error('cancelled'));
@@ -388,6 +534,113 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'observes Stop during a held download on %s and drains startup normally',
async runtimePlatform => {
const download = mockStalledDownload();
const stopped = deferred();
const controller = new AbortController();
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise);
const step = runStep(runtimePlatform, { build_id: 'build-id' }, controller.signal);
step.catch(() => {});
try {
await flushAsync();
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
expect.objectContaining({
deviceRunSessionId: sessionEnv.deviceRunSessionId,
signal: expect.any(AbortSignal),
})
);
stopped.resolve();
await expect(step).resolves.toBeUndefined();
expect(download.aborted()).toBe(true);
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
expect(readIosApplicationIdentifierAsync).not.toHaveBeenCalled();
} finally {
controller.abort(new Error('test cleanup'));
stopped.resolve();
await step.catch(() => {});
}
}
);
it('waits for an in-flight Android boot after Stop without installing or launching', async () => {
const boot = deferred<Awaited<ReturnType<typeof startAndroidEmulatorAsync>>>();
const stopped = deferred();
jest.mocked(startAndroidEmulatorAsync).mockReturnValue(boot.promise);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise);
let settled = false;
const step = runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' });
void step
.finally(() => {
settled = true;
})
.catch(() => {});
try {
await flushAsync();
expect(startAndroidEmulatorAsync).toHaveBeenCalled();
stopped.resolve();
await flushAsync();
expect(settled).toBe(false);
} finally {
boot.resolve({} as Awaited<ReturnType<typeof startAndroidEmulatorAsync>>);
stopped.resolve();
await step;
}
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
});
it('retires the startup watch without treating its abort as user Stop', async () => {
jest
.mocked(runAgentDeviceRemoteSessionAsync)
.mockImplementation(async (_ctx, { onStarted, tasks }) => {
await onStarted!();
expect(tasks.signal.aborted).toBe(false);
});
await expect(runStep(BuildRuntimePlatform.DARWIN)).resolves.toBeUndefined();
const options = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
expect(options.maxDurationSeconds).toBeUndefined();
expect(options.idleTimeout).toBeUndefined();
expect(options.signal!.aborted).toBe(true);
});
it('preserves a download failure that wins before Stop', async () => {
const failed = new Error('download failed first');
const stopped = deferred();
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise);
jest.mocked(downloadBuildAsync).mockRejectedValue(failed);
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' });
step.catch(() => {});
await flushAsync();
stopped.resolve();
await expect(step).rejects.toBe(failed);
});
it('fails when the startup status watch rejects', async () => {
const failed = new Error('session errored');
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValue(failed);
await expect(runStep(BuildRuntimePlatform.DARWIN)).rejects.toBe(failed);
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('cancels and drains a pending download through the external signal', async () => {
const controller = new AbortController();
const download = mockStalledDownload();
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal);
const failure = expect(step).rejects.toThrow('cancelled');
await flushAsync();
controller.abort(new Error('cancelled'));
await failure;
expect(download.aborted()).toBe(true);
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
});
it('does not start device or app work after cancellation during Xcode selection', async () => {
const controller = new AbortController();
const selected = deferred();
@@ -416,6 +669,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
expect(
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual([
'android_log_sources',
'device_identifier',
'system_image_package',
'lcd_width',
@@ -9,6 +9,8 @@ import {
} from '../../../utils/AndroidEmulatorUtils';
import { retryAsync } from '../../../utils/retry';
import { resolveAndroidEmulatorLocalEgressAsync } from '../../utils/androidLocalEgress';
import { AndroidEmulatorLogCapture } from '../../../utils/androidEmulatorLogCapture';
import { getAndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogs';
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
jest.mock('@expo/turtle-spawn', () => ({
@@ -24,6 +26,8 @@ jest.mock('../../utils/androidLocalEgress', () => ({
resolveAndroidEmulatorLocalEgressAsync: jest.fn(),
}));
jest.mock('../../utils/androidEmulatorLogs');
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
AndroidEmulatorUtils: {
defaultSystemImagePackage: 'system-images;android-30;default;x86_64',
@@ -62,6 +66,11 @@ function createStartResult(serialId: string) {
describe(createStartAndroidEmulatorBuildFunction, () => {
beforeEach(() => {
jest
.mocked(getAndroidEmulatorLogCapture)
.mockImplementation(
jest.requireActual('../../utils/androidEmulatorLogs').getAndroidEmulatorLogCapture
);
mockedMkdtemp.mockResolvedValue('/tmp/logcat-directory');
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedAndroidUtils.getAvailableDevicesAsync.mockResolvedValue([]);
@@ -90,6 +99,30 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
mockedMkdtemp.mockRestore();
});
it('preserves an explicit empty source list and one capture through boot retries and clones', async () => {
mockedAndroidUtils.waitForReadyAsync.mockRejectedValueOnce(new Error('retry boot'));
const capture = new AndroidEmulatorLogCapture();
jest.mocked(getAndroidEmulatorLogCapture).mockReturnValue(capture);
await createStep(
{ android_log_sources: [], count: 2 },
{ DEVICE_RUN_SESSION_ID: 'session' }
).executeAsync();
expect(getAndroidEmulatorLogCapture).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ DEVICE_RUN_SESSION_ID: 'session' })
);
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledTimes(4);
for (const [options] of mockedAndroidUtils.startAsync.mock.calls) {
expect(options.logSources).toEqual([]);
expect(options.logCapture).toBe(capture);
}
});
it('rejects invalid sources before starting the emulator', async () => {
await expect(createStep({ android_log_sources: ['unknown'] }).executeAsync()).rejects.toThrow();
expect(mockedAndroidUtils.startAsync).not.toHaveBeenCalled();
});
describe('local egress', () => {
function createLocalEgress() {
return {
@@ -111,6 +144,8 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
deviceName: 'EasAndroidDevice01',
env: expect.any(Object),
logcatDirectory: '/tmp/logcat-directory',
logSources: ['emulator-output', 'device-logcat'],
logCapture: expect.any(AndroidEmulatorLogCapture),
});
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith({
env: expect.any(Object),
@@ -1,7 +1,9 @@
import { type WarmIosSession } from '../../utils/warmIosSession';
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import path from 'node:path';
import * as timersPromises from 'node:timers/promises';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
@@ -13,6 +15,7 @@ import { turtleFetch } from '../../../utils/turtleFetch';
import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import { preparePreinstalledWdaAsync } from '../../utils/preinstalledWda';
import { resolveLocalEgressServeSimBootEnvironmentAsync } from '../../utils/localEgressGuard';
import {
WarmSessionAdoptionError,
runOnWarmIosSimulatorAsync,
@@ -25,6 +28,8 @@ import {
startNgrokTunnelAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import { watchDeviceRunSessionStartup } from '../../utils/deviceRunSessionStartup';
import { createStartupWatchMock } from '../../../__tests__/utils/startupWatch';
import {
createStartAppiumRemoteSessionBuildFunction,
@@ -33,6 +38,10 @@ import {
resolveAppiumDeviceAsync,
} from '../startAppiumRemoteSession';
jest.mock('node:timers/promises', () => ({
...jest.requireActual('node:timers/promises'),
setTimeout: jest.fn(jest.requireActual('node:timers/promises').setTimeout),
}));
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
AndroidEmulatorUtils: { getAttachedDevicesAsync: jest.fn() },
}));
@@ -51,6 +60,9 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
startNgrokTunnelAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
jest.mock('../../utils/deviceRunSessionStartup', () => ({
watchDeviceRunSessionStartup: jest.fn(),
}));
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('../../../sentry');
jest.mock('../../../utils/retry', () => ({
@@ -106,6 +118,7 @@ describe(resolveAppium3VersionSpec, () => {
describe(resolveAppiumDeviceAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock);
});
it('returns XCUITest capabilities for the booted iOS simulator', async () => {
@@ -158,6 +171,7 @@ describe(resolveAppiumDeviceAsync, () => {
describe(installAppiumAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock);
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => {
if (args.includes('--json')) {
return { stdout: JSON.stringify({}) };
@@ -344,6 +358,10 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
const finishHost = jest.fn();
beforeEach(() => {
jest
.mocked(timersPromises.setTimeout)
.mockImplementation(jest.requireActual('node:timers/promises').setTimeout);
jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock);
jest.mocked(ensureFfmpegInstalledOnceAsync).mockResolvedValue(undefined);
jest.mocked(preparePreinstalledWdaAsync).mockResolvedValue({});
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({
@@ -396,7 +414,8 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
maxIdleTimeMinutes?: number,
runtimePlatform = BuildRuntimePlatform.LINUX,
startupInputs: Record<string, { value: unknown }> = {},
warm = false
warm = false,
signal?: AbortSignal
): Promise<void> {
await createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext, { warm }).fn!(
{
@@ -410,6 +429,7 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
...startupInputs,
},
outputs: {},
signal,
env: {
DEVICE_RUN_SESSION_ID: 'device-run-session-id',
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'tunnel.example.com',
@@ -428,6 +448,134 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
);
}
it('does not allocate Appium or a host after parent cancellation during Xcode selection', async () => {
const controller = new AbortController();
const failure = new Error('parent step cancelled');
jest
.mocked(selectXcodeDeveloperDirectoryAsync)
.mockImplementationOnce(async () => controller.abort(failure));
await expect(
runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {}, false, controller.signal)
).rejects.toBe(failure);
expect(spawnDetached).not.toHaveBeenCalled();
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
});
it('preserves an aggregate host startup failure', async () => {
const failure = new AggregateError([
new Error('host failed'),
new Error('host cleanup failed'),
]);
jest.mocked(startDeviceSessionHostAsync).mockRejectedValueOnce(failure);
stopAppium.mockResolvedValue(undefined);
await expect(runSessionAsync(logger)).rejects.toBe(failure);
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
});
it('reports late host cleanup failure after startup Stop', async () => {
stopAppium.mockResolvedValue(undefined);
let entered!: () => void;
const starting = new Promise<void>(resolve => {
entered = resolve;
});
let failed!: AggregateError;
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
entered();
return await new Promise((_resolve, reject) => {
options.signal!.addEventListener(
'abort',
() => {
failed = new AggregateError([options.signal!.reason, new Error('host cleanup failed')]);
setImmediate(() => reject(failed));
},
{ once: true }
);
});
});
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
boot_simulator: { value: true },
device_identifier: { value: 'selected-ios-udid' },
});
running.catch(() => {});
await starting;
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
const failure = await running.then(
() => undefined,
error => error
);
expect(failure).toBe(failed);
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
});
it('keeps watching startup when warm adoption retries cold', async () => {
stopAppium.mockResolvedValue(undefined);
const adoption = new Error('warm adoption failed');
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async (_claim, run) => {
try {
return await run({
device: { udid: 'selected-ios-udid', name: 'iPhone 17' },
} as WarmIosSession);
} catch (error) {
if (error !== adoption) {
throw error;
}
return await run(undefined);
}
});
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
options.onStartupError?.(adoption);
throw adoption;
});
await expect(
Promise.resolve(
runSessionAsync(
logger,
undefined,
BuildRuntimePlatform.DARWIN,
{ boot_simulator: { value: true }, device_identifier: { value: 'selected-ios-udid' } },
true
)
)
).resolves.toBeUndefined();
expect(watchDeviceRunSessionStartup).toHaveBeenCalledTimes(1);
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(2);
});
it.each([false, true])(
'observes Stop during warm claim and preserves cleanup failure=%s',
async failCleanup => {
let entered!: () => void;
const claiming = new Promise<void>(resolve => {
entered = resolve;
});
const cleanup = new Error('warm cleanup failed');
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async claim => {
entered();
await new Promise<void>((_resolve, reject) => {
claim.signal!.addEventListener(
'abort',
() =>
reject(
failCleanup
? new AggregateError([claim.signal!.reason, cleanup])
: claim.signal!.reason
),
{ once: true }
);
});
});
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {}, true);
running.catch(() => {});
await claiming;
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
if (failCleanup) {
await expect(running).rejects.toMatchObject({ errors: expect.arrayContaining([cleanup]) });
} else {
await expect(running).resolves.toBeUndefined();
}
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
}
);
it('passes the existing Android selection to the preview host', async () => {
stopAppium.mockResolvedValue(undefined);
await runSessionAsync(logger);
@@ -631,6 +779,116 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
}
}
);
it.each([
[BuildRuntimePlatform.LINUX, false],
[BuildRuntimePlatform.DARWIN, false],
[BuildRuntimePlatform.DARWIN, true],
])(
'handles Stop during the readiness retry sleep on %s with startup=%s',
async (runtimePlatform, bootSimulator) => {
stopAppium.mockResolvedValue(undefined);
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ udid: 'chosen-ios-udid' } as never]);
jest.mocked(turtleFetch).mockResolvedValue({ ok: false } as never);
jest.mocked(timersPromises.setTimeout).mockImplementationOnce((async (ms, value, options) => {
const sleeping = jest.requireActual('node:timers/promises').setTimeout(ms, value, options);
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
return sleeping;
}) as typeof timersPromises.setTimeout);
await expect(
runSessionAsync(
logger,
undefined,
runtimePlatform,
bootSimulator
? { boot_simulator: { value: true }, device_identifier: { value: 'chosen-device' } }
: {}
)
).resolves.toBeUndefined();
expect(stopAppium).toHaveBeenCalledTimes(1);
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
}
);
it('does not open the preview after Stop while the Appium tunnel is opening', async () => {
stopAppium.mockResolvedValue(undefined);
let tunnelEntered!: () => void;
const entered = new Promise<void>(resolve => {
tunnelEntered = resolve;
});
let finishTunnel!: () => void;
const pendingTunnel = new Promise<void>(resolve => {
finishTunnel = resolve;
});
const stopTunnel = jest.fn();
jest.mocked(startNgrokTunnelAsync).mockImplementationOnce(async () => {
tunnelEntered();
await pendingTunnel;
return { url: 'https://appium.example.com', subdomainId: 'appium', stopAsync: stopTunnel };
});
const openPreviewAsync = jest.fn();
const finishHost = jest.fn();
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync,
finishAsync: finishHost,
getLastPreviewActivityAt: jest.fn(),
});
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
boot_simulator: { value: true },
device_identifier: { value: 'chosen-device' },
});
running.catch(() => {});
try {
await entered;
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
} finally {
finishTunnel();
}
await expect(running).resolves.toBeUndefined();
expect(openPreviewAsync).not.toHaveBeenCalled();
expect(stopTunnel).toHaveBeenCalledTimes(1);
expect(finishHost).toHaveBeenCalledTimes(1);
expect(stopAppium).toHaveBeenCalledTimes(1);
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
});
it('ends normally when Stop interrupts the LOCAL-guard Simulator shutdown', async () => {
jest
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
.mockResolvedValueOnce({ SERVE_SIM_ADDITIONAL_DYLIBS: '/guard.dylib' });
jest
.mocked(IosSimulatorUtils.getDeviceAsync)
.mockResolvedValueOnce({ state: 'Booted' } as never);
let shutdownEntered!: () => void;
const entered = new Promise<void>(resolve => {
shutdownEntered = resolve;
});
jest.mocked(spawn).mockImplementationOnce((_command, _args, options) => {
shutdownEntered();
return new Promise((_resolve, reject) =>
options!.signal!.addEventListener(
'abort',
() => reject(new Error('Shutdown process aborted', { cause: options!.signal!.reason })),
{ once: true }
)
) as ReturnType<typeof spawn>;
});
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
boot_simulator: { value: true },
device_identifier: { value: 'chosen-device' },
});
running.catch(() => {});
await entered;
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
await expect(running).resolves.toBeUndefined();
expect(ensureFfmpegInstalledOnceAsync).not.toHaveBeenCalled();
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
expect(spawnDetached).not.toHaveBeenCalled();
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
});
it.each([false, true])(
'preserves preview settings with Simulator startup %s',
@@ -660,6 +918,154 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
}
);
it.each([
[BuildRuntimePlatform.DARWIN, false],
[BuildRuntimePlatform.LINUX, false],
[BuildRuntimePlatform.DARWIN, true],
])(
'handles Stop during %s setup, preserving teardown failure=%s',
async (runtimePlatform, teardownFails) => {
let setupEntered!: () => void;
const entered = new Promise<void>(resolve => {
setupEntered = resolve;
});
jest.mocked(ensureFfmpegInstalledOnceAsync).mockImplementationOnce(({ signal }) => {
setupEntered();
return new Promise<void>(resolve =>
signal!.addEventListener('abort', () => resolve(), { once: true })
);
});
const teardownError = new Error('host teardown failed');
const finishHost = jest.fn().mockImplementation(async () => {
if (teardownFails) {
throw teardownError;
}
});
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: jest.fn(),
finishAsync: finishHost,
getLastPreviewActivityAt: () => undefined,
});
}
const running = runSessionAsync(
{ info: jest.fn(), warn: jest.fn() },
undefined,
runtimePlatform,
runtimePlatform === BuildRuntimePlatform.DARWIN
? { boot_simulator: { value: true }, device_identifier: { value: 'chosen-ios-udid' } }
: {}
);
running.catch(() => {});
await entered;
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
watch.stop();
if (teardownFails) {
await expect(running).rejects.toBe(teardownError);
} else {
await expect(running).resolves.toBeUndefined();
}
expect(spawnDetached).not.toHaveBeenCalled();
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
expect(watch.finishAsync).toHaveBeenCalled();
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
expect(finishHost).toHaveBeenCalledTimes(1);
}
}
);
it.each([
[BuildRuntimePlatform.DARWIN, false],
[BuildRuntimePlatform.DARWIN, true],
[BuildRuntimePlatform.LINUX, false],
])(
'preserves failed installer cleanup after Stop on %s with Simulator startup=%s',
async (runtimePlatform, bootSimulator) => {
const cleanupError = new Error('Appium home removal failed: EACCES');
const remove = fs.promises.rm.bind(fs.promises);
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ udid: 'chosen-ios-udid' } as never]);
let enteredInstall!: () => void;
const installEntered = new Promise<void>(resolve => {
enteredInstall = resolve;
});
let closeInstaller!: (error: Error) => void;
jest.mocked(spawn).mockImplementationOnce(() => {
enteredInstall();
return Object.assign(
new Promise((_resolve, reject) => {
closeInstaller = reject;
}),
{ child: { pid: 12345 } }
) as never;
});
jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
const rm = jest.spyOn(fs.promises, 'rm').mockRejectedValueOnce(cleanupError);
const running = runSessionAsync(
{ info: jest.fn(), warn: jest.fn() },
undefined,
runtimePlatform,
bootSimulator
? { boot_simulator: { value: true }, device_identifier: { value: 'chosen-ios-udid' } }
: {}
);
const observed = running.catch(() => {});
let home: string | undefined;
try {
await installEntered;
home = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
watch.stop();
closeInstaller(new Error('installer interrupted'));
await expect(running).rejects.toBe(cleanupError);
expect(rm).toHaveBeenCalledWith(home, { recursive: true, force: true });
expect(fs.existsSync(home)).toBe(true);
expect(spawnDetached).not.toHaveBeenCalled();
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
expect(watch.finishAsync).toHaveBeenCalled();
} finally {
closeInstaller?.(new Error('test cleanup'));
await observed;
if (home) {
await remove(home, { recursive: true, force: true });
}
}
}
);
it('preserves an Android installer failure observed before a later Stop', async () => {
const failed = new Error('Appium install failed first');
let observedFailure!: () => void;
const installFailed = new Promise<void>(resolve => {
observedFailure = resolve;
});
jest.mocked(spawn).mockImplementationOnce((() => {
observedFailure();
return Promise.reject(failed);
}) as never);
jest
.mocked(ensureFfmpegInstalledOnceAsync)
.mockImplementationOnce(
({ signal }) =>
new Promise<void>(resolve =>
signal!.addEventListener('abort', () => resolve(), { once: true })
)
);
const running = runSessionAsync(
{ info: jest.fn(), warn: jest.fn() },
undefined,
BuildRuntimePlatform.LINUX
);
running.catch(() => {});
await installFailed;
await new Promise(resolve => setImmediate(resolve));
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
watch.stop();
await expect(running).rejects.toBe(failed);
expect(spawnDetached).not.toHaveBeenCalled();
});
it('selects Xcode only once for an already-prepared iOS session', async () => {
stopAppium.mockResolvedValue(undefined);
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockClear();
@@ -976,9 +1382,9 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
it('keeps the readiness error when Appium cannot be stopped', async () => {
let now = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => now);
jest.mocked(sleepAsync).mockImplementation(async ms => {
now += ms;
});
jest.mocked(timersPromises.setTimeout).mockImplementation((async ms => {
now += Number(ms ?? 0);
}) as typeof timersPromises.setTimeout);
jest.mocked(turtleFetch).mockRejectedValue(new Error('connect ECONNREFUSED'));
const logger = { info: jest.fn(), warn: jest.fn() };
@@ -29,6 +29,8 @@ import {
runOnWarmIosSimulatorAsync,
} from '../../utils/warmDeviceSessionHost';
import { type WarmIosSession } from '../../utils/warmIosSession';
import { watchDeviceRunSessionStartup } from '../../utils/deviceRunSessionStartup';
import { createStartupWatchMock } from '../../../__tests__/utils/startupWatch';
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact';
@@ -86,6 +88,9 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
jest.mock('../../utils/deviceRunSessionStartup', () => ({
watchDeviceRunSessionStartup: jest.fn(),
}));
const TEST_HOME = path.join(os.tmpdir(), 'eas-argent-orchestration-home');
const ARGENT_STATE_DIR = path.join(TEST_HOME, '.argent');
@@ -136,6 +141,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock);
// The (real) tool-server state wait reads this file from the redirected ~/.argent.
await fs.promises.mkdir(ARGENT_STATE_DIR, { recursive: true });
@@ -149,6 +155,363 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
await fs.promises.rm(TEST_HOME, { recursive: true, force: true });
});
it.each([
[BuildRuntimePlatform.DARWIN, false],
[BuildRuntimePlatform.LINUX, false],
[BuildRuntimePlatform.DARWIN, true],
])(
'handles Stop during %s setup, keeping teardown failure=%s visible',
async (runtimePlatform, teardownFails) => {
let rejectSetup!: (error: Error) => void;
const setup = Object.assign(
new Promise<never>((_resolve, reject) => {
rejectSetup = reject;
}),
{ child: { pid: 12345 } }
);
jest.mocked(spawn).mockReturnValueOnce(setup as unknown as ReturnType<typeof spawn>);
jest
.mocked(killProcessGroup)
.mockImplementationOnce(() => rejectSetup(new Error('setup killed')));
const teardownError = new Error('host teardown failed');
if (teardownFails) {
mockPreviewStopAsync.mockRejectedValueOnce(teardownError);
}
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
const running = Promise.resolve(
buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
boot_simulator: {
value: runtimePlatform === BuildRuntimePlatform.DARWIN ? true : undefined,
},
},
outputs: {},
env: {},
} as never
)
);
running.catch(() => {});
try {
for (let i = 0; i < 20 && !jest.mocked(spawn).mock.calls.length; i++) {
await new Promise(resolve => setImmediate(resolve));
}
expect(spawn).toHaveBeenCalledTimes(1);
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
watch.stop();
if (teardownFails) {
await expect(running).rejects.toBe(teardownError);
} else {
await expect(running).resolves.toBeUndefined();
}
expect(killProcessGroup).toHaveBeenCalledTimes(1);
expect(spawnDetached).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
expect(watch.finishAsync).toHaveBeenCalled();
} finally {
rejectSetup(new Error('test cleanup'));
await running.catch(() => {});
}
}
);
it('does not allocate Argent or a host after parent cancellation during Xcode selection', async () => {
const logger = { info: jest.fn(), warn: jest.fn() };
const controller = new AbortController();
const failure = new Error('parent step cancelled');
jest
.mocked(selectXcodeDeveloperDirectoryAsync)
.mockImplementationOnce(async () => controller.abort(failure));
await expect(
createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
signal: controller.signal,
} as never
)
).rejects.toBe(failure);
expect(spawnDetached).not.toHaveBeenCalled();
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
});
it('preserves an aggregate host startup failure', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const failure = new AggregateError([
new Error('host failed'),
new Error('host cleanup failed'),
]);
jest.mocked(startDeviceSessionHostAsync).mockRejectedValueOnce(failure);
await expect(
createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
boot_simulator: { value: true },
},
outputs: {},
env: {},
} as never
)
).rejects.toBe(failure);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('reports late host cleanup failure after startup Stop', async () => {
const ctx = {} as CustomBuildContext;
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
let entered!: () => void;
const starting = new Promise<void>(resolve => {
entered = resolve;
});
let failed!: AggregateError;
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
entered();
return await new Promise((_resolve, reject) => {
options.signal!.addEventListener(
'abort',
() => {
failed = new AggregateError([options.signal!.reason, new Error('host cleanup failed')]);
setImmediate(() => reject(failed));
},
{ once: true }
);
});
});
const running = createStartArgentRemoteSessionBuildFunction(ctx).fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
{
inputs: {
boot_simulator: { value: true },
device_identifier: { value: 'selected-ios-udid' },
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
) as Promise<void>;
running.catch(() => {});
await starting;
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
const failure = await running.then(
() => undefined,
error => error
);
expect(failure).toBe(failed);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('keeps watching startup when warm adoption retries cold', async () => {
const ctx = {} as CustomBuildContext;
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const adoption = new Error('warm adoption failed');
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async (_claim, run) => {
try {
return await run({
device: { udid: 'selected-ios-udid', name: 'iPhone 17' },
} as WarmIosSession);
} catch (error) {
if (error !== adoption) {
throw error;
}
return await run(undefined);
}
});
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
options.onStartupError?.(adoption);
throw adoption;
});
await expect(
Promise.resolve(
createStartArgentRemoteSessionBuildFunction(ctx, { warm: true }).fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
{
inputs: {
boot_simulator: { value: true },
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
)
)
).resolves.toBeUndefined();
expect(watchDeviceRunSessionStartup).toHaveBeenCalledTimes(1);
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(2);
});
it.each([false, true])(
'observes Stop during warm claim and preserves cleanup failure=%s',
async failCleanup => {
const ctx = {} as CustomBuildContext;
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
let entered!: () => void;
const claiming = new Promise<void>(resolve => {
entered = resolve;
});
const cleanup = new Error('warm cleanup failed');
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async claim => {
entered();
await new Promise<void>((_resolve, reject) => {
claim.signal!.addEventListener(
'abort',
() =>
reject(
failCleanup
? new AggregateError([claim.signal!.reason, cleanup])
: claim.signal!.reason
),
{ once: true }
);
});
});
const running = createStartArgentRemoteSessionBuildFunction(ctx, { warm: true }).fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
) as Promise<void>;
running.catch(() => {});
await claiming;
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
if (failCleanup) {
await expect(running).rejects.toMatchObject({ errors: expect.arrayContaining([cleanup]) });
} else {
await expect(running).resolves.toBeUndefined();
}
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
}
);
it('does not open the preview after Stop while the tools tunnel is opening', async () => {
let tunnelEntered!: () => void;
const entered = new Promise<void>(resolve => {
tunnelEntered = resolve;
});
let finishTunnel!: () => void;
const pendingTunnel = new Promise<void>(resolve => {
finishTunnel = resolve;
});
jest.mocked(startNgrokTunnelAsync).mockImplementationOnce(async () => {
tunnelEntered();
await pendingTunnel;
return {
url: 'https://argent.example.com',
subdomainId: 'argent',
stopAsync: mockTunnelStopAsync,
};
});
const openPreviewAsync = jest.fn();
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync,
finishAsync: mockPreviewStopAsync,
getLastPreviewActivityAt: jest.fn(),
});
const running = Promise.resolve(
createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
boot_simulator: { value: true },
},
outputs: {},
env: {},
} as never
)
);
running.catch(() => {});
try {
await entered;
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
} finally {
finishTunnel();
}
await expect(running).resolves.toBeUndefined();
expect(openPreviewAsync).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'preserves an earlier prepared %s host failure when Stop arrives during cleanup',
async runtimePlatform => {
const failure = new Error('Host became ready without a session token');
let cleanupEntered!: () => void;
const entered = new Promise<void>(resolve => {
cleanupEntered = resolve;
});
let finishCleanup!: () => void;
const cleanup = new Promise<void>(resolve => {
finishCleanup = resolve;
});
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
options.onStartupError?.(failure);
cleanupEntered();
await cleanup;
throw failure;
});
const running = Promise.resolve(
createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
)
);
running.catch(() => {});
try {
await entered;
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
} finally {
finishCleanup();
}
await expect(running).rejects.toBe(failure);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
}
);
it('reports an early exit with output and stops Argent before opening tunnels', async () => {
const stopError = new Error('drain timed out');
const stopServer = jest.fn().mockRejectedValue(stopError);
@@ -964,7 +1327,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
});
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'keeps %s prepared setup commands outside the new cancellation path',
'makes %s prepared setup commands cancellable',
async runtimePlatform => {
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
await buildFunction.fn!(
@@ -983,7 +1346,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
);
expect(spawn).toHaveBeenCalledTimes(3);
for (const [, , options] of jest.mocked(spawn).mock.calls) {
expect(options).not.toHaveProperty('detached');
expect(options).toHaveProperty('detached', true);
expect(options).not.toHaveProperty('signal');
}
expect(killProcessGroup).not.toHaveBeenCalled();
@@ -17,6 +17,8 @@ import {
runOnWarmIosSimulatorAsync,
} from '../../utils/warmDeviceSessionHost';
import { type WarmIosSession } from '../../utils/warmIosSession';
import { watchDeviceRunSessionStartup } from '../../utils/deviceRunSessionStartup';
import { createStartupWatchMock } from '../../../__tests__/utils/startupWatch';
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
jest.mock('../../utils/deviceSessionHost');
@@ -45,6 +47,9 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
jest.mock('../../utils/deviceRunSessionStartup', () => ({
watchDeviceRunSessionStartup: jest.fn(),
}));
const ctx = {} as CustomBuildContext;
const env = { NGROK_AUTHTOKEN: 'ngrok-token', PATH: '/usr/bin' } as BuildStepEnv;
@@ -54,7 +59,8 @@ const stopAsync = jest.fn();
async function runAsync(
runtimePlatform: BuildRuntimePlatform,
launchInputs: Record<string, { value: unknown }> = {}
launchInputs: Record<string, { value: unknown }> = {},
signal?: AbortSignal
): Promise<void> {
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx);
await buildFunction.fn!(
@@ -69,6 +75,7 @@ async function runAsync(
...launchInputs,
},
outputs: {},
signal,
env,
} as never
);
@@ -92,9 +99,109 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock);
stopAsync.mockResolvedValue(undefined);
});
it('does not start a host when the parent aborts during Xcode selection', async () => {
const controller = new AbortController();
const failure = new Error('parent step cancelled');
jest
.mocked(selectXcodeDeveloperDirectoryAsync)
.mockImplementationOnce(async () => controller.abort(failure));
await expect(runAsync(BuildRuntimePlatform.DARWIN, {}, controller.signal)).rejects.toBe(
failure
);
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
expect(watchDeviceRunSessionStartup).toHaveBeenCalledTimes(1);
expect(
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.finishAsync
).toHaveBeenCalled();
});
it('keeps watching startup when warm adoption retries cold', async () => {
const adoption = new Error('warm adoption failed');
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async (_claim, run) => {
try {
return await run({
device: { udid: 'selected-ios-udid', name: 'iPhone 17' },
} as WarmIosSession);
} catch (error) {
if (error !== adoption) {
throw error;
}
return await run(undefined);
}
});
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
options.onStartupError?.(adoption);
throw adoption;
});
await expect(
Promise.resolve(
createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true }).fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
{
inputs: {
boot_simulator: { value: true },
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env,
} as never
)
)
).resolves.toBeUndefined();
expect(watchDeviceRunSessionStartup).toHaveBeenCalledTimes(1);
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(2);
});
it.each([false, true])(
'observes Stop during warm claim and preserves cleanup failure=%s',
async failCleanup => {
let entered!: () => void;
const claiming = new Promise<void>(resolve => {
entered = resolve;
});
const cleanup = new Error('warm cleanup failed');
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async claim => {
entered();
await new Promise<void>((_resolve, reject) => {
claim.signal!.addEventListener(
'abort',
() =>
reject(
failCleanup
? new AggregateError([claim.signal!.reason, cleanup])
: claim.signal!.reason
),
{ once: true }
);
});
});
const running = createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true }).fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env,
} as never
) as Promise<void>;
running.catch(() => {});
await claiming;
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
if (failCleanup) {
await expect(running).rejects.toMatchObject({ errors: expect.arrayContaining([cleanup]) });
} else {
await expect(running).resolves.toBeUndefined();
}
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
}
);
it('adopts the claimed warm Simulator when the warm step runs', async () => {
const warmServer = { port: 3200 } as never;
const warm = { device: { udid: 'SELECTED-IOS-UDID', name: 'iPhone 17' } } as WarmIosSession;
@@ -158,6 +265,108 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
});
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'handles Stop while the %s host starts',
async runtimePlatform => {
let hostEntered!: () => void;
const entered = new Promise<void>(resolve => {
hostEntered = resolve;
});
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce((_ctx, { signal }) => {
hostEntered();
return new Promise((_resolve, reject) =>
signal!.addEventListener('abort', () => reject(new Error('host aborted')), { once: true })
);
});
const running = runAsync(runtimePlatform);
running.catch(() => {});
await entered;
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
watch.stop();
await expect(running).resolves.toBeUndefined();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
expect(watch.finishAsync).toHaveBeenCalled();
}
);
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'preserves an earlier %s host failure when Stop arrives during cleanup',
async runtimePlatform => {
const failure = new Error('Host became ready without a session token');
let cleanupEntered!: () => void;
const entered = new Promise<void>(resolve => {
cleanupEntered = resolve;
});
let finishCleanup!: () => void;
const cleanup = new Promise<void>(resolve => {
finishCleanup = resolve;
});
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
options.onStartupError?.(failure);
cleanupEntered();
await cleanup;
throw failure;
});
const running = runAsync(runtimePlatform);
running.catch(() => {});
try {
await entered;
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
} finally {
finishCleanup();
}
await expect(running).rejects.toBe(failure);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
}
);
it('keeps a teardown failure visible after Stop during preview setup', async () => {
let previewEntered!: () => void;
let finishPreview!: (value: {
previewPageUrl: string;
apiUrl: string;
closeAsync: () => Promise<void>;
}) => void;
const entered = new Promise<void>(resolve => {
previewEntered = resolve;
});
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: () => {
previewEntered();
return new Promise(resolve => {
finishPreview = resolve;
});
},
finishAsync: stopAsync,
getLastPreviewActivityAt: jest.fn(),
});
const failed = new Error('host teardown failed');
stopAsync.mockRejectedValueOnce(failed);
const running = runAsync(BuildRuntimePlatform.LINUX);
running.catch(() => {});
await entered;
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
watch.stop();
finishPreview({
previewPageUrl: 'https://preview.test',
apiUrl: 'https://server.test',
closeAsync: async () => {},
});
await expect(running).rejects.toBe(failed);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(stopAsync).toHaveBeenCalledTimes(1);
});
it('retires the startup watcher before lifetime waiting', async () => {
await runAsync(BuildRuntimePlatform.LINUX);
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
expect(watch.completeAsync.mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
);
});
it('reports the session token when serve-sim minted one', async () => {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
@@ -275,7 +484,8 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
logger,
timeoutMs: 60_000,
preview: { baseDomain: 'tunnel.example.com', authtoken: 'ngrok-token' },
signal: undefined,
signal: expect.any(AbortSignal),
onStartupError: expect.any(Function),
...(selectsXcode
? {
iosSimulatorUdid: 'SELECTED-IOS-UDID',
@@ -304,6 +514,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
previewApiUrl: 'https://web-preview.example.test',
},
logger,
signal: expect.any(AbortSignal),
});
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith({
ctx,
@@ -0,0 +1,34 @@
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
import { type CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { collectAndroidEmulatorLogsAsync } from '../utils/androidEmulatorLogs';
import { getDeviceRunSessionIdOrThrow } from '../utils/remoteDeviceRunSession';
export function createCollectAndroidEmulatorLogsBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'collect_android_emulator_logs',
name: 'Collect Android emulator logs',
__metricsId: 'eas/collect_android_emulator_logs',
supportedRuntimePlatforms: [BuildRuntimePlatform.LINUX],
fn: async ({ logger, global }, { env }) => {
try {
await collectAndroidEmulatorLogsAsync(ctx, {
global,
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
logger,
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not collect Android emulator logs', error);
logger.warn(
{ err: error },
'Could not collect Android logs; the session result is unchanged.'
);
}
},
});
}
@@ -25,6 +25,7 @@ import { type ServeSimApplicationOptions } from '../utils/remoteDeviceRunSession
import { type StartupTasks } from '../utils/startupTasks';
import {
type DetachedProcessHandle,
DeviceRunSessionStoppedError,
finishRemoteSessionAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
@@ -101,6 +102,7 @@ export async function runAgentDeviceRemoteSessionAsync(
corsOrigins,
tasks,
warm,
onStarted,
device,
}: {
env: BuildStepEnv;
@@ -119,6 +121,7 @@ export async function runAgentDeviceRemoteSessionAsync(
tasks: StartupTasks;
/** A claimed warm Simulator: adopt it and use its agent-device daemon. */
warm?: WarmIosSession;
onStarted?: () => Promise<void>;
device:
| {
booted: Promise<unknown>;
@@ -251,7 +254,7 @@ export async function runAgentDeviceRemoteSessionAsync(
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
signal: tasks.signal,
ctx,
deviceRunSessionId,
remoteConfig: {
@@ -266,6 +269,7 @@ export async function runAgentDeviceRemoteSessionAsync(
if (sessionStart === 'stopped') {
return;
}
await onStarted?.();
tasks.signal.throwIfAborted();
artifactUploads = pollAgentDeviceArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
@@ -301,13 +305,23 @@ export async function runAgentDeviceRemoteSessionAsync(
: undefined,
});
} catch (error) {
if (
tasks.signal.reason instanceof DeviceRunSessionStoppedError &&
!(error instanceof AggregateError)
) {
throw tasks.signal.reason;
}
sessionFailed = true;
throw error;
} finally {
// Promise.all rejects on the first failure while other tasks can still be starting.
// They stop at their next abort check. Wait for all of them, so the teardown sees
// everything that was started.
await Promise.allSettled([agentDeviceStartup, sessionHostStartup, deviceReady]);
const startupResults = await Promise.allSettled([
agentDeviceStartup,
sessionHostStartup,
deviceReady,
]);
const startedDaemon = daemonProcess;
const toolTeardown = (async () => {
try {
@@ -332,6 +346,11 @@ export async function runAgentDeviceRemoteSessionAsync(
logger,
sessionFailed,
teardown: [
...startupResults.flatMap((result): [string, Promise<unknown>][] =>
result.status === 'rejected' && result.reason instanceof AggregateError
? [['startup cleanup', Promise.reject(result.reason)]]
: []
),
['agent-device tunnel', agentDeviceTunnel?.stopAsync()],
['agent-device daemon', toolTeardown],
[
@@ -15,6 +15,9 @@ import {
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../../utils/AndroidEmulatorUtils';
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
import { AndroidEmulatorLogSourcesSchema } from '../../utils/androidEmulatorLogSources';
import { getAndroidEmulatorLogCapture } from '../utils/androidEmulatorLogs';
import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi';
import { readIosApplicationIdentifierAsync } from '../utils/iosAppArtifact';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
@@ -70,6 +73,12 @@ export function createStartAgentDeviceSessionBuildFunction(
name: 'Start agent-device session',
__metricsId: warm ? 'eas/start_warm_agent_device_session' : 'eas/start_agent_device_session',
inputProviders: [
BuildStepInput.createProvider({
id: 'android_log_sources',
required: false,
defaultValue: ['emulator-output'],
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
// Device. On iOS a Simulator name or UDID, on Android an AVD hardware profile.
BuildStepInput.createProvider({
id: 'device_identifier',
@@ -187,6 +196,9 @@ export function createStartAgentDeviceSessionBuildFunction(
} else {
validateAndroidLaunchOptions({ launchArgs, openUrl });
}
const logSources = isIos
? []
: AndroidEmulatorLogSourcesSchema.parse(inputs.android_log_sources.value);
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
const capture = parseNetworkCaptureInputs(
{
@@ -196,8 +208,18 @@ export function createStartAgentDeviceSessionBuildFunction(
{ runtimePlatform }
);
const startup = new AbortController();
const startupSignal = signal ? AbortSignal.any([signal, startup.signal]) : startup.signal;
const startupWatch = watchDeviceRunSessionStartup({
ctx,
deviceRunSessionId: sessionEnv.deviceRunSessionId,
logger,
startup: { signal: startupSignal, abort: reason => startup.abort(reason) },
});
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
const tasks = createStartupTasks(logger, signal);
const tasks = createStartupTasks(logger, startupSignal);
let booted: Promise<void> | undefined;
let sessionOwnsTeardown = false;
let downloaded: Promise<string> | undefined;
let applicationReady: Promise<ServeSimApplicationOptions | void> | undefined;
@@ -227,7 +249,7 @@ export function createStartAgentDeviceSessionBuildFunction(
logger.info(`Selected iOS Simulator: ${iosSimulatorUdid}.`);
}
const booted = iosSimulatorUdid
booted = iosSimulatorUdid
? Promise.resolve()
: tasks.run('Android Emulator boot', async taskLogger => {
const logcatDirectory = await fs.promises.mkdtemp(
@@ -242,6 +264,8 @@ export function createStartAgentDeviceSessionBuildFunction(
lcdHeight: inputs.lcd_height.value as number | undefined,
lcdDensity: inputs.lcd_density.value as number | undefined,
logcatDirectory,
logSources,
logCapture: getAndroidEmulatorLogCapture(global, env),
env,
logger: taskLogger,
});
@@ -295,7 +319,7 @@ export function createStartAgentDeviceSessionBuildFunction(
});
} else if (!isIos && (installAppPath || downloadedApp)) {
applicationReady = tasks.run('app install and launch', async taskLogger => {
await tasks.untilAborted(booted);
await tasks.untilAborted(booted!);
const artifactPath = installAppPath ?? (await downloadedApp);
if (!artifactPath) {
throw new Error('No app artifact was prepared.');
@@ -320,6 +344,7 @@ export function createStartAgentDeviceSessionBuildFunction(
});
}
sessionOwnsTeardown = true;
await runAgentDeviceRemoteSessionAsync(ctx, {
env,
logger,
@@ -337,6 +362,7 @@ export function createStartAgentDeviceSessionBuildFunction(
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
tasks,
warm: warmSession,
onStarted: () => startupWatch.completeAsync(),
device: iosSimulatorUdid
? {
iosSimulatorUdid,
@@ -348,26 +374,43 @@ export function createStartAgentDeviceSessionBuildFunction(
ready: applicationReady ?? tasks.untilAborted(booted),
},
});
} catch (error) {
if (
!sessionOwnsTeardown &&
tasks.signal.reason === startupWatch.stopped &&
!(error instanceof AggregateError)
) {
throw startupWatch.stopped;
}
throw error;
} finally {
tasks.abort(new Error('Agent-device session ended.'));
await Promise.allSettled([downloaded, applicationReady]);
await Promise.allSettled([booted, downloaded, applicationReady]);
}
};
// The warm path installs only a local app; build_id and application_archive_url start cold.
if (warm && isIos && !download) {
await runOnWarmIosSimulatorAsync(
{
engine: 'agent-device',
requestedDevice: deviceIdentifier,
packageVersion: inputs.package_version.value as string | undefined,
env,
logger,
signal,
},
startSessionAsync
);
} else {
await startSessionAsync();
try {
startupSignal.throwIfAborted();
if (warm && isIos && !download) {
await runOnWarmIosSimulatorAsync(
{
engine: 'agent-device',
requestedDevice: deviceIdentifier,
packageVersion: inputs.package_version.value as string | undefined,
env,
logger,
signal: startupSignal,
},
startSessionAsync
);
} else {
await startSessionAsync();
}
} catch (error) {
if (error !== startupWatch.stopped) {
throw error;
}
} finally {
await startupWatch.finishAsync();
}
}),
});
@@ -20,8 +20,15 @@ import {
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../../utils/AndroidEmulatorUtils';
import { type AndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogCapture';
import { retryAsync } from '../../utils/retry';
import { resolveAndroidEmulatorLocalEgressAsync } from '../utils/androidLocalEgress';
import {
ALL_ANDROID_EMULATOR_LOG_SOURCES,
type AndroidEmulatorLogSource,
AndroidEmulatorLogSourcesSchema,
} from '../../utils/androidEmulatorLogSources';
import { getAndroidEmulatorLogCapture } from '../utils/androidEmulatorLogs';
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
@@ -33,6 +40,12 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
name: 'Start Android Emulator',
__metricsId: 'eas/start_android_emulator',
inputProviders: [
BuildStepInput.createProvider({
id: 'android_log_sources',
required: false,
defaultValue: [...ALL_ANDROID_EMULATOR_LOG_SOURCES],
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
BuildStepInput.createProvider({
id: 'device_name',
required: false,
@@ -78,10 +91,12 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
required: true,
}),
],
fn: async ({ logger }, { inputs, outputs, env }) => {
fn: async ({ logger, global }, { inputs, outputs, env }) => {
const logSources = AndroidEmulatorLogSourcesSchema.parse(inputs.android_log_sources.value);
const logcatDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
);
const logCapture = getAndroidEmulatorLogCapture(global, env);
outputs.logcat_directory.set(logcatDirectory);
const deviceName = `${inputs.device_name.value}` as AndroidVirtualDeviceName;
@@ -95,6 +110,8 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
lcdHeight: inputs.lcd_height.value as number | undefined,
lcdDensity: inputs.lcd_density.value as number | undefined,
logcatDirectory,
logSources,
logCapture,
env,
logger,
});
@@ -133,6 +150,8 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
deviceName: cloneIdentifier,
env,
logcatDirectory,
logSources,
logCapture,
});
cloneSerialId = startResult.serialId;
@@ -202,6 +221,8 @@ export async function startAndroidEmulatorAsync({
lcdHeight,
lcdDensity,
logcatDirectory,
logSources,
logCapture,
env,
logger,
}: {
@@ -212,6 +233,8 @@ export async function startAndroidEmulatorAsync({
lcdHeight?: number;
lcdDensity?: number;
logcatDirectory: string;
logSources: readonly AndroidEmulatorLogSource[];
logCapture: AndroidEmulatorLogCapture;
env: BuildStepEnv;
logger: bunyan;
}): Promise<{
@@ -293,6 +316,8 @@ export async function startAndroidEmulatorAsync({
deviceName,
env,
logcatDirectory,
logSources,
logCapture,
...(localEgress ? { launchGate: localEgress.launchGate } : {}),
});
attemptSerialId = startResult.serialId;
@@ -45,7 +45,9 @@ import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
import { preparePreinstalledWdaAsync } from '../utils/preinstalledWda';
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
import {
DeviceRunSessionStoppedError,
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createIosSessionStartupInputProviders,
@@ -114,11 +116,20 @@ export function createStartAppiumRemoteSessionBuildFunction(
const packageVersion = inputs.package_version.value as string | undefined;
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
const { runtimePlatform } = global;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const startupStop = new AbortController();
const watchSignal = signal
? AbortSignal.any([signal, startupStop.signal])
: startupStop.signal;
const startupWatch = watchDeviceRunSessionStartup({
ctx,
deviceRunSessionId,
logger,
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
});
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
const startupAbortController = new AbortController();
const startupSignal = AbortSignal.any([watchSignal, startupAbortController.signal]);
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
@@ -129,7 +140,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
openUrl: inputs.open_url?.value,
env,
logger,
signal,
signal: startupSignal,
});
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
@@ -151,11 +162,6 @@ export function createStartAppiumRemoteSessionBuildFunction(
udid: iosStartup.iosSimulatorUdid,
}
: await resolveAppiumDeviceAsync({ runtimePlatform, env });
const startupAbortController = new AbortController();
const startupSignal = signal
? AbortSignal.any([signal, startupAbortController.signal])
: startupAbortController.signal;
const setupSignal = iosStartup ? startupSignal : undefined;
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let appiumProcess: ReturnType<typeof spawnDetached> | undefined;
@@ -183,16 +189,14 @@ export function createStartAppiumRemoteSessionBuildFunction(
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
signal: startupSignal,
onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined,
onStartupError: error => startupAbortController.abort(error),
...launch,
networkCapture,
networkCaptureFields,
},
...(warmSession ? [createWarmDeviceSessionServer(ctx, logger, warmSession)] : [])
).catch(error => {
if (iosStartup) {
startupAbortController.abort(error);
}
startupAbortController.abort(error);
throw error;
});
try {
@@ -210,22 +214,25 @@ export function createStartAppiumRemoteSessionBuildFunction(
driverName: device.driverName,
env,
logger,
signal: setupSignal,
onError: iosStartup ? error => startupAbortController.abort(error) : undefined,
signal: startupSignal,
onError: error => startupAbortController.abort(error),
}).catch(error => {
if (iosStartup) {
startupAbortController.abort(error);
}
startupAbortController.abort(error);
throw error;
})
),
asyncResult(
ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger, signal: setupSignal })
ensureFfmpegInstalledOnceAsync({
runtimePlatform,
env,
logger,
signal: startupSignal,
})
),
]);
const installation = appiumInstall.enforceValue();
appiumHome = installation.appiumHome;
setupSignal?.throwIfAborted();
startupSignal?.throwIfAborted();
const { appiumBinPath, appiumEnv } = installation;
const wdaCapabilities = await preparePreinstalledWdaAsync({
runtimeIdentifier: iosStartup
@@ -240,10 +247,10 @@ export function createStartAppiumRemoteSessionBuildFunction(
appiumBinPath,
appiumEnv,
logger,
signal: setupSignal,
signal: startupSignal,
});
setupSignal?.throwIfAborted();
startupSignal?.throwIfAborted();
const useFdaHelper =
runtimePlatform === BuildRuntimePlatform.DARWIN && fs.existsSync(FDA_HELPER);
appiumProcess = spawnDetached({
@@ -275,11 +282,11 @@ export function createStartAppiumRemoteSessionBuildFunction(
env: appiumEnv,
logger,
});
await waitForAppiumReadyAsync({ appiumProcess, logger, signal: setupSignal });
await waitForAppiumReadyAsync({ appiumProcess, logger, signal: startupSignal });
if (hostStartup) {
sessionHost = (await hostStartup).enforceValue();
}
setupSignal?.throwIfAborted();
startupSignal?.throwIfAborted();
eventCollection = await startAppiumEventCollectionAsync({
ctx,
deviceRunSessionId,
@@ -301,11 +308,12 @@ export function createStartAppiumRemoteSessionBuildFunction(
logger.info(launchDescription);
}
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
startupSignal.throwIfAborted();
const webPreview = await sessionHost.openPreviewAsync();
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
signal: startupSignal,
ctx,
deviceRunSessionId,
remoteConfig: {
@@ -325,6 +333,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
return;
}
await startupWatch.completeAsync();
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
@@ -340,8 +349,20 @@ export function createStartAppiumRemoteSessionBuildFunction(
: undefined,
});
} catch (error) {
if (
error === startupSignal.reason &&
startupSignal.reason instanceof DeviceRunSessionStoppedError
) {
throw startupSignal.reason;
}
sessionFailed = true;
sessionError = setupSignal?.aborted ? setupSignal.reason : error;
sessionError =
error instanceof AggregateError
? error
: startupSignal.aborted &&
!(startupSignal.reason instanceof DeviceRunSessionStoppedError)
? startupSignal.reason
: error;
throw sessionError;
} finally {
startupAbortController.abort(new Error('Appium session ended.'));
@@ -374,6 +395,9 @@ export function createStartAppiumRemoteSessionBuildFunction(
if (warmSession) {
await Promise.allSettled([appiumTeardown]);
}
if (result.reason instanceof AggregateError) {
throw result.reason;
}
await result.value?.finishAsync();
}),
],
@@ -387,14 +411,32 @@ export function createStartAppiumRemoteSessionBuildFunction(
}
}
};
if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) {
await runOnWarmIosSimulatorAsync(
// package_version is the Appium version, which the job installs either way.
{ engine: 'appium', requestedDevice: deviceIdentifier, env, logger, signal },
startSessionAsync
);
} else {
await startSessionAsync();
try {
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
watchSignal.throwIfAborted();
if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) {
await runOnWarmIosSimulatorAsync(
// package_version is the Appium version, which the job installs either way.
{
engine: 'appium',
requestedDevice: deviceIdentifier,
env,
logger,
signal: watchSignal,
},
startSessionAsync
);
} else {
await startSessionAsync();
}
} catch (error) {
if (error !== startupWatch.stopped) {
throw error;
}
} finally {
await startupWatch.finishAsync();
}
}),
});
@@ -570,11 +612,11 @@ async function waitForAppiumReadyAsync({
}: {
appiumProcess: { getOutput: () => string };
logger: bunyan;
signal?: AbortSignal;
signal: AbortSignal;
}): Promise<void> {
const deadline = Date.now() + APPIUM_STARTUP_TIMEOUT_MS;
while (Date.now() < deadline) {
signal?.throwIfAborted();
signal.throwIfAborted();
try {
const response = await turtleFetch(`http://${APPIUM_HOST}:${APPIUM_PORT}/status`, 'GET', {
timeout: 2_000,
@@ -582,16 +624,17 @@ async function waitForAppiumReadyAsync({
logger,
signal,
});
signal?.throwIfAborted();
signal.throwIfAborted();
if (response.ok) {
return;
}
} catch {}
signal?.throwIfAborted();
if (signal) {
signal.throwIfAborted();
try {
await setTimeoutAsync(1_000, undefined, { signal });
} else {
await sleepAsync(1_000);
} catch (error) {
signal.throwIfAborted();
throw error;
}
}
const output = appiumProcess.getOutput();
@@ -41,7 +41,9 @@ import { isProcessDescendantOfAsync, killProcessGroup } from '../../utils/proces
import { sleepAsync } from '../../utils/retry';
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents';
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
import {
DeviceRunSessionStoppedError,
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createIosSessionStartupInputProviders,
@@ -143,11 +145,20 @@ export function createStartArgentRemoteSessionBuildFunction(
});
const versionSpec = packageVersion ?? 'latest';
const { runtimePlatform } = global;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const startupStop = new AbortController();
const watchSignal = signal
? AbortSignal.any([signal, startupStop.signal])
: startupStop.signal;
const startupWatch = watchDeviceRunSessionStartup({
ctx,
deviceRunSessionId,
logger,
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
});
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
const startupAbortController = new AbortController();
const startupSignal = AbortSignal.any([watchSignal, startupAbortController.signal]);
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
@@ -158,7 +169,7 @@ export function createStartArgentRemoteSessionBuildFunction(
openUrl: inputs.open_url?.value,
env,
logger,
signal,
signal: startupSignal,
});
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
@@ -182,11 +193,6 @@ export function createStartArgentRemoteSessionBuildFunction(
const argentExec = (args: string[]): { command: string; args: string[] } =>
resolvePackageExec(packageManager, args);
const startupAbortController = new AbortController();
const startupSignal = signal
? AbortSignal.any([signal, startupAbortController.signal])
: startupAbortController.signal;
const setupSignal = iosStartup ? startupSignal : undefined;
const artifactPollAbortController = new AbortController();
let artifactPollingPromise: Promise<void> | undefined;
let eventCollection:
@@ -213,16 +219,14 @@ export function createStartArgentRemoteSessionBuildFunction(
timeoutMs: STARTUP_TIMEOUT_MS,
preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken },
signal: startupSignal,
onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined,
onStartupError: error => startupAbortController.abort(error),
...launch,
networkCapture,
networkCaptureFields,
},
...(warmSession ? [createWarmDeviceSessionServer(ctx, logger, warmSession)] : [])
).catch(error => {
if (iosStartup) {
startupAbortController.abort(error);
}
startupAbortController.abort(error);
throw error;
});
try {
@@ -251,7 +255,7 @@ export function createStartArgentRemoteSessionBuildFunction(
enableArtifacts.command,
enableArtifacts.args,
{ env, logger },
setupSignal
startupSignal
);
logger.info('Enabling the Argent tool-server event log flag.');
@@ -264,7 +268,7 @@ export function createStartArgentRemoteSessionBuildFunction(
enableEventLog.command,
enableEventLog.args,
{ env, logger },
setupSignal
startupSignal
);
logger.info('Disabling the Argent video watermark flag.');
@@ -279,17 +283,17 @@ export function createStartArgentRemoteSessionBuildFunction(
disableVideoWatermark.command,
disableVideoWatermark.args,
{ env, logger },
setupSignal
startupSignal
);
} catch (err) {
setupSignal?.throwIfAborted();
startupSignal?.throwIfAborted();
logger.warn(
{ err },
'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.'
);
}
setupSignal?.throwIfAborted();
startupSignal?.throwIfAborted();
const startServer = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'server',
@@ -330,7 +334,7 @@ export function createStartArgentRemoteSessionBuildFunction(
ancestorPid: argentServer.pid,
timeoutMs: STARTUP_TIMEOUT_MS,
getExitError: argentServer.getExitError,
signal: setupSignal,
signal: startupSignal,
});
toolServerPort = toolServerState.port;
toolServerToken = toolServerState.token;
@@ -350,7 +354,7 @@ export function createStartArgentRemoteSessionBuildFunction(
if (hostStartup) {
sessionHost = (await hostStartup).enforceValue();
}
setupSignal?.throwIfAborted();
startupSignal?.throwIfAborted();
}
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
const artifactPollSignal = signal
@@ -388,6 +392,7 @@ export function createStartArgentRemoteSessionBuildFunction(
logger.info(launchDescription);
}
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
startupSignal.throwIfAborted();
const webPreview = await sessionHost.openPreviewAsync();
logger.info(
webPreview.sharePageUrl
@@ -397,7 +402,7 @@ export function createStartArgentRemoteSessionBuildFunction(
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
signal: startupSignal,
ctx,
deviceRunSessionId,
remoteConfig: {
@@ -413,6 +418,7 @@ export function createStartArgentRemoteSessionBuildFunction(
return;
}
await startupWatch.completeAsync();
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
@@ -429,8 +435,19 @@ export function createStartArgentRemoteSessionBuildFunction(
: undefined,
});
} catch (error) {
if (
startupSignal.reason instanceof DeviceRunSessionStoppedError &&
!(error instanceof AggregateError)
) {
throw startupSignal.reason;
}
sessionFailed = true;
throw setupSignal?.aborted ? setupSignal.reason : error;
throw error instanceof AggregateError
? error
: startupSignal.aborted &&
!(startupSignal.reason instanceof DeviceRunSessionStoppedError)
? startupSignal.reason
: error;
} finally {
startupAbortController.abort(new Error('Argent session ended.'));
const toolTeardown = (async () => {
@@ -470,6 +487,9 @@ export function createStartArgentRemoteSessionBuildFunction(
// The warm host owns Argent too; leave it alive until the final downloads finish.
await Promise.allSettled([toolTeardown]);
}
if (result.reason instanceof AggregateError) {
throw result.reason;
}
await result.value?.finishAsync();
}),
],
@@ -477,20 +497,32 @@ export function createStartArgentRemoteSessionBuildFunction(
});
}
};
if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) {
await runOnWarmIosSimulatorAsync(
{
engine: 'argent',
requestedDevice: deviceIdentifier,
packageVersion,
env,
logger,
signal,
},
startSessionAsync
);
} else {
await startSessionAsync();
try {
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
watchSignal.throwIfAborted();
if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) {
await runOnWarmIosSimulatorAsync(
{
engine: 'argent',
requestedDevice: deviceIdentifier,
packageVersion,
env,
logger,
signal: watchSignal,
},
startSessionAsync
);
} else {
await startSessionAsync();
}
} catch (error) {
if (error !== startupWatch.stopped) {
throw error;
}
} finally {
await startupWatch.finishAsync();
}
}),
});
@@ -7,6 +7,7 @@ import {
} from '@expo/steps';
import { CustomBuildContext } from '../../customBuildContext';
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
import { startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import {
createWarmDeviceSessionServer,
@@ -96,11 +97,20 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
'Web preview idle timeouts require iOS preview activity reporting. Omit max_idle_time_minutes for Android sessions.'
);
}
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const startupStop = new AbortController();
const watchSignal = signal
? AbortSignal.any([signal, startupStop.signal])
: startupStop.signal;
const startupWatch = watchDeviceRunSessionStartup({
ctx,
deviceRunSessionId,
logger,
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
});
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
const startupAbortController = new AbortController();
const startupSignal = AbortSignal.any([watchSignal, startupAbortController.signal]);
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
@@ -111,7 +121,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
openUrl: inputs.open_url?.value,
env,
logger,
signal,
signal: startupSignal,
});
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
@@ -136,7 +146,8 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken },
signal,
signal: startupSignal,
onStartupError: error => startupAbortController.abort(error),
packageVersion,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
@@ -149,9 +160,15 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
networkCaptureFields,
},
...(warmSession ? [createWarmDeviceSessionServer(ctx, logger, warmSession)] : [])
);
).catch(error => {
if (startupSignal.reason === startupWatch.stopped && !(error instanceof AggregateError)) {
throw startupWatch.stopped;
}
throw error;
});
try {
startupSignal.throwIfAborted();
const webPreview = await sessionHost.openPreviewAsync();
logger.info(
webPreview.sharePageUrl
@@ -160,7 +177,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
);
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
signal: startupSignal,
ctx,
deviceRunSessionId,
// WEB_PREVIEW_ONLY sessions read webPreviewUrl and webPreviewToken. Legacy SERVE_SIM
@@ -183,6 +200,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
return;
}
await startupWatch.completeAsync();
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
@@ -202,20 +220,32 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
await sessionHost.finishAsync();
}
};
if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) {
await runOnWarmIosSimulatorAsync(
{
engine: 'serve-sim',
requestedDevice: deviceIdentifier,
packageVersion,
env,
logger,
signal,
},
startSessionAsync
);
} else {
await startSessionAsync();
try {
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
watchSignal.throwIfAborted();
if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) {
await runOnWarmIosSimulatorAsync(
{
engine: 'serve-sim',
requestedDevice: deviceIdentifier,
packageVersion,
env,
logger,
signal: watchSignal,
},
startSessionAsync
);
} else {
await startSessionAsync();
}
} catch (error) {
if (error !== startupWatch.stopped) {
throw error;
}
} finally {
await startupWatch.finishAsync();
}
}),
});
@@ -0,0 +1,240 @@
import { type BuildStepGlobalContext } from '@expo/steps';
import { once } from 'node:events';
import fsPromises, {
mkdir,
mkdtemp,
readFile,
readdir,
rm,
symlink,
writeFile,
} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { PassThrough, Readable } from 'node:stream';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { type CustomBuildContext } from '../../../customBuildContext';
import { MAX_ANDROID_LOG_BYTES_PER_SOURCE } from '../../../utils/androidEmulatorLogCapture';
import { type AndroidEmulatorLogSource } from '../../../utils/androidEmulatorLogSources';
import {
collectAndroidEmulatorLogsAsync,
getAndroidEmulatorLogCapture,
} from '../androidEmulatorLogs';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
jest.mock('../deviceRunSessionArtifacts');
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
const ctx = {} as CustomBuildContext;
const upload = jest.mocked(uploadDeviceRunSessionArtifactAsync);
let directory: string;
let emulatorPath: string;
let logcatPath: string;
let global: BuildStepGlobalContext;
let logger: ReturnType<typeof createMockLogger>;
let uploadedContents: Map<string, string>;
beforeEach(async () => {
global = {} as BuildStepGlobalContext;
logger = createMockLogger();
uploadedContents = new Map();
directory = await mkdtemp(path.join(os.tmpdir(), 'android-log-collection-'));
emulatorPath = path.join(directory, 'device-attempt-1-emulator.log');
logcatPath = path.join(directory, 'device-attempt-1-logcat.log');
upload.mockReset().mockImplementation(async (_ctx, { filename, openStream }) => {
const chunks: Buffer[] = [];
for await (const chunk of openStream()) {
chunks.push(Buffer.from(chunk));
}
uploadedContents.set(filename, Buffer.concat(chunks).toString());
});
});
afterEach(async () => {
jest.restoreAllMocks();
await rm(directory, { recursive: true, force: true });
});
async function captureLog(
filePath: string,
source: AndroidEmulatorLogSource,
text: string,
workflow = global
): Promise<void> {
const capture = getAndroidEmulatorLogCapture(workflow, { DEVICE_RUN_SESSION_ID: 'session' });
const input = Readable.from([Buffer.from(text)]);
capture.attach(input, filePath, source);
await once(input, 'end');
await capture.finishFileAsync(filePath);
}
function collect(): Promise<void> {
return collectAndroidEmulatorLogsAsync(ctx, {
global,
deviceRunSessionId: 'session',
logger,
});
}
it.each([
{ env: {}, expectedBytes: 20 * 1024 * 1024 + 1 },
{ env: { DEVICE_RUN_SESSION_ID: 'session' }, expectedBytes: 20 * 1024 * 1024 },
])('retains $expectedBytes with workflow environment $env', async ({ env, expectedBytes }) => {
const capture = getAndroidEmulatorLogCapture(global, env);
const input = Readable.from([Buffer.alloc(20 * 1024 * 1024 + 1, 'x')]);
capture.attach(input, emulatorPath, 'emulator-output');
await once(input, 'end');
await capture.finishAsync();
expect((await fsPromises.stat(emulatorPath)).size).toBe(expectedBytes);
});
it('uses captured source records and ignores files outside the capture inventory', async () => {
const first = path.join(directory, 'first.txt');
const second = path.join(directory, 'second.txt');
await captureLog(first, 'emulator-output', 'host output');
await captureLog(second, 'device-logcat', 'Android output');
await captureLog(path.join(directory, 'retry.txt'), 'emulator-output', 'retry output');
await writeFile(emulatorPath, 'not captured');
const elsewhere = path.join(directory, 'elsewhere');
await mkdir(elsewhere);
await writeFile(path.join(elsewhere, 'other-emulator.log'), 'another job');
await symlink(path.join(elsewhere, 'other-emulator.log'), logcatPath);
await collect();
expect([...uploadedContents.values()]).toEqual(['host output', 'Android output', 'retry output']);
expect(upload).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
filename: 'first.txt',
kind: 'simulator-log',
metadata: expect.objectContaining({
platform: 'android',
source: 'emulator-output',
scope: 'emulator-process',
originalSizeBytes: 11,
omittedBytes: 0,
truncated: false,
}),
signal: expect.any(AbortSignal),
attempts: 2,
})
);
expect(upload).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
filename: 'second.txt',
metadata: expect.objectContaining({ source: 'device-logcat', scope: 'device' }),
})
);
});
it('seals a live producer before upload and keeps retry bodies identical without copying files', async () => {
const input = new PassThrough();
const capture = getAndroidEmulatorLogCapture(global, { DEVICE_RUN_SESSION_ID: 'session' });
capture.attach(input, emulatorPath, 'emulator-output');
input.write('before');
const contents: string[] = [];
upload.mockImplementationOnce(async (_ctx, { size, openStream, metadata }) => {
expect(size).toBe(6);
expect(metadata).toEqual(expect.objectContaining({ originalSizeBytes: 6, truncated: false }));
for (let attempt = 0; attempt < 2; attempt++) {
input.write(' ignored after finalization');
const chunks: Buffer[] = [];
for await (const chunk of openStream()) {
chunks.push(Buffer.from(chunk));
}
contents.push(Buffer.concat(chunks).toString());
}
});
try {
await collect();
expect(contents).toEqual(['before', 'before']);
expect(await readFile(emulatorPath, 'utf8')).toBe('before');
expect(await readdir(directory)).toEqual([path.basename(emulatorPath)]);
expect(input.readableFlowing).toBe(true);
const late = new PassThrough();
capture.attach(late, logcatPath, 'device-logcat');
late.end('not captured');
expect(late.readableFlowing).toBe(true);
await expect(fsPromises.stat(logcatPath)).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
input.end();
}
});
it('continues with the other log after a failed upload and never repeats the collection', async () => {
await captureLog(emulatorPath, 'emulator-output', 'host output');
await captureLog(logcatPath, 'device-logcat', 'Android output');
upload.mockRejectedValueOnce(new Error('storage unavailable'));
await Promise.all([collect(), collect()]);
await collect();
expect(upload).toHaveBeenCalledTimes(2);
expect(uploadedContents.get(path.basename(logcatPath))).toBe('Android output');
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining('other logs will continue')
);
});
it('uses a fresh 30 second budget for the next file after an upload times out', async () => {
await captureLog(emulatorPath, 'emulator-output', 'host output');
await captureLog(logcatPath, 'device-logcat', 'Android output');
const controllers: AbortController[] = [];
const timeout = jest.spyOn(AbortSignal, 'timeout').mockImplementation(() => {
const controller = new AbortController();
controllers.push(controller);
return controller.signal;
});
upload.mockImplementationOnce(async (_ctx, { signal }) => {
controllers[0].abort(new Error('upload timed out'));
signal?.throwIfAborted();
});
await expect(collect()).resolves.toBeUndefined();
expect(uploadedContents.get(path.basename(logcatPath))).toBe('Android output');
expect(timeout.mock.calls).toEqual([[30_000], [30_000]]);
expect(upload.mock.calls[1][1].signal?.aborted).toBe(false);
});
it('does not collect another workflow and warns when a captured file disappears', async () => {
await captureLog(emulatorPath, 'emulator-output', 'other workflow', {} as BuildStepGlobalContext);
await collect();
expect(upload).not.toHaveBeenCalled();
await captureLog(path.join(directory, 'missing.log'), 'emulator-output', 'removed');
await rm(path.join(directory, 'missing.log'));
await expect(collect()).resolves.toBeUndefined();
expect(upload).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining('missing.log')
);
});
it('uploads the tail of a truncated log with the retained size and omission metadata', async () => {
const capture = getAndroidEmulatorLogCapture(global, { DEVICE_RUN_SESSION_ID: 'session' });
const input = Readable.from([
Buffer.concat([Buffer.from('head: '), Buffer.alloc(MAX_ANDROID_LOG_BYTES_PER_SOURCE, 'b')]),
]);
capture.attach(input, emulatorPath, 'emulator-output');
await once(input, 'end');
await collect();
expect(upload).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
size: MAX_ANDROID_LOG_BYTES_PER_SOURCE,
name: expect.stringContaining('truncated, last 20 MiB retained'),
metadata: expect.objectContaining({
originalSizeBytes: MAX_ANDROID_LOG_BYTES_PER_SOURCE + 6,
omittedBytes: 6,
truncated: true,
}),
})
);
const uploaded = uploadedContents.get(path.basename(emulatorPath));
expect(uploaded).toHaveLength(MAX_ANDROID_LOG_BYTES_PER_SOURCE);
expect(uploaded).not.toContain('head');
});
@@ -0,0 +1,102 @@
import { bunyan } from '@expo/logger';
import { CustomBuildContext } from '../../../customBuildContext';
import { watchDeviceRunSessionStartup } from '../deviceRunSessionStartup';
import {
DeviceRunSessionStoppedError,
waitForDeviceRunSessionStoppedAsync,
} from '../remoteDeviceRunSession';
import { createStartupTasks } from '../startupTasks';
jest.mock('../remoteDeviceRunSession', () => ({
...jest.requireActual('../remoteDeviceRunSession'),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
const ctx = {} as CustomBuildContext;
const logger = {
info: jest.fn(),
warn: jest.fn(),
child: jest.fn().mockReturnThis(),
} as unknown as bunyan;
/** Makes the status poll settle only when the test says so, or end when its signal aborts. */
function holdStatusPoll(): { stop: () => void; fail: (error: Error) => void } {
let stop!: () => void;
let fail!: (error: Error) => void;
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(
({ signal }) =>
new Promise<void>((resolve, reject) => {
stop = resolve;
fail = reject;
signal?.addEventListener('abort', () => resolve(), { once: true });
})
);
return { stop: () => stop(), fail: error => fail(error) };
}
function watch(startup = createStartupTasks(logger)) {
return {
startup,
watch: watchDeviceRunSessionStartup({ ctx, deviceRunSessionId: 'drs-id', logger, startup }),
};
}
describe(watchDeviceRunSessionStartup, () => {
beforeEach(() => jest.clearAllMocks());
it('aborts startup when the session is stopped', async () => {
const poll = holdStatusPoll();
const { startup, watch: startupWatch } = watch();
poll.stop();
await new Promise(resolve => setImmediate(resolve));
expect(startupWatch.stopped).toBeInstanceOf(DeviceRunSessionStoppedError);
expect(startup.signal.reason).toBe(startupWatch.stopped);
expect(startupWatch.isStopped()).toBe(true);
expect(logger.info).toHaveBeenCalledWith(
'Device run session drs-id was stopped during startup.'
);
await expect(startupWatch.completeAsync()).rejects.toBe(startupWatch.stopped);
});
it('ends the poll when startup completes, without aborting startup', async () => {
holdStatusPoll();
const { startup, watch: startupWatch } = watch();
await expect(startupWatch.completeAsync()).resolves.toBeUndefined();
const [{ signal }] = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0];
expect(signal?.aborted).toBe(true);
expect(startup.signal.aborted).toBe(false);
expect(startupWatch.isStopped()).toBe(false);
});
it('aborts startup with a status poll failure', async () => {
const poll = holdStatusPoll();
const { startup, watch: startupWatch } = watch();
const failed = new Error('status poll failed');
poll.fail(failed);
await new Promise(resolve => setImmediate(resolve));
expect(startup.signal.reason).toBe(failed);
expect(startupWatch.isStopped()).toBe(false);
});
it('keeps the first abort reason when startup ends for another reason', async () => {
holdStatusPoll();
const { startup, watch: startupWatch } = watch();
const failed = new Error('host startup failed');
startup.abort(failed);
await startupWatch.finishAsync();
expect(startup.signal.reason).toBe(failed);
expect(startupWatch.isStopped()).toBe(false);
expect(logger.info).not.toHaveBeenCalled();
});
it('follows the step signal', async () => {
holdStatusPoll();
const step = new AbortController();
const { startup, watch: startupWatch } = watch(createStartupTasks(logger, step.signal));
step.abort(new Error('step cancelled'));
await startupWatch.finishAsync();
expect(startup.signal.aborted).toBe(true);
expect(startupWatch.isStopped()).toBe(false);
});
});
@@ -13,6 +13,7 @@ import type { CustomBuildContext } from '../../../customBuildContext';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import { findUnlistedDeviceScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
import { uploadDeviceRunSessionFileAsync } from '../uploadDeviceRunSessionFile';
jest.unmock('node-fetch');
jest.unmock('node:fs');
@@ -123,6 +124,41 @@ it('replays the full file after a transient PUT failure without recreating the u
expect(streams.every(stream => stream.destroyed)).toBe(true);
});
it('retries a PUT that fails before reading the body without aborting the file stream', async () => {
let puts = 0;
let replayed = 0;
handle = (request, response) => {
puts++;
if (puts === 1) {
response.statusCode = 503;
response.flushHeaders();
return;
}
request.on('data', chunk => {
replayed += chunk.length;
});
request.on('end', () => response.end());
};
const file = path.join(directory, 'emulator.log');
const payload = Buffer.alloc(8 * 1024 * 1024, '*');
await writeFile(file, payload);
await expect(
uploadDeviceRunSessionFileAsync(ctx, {
filePath: file,
describe: () => ({
deviceRunSessionId: 'drs-id',
artifactId: 'log-id',
name: 'Emulator log',
filename: 'emulator.log',
kind: 'simulator-log',
}),
attempts: 2,
})
).resolves.toBe(true);
expect(puts).toBe(2);
expect(replayed).toBe(payload.length);
});
it('keeps a slow PUT alive while its body is still flowing', async () => {
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
let received = 0;
@@ -155,6 +155,53 @@ describe('serve-sim Simulator preparation', () => {
}
);
it('preserves the Stop reason when Simulator shutdown rejects after cancellation', async () => {
const controller = new AbortController();
const stopped = new Error('Session stopped');
const wrapped = new Error('Shutdown process aborted', { cause: stopped });
let shutdownEntered!: () => void;
const entered = new Promise<void>(resolve => {
shutdownEntered = resolve;
});
jest
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
.mockResolvedValue({ SERVE_SIM_ADDITIONAL_DYLIBS: '/guard.dylib' });
mockedUtils.getDeviceAsync.mockResolvedValue({ state: 'Booted' } as never);
mockedSpawn.mockImplementationOnce((_command, _args, options) => {
shutdownEntered();
return new Promise((_resolve, reject) =>
options!.signal!.addEventListener('abort', () => reject(wrapped), { once: true })
) as ReturnType<typeof spawn>;
});
const running = resolveIosSessionStartupAsync({
...hostOptions,
bootSimulator: true,
signal: controller.signal,
});
running.catch(() => {});
await entered;
controller.abort(stopped);
await expect(running).rejects.toBe(stopped);
expect(startLocalEgressGuardRelayAsync).not.toHaveBeenCalled();
});
it('preserves a genuine Simulator shutdown failure', async () => {
const failure = new Error('Could not shut down Simulator');
jest
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
.mockResolvedValue({ SERVE_SIM_ADDITIONAL_DYLIBS: '/guard.dylib' });
mockedUtils.getDeviceAsync.mockResolvedValue({ state: 'Booted' } as never);
mockedSpawn.mockRejectedValueOnce(failure);
await expect(
resolveIosSessionStartupAsync({
...hostOptions,
bootSimulator: true,
signal: new AbortController().signal,
})
).rejects.toBe(failure);
expect(startLocalEgressGuardRelayAsync).not.toHaveBeenCalled();
});
it('resolves an explicit Simulator without booting or configuring it', async () => {
const startup = await resolveIosSessionStartupAsync({
...hostOptions,
@@ -1851,6 +1851,23 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
});
it('preserves an errored status when the duration timer fires during the response', async () => {
const ctx = createStatusCtxMock([]);
jest.mocked(turtleFetch).mockImplementationOnce(async () => {
(jest.mocked(setTimeoutCallback).mock.calls[0][0] as () => void)();
return workerApiResponse({ id: 'drs-id', status: 'errored' });
});
await expect(
waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
logger: createLoggerMock(),
maxDurationSeconds: 30,
})
).rejects.toThrow('Device run session drs-id errored.');
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
});
it('clears the duration timeout when the build step is aborted', async () => {
const ctx = createStatusCtxMock([{ status: 'in-progress' }]);
const abortController = new AbortController();
@@ -1869,6 +1886,33 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
});
it('does not warn when retiring a watch aborts its pending status request', async () => {
const controller = new AbortController();
const logger = createLoggerMock();
const ctx = createCtxMock();
ctx.env.EAS_DEVICE_RUN_SESSION_TOKEN = 'worker-token';
jest.mocked(turtleFetch).mockImplementation(
async (_url, _method, options) =>
new Promise((_resolve, reject) => {
options!.signal!.addEventListener('abort', () => reject(new Error('request aborted')), {
once: true,
});
})
);
const waiting = waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
logger,
signal: controller.signal,
});
controller.abort();
await waiting;
expect(logger.warn).not.toHaveBeenCalled();
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('logs and retries transient polling errors', async () => {
const ctx = createStatusCtxMock([{ error: new Error('network down') }, { status: 'stopped' }]);
const logger = createLoggerMock();
@@ -2050,6 +2094,65 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
});
});
describe('remote configuration cancellation', () => {
beforeEach(() => jest.mocked(turtleFetch).mockReset());
it('does not report readiness for an already stopped startup', async () => {
const controller = new AbortController();
const stopped = new Error('startup stopped');
controller.abort(stopped);
await expect(
remoteDeviceRunSession.uploadRemoteSessionConfigAsync({
ctx: createCtxMock(),
deviceRunSessionId: 'drs-id',
remoteConfig: {},
logger: createLoggerMock(),
signal: controller.signal,
})
).rejects.toBe(stopped);
expect(turtleFetch).not.toHaveBeenCalled();
});
it('forwards cancellation to the in-flight readiness HTTP request', async () => {
const controller = new AbortController();
const ctx = createCtxMock();
ctx.env.EAS_DEVICE_RUN_SESSION_TOKEN = 'worker-token';
const stopped = new Error('session cancelled');
let entered!: () => void;
const requested = new Promise<void>(resolve => {
entered = resolve;
});
let requestAborted = false;
jest.mocked(turtleFetch).mockImplementation(
async (_url, _method, options) =>
new Promise((_resolve, reject) => {
entered();
options!.signal!.addEventListener(
'abort',
() => {
requestAborted = true;
reject(new Error('ready request aborted'));
},
{ once: true }
);
})
);
const reporting = remoteDeviceRunSession.uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId: 'drs-id',
remoteConfig: {},
logger: createLoggerMock(),
signal: controller.signal,
});
await requested;
controller.abort(stopped);
await expect(reporting).rejects.toBe(stopped);
expect(requestAborted).toBe(true);
});
});
describe(ensureFfmpegInstalledOnceAsync, () => {
const spawnMock = jest.mocked(spawn);
let binDirectory: string;
@@ -22,29 +22,17 @@ it('uploads NDJSON with explicit user-app scope', async () => {
kind: 'simulator-log',
filename: 'app-logs.ndjson',
size: 11,
attempts: 1,
metadata: expect.objectContaining({ scope: 'user-apps', udid: 'A' }),
})
);
});
it('skips empty and missing files', async () => {
await uploadServeSimLogsFileAsync(ctx, args);
await writeFile(filePath, '');
await uploadServeSimLogsFileAsync(ctx, args);
expect(uploadDeviceRunSessionArtifactAsync).not.toHaveBeenCalled();
});
it('aborts the upload on a file read error without an unhandled error', async () => {
it('warns and resolves when the upload fails', async () => {
await writeFile(filePath, '{}\n');
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { openStream, signal }) => {
const aborted = new Promise<void>((_resolve, reject) => {
signal!.addEventListener('abort', () => reject(signal!.reason), { once: true });
});
openStream().destroy(new Error('read failure'));
await aborted;
});
.mockRejectedValueOnce(new Error('storage unavailable'));
await expect(uploadServeSimLogsFileAsync(ctx, args)).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
@@ -0,0 +1,79 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { type CustomBuildContext } from '../../../customBuildContext';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import { uploadDeviceRunSessionFileAsync } from '../uploadDeviceRunSessionFile';
jest.mock('../deviceRunSessionArtifacts');
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
const ctx = {} as CustomBuildContext;
const upload = jest.mocked(uploadDeviceRunSessionArtifactAsync);
const describeFile = (size: number) => ({
deviceRunSessionId: 'session',
artifactId: 'log',
name: `log (${size} bytes)`,
filename: 'log.txt',
kind: 'simulator-log',
});
let directory: string;
let filePath: string;
beforeEach(async () => {
directory = await mkdtemp(path.join(os.tmpdir(), 'device-run-session-file-'));
filePath = path.join(directory, 'log.txt');
});
afterEach(async () => {
await rm(directory, { recursive: true, force: true });
});
function uploadFile(options: { signal?: AbortSignal; attempts?: number } = {}): Promise<boolean> {
return uploadDeviceRunSessionFileAsync(ctx, { filePath, describe: describeFile, ...options });
}
it('skips an empty file without uploading', async () => {
await writeFile(filePath, '');
expect(await uploadFile()).toBe(false);
expect(upload).not.toHaveBeenCalled();
});
it('rejects a missing file before uploading', async () => {
await expect(uploadFile()).rejects.toMatchObject({ code: 'ENOENT' });
expect(upload).not.toHaveBeenCalled();
});
it('describes the file by its size and forwards attempts and the caller signal', async () => {
await writeFile(filePath, 'abc');
const controller = new AbortController();
expect(await uploadFile({ signal: controller.signal, attempts: 2 })).toBe(true);
expect(upload).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ name: 'log (3 bytes)', size: 3, attempts: 2, filename: 'log.txt' })
);
const { signal } = upload.mock.calls[0][1];
const reason = new Error('stop');
controller.abort(reason);
expect(signal?.reason).toBe(reason);
});
it('aborts the upload on a file read error and ignores an aborted request body', async () => {
await writeFile(filePath, '{}\n');
const readError = Object.assign(new Error('read failed'), { code: 'EIO' });
upload.mockImplementationOnce(async (_ctx, { openStream, signal }) => {
const aborted = openStream();
aborted.destroy(Object.assign(new Error('request body aborted'), { name: 'AbortError' }));
await new Promise<void>(resolve => aborted.once('close', resolve));
expect(signal?.aborted).toBe(false);
const failed = openStream();
failed.destroy(readError);
await new Promise<void>(resolve => failed.once('close', resolve));
expect(signal?.reason).toBe(readError);
});
expect(await uploadFile()).toBe(true);
});
@@ -0,0 +1,140 @@
import { type bunyan } from '@expo/logger';
import { type BuildStepGlobalContext } from '@expo/steps';
import path from 'node:path';
import { type CustomBuildContext } from '../../customBuildContext';
import {
AndroidEmulatorLogCapture,
type FinalizedAndroidEmulatorLog,
MAX_ANDROID_LOG_BYTES_PER_SOURCE,
} from '../../utils/androidEmulatorLogCapture';
import { type AndroidEmulatorLogSource } from '../../utils/androidEmulatorLogSources';
import { uploadDeviceRunSessionFileAsync } from './uploadDeviceRunSessionFile';
const UPLOAD_TIMEOUT_MS = 30_000;
const LOG_SOURCE_DETAILS = {
'emulator-output': { name: 'Emulator output', scope: 'emulator-process' },
'device-logcat': { name: 'Android device logcat', scope: 'device' },
} satisfies Record<AndroidEmulatorLogSource, { name: string; scope: string }>;
type Collection = {
capture: AndroidEmulatorLogCapture;
upload?: Promise<void>;
};
// A worker can execute more than one job; each workflow owns its capture and uploads.
const collections = new WeakMap<BuildStepGlobalContext, Collection>();
export function getAndroidEmulatorLogCapture(
global: BuildStepGlobalContext,
env: NodeJS.ProcessEnv
): AndroidEmulatorLogCapture {
let collection = collections.get(global);
if (!collection) {
collection = {
capture: new AndroidEmulatorLogCapture(
env.DEVICE_RUN_SESSION_ID ? MAX_ANDROID_LOG_BYTES_PER_SOURCE : Infinity
),
};
collections.set(global, collection);
}
return collection.capture;
}
export async function collectAndroidEmulatorLogsAsync(
ctx: CustomBuildContext,
{
global,
deviceRunSessionId,
logger,
}: {
global: BuildStepGlobalContext;
deviceRunSessionId: string;
logger: bunyan;
}
): Promise<void> {
const collection = collections.get(global);
if (!collection) {
logger.info('No Android emulator logs captured; skipping upload.');
return;
}
// Generated session workflows collect once. Repeated calls share that finalization/upload.
collection.upload ??= uploadCapturedLogsAsync(ctx, {
collection,
deviceRunSessionId,
logger,
});
await collection.upload;
}
async function uploadCapturedLogsAsync(
ctx: CustomBuildContext,
{
collection,
deviceRunSessionId,
logger,
}: {
collection: Collection;
deviceRunSessionId: string;
logger: bunyan;
}
): Promise<void> {
const logs = await collection.capture.finishAsync();
for (const log of logs) {
const filename = path.basename(log.filePath);
try {
const uploaded = await uploadLogAsync(ctx, {
deviceRunSessionId,
log,
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
});
if (uploaded) {
logger.info(`Uploaded Android diagnostic log ${filename}.`);
}
} catch (err) {
logger.warn({ err }, `Could not upload Android log ${filename}; other logs will continue.`);
}
}
}
async function uploadLogAsync(
ctx: CustomBuildContext,
{
deviceRunSessionId,
log: { filePath, source, bytesSeen },
signal,
}: {
deviceRunSessionId: string;
log: FinalizedAndroidEmulatorLog;
signal: AbortSignal;
}
): Promise<boolean> {
const filename = path.basename(filePath);
return await uploadDeviceRunSessionFileAsync(ctx, {
filePath,
describe: size => {
const retainedSize =
size >= 1024 * 1024 ? `${Number((size / (1024 * 1024)).toFixed(2))} MiB` : `${size} bytes`;
const truncationNote = size < bytesSeen ? `; truncated, last ${retainedSize} retained` : '';
return {
deviceRunSessionId,
artifactId: filename,
name: `${LOG_SOURCE_DETAILS[source].name} (${filename}${truncationNote})`,
filename,
kind: 'simulator-log',
metadata: {
__eas_type: 'simulator-log',
platform: 'android',
source,
scope: LOG_SOURCE_DETAILS[source].scope,
originalSizeBytes: bytesSeen,
// The capture keeps the newest bytes, so the omitted bytes are the head.
omittedBytes: bytesSeen - size,
truncated: size < bytesSeen,
},
};
},
signal,
attempts: 2,
});
}
@@ -11,7 +11,7 @@ import {
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
type ArtifactMetadata = {
export type ArtifactMetadata = {
deviceRunSessionId: string;
artifactId: string;
name: string;
@@ -0,0 +1,69 @@
import { type bunyan } from '@expo/logger';
import { type CustomBuildContext } from '../../customBuildContext';
import {
DeviceRunSessionStoppedError,
waitForDeviceRunSessionStoppedAsync,
} from './remoteDeviceRunSession';
import { type StartupTasks } from './startupTasks';
export type DeviceRunSessionStartupWatch = {
/** The startup abort reason when a Stop request arrives during startup. */
readonly stopped: DeviceRunSessionStoppedError;
/** Whether a Stop request aborted startup. */
isStopped(): boolean;
/** Ends the watch after the session reports ready. Throws if startup was aborted meanwhile. */
completeAsync(): Promise<void>;
/** Ends the watch without throwing, for cleanup. Safe to call more than once. */
finishAsync(): Promise<void>;
};
/**
* Polls for a Stop request while the session starts up, and aborts startup with `stopped` when
* one arrives. A poll failure aborts startup with that error. Call `completeAsync` before the
* lifetime wait, so only one poll runs at a time, and `finishAsync` in cleanup.
*/
export function watchDeviceRunSessionStartup({
ctx,
deviceRunSessionId,
logger,
startup,
}: {
ctx: CustomBuildContext;
deviceRunSessionId: string;
logger: bunyan;
startup: Pick<StartupTasks, 'signal' | 'abort'>;
}): DeviceRunSessionStartupWatch {
const stopped = new DeviceRunSessionStoppedError(
`Device run session ${deviceRunSessionId} was stopped during startup.`
);
const done = new AbortController();
const watchSignal = AbortSignal.any([startup.signal, done.signal]);
const watching = waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
signal: watchSignal,
}).then(
() => {
if (!watchSignal.aborted) {
logger.info(stopped.message);
startup.abort(stopped);
}
},
error => startup.abort(error)
);
const finishAsync = async (): Promise<void> => {
done.abort();
await watching;
};
return {
stopped,
isStopped: () => startup.signal.reason === stopped,
completeAsync: async () => {
await finishAsync();
startup.signal.throwIfAborted();
},
finishAsync,
};
}
@@ -97,7 +97,12 @@ export async function resolveIosSessionStartupAsync({
signal?.throwIfAborted();
if (device && device.state !== 'Shutdown') {
logger.info(`Shutting down ${iosSimulatorUdid} to apply the local egress guard at boot.`);
await spawn('xcrun', ['simctl', 'shutdown', iosSimulatorUdid], { env, logger, signal });
try {
await spawn('xcrun', ['simctl', 'shutdown', iosSimulatorUdid], { env, logger, signal });
} catch (error) {
signal?.throwIfAborted();
throw error;
}
}
signal?.throwIfAborted();
await startLocalEgressGuardRelayAsync({ logger });
@@ -37,6 +37,7 @@ export async function uploadRemoteSessionConfigWithLocalEgressAsync({
signal?.throwIfAborted();
const sessionStart = await uploadRemoteSessionConfigAsync({
...options,
signal,
remoteConfig: { ...options.remoteConfig, ...buildEgressRemoteConfigFields(localEgress) },
});
if (sessionStart === 'stopped') {
@@ -129,6 +129,8 @@ export type DeviceRunSessionIdleTimeout = {
getLastPreviewActivityAt?: () => Date | undefined;
};
export class DeviceRunSessionStoppedError extends Error {}
export async function waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
@@ -162,9 +164,7 @@ export async function waitForDeviceRunSessionStoppedAsync({
}, maxDurationSeconds * 1_000);
try {
logger.info(
`Remote session is live. Polling device run session ${deviceRunSessionId} until it is stopped.`
);
logger.info(`Polling device run session ${deviceRunSessionId} until it is stopped.`);
if (durationTimeout !== undefined) {
logger.info(
`The device run session will stop automatically after ${maxDurationSeconds} seconds.`
@@ -211,6 +211,9 @@ export async function waitForDeviceRunSessionStoppedAsync({
if (err instanceof SystemError) {
throw err;
}
if (signal.aborted) {
return;
}
const error = err instanceof Error ? err : new Error(String(err));
pollErrorCount += 1;
@@ -556,12 +559,15 @@ export async function uploadRemoteSessionConfigAsync({
deviceRunSessionId,
remoteConfig,
logger,
signal,
}: {
ctx: CustomBuildContext;
deviceRunSessionId: string;
remoteConfig: Record<string, unknown>;
logger: bunyan;
signal?: AbortSignal;
}): Promise<'started' | 'stopped'> {
signal?.throwIfAborted();
logger.info(
`Reporting remote config to the API server (device run session: ${deviceRunSessionId}).`
);
@@ -569,10 +575,11 @@ export async function uploadRemoteSessionConfigAsync({
try {
await workerApi.startAsync(
{ remoteConfig },
{ retries: 3, timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS, logger }
{ retries: 3, timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS, logger, signal }
);
return 'started';
} catch (err) {
signal?.throwIfAborted();
if (
err instanceof DeviceRunSessionWorkerApiError &&
err.code === 'DEVICE_RUN_SESSION_ALREADY_STOPPED'
@@ -1,9 +1,7 @@
import { type bunyan } from '@expo/logger';
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { type CustomBuildContext } from '../../customBuildContext';
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
import { uploadDeviceRunSessionFileAsync } from './uploadDeviceRunSessionFile';
export async function uploadServeSimLogsFileAsync(
ctx: CustomBuildContext,
@@ -22,21 +20,22 @@ export async function uploadServeSimLogsFileAsync(
}
): Promise<void> {
try {
const { size } = await stat(filePath);
if (size === 0) {
return;
}
const fileController = new AbortController();
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId,
artifactId: `simulator-log-${udid}`,
name: `App logs (${udid.slice(0, 8)})`,
filename: 'app-logs.ndjson',
kind: 'simulator-log',
metadata: { __eas_type: 'simulator-log', udid, scope: 'user-apps', source: 'serve-sim/logs' },
size,
openStream: () => createReadStream(filePath).on('error', err => fileController.abort(err)),
signal: AbortSignal.any([fileController.signal, ...(signal ? [signal] : [])]),
await uploadDeviceRunSessionFileAsync(ctx, {
filePath,
describe: () => ({
deviceRunSessionId,
artifactId: `simulator-log-${udid}`,
name: `App logs (${udid.slice(0, 8)})`,
filename: 'app-logs.ndjson',
kind: 'simulator-log',
metadata: {
__eas_type: 'simulator-log',
udid,
scope: 'user-apps',
source: 'serve-sim/logs',
},
}),
signal,
});
} catch (err) {
logger.warn(
@@ -0,0 +1,44 @@
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { type CustomBuildContext } from '../../customBuildContext';
import {
type ArtifactMetadata,
uploadDeviceRunSessionArtifactAsync,
} from './deviceRunSessionArtifacts';
/** Upload a finalized file, reopening it for each attempt. */
export async function uploadDeviceRunSessionFileAsync(
ctx: CustomBuildContext,
{
filePath,
describe,
signal,
attempts = 1,
}: {
filePath: string;
describe: (size: number) => Omit<ArtifactMetadata, 'size'>;
signal?: AbortSignal;
attempts?: number;
}
): Promise<boolean> {
const { size } = await stat(filePath);
if (size === 0) {
return false;
}
const readController = new AbortController();
await uploadDeviceRunSessionArtifactAsync(ctx, {
...describe(size),
size,
openStream: () =>
createReadStream(filePath).on('error', err => {
// An HTTP error aborts one request body, not the remaining upload attempts.
if (err.name !== 'AbortError') {
readController.abort(err);
}
}),
signal: AbortSignal.any([readController.signal, ...(signal ? [signal] : [])]),
attempts,
});
return true;
}
@@ -13,6 +13,8 @@ import { setTimeout } from 'node:timers/promises';
import { z } from 'zod';
import { Sentry } from '../sentry';
import { type AndroidEmulatorLogSource } from './androidEmulatorLogSources';
import { type AndroidEmulatorLogCapture } from './androidEmulatorLogCapture';
import { retryAsync } from './retry';
/** Android Virtual Device is the device we run. */
@@ -326,20 +328,22 @@ export namespace AndroidEmulatorUtils {
deviceName,
env,
logcatDirectory,
logSources,
launchGate,
logCapture,
}: {
deviceName: AndroidVirtualDeviceName;
env: NodeJS.ProcessEnv;
logcatDirectory: string;
logSources: readonly AndroidEmulatorLogSource[];
launchGate?: AndroidEmulatorLaunchGate;
logCapture: AndroidEmulatorLogCapture;
}): Promise<{
emulatorPromise: SpawnPromise<SpawnResult>;
serialId: AndroidDeviceSerialId;
logcatOutputPath: string;
emulatorOutputPath: string;
}> {
let logcatOutputPath: string;
let emulatorOutputPath: string;
let logcatOutputPath: string | undefined;
let emulatorOutputPath: string | undefined;
try {
await fs.promises.mkdir(logcatDirectory, { recursive: true });
const safeDeviceName = deviceName.replace(/[^a-zA-Z0-9_.-]/g, '_');
@@ -347,16 +351,23 @@ export namespace AndroidEmulatorUtils {
.toString(16)
.padStart(8, '0');
const outputName = `${safeDeviceName}-${timestamp}-${randomBytes(2).toString('hex')}`;
logcatOutputPath = path.join(logcatDirectory, `${outputName}-logcat.log`);
emulatorOutputPath = path.join(logcatDirectory, `${outputName}-emulator.log`);
await fs.promises.writeFile(logcatOutputPath, '');
await fs.promises.writeFile(emulatorOutputPath, '');
if (logSources.includes('device-logcat')) {
logcatOutputPath = path.join(logcatDirectory, `${outputName}-logcat.log`);
await fs.promises.writeFile(logcatOutputPath, '');
}
if (logSources.includes('emulator-output')) {
emulatorOutputPath = path.join(logcatDirectory, `${outputName}-emulator.log`);
await fs.promises.writeFile(emulatorOutputPath, '');
}
} catch (err) {
throw new SystemError(`Failed to prepare Android emulator output for ${deviceName}.`, {
cause: err,
});
}
const logcatOutput = logcatOutputPath
? await logCapture.prepareLogcatOutputAsync(logcatOutputPath)
: undefined;
const emulatorCommand = `${process.env.ANDROID_HOME}/emulator/emulator`;
const emulatorArgs = [
'-no-window',
@@ -364,10 +375,7 @@ export namespace AndroidEmulatorUtils {
'-writable-system',
'-noaudio',
'-no-snapshot-save',
'-logcat',
'*:v',
'-logcat-output',
logcatOutputPath,
...(logcatOutput ? ['-logcat', '*:v', '-logcat-output', logcatOutput.path] : []),
'-avd',
deviceName,
'-accel',
@@ -381,49 +389,37 @@ export namespace AndroidEmulatorUtils {
// We don't need to wait for emulator to exit gracefully.
ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1',
};
const outputStdio = emulatorOutputPath ? 'pipe' : 'ignore';
const emulatorPromise = launchGate
? spawn(
launchGate.wrapperCommand,
[...launchGate.wrapperArgs, emulatorCommand, ...emulatorArgs, ...launchGate.emulatorArgs],
{
detached: true,
stdio: ['pipe', 'pipe', 'pipe'],
stdio: ['pipe', outputStdio, outputStdio],
ignoreStdio: true,
env: emulatorEnv,
}
)
: spawn(emulatorCommand, emulatorArgs, {
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ['ignore', outputStdio, outputStdio],
ignoreStdio: true,
env: emulatorEnv,
});
const emulatorOutputStream = fs.createWriteStream(emulatorOutputPath, { flags: 'a' });
let reportedEmulatorOutputError = false;
emulatorOutputStream.on('error', err => {
process.stderr.write(
`Failed to write Android emulator output to ${emulatorOutputPath}: ${err}\n`
);
if (!reportedEmulatorOutputError) {
reportedEmulatorOutputError = true;
Sentry.capture('Failed to write Android emulator process output', err, {
level: 'warning',
tags: {
errorCode: (err as NodeJS.ErrnoException).code ?? 'unknown',
},
extras: {
deviceName,
emulatorOutputPath,
},
});
if (logcatOutput) {
emulatorPromise.child.once('close', logcatOutput.close);
}
if (emulatorOutputPath) {
for (const input of [emulatorPromise.child.stdout, emulatorPromise.child.stderr]) {
if (input) {
logCapture.attach(input, emulatorOutputPath, 'emulator-output');
}
}
});
// Only into the log file -- this process' stdout/stderr is not watched or uploaded.
emulatorPromise.child.stdout?.pipe(emulatorOutputStream, { end: false });
emulatorPromise.child.stderr?.pipe(emulatorOutputStream, { end: false });
emulatorPromise.child.once('close', () => {
emulatorOutputStream.end();
});
emulatorPromise.child.once('close', () => {
void logCapture.finishFileAsync(emulatorOutputPath);
});
}
// Piped stdio creates socket handles in this process which, unlike inherited
// file descriptors, keep the event loop alive for as long as the emulator runs.
// We never stop the emulator explicitly, so without unref-ing these the process
@@ -473,7 +469,7 @@ export namespace AndroidEmulatorUtils {
// We don't want to await the SpawnPromise here.
// eslint-disable-next-line @typescript-eslint/return-await
return { emulatorPromise, serialId, logcatOutputPath, emulatorOutputPath };
return { emulatorPromise, serialId };
}
export async function waitForReadyAsync({
@@ -13,6 +13,8 @@ import {
AndroidVirtualDeviceName,
} from '../AndroidEmulatorUtils';
import { retryAsync } from '../retry';
import { AndroidEmulatorLogCapture } from '../androidEmulatorLogCapture';
import { ALL_ANDROID_EMULATOR_LOG_SOURCES } from '../androidEmulatorLogSources';
// We need to use real fs for cloning devices to work.
jest.unmock('fs');
@@ -78,6 +80,8 @@ describe('AndroidEmulatorUtils', () => {
logger: createMockLogger({ logToConsole: true }),
});
({ serialId } = await AndroidEmulatorUtils.startAsync({
logCapture: new AndroidEmulatorLogCapture(Infinity),
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory,
@@ -105,6 +109,8 @@ describe('AndroidEmulatorUtils', () => {
logger: createMockLogger({ logToConsole: true }),
});
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
logCapture: new AndroidEmulatorLogCapture(Infinity),
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory,
@@ -135,6 +141,8 @@ describe('AndroidEmulatorUtils', () => {
const { serialId: serialIdClone, emulatorPromise: emulatorPromiseClone } =
await AndroidEmulatorUtils.startAsync({
logCapture: new AndroidEmulatorLogCapture(Infinity),
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
deviceName: cloneDeviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory,
@@ -172,6 +180,8 @@ describe('AndroidEmulatorUtils', () => {
});
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
logCapture: new AndroidEmulatorLogCapture(Infinity),
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory,
@@ -226,6 +236,8 @@ describe('AndroidEmulatorUtils', () => {
});
const startResult = await AndroidEmulatorUtils.startAsync({
logCapture: new AndroidEmulatorLogCapture(Infinity),
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
deviceName,
env: envForAttempt,
logcatDirectory,
@@ -309,6 +321,8 @@ describe('AndroidEmulatorUtils', () => {
logger,
});
const startResult = await AndroidEmulatorUtils.startAsync({
logCapture: new AndroidEmulatorLogCapture(Infinity),
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory: testLogcatDirectory,
@@ -319,7 +333,16 @@ describe('AndroidEmulatorUtils', () => {
await AndroidEmulatorUtils.waitForReadyAsync({ serialId, env: process.env });
await retryAsync(
async () => {
const contents = await fs.promises.readFile(startResult.emulatorOutputPath, 'utf-8');
const filename = (await fs.promises.readdir(testLogcatDirectory)).find(file =>
file.endsWith('-emulator.log')
);
if (!filename) {
throw new Error('Missing emulator output');
}
const contents = await fs.promises.readFile(
path.join(testLogcatDirectory, filename),
'utf-8'
);
if (!contents.includes('Android emulator version')) {
throw new Error('Did not find the emulator version in emulator process output yet.');
}
@@ -340,7 +363,16 @@ describe('AndroidEmulatorUtils', () => {
await retryAsync(
async () => {
const contents = await fs.promises.readFile(startResult.logcatOutputPath, 'utf-8');
const filename = (await fs.promises.readdir(testLogcatDirectory)).find(file =>
file.endsWith('-logcat.log')
);
if (!filename) {
throw new Error('Missing logcat output');
}
const contents = await fs.promises.readFile(
path.join(testLogcatDirectory, filename),
'utf-8'
);
if (!contents.includes(marker)) {
throw new Error(`Did not find marker ${marker} in logcat output yet.`);
}
@@ -384,6 +416,8 @@ describe('AndroidEmulatorUtils', () => {
});
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
logCapture: new AndroidEmulatorLogCapture(Infinity),
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
logcatDirectory,
@@ -1,6 +1,6 @@
import { SystemError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { EventEmitter, once } from 'node:events';
import { EventEmitter } from 'node:events';
import fs from 'node:fs';
import { Socket } from 'node:net';
import os from 'node:os';
@@ -14,6 +14,16 @@ import {
AndroidVirtualDeviceName,
} from '../AndroidEmulatorUtils';
import { retryAsync } from '../retry';
import { AndroidEmulatorLogCapture } from '../androidEmulatorLogCapture';
import {
ALL_ANDROID_EMULATOR_LOG_SOURCES,
AndroidEmulatorLogSourcesSchema,
} from '../androidEmulatorLogSources';
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
@@ -35,9 +45,11 @@ const mockedRetryAsync = jest.mocked(retryAsync);
describe('AndroidEmulatorUtils', () => {
let temporaryDirectories: string[] = [];
let homeDirectory: string;
beforeEach(() => {
temporaryDirectories = [];
beforeEach(async () => {
homeDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'android-utils-home-'));
temporaryDirectories = [homeDirectory];
jest.mocked(Sentry.capture).mockReset();
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedRetryAsync.mockImplementation(async fn => await fn(0));
@@ -55,7 +67,7 @@ describe('AndroidEmulatorUtils', () => {
describe(AndroidEmulatorUtils.createAsync, () => {
it('applies an explicit LCD configuration after screen scaling', async () => {
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
const avdDirectory = `${homeDirectory}/.android/avd/${deviceName}.avd`;
await fs.promises.mkdir(avdDirectory, { recursive: true });
await fs.promises.writeFile(
`${avdDirectory}/config.ini`,
@@ -83,7 +95,7 @@ describe('AndroidEmulatorUtils', () => {
lcdHeight: 1600,
lcdDensity: 262,
env: {
HOME: '/home/expo',
HOME: homeDirectory,
ANDROID_EMULATOR_ADJUST_SCREEN: '1',
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
},
@@ -141,7 +153,7 @@ describe('AndroidEmulatorUtils', () => {
['device-run-session-id', 4096],
])('configures memory for session %s without an LCD override', async (sessionId, ramSize) => {
const deviceName = 'generic-emulator' as AndroidVirtualDeviceName;
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
const avdDirectory = `${homeDirectory}/.android/avd/${deviceName}.avd`;
const initialConfig = ['skin.path=pixel_9', 'showDeviceFrame=yes'].join('\n');
await fs.promises.mkdir(avdDirectory, { recursive: true });
await fs.promises.writeFile(`${avdDirectory}/config.ini`, initialConfig);
@@ -157,7 +169,7 @@ describe('AndroidEmulatorUtils', () => {
lcdHeight: null,
lcdDensity: null,
env: {
HOME: '/home/expo',
HOME: homeDirectory,
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
DEVICE_RUN_SESSION_ID: sessionId,
},
@@ -172,6 +184,24 @@ describe('AndroidEmulatorUtils', () => {
});
describe(AndroidEmulatorUtils.startAsync, () => {
let buildCapture: AndroidEmulatorLogCapture;
beforeEach(() => {
buildCapture = new AndroidEmulatorLogCapture(Infinity);
});
afterEach(async () => {
await buildCapture.finishAsync();
});
async function logFile(directory: string, source: 'logcat' | 'emulator'): Promise<string> {
const filename = (await fs.promises.readdir(directory)).find(file =>
file.endsWith(`-${source}.log`)
);
if (!filename) {
throw new Error(`Missing ${source} log in ${directory}`);
}
return path.join(directory, filename);
}
function mockSuccessfulStart(deviceName: AndroidVirtualDeviceName) {
// Under `stdio: 'pipe'` these are net.Sockets, which is what startAsync
// narrows on before unref-ing them.
@@ -202,35 +232,30 @@ describe('AndroidEmulatorUtils', () => {
return { child };
}
it('captures logcat and emulator process output', async () => {
it('names log files after the device and detaches the emulator', async () => {
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'logcat-staging-'));
temporaryDirectories.push(outputDir);
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
const { child } = mockSuccessfulStart(deviceName);
const createWriteStreamSpy = jest.spyOn(fs, 'createWriteStream');
const result = await AndroidEmulatorUtils.startAsync({
await AndroidEmulatorUtils.startAsync({
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
logCapture: buildCapture,
deviceName,
env: process.env,
logcatDirectory: outputDir,
});
expect(result.logcatOutputPath).toMatch(
expect(await logFile(outputDir, 'logcat')).toMatch(
new RegExp(`^${outputDir}/eas-simulator-[a-f0-9]{8}-[a-f0-9]{4}-logcat\\.log$`)
);
expect(result.emulatorOutputPath).toMatch(
expect(await logFile(outputDir, 'emulator')).toMatch(
new RegExp(`^${outputDir}/eas-simulator-[a-f0-9]{8}-[a-f0-9]{4}-emulator\\.log$`)
);
await expect(fs.promises.access(result.logcatOutputPath)).resolves.toBeUndefined();
await expect(fs.promises.access(result.emulatorOutputPath)).resolves.toBeUndefined();
expect(mockedSpawn).toHaveBeenCalledWith(
expect.stringMatching(/\/emulator\/emulator$/),
expect.arrayContaining(['-logcat', '*:v', '-logcat-output', result.logcatOutputPath]),
expect.objectContaining({
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
ignoreStdio: true,
})
expect.any(Array),
expect.objectContaining({ detached: true, ignoreStdio: true })
);
expect(mockedSpawn).not.toHaveBeenCalledWith(
'adb',
@@ -242,28 +267,85 @@ describe('AndroidEmulatorUtils', () => {
// for as long as the emulator runs.
expect(child.stdout.unref).toHaveBeenCalled();
expect(child.stderr.unref).toHaveBeenCalled();
const emulatorOutputStream = createWriteStreamSpy.mock.results[0].value;
const stderrWriteSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
const writeError = Object.assign(new Error('disk full'), { code: 'ENOSPC' });
emulatorOutputStream.emit('error', writeError);
emulatorOutputStream.emit('error', writeError);
expect(stderrWriteSpy).toHaveBeenCalledTimes(2);
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
'Failed to write Android emulator process output',
writeError,
{
level: 'warning',
tags: { errorCode: 'ENOSPC' },
extras: {
deviceName,
emulatorOutputPath: result.emulatorOutputPath,
},
}
);
const emulatorOutputStreamClosed = once(emulatorOutputStream, 'close');
child.emit('close');
await emulatorOutputStreamClosed;
});
it.each([
{ sources: [] },
{ sources: ['emulator-output'] },
{ sources: ['device-logcat'] },
{ sources: ['emulator-output', 'device-logcat'] },
])('records only $sources', async ({ sources }) => {
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'selected-logs-'));
temporaryDirectories.push(outputDir);
const deviceName = 'selected-emulator' as AndroidVirtualDeviceName;
const { child } = mockSuccessfulStart(deviceName);
const stdoutPipe = jest.spyOn(child.stdout, 'pipe');
const stderrPipe = jest.spyOn(child.stderr, 'pipe');
const prepare = jest.spyOn(buildCapture, 'prepareLogcatOutputAsync');
await AndroidEmulatorUtils.startAsync({
logCapture: buildCapture,
deviceName,
env: {},
logcatDirectory: outputDir,
logSources: AndroidEmulatorLogSourcesSchema.parse(sources),
});
const capturesOutput = sources.includes('emulator-output');
const capturesLogcat = sources.includes('device-logcat');
const [, args, options] = mockedSpawn.mock.calls[0];
expect(prepare).toHaveBeenCalledTimes(capturesLogcat ? 1 : 0);
expect(args.includes('-logcat')).toBe(capturesLogcat);
expect(args.includes('-logcat-output')).toBe(capturesLogcat);
expect(options?.stdio).toEqual([
'ignore',
capturesOutput ? 'pipe' : 'ignore',
capturesOutput ? 'pipe' : 'ignore',
]);
expect(stdoutPipe).toHaveBeenCalledTimes(capturesOutput ? 1 : 0);
expect(stderrPipe).toHaveBeenCalledTimes(capturesOutput ? 1 : 0);
if (capturesOutput) {
child.stdout.emit('data', Buffer.from('stdout marker\n'));
child.stderr.emit('data', Buffer.from('stderr marker\n'));
child.emit('close');
await buildCapture.finishAsync();
await expect(
fs.promises.readFile(await logFile(outputDir, 'emulator'), 'utf8')
).resolves.toBe('stdout marker\nstderr marker\n');
}
const files = await fs.promises.readdir(outputDir);
expect(files).toHaveLength(sources.length);
expect(files.some(file => file.endsWith('-logcat.log'))).toBe(capturesLogcat);
expect(files.some(file => file.endsWith('-emulator.log'))).toBe(capturesOutput);
});
it('routes a session logcat through the capture pipe and closes it with the emulator', async () => {
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'bounded-logs-'));
temporaryDirectories.push(outputDir);
const deviceName = 'bounded-emulator' as AndroidVirtualDeviceName;
const { child } = mockSuccessfulStart(deviceName);
const capture = new AndroidEmulatorLogCapture();
const close = jest.fn();
const createPipe = jest.spyOn(capture, 'prepareLogcatOutputAsync').mockResolvedValue({
path: '/logcat.pipe',
close,
closed: Promise.resolve(),
});
await AndroidEmulatorUtils.startAsync({
deviceName,
env: {},
logcatDirectory: outputDir,
logSources: ['device-logcat'],
logCapture: capture,
});
expect(createPipe).toHaveBeenCalledWith(await logFile(outputDir, 'logcat'));
expect(mockedSpawn.mock.calls[0][1]).toEqual(
expect.arrayContaining(['-logcat-output', '/logcat.pipe'])
);
expect(close).not.toHaveBeenCalled();
child.emit('close');
await capture.finishAsync();
expect(close).toHaveBeenCalledTimes(1);
expect(await fs.promises.readdir(outputDir)).toHaveLength(1);
});
it('spawns the emulator directly with the same arguments when there is no launch gate', async () => {
@@ -273,7 +355,9 @@ describe('AndroidEmulatorUtils', () => {
mockSuccessfulStart(deviceName);
const env = { ANDROID_EMULATOR_EXTRA_ARGS: '-gpu swiftshader_indirect' };
const result = await AndroidEmulatorUtils.startAsync({
await AndroidEmulatorUtils.startAsync({
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
logCapture: buildCapture,
deviceName,
env,
logcatDirectory: outputDir,
@@ -294,7 +378,7 @@ describe('AndroidEmulatorUtils', () => {
'-logcat',
'*:v',
'-logcat-output',
result.logcatOutputPath,
await logFile(outputDir, 'logcat'),
'-avd',
deviceName,
'-accel',
@@ -331,7 +415,9 @@ describe('AndroidEmulatorUtils', () => {
: gatedSpawn(command, args, options as any)) as any);
const admitAsync = jest.fn().mockResolvedValue(undefined);
const result = await AndroidEmulatorUtils.startAsync({
await AndroidEmulatorUtils.startAsync({
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
logCapture: buildCapture,
deviceName,
env: {},
logcatDirectory: outputDir,
@@ -358,7 +444,7 @@ describe('AndroidEmulatorUtils', () => {
'-logcat',
'*:v',
'-logcat-output',
result.logcatOutputPath,
await logFile(outputDir, 'logcat'),
'-avd',
deviceName,
'-accel',
@@ -395,6 +481,8 @@ describe('AndroidEmulatorUtils', () => {
await expect(
AndroidEmulatorUtils.startAsync({
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
logCapture: buildCapture,
deviceName,
env: {},
logcatDirectory: outputDir,
@@ -419,6 +507,8 @@ describe('AndroidEmulatorUtils', () => {
try {
await expect(
AndroidEmulatorUtils.startAsync({
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
logCapture: buildCapture,
deviceName,
env: process.env,
logcatDirectory: outputDir,
@@ -0,0 +1,309 @@
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import fs, { type FileHandle } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { PassThrough, Readable } from 'node:stream';
import { Sentry } from '../../sentry';
import {
AndroidEmulatorLogCapture,
MAX_ANDROID_LOG_BYTES_PER_SOURCE,
} from '../androidEmulatorLogCapture';
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
jest.mock('../../sentry', () => ({ Sentry: { capture: jest.fn() } }));
let directory: string;
beforeEach(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'android-capped-logs-'));
});
afterEach(async () => {
jest.restoreAllMocks();
await fs.rm(directory, { recursive: true, force: true });
});
async function record(
capture: AndroidEmulatorLogCapture,
file: string,
text: string
): Promise<void> {
const input = Readable.from([Buffer.from(text)]);
capture.attach(input, file, 'emulator-output');
await once(input, 'end');
await capture.finishFileAsync(file);
}
async function waitForSize(file: string, size: number): Promise<void> {
const deadline = Date.now() + 2_000;
while ((await fs.stat(file)).size !== size && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 5));
}
expect((await fs.stat(file)).size).toBe(size);
}
function trackOpenedFiles(): FileHandle[] {
const opened: FileHandle[] = [];
const open = fs.open;
jest.spyOn(fs, 'open').mockImplementation(async (file, flags, mode) => {
const handle = await open(file, flags, mode);
opened.push(handle);
return handle;
});
return opened;
}
it('keeps the newest bytes of a source across launches, with independent budgets for the two sources', async () => {
const capture = new AndroidEmulatorLogCapture(10);
const first = path.join(directory, 'first.log');
const retry = path.join(directory, 'retry.log');
const device = path.join(directory, 'logcat.log');
await record(capture, first, 'first!');
await record(capture, retry, 'second!!');
const logcat = Readable.from([Buffer.from('device logs are separate')]);
capture.attach(logcat, device, 'device-logcat');
await once(logcat, 'end');
// A partially evicted file keeps its size on disk until it is sealed.
expect((await fs.stat(first)).size).toBe(6);
const logs = await capture.finishAsync();
expect(logs).toEqual([
{ filePath: first, source: 'emulator-output', bytesSeen: 6 },
{ filePath: retry, source: 'emulator-output', bytesSeen: 8 },
{ filePath: device, source: 'device-logcat', bytesSeen: 24 },
]);
// The retry evicts the head of the earlier launch; its own tail stays whole.
expect(await fs.readFile(first, 'utf8')).toBe('t!');
expect(await fs.readFile(retry, 'utf8')).toBe('second!!');
expect(await fs.readFile(device, 'utf8')).toBe('e separate');
});
it('frees a finished file on disk once a later launch evicts all of it', async () => {
const capture = new AndroidEmulatorLogCapture(10);
const first = path.join(directory, 'first.log');
const retry = path.join(directory, 'retry.log');
await record(capture, first, 'first!');
const input = new PassThrough();
capture.attach(input, retry, 'emulator-output');
input.write('second log is too long');
await waitForSize(first, 0);
await capture.finishAsync();
expect(await fs.readFile(first, 'utf8')).toBe('');
expect(await fs.readFile(retry, 'utf8')).toBe('s too long');
});
it('frees a fully evicted live capture on disk and records its later output', async () => {
const capture = new AndroidEmulatorLogCapture(10);
const first = path.join(directory, 'first.log');
const second = path.join(directory, 'second.log');
const a = new PassThrough();
const b = new PassThrough();
capture.attach(a, first, 'emulator-output');
capture.attach(b, second, 'emulator-output');
// Wait between writes: two streams deliver their first chunks in either order.
a.write('0123456789');
await waitForSize(first, 10);
b.write('abcdefghij');
await waitForSize(first, 0);
await waitForSize(second, 10);
a.write('xy');
await capture.finishAsync();
expect(await fs.readFile(first, 'utf8')).toBe('xy');
expect(await fs.readFile(second, 'utf8')).toBe('cdefghij');
});
it('closes an uncapped file when its stream finishes', async () => {
const opened = trackOpenedFiles();
const capture = new AndroidEmulatorLogCapture(Infinity);
const file = path.join(directory, 'build.log');
await record(capture, file, 'ordinary build output');
expect(opened).toHaveLength(1);
expect(opened[0].fd).toBe(-1);
expect(await fs.readFile(file, 'utf8')).toBe('ordinary build output');
expect(await capture.finishAsync()).toEqual([
{ filePath: file, source: 'emulator-output', bytesSeen: 21 },
]);
});
it('closes the file and reports once when sealing fails', async () => {
const opened = trackOpenedFiles();
const capture = new AndroidEmulatorLogCapture(10);
const file = path.join(directory, 'wrapped.log');
await record(capture, file, '0123456789abcdef');
const diskError = Object.assign(new Error('full'), { code: 'ENOSPC' });
jest.spyOn(opened[0], 'truncate').mockRejectedValueOnce(diskError);
await capture.finishAsync();
expect(opened[0].fd).toBe(-1);
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
'Failed to write Android diagnostic log',
diskError,
expect.anything()
);
});
it('keeps the tail of a wrapped ring in order', async () => {
const capture = new AndroidEmulatorLogCapture(10);
const file = path.join(directory, 'wrapped.log');
const input = new PassThrough();
capture.attach(input, file, 'emulator-output');
for (const piece of ['0123', '4567', '89ab', 'cdef']) {
input.write(piece);
}
await capture.finishAsync();
expect(await fs.readFile(file, 'utf8')).toBe('6789abcdef');
});
it('reserves bytes across simultaneous captures and drains after finalization', async () => {
const capture = new AndroidEmulatorLogCapture(10);
const a = new PassThrough();
const b = new PassThrough();
const first = path.join(directory, 'first.log');
const second = path.join(directory, 'second.log');
capture.attach(a, first, 'emulator-output');
capture.attach(b, second, 'emulator-output');
a.write('12345678');
await waitForSize(first, 8);
b.write('abcdefgh');
await capture.finishAsync();
// The least recently written capture gives up its bytes first.
expect(await fs.readFile(first, 'utf8')).toBe('78');
expect(await fs.readFile(second, 'utf8')).toBe('abcdefgh');
a.end('ignored after cleanup');
b.end('also ignored');
expect(a.readableFlowing).toBe(true);
expect(b.readableFlowing).toBe(true);
});
it('drains a real FIFO beyond 20 MiB, retains the tail, and closes after the writer exits', async () => {
const file = path.join(directory, 'logcat.log');
const capture = new AndroidEmulatorLogCapture();
const pipe = await capture.prepareLogcatOutputAsync(file);
expect((await fs.stat(pipe.path)).isFIFO()).toBe(true);
const writer = spawn(
process.execPath,
[
'-e',
`
const fs = require('node:fs');
const fd = fs.openSync(process.argv[1], 'w');
fs.writeSync(fd, 'BEGIN\\n');
const chunk = Buffer.alloc(64 * 1024, 'x');
for (let i = 0; i < 1024; i++) {
let offset = 0;
while (offset < chunk.length) offset += fs.writeSync(fd, chunk, offset);
}
fs.writeSync(fd, '\\nEND');
fs.closeSync(fd);
`,
pipe.path,
],
{ stdio: 'ignore' }
);
try {
expect(await once(writer, 'close')).toEqual([0, null]);
pipe.close();
pipe.close();
await pipe.closed;
const logs = await capture.finishAsync();
const result = await fs.readFile(file);
expect(result.length).toBe(MAX_ANDROID_LOG_BYTES_PER_SOURCE);
expect(result.subarray(0, 4).toString()).toBe('xxxx');
expect(result.subarray(-4).toString()).toBe('\nEND');
expect(logs).toEqual([
{ filePath: file, source: 'device-logcat', bytesSeen: 64 * 1024 * 1024 + 10 },
]);
await expect(fs.stat(pipe.path)).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
writer.kill();
pipe.close();
}
}, 30_000);
it('uses the original logcat path for ordinary builds without creating a FIFO', async () => {
const file = path.join(directory, 'build-logcat.log');
const capture = new AndroidEmulatorLogCapture(Infinity);
const output = await capture.prepareLogcatOutputAsync(file);
expect(output.path).toBe(file);
expect(await fs.readdir(directory)).toEqual([]);
output.close();
await output.closed;
expect(await capture.finishAsync()).toEqual([]);
});
it('closes an unused logcat pipe when the emulator fails to launch', async () => {
const file = path.join(directory, 'empty.log');
const capture = new AndroidEmulatorLogCapture();
const pipe = await capture.prepareLogcatOutputAsync(file);
pipe.close();
await pipe.closed;
await expect(fs.stat(pipe.path)).rejects.toMatchObject({ code: 'ENOENT' });
expect(await capture.finishAsync()).toEqual([
{ filePath: file, source: 'device-logcat', bytesSeen: 0 },
]);
expect((await fs.stat(file)).size).toBe(0);
}, 5_000);
it('keeps draining the FIFO after capture finishes while the emulator is alive', async () => {
const file = path.join(directory, 'live-logcat.log');
const capture = new AndroidEmulatorLogCapture(10);
const output = await capture.prepareLogcatOutputAsync(file);
const writer = spawn(
process.execPath,
[
'-e',
`
const fs = require('node:fs');
const fd = fs.openSync(process.argv[1], 'w');
process.stdout.write('ready');
process.stdin.once('data', () => {
const chunk = Buffer.alloc(64 * 1024, 'x');
for (let i = 0; i < 128; i++) {
let offset = 0;
while (offset < chunk.length) offset += fs.writeSync(fd, chunk, offset);
}
fs.closeSync(fd);
process.stdin.destroy();
});
`,
output.path,
],
{ stdio: ['pipe', 'pipe', 'pipe'] }
);
try {
await once(writer.stdout, 'data');
await capture.finishAsync();
writer.stdin.end('go');
expect(await once(writer, 'close')).toEqual([0, null]);
output.close();
await output.closed;
expect((await fs.stat(file)).size).toBe(0);
await expect(fs.stat(output.path)).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
writer.kill();
output.close();
await output.closed;
}
}, 10_000);
it('reports a disk write failure once and continues draining output', async () => {
const capture = new AndroidEmulatorLogCapture(10);
const input = new PassThrough();
const file = path.join(directory, 'failed.log');
const diskError = Object.assign(new Error('full'), { code: 'ENOSPC' });
jest.spyOn(fs, 'open').mockRejectedValueOnce(diskError);
capture.attach(input, file, 'emulator-output');
input.write('first');
input.write('second');
await capture.finishAsync();
input.end('more');
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
'Failed to write Android diagnostic log',
diskError,
expect.anything()
);
expect(input.readableFlowing).toBe(true);
});
@@ -0,0 +1,243 @@
import fs from 'node:fs';
import { type FileHandle } from 'node:fs/promises';
import { type Readable, Writable } from 'node:stream';
import { finished } from 'node:stream/promises';
import { Sentry } from '../sentry';
import { type AndroidEmulatorLogSource } from './androidEmulatorLogSources';
import { createAndroidLogcatPipeAsync } from './androidLogcatPipe';
export const MAX_ANDROID_LOG_BYTES_PER_SOURCE = 20 * 1024 * 1024;
export type FinalizedAndroidEmulatorLog = {
readonly filePath: string;
readonly source: AndroidEmulatorLogSource;
readonly bytesSeen: number;
};
/**
* One file is a ring of `maxBytes` bytes: `writePos` wraps and the newest
* `retained` bytes end at it. Sealing rewrites the file in log order.
*/
type Capture = {
source: AndroidEmulatorLogSource;
stream: Writable;
inputs: Set<Readable>;
done: Promise<void>;
/** Runs one file operation after every earlier one on the same handle. */
run: (operation: (handle: FileHandle) => Promise<void>) => Promise<void>;
sealed?: Promise<void>;
bytesSeen: number;
writePos: number;
retained: number;
lastWrite: number;
};
/**
* One budget per workflow, shared by all emulator launches and retries.
*
* A source keeps the last `maxBytes` bytes it produced, across files. The
* oldest bytes go first, so an earlier launch gives up its head to a later
* one and the final attempt always keeps its tail, where failures explain
* themselves. The least recently written file gives up bytes first, which
* approximates byte age without tracking every chunk.
*
* Eviction is bookkeeping until a file loses all its bytes, which frees it
* on disk at once. A partially evicted file keeps its size until it is
* sealed, so one source occupies at most twice its budget while recording.
*/
export class AndroidEmulatorLogCapture {
private readonly sources = new Map<AndroidEmulatorLogSource, Capture[]>();
private readonly captures = new Map<string, Capture>();
private finishing = false;
private writes = 0;
constructor(private readonly maxBytes = MAX_ANDROID_LOG_BYTES_PER_SOURCE) {}
attach(input: Readable, filePath: string, source: AndroidEmulatorLogSource): void {
if (this.finishing) {
input.resume();
return;
}
let capture = this.captures.get(filePath);
if (!capture) {
capture = this.open(filePath, source);
this.captures.set(filePath, capture);
const list = this.sources.get(source) ?? [];
list.push(capture);
this.sources.set(source, list);
}
capture.inputs.add(input);
input.pipe(capture.stream, { end: false });
}
async finishFileAsync(filePath: string): Promise<void> {
const capture = this.captures.get(filePath);
if (!capture) {
return;
}
for (const input of capture.inputs) {
input.unpipe(capture.stream);
// Keep draining: closing a full log must never block or break the emulator.
input.resume();
}
capture.inputs.clear();
capture.stream.end();
await capture.done;
// Nothing can evict an uncapped file later, so release its handle now.
if (!Number.isFinite(this.maxBytes)) {
await this.sealAsync(capture);
}
}
async finishAsync(): Promise<readonly FinalizedAndroidEmulatorLog[]> {
this.finishing = true;
await Promise.all([...this.captures.keys()].map(file => this.finishFileAsync(file)));
await Promise.all([...this.captures.values()].map(capture => this.sealAsync(capture)));
// Every writer is closed, so uploads can reopen these files on each retry.
return [...this.captures].map(([filePath, { source, bytesSeen }]) => ({
filePath,
source,
bytesSeen,
}));
}
/** Select the output transport without exposing the capture budget to emulator launch. */
async prepareLogcatOutputAsync(
filePath: string
): Promise<{ path: string; close: () => void; closed: Promise<void> }> {
// Ordinary builds retain the emulator's direct, uncapped logcat file writer.
if (!Number.isFinite(this.maxBytes)) {
return { path: filePath, close: () => {}, closed: Promise.resolve() };
}
const pipe = await createAndroidLogcatPipeAsync(`${filePath}.pipe`);
this.attach(pipe.stream, filePath, 'device-logcat');
const closed = pipe.closed.then(() => this.finishFileAsync(filePath)).catch(() => {});
return { path: pipe.path, close: pipe.close, closed };
}
private open(filePath: string, source: AndroidEmulatorLogSource): Capture {
const file = fs.promises.open(filePath, 'w+');
// Handle open errors even if the process never produces output.
void file.catch(() => {});
let failed = false;
const report = (err: unknown): void => {
if (!failed) {
failed = true;
Sentry.capture(
'Failed to write Android diagnostic log',
err instanceof Error ? err : new Error(String(err)),
{
level: 'warning',
extras: { filePath, source },
}
);
}
};
let queue: Promise<void> = Promise.resolve();
const run = (operation: (handle: FileHandle) => Promise<void>): Promise<void> => {
queue = queue
.then(() => file)
.then(operation)
.catch(report);
return queue;
};
const record = {
source,
inputs: new Set<Readable>(),
run,
bytesSeen: 0,
writePos: 0,
retained: 0,
lastWrite: 0,
};
const stream = new Writable({
write: (chunk: Buffer, _encoding, callback) => {
record.bytesSeen += chunk.length;
if (failed) {
callback();
return;
}
const max = this.maxBytes;
const data = chunk.length > max ? chunk.subarray(chunk.length - max) : chunk;
const position = record.writePos;
const head = Math.min(data.length, max - position);
// Account before awaiting I/O so concurrent launches share the same cap.
record.writePos = (position + data.length) % max;
record.retained = Math.min(record.retained + data.length, max);
record.lastWrite = ++this.writes;
this.evict(source);
void run(async handle => {
await writeAtAsync(handle, data.subarray(0, head), position);
await writeAtAsync(handle, data.subarray(head), 0);
}).then(() => callback());
},
});
return Object.assign(record, { stream, done: finished(stream) });
}
/** Drop the oldest bytes of a source until its files fit the budget together. */
private evict(source: AndroidEmulatorLogSource): void {
const list = [...(this.sources.get(source) ?? [])].sort((a, b) => a.lastWrite - b.lastWrite);
let excess = list.reduce((sum, capture) => sum + capture.retained, 0) - this.maxBytes;
for (const capture of list) {
if (excess <= 0) {
return;
}
const dropped = Math.min(capture.retained, excess);
capture.retained -= dropped;
excess -= dropped;
if (dropped > 0 && capture.retained === 0) {
// Nothing of this file survives: free the disk after its queued writes land.
capture.writePos = 0;
void capture.run(handle => handle.truncate(0));
}
}
}
/** Rewrite the ring in log order, drop evicted bytes, and close the file. */
private sealAsync(capture: Capture): Promise<void> {
capture.sealed ??= capture.run(async handle => {
try {
const { retained } = capture;
let start = capture.writePos - retained;
if (start < 0) {
start += this.maxBytes;
}
if (start > 0) {
const tail = Buffer.alloc(retained);
const head = Math.min(retained, this.maxBytes - start);
await readAtAsync(handle, tail.subarray(0, head), start);
await readAtAsync(handle, tail.subarray(head), 0);
await writeAtAsync(handle, tail, 0);
}
await handle.truncate(retained);
} finally {
await handle.close();
}
});
return capture.sealed;
}
}
async function writeAtAsync(handle: FileHandle, data: Buffer, position: number): Promise<void> {
for (let offset = 0; offset < data.length; ) {
const { bytesWritten } = await handle.write(
data,
offset,
data.length - offset,
position + offset
);
offset += bytesWritten;
}
}
async function readAtAsync(handle: FileHandle, into: Buffer, position: number): Promise<void> {
for (let offset = 0; offset < into.length; ) {
const { bytesRead } = await handle.read(into, offset, into.length - offset, position + offset);
if (bytesRead === 0) {
throw new Error(`Android diagnostic log ended before offset ${position + into.length}`);
}
offset += bytesRead;
}
}
@@ -0,0 +1,12 @@
import { z } from 'zod';
export const AndroidEmulatorLogSourcesSchema = z.array(
z.enum(['emulator-output', 'device-logcat'])
);
export type AndroidEmulatorLogSource = z.infer<typeof AndroidEmulatorLogSourcesSchema>[number];
// Existing build workflows capture both sources. Device sessions pass their selection explicitly.
export const ALL_ANDROID_EMULATOR_LOG_SOURCES: readonly AndroidEmulatorLogSource[] = [
'emulator-output',
'device-logcat',
];
@@ -0,0 +1,66 @@
import { execFile, spawn } from 'node:child_process';
import fs from 'node:fs';
import { Socket } from 'node:net';
import { type Readable } from 'node:stream';
import { promisify } from 'node:util';
import { Sentry } from '../sentry';
/** Bridge the emulator's path-based logcat output to a readable stream. */
export async function createAndroidLogcatPipeAsync(
pipePath: string
): Promise<{ path: string; stream: Readable; close: () => void; closed: Promise<void> }> {
await promisify(execFile)('mkfifo', ['-m', '600', pipePath]);
// The sentinel keeps the FIFO open before emulator launch. The reader signals
// after opening it, so even a failed launch can close the pipe without a race.
const sentinel = fs.openSync(pipePath, fs.constants.O_RDWR | fs.constants.O_NONBLOCK);
let sentinelOpen = true;
const close = (): void => {
if (sentinelOpen) {
sentinelOpen = false;
fs.closeSync(sentinel);
}
};
const reader = spawn(
'sh',
['-c', 'exec 3<"$1" || exit 1; printf ready >&2; exec cat <&3', 'android-logcat', pipePath],
{
stdio: ['ignore', 'pipe', 'pipe'],
}
);
const ready = new Promise<void>((resolve, reject) => {
reader.once('error', reject);
reader.once('exit', () =>
reject(new Error('Android logcat reader exited before opening its pipe.'))
);
reader.stderr.once('data', () => resolve());
});
reader.on('error', err => {
close();
Sentry.capture('Failed to read Android logcat pipe', err, { level: 'warning' });
});
const closed = new Promise<void>(resolve => {
reader.once('close', () => {
close();
void fs.promises
.rm(pipePath, { force: true })
.catch(() => {})
.then(resolve);
});
});
try {
await ready;
} catch (err) {
close();
reader.kill();
await closed;
throw err;
}
reader.unref();
for (const stream of [reader.stdout, reader.stderr]) {
if (stream instanceof Socket) {
stream.unref();
}
}
return { path: pipePath, stream: reader.stdout, close, closed };
}
+30 -12
View File
@@ -3058,16 +3058,22 @@ USAGE
$ eas sim:start [-p android|ios] [--name <value>] [--tag <value>...] [--device <value>] [--system-image
<value>] [--build-id <value> | --build-fingerprint <value> | --application-archive-url <value> | --expo-go]
[--sdk-version <value>] [--launch-arg <value>...] [--open-url <value>] [--type
agent-device|appium|argent|web-preview-only] [--package-version <value>] [--network-capture-field <value>...
--network-capture] [--max-duration-minutes <value>] [--max-idle-time-minutes <value>] [--egress-allow <value>...
--egress local] [--cors-origin <value>...] [--frame-ancestor <value>...] [--force] [--out-config-type env|dotenv]
[--public-link] [--json] [--non-interactive]
agent-device|appium|argent|web-preview-only] [--package-version <value>] [--android-log-source
emulator-output|device-logcat...] [--network-capture-field <value>... --network-capture] [--max-duration-minutes
<value>] [--max-idle-time-minutes <value>] [--egress-allow <value>... --egress local] [--cors-origin <value>...]
[--frame-ancestor <value>...] [--force] [--out-config-type env|dotenv] [--public-link] [--json] [--non-interactive]
FLAGS
-p, --platform=<option>
Device platform
<options: android|ios>
--android-log-source=<option>...
Android logs to record during the session and upload afterward. Repeat to select both sources; replaces the default
emulator-output selection. Device-wide device-logcat may contain credentials and personal data. Logs are not
redacted and are retained for 90 days.
<options: emulator-output|device-logcat>
--application-archive-url=<value>
Application archive URL to download, install, and launch before the simulator session is ready.
@@ -3214,16 +3220,22 @@ USAGE
$ eas simulator [-p android|ios] [--name <value>] [--tag <value>...] [--device <value>] [--system-image
<value>] [--build-id <value> | --build-fingerprint <value> | --application-archive-url <value> | --expo-go]
[--sdk-version <value>] [--launch-arg <value>...] [--open-url <value>] [--type
agent-device|appium|argent|web-preview-only] [--package-version <value>] [--network-capture-field <value>...
--network-capture] [--max-duration-minutes <value>] [--max-idle-time-minutes <value>] [--egress-allow <value>...
--egress local] [--cors-origin <value>...] [--frame-ancestor <value>...] [--force] [--out-config-type env|dotenv]
[--public-link] [--json] [--non-interactive]
agent-device|appium|argent|web-preview-only] [--package-version <value>] [--android-log-source
emulator-output|device-logcat...] [--network-capture-field <value>... --network-capture] [--max-duration-minutes
<value>] [--max-idle-time-minutes <value>] [--egress-allow <value>... --egress local] [--cors-origin <value>...]
[--frame-ancestor <value>...] [--force] [--out-config-type env|dotenv] [--public-link] [--json] [--non-interactive]
FLAGS
-p, --platform=<option>
Device platform
<options: android|ios>
--android-log-source=<option>...
Android logs to record during the session and upload afterward. Repeat to select both sources; replaces the default
emulator-output selection. Device-wide device-logcat may contain credentials and personal data. Logs are not
redacted and are retained for 90 days.
<options: emulator-output|device-logcat>
--application-archive-url=<value>
Application archive URL to download, install, and launch before the simulator session is ready.
@@ -3580,16 +3592,22 @@ USAGE
$ eas simulator:start [-p android|ios] [--name <value>] [--tag <value>...] [--device <value>] [--system-image
<value>] [--build-id <value> | --build-fingerprint <value> | --application-archive-url <value> | --expo-go]
[--sdk-version <value>] [--launch-arg <value>...] [--open-url <value>] [--type
agent-device|appium|argent|web-preview-only] [--package-version <value>] [--network-capture-field <value>...
--network-capture] [--max-duration-minutes <value>] [--max-idle-time-minutes <value>] [--egress-allow <value>...
--egress local] [--cors-origin <value>...] [--frame-ancestor <value>...] [--force] [--out-config-type env|dotenv]
[--public-link] [--json] [--non-interactive]
agent-device|appium|argent|web-preview-only] [--package-version <value>] [--android-log-source
emulator-output|device-logcat...] [--network-capture-field <value>... --network-capture] [--max-duration-minutes
<value>] [--max-idle-time-minutes <value>] [--egress-allow <value>... --egress local] [--cors-origin <value>...]
[--frame-ancestor <value>...] [--force] [--out-config-type env|dotenv] [--public-link] [--json] [--non-interactive]
FLAGS
-p, --platform=<option>
Device platform
<options: android|ios>
--android-log-source=<option>...
Android logs to record during the session and upload afterward. Repeat to select both sources; replaces the default
emulator-output selection. Device-wide device-logcat may contain credentials and personal data. Logs are not
redacted and are retained for 90 days.
<options: emulator-output|device-logcat>
--application-archive-url=<value>
Application archive URL to download, install, and launch before the simulator session is ready.
+20
View File
@@ -47380,6 +47380,26 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "logSources",
"description": "Logs to record during the session and upload afterward: emulator-output and device-logcat. Defaults to\n[\"emulator-output\"]. An empty list disables recording and uploading both sources. Device-wide logcat can contain\ncredentials and personal data; include device-logcat only when you want to retain it.\nLogs are not redacted and use the existing 90-day artifact retention policy.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "systemImagePackage",
"description": "Android SDK system image package to use for the emulator (e.g.\n\"system-images;android-35;google_apis;x86_64\").",
@@ -8,7 +8,7 @@ import Log, { link } from '../log';
import { LoggedInAuthenticationInfo } from '../user/SessionManager';
import { Actor } from '../user/User';
const SIMULATORS_LAUNCH_AT = new Date('2026-11-10T18:00:00Z');
const SIMULATORS_LAUNCH_AT = new Date('2026-11-12T18:00:00Z');
const SIMULATORS_LAUNCH_HINT_PREFERENCE_KEY = 'simulators-launch-cli-hint-seen';
const REQUEST_TIMEOUT_MS = 3000;
const SIMULATORS_QUICKSTART_URL =
@@ -883,6 +883,38 @@ describe(Simulator, () => {
);
});
it('accumulates repeated --android-log-source flags in order', async () => {
const { command } = createCommand([
'--platform',
'android',
'--non-interactive',
'--android-log-source',
'emulator-output',
'--android-log-source',
'device-logcat',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ android: { logSources: ['emulator-output', 'device-logcat'] } })
);
});
it('leaves Android log sources omitted unless explicitly requested', async () => {
const { command } = createCommand(['--platform', 'android', '--non-interactive']);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync.mock.calls[0][1].android?.logSources).toBeUndefined();
});
it.each([
{ args: ['--platform', 'ios', '--android-log-source', 'device-logcat'] },
{ args: ['--platform', 'android', '--android-log-source', 'unknown'] },
])('rejects invalid Android logging options $args', async ({ args }) => {
const { command } = createCommand([...args, '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow();
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
});
it('passes --network-capture to the createDeviceRunSession mutation', async () => {
const { command } = createCommand([
'--platform',
@@ -150,6 +150,12 @@ export default class Simulator extends EasCommand {
description:
'Version of the package backing the simulator session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.',
}),
'android-log-source': Flags.option({
description:
'Android logs to record during the session and upload afterward. Repeat to select both sources; replaces the default emulator-output selection. Device-wide device-logcat may contain credentials and personal data. Logs are not redacted and are retained for 90 days.',
options: ['emulator-output', 'device-logcat'],
multiple: true,
})(),
'network-capture': Flags.boolean({
description:
'Record HTTP(S) traffic from apps on the device (iOS only). HTTPS is decrypted, so recordings contain credentials in cleartext and certificate-pinned apps fail to connect.',
@@ -257,6 +263,7 @@ export default class Simulator extends EasCommand {
const tags = flags.tag?.map(tag => tag.trim()).filter(tag => tag.length > 0);
const deviceIdentifier = flags.device?.trim() || undefined;
const systemImagePackage = flags['system-image']?.trim() || undefined;
const androidLogSources = flags['android-log-source'];
const buildId = flags['build-id']?.trim() || undefined;
const buildFingerprint = flags['build-fingerprint']?.trim() || undefined;
const applicationArchiveUrlFromFlag = flags['application-archive-url']?.trim() || undefined;
@@ -300,6 +307,9 @@ export default class Simulator extends EasCommand {
const platform = await resolvePlatformAsync(flags.platform, nonInteractive);
const egress = flags.egress === 'local' ? DeviceRunSessionEgress.Local : undefined;
if (androidLogSources && platform !== AppPlatform.Android) {
throw new EasCommandError('--android-log-source is only supported with --platform android.');
}
if (systemImagePackage && platform !== AppPlatform.Android) {
throw new EasCommandError('--system-image is only supported with --platform android.');
}
@@ -363,6 +373,19 @@ export default class Simulator extends EasCommand {
packageVersion: flags['package-version'],
nonInteractive,
});
const android = {
...(deviceIdentifier ? { deviceIdentifier } : {}),
...(systemImagePackage ? { systemImagePackage } : {}),
...(androidLogSources ? { logSources: androidLogSources } : {}),
};
const platformInput =
platform === AppPlatform.Ios
? deviceIdentifier
? { ios: { deviceIdentifier } }
: {}
: Object.keys(android).length
? { android }
: {};
const session = await withSimulatorRequestAnalyticsAsync(
analytics,
requestProperties,
@@ -378,18 +401,7 @@ export default class Simulator extends EasCommand {
packageVersion: flags['package-version'],
networkCapture: flags['network-capture'],
...(networkCaptureFields.length ? { networkCaptureFields } : {}),
...(platform === AppPlatform.Ios
? deviceIdentifier
? { ios: { deviceIdentifier } }
: {}
: deviceIdentifier || systemImagePackage
? {
android: {
...(deviceIdentifier ? { deviceIdentifier } : {}),
...(systemImagePackage ? { systemImagePackage } : {}),
},
}
: {}),
...platformInput,
...(buildId ? { buildId } : {}),
...(buildFingerprint ? { buildFingerprint } : {}),
...(applicationArchiveUrlFromFlag
+7
View File
@@ -6622,6 +6622,13 @@ export type CreateAndConfigureRepositoryInput = {
export type CreateAndroidDeviceRunSessionInput = {
/** AVD hardware profile id to use for the emulator (e.g. "pixel_9"). */
deviceIdentifier?: InputMaybe<Scalars['String']['input']>;
/**
* Logs to record during the session and upload afterward: emulator-output and device-logcat. Defaults to
* ["emulator-output"]. An empty list disables recording and uploading both sources. Device-wide logcat can contain
* credentials and personal data; include device-logcat only when you want to retain it.
* Logs are not redacted and use the existing 90-day artifact retention policy.
*/
logSources?: InputMaybe<Array<Scalars['String']['input']>>;
/**
* Android SDK system image package to use for the emulator (e.g.
* "system-images;android-35;google_apis;x86_64").