mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23952dfb64 | ||
|
|
6ab793bbc9 | ||
|
|
96b3e76c63 | ||
|
|
9f087b164e | ||
|
|
2d8ad58854 | ||
|
|
95bcffb731 | ||
|
|
1099ac72ac | ||
|
|
4dbdaed113 | ||
|
|
85ea9a2a0b | ||
|
|
b03bd85fec | ||
|
|
2d5c88d2bc | ||
|
|
4ce581cf7b | ||
|
|
e412e2923a | ||
|
|
c6231366e1 | ||
|
|
04f8663856 | ||
|
|
81243a6631 | ||
|
|
e38cc79811 | ||
|
|
b5de1517ac | ||
|
|
dd3808f345 | ||
|
|
85c77cc85b | ||
|
|
b38126abb3 | ||
|
|
c68978acd8 | ||
|
|
2dda59d57c | ||
|
|
156e38eb54 | ||
|
|
3263ecfa36 | ||
|
|
1da5a21f42 | ||
|
|
64b9b14d09 |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Stop an agent-device session that is still starting up when it is stopped.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Stop an Appium session that is still starting up when it is stopped.
|
||||
Report temporary-directory cleanup failures when Stop interrupts Appium installation.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Stop an Argent session that is still starting up when it is stopped.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Show the Expo Simulators hint after a successful build from November 12 instead of November 10.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Stop a web-preview session that is still starting up when it is stopped.
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
## [27.4.1](https://github.com/expo/eas-cli/releases/tag/v27.4.1) - 2026-11-04
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [simulator-launcher][build-tools] Wait for a starting warm Simulator launcher before claiming its session, instead of falling back to a cold start. ([#4810](https://github.com/expo/eas-cli/pull/4810) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [27.4.0](https://github.com/expo/eas-cli/releases/tag/v27.4.0) - 2026-11-03
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "27.3.0",
|
||||
"PRODUCTION": "27.3.0"
|
||||
"STAGING": "27.4.1",
|
||||
"PRODUCTION": "27.4.1"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "27.4.0",
|
||||
"version": "27.4.1",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
# @expo/build-tools
|
||||
|
||||
## 27.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Wait for a starting warm Simulator launcher before claiming its session, instead of falling back to a cold start. ([#4810](https://github.com/expo/eas-cli/pull/4810) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 27.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "27.4.0",
|
||||
"version": "27.4.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -42,17 +42,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "27.4.0",
|
||||
"@expo/eas-build-job": "27.4.0",
|
||||
"@expo/downloader": "27.4.1",
|
||||
"@expo/eas-build-job": "27.4.1",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "27.4.0",
|
||||
"@expo/logger": "27.4.1",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "27.4.0",
|
||||
"@expo/template-file": "27.4.0",
|
||||
"@expo/turtle-spawn": "27.4.0",
|
||||
"@expo/steps": "27.4.1",
|
||||
"@expo/template-file": "27.4.1",
|
||||
"@expo/turtle-spawn": "27.4.1",
|
||||
"@expo/xcpretty": "^4.4.7",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { type watchDeviceRunSessionStartup } from '../../steps/utils/deviceRunSessionStartup';
|
||||
import { DeviceRunSessionStoppedError } from '../../steps/utils/remoteDeviceRunSession';
|
||||
|
||||
/**
|
||||
* A `watchDeviceRunSessionStartup` stand-in that never polls. A test calls `stop` on the
|
||||
* returned watch (`jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value`) to act like
|
||||
* a Stop request during startup.
|
||||
*/
|
||||
export function createStartupWatchMock({
|
||||
startup,
|
||||
}: Parameters<typeof watchDeviceRunSessionStartup>[0]): ReturnType<
|
||||
typeof watchDeviceRunSessionStartup
|
||||
> & { stop: () => void } {
|
||||
const stopped = new DeviceRunSessionStoppedError('Stopped during startup.');
|
||||
return {
|
||||
stopped,
|
||||
stop: () => startup.abort(stopped),
|
||||
isStopped: () => startup.signal.reason === stopped,
|
||||
completeAsync: jest.fn(async () => startup.signal.throwIfAborted()),
|
||||
finishAsync: jest.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
@@ -62,6 +62,7 @@ import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/sta
|
||||
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
|
||||
import { createStartServeSimLogsBuildFunction } from './functions/startServeSimLogs';
|
||||
import { createCollectServeSimLogsBuildFunction } from './functions/collectServeSimLogs';
|
||||
import { createCollectAndroidEmulatorLogsBuildFunction } from './functions/collectAndroidEmulatorLogs';
|
||||
import { createStartSandboxBuildFunction } from './functions/startSandbox';
|
||||
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
|
||||
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
|
||||
@@ -129,6 +130,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createStartServeSimMetricsBuildFunction(),
|
||||
createStartServeSimLogsBuildFunction(),
|
||||
createCollectServeSimLogsBuildFunction(ctx),
|
||||
createCollectAndroidEmulatorLogsBuildFunction(ctx),
|
||||
createStartSandboxBuildFunction(ctx),
|
||||
createRunAgentBuildFunction(ctx),
|
||||
createCollectServeSimMetricsBuildFunction(ctx),
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import {
|
||||
createCustomBuildContextMock,
|
||||
createGlobalContextMock,
|
||||
} from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { collectAndroidEmulatorLogsAsync } from '../../utils/androidEmulatorLogs';
|
||||
import { createCollectAndroidEmulatorLogsBuildFunction } from '../collectAndroidEmulatorLogs';
|
||||
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../utils/androidEmulatorLogs');
|
||||
|
||||
function createStep(callInputs: Record<string, unknown> = {}) {
|
||||
const logger = createMockLogger();
|
||||
const ctx = createCustomBuildContextMock();
|
||||
const global = createGlobalContextMock({ logger });
|
||||
const fn = createCollectAndroidEmulatorLogsBuildFunction(ctx);
|
||||
const step = fn.createBuildStepFromFunctionCall(global, {
|
||||
callInputs,
|
||||
env: { DEVICE_RUN_SESSION_ID: 'session' },
|
||||
});
|
||||
return { ctx, global, logger, fn, step };
|
||||
}
|
||||
|
||||
it('collects captured logs without a second source selection', async () => {
|
||||
const { ctx, global, fn, step } = createStep();
|
||||
expect(fn.supportedRuntimePlatforms).toEqual([BuildRuntimePlatform.LINUX]);
|
||||
await step.executeAsync();
|
||||
expect(collectAndroidEmulatorLogsAsync).toHaveBeenCalledWith(ctx, {
|
||||
global,
|
||||
deviceRunSessionId: 'session',
|
||||
logger: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('reports collection failure without changing the session result', async () => {
|
||||
jest
|
||||
.mocked(collectAndroidEmulatorLogsAsync)
|
||||
.mockRejectedValueOnce(new Error('collection failed'));
|
||||
const { step } = createStep();
|
||||
await expect(step.executeAsync()).resolves.toBeUndefined();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not collect Android emulator logs',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
+34
-5
@@ -2,6 +2,7 @@ import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
|
||||
@@ -45,6 +46,26 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
waitForFileAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
// Agent-device also polls for Stop while it starts up. That watch sets neither an idle timeout nor
|
||||
// a duration; keep it pending until startup retires it, and give each lifetime wait its own mock.
|
||||
const lifetimeWait = jest.fn<
|
||||
ReturnType<typeof waitForDeviceRunSessionStoppedAsync>,
|
||||
Parameters<typeof waitForDeviceRunSessionStoppedAsync>
|
||||
>();
|
||||
function routeStartupWatch(): void {
|
||||
lifetimeWait.mockReset();
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async args => {
|
||||
if ('idleTimeout' in args || 'maxDurationSeconds' in args) {
|
||||
return await lifetimeWait(args);
|
||||
}
|
||||
if (!args.signal?.aborted) {
|
||||
await new Promise<void>(resolve =>
|
||||
args.signal?.addEventListener('abort', () => resolve(), { once: true })
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const finishHost = jest.fn();
|
||||
const closePreview = jest.fn();
|
||||
const stopTool = jest.fn();
|
||||
@@ -56,6 +77,7 @@ const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnTh
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockResolvedValue();
|
||||
routeStartupWatch();
|
||||
// The daemon package is external. No installation or daemon process runs in these tests.
|
||||
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
// Only the emulator logcat folder: the Appium step writes into its own temp folder.
|
||||
@@ -101,7 +123,7 @@ beforeEach(() => {
|
||||
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue(events);
|
||||
jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue(events);
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
lifetimeWait.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -118,13 +140,20 @@ describe.each([
|
||||
corsOrigins?: string[]
|
||||
) {
|
||||
const fn = createFunction({} as CustomBuildContext);
|
||||
const global = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX });
|
||||
const defaults = new Map(
|
||||
fn.inputProviders?.map(provider => {
|
||||
const input = provider(global, 'Test session');
|
||||
return [input.id, input.defaultValue];
|
||||
})
|
||||
);
|
||||
await fn.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
// Inputs a step does not get default to undefined, like in a real step call.
|
||||
// Match step execution: omitted inputs use their declared defaults.
|
||||
inputs: new Proxy(
|
||||
{
|
||||
public_preview: { value: publicPreview },
|
||||
@@ -132,7 +161,7 @@ describe.each([
|
||||
cors_origins: { value: corsOrigins },
|
||||
} as Record<string, { value: unknown }>,
|
||||
{
|
||||
get: (target, id: string) => target[id] ?? { value: undefined },
|
||||
get: (target, id: string) => target[id] ?? { value: defaults.get(id) },
|
||||
}
|
||||
),
|
||||
outputs: {},
|
||||
@@ -163,7 +192,7 @@ describe.each([
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
expect(closePreview).not.toHaveBeenCalled();
|
||||
expect(finishHost.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
|
||||
lifetimeWait.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(stopTool).toHaveBeenCalledTimes(1);
|
||||
expect(stopEvents).toHaveBeenCalledTimes(1);
|
||||
@@ -308,7 +337,7 @@ describe.each([
|
||||
} else if (phase === 'config') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
|
||||
} else {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
|
||||
lifetimeWait.mockRejectedValueOnce(error);
|
||||
}
|
||||
await expect(runAsync()).rejects.toBe(error);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
|
||||
+40
-18
@@ -83,6 +83,26 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
// Agent-device also polls for Stop while it starts up. That watch sets neither an idle timeout nor
|
||||
// a duration; keep it pending until startup retires it, and give each lifetime wait its own mock.
|
||||
const lifetimeWait = jest.fn<
|
||||
ReturnType<typeof waitForDeviceRunSessionStoppedAsync>,
|
||||
Parameters<typeof waitForDeviceRunSessionStoppedAsync>
|
||||
>();
|
||||
function routeStartupWatch(): void {
|
||||
lifetimeWait.mockReset();
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async args => {
|
||||
if ('idleTimeout' in args || 'maxDurationSeconds' in args) {
|
||||
return await lifetimeWait(args);
|
||||
}
|
||||
if (!args.signal?.aborted) {
|
||||
await new Promise<void>(resolve =>
|
||||
args.signal?.addEventListener('abort', () => resolve(), { once: true })
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
@@ -126,6 +146,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockResolvedValue();
|
||||
routeStartupWatch();
|
||||
lifetime = registerLocalEgressResources(stopEgress);
|
||||
stopEgress.mockResolvedValue(undefined);
|
||||
stopPreview.mockResolvedValue(undefined);
|
||||
@@ -168,7 +189,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
finishAsync: stopPreview,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
|
||||
lifetimeWait.mockImplementation(async () => {
|
||||
expect(lifetime.aborted).toBe(false);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -181,7 +202,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
it('releases resources without monitoring when the session stopped during startup', async () => {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValueOnce('stopped');
|
||||
await run();
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
expect(lifetimeWait).not.toHaveBeenCalled();
|
||||
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(stopPreview).toHaveBeenCalledTimes(1);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
@@ -190,16 +211,18 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
|
||||
it('reports credentials and starts monitoring before waiting, then releases resources', async () => {
|
||||
await run();
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
deviceRunSessionId: 'session-id',
|
||||
logger,
|
||||
remoteConfig: expect.objectContaining({
|
||||
...fields,
|
||||
[controllerField]: expect.any(String),
|
||||
[controllerField === 'previewUrl' ? 'previewToken' : 'webPreviewToken']: 'preview-secret',
|
||||
}),
|
||||
});
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ctx,
|
||||
deviceRunSessionId: 'session-id',
|
||||
logger,
|
||||
remoteConfig: expect.objectContaining({
|
||||
...fields,
|
||||
[controllerField]: expect.any(String),
|
||||
[controllerField === 'previewUrl' ? 'previewToken' : 'webPreviewToken']: 'preview-secret',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledWith({
|
||||
port: handoff.port,
|
||||
env,
|
||||
@@ -213,7 +236,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
stopEgress.mockClear();
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(null);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
lifetimeWait.mockResolvedValue(undefined);
|
||||
await run();
|
||||
const { remoteConfig } = jest.mocked(uploadRemoteSessionConfigAsync).mock.calls[0][0];
|
||||
expect(remoteConfig).toHaveProperty(controllerField);
|
||||
@@ -232,7 +255,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
} else if (phase === 'report') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValue(error);
|
||||
} else if (phase === 'wait') {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValue(error);
|
||||
lifetimeWait.mockRejectedValue(error);
|
||||
} else {
|
||||
stopPreview.mockRejectedValue(error);
|
||||
}
|
||||
@@ -246,11 +269,10 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
);
|
||||
it('passes cancellation to the monitor and releases the lifetime', async () => {
|
||||
const abort = new AbortController();
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => abort.abort());
|
||||
lifetimeWait.mockImplementation(async () => abort.abort());
|
||||
await run(abort.signal);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signal: abort.signal })
|
||||
);
|
||||
// Agent-device hands the monitor its startup signal, which follows the step signal.
|
||||
expect(jest.mocked(monitorLocalEgressAsync).mock.calls[0][0].signal?.aborted).toBe(true);
|
||||
expect(lifetime.aborted).toBe(true);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
+104
-1
@@ -12,6 +12,7 @@ import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEve
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../../utils/deviceRunSessionArtifacts';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
DeviceRunSessionStoppedError,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
@@ -557,7 +558,8 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
type Device = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['device'];
|
||||
function startSession(
|
||||
device: Device | ((tasks: StartupTasks) => Device),
|
||||
signal?: AbortSignal
|
||||
signal?: AbortSignal,
|
||||
onStarted?: () => Promise<void>
|
||||
) {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
@@ -585,6 +587,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
capture: NO_CAPTURE,
|
||||
tasks,
|
||||
device: sessionDevice,
|
||||
onStarted,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -857,6 +860,106 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports late host cleanup failure after startup Stop', async () => {
|
||||
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
|
||||
const failed = new AggregateError([stopped, new Error('host cleanup failed')]);
|
||||
let entered!: () => void;
|
||||
const starting = new Promise<void>(resolve => {
|
||||
entered = resolve;
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
|
||||
entered();
|
||||
return await new Promise((_resolve, reject) => {
|
||||
options.signal!.addEventListener('abort', () => setImmediate(() => reject(failed)), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
let tasks!: StartupTasks;
|
||||
const running = startSession(startup => {
|
||||
tasks = startup;
|
||||
return { booted: Promise.resolve(), ready: Promise.resolve() };
|
||||
});
|
||||
running.catch(() => {});
|
||||
await starting;
|
||||
tasks.abort(stopped);
|
||||
await expect(running).rejects.toBe(failed);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drains resources and preserves the explicit startup Stop reason', async () => {
|
||||
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
|
||||
let tasks!: StartupTasks;
|
||||
const session = startSession(startup => {
|
||||
tasks = startup;
|
||||
return { booted: Promise.resolve(), ready: tasks.untilAborted(new Promise(() => {})) };
|
||||
});
|
||||
session.catch(() => {});
|
||||
await flushAsync();
|
||||
tasks.abort(stopped);
|
||||
await expect(session).rejects.toBe(stopped);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not hide a teardown failure after an explicit startup Stop', async () => {
|
||||
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
|
||||
const failed = new Error('daemon teardown failed');
|
||||
let tasks!: StartupTasks;
|
||||
const session = startSession(startup => {
|
||||
tasks = startup;
|
||||
return { booted: Promise.resolve(), ready: tasks.untilAborted(new Promise(() => {})) };
|
||||
});
|
||||
session.catch(() => {});
|
||||
await flushAsync();
|
||||
mockDaemonStopAsync.mockRejectedValueOnce(failed);
|
||||
tasks.abort(stopped);
|
||||
await expect(session).rejects.toBe(failed);
|
||||
});
|
||||
|
||||
it('retires startup monitoring after reporting and before lifetime waiting', async () => {
|
||||
const retired = deferred();
|
||||
const retiring = jest.fn(() => retired.promise);
|
||||
const session = startSession(
|
||||
{ booted: Promise.resolve(), ready: Promise.resolve() },
|
||||
undefined,
|
||||
retiring
|
||||
);
|
||||
await flushAsync();
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
|
||||
expect(retiring).toHaveBeenCalledTimes(1);
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
retired.resolve();
|
||||
await session;
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('aborts a pending ready report when startup is stopped', async () => {
|
||||
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
|
||||
let tasks!: StartupTasks;
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockImplementation(
|
||||
async ({ signal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
signal!.addEventListener('abort', () => reject(new Error('request aborted')), {
|
||||
once: true,
|
||||
});
|
||||
})
|
||||
);
|
||||
const session = startSession(startup => {
|
||||
tasks = startup;
|
||||
return { booted: Promise.resolve(), ready: Promise.resolve() };
|
||||
});
|
||||
session.catch(() => {});
|
||||
await flushAsync();
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signal: tasks.signal })
|
||||
);
|
||||
tasks.abort(stopped);
|
||||
await expect(session).rejects.toBe(stopped);
|
||||
expect(startAgentDeviceEventCollectionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts the Android host before application readiness and waits before reporting ready', async () => {
|
||||
const ready = deferred();
|
||||
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
|
||||
|
||||
+283
-29
@@ -4,13 +4,18 @@ import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { AndroidEmulatorLogCapture } from '../../../utils/androidEmulatorLogCapture';
|
||||
import { getAndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogs';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { stopLocalEgressResourcesAsync } from '../../utils/localEgress';
|
||||
import {
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync,
|
||||
startLocalEgressGuardRelayAsync,
|
||||
} from '../../utils/localEgressGuard';
|
||||
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
|
||||
import {
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { downloadBuildAsync } from '../downloadBuild';
|
||||
import { installBuildAsync } from '../installBuild';
|
||||
import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact';
|
||||
@@ -32,10 +37,12 @@ jest.mock('../../utils/localEgressGuard', () => ({
|
||||
startLocalEgressGuardRelayAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../utils/androidEmulatorLogs');
|
||||
jest.mock('../../../utils/IosSimulatorUtils');
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../startIosSimulator', () => ({
|
||||
...jest.requireActual('../startIosSimulator'),
|
||||
@@ -104,43 +111,28 @@ function runStep(
|
||||
inputValues: Record<string, unknown> = {},
|
||||
signal?: AbortSignal,
|
||||
stepCtx: CustomBuildContext = ctx,
|
||||
warm = false
|
||||
warm = false,
|
||||
env: NodeJS.ProcessEnv = {}
|
||||
): Promise<void> {
|
||||
const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx, { warm });
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const global = createGlobalContextMock({
|
||||
runtimePlatform,
|
||||
staticContextContent: { job: { secrets: { robotAccessToken: 'robot-token' } } },
|
||||
});
|
||||
const inputs = Object.fromEntries(
|
||||
[
|
||||
'device_identifier',
|
||||
'system_image_package',
|
||||
'lcd_width',
|
||||
'lcd_height',
|
||||
'lcd_density',
|
||||
'install_app_path',
|
||||
'build_id',
|
||||
'application_archive_url',
|
||||
'launch_app_identifier',
|
||||
'launch_args',
|
||||
'open_url',
|
||||
'network_capture',
|
||||
'network_capture_fields',
|
||||
'public_preview',
|
||||
'private_preview_url',
|
||||
'frame_ancestors',
|
||||
'cors_origins',
|
||||
'package_version',
|
||||
'max_idle_time_minutes',
|
||||
'max_duration_seconds',
|
||||
].map(id => [id, { value: inputValues[id] }])
|
||||
(buildFunction.inputProviders ?? []).map(provider => {
|
||||
const input = provider(global, 'test');
|
||||
input.set(inputValues[input.id]);
|
||||
return [input.id, { value: input.rawValue }];
|
||||
})
|
||||
);
|
||||
return buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: {
|
||||
runtimePlatform,
|
||||
staticContext: { job: { secrets: { robotAccessToken: 'robot-token' } } },
|
||||
},
|
||||
global,
|
||||
} as unknown as BuildStepContext,
|
||||
{ inputs, outputs: {}, env: {}, signal } as never
|
||||
{ inputs, outputs: {}, env, signal } as never
|
||||
) as Promise<void>;
|
||||
}
|
||||
|
||||
@@ -149,7 +141,31 @@ function sessionDevice(): Device {
|
||||
}
|
||||
|
||||
describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
function mockStalledDownload(): { aborted: () => boolean; settled: () => boolean } {
|
||||
let aborted = false;
|
||||
let settled = false;
|
||||
jest.mocked(downloadBuildAsync).mockImplementation(
|
||||
({ signal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
signal!.addEventListener('abort', () => {
|
||||
aborted = true;
|
||||
// Settles a bit later, like a request that winds down.
|
||||
setImmediate(() => {
|
||||
settled = true;
|
||||
reject(new Error('The user aborted a request.'));
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
return { aborted: () => aborted, settled: () => settled };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.mocked(getAndroidEmulatorLogCapture)
|
||||
.mockImplementation(
|
||||
jest.requireActual('../../utils/androidEmulatorLogs').getAndroidEmulatorLogCapture
|
||||
);
|
||||
deviceLaunch = undefined;
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(null);
|
||||
@@ -161,6 +177,13 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ name: 'iPhone 17', udid: 'SELECTED-UDID' }] as never);
|
||||
jest.mocked(getAgentDeviceRemoteSessionEnvOrThrow).mockReturnValue(sessionEnv);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async ({ signal }) => {
|
||||
if (!signal?.aborted) {
|
||||
await new Promise<void>(resolve =>
|
||||
signal?.addEventListener('abort', () => resolve(), { once: true })
|
||||
);
|
||||
}
|
||||
});
|
||||
jest
|
||||
.mocked(runAgentDeviceRemoteSessionAsync)
|
||||
.mockImplementation(async (_ctx, { device, tasks }) => {
|
||||
@@ -183,6 +206,43 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
jest.mocked(readIosApplicationIdentifierAsync).mockResolvedValue('dev.example.app');
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'observes Stop during warm claim and preserves cleanup failure=%s',
|
||||
async failCleanup => {
|
||||
const stopped = deferred();
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise);
|
||||
let entered!: () => void;
|
||||
const claiming = new Promise<void>(resolve => {
|
||||
entered = resolve;
|
||||
});
|
||||
const cleanup = new Error('warm cleanup failed');
|
||||
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async claim => {
|
||||
entered();
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
claim.signal!.addEventListener(
|
||||
'abort',
|
||||
() =>
|
||||
reject(
|
||||
failCleanup
|
||||
? new AggregateError([claim.signal!.reason, cleanup])
|
||||
: claim.signal!.reason
|
||||
),
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
const running = runStep(BuildRuntimePlatform.DARWIN, {}, undefined, ctx, true);
|
||||
running.catch(() => {});
|
||||
await claiming;
|
||||
stopped.resolve();
|
||||
if (failCleanup) {
|
||||
await expect(running).rejects.toMatchObject({ errors: expect.arrayContaining([cleanup]) });
|
||||
} else {
|
||||
await expect(running).resolves.toBeUndefined();
|
||||
}
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
it('hands guarded iOS boot to serve-sim and starts reporting before host startup', async () => {
|
||||
const bootEnv = {
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
@@ -263,6 +323,33 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('treats Stop during guarded device shutdown as normal cancellation', async () => {
|
||||
const stopped = deferred();
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise);
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
});
|
||||
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Booted' } as never);
|
||||
jest.mocked(spawn).mockImplementation(
|
||||
(_command, _args, options) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
options!.signal!.addEventListener('abort', () => reject(new Error('shutdown aborted')), {
|
||||
once: true,
|
||||
});
|
||||
}) as ReturnType<typeof spawn>
|
||||
);
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' });
|
||||
step.catch(() => {});
|
||||
await flushAsync();
|
||||
expect(spawn).toHaveBeenCalled();
|
||||
stopped.resolve();
|
||||
await expect(step).resolves.toBeUndefined();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
expect(startLocalEgressGuardRelayAsync).not.toHaveBeenCalled();
|
||||
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('provides Simulator preparation and local app startup options to the remote session', async () => {
|
||||
const hostReady = deferred();
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
|
||||
@@ -343,6 +430,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
expect(getAndroidEmulatorLogCapture).not.toHaveBeenCalled();
|
||||
expect(sessionDevice()).toEqual({
|
||||
iosSimulatorUdid: 'SELECTED-UDID',
|
||||
bootEnv: undefined,
|
||||
@@ -350,6 +438,48 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults an omitted session log input to emulator output through the step boundary', async () => {
|
||||
const buildFunction = createStartAgentDeviceSessionBuildFunction(ctx);
|
||||
const global = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX });
|
||||
const step = buildFunction.createBuildStepFromFunctionCall(global, {
|
||||
callInputs: {},
|
||||
env: { DEVICE_RUN_SESSION_ID: 'session' },
|
||||
});
|
||||
await step.executeAsync();
|
||||
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ logSources: ['emulator-output'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('passes an explicit empty source list and the workflow capture to Android boot', async () => {
|
||||
const capture = new AndroidEmulatorLogCapture();
|
||||
jest.mocked(getAndroidEmulatorLogCapture).mockReturnValue(capture);
|
||||
const env = { DEVICE_RUN_SESSION_ID: 'session' };
|
||||
await runStep(
|
||||
BuildRuntimePlatform.LINUX,
|
||||
{ android_log_sources: [] },
|
||||
undefined,
|
||||
ctx,
|
||||
false,
|
||||
env
|
||||
);
|
||||
expect(getAndroidEmulatorLogCapture).toHaveBeenCalledWith(expect.anything(), env);
|
||||
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ logSources: [], logCapture: capture })
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid Android log sources before boot or downloading', async () => {
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.LINUX, {
|
||||
android_log_sources: ['unknown'],
|
||||
build_id: 'build-id',
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('boots the Android Emulator with the device inputs', async () => {
|
||||
await runStep(BuildRuntimePlatform.LINUX, {
|
||||
device_identifier: 'pixel_7',
|
||||
@@ -376,6 +506,22 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('stops the download and waits for it when Android boot fails', async () => {
|
||||
const download = mockStalledDownload();
|
||||
jest.mocked(startAndroidEmulatorAsync).mockRejectedValue(new Error('boot failed'));
|
||||
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' }, undefined, ctx, false, {
|
||||
DEVICE_RUN_SESSION_ID: 'session',
|
||||
})
|
||||
).rejects.toThrow('boot failed');
|
||||
|
||||
expect(download.aborted()).toBe(true);
|
||||
// The step returned only after the download stopped.
|
||||
expect(download.settled()).toBe(true);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not start work for an already cancelled step', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error('cancelled'));
|
||||
@@ -388,6 +534,113 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
|
||||
'observes Stop during a held download on %s and drains startup normally',
|
||||
async runtimePlatform => {
|
||||
const download = mockStalledDownload();
|
||||
const stopped = deferred();
|
||||
const controller = new AbortController();
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise);
|
||||
const step = runStep(runtimePlatform, { build_id: 'build-id' }, controller.signal);
|
||||
step.catch(() => {});
|
||||
try {
|
||||
await flushAsync();
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deviceRunSessionId: sessionEnv.deviceRunSessionId,
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
);
|
||||
stopped.resolve();
|
||||
await expect(step).resolves.toBeUndefined();
|
||||
expect(download.aborted()).toBe(true);
|
||||
expect(download.settled()).toBe(true);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
expect(readIosApplicationIdentifierAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
controller.abort(new Error('test cleanup'));
|
||||
stopped.resolve();
|
||||
await step.catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('waits for an in-flight Android boot after Stop without installing or launching', async () => {
|
||||
const boot = deferred<Awaited<ReturnType<typeof startAndroidEmulatorAsync>>>();
|
||||
const stopped = deferred();
|
||||
jest.mocked(startAndroidEmulatorAsync).mockReturnValue(boot.promise);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise);
|
||||
let settled = false;
|
||||
const step = runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' });
|
||||
void step
|
||||
.finally(() => {
|
||||
settled = true;
|
||||
})
|
||||
.catch(() => {});
|
||||
try {
|
||||
await flushAsync();
|
||||
expect(startAndroidEmulatorAsync).toHaveBeenCalled();
|
||||
stopped.resolve();
|
||||
await flushAsync();
|
||||
expect(settled).toBe(false);
|
||||
} finally {
|
||||
boot.resolve({} as Awaited<ReturnType<typeof startAndroidEmulatorAsync>>);
|
||||
stopped.resolve();
|
||||
await step;
|
||||
}
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retires the startup watch without treating its abort as user Stop', async () => {
|
||||
jest
|
||||
.mocked(runAgentDeviceRemoteSessionAsync)
|
||||
.mockImplementation(async (_ctx, { onStarted, tasks }) => {
|
||||
await onStarted!();
|
||||
expect(tasks.signal.aborted).toBe(false);
|
||||
});
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN)).resolves.toBeUndefined();
|
||||
const options = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
expect(options.maxDurationSeconds).toBeUndefined();
|
||||
expect(options.idleTimeout).toBeUndefined();
|
||||
expect(options.signal!.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves a download failure that wins before Stop', async () => {
|
||||
const failed = new Error('download failed first');
|
||||
const stopped = deferred();
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise);
|
||||
jest.mocked(downloadBuildAsync).mockRejectedValue(failed);
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' });
|
||||
step.catch(() => {});
|
||||
await flushAsync();
|
||||
stopped.resolve();
|
||||
await expect(step).rejects.toBe(failed);
|
||||
});
|
||||
|
||||
it('fails when the startup status watch rejects', async () => {
|
||||
const failed = new Error('session errored');
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValue(failed);
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN)).rejects.toBe(failed);
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels and drains a pending download through the external signal', async () => {
|
||||
const controller = new AbortController();
|
||||
const download = mockStalledDownload();
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal);
|
||||
const failure = expect(step).rejects.toThrow('cancelled');
|
||||
await flushAsync();
|
||||
controller.abort(new Error('cancelled'));
|
||||
|
||||
await failure;
|
||||
expect(download.aborted()).toBe(true);
|
||||
expect(download.settled()).toBe(true);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not start device or app work after cancellation during Xcode selection', async () => {
|
||||
const controller = new AbortController();
|
||||
const selected = deferred();
|
||||
@@ -416,6 +669,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual([
|
||||
'android_log_sources',
|
||||
'device_identifier',
|
||||
'system_image_package',
|
||||
'lcd_width',
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from '../../../utils/AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../../../utils/retry';
|
||||
import { resolveAndroidEmulatorLocalEgressAsync } from '../../utils/androidLocalEgress';
|
||||
import { AndroidEmulatorLogCapture } from '../../../utils/androidEmulatorLogCapture';
|
||||
import { getAndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogs';
|
||||
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
@@ -24,6 +26,8 @@ jest.mock('../../utils/androidLocalEgress', () => ({
|
||||
resolveAndroidEmulatorLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/androidEmulatorLogs');
|
||||
|
||||
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
AndroidEmulatorUtils: {
|
||||
defaultSystemImagePackage: 'system-images;android-30;default;x86_64',
|
||||
@@ -62,6 +66,11 @@ function createStartResult(serialId: string) {
|
||||
|
||||
describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.mocked(getAndroidEmulatorLogCapture)
|
||||
.mockImplementation(
|
||||
jest.requireActual('../../utils/androidEmulatorLogs').getAndroidEmulatorLogCapture
|
||||
);
|
||||
mockedMkdtemp.mockResolvedValue('/tmp/logcat-directory');
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
mockedAndroidUtils.getAvailableDevicesAsync.mockResolvedValue([]);
|
||||
@@ -90,6 +99,30 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
mockedMkdtemp.mockRestore();
|
||||
});
|
||||
|
||||
it('preserves an explicit empty source list and one capture through boot retries and clones', async () => {
|
||||
mockedAndroidUtils.waitForReadyAsync.mockRejectedValueOnce(new Error('retry boot'));
|
||||
const capture = new AndroidEmulatorLogCapture();
|
||||
jest.mocked(getAndroidEmulatorLogCapture).mockReturnValue(capture);
|
||||
await createStep(
|
||||
{ android_log_sources: [], count: 2 },
|
||||
{ DEVICE_RUN_SESSION_ID: 'session' }
|
||||
).executeAsync();
|
||||
expect(getAndroidEmulatorLogCapture).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ DEVICE_RUN_SESSION_ID: 'session' })
|
||||
);
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledTimes(4);
|
||||
for (const [options] of mockedAndroidUtils.startAsync.mock.calls) {
|
||||
expect(options.logSources).toEqual([]);
|
||||
expect(options.logCapture).toBe(capture);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid sources before starting the emulator', async () => {
|
||||
await expect(createStep({ android_log_sources: ['unknown'] }).executeAsync()).rejects.toThrow();
|
||||
expect(mockedAndroidUtils.startAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('local egress', () => {
|
||||
function createLocalEgress() {
|
||||
return {
|
||||
@@ -111,6 +144,8 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
deviceName: 'EasAndroidDevice01',
|
||||
env: expect.any(Object),
|
||||
logcatDirectory: '/tmp/logcat-directory',
|
||||
logSources: ['emulator-output', 'device-logcat'],
|
||||
logCapture: expect.any(AndroidEmulatorLogCapture),
|
||||
});
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith({
|
||||
env: expect.any(Object),
|
||||
|
||||
+410
-4
@@ -1,7 +1,9 @@
|
||||
import { type WarmIosSession } from '../../utils/warmIosSession';
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as timersPromises from 'node:timers/promises';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
@@ -13,6 +15,7 @@ import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import { preparePreinstalledWdaAsync } from '../../utils/preinstalledWda';
|
||||
import { resolveLocalEgressServeSimBootEnvironmentAsync } from '../../utils/localEgressGuard';
|
||||
import {
|
||||
WarmSessionAdoptionError,
|
||||
runOnWarmIosSimulatorAsync,
|
||||
@@ -25,6 +28,8 @@ import {
|
||||
startNgrokTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { watchDeviceRunSessionStartup } from '../../utils/deviceRunSessionStartup';
|
||||
import { createStartupWatchMock } from '../../../__tests__/utils/startupWatch';
|
||||
|
||||
import {
|
||||
createStartAppiumRemoteSessionBuildFunction,
|
||||
@@ -33,6 +38,10 @@ import {
|
||||
resolveAppiumDeviceAsync,
|
||||
} from '../startAppiumRemoteSession';
|
||||
|
||||
jest.mock('node:timers/promises', () => ({
|
||||
...jest.requireActual('node:timers/promises'),
|
||||
setTimeout: jest.fn(jest.requireActual('node:timers/promises').setTimeout),
|
||||
}));
|
||||
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
AndroidEmulatorUtils: { getAttachedDevicesAsync: jest.fn() },
|
||||
}));
|
||||
@@ -51,6 +60,9 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/deviceRunSessionStartup', () => ({
|
||||
watchDeviceRunSessionStartup: jest.fn(),
|
||||
}));
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../utils/retry', () => ({
|
||||
@@ -106,6 +118,7 @@ describe(resolveAppium3VersionSpec, () => {
|
||||
describe(resolveAppiumDeviceAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock);
|
||||
});
|
||||
|
||||
it('returns XCUITest capabilities for the booted iOS simulator', async () => {
|
||||
@@ -158,6 +171,7 @@ describe(resolveAppiumDeviceAsync, () => {
|
||||
describe(installAppiumAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock);
|
||||
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (args.includes('--json')) {
|
||||
return { stdout: JSON.stringify({}) };
|
||||
@@ -344,6 +358,10 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
const finishHost = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.mocked(timersPromises.setTimeout)
|
||||
.mockImplementation(jest.requireActual('node:timers/promises').setTimeout);
|
||||
jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock);
|
||||
jest.mocked(ensureFfmpegInstalledOnceAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(preparePreinstalledWdaAsync).mockResolvedValue({});
|
||||
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({
|
||||
@@ -396,7 +414,8 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
maxIdleTimeMinutes?: number,
|
||||
runtimePlatform = BuildRuntimePlatform.LINUX,
|
||||
startupInputs: Record<string, { value: unknown }> = {},
|
||||
warm = false
|
||||
warm = false,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
await createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext, { warm }).fn!(
|
||||
{
|
||||
@@ -410,6 +429,7 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
...startupInputs,
|
||||
},
|
||||
outputs: {},
|
||||
signal,
|
||||
env: {
|
||||
DEVICE_RUN_SESSION_ID: 'device-run-session-id',
|
||||
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'tunnel.example.com',
|
||||
@@ -428,6 +448,134 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
);
|
||||
}
|
||||
|
||||
it('does not allocate Appium or a host after parent cancellation during Xcode selection', async () => {
|
||||
const controller = new AbortController();
|
||||
const failure = new Error('parent step cancelled');
|
||||
jest
|
||||
.mocked(selectXcodeDeveloperDirectoryAsync)
|
||||
.mockImplementationOnce(async () => controller.abort(failure));
|
||||
await expect(
|
||||
runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {}, false, controller.signal)
|
||||
).rejects.toBe(failure);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves an aggregate host startup failure', async () => {
|
||||
const failure = new AggregateError([
|
||||
new Error('host failed'),
|
||||
new Error('host cleanup failed'),
|
||||
]);
|
||||
jest.mocked(startDeviceSessionHostAsync).mockRejectedValueOnce(failure);
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
await expect(runSessionAsync(logger)).rejects.toBe(failure);
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports late host cleanup failure after startup Stop', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
let entered!: () => void;
|
||||
const starting = new Promise<void>(resolve => {
|
||||
entered = resolve;
|
||||
});
|
||||
let failed!: AggregateError;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
|
||||
entered();
|
||||
return await new Promise((_resolve, reject) => {
|
||||
options.signal!.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
failed = new AggregateError([options.signal!.reason, new Error('host cleanup failed')]);
|
||||
setImmediate(() => reject(failed));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
|
||||
boot_simulator: { value: true },
|
||||
device_identifier: { value: 'selected-ios-udid' },
|
||||
});
|
||||
running.catch(() => {});
|
||||
await starting;
|
||||
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
|
||||
const failure = await running.then(
|
||||
() => undefined,
|
||||
error => error
|
||||
);
|
||||
expect(failure).toBe(failed);
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps watching startup when warm adoption retries cold', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
const adoption = new Error('warm adoption failed');
|
||||
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async (_claim, run) => {
|
||||
try {
|
||||
return await run({
|
||||
device: { udid: 'selected-ios-udid', name: 'iPhone 17' },
|
||||
} as WarmIosSession);
|
||||
} catch (error) {
|
||||
if (error !== adoption) {
|
||||
throw error;
|
||||
}
|
||||
return await run(undefined);
|
||||
}
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
|
||||
options.onStartupError?.(adoption);
|
||||
throw adoption;
|
||||
});
|
||||
await expect(
|
||||
Promise.resolve(
|
||||
runSessionAsync(
|
||||
logger,
|
||||
undefined,
|
||||
BuildRuntimePlatform.DARWIN,
|
||||
{ boot_simulator: { value: true }, device_identifier: { value: 'selected-ios-udid' } },
|
||||
true
|
||||
)
|
||||
)
|
||||
).resolves.toBeUndefined();
|
||||
expect(watchDeviceRunSessionStartup).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'observes Stop during warm claim and preserves cleanup failure=%s',
|
||||
async failCleanup => {
|
||||
let entered!: () => void;
|
||||
const claiming = new Promise<void>(resolve => {
|
||||
entered = resolve;
|
||||
});
|
||||
const cleanup = new Error('warm cleanup failed');
|
||||
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async claim => {
|
||||
entered();
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
claim.signal!.addEventListener(
|
||||
'abort',
|
||||
() =>
|
||||
reject(
|
||||
failCleanup
|
||||
? new AggregateError([claim.signal!.reason, cleanup])
|
||||
: claim.signal!.reason
|
||||
),
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {}, true);
|
||||
running.catch(() => {});
|
||||
await claiming;
|
||||
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
|
||||
if (failCleanup) {
|
||||
await expect(running).rejects.toMatchObject({ errors: expect.arrayContaining([cleanup]) });
|
||||
} else {
|
||||
await expect(running).resolves.toBeUndefined();
|
||||
}
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
it('passes the existing Android selection to the preview host', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
await runSessionAsync(logger);
|
||||
@@ -631,6 +779,116 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
}
|
||||
}
|
||||
);
|
||||
it.each([
|
||||
[BuildRuntimePlatform.LINUX, false],
|
||||
[BuildRuntimePlatform.DARWIN, false],
|
||||
[BuildRuntimePlatform.DARWIN, true],
|
||||
])(
|
||||
'handles Stop during the readiness retry sleep on %s with startup=%s',
|
||||
async (runtimePlatform, bootSimulator) => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid: 'chosen-ios-udid' } as never]);
|
||||
jest.mocked(turtleFetch).mockResolvedValue({ ok: false } as never);
|
||||
jest.mocked(timersPromises.setTimeout).mockImplementationOnce((async (ms, value, options) => {
|
||||
const sleeping = jest.requireActual('node:timers/promises').setTimeout(ms, value, options);
|
||||
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
|
||||
return sleeping;
|
||||
}) as typeof timersPromises.setTimeout);
|
||||
await expect(
|
||||
runSessionAsync(
|
||||
logger,
|
||||
undefined,
|
||||
runtimePlatform,
|
||||
bootSimulator
|
||||
? { boot_simulator: { value: true }, device_identifier: { value: 'chosen-device' } }
|
||||
: {}
|
||||
)
|
||||
).resolves.toBeUndefined();
|
||||
expect(stopAppium).toHaveBeenCalledTimes(1);
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('does not open the preview after Stop while the Appium tunnel is opening', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
let tunnelEntered!: () => void;
|
||||
const entered = new Promise<void>(resolve => {
|
||||
tunnelEntered = resolve;
|
||||
});
|
||||
let finishTunnel!: () => void;
|
||||
const pendingTunnel = new Promise<void>(resolve => {
|
||||
finishTunnel = resolve;
|
||||
});
|
||||
const stopTunnel = jest.fn();
|
||||
jest.mocked(startNgrokTunnelAsync).mockImplementationOnce(async () => {
|
||||
tunnelEntered();
|
||||
await pendingTunnel;
|
||||
return { url: 'https://appium.example.com', subdomainId: 'appium', stopAsync: stopTunnel };
|
||||
});
|
||||
const openPreviewAsync = jest.fn();
|
||||
const finishHost = jest.fn();
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
|
||||
openPreviewAsync,
|
||||
finishAsync: finishHost,
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
});
|
||||
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
|
||||
boot_simulator: { value: true },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
});
|
||||
running.catch(() => {});
|
||||
try {
|
||||
await entered;
|
||||
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
|
||||
} finally {
|
||||
finishTunnel();
|
||||
}
|
||||
await expect(running).resolves.toBeUndefined();
|
||||
expect(openPreviewAsync).not.toHaveBeenCalled();
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
expect(stopAppium).toHaveBeenCalledTimes(1);
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ends normally when Stop interrupts the LOCAL-guard Simulator shutdown', async () => {
|
||||
jest
|
||||
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
|
||||
.mockResolvedValueOnce({ SERVE_SIM_ADDITIONAL_DYLIBS: '/guard.dylib' });
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getDeviceAsync)
|
||||
.mockResolvedValueOnce({ state: 'Booted' } as never);
|
||||
let shutdownEntered!: () => void;
|
||||
const entered = new Promise<void>(resolve => {
|
||||
shutdownEntered = resolve;
|
||||
});
|
||||
jest.mocked(spawn).mockImplementationOnce((_command, _args, options) => {
|
||||
shutdownEntered();
|
||||
return new Promise((_resolve, reject) =>
|
||||
options!.signal!.addEventListener(
|
||||
'abort',
|
||||
() => reject(new Error('Shutdown process aborted', { cause: options!.signal!.reason })),
|
||||
{ once: true }
|
||||
)
|
||||
) as ReturnType<typeof spawn>;
|
||||
});
|
||||
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
|
||||
boot_simulator: { value: true },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
});
|
||||
running.catch(() => {});
|
||||
await entered;
|
||||
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
|
||||
await expect(running).resolves.toBeUndefined();
|
||||
expect(ensureFfmpegInstalledOnceAsync).not.toHaveBeenCalled();
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'preserves preview settings with Simulator startup %s',
|
||||
@@ -660,6 +918,154 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
[BuildRuntimePlatform.DARWIN, false],
|
||||
[BuildRuntimePlatform.LINUX, false],
|
||||
[BuildRuntimePlatform.DARWIN, true],
|
||||
])(
|
||||
'handles Stop during %s setup, preserving teardown failure=%s',
|
||||
async (runtimePlatform, teardownFails) => {
|
||||
let setupEntered!: () => void;
|
||||
const entered = new Promise<void>(resolve => {
|
||||
setupEntered = resolve;
|
||||
});
|
||||
jest.mocked(ensureFfmpegInstalledOnceAsync).mockImplementationOnce(({ signal }) => {
|
||||
setupEntered();
|
||||
return new Promise<void>(resolve =>
|
||||
signal!.addEventListener('abort', () => resolve(), { once: true })
|
||||
);
|
||||
});
|
||||
const teardownError = new Error('host teardown failed');
|
||||
const finishHost = jest.fn().mockImplementation(async () => {
|
||||
if (teardownFails) {
|
||||
throw teardownError;
|
||||
}
|
||||
});
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
|
||||
openPreviewAsync: jest.fn(),
|
||||
finishAsync: finishHost,
|
||||
getLastPreviewActivityAt: () => undefined,
|
||||
});
|
||||
}
|
||||
const running = runSessionAsync(
|
||||
{ info: jest.fn(), warn: jest.fn() },
|
||||
undefined,
|
||||
runtimePlatform,
|
||||
runtimePlatform === BuildRuntimePlatform.DARWIN
|
||||
? { boot_simulator: { value: true }, device_identifier: { value: 'chosen-ios-udid' } }
|
||||
: {}
|
||||
);
|
||||
running.catch(() => {});
|
||||
await entered;
|
||||
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
|
||||
watch.stop();
|
||||
if (teardownFails) {
|
||||
await expect(running).rejects.toBe(teardownError);
|
||||
} else {
|
||||
await expect(running).resolves.toBeUndefined();
|
||||
}
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(watch.finishAsync).toHaveBeenCalled();
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
[BuildRuntimePlatform.DARWIN, false],
|
||||
[BuildRuntimePlatform.DARWIN, true],
|
||||
[BuildRuntimePlatform.LINUX, false],
|
||||
])(
|
||||
'preserves failed installer cleanup after Stop on %s with Simulator startup=%s',
|
||||
async (runtimePlatform, bootSimulator) => {
|
||||
const cleanupError = new Error('Appium home removal failed: EACCES');
|
||||
const remove = fs.promises.rm.bind(fs.promises);
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid: 'chosen-ios-udid' } as never]);
|
||||
let enteredInstall!: () => void;
|
||||
const installEntered = new Promise<void>(resolve => {
|
||||
enteredInstall = resolve;
|
||||
});
|
||||
let closeInstaller!: (error: Error) => void;
|
||||
jest.mocked(spawn).mockImplementationOnce(() => {
|
||||
enteredInstall();
|
||||
return Object.assign(
|
||||
new Promise((_resolve, reject) => {
|
||||
closeInstaller = reject;
|
||||
}),
|
||||
{ child: { pid: 12345 } }
|
||||
) as never;
|
||||
});
|
||||
jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
|
||||
const rm = jest.spyOn(fs.promises, 'rm').mockRejectedValueOnce(cleanupError);
|
||||
const running = runSessionAsync(
|
||||
{ info: jest.fn(), warn: jest.fn() },
|
||||
undefined,
|
||||
runtimePlatform,
|
||||
bootSimulator
|
||||
? { boot_simulator: { value: true }, device_identifier: { value: 'chosen-ios-udid' } }
|
||||
: {}
|
||||
);
|
||||
const observed = running.catch(() => {});
|
||||
let home: string | undefined;
|
||||
try {
|
||||
await installEntered;
|
||||
home = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
|
||||
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
|
||||
watch.stop();
|
||||
closeInstaller(new Error('installer interrupted'));
|
||||
await expect(running).rejects.toBe(cleanupError);
|
||||
expect(rm).toHaveBeenCalledWith(home, { recursive: true, force: true });
|
||||
expect(fs.existsSync(home)).toBe(true);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(watch.finishAsync).toHaveBeenCalled();
|
||||
} finally {
|
||||
closeInstaller?.(new Error('test cleanup'));
|
||||
await observed;
|
||||
if (home) {
|
||||
await remove(home, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('preserves an Android installer failure observed before a later Stop', async () => {
|
||||
const failed = new Error('Appium install failed first');
|
||||
let observedFailure!: () => void;
|
||||
const installFailed = new Promise<void>(resolve => {
|
||||
observedFailure = resolve;
|
||||
});
|
||||
jest.mocked(spawn).mockImplementationOnce((() => {
|
||||
observedFailure();
|
||||
return Promise.reject(failed);
|
||||
}) as never);
|
||||
jest
|
||||
.mocked(ensureFfmpegInstalledOnceAsync)
|
||||
.mockImplementationOnce(
|
||||
({ signal }) =>
|
||||
new Promise<void>(resolve =>
|
||||
signal!.addEventListener('abort', () => resolve(), { once: true })
|
||||
)
|
||||
);
|
||||
const running = runSessionAsync(
|
||||
{ info: jest.fn(), warn: jest.fn() },
|
||||
undefined,
|
||||
BuildRuntimePlatform.LINUX
|
||||
);
|
||||
running.catch(() => {});
|
||||
await installFailed;
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
|
||||
watch.stop();
|
||||
await expect(running).rejects.toBe(failed);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('selects Xcode only once for an already-prepared iOS session', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockClear();
|
||||
@@ -976,9 +1382,9 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
it('keeps the readiness error when Appium cannot be stopped', async () => {
|
||||
let now = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
jest.mocked(sleepAsync).mockImplementation(async ms => {
|
||||
now += ms;
|
||||
});
|
||||
jest.mocked(timersPromises.setTimeout).mockImplementation((async ms => {
|
||||
now += Number(ms ?? 0);
|
||||
}) as typeof timersPromises.setTimeout);
|
||||
jest.mocked(turtleFetch).mockRejectedValue(new Error('connect ECONNREFUSED'));
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
|
||||
|
||||
+365
-2
@@ -29,6 +29,8 @@ import {
|
||||
runOnWarmIosSimulatorAsync,
|
||||
} from '../../utils/warmDeviceSessionHost';
|
||||
import { type WarmIosSession } from '../../utils/warmIosSession';
|
||||
import { watchDeviceRunSessionStartup } from '../../utils/deviceRunSessionStartup';
|
||||
import { createStartupWatchMock } from '../../../__tests__/utils/startupWatch';
|
||||
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
|
||||
import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact';
|
||||
|
||||
@@ -86,6 +88,9 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/deviceRunSessionStartup', () => ({
|
||||
watchDeviceRunSessionStartup: jest.fn(),
|
||||
}));
|
||||
|
||||
const TEST_HOME = path.join(os.tmpdir(), 'eas-argent-orchestration-home');
|
||||
const ARGENT_STATE_DIR = path.join(TEST_HOME, '.argent');
|
||||
@@ -136,6 +141,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock);
|
||||
|
||||
// The (real) tool-server state wait reads this file from the redirected ~/.argent.
|
||||
await fs.promises.mkdir(ARGENT_STATE_DIR, { recursive: true });
|
||||
@@ -149,6 +155,363 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
await fs.promises.rm(TEST_HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[BuildRuntimePlatform.DARWIN, false],
|
||||
[BuildRuntimePlatform.LINUX, false],
|
||||
[BuildRuntimePlatform.DARWIN, true],
|
||||
])(
|
||||
'handles Stop during %s setup, keeping teardown failure=%s visible',
|
||||
async (runtimePlatform, teardownFails) => {
|
||||
let rejectSetup!: (error: Error) => void;
|
||||
const setup = Object.assign(
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
rejectSetup = reject;
|
||||
}),
|
||||
{ child: { pid: 12345 } }
|
||||
);
|
||||
jest.mocked(spawn).mockReturnValueOnce(setup as unknown as ReturnType<typeof spawn>);
|
||||
jest
|
||||
.mocked(killProcessGroup)
|
||||
.mockImplementationOnce(() => rejectSetup(new Error('setup killed')));
|
||||
const teardownError = new Error('host teardown failed');
|
||||
if (teardownFails) {
|
||||
mockPreviewStopAsync.mockRejectedValueOnce(teardownError);
|
||||
}
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
const running = Promise.resolve(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
boot_simulator: {
|
||||
value: runtimePlatform === BuildRuntimePlatform.DARWIN ? true : undefined,
|
||||
},
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
);
|
||||
running.catch(() => {});
|
||||
try {
|
||||
for (let i = 0; i < 20 && !jest.mocked(spawn).mock.calls.length; i++) {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
|
||||
watch.stop();
|
||||
if (teardownFails) {
|
||||
await expect(running).rejects.toBe(teardownError);
|
||||
} else {
|
||||
await expect(running).resolves.toBeUndefined();
|
||||
}
|
||||
expect(killProcessGroup).toHaveBeenCalledTimes(1);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
expect(watch.finishAsync).toHaveBeenCalled();
|
||||
} finally {
|
||||
rejectSetup(new Error('test cleanup'));
|
||||
await running.catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('does not allocate Argent or a host after parent cancellation during Xcode selection', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
const controller = new AbortController();
|
||||
const failure = new Error('parent step cancelled');
|
||||
jest
|
||||
.mocked(selectXcodeDeveloperDirectoryAsync)
|
||||
.mockImplementationOnce(async () => controller.abort(failure));
|
||||
await expect(
|
||||
createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
signal: controller.signal,
|
||||
} as never
|
||||
)
|
||||
).rejects.toBe(failure);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves an aggregate host startup failure', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const failure = new AggregateError([
|
||||
new Error('host failed'),
|
||||
new Error('host cleanup failed'),
|
||||
]);
|
||||
jest.mocked(startDeviceSessionHostAsync).mockRejectedValueOnce(failure);
|
||||
await expect(
|
||||
createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
boot_simulator: { value: true },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
).rejects.toBe(failure);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports late host cleanup failure after startup Stop', async () => {
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
let entered!: () => void;
|
||||
const starting = new Promise<void>(resolve => {
|
||||
entered = resolve;
|
||||
});
|
||||
let failed!: AggregateError;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
|
||||
entered();
|
||||
return await new Promise((_resolve, reject) => {
|
||||
options.signal!.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
failed = new AggregateError([options.signal!.reason, new Error('host cleanup failed')]);
|
||||
setImmediate(() => reject(failed));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
const running = createStartArgentRemoteSessionBuildFunction(ctx).fn!(
|
||||
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
|
||||
{
|
||||
inputs: {
|
||||
boot_simulator: { value: true },
|
||||
device_identifier: { value: 'selected-ios-udid' },
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
) as Promise<void>;
|
||||
running.catch(() => {});
|
||||
await starting;
|
||||
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
|
||||
const failure = await running.then(
|
||||
() => undefined,
|
||||
error => error
|
||||
);
|
||||
expect(failure).toBe(failed);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps watching startup when warm adoption retries cold', async () => {
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const adoption = new Error('warm adoption failed');
|
||||
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async (_claim, run) => {
|
||||
try {
|
||||
return await run({
|
||||
device: { udid: 'selected-ios-udid', name: 'iPhone 17' },
|
||||
} as WarmIosSession);
|
||||
} catch (error) {
|
||||
if (error !== adoption) {
|
||||
throw error;
|
||||
}
|
||||
return await run(undefined);
|
||||
}
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
|
||||
options.onStartupError?.(adoption);
|
||||
throw adoption;
|
||||
});
|
||||
await expect(
|
||||
Promise.resolve(
|
||||
createStartArgentRemoteSessionBuildFunction(ctx, { warm: true }).fn!(
|
||||
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
|
||||
{
|
||||
inputs: {
|
||||
boot_simulator: { value: true },
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
)
|
||||
).resolves.toBeUndefined();
|
||||
expect(watchDeviceRunSessionStartup).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'observes Stop during warm claim and preserves cleanup failure=%s',
|
||||
async failCleanup => {
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
let entered!: () => void;
|
||||
const claiming = new Promise<void>(resolve => {
|
||||
entered = resolve;
|
||||
});
|
||||
const cleanup = new Error('warm cleanup failed');
|
||||
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async claim => {
|
||||
entered();
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
claim.signal!.addEventListener(
|
||||
'abort',
|
||||
() =>
|
||||
reject(
|
||||
failCleanup
|
||||
? new AggregateError([claim.signal!.reason, cleanup])
|
||||
: claim.signal!.reason
|
||||
),
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
const running = createStartArgentRemoteSessionBuildFunction(ctx, { warm: true }).fn!(
|
||||
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
) as Promise<void>;
|
||||
running.catch(() => {});
|
||||
await claiming;
|
||||
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
|
||||
if (failCleanup) {
|
||||
await expect(running).rejects.toMatchObject({ errors: expect.arrayContaining([cleanup]) });
|
||||
} else {
|
||||
await expect(running).resolves.toBeUndefined();
|
||||
}
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
it('does not open the preview after Stop while the tools tunnel is opening', async () => {
|
||||
let tunnelEntered!: () => void;
|
||||
const entered = new Promise<void>(resolve => {
|
||||
tunnelEntered = resolve;
|
||||
});
|
||||
let finishTunnel!: () => void;
|
||||
const pendingTunnel = new Promise<void>(resolve => {
|
||||
finishTunnel = resolve;
|
||||
});
|
||||
jest.mocked(startNgrokTunnelAsync).mockImplementationOnce(async () => {
|
||||
tunnelEntered();
|
||||
await pendingTunnel;
|
||||
return {
|
||||
url: 'https://argent.example.com',
|
||||
subdomainId: 'argent',
|
||||
stopAsync: mockTunnelStopAsync,
|
||||
};
|
||||
});
|
||||
const openPreviewAsync = jest.fn();
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
|
||||
openPreviewAsync,
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
});
|
||||
const running = Promise.resolve(
|
||||
createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
boot_simulator: { value: true },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
);
|
||||
running.catch(() => {});
|
||||
try {
|
||||
await entered;
|
||||
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
|
||||
} finally {
|
||||
finishTunnel();
|
||||
}
|
||||
await expect(running).resolves.toBeUndefined();
|
||||
expect(openPreviewAsync).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
|
||||
'preserves an earlier prepared %s host failure when Stop arrives during cleanup',
|
||||
async runtimePlatform => {
|
||||
const failure = new Error('Host became ready without a session token');
|
||||
let cleanupEntered!: () => void;
|
||||
const entered = new Promise<void>(resolve => {
|
||||
cleanupEntered = resolve;
|
||||
});
|
||||
let finishCleanup!: () => void;
|
||||
const cleanup = new Promise<void>(resolve => {
|
||||
finishCleanup = resolve;
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
|
||||
options.onStartupError?.(failure);
|
||||
cleanupEntered();
|
||||
await cleanup;
|
||||
throw failure;
|
||||
});
|
||||
const running = Promise.resolve(
|
||||
createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
);
|
||||
running.catch(() => {});
|
||||
try {
|
||||
await entered;
|
||||
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
|
||||
} finally {
|
||||
finishCleanup();
|
||||
}
|
||||
await expect(running).rejects.toBe(failure);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('reports an early exit with output and stops Argent before opening tunnels', async () => {
|
||||
const stopError = new Error('drain timed out');
|
||||
const stopServer = jest.fn().mockRejectedValue(stopError);
|
||||
@@ -964,7 +1327,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
});
|
||||
|
||||
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
|
||||
'keeps %s prepared setup commands outside the new cancellation path',
|
||||
'makes %s prepared setup commands cancellable',
|
||||
async runtimePlatform => {
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await buildFunction.fn!(
|
||||
@@ -983,7 +1346,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
);
|
||||
expect(spawn).toHaveBeenCalledTimes(3);
|
||||
for (const [, , options] of jest.mocked(spawn).mock.calls) {
|
||||
expect(options).not.toHaveProperty('detached');
|
||||
expect(options).toHaveProperty('detached', true);
|
||||
expect(options).not.toHaveProperty('signal');
|
||||
}
|
||||
expect(killProcessGroup).not.toHaveBeenCalled();
|
||||
|
||||
+213
-2
@@ -17,6 +17,8 @@ import {
|
||||
runOnWarmIosSimulatorAsync,
|
||||
} from '../../utils/warmDeviceSessionHost';
|
||||
import { type WarmIosSession } from '../../utils/warmIosSession';
|
||||
import { watchDeviceRunSessionStartup } from '../../utils/deviceRunSessionStartup';
|
||||
import { createStartupWatchMock } from '../../../__tests__/utils/startupWatch';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
|
||||
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
@@ -45,6 +47,9 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/deviceRunSessionStartup', () => ({
|
||||
watchDeviceRunSessionStartup: jest.fn(),
|
||||
}));
|
||||
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const env = { NGROK_AUTHTOKEN: 'ngrok-token', PATH: '/usr/bin' } as BuildStepEnv;
|
||||
@@ -54,7 +59,8 @@ const stopAsync = jest.fn();
|
||||
|
||||
async function runAsync(
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
launchInputs: Record<string, { value: unknown }> = {}
|
||||
launchInputs: Record<string, { value: unknown }> = {},
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx);
|
||||
await buildFunction.fn!(
|
||||
@@ -69,6 +75,7 @@ async function runAsync(
|
||||
...launchInputs,
|
||||
},
|
||||
outputs: {},
|
||||
signal,
|
||||
env,
|
||||
} as never
|
||||
);
|
||||
@@ -92,9 +99,109 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock);
|
||||
stopAsync.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('does not start a host when the parent aborts during Xcode selection', async () => {
|
||||
const controller = new AbortController();
|
||||
const failure = new Error('parent step cancelled');
|
||||
jest
|
||||
.mocked(selectXcodeDeveloperDirectoryAsync)
|
||||
.mockImplementationOnce(async () => controller.abort(failure));
|
||||
await expect(runAsync(BuildRuntimePlatform.DARWIN, {}, controller.signal)).rejects.toBe(
|
||||
failure
|
||||
);
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
expect(watchDeviceRunSessionStartup).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.finishAsync
|
||||
).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps watching startup when warm adoption retries cold', async () => {
|
||||
const adoption = new Error('warm adoption failed');
|
||||
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async (_claim, run) => {
|
||||
try {
|
||||
return await run({
|
||||
device: { udid: 'selected-ios-udid', name: 'iPhone 17' },
|
||||
} as WarmIosSession);
|
||||
} catch (error) {
|
||||
if (error !== adoption) {
|
||||
throw error;
|
||||
}
|
||||
return await run(undefined);
|
||||
}
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
|
||||
options.onStartupError?.(adoption);
|
||||
throw adoption;
|
||||
});
|
||||
await expect(
|
||||
Promise.resolve(
|
||||
createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true }).fn!(
|
||||
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
|
||||
{
|
||||
inputs: {
|
||||
boot_simulator: { value: true },
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env,
|
||||
} as never
|
||||
)
|
||||
)
|
||||
).resolves.toBeUndefined();
|
||||
expect(watchDeviceRunSessionStartup).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'observes Stop during warm claim and preserves cleanup failure=%s',
|
||||
async failCleanup => {
|
||||
let entered!: () => void;
|
||||
const claiming = new Promise<void>(resolve => {
|
||||
entered = resolve;
|
||||
});
|
||||
const cleanup = new Error('warm cleanup failed');
|
||||
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async claim => {
|
||||
entered();
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
claim.signal!.addEventListener(
|
||||
'abort',
|
||||
() =>
|
||||
reject(
|
||||
failCleanup
|
||||
? new AggregateError([claim.signal!.reason, cleanup])
|
||||
: claim.signal!.reason
|
||||
),
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
const running = createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true }).fn!(
|
||||
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env,
|
||||
} as never
|
||||
) as Promise<void>;
|
||||
running.catch(() => {});
|
||||
await claiming;
|
||||
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
|
||||
if (failCleanup) {
|
||||
await expect(running).rejects.toMatchObject({ errors: expect.arrayContaining([cleanup]) });
|
||||
} else {
|
||||
await expect(running).resolves.toBeUndefined();
|
||||
}
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
it('adopts the claimed warm Simulator when the warm step runs', async () => {
|
||||
const warmServer = { port: 3200 } as never;
|
||||
const warm = { device: { udid: 'SELECTED-IOS-UDID', name: 'iPhone 17' } } as WarmIosSession;
|
||||
@@ -158,6 +265,108 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
|
||||
'handles Stop while the %s host starts',
|
||||
async runtimePlatform => {
|
||||
let hostEntered!: () => void;
|
||||
const entered = new Promise<void>(resolve => {
|
||||
hostEntered = resolve;
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce((_ctx, { signal }) => {
|
||||
hostEntered();
|
||||
return new Promise((_resolve, reject) =>
|
||||
signal!.addEventListener('abort', () => reject(new Error('host aborted')), { once: true })
|
||||
);
|
||||
});
|
||||
const running = runAsync(runtimePlatform);
|
||||
running.catch(() => {});
|
||||
await entered;
|
||||
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
|
||||
watch.stop();
|
||||
await expect(running).resolves.toBeUndefined();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
expect(watch.finishAsync).toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
|
||||
'preserves an earlier %s host failure when Stop arrives during cleanup',
|
||||
async runtimePlatform => {
|
||||
const failure = new Error('Host became ready without a session token');
|
||||
let cleanupEntered!: () => void;
|
||||
const entered = new Promise<void>(resolve => {
|
||||
cleanupEntered = resolve;
|
||||
});
|
||||
let finishCleanup!: () => void;
|
||||
const cleanup = new Promise<void>(resolve => {
|
||||
finishCleanup = resolve;
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
|
||||
options.onStartupError?.(failure);
|
||||
cleanupEntered();
|
||||
await cleanup;
|
||||
throw failure;
|
||||
});
|
||||
const running = runAsync(runtimePlatform);
|
||||
running.catch(() => {});
|
||||
try {
|
||||
await entered;
|
||||
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
|
||||
} finally {
|
||||
finishCleanup();
|
||||
}
|
||||
await expect(running).rejects.toBe(failure);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps a teardown failure visible after Stop during preview setup', async () => {
|
||||
let previewEntered!: () => void;
|
||||
let finishPreview!: (value: {
|
||||
previewPageUrl: string;
|
||||
apiUrl: string;
|
||||
closeAsync: () => Promise<void>;
|
||||
}) => void;
|
||||
const entered = new Promise<void>(resolve => {
|
||||
previewEntered = resolve;
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
|
||||
openPreviewAsync: () => {
|
||||
previewEntered();
|
||||
return new Promise(resolve => {
|
||||
finishPreview = resolve;
|
||||
});
|
||||
},
|
||||
finishAsync: stopAsync,
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
});
|
||||
const failed = new Error('host teardown failed');
|
||||
stopAsync.mockRejectedValueOnce(failed);
|
||||
const running = runAsync(BuildRuntimePlatform.LINUX);
|
||||
running.catch(() => {});
|
||||
await entered;
|
||||
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
|
||||
watch.stop();
|
||||
finishPreview({
|
||||
previewPageUrl: 'https://preview.test',
|
||||
apiUrl: 'https://server.test',
|
||||
closeAsync: async () => {},
|
||||
});
|
||||
await expect(running).rejects.toBe(failed);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retires the startup watcher before lifetime waiting', async () => {
|
||||
await runAsync(BuildRuntimePlatform.LINUX);
|
||||
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
|
||||
expect(watch.completeAsync.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('reports the session token when serve-sim minted one', async () => {
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
@@ -275,7 +484,8 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
logger,
|
||||
timeoutMs: 60_000,
|
||||
preview: { baseDomain: 'tunnel.example.com', authtoken: 'ngrok-token' },
|
||||
signal: undefined,
|
||||
signal: expect.any(AbortSignal),
|
||||
onStartupError: expect.any(Function),
|
||||
...(selectsXcode
|
||||
? {
|
||||
iosSimulatorUdid: 'SELECTED-IOS-UDID',
|
||||
@@ -304,6 +514,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
previewApiUrl: 'https://web-preview.example.test',
|
||||
},
|
||||
logger,
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { collectAndroidEmulatorLogsAsync } from '../utils/androidEmulatorLogs';
|
||||
import { getDeviceRunSessionIdOrThrow } from '../utils/remoteDeviceRunSession';
|
||||
|
||||
export function createCollectAndroidEmulatorLogsBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'collect_android_emulator_logs',
|
||||
name: 'Collect Android emulator logs',
|
||||
__metricsId: 'eas/collect_android_emulator_logs',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.LINUX],
|
||||
fn: async ({ logger, global }, { env }) => {
|
||||
try {
|
||||
await collectAndroidEmulatorLogsAsync(ctx, {
|
||||
global,
|
||||
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
|
||||
logger,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not collect Android emulator logs', error);
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Could not collect Android logs; the session result is unchanged.'
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { type ServeSimApplicationOptions } from '../utils/remoteDeviceRunSession
|
||||
import { type StartupTasks } from '../utils/startupTasks';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
DeviceRunSessionStoppedError,
|
||||
finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
@@ -101,6 +102,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
corsOrigins,
|
||||
tasks,
|
||||
warm,
|
||||
onStarted,
|
||||
device,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
@@ -119,6 +121,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
tasks: StartupTasks;
|
||||
/** A claimed warm Simulator: adopt it and use its agent-device daemon. */
|
||||
warm?: WarmIosSession;
|
||||
onStarted?: () => Promise<void>;
|
||||
device:
|
||||
| {
|
||||
booted: Promise<unknown>;
|
||||
@@ -251,7 +254,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
|
||||
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
signal: tasks.signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
@@ -266,6 +269,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
if (sessionStart === 'stopped') {
|
||||
return;
|
||||
}
|
||||
await onStarted?.();
|
||||
tasks.signal.throwIfAborted();
|
||||
artifactUploads = pollAgentDeviceArtifactsForUploadAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
@@ -301,13 +305,23 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
tasks.signal.reason instanceof DeviceRunSessionStoppedError &&
|
||||
!(error instanceof AggregateError)
|
||||
) {
|
||||
throw tasks.signal.reason;
|
||||
}
|
||||
sessionFailed = true;
|
||||
throw error;
|
||||
} finally {
|
||||
// Promise.all rejects on the first failure while other tasks can still be starting.
|
||||
// They stop at their next abort check. Wait for all of them, so the teardown sees
|
||||
// everything that was started.
|
||||
await Promise.allSettled([agentDeviceStartup, sessionHostStartup, deviceReady]);
|
||||
const startupResults = await Promise.allSettled([
|
||||
agentDeviceStartup,
|
||||
sessionHostStartup,
|
||||
deviceReady,
|
||||
]);
|
||||
const startedDaemon = daemonProcess;
|
||||
const toolTeardown = (async () => {
|
||||
try {
|
||||
@@ -332,6 +346,11 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
logger,
|
||||
sessionFailed,
|
||||
teardown: [
|
||||
...startupResults.flatMap((result): [string, Promise<unknown>][] =>
|
||||
result.status === 'rejected' && result.reason instanceof AggregateError
|
||||
? [['startup cleanup', Promise.reject(result.reason)]]
|
||||
: []
|
||||
),
|
||||
['agent-device tunnel', agentDeviceTunnel?.stopAsync()],
|
||||
['agent-device daemon', toolTeardown],
|
||||
[
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
|
||||
import { AndroidEmulatorLogSourcesSchema } from '../../utils/androidEmulatorLogSources';
|
||||
import { getAndroidEmulatorLogCapture } from '../utils/androidEmulatorLogs';
|
||||
import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi';
|
||||
import { readIosApplicationIdentifierAsync } from '../utils/iosAppArtifact';
|
||||
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
|
||||
@@ -70,6 +73,12 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
name: 'Start agent-device session',
|
||||
__metricsId: warm ? 'eas/start_warm_agent_device_session' : 'eas/start_agent_device_session',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'android_log_sources',
|
||||
required: false,
|
||||
defaultValue: ['emulator-output'],
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
// Device. On iOS a Simulator name or UDID, on Android an AVD hardware profile.
|
||||
BuildStepInput.createProvider({
|
||||
id: 'device_identifier',
|
||||
@@ -187,6 +196,9 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
} else {
|
||||
validateAndroidLaunchOptions({ launchArgs, openUrl });
|
||||
}
|
||||
const logSources = isIos
|
||||
? []
|
||||
: AndroidEmulatorLogSourcesSchema.parse(inputs.android_log_sources.value);
|
||||
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
|
||||
const capture = parseNetworkCaptureInputs(
|
||||
{
|
||||
@@ -196,8 +208,18 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
{ runtimePlatform }
|
||||
);
|
||||
|
||||
const startup = new AbortController();
|
||||
const startupSignal = signal ? AbortSignal.any([signal, startup.signal]) : startup.signal;
|
||||
const startupWatch = watchDeviceRunSessionStartup({
|
||||
ctx,
|
||||
deviceRunSessionId: sessionEnv.deviceRunSessionId,
|
||||
logger,
|
||||
startup: { signal: startupSignal, abort: reason => startup.abort(reason) },
|
||||
});
|
||||
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
|
||||
const tasks = createStartupTasks(logger, signal);
|
||||
const tasks = createStartupTasks(logger, startupSignal);
|
||||
let booted: Promise<void> | undefined;
|
||||
let sessionOwnsTeardown = false;
|
||||
let downloaded: Promise<string> | undefined;
|
||||
let applicationReady: Promise<ServeSimApplicationOptions | void> | undefined;
|
||||
|
||||
@@ -227,7 +249,7 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
logger.info(`Selected iOS Simulator: ${iosSimulatorUdid}.`);
|
||||
}
|
||||
|
||||
const booted = iosSimulatorUdid
|
||||
booted = iosSimulatorUdid
|
||||
? Promise.resolve()
|
||||
: tasks.run('Android Emulator boot', async taskLogger => {
|
||||
const logcatDirectory = await fs.promises.mkdtemp(
|
||||
@@ -242,6 +264,8 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
lcdHeight: inputs.lcd_height.value as number | undefined,
|
||||
lcdDensity: inputs.lcd_density.value as number | undefined,
|
||||
logcatDirectory,
|
||||
logSources,
|
||||
logCapture: getAndroidEmulatorLogCapture(global, env),
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
@@ -295,7 +319,7 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
});
|
||||
} else if (!isIos && (installAppPath || downloadedApp)) {
|
||||
applicationReady = tasks.run('app install and launch', async taskLogger => {
|
||||
await tasks.untilAborted(booted);
|
||||
await tasks.untilAborted(booted!);
|
||||
const artifactPath = installAppPath ?? (await downloadedApp);
|
||||
if (!artifactPath) {
|
||||
throw new Error('No app artifact was prepared.');
|
||||
@@ -320,6 +344,7 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
});
|
||||
}
|
||||
|
||||
sessionOwnsTeardown = true;
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env,
|
||||
logger,
|
||||
@@ -337,6 +362,7 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
tasks,
|
||||
warm: warmSession,
|
||||
onStarted: () => startupWatch.completeAsync(),
|
||||
device: iosSimulatorUdid
|
||||
? {
|
||||
iosSimulatorUdid,
|
||||
@@ -348,26 +374,43 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
ready: applicationReady ?? tasks.untilAborted(booted),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
!sessionOwnsTeardown &&
|
||||
tasks.signal.reason === startupWatch.stopped &&
|
||||
!(error instanceof AggregateError)
|
||||
) {
|
||||
throw startupWatch.stopped;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
tasks.abort(new Error('Agent-device session ended.'));
|
||||
await Promise.allSettled([downloaded, applicationReady]);
|
||||
await Promise.allSettled([booted, downloaded, applicationReady]);
|
||||
}
|
||||
};
|
||||
// The warm path installs only a local app; build_id and application_archive_url start cold.
|
||||
if (warm && isIos && !download) {
|
||||
await runOnWarmIosSimulatorAsync(
|
||||
{
|
||||
engine: 'agent-device',
|
||||
requestedDevice: deviceIdentifier,
|
||||
packageVersion: inputs.package_version.value as string | undefined,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
},
|
||||
startSessionAsync
|
||||
);
|
||||
} else {
|
||||
await startSessionAsync();
|
||||
try {
|
||||
startupSignal.throwIfAborted();
|
||||
if (warm && isIos && !download) {
|
||||
await runOnWarmIosSimulatorAsync(
|
||||
{
|
||||
engine: 'agent-device',
|
||||
requestedDevice: deviceIdentifier,
|
||||
packageVersion: inputs.package_version.value as string | undefined,
|
||||
env,
|
||||
logger,
|
||||
signal: startupSignal,
|
||||
},
|
||||
startSessionAsync
|
||||
);
|
||||
} else {
|
||||
await startSessionAsync();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== startupWatch.stopped) {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await startupWatch.finishAsync();
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -20,8 +20,15 @@ import {
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { type AndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogCapture';
|
||||
import { retryAsync } from '../../utils/retry';
|
||||
import { resolveAndroidEmulatorLocalEgressAsync } from '../utils/androidLocalEgress';
|
||||
import {
|
||||
ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
type AndroidEmulatorLogSource,
|
||||
AndroidEmulatorLogSourcesSchema,
|
||||
} from '../../utils/androidEmulatorLogSources';
|
||||
import { getAndroidEmulatorLogCapture } from '../utils/androidEmulatorLogs';
|
||||
|
||||
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
|
||||
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
|
||||
@@ -33,6 +40,12 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
name: 'Start Android Emulator',
|
||||
__metricsId: 'eas/start_android_emulator',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'android_log_sources',
|
||||
required: false,
|
||||
defaultValue: [...ALL_ANDROID_EMULATOR_LOG_SOURCES],
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'device_name',
|
||||
required: false,
|
||||
@@ -78,10 +91,12 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
required: true,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { inputs, outputs, env }) => {
|
||||
fn: async ({ logger, global }, { inputs, outputs, env }) => {
|
||||
const logSources = AndroidEmulatorLogSourcesSchema.parse(inputs.android_log_sources.value);
|
||||
const logcatDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
|
||||
);
|
||||
const logCapture = getAndroidEmulatorLogCapture(global, env);
|
||||
outputs.logcat_directory.set(logcatDirectory);
|
||||
|
||||
const deviceName = `${inputs.device_name.value}` as AndroidVirtualDeviceName;
|
||||
@@ -95,6 +110,8 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
lcdHeight: inputs.lcd_height.value as number | undefined,
|
||||
lcdDensity: inputs.lcd_density.value as number | undefined,
|
||||
logcatDirectory,
|
||||
logSources,
|
||||
logCapture,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
@@ -133,6 +150,8 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
deviceName: cloneIdentifier,
|
||||
env,
|
||||
logcatDirectory,
|
||||
logSources,
|
||||
logCapture,
|
||||
});
|
||||
cloneSerialId = startResult.serialId;
|
||||
|
||||
@@ -202,6 +221,8 @@ export async function startAndroidEmulatorAsync({
|
||||
lcdHeight,
|
||||
lcdDensity,
|
||||
logcatDirectory,
|
||||
logSources,
|
||||
logCapture,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
@@ -212,6 +233,8 @@ export async function startAndroidEmulatorAsync({
|
||||
lcdHeight?: number;
|
||||
lcdDensity?: number;
|
||||
logcatDirectory: string;
|
||||
logSources: readonly AndroidEmulatorLogSource[];
|
||||
logCapture: AndroidEmulatorLogCapture;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<{
|
||||
@@ -293,6 +316,8 @@ export async function startAndroidEmulatorAsync({
|
||||
deviceName,
|
||||
env,
|
||||
logcatDirectory,
|
||||
logSources,
|
||||
logCapture,
|
||||
...(localEgress ? { launchGate: localEgress.launchGate } : {}),
|
||||
});
|
||||
attemptSerialId = startResult.serialId;
|
||||
|
||||
@@ -45,7 +45,9 @@ import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
|
||||
import { preparePreinstalledWdaAsync } from '../utils/preinstalledWda';
|
||||
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
|
||||
import {
|
||||
DeviceRunSessionStoppedError,
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createIosSessionStartupInputProviders,
|
||||
@@ -114,11 +116,20 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
const startupStop = new AbortController();
|
||||
const watchSignal = signal
|
||||
? AbortSignal.any([signal, startupStop.signal])
|
||||
: startupStop.signal;
|
||||
const startupWatch = watchDeviceRunSessionStartup({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
|
||||
});
|
||||
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
|
||||
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
|
||||
const startupAbortController = new AbortController();
|
||||
const startupSignal = AbortSignal.any([watchSignal, startupAbortController.signal]);
|
||||
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
|
||||
@@ -129,7 +140,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
openUrl: inputs.open_url?.value,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
signal: startupSignal,
|
||||
});
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
@@ -151,11 +162,6 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
udid: iosStartup.iosSimulatorUdid,
|
||||
}
|
||||
: await resolveAppiumDeviceAsync({ runtimePlatform, env });
|
||||
const startupAbortController = new AbortController();
|
||||
const startupSignal = signal
|
||||
? AbortSignal.any([signal, startupAbortController.signal])
|
||||
: startupAbortController.signal;
|
||||
const setupSignal = iosStartup ? startupSignal : undefined;
|
||||
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let appiumProcess: ReturnType<typeof spawnDetached> | undefined;
|
||||
@@ -183,16 +189,14 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
signal: startupSignal,
|
||||
onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined,
|
||||
onStartupError: error => startupAbortController.abort(error),
|
||||
...launch,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
},
|
||||
...(warmSession ? [createWarmDeviceSessionServer(ctx, logger, warmSession)] : [])
|
||||
).catch(error => {
|
||||
if (iosStartup) {
|
||||
startupAbortController.abort(error);
|
||||
}
|
||||
startupAbortController.abort(error);
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
@@ -210,22 +214,25 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
driverName: device.driverName,
|
||||
env,
|
||||
logger,
|
||||
signal: setupSignal,
|
||||
onError: iosStartup ? error => startupAbortController.abort(error) : undefined,
|
||||
signal: startupSignal,
|
||||
onError: error => startupAbortController.abort(error),
|
||||
}).catch(error => {
|
||||
if (iosStartup) {
|
||||
startupAbortController.abort(error);
|
||||
}
|
||||
startupAbortController.abort(error);
|
||||
throw error;
|
||||
})
|
||||
),
|
||||
asyncResult(
|
||||
ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger, signal: setupSignal })
|
||||
ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
signal: startupSignal,
|
||||
})
|
||||
),
|
||||
]);
|
||||
const installation = appiumInstall.enforceValue();
|
||||
appiumHome = installation.appiumHome;
|
||||
setupSignal?.throwIfAborted();
|
||||
startupSignal?.throwIfAborted();
|
||||
const { appiumBinPath, appiumEnv } = installation;
|
||||
const wdaCapabilities = await preparePreinstalledWdaAsync({
|
||||
runtimeIdentifier: iosStartup
|
||||
@@ -240,10 +247,10 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
appiumBinPath,
|
||||
appiumEnv,
|
||||
logger,
|
||||
signal: setupSignal,
|
||||
signal: startupSignal,
|
||||
});
|
||||
|
||||
setupSignal?.throwIfAborted();
|
||||
startupSignal?.throwIfAborted();
|
||||
const useFdaHelper =
|
||||
runtimePlatform === BuildRuntimePlatform.DARWIN && fs.existsSync(FDA_HELPER);
|
||||
appiumProcess = spawnDetached({
|
||||
@@ -275,11 +282,11 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
});
|
||||
await waitForAppiumReadyAsync({ appiumProcess, logger, signal: setupSignal });
|
||||
await waitForAppiumReadyAsync({ appiumProcess, logger, signal: startupSignal });
|
||||
if (hostStartup) {
|
||||
sessionHost = (await hostStartup).enforceValue();
|
||||
}
|
||||
setupSignal?.throwIfAborted();
|
||||
startupSignal?.throwIfAborted();
|
||||
eventCollection = await startAppiumEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
@@ -301,11 +308,12 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
|
||||
startupSignal.throwIfAborted();
|
||||
const webPreview = await sessionHost.openPreviewAsync();
|
||||
|
||||
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
signal: startupSignal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
@@ -325,6 +333,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
return;
|
||||
}
|
||||
|
||||
await startupWatch.completeAsync();
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
@@ -340,8 +349,20 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error === startupSignal.reason &&
|
||||
startupSignal.reason instanceof DeviceRunSessionStoppedError
|
||||
) {
|
||||
throw startupSignal.reason;
|
||||
}
|
||||
sessionFailed = true;
|
||||
sessionError = setupSignal?.aborted ? setupSignal.reason : error;
|
||||
sessionError =
|
||||
error instanceof AggregateError
|
||||
? error
|
||||
: startupSignal.aborted &&
|
||||
!(startupSignal.reason instanceof DeviceRunSessionStoppedError)
|
||||
? startupSignal.reason
|
||||
: error;
|
||||
throw sessionError;
|
||||
} finally {
|
||||
startupAbortController.abort(new Error('Appium session ended.'));
|
||||
@@ -374,6 +395,9 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
if (warmSession) {
|
||||
await Promise.allSettled([appiumTeardown]);
|
||||
}
|
||||
if (result.reason instanceof AggregateError) {
|
||||
throw result.reason;
|
||||
}
|
||||
await result.value?.finishAsync();
|
||||
}),
|
||||
],
|
||||
@@ -387,14 +411,32 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
}
|
||||
}
|
||||
};
|
||||
if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await runOnWarmIosSimulatorAsync(
|
||||
// package_version is the Appium version, which the job installs either way.
|
||||
{ engine: 'appium', requestedDevice: deviceIdentifier, env, logger, signal },
|
||||
startSessionAsync
|
||||
);
|
||||
} else {
|
||||
await startSessionAsync();
|
||||
try {
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
watchSignal.throwIfAborted();
|
||||
if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await runOnWarmIosSimulatorAsync(
|
||||
// package_version is the Appium version, which the job installs either way.
|
||||
{
|
||||
engine: 'appium',
|
||||
requestedDevice: deviceIdentifier,
|
||||
env,
|
||||
logger,
|
||||
signal: watchSignal,
|
||||
},
|
||||
startSessionAsync
|
||||
);
|
||||
} else {
|
||||
await startSessionAsync();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== startupWatch.stopped) {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await startupWatch.finishAsync();
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -570,11 +612,11 @@ async function waitForAppiumReadyAsync({
|
||||
}: {
|
||||
appiumProcess: { getOutput: () => string };
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
signal: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const deadline = Date.now() + APPIUM_STARTUP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
signal?.throwIfAborted();
|
||||
signal.throwIfAborted();
|
||||
try {
|
||||
const response = await turtleFetch(`http://${APPIUM_HOST}:${APPIUM_PORT}/status`, 'GET', {
|
||||
timeout: 2_000,
|
||||
@@ -582,16 +624,17 @@ async function waitForAppiumReadyAsync({
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
signal.throwIfAborted();
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
signal?.throwIfAborted();
|
||||
if (signal) {
|
||||
signal.throwIfAborted();
|
||||
try {
|
||||
await setTimeoutAsync(1_000, undefined, { signal });
|
||||
} else {
|
||||
await sleepAsync(1_000);
|
||||
} catch (error) {
|
||||
signal.throwIfAborted();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const output = appiumProcess.getOutput();
|
||||
|
||||
@@ -41,7 +41,9 @@ import { isProcessDescendantOfAsync, killProcessGroup } from '../../utils/proces
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
|
||||
import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents';
|
||||
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
|
||||
import {
|
||||
DeviceRunSessionStoppedError,
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createIosSessionStartupInputProviders,
|
||||
@@ -143,11 +145,20 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
});
|
||||
const versionSpec = packageVersion ?? 'latest';
|
||||
const { runtimePlatform } = global;
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
const startupStop = new AbortController();
|
||||
const watchSignal = signal
|
||||
? AbortSignal.any([signal, startupStop.signal])
|
||||
: startupStop.signal;
|
||||
const startupWatch = watchDeviceRunSessionStartup({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
|
||||
});
|
||||
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
|
||||
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
|
||||
const startupAbortController = new AbortController();
|
||||
const startupSignal = AbortSignal.any([watchSignal, startupAbortController.signal]);
|
||||
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
|
||||
@@ -158,7 +169,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
openUrl: inputs.open_url?.value,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
signal: startupSignal,
|
||||
});
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
@@ -182,11 +193,6 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
const argentExec = (args: string[]): { command: string; args: string[] } =>
|
||||
resolvePackageExec(packageManager, args);
|
||||
|
||||
const startupAbortController = new AbortController();
|
||||
const startupSignal = signal
|
||||
? AbortSignal.any([signal, startupAbortController.signal])
|
||||
: startupAbortController.signal;
|
||||
const setupSignal = iosStartup ? startupSignal : undefined;
|
||||
const artifactPollAbortController = new AbortController();
|
||||
let artifactPollingPromise: Promise<void> | undefined;
|
||||
let eventCollection:
|
||||
@@ -213,16 +219,14 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken },
|
||||
signal: startupSignal,
|
||||
onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined,
|
||||
onStartupError: error => startupAbortController.abort(error),
|
||||
...launch,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
},
|
||||
...(warmSession ? [createWarmDeviceSessionServer(ctx, logger, warmSession)] : [])
|
||||
).catch(error => {
|
||||
if (iosStartup) {
|
||||
startupAbortController.abort(error);
|
||||
}
|
||||
startupAbortController.abort(error);
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
@@ -251,7 +255,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
enableArtifacts.command,
|
||||
enableArtifacts.args,
|
||||
{ env, logger },
|
||||
setupSignal
|
||||
startupSignal
|
||||
);
|
||||
|
||||
logger.info('Enabling the Argent tool-server event log flag.');
|
||||
@@ -264,7 +268,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
enableEventLog.command,
|
||||
enableEventLog.args,
|
||||
{ env, logger },
|
||||
setupSignal
|
||||
startupSignal
|
||||
);
|
||||
|
||||
logger.info('Disabling the Argent video watermark flag.');
|
||||
@@ -279,17 +283,17 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
disableVideoWatermark.command,
|
||||
disableVideoWatermark.args,
|
||||
{ env, logger },
|
||||
setupSignal
|
||||
startupSignal
|
||||
);
|
||||
} catch (err) {
|
||||
setupSignal?.throwIfAborted();
|
||||
startupSignal?.throwIfAborted();
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.'
|
||||
);
|
||||
}
|
||||
|
||||
setupSignal?.throwIfAborted();
|
||||
startupSignal?.throwIfAborted();
|
||||
const startServer = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
@@ -330,7 +334,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
ancestorPid: argentServer.pid,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
getExitError: argentServer.getExitError,
|
||||
signal: setupSignal,
|
||||
signal: startupSignal,
|
||||
});
|
||||
toolServerPort = toolServerState.port;
|
||||
toolServerToken = toolServerState.token;
|
||||
@@ -350,7 +354,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
if (hostStartup) {
|
||||
sessionHost = (await hostStartup).enforceValue();
|
||||
}
|
||||
setupSignal?.throwIfAborted();
|
||||
startupSignal?.throwIfAborted();
|
||||
}
|
||||
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
|
||||
const artifactPollSignal = signal
|
||||
@@ -388,6 +392,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
|
||||
startupSignal.throwIfAborted();
|
||||
const webPreview = await sessionHost.openPreviewAsync();
|
||||
logger.info(
|
||||
webPreview.sharePageUrl
|
||||
@@ -397,7 +402,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
|
||||
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
signal: startupSignal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
@@ -413,6 +418,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
return;
|
||||
}
|
||||
|
||||
await startupWatch.completeAsync();
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
@@ -429,8 +435,19 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
startupSignal.reason instanceof DeviceRunSessionStoppedError &&
|
||||
!(error instanceof AggregateError)
|
||||
) {
|
||||
throw startupSignal.reason;
|
||||
}
|
||||
sessionFailed = true;
|
||||
throw setupSignal?.aborted ? setupSignal.reason : error;
|
||||
throw error instanceof AggregateError
|
||||
? error
|
||||
: startupSignal.aborted &&
|
||||
!(startupSignal.reason instanceof DeviceRunSessionStoppedError)
|
||||
? startupSignal.reason
|
||||
: error;
|
||||
} finally {
|
||||
startupAbortController.abort(new Error('Argent session ended.'));
|
||||
const toolTeardown = (async () => {
|
||||
@@ -470,6 +487,9 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
// The warm host owns Argent too; leave it alive until the final downloads finish.
|
||||
await Promise.allSettled([toolTeardown]);
|
||||
}
|
||||
if (result.reason instanceof AggregateError) {
|
||||
throw result.reason;
|
||||
}
|
||||
await result.value?.finishAsync();
|
||||
}),
|
||||
],
|
||||
@@ -477,20 +497,32 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
});
|
||||
}
|
||||
};
|
||||
if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await runOnWarmIosSimulatorAsync(
|
||||
{
|
||||
engine: 'argent',
|
||||
requestedDevice: deviceIdentifier,
|
||||
packageVersion,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
},
|
||||
startSessionAsync
|
||||
);
|
||||
} else {
|
||||
await startSessionAsync();
|
||||
try {
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
watchSignal.throwIfAborted();
|
||||
if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await runOnWarmIosSimulatorAsync(
|
||||
{
|
||||
engine: 'argent',
|
||||
requestedDevice: deviceIdentifier,
|
||||
packageVersion,
|
||||
env,
|
||||
logger,
|
||||
signal: watchSignal,
|
||||
},
|
||||
startSessionAsync
|
||||
);
|
||||
} else {
|
||||
await startSessionAsync();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== startupWatch.stopped) {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await startupWatch.finishAsync();
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@expo/steps';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
|
||||
import { startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import {
|
||||
createWarmDeviceSessionServer,
|
||||
@@ -96,11 +97,20 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
'Web preview idle timeouts require iOS preview activity reporting. Omit max_idle_time_minutes for Android sessions.'
|
||||
);
|
||||
}
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
const startupStop = new AbortController();
|
||||
const watchSignal = signal
|
||||
? AbortSignal.any([signal, startupStop.signal])
|
||||
: startupStop.signal;
|
||||
const startupWatch = watchDeviceRunSessionStartup({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
|
||||
});
|
||||
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
|
||||
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
|
||||
const startupAbortController = new AbortController();
|
||||
const startupSignal = AbortSignal.any([watchSignal, startupAbortController.signal]);
|
||||
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
|
||||
@@ -111,7 +121,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
openUrl: inputs.open_url?.value,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
signal: startupSignal,
|
||||
});
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
@@ -136,7 +146,8 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken },
|
||||
signal,
|
||||
signal: startupSignal,
|
||||
onStartupError: error => startupAbortController.abort(error),
|
||||
packageVersion,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
@@ -149,9 +160,15 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
networkCaptureFields,
|
||||
},
|
||||
...(warmSession ? [createWarmDeviceSessionServer(ctx, logger, warmSession)] : [])
|
||||
);
|
||||
).catch(error => {
|
||||
if (startupSignal.reason === startupWatch.stopped && !(error instanceof AggregateError)) {
|
||||
throw startupWatch.stopped;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
try {
|
||||
startupSignal.throwIfAborted();
|
||||
const webPreview = await sessionHost.openPreviewAsync();
|
||||
logger.info(
|
||||
webPreview.sharePageUrl
|
||||
@@ -160,7 +177,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
);
|
||||
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
signal: startupSignal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
// WEB_PREVIEW_ONLY sessions read webPreviewUrl and webPreviewToken. Legacy SERVE_SIM
|
||||
@@ -183,6 +200,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
return;
|
||||
}
|
||||
|
||||
await startupWatch.completeAsync();
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
@@ -202,20 +220,32 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
await sessionHost.finishAsync();
|
||||
}
|
||||
};
|
||||
if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await runOnWarmIosSimulatorAsync(
|
||||
{
|
||||
engine: 'serve-sim',
|
||||
requestedDevice: deviceIdentifier,
|
||||
packageVersion,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
},
|
||||
startSessionAsync
|
||||
);
|
||||
} else {
|
||||
await startSessionAsync();
|
||||
try {
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
watchSignal.throwIfAborted();
|
||||
if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await runOnWarmIosSimulatorAsync(
|
||||
{
|
||||
engine: 'serve-sim',
|
||||
requestedDevice: deviceIdentifier,
|
||||
packageVersion,
|
||||
env,
|
||||
logger,
|
||||
signal: watchSignal,
|
||||
},
|
||||
startSessionAsync
|
||||
);
|
||||
} else {
|
||||
await startSessionAsync();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== startupWatch.stopped) {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await startupWatch.finishAsync();
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { type BuildStepGlobalContext } from '@expo/steps';
|
||||
import { once } from 'node:events';
|
||||
import fsPromises, {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { PassThrough, Readable } from 'node:stream';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { MAX_ANDROID_LOG_BYTES_PER_SOURCE } from '../../../utils/androidEmulatorLogCapture';
|
||||
import { type AndroidEmulatorLogSource } from '../../../utils/androidEmulatorLogSources';
|
||||
import {
|
||||
collectAndroidEmulatorLogsAsync,
|
||||
getAndroidEmulatorLogCapture,
|
||||
} from '../androidEmulatorLogs';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
|
||||
jest.mock('../deviceRunSessionArtifacts');
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const upload = jest.mocked(uploadDeviceRunSessionArtifactAsync);
|
||||
let directory: string;
|
||||
let emulatorPath: string;
|
||||
let logcatPath: string;
|
||||
let global: BuildStepGlobalContext;
|
||||
let logger: ReturnType<typeof createMockLogger>;
|
||||
let uploadedContents: Map<string, string>;
|
||||
|
||||
beforeEach(async () => {
|
||||
global = {} as BuildStepGlobalContext;
|
||||
logger = createMockLogger();
|
||||
uploadedContents = new Map();
|
||||
directory = await mkdtemp(path.join(os.tmpdir(), 'android-log-collection-'));
|
||||
emulatorPath = path.join(directory, 'device-attempt-1-emulator.log');
|
||||
logcatPath = path.join(directory, 'device-attempt-1-logcat.log');
|
||||
upload.mockReset().mockImplementation(async (_ctx, { filename, openStream }) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of openStream()) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
uploadedContents.set(filename, Buffer.concat(chunks).toString());
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
jest.restoreAllMocks();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function captureLog(
|
||||
filePath: string,
|
||||
source: AndroidEmulatorLogSource,
|
||||
text: string,
|
||||
workflow = global
|
||||
): Promise<void> {
|
||||
const capture = getAndroidEmulatorLogCapture(workflow, { DEVICE_RUN_SESSION_ID: 'session' });
|
||||
const input = Readable.from([Buffer.from(text)]);
|
||||
capture.attach(input, filePath, source);
|
||||
await once(input, 'end');
|
||||
await capture.finishFileAsync(filePath);
|
||||
}
|
||||
|
||||
function collect(): Promise<void> {
|
||||
return collectAndroidEmulatorLogsAsync(ctx, {
|
||||
global,
|
||||
deviceRunSessionId: 'session',
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ env: {}, expectedBytes: 20 * 1024 * 1024 + 1 },
|
||||
{ env: { DEVICE_RUN_SESSION_ID: 'session' }, expectedBytes: 20 * 1024 * 1024 },
|
||||
])('retains $expectedBytes with workflow environment $env', async ({ env, expectedBytes }) => {
|
||||
const capture = getAndroidEmulatorLogCapture(global, env);
|
||||
const input = Readable.from([Buffer.alloc(20 * 1024 * 1024 + 1, 'x')]);
|
||||
capture.attach(input, emulatorPath, 'emulator-output');
|
||||
await once(input, 'end');
|
||||
await capture.finishAsync();
|
||||
expect((await fsPromises.stat(emulatorPath)).size).toBe(expectedBytes);
|
||||
});
|
||||
|
||||
it('uses captured source records and ignores files outside the capture inventory', async () => {
|
||||
const first = path.join(directory, 'first.txt');
|
||||
const second = path.join(directory, 'second.txt');
|
||||
await captureLog(first, 'emulator-output', 'host output');
|
||||
await captureLog(second, 'device-logcat', 'Android output');
|
||||
await captureLog(path.join(directory, 'retry.txt'), 'emulator-output', 'retry output');
|
||||
await writeFile(emulatorPath, 'not captured');
|
||||
const elsewhere = path.join(directory, 'elsewhere');
|
||||
await mkdir(elsewhere);
|
||||
await writeFile(path.join(elsewhere, 'other-emulator.log'), 'another job');
|
||||
await symlink(path.join(elsewhere, 'other-emulator.log'), logcatPath);
|
||||
await collect();
|
||||
|
||||
expect([...uploadedContents.values()]).toEqual(['host output', 'Android output', 'retry output']);
|
||||
expect(upload).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
filename: 'first.txt',
|
||||
kind: 'simulator-log',
|
||||
metadata: expect.objectContaining({
|
||||
platform: 'android',
|
||||
source: 'emulator-output',
|
||||
scope: 'emulator-process',
|
||||
originalSizeBytes: 11,
|
||||
omittedBytes: 0,
|
||||
truncated: false,
|
||||
}),
|
||||
signal: expect.any(AbortSignal),
|
||||
attempts: 2,
|
||||
})
|
||||
);
|
||||
expect(upload).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
filename: 'second.txt',
|
||||
metadata: expect.objectContaining({ source: 'device-logcat', scope: 'device' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('seals a live producer before upload and keeps retry bodies identical without copying files', async () => {
|
||||
const input = new PassThrough();
|
||||
const capture = getAndroidEmulatorLogCapture(global, { DEVICE_RUN_SESSION_ID: 'session' });
|
||||
capture.attach(input, emulatorPath, 'emulator-output');
|
||||
input.write('before');
|
||||
const contents: string[] = [];
|
||||
upload.mockImplementationOnce(async (_ctx, { size, openStream, metadata }) => {
|
||||
expect(size).toBe(6);
|
||||
expect(metadata).toEqual(expect.objectContaining({ originalSizeBytes: 6, truncated: false }));
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
input.write(' ignored after finalization');
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of openStream()) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
contents.push(Buffer.concat(chunks).toString());
|
||||
}
|
||||
});
|
||||
try {
|
||||
await collect();
|
||||
expect(contents).toEqual(['before', 'before']);
|
||||
expect(await readFile(emulatorPath, 'utf8')).toBe('before');
|
||||
expect(await readdir(directory)).toEqual([path.basename(emulatorPath)]);
|
||||
expect(input.readableFlowing).toBe(true);
|
||||
const late = new PassThrough();
|
||||
capture.attach(late, logcatPath, 'device-logcat');
|
||||
late.end('not captured');
|
||||
expect(late.readableFlowing).toBe(true);
|
||||
await expect(fsPromises.stat(logcatPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
} finally {
|
||||
input.end();
|
||||
}
|
||||
});
|
||||
|
||||
it('continues with the other log after a failed upload and never repeats the collection', async () => {
|
||||
await captureLog(emulatorPath, 'emulator-output', 'host output');
|
||||
await captureLog(logcatPath, 'device-logcat', 'Android output');
|
||||
upload.mockRejectedValueOnce(new Error('storage unavailable'));
|
||||
await Promise.all([collect(), collect()]);
|
||||
await collect();
|
||||
expect(upload).toHaveBeenCalledTimes(2);
|
||||
expect(uploadedContents.get(path.basename(logcatPath))).toBe('Android output');
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.stringContaining('other logs will continue')
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a fresh 30 second budget for the next file after an upload times out', async () => {
|
||||
await captureLog(emulatorPath, 'emulator-output', 'host output');
|
||||
await captureLog(logcatPath, 'device-logcat', 'Android output');
|
||||
const controllers: AbortController[] = [];
|
||||
const timeout = jest.spyOn(AbortSignal, 'timeout').mockImplementation(() => {
|
||||
const controller = new AbortController();
|
||||
controllers.push(controller);
|
||||
return controller.signal;
|
||||
});
|
||||
upload.mockImplementationOnce(async (_ctx, { signal }) => {
|
||||
controllers[0].abort(new Error('upload timed out'));
|
||||
signal?.throwIfAborted();
|
||||
});
|
||||
await expect(collect()).resolves.toBeUndefined();
|
||||
expect(uploadedContents.get(path.basename(logcatPath))).toBe('Android output');
|
||||
expect(timeout.mock.calls).toEqual([[30_000], [30_000]]);
|
||||
expect(upload.mock.calls[1][1].signal?.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it('does not collect another workflow and warns when a captured file disappears', async () => {
|
||||
await captureLog(emulatorPath, 'emulator-output', 'other workflow', {} as BuildStepGlobalContext);
|
||||
await collect();
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
await captureLog(path.join(directory, 'missing.log'), 'emulator-output', 'removed');
|
||||
await rm(path.join(directory, 'missing.log'));
|
||||
await expect(collect()).resolves.toBeUndefined();
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.stringContaining('missing.log')
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads the tail of a truncated log with the retained size and omission metadata', async () => {
|
||||
const capture = getAndroidEmulatorLogCapture(global, { DEVICE_RUN_SESSION_ID: 'session' });
|
||||
const input = Readable.from([
|
||||
Buffer.concat([Buffer.from('head: '), Buffer.alloc(MAX_ANDROID_LOG_BYTES_PER_SOURCE, 'b')]),
|
||||
]);
|
||||
capture.attach(input, emulatorPath, 'emulator-output');
|
||||
await once(input, 'end');
|
||||
await collect();
|
||||
expect(upload).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
size: MAX_ANDROID_LOG_BYTES_PER_SOURCE,
|
||||
name: expect.stringContaining('truncated, last 20 MiB retained'),
|
||||
metadata: expect.objectContaining({
|
||||
originalSizeBytes: MAX_ANDROID_LOG_BYTES_PER_SOURCE + 6,
|
||||
omittedBytes: 6,
|
||||
truncated: true,
|
||||
}),
|
||||
})
|
||||
);
|
||||
const uploaded = uploadedContents.get(path.basename(emulatorPath));
|
||||
expect(uploaded).toHaveLength(MAX_ANDROID_LOG_BYTES_PER_SOURCE);
|
||||
expect(uploaded).not.toContain('head');
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { watchDeviceRunSessionStartup } from '../deviceRunSessionStartup';
|
||||
import {
|
||||
DeviceRunSessionStoppedError,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../remoteDeviceRunSession';
|
||||
import { createStartupTasks } from '../startupTasks';
|
||||
|
||||
jest.mock('../remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../remoteDeviceRunSession'),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
child: jest.fn().mockReturnThis(),
|
||||
} as unknown as bunyan;
|
||||
|
||||
/** Makes the status poll settle only when the test says so, or end when its signal aborts. */
|
||||
function holdStatusPoll(): { stop: () => void; fail: (error: Error) => void } {
|
||||
let stop!: () => void;
|
||||
let fail!: (error: Error) => void;
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(
|
||||
({ signal }) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
stop = resolve;
|
||||
fail = reject;
|
||||
signal?.addEventListener('abort', () => resolve(), { once: true });
|
||||
})
|
||||
);
|
||||
return { stop: () => stop(), fail: error => fail(error) };
|
||||
}
|
||||
|
||||
function watch(startup = createStartupTasks(logger)) {
|
||||
return {
|
||||
startup,
|
||||
watch: watchDeviceRunSessionStartup({ ctx, deviceRunSessionId: 'drs-id', logger, startup }),
|
||||
};
|
||||
}
|
||||
|
||||
describe(watchDeviceRunSessionStartup, () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('aborts startup when the session is stopped', async () => {
|
||||
const poll = holdStatusPoll();
|
||||
const { startup, watch: startupWatch } = watch();
|
||||
poll.stop();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(startupWatch.stopped).toBeInstanceOf(DeviceRunSessionStoppedError);
|
||||
expect(startup.signal.reason).toBe(startupWatch.stopped);
|
||||
expect(startupWatch.isStopped()).toBe(true);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Device run session drs-id was stopped during startup.'
|
||||
);
|
||||
await expect(startupWatch.completeAsync()).rejects.toBe(startupWatch.stopped);
|
||||
});
|
||||
|
||||
it('ends the poll when startup completes, without aborting startup', async () => {
|
||||
holdStatusPoll();
|
||||
const { startup, watch: startupWatch } = watch();
|
||||
await expect(startupWatch.completeAsync()).resolves.toBeUndefined();
|
||||
const [{ signal }] = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0];
|
||||
expect(signal?.aborted).toBe(true);
|
||||
expect(startup.signal.aborted).toBe(false);
|
||||
expect(startupWatch.isStopped()).toBe(false);
|
||||
});
|
||||
|
||||
it('aborts startup with a status poll failure', async () => {
|
||||
const poll = holdStatusPoll();
|
||||
const { startup, watch: startupWatch } = watch();
|
||||
const failed = new Error('status poll failed');
|
||||
poll.fail(failed);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(startup.signal.reason).toBe(failed);
|
||||
expect(startupWatch.isStopped()).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the first abort reason when startup ends for another reason', async () => {
|
||||
holdStatusPoll();
|
||||
const { startup, watch: startupWatch } = watch();
|
||||
const failed = new Error('host startup failed');
|
||||
startup.abort(failed);
|
||||
await startupWatch.finishAsync();
|
||||
expect(startup.signal.reason).toBe(failed);
|
||||
expect(startupWatch.isStopped()).toBe(false);
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('follows the step signal', async () => {
|
||||
holdStatusPoll();
|
||||
const step = new AbortController();
|
||||
const { startup, watch: startupWatch } = watch(createStartupTasks(logger, step.signal));
|
||||
step.abort(new Error('step cancelled'));
|
||||
await startupWatch.finishAsync();
|
||||
expect(startup.signal.aborted).toBe(true);
|
||||
expect(startupWatch.isStopped()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import { findUnlistedDeviceScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
import { uploadDeviceRunSessionFileAsync } from '../uploadDeviceRunSessionFile';
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
jest.unmock('node:fs');
|
||||
@@ -123,6 +124,41 @@ it('replays the full file after a transient PUT failure without recreating the u
|
||||
expect(streams.every(stream => stream.destroyed)).toBe(true);
|
||||
});
|
||||
|
||||
it('retries a PUT that fails before reading the body without aborting the file stream', async () => {
|
||||
let puts = 0;
|
||||
let replayed = 0;
|
||||
handle = (request, response) => {
|
||||
puts++;
|
||||
if (puts === 1) {
|
||||
response.statusCode = 503;
|
||||
response.flushHeaders();
|
||||
return;
|
||||
}
|
||||
request.on('data', chunk => {
|
||||
replayed += chunk.length;
|
||||
});
|
||||
request.on('end', () => response.end());
|
||||
};
|
||||
const file = path.join(directory, 'emulator.log');
|
||||
const payload = Buffer.alloc(8 * 1024 * 1024, '*');
|
||||
await writeFile(file, payload);
|
||||
await expect(
|
||||
uploadDeviceRunSessionFileAsync(ctx, {
|
||||
filePath: file,
|
||||
describe: () => ({
|
||||
deviceRunSessionId: 'drs-id',
|
||||
artifactId: 'log-id',
|
||||
name: 'Emulator log',
|
||||
filename: 'emulator.log',
|
||||
kind: 'simulator-log',
|
||||
}),
|
||||
attempts: 2,
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
expect(puts).toBe(2);
|
||||
expect(replayed).toBe(payload.length);
|
||||
});
|
||||
|
||||
it('keeps a slow PUT alive while its body is still flowing', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
|
||||
let received = 0;
|
||||
|
||||
@@ -155,6 +155,53 @@ describe('serve-sim Simulator preparation', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it('preserves the Stop reason when Simulator shutdown rejects after cancellation', async () => {
|
||||
const controller = new AbortController();
|
||||
const stopped = new Error('Session stopped');
|
||||
const wrapped = new Error('Shutdown process aborted', { cause: stopped });
|
||||
let shutdownEntered!: () => void;
|
||||
const entered = new Promise<void>(resolve => {
|
||||
shutdownEntered = resolve;
|
||||
});
|
||||
jest
|
||||
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
|
||||
.mockResolvedValue({ SERVE_SIM_ADDITIONAL_DYLIBS: '/guard.dylib' });
|
||||
mockedUtils.getDeviceAsync.mockResolvedValue({ state: 'Booted' } as never);
|
||||
mockedSpawn.mockImplementationOnce((_command, _args, options) => {
|
||||
shutdownEntered();
|
||||
return new Promise((_resolve, reject) =>
|
||||
options!.signal!.addEventListener('abort', () => reject(wrapped), { once: true })
|
||||
) as ReturnType<typeof spawn>;
|
||||
});
|
||||
const running = resolveIosSessionStartupAsync({
|
||||
...hostOptions,
|
||||
bootSimulator: true,
|
||||
signal: controller.signal,
|
||||
});
|
||||
running.catch(() => {});
|
||||
await entered;
|
||||
controller.abort(stopped);
|
||||
await expect(running).rejects.toBe(stopped);
|
||||
expect(startLocalEgressGuardRelayAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves a genuine Simulator shutdown failure', async () => {
|
||||
const failure = new Error('Could not shut down Simulator');
|
||||
jest
|
||||
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
|
||||
.mockResolvedValue({ SERVE_SIM_ADDITIONAL_DYLIBS: '/guard.dylib' });
|
||||
mockedUtils.getDeviceAsync.mockResolvedValue({ state: 'Booted' } as never);
|
||||
mockedSpawn.mockRejectedValueOnce(failure);
|
||||
await expect(
|
||||
resolveIosSessionStartupAsync({
|
||||
...hostOptions,
|
||||
bootSimulator: true,
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
).rejects.toBe(failure);
|
||||
expect(startLocalEgressGuardRelayAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves an explicit Simulator without booting or configuring it', async () => {
|
||||
const startup = await resolveIosSessionStartupAsync({
|
||||
...hostOptions,
|
||||
|
||||
@@ -1851,6 +1851,23 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
|
||||
});
|
||||
|
||||
it('preserves an errored status when the duration timer fires during the response', async () => {
|
||||
const ctx = createStatusCtxMock([]);
|
||||
jest.mocked(turtleFetch).mockImplementationOnce(async () => {
|
||||
(jest.mocked(setTimeoutCallback).mock.calls[0][0] as () => void)();
|
||||
return workerApiResponse({ id: 'drs-id', status: 'errored' });
|
||||
});
|
||||
await expect(
|
||||
waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger: createLoggerMock(),
|
||||
maxDurationSeconds: 30,
|
||||
})
|
||||
).rejects.toThrow('Device run session drs-id errored.');
|
||||
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
|
||||
});
|
||||
|
||||
it('clears the duration timeout when the build step is aborted', async () => {
|
||||
const ctx = createStatusCtxMock([{ status: 'in-progress' }]);
|
||||
const abortController = new AbortController();
|
||||
@@ -1869,6 +1886,33 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
|
||||
});
|
||||
|
||||
it('does not warn when retiring a watch aborts its pending status request', async () => {
|
||||
const controller = new AbortController();
|
||||
const logger = createLoggerMock();
|
||||
const ctx = createCtxMock();
|
||||
ctx.env.EAS_DEVICE_RUN_SESSION_TOKEN = 'worker-token';
|
||||
jest.mocked(turtleFetch).mockImplementation(
|
||||
async (_url, _method, options) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
options!.signal!.addEventListener('abort', () => reject(new Error('request aborted')), {
|
||||
once: true,
|
||||
});
|
||||
})
|
||||
);
|
||||
const waiting = waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
controller.abort();
|
||||
await waiting;
|
||||
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs and retries transient polling errors', async () => {
|
||||
const ctx = createStatusCtxMock([{ error: new Error('network down') }, { status: 'stopped' }]);
|
||||
const logger = createLoggerMock();
|
||||
@@ -2050,6 +2094,65 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote configuration cancellation', () => {
|
||||
beforeEach(() => jest.mocked(turtleFetch).mockReset());
|
||||
|
||||
it('does not report readiness for an already stopped startup', async () => {
|
||||
const controller = new AbortController();
|
||||
const stopped = new Error('startup stopped');
|
||||
controller.abort(stopped);
|
||||
|
||||
await expect(
|
||||
remoteDeviceRunSession.uploadRemoteSessionConfigAsync({
|
||||
ctx: createCtxMock(),
|
||||
deviceRunSessionId: 'drs-id',
|
||||
remoteConfig: {},
|
||||
logger: createLoggerMock(),
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toBe(stopped);
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards cancellation to the in-flight readiness HTTP request', async () => {
|
||||
const controller = new AbortController();
|
||||
const ctx = createCtxMock();
|
||||
ctx.env.EAS_DEVICE_RUN_SESSION_TOKEN = 'worker-token';
|
||||
const stopped = new Error('session cancelled');
|
||||
let entered!: () => void;
|
||||
const requested = new Promise<void>(resolve => {
|
||||
entered = resolve;
|
||||
});
|
||||
let requestAborted = false;
|
||||
jest.mocked(turtleFetch).mockImplementation(
|
||||
async (_url, _method, options) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
entered();
|
||||
options!.signal!.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
requestAborted = true;
|
||||
reject(new Error('ready request aborted'));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
})
|
||||
);
|
||||
const reporting = remoteDeviceRunSession.uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
remoteConfig: {},
|
||||
logger: createLoggerMock(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
await requested;
|
||||
controller.abort(stopped);
|
||||
|
||||
await expect(reporting).rejects.toBe(stopped);
|
||||
expect(requestAborted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
const spawnMock = jest.mocked(spawn);
|
||||
let binDirectory: string;
|
||||
|
||||
@@ -22,29 +22,17 @@ it('uploads NDJSON with explicit user-app scope', async () => {
|
||||
kind: 'simulator-log',
|
||||
filename: 'app-logs.ndjson',
|
||||
size: 11,
|
||||
attempts: 1,
|
||||
metadata: expect.objectContaining({ scope: 'user-apps', udid: 'A' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('skips empty and missing files', async () => {
|
||||
await uploadServeSimLogsFileAsync(ctx, args);
|
||||
await writeFile(filePath, '');
|
||||
await uploadServeSimLogsFileAsync(ctx, args);
|
||||
expect(uploadDeviceRunSessionArtifactAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aborts the upload on a file read error without an unhandled error', async () => {
|
||||
it('warns and resolves when the upload fails', async () => {
|
||||
await writeFile(filePath, '{}\n');
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { openStream, signal }) => {
|
||||
const aborted = new Promise<void>((_resolve, reject) => {
|
||||
signal!.addEventListener('abort', () => reject(signal!.reason), { once: true });
|
||||
});
|
||||
openStream().destroy(new Error('read failure'));
|
||||
await aborted;
|
||||
});
|
||||
.mockRejectedValueOnce(new Error('storage unavailable'));
|
||||
await expect(uploadServeSimLogsFileAsync(ctx, args)).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import { uploadDeviceRunSessionFileAsync } from '../uploadDeviceRunSessionFile';
|
||||
|
||||
jest.mock('../deviceRunSessionArtifacts');
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const upload = jest.mocked(uploadDeviceRunSessionArtifactAsync);
|
||||
const describeFile = (size: number) => ({
|
||||
deviceRunSessionId: 'session',
|
||||
artifactId: 'log',
|
||||
name: `log (${size} bytes)`,
|
||||
filename: 'log.txt',
|
||||
kind: 'simulator-log',
|
||||
});
|
||||
let directory: string;
|
||||
let filePath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(path.join(os.tmpdir(), 'device-run-session-file-'));
|
||||
filePath = path.join(directory, 'log.txt');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function uploadFile(options: { signal?: AbortSignal; attempts?: number } = {}): Promise<boolean> {
|
||||
return uploadDeviceRunSessionFileAsync(ctx, { filePath, describe: describeFile, ...options });
|
||||
}
|
||||
|
||||
it('skips an empty file without uploading', async () => {
|
||||
await writeFile(filePath, '');
|
||||
expect(await uploadFile()).toBe(false);
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a missing file before uploading', async () => {
|
||||
await expect(uploadFile()).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('describes the file by its size and forwards attempts and the caller signal', async () => {
|
||||
await writeFile(filePath, 'abc');
|
||||
const controller = new AbortController();
|
||||
expect(await uploadFile({ signal: controller.signal, attempts: 2 })).toBe(true);
|
||||
expect(upload).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ name: 'log (3 bytes)', size: 3, attempts: 2, filename: 'log.txt' })
|
||||
);
|
||||
const { signal } = upload.mock.calls[0][1];
|
||||
const reason = new Error('stop');
|
||||
controller.abort(reason);
|
||||
expect(signal?.reason).toBe(reason);
|
||||
});
|
||||
|
||||
it('aborts the upload on a file read error and ignores an aborted request body', async () => {
|
||||
await writeFile(filePath, '{}\n');
|
||||
const readError = Object.assign(new Error('read failed'), { code: 'EIO' });
|
||||
upload.mockImplementationOnce(async (_ctx, { openStream, signal }) => {
|
||||
const aborted = openStream();
|
||||
aborted.destroy(Object.assign(new Error('request body aborted'), { name: 'AbortError' }));
|
||||
await new Promise<void>(resolve => aborted.once('close', resolve));
|
||||
expect(signal?.aborted).toBe(false);
|
||||
const failed = openStream();
|
||||
failed.destroy(readError);
|
||||
await new Promise<void>(resolve => failed.once('close', resolve));
|
||||
expect(signal?.reason).toBe(readError);
|
||||
});
|
||||
expect(await uploadFile()).toBe(true);
|
||||
});
|
||||
@@ -37,7 +37,11 @@ it('does not claim when the job does not use the warm Simulator', async () => {
|
||||
expect(stopWarmSimulatorAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('claims the requested device and returns the launcher record', async () => {
|
||||
it('logs the wait before claiming, without logging the private launcher record', async () => {
|
||||
jest.mocked(runSimulatorLauncherAsync).mockImplementationOnce(async () => {
|
||||
expect(logger.info).toHaveBeenCalledWith('Waiting for the warm Simulator launcher.');
|
||||
return JSON.stringify(claim);
|
||||
});
|
||||
const session = await tryClaimWarmIosSessionAsync({
|
||||
engine: 'serve-sim',
|
||||
requestedDevice: 'iPhone 17',
|
||||
@@ -48,6 +52,10 @@ it('claims the requested device and returns the launcher record', async () => {
|
||||
env,
|
||||
signal: undefined,
|
||||
});
|
||||
expect(jest.mocked(logger.info).mock.calls).toEqual([
|
||||
['Waiting for the warm Simulator launcher.'],
|
||||
['Claimed the warm Simulator iPhone 17 (UDID).'],
|
||||
]);
|
||||
expect(session).toMatchObject({
|
||||
device: claim.device,
|
||||
server: { pid: 10, port: 3200, token: 'serve-token' },
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type BuildStepGlobalContext } from '@expo/steps';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
AndroidEmulatorLogCapture,
|
||||
type FinalizedAndroidEmulatorLog,
|
||||
MAX_ANDROID_LOG_BYTES_PER_SOURCE,
|
||||
} from '../../utils/androidEmulatorLogCapture';
|
||||
import { type AndroidEmulatorLogSource } from '../../utils/androidEmulatorLogSources';
|
||||
import { uploadDeviceRunSessionFileAsync } from './uploadDeviceRunSessionFile';
|
||||
|
||||
const UPLOAD_TIMEOUT_MS = 30_000;
|
||||
const LOG_SOURCE_DETAILS = {
|
||||
'emulator-output': { name: 'Emulator output', scope: 'emulator-process' },
|
||||
'device-logcat': { name: 'Android device logcat', scope: 'device' },
|
||||
} satisfies Record<AndroidEmulatorLogSource, { name: string; scope: string }>;
|
||||
|
||||
type Collection = {
|
||||
capture: AndroidEmulatorLogCapture;
|
||||
upload?: Promise<void>;
|
||||
};
|
||||
|
||||
// A worker can execute more than one job; each workflow owns its capture and uploads.
|
||||
const collections = new WeakMap<BuildStepGlobalContext, Collection>();
|
||||
|
||||
export function getAndroidEmulatorLogCapture(
|
||||
global: BuildStepGlobalContext,
|
||||
env: NodeJS.ProcessEnv
|
||||
): AndroidEmulatorLogCapture {
|
||||
let collection = collections.get(global);
|
||||
if (!collection) {
|
||||
collection = {
|
||||
capture: new AndroidEmulatorLogCapture(
|
||||
env.DEVICE_RUN_SESSION_ID ? MAX_ANDROID_LOG_BYTES_PER_SOURCE : Infinity
|
||||
),
|
||||
};
|
||||
collections.set(global, collection);
|
||||
}
|
||||
return collection.capture;
|
||||
}
|
||||
|
||||
export async function collectAndroidEmulatorLogsAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
global,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
}: {
|
||||
global: BuildStepGlobalContext;
|
||||
deviceRunSessionId: string;
|
||||
logger: bunyan;
|
||||
}
|
||||
): Promise<void> {
|
||||
const collection = collections.get(global);
|
||||
if (!collection) {
|
||||
logger.info('No Android emulator logs captured; skipping upload.');
|
||||
return;
|
||||
}
|
||||
// Generated session workflows collect once. Repeated calls share that finalization/upload.
|
||||
collection.upload ??= uploadCapturedLogsAsync(ctx, {
|
||||
collection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
});
|
||||
await collection.upload;
|
||||
}
|
||||
|
||||
async function uploadCapturedLogsAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
collection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
}: {
|
||||
collection: Collection;
|
||||
deviceRunSessionId: string;
|
||||
logger: bunyan;
|
||||
}
|
||||
): Promise<void> {
|
||||
const logs = await collection.capture.finishAsync();
|
||||
for (const log of logs) {
|
||||
const filename = path.basename(log.filePath);
|
||||
try {
|
||||
const uploaded = await uploadLogAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
log,
|
||||
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
|
||||
});
|
||||
if (uploaded) {
|
||||
logger.info(`Uploaded Android diagnostic log ${filename}.`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `Could not upload Android log ${filename}; other logs will continue.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadLogAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
deviceRunSessionId,
|
||||
log: { filePath, source, bytesSeen },
|
||||
signal,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
log: FinalizedAndroidEmulatorLog;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
const filename = path.basename(filePath);
|
||||
return await uploadDeviceRunSessionFileAsync(ctx, {
|
||||
filePath,
|
||||
describe: size => {
|
||||
const retainedSize =
|
||||
size >= 1024 * 1024 ? `${Number((size / (1024 * 1024)).toFixed(2))} MiB` : `${size} bytes`;
|
||||
const truncationNote = size < bytesSeen ? `; truncated, last ${retainedSize} retained` : '';
|
||||
return {
|
||||
deviceRunSessionId,
|
||||
artifactId: filename,
|
||||
name: `${LOG_SOURCE_DETAILS[source].name} (${filename}${truncationNote})`,
|
||||
filename,
|
||||
kind: 'simulator-log',
|
||||
metadata: {
|
||||
__eas_type: 'simulator-log',
|
||||
platform: 'android',
|
||||
source,
|
||||
scope: LOG_SOURCE_DETAILS[source].scope,
|
||||
originalSizeBytes: bytesSeen,
|
||||
// The capture keeps the newest bytes, so the omitted bytes are the head.
|
||||
omittedBytes: bytesSeen - size,
|
||||
truncated: size < bytesSeen,
|
||||
},
|
||||
};
|
||||
},
|
||||
signal,
|
||||
attempts: 2,
|
||||
});
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
|
||||
|
||||
type ArtifactMetadata = {
|
||||
export type ArtifactMetadata = {
|
||||
deviceRunSessionId: string;
|
||||
artifactId: string;
|
||||
name: string;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
DeviceRunSessionStoppedError,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from './remoteDeviceRunSession';
|
||||
import { type StartupTasks } from './startupTasks';
|
||||
|
||||
export type DeviceRunSessionStartupWatch = {
|
||||
/** The startup abort reason when a Stop request arrives during startup. */
|
||||
readonly stopped: DeviceRunSessionStoppedError;
|
||||
/** Whether a Stop request aborted startup. */
|
||||
isStopped(): boolean;
|
||||
/** Ends the watch after the session reports ready. Throws if startup was aborted meanwhile. */
|
||||
completeAsync(): Promise<void>;
|
||||
/** Ends the watch without throwing, for cleanup. Safe to call more than once. */
|
||||
finishAsync(): Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Polls for a Stop request while the session starts up, and aborts startup with `stopped` when
|
||||
* one arrives. A poll failure aborts startup with that error. Call `completeAsync` before the
|
||||
* lifetime wait, so only one poll runs at a time, and `finishAsync` in cleanup.
|
||||
*/
|
||||
export function watchDeviceRunSessionStartup({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
startup,
|
||||
}: {
|
||||
ctx: CustomBuildContext;
|
||||
deviceRunSessionId: string;
|
||||
logger: bunyan;
|
||||
startup: Pick<StartupTasks, 'signal' | 'abort'>;
|
||||
}): DeviceRunSessionStartupWatch {
|
||||
const stopped = new DeviceRunSessionStoppedError(
|
||||
`Device run session ${deviceRunSessionId} was stopped during startup.`
|
||||
);
|
||||
const done = new AbortController();
|
||||
const watchSignal = AbortSignal.any([startup.signal, done.signal]);
|
||||
const watching = waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
signal: watchSignal,
|
||||
}).then(
|
||||
() => {
|
||||
if (!watchSignal.aborted) {
|
||||
logger.info(stopped.message);
|
||||
startup.abort(stopped);
|
||||
}
|
||||
},
|
||||
error => startup.abort(error)
|
||||
);
|
||||
const finishAsync = async (): Promise<void> => {
|
||||
done.abort();
|
||||
await watching;
|
||||
};
|
||||
return {
|
||||
stopped,
|
||||
isStopped: () => startup.signal.reason === stopped,
|
||||
completeAsync: async () => {
|
||||
await finishAsync();
|
||||
startup.signal.throwIfAborted();
|
||||
},
|
||||
finishAsync,
|
||||
};
|
||||
}
|
||||
@@ -97,7 +97,12 @@ export async function resolveIosSessionStartupAsync({
|
||||
signal?.throwIfAborted();
|
||||
if (device && device.state !== 'Shutdown') {
|
||||
logger.info(`Shutting down ${iosSimulatorUdid} to apply the local egress guard at boot.`);
|
||||
await spawn('xcrun', ['simctl', 'shutdown', iosSimulatorUdid], { env, logger, signal });
|
||||
try {
|
||||
await spawn('xcrun', ['simctl', 'shutdown', iosSimulatorUdid], { env, logger, signal });
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
await startLocalEgressGuardRelayAsync({ logger });
|
||||
|
||||
@@ -37,6 +37,7 @@ export async function uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
signal?.throwIfAborted();
|
||||
const sessionStart = await uploadRemoteSessionConfigAsync({
|
||||
...options,
|
||||
signal,
|
||||
remoteConfig: { ...options.remoteConfig, ...buildEgressRemoteConfigFields(localEgress) },
|
||||
});
|
||||
if (sessionStart === 'stopped') {
|
||||
|
||||
@@ -129,6 +129,8 @@ export type DeviceRunSessionIdleTimeout = {
|
||||
getLastPreviewActivityAt?: () => Date | undefined;
|
||||
};
|
||||
|
||||
export class DeviceRunSessionStoppedError extends Error {}
|
||||
|
||||
export async function waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
@@ -162,9 +164,7 @@ export async function waitForDeviceRunSessionStoppedAsync({
|
||||
}, maxDurationSeconds * 1_000);
|
||||
|
||||
try {
|
||||
logger.info(
|
||||
`Remote session is live. Polling device run session ${deviceRunSessionId} until it is stopped.`
|
||||
);
|
||||
logger.info(`Polling device run session ${deviceRunSessionId} until it is stopped.`);
|
||||
if (durationTimeout !== undefined) {
|
||||
logger.info(
|
||||
`The device run session will stop automatically after ${maxDurationSeconds} seconds.`
|
||||
@@ -211,6 +211,9 @@ export async function waitForDeviceRunSessionStoppedAsync({
|
||||
if (err instanceof SystemError) {
|
||||
throw err;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
pollErrorCount += 1;
|
||||
@@ -556,12 +559,15 @@ export async function uploadRemoteSessionConfigAsync({
|
||||
deviceRunSessionId,
|
||||
remoteConfig,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
ctx: CustomBuildContext;
|
||||
deviceRunSessionId: string;
|
||||
remoteConfig: Record<string, unknown>;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<'started' | 'stopped'> {
|
||||
signal?.throwIfAborted();
|
||||
logger.info(
|
||||
`Reporting remote config to the API server (device run session: ${deviceRunSessionId}).`
|
||||
);
|
||||
@@ -569,10 +575,11 @@ export async function uploadRemoteSessionConfigAsync({
|
||||
try {
|
||||
await workerApi.startAsync(
|
||||
{ remoteConfig },
|
||||
{ retries: 3, timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS, logger }
|
||||
{ retries: 3, timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS, logger, signal }
|
||||
);
|
||||
return 'started';
|
||||
} catch (err) {
|
||||
signal?.throwIfAborted();
|
||||
if (
|
||||
err instanceof DeviceRunSessionWorkerApiError &&
|
||||
err.code === 'DEVICE_RUN_SESSION_ALREADY_STOPPED'
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
|
||||
import { uploadDeviceRunSessionFileAsync } from './uploadDeviceRunSessionFile';
|
||||
|
||||
export async function uploadServeSimLogsFileAsync(
|
||||
ctx: CustomBuildContext,
|
||||
@@ -22,21 +20,22 @@ export async function uploadServeSimLogsFileAsync(
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { size } = await stat(filePath);
|
||||
if (size === 0) {
|
||||
return;
|
||||
}
|
||||
const fileController = new AbortController();
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId: `simulator-log-${udid}`,
|
||||
name: `App logs (${udid.slice(0, 8)})`,
|
||||
filename: 'app-logs.ndjson',
|
||||
kind: 'simulator-log',
|
||||
metadata: { __eas_type: 'simulator-log', udid, scope: 'user-apps', source: 'serve-sim/logs' },
|
||||
size,
|
||||
openStream: () => createReadStream(filePath).on('error', err => fileController.abort(err)),
|
||||
signal: AbortSignal.any([fileController.signal, ...(signal ? [signal] : [])]),
|
||||
await uploadDeviceRunSessionFileAsync(ctx, {
|
||||
filePath,
|
||||
describe: () => ({
|
||||
deviceRunSessionId,
|
||||
artifactId: `simulator-log-${udid}`,
|
||||
name: `App logs (${udid.slice(0, 8)})`,
|
||||
filename: 'app-logs.ndjson',
|
||||
kind: 'simulator-log',
|
||||
metadata: {
|
||||
__eas_type: 'simulator-log',
|
||||
udid,
|
||||
scope: 'user-apps',
|
||||
source: 'serve-sim/logs',
|
||||
},
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
type ArtifactMetadata,
|
||||
uploadDeviceRunSessionArtifactAsync,
|
||||
} from './deviceRunSessionArtifacts';
|
||||
|
||||
/** Upload a finalized file, reopening it for each attempt. */
|
||||
export async function uploadDeviceRunSessionFileAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
filePath,
|
||||
describe,
|
||||
signal,
|
||||
attempts = 1,
|
||||
}: {
|
||||
filePath: string;
|
||||
describe: (size: number) => Omit<ArtifactMetadata, 'size'>;
|
||||
signal?: AbortSignal;
|
||||
attempts?: number;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
const { size } = await stat(filePath);
|
||||
if (size === 0) {
|
||||
return false;
|
||||
}
|
||||
const readController = new AbortController();
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...describe(size),
|
||||
size,
|
||||
openStream: () =>
|
||||
createReadStream(filePath).on('error', err => {
|
||||
// An HTTP error aborts one request body, not the remaining upload attempts.
|
||||
if (err.name !== 'AbortError') {
|
||||
readController.abort(err);
|
||||
}
|
||||
}),
|
||||
signal: AbortSignal.any([readController.signal, ...(signal ? [signal] : [])]),
|
||||
attempts,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -66,6 +66,7 @@ export async function tryClaimWarmIosSessionAsync({
|
||||
if (await readLocalEgressHandoffAsync()) {
|
||||
throw new Error('A local egress session needs a cold start.');
|
||||
}
|
||||
logger.info('Waiting for the warm Simulator launcher.');
|
||||
const claimArgs = ['claim', engine, ...(requestedDevice ? [requestedDevice] : [])];
|
||||
const claim = ClaimSchema.parse(
|
||||
JSON.parse(await runSimulatorLauncherAsync(claimArgs, { env, signal }))
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
# create-eas-build-function
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "27.4.0",
|
||||
"version": "27.4.1",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "27.4.0"
|
||||
"@expo/steps": "27.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/downloader
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "27.4.0",
|
||||
"version": "27.4.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# eas-build-cache-provider
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "27.4.0",
|
||||
"version": "27.4.1",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/eas-build-job
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "27.4.0",
|
||||
"version": "27.4.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,9 +25,9 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "27.4.0",
|
||||
"@expo/logger": "27.4.1",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "27.4.0",
|
||||
"@expo/turtle-spawn": "27.4.1",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# eas-cli
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
+176
-158
File diff suppressed because it is too large.
Load diff
Generated
+20
@@ -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\").",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "27.4.0",
|
||||
"version": "27.4.1",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,12 +48,12 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "27.4.0",
|
||||
"@expo/eas-json": "27.4.0",
|
||||
"@expo/eas-build-job": "27.4.1",
|
||||
"@expo/eas-json": "27.4.1",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/experimentation": "0.0.5",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "27.4.0",
|
||||
"@expo/logger": "27.4.1",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -65,7 +65,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "27.4.0",
|
||||
"@expo/steps": "27.4.1",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
|
||||
@@ -8,7 +8,7 @@ import Log, { link } from '../log';
|
||||
import { LoggedInAuthenticationInfo } from '../user/SessionManager';
|
||||
import { Actor } from '../user/User';
|
||||
|
||||
const SIMULATORS_LAUNCH_AT = new Date('2026-11-10T18:00:00Z');
|
||||
const SIMULATORS_LAUNCH_AT = new Date('2026-11-12T18:00:00Z');
|
||||
const SIMULATORS_LAUNCH_HINT_PREFERENCE_KEY = 'simulators-launch-cli-hint-seen';
|
||||
const REQUEST_TIMEOUT_MS = 3000;
|
||||
const SIMULATORS_QUICKSTART_URL =
|
||||
|
||||
@@ -883,6 +883,38 @@ describe(Simulator, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('accumulates repeated --android-log-source flags in order', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'android',
|
||||
'--non-interactive',
|
||||
'--android-log-source',
|
||||
'emulator-output',
|
||||
'--android-log-source',
|
||||
'device-logcat',
|
||||
]);
|
||||
await command.runAsync();
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ android: { logSources: ['emulator-output', 'device-logcat'] } })
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves Android log sources omitted unless explicitly requested', async () => {
|
||||
const { command } = createCommand(['--platform', 'android', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
expect(mockCreateDeviceRunSessionAsync.mock.calls[0][1].android?.logSources).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ args: ['--platform', 'ios', '--android-log-source', 'device-logcat'] },
|
||||
{ args: ['--platform', 'android', '--android-log-source', 'unknown'] },
|
||||
])('rejects invalid Android logging options $args', async ({ args }) => {
|
||||
const { command } = createCommand([...args, '--non-interactive']);
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes --network-capture to the createDeviceRunSession mutation', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
|
||||
@@ -150,6 +150,12 @@ export default class Simulator extends EasCommand {
|
||||
description:
|
||||
'Version of the package backing the simulator session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.',
|
||||
}),
|
||||
'android-log-source': Flags.option({
|
||||
description:
|
||||
'Android logs to record during the session and upload afterward. Repeat to select both sources; replaces the default emulator-output selection. Device-wide device-logcat may contain credentials and personal data. Logs are not redacted and are retained for 90 days.',
|
||||
options: ['emulator-output', 'device-logcat'],
|
||||
multiple: true,
|
||||
})(),
|
||||
'network-capture': Flags.boolean({
|
||||
description:
|
||||
'Record HTTP(S) traffic from apps on the device (iOS only). HTTPS is decrypted, so recordings contain credentials in cleartext and certificate-pinned apps fail to connect.',
|
||||
@@ -257,6 +263,7 @@ export default class Simulator extends EasCommand {
|
||||
const tags = flags.tag?.map(tag => tag.trim()).filter(tag => tag.length > 0);
|
||||
const deviceIdentifier = flags.device?.trim() || undefined;
|
||||
const systemImagePackage = flags['system-image']?.trim() || undefined;
|
||||
const androidLogSources = flags['android-log-source'];
|
||||
const buildId = flags['build-id']?.trim() || undefined;
|
||||
const buildFingerprint = flags['build-fingerprint']?.trim() || undefined;
|
||||
const applicationArchiveUrlFromFlag = flags['application-archive-url']?.trim() || undefined;
|
||||
@@ -300,6 +307,9 @@ export default class Simulator extends EasCommand {
|
||||
|
||||
const platform = await resolvePlatformAsync(flags.platform, nonInteractive);
|
||||
const egress = flags.egress === 'local' ? DeviceRunSessionEgress.Local : undefined;
|
||||
if (androidLogSources && platform !== AppPlatform.Android) {
|
||||
throw new EasCommandError('--android-log-source is only supported with --platform android.');
|
||||
}
|
||||
if (systemImagePackage && platform !== AppPlatform.Android) {
|
||||
throw new EasCommandError('--system-image is only supported with --platform android.');
|
||||
}
|
||||
@@ -363,6 +373,19 @@ export default class Simulator extends EasCommand {
|
||||
packageVersion: flags['package-version'],
|
||||
nonInteractive,
|
||||
});
|
||||
const android = {
|
||||
...(deviceIdentifier ? { deviceIdentifier } : {}),
|
||||
...(systemImagePackage ? { systemImagePackage } : {}),
|
||||
...(androidLogSources ? { logSources: androidLogSources } : {}),
|
||||
};
|
||||
const platformInput =
|
||||
platform === AppPlatform.Ios
|
||||
? deviceIdentifier
|
||||
? { ios: { deviceIdentifier } }
|
||||
: {}
|
||||
: Object.keys(android).length
|
||||
? { android }
|
||||
: {};
|
||||
const session = await withSimulatorRequestAnalyticsAsync(
|
||||
analytics,
|
||||
requestProperties,
|
||||
@@ -378,18 +401,7 @@ export default class Simulator extends EasCommand {
|
||||
packageVersion: flags['package-version'],
|
||||
networkCapture: flags['network-capture'],
|
||||
...(networkCaptureFields.length ? { networkCaptureFields } : {}),
|
||||
...(platform === AppPlatform.Ios
|
||||
? deviceIdentifier
|
||||
? { ios: { deviceIdentifier } }
|
||||
: {}
|
||||
: deviceIdentifier || systemImagePackage
|
||||
? {
|
||||
android: {
|
||||
...(deviceIdentifier ? { deviceIdentifier } : {}),
|
||||
...(systemImagePackage ? { systemImagePackage } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...platformInput,
|
||||
...(buildId ? { buildId } : {}),
|
||||
...(buildFingerprint ? { buildFingerprint } : {}),
|
||||
...(applicationArchiveUrlFromFlag
|
||||
|
||||
+7
@@ -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").
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/eas-json
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"version": "27.4.0",
|
||||
"version": "27.4.1",
|
||||
"description": "A library for interacting with eas.json",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "7.23.5",
|
||||
"@expo/eas-build-job": "27.4.0",
|
||||
"@expo/eas-build-job": "27.4.1",
|
||||
"chalk": "4.1.2",
|
||||
"env-string": "1.0.1",
|
||||
"fs-extra": "11.2.0",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# eas-cli-local-build-plugin
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "27.4.0",
|
||||
"version": "27.4.1",
|
||||
"description": "Tool for running EAS compatible builds on a local machine.",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
@@ -26,12 +26,12 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/build-tools": "27.4.0",
|
||||
"@expo/build-tools": "27.4.1",
|
||||
"@expo/bunyan": "^4.0.1",
|
||||
"@expo/eas-build-job": "27.4.0",
|
||||
"@expo/logger": "27.4.0",
|
||||
"@expo/eas-build-job": "27.4.1",
|
||||
"@expo/logger": "27.4.1",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"@expo/turtle-spawn": "27.4.0",
|
||||
"@expo/turtle-spawn": "27.4.1",
|
||||
"chalk": "4.1.2",
|
||||
"env-paths": "^2.2.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/logger
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/logger",
|
||||
"version": "27.4.0",
|
||||
"version": "27.4.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @expo/simulator-launcher
|
||||
|
||||
## 27.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Wait for a starting warm Simulator launcher before claiming its session, instead of falling back to a cold start. ([#4810](https://github.com/expo/eas-cli/pull/4810) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 27.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/simulator-launcher",
|
||||
"version": "27.4.0",
|
||||
"version": "27.4.1",
|
||||
"description": "Launch and claim Simulator sessions on EAS workers",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
|
||||
@@ -4,13 +4,14 @@ import path from 'node:path';
|
||||
|
||||
import { claimAsync } from '../commands/claim';
|
||||
import { stopAsync } from '../commands/stop';
|
||||
import * as launcherState from '../utils/state';
|
||||
import {
|
||||
LAUNCHER_STATE_FILE,
|
||||
readLauncherStateAsync,
|
||||
updateLauncherStateAsync,
|
||||
waitForLauncherStateAsync,
|
||||
} from '../utils/state';
|
||||
import { deferred } from './helpers';
|
||||
import { deferred, until } from './helpers';
|
||||
|
||||
const boot = 'boot-one';
|
||||
let root: string;
|
||||
@@ -30,6 +31,7 @@ const info = {
|
||||
argentEventLogPath: '/argent/events',
|
||||
};
|
||||
const directory = () => path.join(root, 'control');
|
||||
const pollLauncherStateAsync = waitForLauncherStateAsync;
|
||||
|
||||
const nativeFs = {
|
||||
readFile: fs.readFile.bind(fs),
|
||||
@@ -46,6 +48,7 @@ const remap = (file: unknown): unknown =>
|
||||
: file;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.spyOn(process.stderr, 'write').mockReturnValue(true);
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), 'launcher-state-'));
|
||||
jest.spyOn(os, 'homedir').mockReturnValue(root);
|
||||
await fs.mkdir(path.join(root, 'control'));
|
||||
@@ -192,6 +195,7 @@ test('two job claims acquire only one ready session and the result includes its
|
||||
await updateLauncherStateAsync(boot, state => ({ ...state!, status: 'claimed' }));
|
||||
await Promise.all(claims);
|
||||
expect(results.filter(value => value === 0)).toHaveLength(1);
|
||||
expect(process.stderr.write).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(String(output.mock.calls[0][0]))).toMatchObject({
|
||||
...info,
|
||||
status: 'claimed',
|
||||
@@ -200,6 +204,252 @@ test('two job claims acquire only one ready session and the result includes its
|
||||
await expect(claimAsync(boot, 'serve-sim')).rejects.toThrow('available');
|
||||
});
|
||||
|
||||
async function beginStartingClaimAsync(engine = 'serve-sim') {
|
||||
let observedStarting = false;
|
||||
jest
|
||||
.spyOn(launcherState, 'waitForLauncherStateAsync')
|
||||
.mockImplementationOnce((bootUuid, isComplete, timeoutMs) =>
|
||||
pollLauncherStateAsync(
|
||||
bootUuid,
|
||||
state => {
|
||||
const complete = isComplete(state);
|
||||
observedStarting ||= state?.status === 'starting';
|
||||
return complete;
|
||||
},
|
||||
timeoutMs
|
||||
)
|
||||
);
|
||||
const result = claimAsync(boot, engine, 'iPhone 17').then(
|
||||
value => value,
|
||||
error => error as Error
|
||||
);
|
||||
await until(() => observedStarting);
|
||||
return { result };
|
||||
}
|
||||
|
||||
test('a claim waits for the minimal starting record to become ready without changing it', async () => {
|
||||
const starting = {
|
||||
bootUuid: boot,
|
||||
status: 'starting' as const,
|
||||
controllerPid: info.controllerPid,
|
||||
launcherVersion: '27.4.0',
|
||||
};
|
||||
await updateLauncherStateAsync(boot, () => starting);
|
||||
const output = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
const { result } = await beginStartingClaimAsync();
|
||||
expect(await readLauncherStateAsync(boot)).toEqual(starting);
|
||||
expect(await fs.readdir(directory())).toEqual([path.basename(LAUNCHER_STATE_FILE)]);
|
||||
expect(output).not.toHaveBeenCalled();
|
||||
|
||||
await updateLauncherStateAsync(boot, state => ({ ...state!, ...info, status: 'ready' }));
|
||||
await waitForLauncherStateAsync(boot, state => state?.status === 'claiming', 1_000);
|
||||
await updateLauncherStateAsync(boot, state => ({ ...state!, status: 'claimed' }));
|
||||
expect(await result).toBe(0);
|
||||
expect(JSON.parse(String(output.mock.calls[0][0]))).toMatchObject({
|
||||
...info,
|
||||
status: 'claimed',
|
||||
engine: 'serve-sim',
|
||||
requestedDevice: 'iPhone 17',
|
||||
});
|
||||
});
|
||||
|
||||
test('two claims waiting for startup still acquire only one session', async () => {
|
||||
await updateLauncherStateAsync(boot, () => ({
|
||||
bootUuid: boot,
|
||||
status: 'starting',
|
||||
controllerPid: info.controllerPid,
|
||||
}));
|
||||
jest.spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
const first = await beginStartingClaimAsync();
|
||||
const second = await beginStartingClaimAsync('argent');
|
||||
await updateLauncherStateAsync(boot, state => ({ ...state!, ...info, status: 'ready' }));
|
||||
await waitForLauncherStateAsync(boot, state => state?.status === 'claiming', 1_000);
|
||||
await updateLauncherStateAsync(boot, state => ({ ...state!, status: 'claimed' }));
|
||||
const results = await Promise.all([first.result, second.result]);
|
||||
expect(results.filter(value => value === 0)).toHaveLength(1);
|
||||
expect(results.filter(value => value instanceof Error)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('startup wait progress is throttled and stdout remains the claim JSON', async () => {
|
||||
let now = 0;
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
const output = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
const progress = jest.mocked(process.stderr.write);
|
||||
await updateLauncherStateAsync(boot, () => ({
|
||||
bootUuid: boot,
|
||||
status: 'starting',
|
||||
controllerPid: info.controllerPid,
|
||||
}));
|
||||
const { result } = await beginStartingClaimAsync();
|
||||
expect(progress.mock.calls).toEqual([
|
||||
['Warm Simulator is still starting; waiting (0s elapsed, 420s limit).\n'],
|
||||
]);
|
||||
const reads = jest.mocked(fs.readFile).mock.calls.length;
|
||||
now = 10_000;
|
||||
await until(() => jest.mocked(fs.readFile).mock.calls.length >= reads + 2);
|
||||
expect(progress).toHaveBeenCalledTimes(1);
|
||||
expect(output).not.toHaveBeenCalled();
|
||||
now = 30_000;
|
||||
await until(() => progress.mock.calls.length === 2);
|
||||
expect(progress.mock.calls[1]).toEqual([
|
||||
'Warm Simulator is still starting; waiting (30s elapsed, 420s limit).\n',
|
||||
]);
|
||||
now = 31_000;
|
||||
await updateLauncherStateAsync(boot, state => ({ ...state!, ...info, status: 'ready' }));
|
||||
await waitForLauncherStateAsync(boot, state => state?.status === 'claiming', 1_000);
|
||||
await updateLauncherStateAsync(boot, state => ({ ...state!, status: 'claimed' }));
|
||||
expect(await result).toBe(0);
|
||||
expect(progress.mock.calls).toHaveLength(3);
|
||||
expect(progress.mock.calls[2]).toEqual(['Warm Simulator became ready after waiting 31s.\n']);
|
||||
expect(output).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(String(output.mock.calls[0][0]))).toMatchObject({
|
||||
status: 'claimed',
|
||||
server: { token: info.server.token },
|
||||
});
|
||||
expect(progress.mock.calls.flat().join('')).not.toContain(info.server.token);
|
||||
});
|
||||
|
||||
test.each(['stopping', 'disabled'] as const)(
|
||||
'a waiting claim preserves a startup failure in %s state',
|
||||
async status => {
|
||||
await updateLauncherStateAsync(boot, () => ({
|
||||
bootUuid: boot,
|
||||
status: 'starting',
|
||||
controllerPid: info.controllerPid,
|
||||
}));
|
||||
const { result } = await beginStartingClaimAsync();
|
||||
await updateLauncherStateAsync(boot, state => ({
|
||||
...state!,
|
||||
status,
|
||||
error: 'serve-sim exited',
|
||||
}));
|
||||
expect(await result).toEqual(
|
||||
new Error(
|
||||
'Simulator preparation was interrupted; cleanup may still be running. serve-sim exited'
|
||||
)
|
||||
);
|
||||
expect((await readLauncherStateAsync(boot))?.status).toBe(status);
|
||||
}
|
||||
);
|
||||
|
||||
test.each(['missing', 'old boot', 'different controller', 'different start time'] as const)(
|
||||
'a waiting claim cannot acquire %s state',
|
||||
async replacement => {
|
||||
await updateLauncherStateAsync(boot, () => ({
|
||||
bootUuid: boot,
|
||||
status: 'starting',
|
||||
controllerPid: info.controllerPid,
|
||||
controllerStartTime: info.controllerStartTime,
|
||||
}));
|
||||
const { result } = await beginStartingClaimAsync();
|
||||
if (replacement === 'missing') {
|
||||
await fs.rm(LAUNCHER_STATE_FILE);
|
||||
} else if (replacement === 'old boot') {
|
||||
await updateLauncherStateAsync('other-boot', () => ({
|
||||
bootUuid: 'other-boot',
|
||||
status: 'ready',
|
||||
...info,
|
||||
}));
|
||||
} else {
|
||||
await updateLauncherStateAsync(boot, state => ({
|
||||
...state!,
|
||||
...info,
|
||||
status: 'ready',
|
||||
...(replacement === 'different controller'
|
||||
? { controllerPid: info.controllerPid + 1 }
|
||||
: { controllerStartTime: 'replacement-start' }),
|
||||
}));
|
||||
}
|
||||
expect(await result).toBeInstanceOf(Error);
|
||||
expect((await readLauncherStateAsync(boot))?.status).not.toBe('claiming');
|
||||
}
|
||||
);
|
||||
|
||||
test('a claim rechecks controller identity under the state lock', async () => {
|
||||
await updateLauncherStateAsync(boot, () => ({ bootUuid: boot, status: 'ready', ...info }));
|
||||
jest.spyOn(fs, 'mkdir').mockImplementationOnce(async file => {
|
||||
await nativeFs.writeFile(
|
||||
remap(LAUNCHER_STATE_FILE) as string,
|
||||
JSON.stringify({ bootUuid: boot, status: 'ready', ...info, controllerPid: 9002 })
|
||||
);
|
||||
await nativeFs.mkdir(remap(file) as string);
|
||||
return undefined;
|
||||
});
|
||||
await expect(claimAsync(boot, 'serve-sim')).rejects.toThrow('different session');
|
||||
expect(await readLauncherStateAsync(boot)).toMatchObject({
|
||||
status: 'ready',
|
||||
controllerPid: 9002,
|
||||
});
|
||||
});
|
||||
|
||||
test('startup and claim acknowledgement share one timeout', async () => {
|
||||
let now = 0;
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
await updateLauncherStateAsync(boot, () => ({
|
||||
bootUuid: boot,
|
||||
status: 'starting',
|
||||
controllerPid: info.controllerPid,
|
||||
}));
|
||||
const { result } = await beginStartingClaimAsync();
|
||||
now = 419_000;
|
||||
await updateLauncherStateAsync(boot, state => ({ ...state!, ...info, status: 'ready' }));
|
||||
await waitForLauncherStateAsync(boot, state => state?.status === 'claiming', 1_000);
|
||||
now = 420_001;
|
||||
let settled = false;
|
||||
void result.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
try {
|
||||
await until(() => settled);
|
||||
expect(await result).toEqual(
|
||||
new Error('Timed out waiting for the Simulator launcher; cleanup may still be running.')
|
||||
);
|
||||
} finally {
|
||||
now = 1_000_000;
|
||||
await result;
|
||||
}
|
||||
});
|
||||
|
||||
test('startup times out without acquiring or changing the starting record', async () => {
|
||||
let now = 0;
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
await updateLauncherStateAsync(boot, () => ({
|
||||
bootUuid: boot,
|
||||
status: 'starting',
|
||||
controllerPid: info.controllerPid,
|
||||
}));
|
||||
const { result } = await beginStartingClaimAsync();
|
||||
now = 420_001;
|
||||
expect(await result).toEqual(
|
||||
new Error('Timed out waiting for the Simulator launcher; cleanup may still be running.')
|
||||
);
|
||||
expect((await readLauncherStateAsync(boot))?.status).toBe('starting');
|
||||
});
|
||||
|
||||
test('a claim cannot acquire after the deadline expires while taking the state lock', async () => {
|
||||
let now = 0;
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
await updateLauncherStateAsync(boot, () => ({ bootUuid: boot, status: 'ready', ...info }));
|
||||
jest.spyOn(fs, 'mkdir').mockImplementationOnce(async file => {
|
||||
now = 420_001;
|
||||
await nativeFs.mkdir(remap(file) as string);
|
||||
return undefined;
|
||||
});
|
||||
await expect(claimAsync(boot, 'serve-sim')).rejects.toThrow('Timed out');
|
||||
expect((await readLauncherStateAsync(boot))?.status).toBe('ready');
|
||||
});
|
||||
|
||||
test.each(['missing', 'old boot', 'starting without a controller'] as const)(
|
||||
'a claim immediately rejects %s state',
|
||||
async state => {
|
||||
if (state !== 'missing') {
|
||||
const bootUuid = state === 'old boot' ? 'old-boot' : boot;
|
||||
await updateLauncherStateAsync(bootUuid, () => ({ bootUuid, status: 'starting' }));
|
||||
}
|
||||
await expect(claimAsync(boot, 'serve-sim')).rejects.toThrow('available');
|
||||
}
|
||||
);
|
||||
|
||||
test('a claim fails when the launcher hands over a different device than requested', async () => {
|
||||
await updateLauncherStateAsync(boot, () => ({ bootUuid: boot, status: 'ready', ...info }));
|
||||
const claim = expect(claimAsync(boot, 'serve-sim', 'iPad Pro')).rejects.toThrow(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ClaimInputSchema,
|
||||
type LauncherState,
|
||||
WarmSessionInfoSchema,
|
||||
updateLauncherStateAsync,
|
||||
waitForLauncherStateAsync,
|
||||
@@ -16,13 +17,72 @@ export async function claimAsync(
|
||||
`Invalid Simulator session request. Choose ${ClaimInputSchema.shape.engine.options.join(', ')} and an optional device name or UDID.`
|
||||
);
|
||||
}
|
||||
// Startup and handoff share the worker's existing claim timeout.
|
||||
const startedAt = Date.now();
|
||||
const deadline = startedAt + 420_000;
|
||||
let lastWaitLogAt: number | undefined;
|
||||
let controller: LauncherState | undefined;
|
||||
const ready = await waitForLauncherStateAsync(
|
||||
bootUuid,
|
||||
state => {
|
||||
if (state?.status === 'stopping' || state?.status === 'disabled' || state?.error) {
|
||||
throw new Error(
|
||||
`Simulator preparation was interrupted; cleanup may still be running.${state.error ? ` ${state.error}` : ''}`
|
||||
);
|
||||
}
|
||||
if (
|
||||
!state ||
|
||||
(state.status !== 'starting' && state.status !== 'ready') ||
|
||||
state.controllerPid === undefined
|
||||
) {
|
||||
throw new Error('No warm Simulator session is available to claim on this VM.');
|
||||
}
|
||||
if (
|
||||
controller &&
|
||||
(state.controllerPid !== controller.controllerPid ||
|
||||
(controller.controllerStartTime !== undefined &&
|
||||
state.controllerStartTime !== controller.controllerStartTime))
|
||||
) {
|
||||
throw new Error('The Simulator launcher returned a different session or engine.');
|
||||
}
|
||||
controller = state;
|
||||
if (state.status === 'starting') {
|
||||
const now = Date.now();
|
||||
if (lastWaitLogAt === undefined || now - lastWaitLogAt >= 30_000) {
|
||||
process.stderr.write(
|
||||
`Warm Simulator is still starting; waiting (${Math.floor((now - startedAt) / 1_000)}s elapsed, 420s limit).\n`
|
||||
);
|
||||
lastWaitLogAt = now;
|
||||
}
|
||||
}
|
||||
return state.status === 'ready';
|
||||
},
|
||||
deadline - Date.now()
|
||||
);
|
||||
const prepared = WarmSessionInfoSchema.parse(ready);
|
||||
const acquired = await updateLauncherStateAsync(bootUuid, state => {
|
||||
if (state?.status !== 'ready') {
|
||||
throw new Error('No warm Simulator session is available to claim on this VM.');
|
||||
}
|
||||
if (
|
||||
state.controllerPid !== prepared.controllerPid ||
|
||||
state.controllerStartTime !== prepared.controllerStartTime
|
||||
) {
|
||||
throw new Error('The Simulator launcher returned a different session or engine.');
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(
|
||||
'Timed out waiting for the Simulator launcher; cleanup may still be running.'
|
||||
);
|
||||
}
|
||||
WarmSessionInfoSchema.parse(state);
|
||||
return { ...state, ...input.data, status: 'claiming' };
|
||||
});
|
||||
if (lastWaitLogAt !== undefined) {
|
||||
process.stderr.write(
|
||||
`Warm Simulator became ready after waiting ${Math.floor((Date.now() - startedAt) / 1_000)}s.\n`
|
||||
);
|
||||
}
|
||||
const claimed = await waitForLauncherStateAsync(
|
||||
bootUuid,
|
||||
state => {
|
||||
@@ -40,7 +100,7 @@ export async function claimAsync(
|
||||
}
|
||||
return state.status === 'claimed';
|
||||
},
|
||||
420_000
|
||||
Math.max(0, deadline - Date.now())
|
||||
);
|
||||
const { device } = WarmSessionInfoSchema.parse(claimed);
|
||||
if (
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/steps
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/steps",
|
||||
"version": "27.4.0",
|
||||
"version": "27.4.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -27,8 +27,8 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/eas-build-job": "27.4.0",
|
||||
"@expo/logger": "27.4.0",
|
||||
"@expo/eas-build-job": "27.4.1",
|
||||
"@expo/logger": "27.4.1",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"arg": "^5.0.2",
|
||||
"fast-glob": "^3.3.2",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/template-file
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/template-file",
|
||||
"version": "27.4.0",
|
||||
"version": "27.4.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/turtle-spawn
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/turtle-spawn",
|
||||
"version": "27.4.0",
|
||||
"version": "27.4.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,7 +25,7 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "27.4.0",
|
||||
"@expo/logger": "27.4.1",
|
||||
"@expo/spawn-async": "^1.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/vm-tools
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/vm-tools",
|
||||
"version": "27.4.0",
|
||||
"version": "27.4.1",
|
||||
"description": "VM startup script loader",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/worker
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "27.4.0",
|
||||
"version": "27.4.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,17 +19,17 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "27.4.0",
|
||||
"@expo/build-tools": "27.4.1",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@expo/eas-build-job": "27.4.0",
|
||||
"@expo/logger": "27.4.0",
|
||||
"@expo/eas-build-job": "27.4.1",
|
||||
"@expo/logger": "27.4.1",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "27.4.0",
|
||||
"@expo/template-file": "27.4.0",
|
||||
"@expo/turtle-spawn": "27.4.0",
|
||||
"@expo/steps": "27.4.1",
|
||||
"@expo/template-file": "27.4.1",
|
||||
"@expo/turtle-spawn": "27.4.1",
|
||||
"@hapi/boom": "10.0.1",
|
||||
"check-disk-space": "3.4.0",
|
||||
"fast-glob": "3.3.2",
|
||||
|
||||
@@ -2515,24 +2515,24 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/build-tools@npm:27.4.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
"@expo/build-tools@npm:27.4.1, @expo/build-tools@workspace:packages/build-tools":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/build-tools@workspace:packages/build-tools"
|
||||
dependencies:
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/downloader": "npm:27.4.0"
|
||||
"@expo/eas-build-job": "npm:27.4.0"
|
||||
"@expo/downloader": "npm:27.4.1"
|
||||
"@expo/eas-build-job": "npm:27.4.1"
|
||||
"@expo/env": "npm:^0.4.0"
|
||||
"@expo/logger": "npm:27.4.0"
|
||||
"@expo/logger": "npm:27.4.1"
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/plist": "npm:^0.3.5"
|
||||
"@expo/repack-app": "npm:~0.9.0"
|
||||
"@expo/results": "npm:^1.0.0"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:27.4.0"
|
||||
"@expo/template-file": "npm:27.4.0"
|
||||
"@expo/turtle-spawn": "npm:27.4.0"
|
||||
"@expo/steps": "npm:27.4.1"
|
||||
"@expo/template-file": "npm:27.4.1"
|
||||
"@expo/turtle-spawn": "npm:27.4.1"
|
||||
"@expo/xcpretty": "npm:^4.4.7"
|
||||
"@ngrok/ngrok": "npm:1.7.0"
|
||||
"@sentry/node": "npm:7.77.0"
|
||||
@@ -2768,7 +2768,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/downloader@npm:27.4.0, @expo/downloader@workspace:packages/downloader":
|
||||
"@expo/downloader@npm:27.4.1, @expo/downloader@workspace:packages/downloader":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/downloader@workspace:packages/downloader"
|
||||
dependencies:
|
||||
@@ -2784,13 +2784,13 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-build-job@npm:27.4.0, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
"@expo/eas-build-job@npm:27.4.1, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
|
||||
dependencies:
|
||||
"@expo/logger": "npm:27.4.0"
|
||||
"@expo/logger": "npm:27.4.1"
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/turtle-spawn": "npm:27.4.0"
|
||||
"@expo/turtle-spawn": "npm:27.4.1"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:22.20.5"
|
||||
jest: "npm:^29.7.0"
|
||||
@@ -2803,12 +2803,12 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-json@npm:27.4.0, @expo/eas-json@workspace:packages/eas-json":
|
||||
"@expo/eas-json@npm:27.4.1, @expo/eas-json@workspace:packages/eas-json":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/eas-json@workspace:packages/eas-json"
|
||||
dependencies:
|
||||
"@babel/code-frame": "npm:7.23.5"
|
||||
"@expo/eas-build-job": "npm:27.4.0"
|
||||
"@expo/eas-build-job": "npm:27.4.1"
|
||||
"@tsconfig/node22": "npm:22.0.6"
|
||||
"@types/babel__code-frame": "npm:7.0.3"
|
||||
"@types/fs-extra": "npm:11.0.4"
|
||||
@@ -2931,7 +2931,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/logger@npm:27.4.0, @expo/logger@workspace:packages/logger":
|
||||
"@expo/logger@npm:27.4.1, @expo/logger@workspace:packages/logger":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/logger@workspace:packages/logger"
|
||||
dependencies:
|
||||
@@ -3147,12 +3147,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/steps@npm:27.4.0, @expo/steps@workspace:packages/steps":
|
||||
"@expo/steps@npm:27.4.1, @expo/steps@workspace:packages/steps":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/steps@workspace:packages/steps"
|
||||
dependencies:
|
||||
"@expo/eas-build-job": "npm:27.4.0"
|
||||
"@expo/logger": "npm:27.4.0"
|
||||
"@expo/eas-build-job": "npm:27.4.1"
|
||||
"@expo/logger": "npm:27.4.1"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
@@ -3178,7 +3178,7 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/template-file@npm:27.4.0, @expo/template-file@workspace:packages/template-file":
|
||||
"@expo/template-file@npm:27.4.1, @expo/template-file@workspace:packages/template-file":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/template-file@workspace:packages/template-file"
|
||||
dependencies:
|
||||
@@ -3201,11 +3201,11 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/turtle-spawn@npm:27.4.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
|
||||
"@expo/turtle-spawn@npm:27.4.1, @expo/turtle-spawn@workspace:packages/turtle-spawn":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/turtle-spawn@workspace:packages/turtle-spawn"
|
||||
dependencies:
|
||||
"@expo/logger": "npm:27.4.0"
|
||||
"@expo/logger": "npm:27.4.1"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:22.20.5"
|
||||
@@ -3236,17 +3236,17 @@ __metadata:
|
||||
resolution: "@expo/worker@workspace:packages/worker"
|
||||
dependencies:
|
||||
"@babel/core": "npm:7.24.7"
|
||||
"@expo/build-tools": "npm:27.4.0"
|
||||
"@expo/build-tools": "npm:27.4.1"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/config-types": "npm:55.0.5"
|
||||
"@expo/eas-build-job": "npm:27.4.0"
|
||||
"@expo/logger": "npm:27.4.0"
|
||||
"@expo/eas-build-job": "npm:27.4.1"
|
||||
"@expo/logger": "npm:27.4.1"
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:27.4.0"
|
||||
"@expo/template-file": "npm:27.4.0"
|
||||
"@expo/turtle-spawn": "npm:27.4.0"
|
||||
"@expo/steps": "npm:27.4.1"
|
||||
"@expo/template-file": "npm:27.4.1"
|
||||
"@expo/turtle-spawn": "npm:27.4.1"
|
||||
"@hapi/boom": "npm:10.0.1"
|
||||
"@types/babel__core": "npm:7.20.5"
|
||||
"@types/fs-extra": "npm:11.0.4"
|
||||
@@ -10130,7 +10130,7 @@ __metadata:
|
||||
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
|
||||
dependencies:
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/steps": "npm:27.4.0"
|
||||
"@expo/steps": "npm:27.4.1"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:22.20.5"
|
||||
@@ -10714,12 +10714,12 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
|
||||
dependencies:
|
||||
"@expo/build-tools": "npm:27.4.0"
|
||||
"@expo/build-tools": "npm:27.4.1"
|
||||
"@expo/bunyan": "npm:^4.0.1"
|
||||
"@expo/eas-build-job": "npm:27.4.0"
|
||||
"@expo/logger": "npm:27.4.0"
|
||||
"@expo/eas-build-job": "npm:27.4.1"
|
||||
"@expo/logger": "npm:27.4.1"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@expo/turtle-spawn": "npm:27.4.0"
|
||||
"@expo/turtle-spawn": "npm:27.4.1"
|
||||
"@types/fs-extra": "npm:^11.0.4"
|
||||
"@types/hapi__joi": "npm:^17.1.14"
|
||||
"@types/lodash": "npm:^4.17.4"
|
||||
@@ -10773,12 +10773,12 @@ __metadata:
|
||||
"@expo/code-signing-certificates": "npm:0.0.5"
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/eas-build-job": "npm:27.4.0"
|
||||
"@expo/eas-json": "npm:27.4.0"
|
||||
"@expo/eas-build-job": "npm:27.4.1"
|
||||
"@expo/eas-json": "npm:27.4.1"
|
||||
"@expo/env": "npm:^1.0.0"
|
||||
"@expo/experimentation": "npm:0.0.5"
|
||||
"@expo/json-file": "npm:8.3.3"
|
||||
"@expo/logger": "npm:27.4.0"
|
||||
"@expo/logger": "npm:27.4.1"
|
||||
"@expo/multipart-body-parser": "npm:2.0.0"
|
||||
"@expo/osascript": "npm:2.1.4"
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
@@ -10790,7 +10790,7 @@ __metadata:
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:27.4.0"
|
||||
"@expo/steps": "npm:27.4.1"
|
||||
"@expo/timeago.js": "npm:1.0.0"
|
||||
"@graphql-codegen/cli": "npm:6.3.1"
|
||||
"@graphql-codegen/introspection": "npm:5.0.2"
|
||||
|
||||
Reference in new issue
Block a user