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
29 changed files with 1767 additions and 176 deletions

No files matched your search

+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.
@@ -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';
@@ -139,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 },
@@ -153,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: {},
@@ -4,6 +4,8 @@ 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 {
@@ -35,6 +37,7 @@ 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'),
@@ -108,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>;
}
@@ -173,6 +161,11 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
}
beforeEach(() => {
jest
.mocked(getAndroidEmulatorLogCapture)
.mockImplementation(
jest.requireActual('../../utils/androidEmulatorLogs').getAndroidEmulatorLogCapture
);
deviceLaunch = undefined;
jest.clearAllMocks();
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(null);
@@ -437,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,
@@ -444,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',
@@ -470,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'));
@@ -617,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),
@@ -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.'
);
}
},
});
}
@@ -16,6 +16,8 @@ import {
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';
@@ -71,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',
@@ -188,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(
{
@@ -253,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,
});
@@ -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;
@@ -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');
});
@@ -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;
@@ -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;
@@ -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\").",
@@ -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").