mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
37
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 | ||
|
|
5cca4faff5 | ||
|
|
80fb6df91c | ||
|
|
16ed2a92f7 | ||
|
|
68fd6027b7 | ||
|
|
f15fe03a71 | ||
|
|
5907c64556 | ||
|
|
ddbde07029 | ||
|
|
5e59c64231 | ||
|
|
0efaffd982 | ||
|
|
2dbaa12750 |
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,31 @@
|
||||
|
||||
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
|
||||
|
||||
- [simulator-launcher][build-tools] Add `eas/start_warm_appium_remote_session`, which runs the Appium session on the warm Simulator, or starts cold. An `appium` claim stops the unused tools first, so WDA does not share the Simulator with the agent-device runner. ([#4735](https://github.com/expo/eas-cli/pull/4735) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add warm iOS session steps (`eas/start_warm_serve_sim_remote_session`, `eas/start_warm_agent_device_session`, `eas/start_warm_argent_remote_session`). They run the existing session on the warm Simulator, or start cold. `eas/start_agent_device_session` also accepts `install_app_path` and `launch_app_identifier`. ([#4723](https://github.com/expo/eas-cli/pull/4723) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Update the app version and build number from the final application archive, so the build shows the values the native build resolved. ([#3499](https://github.com/expo/eas-cli/pull/3499) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Upload the final agent-device session artifacts before stopping the daemon, including sessions that reuse a warm Simulator. ([#4750](https://github.com/expo/eas-cli/pull/4750) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add internal helpers that claim the warm Simulator through the launcher and adopt it on the warm serve-sim. Skip the Expo Go install when the warm Simulator already has the same Expo Go version. ([#4724](https://github.com/expo/eas-cli/pull/4724) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Recover warm Simulator recordings when their MP4 exists before the shutdown manifest is ready. ([#4789](https://github.com/expo/eas-cli/pull/4789) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Show the Expo Simulators hint after a successful build from November 10 instead of November 5. ([#4801](https://github.com/expo/eas-cli/pull/4801) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [build-tools] Move iOS Simulator startup behind a shared session host contract. ([#4752](https://github.com/expo/eas-cli/pull/4752) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [27.3.0](https://github.com/expo/eas-cli/releases/tag/v27.3.0) - 2026-11-02
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "27.2.0",
|
||||
"PRODUCTION": "27.2.0"
|
||||
"STAGING": "27.4.1",
|
||||
"PRODUCTION": "27.4.1"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "27.3.0",
|
||||
"version": "27.4.1",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,5 +1,25 @@
|
||||
# @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
|
||||
|
||||
- Add `eas/start_warm_appium_remote_session`, which runs the Appium session on the warm Simulator, or starts cold. An `appium` claim stops the unused tools first, so WDA does not share the Simulator with the agent-device runner. ([#4735](https://github.com/expo/eas-cli/pull/4735) by [@gwdp](https://github.com/gwdp))
|
||||
- Add warm iOS session steps (`eas/start_warm_serve_sim_remote_session`, `eas/start_warm_agent_device_session`, `eas/start_warm_argent_remote_session`). They run the existing session on the warm Simulator, or start cold. `eas/start_agent_device_session` also accepts `install_app_path` and `launch_app_identifier`. ([#4723](https://github.com/expo/eas-cli/pull/4723) by [@gwdp](https://github.com/gwdp))
|
||||
- Update the app version and build number from the final application archive, so the build shows the values the native build resolved. ([#3499](https://github.com/expo/eas-cli/pull/3499) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Upload the final agent-device session artifacts before stopping the daemon, including sessions that reuse a warm Simulator. ([#4750](https://github.com/expo/eas-cli/pull/4750) by [@gwdp](https://github.com/gwdp))
|
||||
- Add internal helpers that claim the warm Simulator through the launcher and adopt it on the warm serve-sim. Skip the Expo Go install when the warm Simulator already has the same Expo Go version. ([#4724](https://github.com/expo/eas-cli/pull/4724) by [@gwdp](https://github.com/gwdp))
|
||||
- Recover warm Simulator recordings when their MP4 exists before the shutdown manifest is ready. ([#4789](https://github.com/expo/eas-cli/pull/4789) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 27.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "27.3.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.3.0",
|
||||
"@expo/eas-build-job": "27.3.0",
|
||||
"@expo/downloader": "27.4.1",
|
||||
"@expo/eas-build-job": "27.4.1",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "27.3.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.3.0",
|
||||
"@expo/template-file": "27.3.0",
|
||||
"@expo/turtle-spawn": "27.3.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 () => {}),
|
||||
};
|
||||
}
|
||||
@@ -53,7 +53,7 @@ jest.mock('../../steps/utils/android/gradleConfig', () => ({
|
||||
injectCredentialsGradleConfig: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/artifacts', () => ({
|
||||
uploadApplicationArchive: jest.fn(),
|
||||
uploadApplicationArchive: jest.fn(async () => []),
|
||||
}));
|
||||
jest.mock('../../utils/expoUpdates', () => ({
|
||||
configureExpoUpdatesIfInstalledAsync: jest.fn(),
|
||||
|
||||
@@ -17,6 +17,10 @@ import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
|
||||
import { prebuildAsync } from '../common/prebuild';
|
||||
import { JobHooksRef, setupAsync } from '../common/setup';
|
||||
import { Artifacts, BuildContext, SkipNativeBuildError } from '../context';
|
||||
import {
|
||||
extractAndroidVersionAsync,
|
||||
updateResolvedVersionAsync,
|
||||
} from '../steps/functions/updateResolvedVersion';
|
||||
import {
|
||||
cacheStatsAsync,
|
||||
restoreCcacheAsync,
|
||||
@@ -220,11 +224,30 @@ async function buildInnerAsync(
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
|
||||
await uploadApplicationArchive(ctx, {
|
||||
const [archivePath] = await uploadApplicationArchive(ctx, {
|
||||
patternOrPath: resolveArtifactPath(ctx.job),
|
||||
rootDir: ctx.getReactNativeProjectDirectory(),
|
||||
logger: ctx.logger,
|
||||
});
|
||||
const buildId = ctx.env.EAS_BUILD_ID;
|
||||
if (!archivePath || !buildId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { appVersion, appBuildVersion } = await extractAndroidVersionAsync(archivePath);
|
||||
if (appVersion || appBuildVersion) {
|
||||
await updateResolvedVersionAsync(ctx.graphqlClient, buildId, {
|
||||
appVersion,
|
||||
appBuildVersion,
|
||||
});
|
||||
ctx.logger.info(
|
||||
`Updated resolved version: ${appVersion ?? 'N/A'} (${appBuildVersion ?? 'N/A'})`
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
Sentry.capture('Failed to update resolved version', err);
|
||||
ctx.logger.warn({ err }, 'Failed to update resolved version (non-fatal)');
|
||||
}
|
||||
});
|
||||
|
||||
if (shouldUploadEmbeddedBundle(ctx)) {
|
||||
|
||||
@@ -18,6 +18,10 @@ import { downloadApplicationArchiveAsync } from '../ios/resign';
|
||||
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
|
||||
import { Sentry } from '../sentry';
|
||||
import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
|
||||
import {
|
||||
extractIosVersionAsync,
|
||||
updateResolvedVersionAsync,
|
||||
} from '../steps/functions/updateResolvedVersion';
|
||||
import {
|
||||
cacheStatsAsync,
|
||||
restoreCcacheAsync,
|
||||
@@ -236,11 +240,30 @@ async function buildInnerAsync(
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
|
||||
await uploadApplicationArchive(ctx, {
|
||||
const [archivePath] = await uploadApplicationArchive(ctx, {
|
||||
patternOrPath: resolveArtifactPath(ctx.job),
|
||||
rootDir: ctx.getReactNativeProjectDirectory(),
|
||||
logger: ctx.logger,
|
||||
});
|
||||
const buildId = ctx.env.EAS_BUILD_ID;
|
||||
if (!archivePath || !buildId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { appVersion, appBuildVersion } = await extractIosVersionAsync(archivePath);
|
||||
if (appVersion || appBuildVersion) {
|
||||
await updateResolvedVersionAsync(ctx.graphqlClient, buildId, {
|
||||
appVersion,
|
||||
appBuildVersion,
|
||||
});
|
||||
ctx.logger.info(
|
||||
`Updated resolved version: ${appVersion ?? 'N/A'} (${appBuildVersion ?? 'N/A'})`
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
Sentry.capture('Failed to update resolved version', err);
|
||||
ctx.logger.warn({ err }, 'Failed to update resolved version (non-fatal)');
|
||||
}
|
||||
});
|
||||
|
||||
if (shouldUploadEmbeddedBundle(ctx)) {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { getEasFunctions } from '../easFunctions';
|
||||
|
||||
it('registers opt-in session functions with the existing inputs', () => {
|
||||
const functions = getEasFunctions({ hasBuildJob: () => false } as CustomBuildContext);
|
||||
for (const name of ['serve_sim_remote', 'agent_device', 'argent_remote', 'appium_remote']) {
|
||||
const existing = functions.find(fn => fn.id === `start_${name}_session`)!;
|
||||
const warm = functions.find(fn => fn.id === `start_warm_${name}_session`)!;
|
||||
expect(warm.getFullId()).toBe(`eas/start_warm_${name}_session`);
|
||||
const warmInputs = warm.inputProviders!.map(input => input(undefined as never, 'warm').id);
|
||||
const existingInputs = existing.inputProviders!.map(
|
||||
input => input(undefined as never, 'existing').id
|
||||
);
|
||||
expect(warmInputs).toEqual(existingInputs);
|
||||
}
|
||||
const ids = functions.map(fn => fn.getFullId());
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
@@ -33,6 +33,7 @@ import { createSubmitToGooglePlayBuildFunction } from './functions/submitToGoogl
|
||||
import { createReadPackageJsonBuildFunction } from './functions/readPackageJson';
|
||||
import { createRepackBuildFunction } from './functions/repack';
|
||||
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
|
||||
import { createUpdateResolvedVersionBuildFunction } from './functions/updateResolvedVersion';
|
||||
import { resolveAppleTeamIdFromCredentialsFunction } from './functions/resolveAppleTeamIdFromCredentials';
|
||||
import { createResolveBuildConfigBuildFunction } from './functions/resolveBuildConfig';
|
||||
import {
|
||||
@@ -61,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';
|
||||
@@ -111,8 +113,11 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
runFastlaneFunction(),
|
||||
parseXcactivitylogFunction(),
|
||||
createStartAgentDeviceSessionBuildFunction(ctx),
|
||||
createStartAgentDeviceSessionBuildFunction(ctx, { warm: true }),
|
||||
createStartArgentRemoteSessionBuildFunction(ctx),
|
||||
createStartArgentRemoteSessionBuildFunction(ctx, { warm: true }),
|
||||
createStartAppiumRemoteSessionBuildFunction(ctx),
|
||||
createStartAppiumRemoteSessionBuildFunction(ctx, { warm: true }),
|
||||
createStartAndroidEmulatorBuildFunction(),
|
||||
createStartCuttlefishDeviceBuildFunction(),
|
||||
createStartIosSimulatorBuildFunction(),
|
||||
@@ -121,9 +126,11 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createFinishIosSimulatorRecordingsBuildFunction(),
|
||||
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
|
||||
createStartWebPreviewRemoteSessionBuildFunction(ctx),
|
||||
createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true }),
|
||||
createStartServeSimMetricsBuildFunction(),
|
||||
createStartServeSimLogsBuildFunction(),
|
||||
createCollectServeSimLogsBuildFunction(ctx),
|
||||
createCollectAndroidEmulatorLogsBuildFunction(ctx),
|
||||
createStartSandboxBuildFunction(ctx),
|
||||
createRunAgentBuildFunction(ctx),
|
||||
createCollectServeSimMetricsBuildFunction(ctx),
|
||||
@@ -154,6 +161,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
functions.push(
|
||||
...[
|
||||
createFindAndUploadBuildArtifactsBuildFunction(ctx),
|
||||
createUpdateResolvedVersionBuildFunction(ctx),
|
||||
createUploadEmbeddedBundleBuildFunction(ctx),
|
||||
createResolveBuildConfigBuildFunction(ctx),
|
||||
createGetCredentialsForBuildTriggeredByGithubIntegration(ctx),
|
||||
|
||||
@@ -130,6 +130,35 @@ describe(createEasBuildBuildFunctionGroup, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Android without credentials', { platform: Platform.ANDROID }],
|
||||
['Android with credentials', { platform: Platform.ANDROID, buildCredentials: { test: {} } }],
|
||||
['iOS simulator', { platform: Platform.IOS, simulator: true }],
|
||||
['iOS with credentials', { platform: Platform.IOS, buildCredentials: { test: {} } }],
|
||||
])('wires the uploaded archive path into the update step (%s)', (_label, overrides) => {
|
||||
const buildToolsContext = createMockBuildToolsContext(overrides);
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
|
||||
|
||||
const updateStep = steps.find(s => s.displayName === 'Update resolved version');
|
||||
const archivePathInput = updateStep?.inputs?.find(
|
||||
input => input.id === 'application_archive_path'
|
||||
);
|
||||
expect(updateStep).toBeDefined();
|
||||
expect(archivePathInput?.rawValue).toBe(
|
||||
'${ steps.find_and_upload_build_artifacts.application_archive_path }'
|
||||
);
|
||||
|
||||
// The step ID must be assigned explicitly: an auto-generated ID makes the input
|
||||
// interpolation throw "Step ... does not exist" before the function body runs.
|
||||
const uploadStepIndex = steps.findIndex(s => s.id === 'find_and_upload_build_artifacts');
|
||||
expect(uploadStepIndex).toBeGreaterThan(-1);
|
||||
expect(steps[uploadStepIndex].displayName).toBe('Find and upload build artifacts');
|
||||
expect(uploadStepIndex).toBeLessThan(steps.indexOf(updateStep!));
|
||||
});
|
||||
|
||||
it('composes working directory with installPods step-level ./ios dir (iOS)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
|
||||
@@ -19,6 +19,7 @@ import { configureIosCredentialsFunction } from '../functions/configureIosCreden
|
||||
import { configureIosVersionFunction } from '../functions/configureIosVersion';
|
||||
import { eagerBundleBuildFunction } from '../functions/eagerBundle';
|
||||
import { createFindAndUploadBuildArtifactsBuildFunction } from '../functions/findAndUploadBuildArtifacts';
|
||||
import { createUpdateResolvedVersionBuildFunction } from '../functions/updateResolvedVersion';
|
||||
import { generateGymfileFromTemplateFunction } from '../functions/generateGymfileFromTemplate';
|
||||
import { injectAndroidCredentialsFunction } from '../functions/injectAndroidCredentials';
|
||||
import { createInstallNodeModulesBuildFunction } from '../functions/installNodeModules';
|
||||
@@ -183,7 +184,20 @@ function createStepsForIosSimulatorBuild({
|
||||
runFastlane,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
).createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'find_and_upload_build_artifacts',
|
||||
workingDirectory,
|
||||
}),
|
||||
createUpdateResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
application_archive_path:
|
||||
'${ steps.find_and_upload_build_artifacts.application_archive_path }',
|
||||
},
|
||||
}
|
||||
),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
@@ -304,7 +318,10 @@ function createStepsForIosBuildWithCredentials({
|
||||
runFastlane,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
).createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'find_and_upload_build_artifacts',
|
||||
workingDirectory,
|
||||
}),
|
||||
...(shouldUploadEmbeddedBundle({
|
||||
env: globalCtx.env,
|
||||
metadata: globalCtx.staticContext.metadata,
|
||||
@@ -318,6 +335,16 @@ function createStepsForIosBuildWithCredentials({
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
createUpdateResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
application_archive_path:
|
||||
'${ steps.find_and_upload_build_artifacts.application_archive_path }',
|
||||
},
|
||||
}
|
||||
),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
@@ -404,7 +431,10 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
runGradle,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
).createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'find_and_upload_build_artifacts',
|
||||
workingDirectory,
|
||||
}),
|
||||
...(shouldUploadEmbeddedBundle({
|
||||
env: globalCtx.env,
|
||||
metadata: globalCtx.staticContext.metadata,
|
||||
@@ -418,6 +448,16 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
createUpdateResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
application_archive_path:
|
||||
'${ steps.find_and_upload_build_artifacts.application_archive_path }',
|
||||
},
|
||||
}
|
||||
),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
@@ -510,7 +550,10 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
: []),
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
).createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'find_and_upload_build_artifacts',
|
||||
workingDirectory,
|
||||
}),
|
||||
...(shouldUploadEmbeddedBundle({
|
||||
env: globalCtx.env,
|
||||
metadata: globalCtx.staticContext.metadata,
|
||||
@@ -524,6 +567,16 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
createUpdateResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
application_archive_path:
|
||||
'${ steps.find_and_upload_build_artifacts.application_archive_path }',
|
||||
},
|
||||
}
|
||||
),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
+53
-13
@@ -2,10 +2,12 @@ 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';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
|
||||
import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
@@ -44,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();
|
||||
@@ -54,6 +76,8 @@ 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.
|
||||
@@ -99,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(() => {
|
||||
@@ -116,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 },
|
||||
@@ -130,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: {},
|
||||
@@ -145,7 +176,11 @@ describe.each([
|
||||
|
||||
it('holds the host until session completion', async () => {
|
||||
await runAsync();
|
||||
expect(openPreview).toHaveBeenCalledWith({ baseDomain: 'example.test', authtoken: 'token' });
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ preview: { baseDomain: 'example.test', authtoken: 'token' } })
|
||||
);
|
||||
expect(openPreview).toHaveBeenCalledWith();
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({
|
||||
@@ -157,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);
|
||||
@@ -302,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);
|
||||
@@ -350,6 +385,10 @@ describe('Appium ffmpeg setup', () => {
|
||||
}
|
||||
|
||||
function holdFfmpegInstall({ appiumError }: { appiumError?: Error } = {}) {
|
||||
let markStarted!: () => void;
|
||||
const started = new Promise<void>(resolve => {
|
||||
markStarted = resolve;
|
||||
});
|
||||
let finish!: (outcome: 'installed' | 'failed') => void;
|
||||
const install = new Promise((resolve, reject) => {
|
||||
finish = outcome =>
|
||||
@@ -360,25 +399,26 @@ describe('Appium ffmpeg setup', () => {
|
||||
return Promise.reject(new Error('ffmpeg missing'));
|
||||
}
|
||||
if (args.includes('install') && args.includes('ffmpeg')) {
|
||||
markStarted();
|
||||
return install;
|
||||
}
|
||||
return command === 'npm' && appiumError
|
||||
? Promise.reject(appiumError)
|
||||
: Promise.resolve({ stdout: '{}' });
|
||||
}) as never);
|
||||
return finish;
|
||||
return { finish, started };
|
||||
}
|
||||
|
||||
it.each(['installed', 'failed'] as const)(
|
||||
'starts Appium after the ffmpeg install settles (%s)',
|
||||
async outcome => {
|
||||
const finishFfmpegInstall = holdFfmpegInstall();
|
||||
const { finish, started } = holdFfmpegInstall();
|
||||
|
||||
const run = runAppiumAsync();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await started;
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
|
||||
finishFfmpegInstall(outcome);
|
||||
finish(outcome);
|
||||
await run;
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
@@ -386,16 +426,16 @@ describe('Appium ffmpeg setup', () => {
|
||||
|
||||
it('waits for the ffmpeg install before failing on an Appium install error', async () => {
|
||||
const appiumError = new Error('npm failed');
|
||||
const finishFfmpegInstall = holdFfmpegInstall({ appiumError });
|
||||
const { finish, started } = holdFfmpegInstall({ appiumError });
|
||||
|
||||
let settled = false;
|
||||
const run = runAppiumAsync().finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await started;
|
||||
expect(settled).toBe(false);
|
||||
|
||||
finishFfmpegInstall('installed');
|
||||
finish('installed');
|
||||
await expect(run).rejects.toBe(appiumError);
|
||||
});
|
||||
});
|
||||
+18
@@ -51,6 +51,24 @@ describe(createFindAndUploadBuildArtifactsBuildFunction, () => {
|
||||
await expect(buildStep.executeAsync()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('sets application_archive_path output to first archive', async () => {
|
||||
const globalContext = createGlobalContextMock({});
|
||||
vol.fromJSON(
|
||||
{
|
||||
'ios/build/test.ipa': '',
|
||||
},
|
||||
globalContext.defaultWorkingDirectory
|
||||
);
|
||||
const buildStep = findAndUploadBuildArtifacts.createBuildStepFromFunctionCall(
|
||||
globalContext,
|
||||
{}
|
||||
);
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.outputById.application_archive_path.value).toMatch(/ios\/build\/test\.ipa$/);
|
||||
});
|
||||
|
||||
it('throws build artifacts error', async () => {
|
||||
const globalContext = createGlobalContextMock({});
|
||||
ctx.job.buildArtifactPaths = ['worker.log'];
|
||||
|
||||
+42
-18
@@ -7,6 +7,7 @@ import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
monitorLocalEgressAsync,
|
||||
@@ -82,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(),
|
||||
@@ -124,6 +145,8 @@ 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);
|
||||
@@ -166,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);
|
||||
});
|
||||
@@ -179,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);
|
||||
@@ -188,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,
|
||||
@@ -211,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);
|
||||
@@ -230,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);
|
||||
}
|
||||
@@ -244,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);
|
||||
});
|
||||
|
||||
+332
-24
@@ -1,14 +1,18 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fetch from 'node-fetch';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../../utils/deviceRunSessionArtifacts';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
DeviceRunSessionStoppedError,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
@@ -21,6 +25,8 @@ import {
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createProcessOutput } from '../../utils/processOutput';
|
||||
import { type StartupTasks, createStartupTasks } from '../../utils/startupTasks';
|
||||
import { createWarmDeviceSessionServer } from '../../utils/warmDeviceSessionHost';
|
||||
import { type WarmIosSession } from '../../utils/warmIosSession';
|
||||
import { runAgentDeviceRemoteSessionAsync } from '../startAgentDeviceRemoteSession';
|
||||
|
||||
// The daemon entry path and the state directory are resolved from the home directory when
|
||||
@@ -38,10 +44,14 @@ jest.mock('../../../sentry');
|
||||
jest.mock('../../utils/agentDeviceArtifacts', () => ({
|
||||
pollAgentDeviceArtifactsForUploadAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/deviceRunSessionArtifacts', () => ({
|
||||
uploadDeviceRunSessionArtifactAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/agentDeviceEvents', () => ({
|
||||
startAgentDeviceEventCollectionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/warmDeviceSessionHost');
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
getDeviceRunSessionIdOrThrow: jest.fn(),
|
||||
@@ -74,10 +84,7 @@ const NO_CAPTURE: CaptureInputs = { networkCapture: false, networkCaptureFields:
|
||||
async function runAsync(
|
||||
logger: { info: jest.Mock; warn: jest.Mock; child: jest.Mock },
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
capture: CaptureInputs = NO_CAPTURE,
|
||||
disableShare = false,
|
||||
privatePreviewUrl?: string,
|
||||
maxIdleTimeMinutes?: number
|
||||
options: Partial<Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]> = {}
|
||||
): Promise<void> {
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env: {},
|
||||
@@ -89,16 +96,15 @@ async function runAsync(
|
||||
ngrokAuthtoken: 'ngrok-token',
|
||||
},
|
||||
packageVersion: undefined,
|
||||
maxIdleTimeMinutes,
|
||||
maxIdleTimeMinutes: undefined,
|
||||
maxDurationSeconds: undefined,
|
||||
capture,
|
||||
disableShare,
|
||||
privatePreviewUrl,
|
||||
tasks: createStartupTasks(logger as never),
|
||||
capture: NO_CAPTURE,
|
||||
tasks: createStartupTasks(logger as never, options.signal),
|
||||
device:
|
||||
runtimePlatform === BuildRuntimePlatform.DARWIN
|
||||
? { iosSimulatorUdid: 'selected-udid', application: Promise.resolve() }
|
||||
: { booted: Promise.resolve(), ready: Promise.resolve() },
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -195,6 +201,213 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses the warm daemon after adoption and starts no daemon of its own', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const warmServer = { port: 3200 } as never;
|
||||
jest.mocked(createWarmDeviceSessionServer).mockReturnValue(warmServer);
|
||||
const warm = {
|
||||
agentDeviceStateDir: '/warm/agent',
|
||||
tools: { pid: 20, port: 4310, token: 'warm-token' },
|
||||
} as WarmIosSession;
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env: {},
|
||||
logger: logger as never,
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
sessionEnv: {
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
ngrokTunnelDomain: 'tunnel.example.com',
|
||||
ngrokAuthtoken: 'ngrok-token',
|
||||
},
|
||||
packageVersion: undefined,
|
||||
maxIdleTimeMinutes: undefined,
|
||||
maxDurationSeconds: undefined,
|
||||
capture: NO_CAPTURE,
|
||||
tasks: createStartupTasks(logger as never),
|
||||
warm,
|
||||
device: { iosSimulatorUdid: 'UDID', application: Promise.resolve() },
|
||||
});
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(ctx, expect.anything(), warmServer);
|
||||
expect(startNgrokTunnelAsync).toHaveBeenCalledWith(expect.objectContaining({ port: 4310 }));
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({ agentDeviceRemoteSessionToken: 'warm-token' }),
|
||||
})
|
||||
);
|
||||
expect(startAgentDeviceEventCollectionAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ stateDir: '/warm/agent' })
|
||||
);
|
||||
expect(mockEventCollectionStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'finishes the final artifact upload before stopping its owner (warm=%s)',
|
||||
async warm => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const { pollAgentDeviceArtifactsForUploadAsync: pollActual } = jest.requireActual<
|
||||
typeof import('../../utils/agentDeviceArtifacts')
|
||||
>('../../utils/agentDeviceArtifacts');
|
||||
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockImplementation(pollActual);
|
||||
const { Response } = jest.requireActual<typeof import('node-fetch')>('node-fetch');
|
||||
const artifacts: { id: string; filename: string }[] = [];
|
||||
let releaseUpload = () => {};
|
||||
const pendingUpload = new Promise<void>(resolve => {
|
||||
releaseUpload = resolve;
|
||||
});
|
||||
let markUploadStarted = () => {};
|
||||
const uploadStarted = new Promise<void>(resolve => {
|
||||
markUploadStarted = resolve;
|
||||
});
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementationOnce(async () => {
|
||||
markUploadStarted();
|
||||
await pendingUpload;
|
||||
});
|
||||
let inventories = 0;
|
||||
jest.mocked(fetch).mockImplementation(async url => {
|
||||
if (String(url).endsWith('/artifacts')) {
|
||||
inventories += 1;
|
||||
return new Response(JSON.stringify({ artifacts }));
|
||||
}
|
||||
return new Response(Readable.from([Buffer.from('screenshot')]));
|
||||
});
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
|
||||
while (inventories === 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
// The next periodic inventory is 5 seconds away, so only the final one can see this file.
|
||||
artifacts.push({ id: 'late', filename: 'late.png' });
|
||||
});
|
||||
|
||||
const running = runAsync(
|
||||
logger,
|
||||
warm ? BuildRuntimePlatform.DARWIN : BuildRuntimePlatform.LINUX,
|
||||
{
|
||||
warm: warm
|
||||
? ({
|
||||
agentDeviceStateDir: '/warm/agent',
|
||||
tools: { port: 4310, token: 'warm-token' },
|
||||
} as WarmIosSession)
|
||||
: undefined,
|
||||
}
|
||||
);
|
||||
try {
|
||||
await uploadStarted;
|
||||
expect(mockDaemonStopAsync).not.toHaveBeenCalled();
|
||||
if (warm) {
|
||||
expect(mockPreviewStopAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
} finally {
|
||||
releaseUpload();
|
||||
await running;
|
||||
}
|
||||
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ artifactId: 'late', filename: 'late.png' })
|
||||
);
|
||||
expect(inventories).toBe(2);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(warm ? 0 : 1);
|
||||
}
|
||||
);
|
||||
|
||||
it('stops the daemon when the final artifact drain fails', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const failure = new Error('drain failed');
|
||||
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockRejectedValue(failure);
|
||||
|
||||
await expect(runAsync(logger, BuildRuntimePlatform.LINUX)).rejects.toBe(failure);
|
||||
|
||||
expect(pollAgentDeviceArtifactsForUploadAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ stopPollingSignal: expect.any(AbortSignal) })
|
||||
);
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'stops the warm host after a failed drain (collector startup fails=%s)',
|
||||
async startupFails => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const drainFailure = new Error('drain failed');
|
||||
const startupFailure = new Error('event collector failed');
|
||||
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockRejectedValueOnce(drainFailure);
|
||||
if (startupFails) {
|
||||
jest.mocked(startAgentDeviceEventCollectionAsync).mockRejectedValueOnce(startupFailure);
|
||||
}
|
||||
await expect(
|
||||
runAsync(logger, BuildRuntimePlatform.DARWIN, {
|
||||
warm: {
|
||||
agentDeviceStateDir: '/warm/agent',
|
||||
tools: { port: 4310, token: 'warm-token' },
|
||||
} as WarmIosSession,
|
||||
})
|
||||
).rejects.toBe(startupFails ? startupFailure : drainFailure);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaemonStopAsync).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: drainFailure },
|
||||
'Could not stop the agent-device daemon during remote session teardown.'
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('cancels a warm upload and drains its poller before stopping the host', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const controller = new AbortController();
|
||||
const cancellation = new Error('job cancelled');
|
||||
const { pollAgentDeviceArtifactsForUploadAsync: pollActual } = jest.requireActual<
|
||||
typeof import('../../utils/agentDeviceArtifacts')
|
||||
>('../../utils/agentDeviceArtifacts');
|
||||
const pollingFinished = jest.fn();
|
||||
jest
|
||||
.mocked(pollAgentDeviceArtifactsForUploadAsync)
|
||||
.mockImplementation((...args) => pollActual(...args).finally(pollingFinished));
|
||||
const { Response } = jest.requireActual<typeof import('node-fetch')>('node-fetch');
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockImplementation(async url =>
|
||||
String(url).endsWith('/artifacts')
|
||||
? new Response(JSON.stringify({ artifacts: [{ id: 'active', filename: 'active.png' }] }))
|
||||
: new Response(Readable.from(['png']))
|
||||
);
|
||||
let markUploadStarted = () => {};
|
||||
const uploadStarted = new Promise<void>(resolve => {
|
||||
markUploadStarted = resolve;
|
||||
});
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { signal }) => {
|
||||
markUploadStarted();
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
});
|
||||
});
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementationOnce(async () => {
|
||||
await uploadStarted;
|
||||
controller.abort(cancellation);
|
||||
throw cancellation;
|
||||
});
|
||||
await expect(
|
||||
runAsync(logger, BuildRuntimePlatform.DARWIN, {
|
||||
signal: controller.signal,
|
||||
warm: {
|
||||
agentDeviceStateDir: '/warm/agent',
|
||||
tools: { port: 4310, token: 'warm-token' },
|
||||
} as WarmIosSession,
|
||||
})
|
||||
).rejects.toBe(cancellation);
|
||||
expect(pollingFinished).toHaveBeenCalledTimes(1);
|
||||
expect(pollingFinished.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockPreviewStopAsync.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaemonStopAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redacts learned daemon credentials in arbitrary output and retained diagnostics', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const token = 'daemon secret/+';
|
||||
@@ -224,7 +437,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
} as never);
|
||||
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(previewInputAt);
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.LINUX, NO_CAPTURE, false, undefined, 5);
|
||||
await runAsync(logger, BuildRuntimePlatform.LINUX, { maxIdleTimeMinutes: 5 });
|
||||
|
||||
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
|
||||
@@ -235,8 +448,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
|
||||
networkCapture: true,
|
||||
networkCaptureFields: ['header'],
|
||||
capture: { networkCapture: true, networkCaptureFields: ['header'] },
|
||||
});
|
||||
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
@@ -247,13 +459,10 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
|
||||
it('hands a private preview choice to the session host', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
await runAsync(
|
||||
logger,
|
||||
BuildRuntimePlatform.DARWIN,
|
||||
NO_CAPTURE,
|
||||
true,
|
||||
'https://expo.dev/private-preview'
|
||||
);
|
||||
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
|
||||
disableShare: true,
|
||||
privatePreviewUrl: 'https://expo.dev/private-preview',
|
||||
});
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
@@ -349,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(),
|
||||
@@ -377,6 +587,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
capture: NO_CAPTURE,
|
||||
tasks,
|
||||
device: sessionDevice,
|
||||
onStarted,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -615,10 +826,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
ctx,
|
||||
{} as never
|
||||
);
|
||||
const preview = await host.openPreviewAsync({
|
||||
baseDomain: 'tunnel.example.com',
|
||||
authtoken: 'ngrok-token',
|
||||
});
|
||||
const preview = await host.openPreviewAsync();
|
||||
jest.mocked(host.openPreviewAsync).mockImplementation(async () => {
|
||||
await previewReady.promise;
|
||||
return preview;
|
||||
@@ -652,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 });
|
||||
|
||||
+471
-236
@@ -4,14 +4,18 @@ import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { DeviceRunSessionWorkerApi } from '../../utils/deviceRunSessionWorkerApi';
|
||||
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';
|
||||
@@ -20,6 +24,7 @@ import {
|
||||
getAgentDeviceRemoteSessionEnvOrThrow,
|
||||
runAgentDeviceRemoteSessionAsync,
|
||||
} from '../startAgentDeviceRemoteSession';
|
||||
import { runOnWarmIosSimulatorAsync } from '../../utils/warmDeviceSessionHost';
|
||||
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
|
||||
import { startAndroidEmulatorAsync } from '../startAndroidEmulator';
|
||||
import { bootIosSimulatorAsync } from '../startIosSimulator';
|
||||
@@ -32,16 +37,19 @@ 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'),
|
||||
bootIosSimulatorAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../startAndroidEmulator', () => ({ startAndroidEmulatorAsync: jest.fn() }));
|
||||
jest.mock('../../utils/warmDeviceSessionHost');
|
||||
jest.mock('../downloadBuild', () => ({ downloadBuildAsync: jest.fn() }));
|
||||
jest.mock('../installBuild', () => ({ installBuildAsync: jest.fn() }));
|
||||
jest.mock('../../utils/iosAppArtifact', () => ({
|
||||
@@ -102,41 +110,29 @@ function runStep(
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
inputValues: Record<string, unknown> = {},
|
||||
signal?: AbortSignal,
|
||||
stepCtx: CustomBuildContext = ctx
|
||||
stepCtx: CustomBuildContext = ctx,
|
||||
warm = false,
|
||||
env: NodeJS.ProcessEnv = {}
|
||||
): Promise<void> {
|
||||
const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx);
|
||||
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',
|
||||
'build_id',
|
||||
'application_archive_url',
|
||||
'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>;
|
||||
}
|
||||
|
||||
@@ -145,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);
|
||||
@@ -157,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 }) => {
|
||||
@@ -172,7 +199,6 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
emulatorPromise: Promise.resolve(),
|
||||
shouldAdjustAnimationScale: true,
|
||||
});
|
||||
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/App.app' });
|
||||
jest
|
||||
.mocked(installBuildAsync)
|
||||
.mockResolvedValue({ applicationIdentifier: 'dev.example.app', activityName: '.Main' });
|
||||
@@ -180,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',
|
||||
@@ -188,7 +251,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
};
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(bootEnv);
|
||||
await runStep(BuildRuntimePlatform.DARWIN, {
|
||||
build_id: 'build-id',
|
||||
install_app_path: '/tmp/App.app',
|
||||
launch_args: ['-flag'],
|
||||
open_url: 'exp://example.test',
|
||||
});
|
||||
@@ -202,14 +265,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
buildId: 'build-id',
|
||||
extensions: ['app'],
|
||||
graphqlClient,
|
||||
robotAccessToken: 'robot-token',
|
||||
})
|
||||
);
|
||||
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(1);
|
||||
const device = sessionDevice();
|
||||
expect(device).toEqual({
|
||||
@@ -253,63 +309,48 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('downloads build_id through the device run session worker API when the job has a session token', async () => {
|
||||
const sessionCtx = {
|
||||
graphqlClient,
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
} as unknown as CustomBuildContext;
|
||||
|
||||
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' }, undefined, sessionCtx);
|
||||
|
||||
expect(downloadBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
buildId: 'build-id',
|
||||
deviceRunSessionWorkerApi: expect.any(DeviceRunSessionWorkerApi),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not use the worker API without a session token or for an archive URL', async () => {
|
||||
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' });
|
||||
expect(downloadBuildAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ buildId: 'build-id', deviceRunSessionWorkerApi: null })
|
||||
);
|
||||
|
||||
const sessionCtx = {
|
||||
graphqlClient,
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
} as unknown as CustomBuildContext;
|
||||
await runStep(
|
||||
BuildRuntimePlatform.LINUX,
|
||||
{ application_archive_url: 'https://example.test/app.tar.gz' },
|
||||
undefined,
|
||||
sessionCtx
|
||||
);
|
||||
expect(downloadBuildAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
applicationArchiveUrl: 'https://example.test/app.tar.gz',
|
||||
deviceRunSessionWorkerApi: null,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('fails before download or host startup when guarded device shutdown fails', async () => {
|
||||
it('fails before app installation or host startup when guarded device shutdown fails', async () => {
|
||||
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).mockRejectedValue(new Error('shutdown failed'));
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'shutdown failed'
|
||||
);
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app' })
|
||||
).rejects.toThrow('shutdown failed');
|
||||
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('provides Simulator preparation and downloaded startup options to the remote session', async () => {
|
||||
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({
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
@@ -324,7 +365,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
});
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, {
|
||||
device_identifier: 'iPhone 17',
|
||||
build_id: 'build-id',
|
||||
install_app_path: '/tmp/App.app',
|
||||
launch_args: ['-flag'],
|
||||
open_url: 'exp://example.test',
|
||||
});
|
||||
@@ -334,7 +375,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
env: {},
|
||||
});
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
|
||||
artifactPath: '/tmp/App.app',
|
||||
env: {},
|
||||
@@ -352,15 +393,15 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('fails a guarded session before download or host startup if its boot files are missing', async () => {
|
||||
it('fails a guarded session before app installation or host startup if its boot files are missing', async () => {
|
||||
jest
|
||||
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
|
||||
.mockRejectedValue(new Error('missing bin/egress-guard-check'));
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'missing bin/egress-guard-check'
|
||||
);
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app' })
|
||||
).rejects.toThrow('missing bin/egress-guard-check');
|
||||
expect(startLocalEgressGuardRelayAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
@@ -375,37 +416,21 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
jest
|
||||
.mocked(runAgentDeviceRemoteSessionAsync)
|
||||
.mockRejectedValueOnce(new Error('guard check failed'));
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'guard check failed'
|
||||
);
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app' })
|
||||
).rejects.toThrow('guard check failed');
|
||||
expect(startLocalEgressGuardRelayAsync).toHaveBeenCalledTimes(1);
|
||||
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits for the download before handing the app to the host', async () => {
|
||||
const download = deferred<{ artifactPath: string }>();
|
||||
jest.mocked(downloadBuildAsync).mockReturnValue(download.promise);
|
||||
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, {
|
||||
application_archive_url: 'https://example.test/app.tar.gz',
|
||||
});
|
||||
await flushAsync();
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
|
||||
download.resolve({ artifactPath: '/tmp/App.app' });
|
||||
await step;
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes the selected Simulator to the host even when there is no app', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN);
|
||||
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
expect(getAndroidEmulatorLogCapture).not.toHaveBeenCalled();
|
||||
expect(sessionDevice()).toEqual({
|
||||
iosSimulatorUdid: 'SELECTED-UDID',
|
||||
bootEnv: undefined,
|
||||
@@ -413,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',
|
||||
@@ -420,7 +487,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
lcd_width: 720,
|
||||
lcd_height: 1600,
|
||||
lcd_density: 300,
|
||||
build_id: 'build-id',
|
||||
install_app_path: '/tmp/App.app',
|
||||
});
|
||||
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
@@ -433,67 +500,21 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
lcdDensity: 300,
|
||||
})
|
||||
);
|
||||
expect(downloadBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ extensions: ['apk'] })
|
||||
);
|
||||
|
||||
expect(launchApplicationAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ applicationIdentifier: 'dev.example.app', activityName: '.Main' })
|
||||
);
|
||||
});
|
||||
|
||||
it('fails the session when the download fails, without an unhandled rejection', async () => {
|
||||
jest.mocked(downloadBuildAsync).mockRejectedValue(new Error('download failed'));
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'download failed'
|
||||
);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/** A download that runs until its abort signal fires, like a stalled one. */
|
||||
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 };
|
||||
}
|
||||
|
||||
/** Runs the session like the real one: fails with the daemon, then waits for every part. */
|
||||
function mockSessionWithFailingDaemon(beforeFailure?: () => Promise<void>): void {
|
||||
jest
|
||||
.mocked(runAgentDeviceRemoteSessionAsync)
|
||||
.mockImplementation(async (_ctx, { tasks, device }) => {
|
||||
const ready = runDevicePreparationAsync(device);
|
||||
await beforeFailure?.();
|
||||
const daemon = tasks.run('agent-device daemon', async () => {
|
||||
throw new Error('daemon failed');
|
||||
});
|
||||
try {
|
||||
await Promise.all([daemon, ready]);
|
||||
} finally {
|
||||
await Promise.allSettled([daemon, ready]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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' })).rejects.toThrow(
|
||||
'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.
|
||||
@@ -501,40 +522,107 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops a stalled download and does not install when the daemon fails', async () => {
|
||||
const download = mockStalledDownload();
|
||||
mockSessionWithFailingDaemon();
|
||||
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'daemon failed'
|
||||
);
|
||||
|
||||
expect(download.aborted()).toBe(true);
|
||||
expect(download.settled()).toBe(true);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drains the download when host startup fails before app preparation', async () => {
|
||||
const download = mockStalledDownload();
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mockRejectedValue(new Error('host failed'));
|
||||
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'host failed'
|
||||
);
|
||||
expect(download.aborted()).toBe(true);
|
||||
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'));
|
||||
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal)
|
||||
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app' }, controller.signal)
|
||||
).rejects.toThrow('cancelled');
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -553,11 +641,15 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not start device or download work after cancellation during Xcode selection', async () => {
|
||||
it('does not start device or app work after cancellation during Xcode selection', async () => {
|
||||
const controller = new AbortController();
|
||||
const selected = deferred();
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockReturnValue(selected.promise);
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal);
|
||||
const step = runStep(
|
||||
BuildRuntimePlatform.DARWIN,
|
||||
{ install_app_path: '/tmp/App.app' },
|
||||
controller.signal
|
||||
);
|
||||
const failure = expect(step).rejects.toThrow('cancelled');
|
||||
await flushAsync();
|
||||
controller.abort(new Error('cancelled'));
|
||||
@@ -566,42 +658,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
await failure;
|
||||
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drains app preparation when the daemon fails', async () => {
|
||||
const metadata = deferred<string>();
|
||||
const metadataStarted = deferred();
|
||||
jest.mocked(readIosApplicationIdentifierAsync).mockImplementation(() => {
|
||||
metadataStarted.resolve();
|
||||
return metadata.promise;
|
||||
});
|
||||
mockSessionWithFailingDaemon(async () => {
|
||||
await metadataStarted.promise;
|
||||
setImmediate(() => metadata.resolve('dev.example.app'));
|
||||
});
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'daemon failed'
|
||||
);
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
expect(deviceLaunch).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects conflicting application inputs before it boots anything', async () => {
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, {
|
||||
build_id: 'build-id',
|
||||
application_archive_url: 'https://example.test/app.tar.gz',
|
||||
})
|
||||
).rejects.toThrow('Pass only one of build_id or application_archive_url.');
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { open_url: 'exp://example.test' })
|
||||
).rejects.toThrow('launch_args and open_url need an application');
|
||||
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -612,13 +669,16 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual([
|
||||
'android_log_sources',
|
||||
'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',
|
||||
@@ -678,12 +738,15 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
['NUL URL', { open_url: 'example:\u0000screen' }],
|
||||
['URL length', { open_url: `example://${'a'.repeat(8192)}` }],
|
||||
])('rejects iOS launch %s before startup work', async (_name, inputs) => {
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id', ...inputs });
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, {
|
||||
install_app_path: '/tmp/App.app',
|
||||
...inputs,
|
||||
});
|
||||
await expect(step).rejects.toBeInstanceOf(UserError);
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -692,16 +755,22 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
['command length', { launch_args: ['--es', 'quotes', "'".repeat(20_000)] }],
|
||||
['NUL URL', { open_url: 'example:\u0000screen' }],
|
||||
])('rejects Android launch %s before startup work', async (_name, inputs) => {
|
||||
const step = runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', ...inputs });
|
||||
const step = runStep(BuildRuntimePlatform.LINUX, {
|
||||
install_app_path: '/tmp/App.app',
|
||||
...inputs,
|
||||
});
|
||||
await expect(step).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
|
||||
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps Android launch inputs on its existing direct path', async () => {
|
||||
const launchArgs = ['a'.repeat(9000)];
|
||||
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', launch_args: launchArgs });
|
||||
await runStep(BuildRuntimePlatform.LINUX, {
|
||||
install_app_path: '/tmp/App.app',
|
||||
launch_args: launchArgs,
|
||||
});
|
||||
expect(launchApplicationAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, launchArgs })
|
||||
);
|
||||
@@ -728,4 +797,170 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
it('requires an application for launch options before starting the device', async () => {
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { open_url: 'fixture://route' })
|
||||
).rejects.toThrow('pass install_app_path or launch_app_identifier');
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('launches an installed iOS application without reading an app artifact', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN, {
|
||||
launch_app_identifier: 'dev.installed.app',
|
||||
launch_args: ['--test'],
|
||||
open_url: 'fixture://route',
|
||||
});
|
||||
expect(readIosApplicationIdentifierAsync).not.toHaveBeenCalled();
|
||||
expect(deviceLaunch).toEqual({
|
||||
installAppPath: undefined,
|
||||
launchAppIdentifier: 'dev.installed.app',
|
||||
launchArgs: ['--test'],
|
||||
openUrl: 'fixture://route',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps explicit application identifier launch iOS-only', async () => {
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.LINUX, { launch_app_identifier: 'dev.app' })
|
||||
).rejects.toThrow('Pass install_app_path for Android');
|
||||
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still downloads a build_id app and launches it on iOS', async () => {
|
||||
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/Downloaded.app' });
|
||||
await runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id', launch_args: ['-flag'] });
|
||||
expect(downloadBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ buildId: 'build-id', extensions: ['app'] })
|
||||
);
|
||||
expect(deviceLaunch).toEqual({
|
||||
installAppPath: '/tmp/Downloaded.app',
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
launchArgs: ['-flag'],
|
||||
openUrl: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('still downloads an application_archive_url app and installs it on Android', async () => {
|
||||
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/Downloaded.apk' });
|
||||
await runStep(BuildRuntimePlatform.LINUX, {
|
||||
application_archive_url: 'https://example.test/app.tar.gz',
|
||||
});
|
||||
expect(downloadBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
applicationArchiveUrl: 'https://example.test/app.tar.gz',
|
||||
extensions: ['apk'],
|
||||
})
|
||||
);
|
||||
expect(installBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ artifactPath: '/tmp/Downloaded.apk' })
|
||||
);
|
||||
expect(launchApplicationAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('runs the warm step on the warm Simulator for a local app only', async () => {
|
||||
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation((_claim, run) => run());
|
||||
const runWarm = (inputs: Record<string, unknown>) =>
|
||||
runStep(BuildRuntimePlatform.DARWIN, inputs, undefined, ctx, true);
|
||||
await runWarm({ install_app_path: '/tmp/App.app', device_identifier: 'iPhone 17' });
|
||||
expect(runOnWarmIosSimulatorAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ engine: 'agent-device', requestedDevice: 'iPhone 17' }),
|
||||
expect.any(Function)
|
||||
);
|
||||
jest.mocked(runOnWarmIosSimulatorAsync).mockClear();
|
||||
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/Downloaded.app' });
|
||||
await runWarm({ build_id: 'build-id' });
|
||||
expect(runOnWarmIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('accepts only one application source', async () => {
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app', build_id: 'id' })
|
||||
).rejects.toThrow('Pass only one of install_app_path, build_id or application_archive_url.');
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits for Android boot before installing the supplied local APK', async () => {
|
||||
const boot = deferred();
|
||||
jest.mocked(startAndroidEmulatorAsync).mockReturnValue(boot.promise as never);
|
||||
const step = runStep(BuildRuntimePlatform.LINUX, { install_app_path: '/tmp/App.apk' });
|
||||
await flushAsync();
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
boot.resolve();
|
||||
await step;
|
||||
expect(installBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ artifactPath: '/tmp/App.apk' })
|
||||
);
|
||||
expect(launchApplicationAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('drains an in-flight Android install on cancellation and never launches it', async () => {
|
||||
const controller = new AbortController();
|
||||
const installation = deferred<{ applicationIdentifier: string; activityName: string }>();
|
||||
jest.mocked(installBuildAsync).mockReturnValue(installation.promise);
|
||||
const step = runStep(
|
||||
BuildRuntimePlatform.LINUX,
|
||||
{ install_app_path: '/tmp/App.apk' },
|
||||
controller.signal
|
||||
);
|
||||
let settled = false;
|
||||
void step.then(
|
||||
() => (settled = true),
|
||||
() => (settled = true)
|
||||
);
|
||||
const failure = expect(step).rejects.toThrow('cancelled');
|
||||
await flushAsync();
|
||||
expect(installBuildAsync).toHaveBeenCalledTimes(1);
|
||||
controller.abort(new Error('cancelled'));
|
||||
await flushAsync();
|
||||
expect(settled).toBe(false);
|
||||
installation.resolve({ applicationIdentifier: 'dev.app', activityName: '.Main' });
|
||||
await failure;
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drains an in-flight Android install when session startup fails', async () => {
|
||||
const entered = deferred();
|
||||
const installation = deferred<{ applicationIdentifier: string; activityName: string }>();
|
||||
jest.mocked(installBuildAsync).mockImplementation(() => {
|
||||
entered.resolve();
|
||||
return installation.promise;
|
||||
});
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async () => {
|
||||
await entered.promise;
|
||||
throw new Error('session startup failed');
|
||||
});
|
||||
const step = runStep(BuildRuntimePlatform.LINUX, { install_app_path: '/tmp/App.apk' });
|
||||
let settled = false;
|
||||
void step.then(
|
||||
() => (settled = true),
|
||||
() => (settled = true)
|
||||
);
|
||||
const failure = expect(step).rejects.toThrow('session startup failed');
|
||||
await entered.promise;
|
||||
await flushAsync();
|
||||
expect(settled).toBe(false);
|
||||
installation.resolve({ applicationIdentifier: 'dev.app', activityName: '.Main' });
|
||||
await failure;
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drains iOS metadata reading on cancellation without starting the session', async () => {
|
||||
const controller = new AbortController();
|
||||
const metadata = deferred<string>();
|
||||
jest.mocked(readIosApplicationIdentifierAsync).mockReturnValue(metadata.promise);
|
||||
const step = runStep(
|
||||
BuildRuntimePlatform.DARWIN,
|
||||
{ install_app_path: '/tmp/App.app' },
|
||||
controller.signal
|
||||
);
|
||||
const failure = expect(step).rejects.toThrow('cancelled');
|
||||
await flushAsync();
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
|
||||
controller.abort(new Error('cancelled'));
|
||||
metadata.resolve('dev.app');
|
||||
await failure;
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
+639
-6
@@ -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,12 @@ 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,
|
||||
} from '../../utils/warmDeviceSessionHost';
|
||||
import * as warmIosSession from '../../utils/warmIosSession';
|
||||
import {
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
@@ -20,6 +28,8 @@ import {
|
||||
startNgrokTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { watchDeviceRunSessionStartup } from '../../utils/deviceRunSessionStartup';
|
||||
import { createStartupWatchMock } from '../../../__tests__/utils/startupWatch';
|
||||
|
||||
import {
|
||||
createStartAppiumRemoteSessionBuildFunction,
|
||||
@@ -28,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() },
|
||||
}));
|
||||
@@ -46,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', () => ({
|
||||
@@ -58,6 +75,14 @@ jest.mock('../../../utils/turtleFetch', () => ({
|
||||
}));
|
||||
jest.mock('../../utils/appiumEvents', () => ({ startAppiumEventCollectionAsync: jest.fn() }));
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/warmDeviceSessionHost', () => ({
|
||||
...jest.createMockFromModule<typeof import('../../utils/warmDeviceSessionHost')>(
|
||||
'../../utils/warmDeviceSessionHost'
|
||||
),
|
||||
WarmSessionAdoptionError: jest.requireActual<typeof import('../../utils/warmDeviceSessionHost')>(
|
||||
'../../utils/warmDeviceSessionHost'
|
||||
).WarmSessionAdoptionError,
|
||||
}));
|
||||
jest.mock('../../utils/preinstalledWda', () => ({ preparePreinstalledWdaAsync: jest.fn() }));
|
||||
jest.mock('../../utils/iosAppArtifact', () => ({
|
||||
...jest.requireActual('../../utils/iosAppArtifact'),
|
||||
@@ -93,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 () => {
|
||||
@@ -145,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({}) };
|
||||
@@ -275,6 +302,36 @@ describe('createStartAppiumRemoteSessionBuildFunction', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('claims the warm Simulator for Appium without its package version', async () => {
|
||||
jest.mocked(runOnWarmIosSimulatorAsync).mockResolvedValue(undefined);
|
||||
const buildFunction = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext, {
|
||||
warm: true,
|
||||
});
|
||||
expect(buildFunction.id).toBe('start_warm_appium_remote_session');
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: '3.5.0' },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
device_identifier: { value: 'iPhone 17' },
|
||||
},
|
||||
outputs: {},
|
||||
env: {
|
||||
DEVICE_RUN_SESSION_ID: 'session',
|
||||
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'tunnel.example',
|
||||
NGROK_AUTHTOKEN: 'ngrok-token',
|
||||
},
|
||||
} as never
|
||||
);
|
||||
const [claim] = jest.mocked(runOnWarmIosSimulatorAsync).mock.calls[0];
|
||||
expect(claim).toMatchObject({ engine: 'appium', requestedDevice: 'iPhone 17' });
|
||||
expect(claim).not.toHaveProperty('packageVersion');
|
||||
});
|
||||
|
||||
it('declares the launch inputs so serve-sim can launch the app', () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const buildFunction = createStartAppiumRemoteSessionBuildFunction(ctx);
|
||||
@@ -298,8 +355,13 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
const stopError = new Error('Process output drain timed out after 5000ms.');
|
||||
const stopAppium = jest.fn();
|
||||
const stopEventCollection = jest.fn();
|
||||
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({
|
||||
@@ -331,6 +393,7 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
subdomainId: 'appium-abc',
|
||||
stopAsync: jest.fn(),
|
||||
});
|
||||
finishHost.mockResolvedValue(undefined);
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
@@ -338,7 +401,7 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: jest.fn(),
|
||||
finishAsync: finishHost,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -350,9 +413,11 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
logger: { info: jest.Mock; warn: jest.Mock },
|
||||
maxIdleTimeMinutes?: number,
|
||||
runtimePlatform = BuildRuntimePlatform.LINUX,
|
||||
startupInputs: Record<string, { value: unknown }> = {}
|
||||
startupInputs: Record<string, { value: unknown }> = {},
|
||||
warm = false,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
await createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
|
||||
await createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext, { warm }).fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform },
|
||||
@@ -364,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',
|
||||
@@ -382,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);
|
||||
@@ -405,6 +599,297 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
expect(options.iosSimulatorUdid).toBeUndefined();
|
||||
});
|
||||
|
||||
function mockWarmSession(): jest.Mock {
|
||||
const stopWarm = jest.fn().mockResolvedValue(undefined);
|
||||
const warm: warmIosSession.WarmIosSession = {
|
||||
device: { udid: 'CHOSEN-IOS-UDID', name: 'iPhone 17' },
|
||||
server: { pid: 100, port: 3200, token: 'warm-token' },
|
||||
agentDeviceStateDir: '/warm/agent-device',
|
||||
argentEventLogPath: '/warm/argent-events',
|
||||
prepareToolsAsync: jest.fn(),
|
||||
process: {
|
||||
pid: 100,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopWarm,
|
||||
},
|
||||
};
|
||||
jest.spyOn(warmIosSession, 'tryClaimWarmIosSessionAsync').mockResolvedValue(warm);
|
||||
jest
|
||||
.mocked(runOnWarmIosSimulatorAsync)
|
||||
.mockImplementation(
|
||||
jest.requireActual<typeof import('../../utils/warmDeviceSessionHost')>(
|
||||
'../../utils/warmDeviceSessionHost'
|
||||
).runOnWarmIosSimulatorAsync
|
||||
);
|
||||
return stopWarm;
|
||||
}
|
||||
|
||||
it.each(['event collection', 'Appium process'])(
|
||||
'waits for warm %s teardown before finishing the session host',
|
||||
async stage => {
|
||||
mockWarmSession();
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
let finishTeardown = () => {};
|
||||
const teardownGate = new Promise<void>(resolve => {
|
||||
finishTeardown = resolve;
|
||||
});
|
||||
let startedTeardown = () => {};
|
||||
const teardownStarted = new Promise<void>(resolve => {
|
||||
startedTeardown = resolve;
|
||||
});
|
||||
const stop = async () => {
|
||||
startedTeardown();
|
||||
await teardownGate;
|
||||
};
|
||||
(stage === 'event collection' ? stopEventCollection : stopAppium).mockImplementationOnce(
|
||||
stop
|
||||
);
|
||||
|
||||
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {}, true);
|
||||
try {
|
||||
await teardownStarted;
|
||||
await Promise.resolve();
|
||||
expect(finishHost).not.toHaveBeenCalled();
|
||||
if (stage === 'event collection') {
|
||||
expect(stopAppium).not.toHaveBeenCalled();
|
||||
}
|
||||
} finally {
|
||||
finishTeardown();
|
||||
await running;
|
||||
}
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
expect(stopAppium.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
finishHost.mock.invocationCallOrder[0]
|
||||
);
|
||||
const home = jest.mocked(spawnDetached).mock.calls[0][0].env.APPIUM_HOME;
|
||||
expect(fs.existsSync(home ?? '')).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['event collection', 'Appium process'])(
|
||||
'finishes the warm host after %s teardown fails',
|
||||
async stage => {
|
||||
const stopWarm = mockWarmSession();
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
const error = new Error(`${stage} stop failed`);
|
||||
(stage === 'event collection' ? stopEventCollection : stopAppium).mockRejectedValueOnce(
|
||||
error
|
||||
);
|
||||
|
||||
await expect(
|
||||
runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {}, true)
|
||||
).rejects.toBe(error);
|
||||
|
||||
expect(stopAppium).toHaveBeenCalledTimes(1);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
expect(stopWarm).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
'finishes a cold host while Appium is still stopping (warm step: %s)',
|
||||
async warm => {
|
||||
if (warm) {
|
||||
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementationOnce((_claim, run) => run());
|
||||
}
|
||||
let finishAppium = () => {};
|
||||
const appiumGate = new Promise<void>(resolve => {
|
||||
finishAppium = resolve;
|
||||
});
|
||||
let startedAppiumStop = () => {};
|
||||
const appiumStopping = new Promise<void>(resolve => {
|
||||
startedAppiumStop = resolve;
|
||||
});
|
||||
stopAppium.mockImplementationOnce(async () => {
|
||||
startedAppiumStop();
|
||||
await appiumGate;
|
||||
});
|
||||
let startedHostFinish = () => {};
|
||||
const hostFinishing = new Promise<void>(resolve => {
|
||||
startedHostFinish = resolve;
|
||||
});
|
||||
finishHost.mockImplementationOnce(async () => startedHostFinish());
|
||||
|
||||
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {}, warm);
|
||||
try {
|
||||
await appiumStopping;
|
||||
await Promise.resolve();
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
await hostFinishing;
|
||||
} finally {
|
||||
finishAppium();
|
||||
await running;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['stopped', 'process stop failed', 'home cleanup failed'])(
|
||||
'retries a failed warm adoption only after Appium stops (%s)',
|
||||
async cleanup => {
|
||||
const stopFails = cleanup === 'process stop failed';
|
||||
const adoptionError = new WarmSessionAdoptionError();
|
||||
const stopWarm = mockWarmSession();
|
||||
let failHost: (error: Error) => void = () => {};
|
||||
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failHost = reject;
|
||||
})
|
||||
);
|
||||
jest.mocked(spawnDetached).mockImplementationOnce(() => {
|
||||
failHost(adoptionError);
|
||||
return {
|
||||
pid: 4242,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopAppium,
|
||||
};
|
||||
});
|
||||
if (!stopFails) {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
}
|
||||
if (cleanup === 'home cleanup failed') {
|
||||
jest.spyOn(fs.promises, 'rm').mockRejectedValueOnce(new Error('home cleanup failed'));
|
||||
}
|
||||
|
||||
const running = runSessionAsync(
|
||||
{ info: jest.fn(), warn: jest.fn() },
|
||||
undefined,
|
||||
BuildRuntimePlatform.DARWIN,
|
||||
{ boot_simulator: { value: true }, device_identifier: { value: 'iPhone 17' } },
|
||||
true
|
||||
);
|
||||
|
||||
if (stopFails) {
|
||||
await expect(running).rejects.toMatchObject({ errors: [adoptionError, stopError] });
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
expect(stopWarm).toHaveBeenCalledTimes(1);
|
||||
} else {
|
||||
await expect(running).resolves.toBeUndefined();
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(2);
|
||||
expect(stopWarm).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
expect(stopAppium).toHaveBeenCalledTimes(stopFails ? 1 : 2);
|
||||
if (cleanup === 'home cleanup failed') {
|
||||
const home = jest.mocked(spawnDetached).mock.calls[0][0].env.APPIUM_HOME;
|
||||
if (home) {
|
||||
await fs.promises.rm(home, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
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',
|
||||
async bootSimulator => {
|
||||
@@ -433,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();
|
||||
@@ -749,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() };
|
||||
|
||||
|
||||
+437
-2
@@ -24,6 +24,13 @@ import {
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import {
|
||||
createWarmDeviceSessionServer,
|
||||
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';
|
||||
|
||||
@@ -50,6 +57,7 @@ jest.mock('../../utils/argentEvents', () => ({
|
||||
startArgentEventCollectionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/warmDeviceSessionHost');
|
||||
jest.mock('../../utils/iosAppArtifact', () => ({
|
||||
...jest.requireActual('../../utils/iosAppArtifact'),
|
||||
readIosApplicationIdentifierAsync: jest.fn().mockResolvedValue('dev.example.app'),
|
||||
@@ -80,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');
|
||||
@@ -130,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 });
|
||||
@@ -143,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);
|
||||
@@ -605,6 +974,72 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
expect(JSON.stringify(logger.info.mock.calls)).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('uses the warm Argent server and drains its artifacts before stopping the host', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const warmServer = { port: 3200 } as never;
|
||||
const warm = {
|
||||
device: { udid: 'WARM-UDID', name: 'iPhone 17' },
|
||||
argentEventLogPath: '/warm/argent-events',
|
||||
tools: { pid: 30, port: 3201, token: 'warm-token' },
|
||||
} as WarmIosSession;
|
||||
jest.mocked(createWarmDeviceSessionServer).mockReturnValue(warmServer);
|
||||
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation((_claim, run) => run(warm));
|
||||
let releaseArtifacts = () => {};
|
||||
const artifactsDrained = new Promise<void>(resolve => {
|
||||
releaseArtifacts = resolve;
|
||||
});
|
||||
let pollingStarted = () => {};
|
||||
const polling = new Promise<void>(resolve => {
|
||||
pollingStarted = resolve;
|
||||
});
|
||||
jest.mocked(pollArgentArtifactsForUploadAsync).mockImplementationOnce(async () => {
|
||||
pollingStarted();
|
||||
await artifactsDrained;
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx, { warm: true });
|
||||
expect(buildFunction.id).toBe('start_warm_argent_remote_session');
|
||||
if (!buildFunction.fn) {
|
||||
throw new Error('Expected the Argent session function.');
|
||||
}
|
||||
const running = buildFunction.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 },
|
||||
max_duration_seconds: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
try {
|
||||
await polling;
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalled();
|
||||
expect(mockPreviewStopAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
releaseArtifacts();
|
||||
await running;
|
||||
}
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(ctx, expect.anything(), warmServer);
|
||||
expect(startNgrokTunnelAsync).toHaveBeenCalledWith(expect.objectContaining({ port: 3201 }));
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({ toolsAuthToken: 'warm-token' }),
|
||||
})
|
||||
);
|
||||
expect(startArgentEventCollectionAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ eventLogPath: '/warm/argent-events' })
|
||||
);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
@@ -892,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!(
|
||||
@@ -911,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();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
@@ -6,79 +5,12 @@ import path from 'node:path';
|
||||
|
||||
import { Sentry } from '../../../sentry';
|
||||
import {
|
||||
MIN_ARGENT_REMOTE_SESSION_VERSION,
|
||||
stopArgentEventCollectionSafelyAsync,
|
||||
waitForArgentToolServerStateAsync,
|
||||
warnIfArgentPackageVersionCannotBeVerified,
|
||||
} from '../startArgentRemoteSession';
|
||||
|
||||
jest.mock('../../../sentry');
|
||||
|
||||
describe(warnIfArgentPackageVersionCannotBeVerified, () => {
|
||||
const warn = jest.fn();
|
||||
const logger = { warn } as unknown as bunyan;
|
||||
const supportedVersion = '999.0.0';
|
||||
const oldVersion = '0.0.0';
|
||||
|
||||
beforeEach(() => {
|
||||
warn.mockClear();
|
||||
});
|
||||
|
||||
it('allows the default latest package version', () => {
|
||||
expect(() =>
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: undefined, logger })
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: 'latest', logger })
|
||||
).not.toThrow();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows exact supported semver versions', () => {
|
||||
expect(() =>
|
||||
warnIfArgentPackageVersionCannotBeVerified({
|
||||
packageVersion: MIN_ARGENT_REMOTE_SESSION_VERSION,
|
||||
logger,
|
||||
})
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: supportedVersion, logger })
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
warnIfArgentPackageVersionCannotBeVerified({
|
||||
packageVersion: `v${supportedVersion}`,
|
||||
logger,
|
||||
})
|
||||
).not.toThrow();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects exact semver versions that are too old', () => {
|
||||
expect(() =>
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: oldVersion, logger })
|
||||
).toThrow(SystemError);
|
||||
expect(() =>
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: oldVersion, logger })
|
||||
).toThrow(`Use "latest" or pass an exact version >= ${MIN_ARGENT_REMOTE_SESSION_VERSION}`);
|
||||
});
|
||||
|
||||
it('warns for package tags or ranges that cannot be verified', () => {
|
||||
expect(() =>
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: 'next', logger })
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
warnIfArgentPackageVersionCannotBeVerified({
|
||||
packageVersion: `^${MIN_ARGENT_REMOTE_SESSION_VERSION}`,
|
||||
logger,
|
||||
})
|
||||
).not.toThrow();
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Continuing and letting the package manager resolve it.')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(waitForArgentToolServerStateAsync, () => {
|
||||
let stateDir: string;
|
||||
|
||||
|
||||
+243
-6
@@ -12,9 +12,17 @@ import {
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import {
|
||||
createWarmDeviceSessionServer,
|
||||
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');
|
||||
jest.mock('../../utils/warmDeviceSessionHost');
|
||||
jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
IosSimulatorUtils: {
|
||||
resolveUdidAsync: jest.fn().mockResolvedValue('selected-ios-udid'),
|
||||
@@ -39,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;
|
||||
@@ -48,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!(
|
||||
@@ -63,6 +75,7 @@ async function runAsync(
|
||||
...launchInputs,
|
||||
},
|
||||
outputs: {},
|
||||
signal,
|
||||
env,
|
||||
} as never
|
||||
);
|
||||
@@ -86,9 +99,131 @@ 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;
|
||||
jest.mocked(createWarmDeviceSessionServer).mockReturnValue(warmServer);
|
||||
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation((_claim, run) => run(warm));
|
||||
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true });
|
||||
expect(buildFunction.id).toBe('start_warm_serve_sim_remote_session');
|
||||
await buildFunction.fn!(
|
||||
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
|
||||
{ inputs: { device_identifier: { value: 'iPhone 17' } }, outputs: {}, env } as never
|
||||
);
|
||||
expect(runOnWarmIosSimulatorAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ engine: 'serve-sim', requestedDevice: 'iPhone 17' }),
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ iosSimulatorUdid: 'SELECTED-IOS-UDID' }),
|
||||
warmServer
|
||||
);
|
||||
});
|
||||
|
||||
it('uses preview activity to reset the configured idle timeout', async () => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, { max_idle_time_minutes: { value: 5 } });
|
||||
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
@@ -130,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({
|
||||
@@ -246,7 +483,9 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
env: childEnv,
|
||||
logger,
|
||||
timeoutMs: 60_000,
|
||||
signal: undefined,
|
||||
preview: { baseDomain: 'tunnel.example.com', authtoken: 'ngrok-token' },
|
||||
signal: expect.any(AbortSignal),
|
||||
onStartupError: expect.any(Function),
|
||||
...(selectsXcode
|
||||
? {
|
||||
iosSimulatorUdid: 'SELECTED-IOS-UDID',
|
||||
@@ -265,10 +504,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
networkCaptureFields: [],
|
||||
});
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
|
||||
expect(host.openPreviewAsync).toHaveBeenCalledWith({
|
||||
baseDomain: 'tunnel.example.com',
|
||||
authtoken: 'ngrok-token',
|
||||
});
|
||||
expect(host.openPreviewAsync).toHaveBeenCalledWith();
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
@@ -278,6 +514,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
previewApiUrl: 'https://web-preview.example.test',
|
||||
},
|
||||
logger,
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
import { BuildStep, spawnAsync } from '@expo/steps';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
jest.mock('@expo/steps', () => ({
|
||||
...jest.requireActual('@expo/steps'),
|
||||
spawnAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawnAsync = jest.mocked(spawnAsync);
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createTestAndroidJob } from '../../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../../context';
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { parseInfoPlistBuffer } from '../readIpaInfo';
|
||||
import {
|
||||
createUpdateResolvedVersionBuildFunction,
|
||||
extractAndroidVersionAsync,
|
||||
extractIosVersionAsync,
|
||||
parseAaptOutput,
|
||||
parseManifestXml,
|
||||
} from '../updateResolvedVersion';
|
||||
|
||||
describe(parseAaptOutput, () => {
|
||||
it('extracts versionName and versionCode from aapt2 output', () => {
|
||||
const output = `package: name='com.example.app' versionCode='42' versionName='2.5.0' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34' compileSdkVersionCodename='14'
|
||||
sdkVersion:'21'
|
||||
targetSdkVersion:'34'
|
||||
uses-permission: name='android.permission.INTERNET'`;
|
||||
|
||||
expect(parseAaptOutput(output)).toEqual({
|
||||
appVersion: '2.5.0',
|
||||
appBuildVersion: '42',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles output with no version info', () => {
|
||||
expect(parseAaptOutput('some unrelated output')).toEqual({
|
||||
appVersion: undefined,
|
||||
appBuildVersion: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles versionName with prerelease suffix', () => {
|
||||
const output = `package: name='com.example' versionCode='1' versionName='1.0.0-beta.1'`;
|
||||
|
||||
expect(parseAaptOutput(output)).toEqual({
|
||||
appVersion: '1.0.0-beta.1',
|
||||
appBuildVersion: '1',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps an unescaped apostrophe in versionName', () => {
|
||||
const output = `package: name='com.example' versionCode='1' versionName='1.0-O'Brien' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34'
|
||||
sdkVersion:'21'`;
|
||||
|
||||
expect(parseAaptOutput(output)).toEqual({
|
||||
appVersion: "1.0-O'Brien",
|
||||
appBuildVersion: '1',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a versionName that contains a quote and an equals sign', () => {
|
||||
const output = `package: name='com.example' versionCode='1' versionName='1.0' build=beta'
|
||||
sdkVersion:'21'`;
|
||||
|
||||
expect(parseAaptOutput(output)).toEqual({
|
||||
appVersion: "1.0' build=beta",
|
||||
appBuildVersion: '1',
|
||||
});
|
||||
});
|
||||
|
||||
it('stops versionName at the split field of a split APK', () => {
|
||||
const output = `package: name='com.example' versionCode='42' versionName='2.5.0' split='feature' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34'
|
||||
sdkVersion:'21'`;
|
||||
|
||||
expect(parseAaptOutput(output)).toEqual({
|
||||
appVersion: '2.5.0',
|
||||
appBuildVersion: '42',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an empty versionName as missing', () => {
|
||||
const output = `package: name='com.example' versionCode='42' versionName='' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34'
|
||||
sdkVersion:'21'`;
|
||||
|
||||
expect(parseAaptOutput(output)).toEqual({
|
||||
appVersion: undefined,
|
||||
appBuildVersion: '42',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseManifestXml, () => {
|
||||
it('extracts versionName and versionCode from bundletool manifest XML', () => {
|
||||
const xml = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:versionCode="10"
|
||||
android:versionName="3.1.0"
|
||||
package="com.example.app">
|
||||
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
|
||||
</manifest>`;
|
||||
|
||||
expect(parseManifestXml(xml)).toEqual({
|
||||
appVersion: '3.1.0',
|
||||
appBuildVersion: '10',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles XML with no version attributes', () => {
|
||||
const xml = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.example.app">
|
||||
</manifest>`;
|
||||
|
||||
expect(parseManifestXml(xml)).toEqual({
|
||||
appVersion: undefined,
|
||||
appBuildVersion: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('simulator .app Info.plist parsing', () => {
|
||||
const FIXTURES_DIR = path.join(__dirname, 'fixtures');
|
||||
const SIMULATOR_APP_DIR = path.join(FIXTURES_DIR, 'TestSimulator.app');
|
||||
const INFO_PLIST_PATH = path.join(SIMULATOR_APP_DIR, 'Info.plist');
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create a minimal XML Info.plist fixture
|
||||
await fs.promises.mkdir(SIMULATOR_APP_DIR, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
INFO_PLIST_PATH,
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.example.test</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>4.2.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>99</string>
|
||||
</dict>
|
||||
</plist>`
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.promises.rm(SIMULATOR_APP_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reads version from a .app Info.plist', async () => {
|
||||
const buffer = await fs.promises.readFile(INFO_PLIST_PATH);
|
||||
const infoPlist = parseInfoPlistBuffer(buffer);
|
||||
|
||||
expect(infoPlist.CFBundleShortVersionString).toBe('4.2.0');
|
||||
expect(infoPlist.CFBundleVersion).toBe('99');
|
||||
});
|
||||
});
|
||||
|
||||
describe(extractIosVersionAsync, () => {
|
||||
const FIXTURES_DIR = path.join(__dirname, 'fixtures');
|
||||
const SIMULATOR_APP_DIR = path.join(FIXTURES_DIR, 'ExtractVersion.app');
|
||||
const INFO_PLIST_PATH = path.join(SIMULATOR_APP_DIR, 'Info.plist');
|
||||
const IPA_FIXTURE_PATH = path.join(FIXTURES_DIR, 'SmallestAppExample.ipa');
|
||||
|
||||
beforeAll(async () => {
|
||||
await fs.promises.mkdir(SIMULATOR_APP_DIR, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
INFO_PLIST_PATH,
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.0.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>55</string>
|
||||
</dict>
|
||||
</plist>`
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.promises.rm(SIMULATOR_APP_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('extracts version from a .app directory', async () => {
|
||||
const result = await extractIosVersionAsync(SIMULATOR_APP_DIR);
|
||||
|
||||
expect(result).toEqual({
|
||||
appVersion: '2.0.1',
|
||||
appBuildVersion: '55',
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts version from an .ipa file', async () => {
|
||||
const result = await extractIosVersionAsync(IPA_FIXTURE_PATH);
|
||||
|
||||
expect(result).toEqual({
|
||||
appVersion: '1.0',
|
||||
appBuildVersion: '1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(extractAndroidVersionAsync, () => {
|
||||
afterEach(() => {
|
||||
mockedSpawnAsync.mockReset();
|
||||
});
|
||||
|
||||
it('extracts version from an .apk via aapt2', async () => {
|
||||
mockedSpawnAsync.mockResolvedValue({
|
||||
stdout: `package: name='com.example.app' versionCode='42' versionName='2.5.0'`,
|
||||
stderr: '',
|
||||
} as any);
|
||||
|
||||
const result = await extractAndroidVersionAsync('/path/to/app.apk');
|
||||
|
||||
expect(mockedSpawnAsync).toHaveBeenCalledWith(
|
||||
'aapt2',
|
||||
['dump', 'badging', '/path/to/app.apk'],
|
||||
{
|
||||
stdio: 'pipe',
|
||||
}
|
||||
);
|
||||
expect(result).toEqual({
|
||||
appVersion: '2.5.0',
|
||||
appBuildVersion: '42',
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts version from an .aab via bundletool', async () => {
|
||||
mockedSpawnAsync.mockResolvedValue({
|
||||
stdout: `<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:versionCode="10"
|
||||
android:versionName="3.1.0"
|
||||
package="com.example.app">
|
||||
</manifest>`,
|
||||
stderr: '',
|
||||
} as any);
|
||||
|
||||
const result = await extractAndroidVersionAsync('/path/to/app.aab');
|
||||
|
||||
expect(mockedSpawnAsync).toHaveBeenCalledWith(
|
||||
'bundletool',
|
||||
['dump', 'manifest', '--bundle', '/path/to/app.aab'],
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
expect(result).toEqual({
|
||||
appVersion: '3.1.0',
|
||||
appBuildVersion: '10',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty object for unknown extension', async () => {
|
||||
const result = await extractAndroidVersionAsync('/path/to/archive.zip');
|
||||
|
||||
expect(mockedSpawnAsync).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe(createUpdateResolvedVersionBuildFunction, () => {
|
||||
afterEach(() => {
|
||||
mockedSpawnAsync.mockReset();
|
||||
});
|
||||
|
||||
function createStep({
|
||||
env,
|
||||
archivePath,
|
||||
workingDirectory,
|
||||
mutationResult = { data: {}, error: undefined },
|
||||
}: {
|
||||
env: Record<string, string>;
|
||||
archivePath?: string;
|
||||
workingDirectory?: string;
|
||||
mutationResult?: { data?: unknown; error?: Error };
|
||||
}): { step: BuildStep; mutation: jest.Mock } {
|
||||
const mutation = jest.fn(() => ({ toPromise: async () => mutationResult }));
|
||||
const ctx = new BuildContext(createTestAndroidJob({}), {
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test', ...env },
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
uploadArtifact: jest.fn(),
|
||||
workingdir: '',
|
||||
});
|
||||
ctx.graphqlClient.mutation = mutation as any;
|
||||
const step = createUpdateResolvedVersionBuildFunction(
|
||||
new CustomBuildContext(ctx)
|
||||
).createBuildStepFromFunctionCall(createGlobalContextMock({}), {
|
||||
callInputs: archivePath ? { application_archive_path: archivePath } : {},
|
||||
workingDirectory,
|
||||
});
|
||||
return { step, mutation };
|
||||
}
|
||||
|
||||
it('resolves a relative archive path against the step working directory', async () => {
|
||||
mockedSpawnAsync.mockResolvedValue({
|
||||
stdout: `package: name='com.example.app' versionCode='42' versionName='2.5.0'`,
|
||||
stderr: '',
|
||||
} as any);
|
||||
const { step, mutation } = createStep({
|
||||
env: { EAS_BUILD_ID: 'build-id' },
|
||||
archivePath: 'android/app/build/outputs/apk/release/app-release.apk',
|
||||
workingDirectory: './my-app',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
const expectedPath = path.join(
|
||||
step.ctx.workingDirectory,
|
||||
'android/app/build/outputs/apk/release/app-release.apk'
|
||||
);
|
||||
expect(path.isAbsolute(expectedPath)).toBe(true);
|
||||
expect(mockedSpawnAsync).toHaveBeenCalledWith('aapt2', ['dump', 'badging', expectedPath], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('updates the resolved versions for the build', async () => {
|
||||
mockedSpawnAsync.mockResolvedValue({
|
||||
stdout: `package: name='com.example.app' versionCode='42' versionName='2.5.0'`,
|
||||
stderr: '',
|
||||
} as any);
|
||||
const { step, mutation } = createStep({
|
||||
env: { EAS_BUILD_ID: 'build-id' },
|
||||
archivePath: '/path/to/app.apk',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(mutation.mock.calls[0][1]).toEqual({
|
||||
buildId: 'build-id',
|
||||
appVersion: '2.5.0',
|
||||
appBuildVersion: '42',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits a missing value instead of sending null', async () => {
|
||||
mockedSpawnAsync.mockResolvedValue({
|
||||
stdout: `package: name='com.example.app' versionCode='42'`,
|
||||
stderr: '',
|
||||
} as any);
|
||||
const { step, mutation } = createStep({
|
||||
env: { EAS_BUILD_ID: 'build-id' },
|
||||
archivePath: '/path/to/app.apk',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
const variables = mutation.mock.calls[0][1];
|
||||
expect(variables.appBuildVersion).toBe('42');
|
||||
expect(variables.appVersion).toBeUndefined();
|
||||
expect(variables.appVersion).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does nothing without a build ID', async () => {
|
||||
const { step, mutation } = createStep({ env: {}, archivePath: '/path/to/app.apk' });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawnAsync).not.toHaveBeenCalled();
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing without an archive path', async () => {
|
||||
const { step, mutation } = createStep({ env: { EAS_BUILD_ID: 'build-id' } });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawnAsync).not.toHaveBeenCalled();
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fail the step when extraction or the mutation fails', async () => {
|
||||
mockedSpawnAsync.mockRejectedValueOnce(new Error('aapt2: command not found'));
|
||||
const failingExtraction = createStep({
|
||||
env: { EAS_BUILD_ID: 'build-id' },
|
||||
archivePath: '/path/to/app.apk',
|
||||
});
|
||||
await expect(failingExtraction.step.executeAsync()).resolves.not.toThrow();
|
||||
expect(failingExtraction.mutation).not.toHaveBeenCalled();
|
||||
|
||||
mockedSpawnAsync.mockResolvedValue({
|
||||
stdout: `package: name='com.example.app' versionCode='42' versionName='2.5.0'`,
|
||||
stderr: '',
|
||||
} as any);
|
||||
const failingMutation = createStep({
|
||||
env: { EAS_BUILD_ID: 'build-id' },
|
||||
archivePath: '/path/to/app.apk',
|
||||
mutationResult: { error: new Error('GraphQL error') },
|
||||
});
|
||||
await expect(failingMutation.step.executeAsync()).resolves.not.toThrow();
|
||||
expect(failingMutation.mutation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -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.'
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BuildJob, Ios, ManagedArtifactType, Platform } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildStepContext } from '@expo/steps';
|
||||
import { BuildFunction, BuildStepContext, BuildStepOutput } from '@expo/steps';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { findXcodeBuildLogsPathAsync } from '../../ios/xcodeBuildLogs';
|
||||
@@ -13,7 +13,13 @@ export function createFindAndUploadBuildArtifactsBuildFunction(
|
||||
id: 'find_and_upload_build_artifacts',
|
||||
name: 'Find and upload build artifacts',
|
||||
__metricsId: 'eas/find_and_upload_build_artifacts',
|
||||
fn: async stepCtx => {
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'application_archive_path',
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { outputs }) => {
|
||||
// We want each upload to print logs on its own
|
||||
// and we don't want to interleave logs from different uploads
|
||||
// so we execute uploads consecutively.
|
||||
@@ -24,7 +30,10 @@ export function createFindAndUploadBuildArtifactsBuildFunction(
|
||||
let firstError: any = null;
|
||||
|
||||
try {
|
||||
await uploadApplicationArchivesAsync({ ctx, stepCtx });
|
||||
const [firstArchivePath] = await uploadApplicationArchivesAsync({ ctx, stepCtx });
|
||||
if (firstArchivePath) {
|
||||
outputs.application_archive_path.set(firstArchivePath);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
stepCtx.logger.error({ err }, `Failed to upload application archives.`);
|
||||
firstError ||= err;
|
||||
@@ -68,7 +77,7 @@ async function uploadApplicationArchivesAsync({
|
||||
}: {
|
||||
ctx: CustomBuildContext<BuildJob>;
|
||||
stepCtx: BuildStepContext;
|
||||
}): Promise<void> {
|
||||
}): Promise<string[]> {
|
||||
const applicationArchivePatternOrPath =
|
||||
ctx.job.platform === Platform.ANDROID
|
||||
? (ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}')
|
||||
@@ -99,6 +108,8 @@ async function uploadApplicationArchivesAsync({
|
||||
logger,
|
||||
});
|
||||
logger.info('Done.');
|
||||
|
||||
return applicationArchives;
|
||||
}
|
||||
|
||||
async function uploadBuildArtifacts({
|
||||
|
||||
@@ -117,7 +117,7 @@ export async function readIpaInfoAsync(ipaPath: string): Promise<IpaInfo> {
|
||||
}
|
||||
}
|
||||
|
||||
function parseInfoPlistBuffer(data: Buffer): Record<string, unknown> {
|
||||
export function parseInfoPlistBuffer(data: Buffer): Record<string, unknown> {
|
||||
const isBinaryPlist = data.subarray(0, 8).toString('ascii') === 'bplist00';
|
||||
if (isBinaryPlist) {
|
||||
const parsedBinaryPlists = bplistParser.parseBuffer(data);
|
||||
|
||||
@@ -9,6 +9,8 @@ import path from 'node:path';
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { uploadRemoteSessionConfigWithLocalEgressAsync } from '../utils/localEgressSession';
|
||||
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import { createWarmDeviceSessionServer } from '../utils/warmDeviceSessionHost';
|
||||
import { type WarmIosSession } from '../utils/warmIosSession';
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
PackageManager,
|
||||
@@ -23,6 +25,7 @@ import { type ServeSimApplicationOptions } from '../utils/remoteDeviceRunSession
|
||||
import { type StartupTasks } from '../utils/startupTasks';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
DeviceRunSessionStoppedError,
|
||||
finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
@@ -98,6 +101,8 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
frameAncestors,
|
||||
corsOrigins,
|
||||
tasks,
|
||||
warm,
|
||||
onStarted,
|
||||
device,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
@@ -114,6 +119,9 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
frameAncestors?: string[];
|
||||
corsOrigins?: string[];
|
||||
tasks: StartupTasks;
|
||||
/** A claimed warm Simulator: adopt it and use its agent-device daemon. */
|
||||
warm?: WarmIosSession;
|
||||
onStarted?: () => Promise<void>;
|
||||
device:
|
||||
| {
|
||||
booted: Promise<unknown>;
|
||||
@@ -137,6 +145,9 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
let agentDeviceTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let eventCollection: Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>> | undefined;
|
||||
let artifactUploads: Promise<void> | undefined;
|
||||
// Ends periodic polling; the poller then runs one bounded final inventory and upload drain.
|
||||
const stopArtifactPolling = new AbortController();
|
||||
let sessionFailed = false;
|
||||
const daemonSecrets = [ngrokAuthtoken];
|
||||
const hostBootedDevice = 'iosSimulatorUdid' in device ? device : undefined;
|
||||
@@ -144,7 +155,70 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
|
||||
// Each task stores what it started, so the teardown below can stop it even when
|
||||
// another task failed first.
|
||||
const sessionHostStartup = tasks.run('session host', async taskLogger => {
|
||||
if (externallyBootedDevice) {
|
||||
await tasks.untilAborted(externallyBootedDevice.booted);
|
||||
}
|
||||
tasks.signal.throwIfAborted();
|
||||
sessionHost = await startDeviceSessionHostAsync(
|
||||
ctx,
|
||||
{
|
||||
separateLogPhase: true,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken },
|
||||
disableShare,
|
||||
privatePreviewUrl,
|
||||
frameAncestors,
|
||||
corsOrigins,
|
||||
signal: tasks.signal,
|
||||
onStartupError: tasks.abort,
|
||||
networkCapture: capture.networkCapture,
|
||||
networkCaptureFields: capture.networkCaptureFields,
|
||||
iosSimulatorUdid: hostBootedDevice?.iosSimulatorUdid,
|
||||
bootEnv: hostBootedDevice?.bootEnv,
|
||||
application: hostBootedDevice?.application,
|
||||
},
|
||||
...(warm ? [createWarmDeviceSessionServer(ctx, taskLogger, warm)] : [])
|
||||
);
|
||||
tasks.signal.throwIfAborted();
|
||||
const webPreview = await sessionHost.openPreviewAsync();
|
||||
tasks.signal.throwIfAborted();
|
||||
taskLogger.info(
|
||||
webPreview.sharePageUrl
|
||||
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
|
||||
: 'Private preview URL unavailable for this session.'
|
||||
);
|
||||
return webPreview;
|
||||
});
|
||||
const agentDeviceStartup = tasks.run('agent-device daemon', async taskLogger => {
|
||||
let daemonInfo: { port: number; token: string };
|
||||
if (warm) {
|
||||
// Adoption prepares the launcher's daemon for the claimed device.
|
||||
await tasks.untilAborted(sessionHostStartup);
|
||||
if (!warm.tools) {
|
||||
throw new Error('The warm agent-device daemon was not prepared.');
|
||||
}
|
||||
daemonInfo = warm.tools;
|
||||
} else {
|
||||
daemonInfo = await startDaemonAsync(taskLogger);
|
||||
}
|
||||
tasks.signal.throwIfAborted();
|
||||
agentDeviceTunnel = await startNgrokTunnelAsync({
|
||||
port: daemonInfo.port,
|
||||
subdomainPrefix: 'agent-device',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
healthCheck: { path: '/health' },
|
||||
logger: taskLogger,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
taskLogger.info(`Tunnel is ready at ${agentDeviceTunnel.url}.`);
|
||||
return { ...daemonInfo, remoteSessionUrl: agentDeviceTunnel.url };
|
||||
});
|
||||
async function startDaemonAsync(taskLogger: bunyan) {
|
||||
taskLogger.info('Launching agent-device daemon.');
|
||||
daemonProcess = await startAgentDeviceDaemonAsync({
|
||||
packageVersion,
|
||||
@@ -160,56 +234,8 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
const daemonInfo = await tasks.untilAborted(waitForDaemonInfoAsync({ daemonProcess }));
|
||||
daemonSecrets.push(daemonInfo.token);
|
||||
taskLogger.info(`Daemon is listening on port ${daemonInfo.port}; loaded auth token.`);
|
||||
|
||||
tasks.signal.throwIfAborted();
|
||||
agentDeviceTunnel = await startNgrokTunnelAsync({
|
||||
port: daemonInfo.port,
|
||||
subdomainPrefix: 'agent-device',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
healthCheck: { path: '/health' },
|
||||
logger: taskLogger,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
taskLogger.info(`Tunnel is ready at ${agentDeviceTunnel.url}.`);
|
||||
return { ...daemonInfo, remoteSessionUrl: agentDeviceTunnel.url };
|
||||
});
|
||||
const sessionHostStartup = tasks.run('session host', async taskLogger => {
|
||||
if (externallyBootedDevice) {
|
||||
await tasks.untilAborted(externallyBootedDevice.booted);
|
||||
}
|
||||
tasks.signal.throwIfAborted();
|
||||
sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
separateLogPhase: true,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
disableShare,
|
||||
privatePreviewUrl,
|
||||
frameAncestors,
|
||||
corsOrigins,
|
||||
signal: tasks.signal,
|
||||
onStartupError: tasks.abort,
|
||||
networkCapture: capture.networkCapture,
|
||||
networkCaptureFields: capture.networkCaptureFields,
|
||||
iosSimulatorUdid: hostBootedDevice?.iosSimulatorUdid,
|
||||
bootEnv: hostBootedDevice?.bootEnv,
|
||||
application: hostBootedDevice?.application,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
const webPreview = await sessionHost.openPreviewAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
taskLogger.info(
|
||||
webPreview.sharePageUrl
|
||||
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
|
||||
: 'Private preview URL unavailable for this session.'
|
||||
);
|
||||
return webPreview;
|
||||
});
|
||||
return daemonInfo;
|
||||
}
|
||||
const deviceReady = externallyBootedDevice?.ready;
|
||||
|
||||
try {
|
||||
@@ -228,7 +254,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
|
||||
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
signal: tasks.signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
@@ -243,18 +269,23 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
if (sessionStart === 'stopped') {
|
||||
return;
|
||||
}
|
||||
await onStarted?.();
|
||||
tasks.signal.throwIfAborted();
|
||||
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
|
||||
artifactUploads = pollAgentDeviceArtifactsForUploadAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
daemonUrl: `http://127.0.0.1:${daemonPort}`,
|
||||
daemonToken,
|
||||
logger,
|
||||
signal,
|
||||
stopPollingSignal: stopArtifactPolling.signal,
|
||||
});
|
||||
// Attach a handler at once; the teardown still awaits and reports a rejection.
|
||||
void artifactUploads.catch(() => {});
|
||||
|
||||
eventCollection = await startAgentDeviceEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
stateDir: AGENT_DEVICE_STATE_DIR,
|
||||
stateDir: warm?.agentDeviceStateDir ?? AGENT_DEVICE_STATE_DIR,
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -274,37 +305,64 @@ 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 {
|
||||
if (eventCollection) {
|
||||
await stopAgentDeviceEventCollectionSafelyAsync({
|
||||
eventCollection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
// The final inventory reads from the daemon, so it finishes before the daemon stops.
|
||||
stopArtifactPolling.abort();
|
||||
await artifactUploads;
|
||||
} finally {
|
||||
await startedDaemon?.stopAsync();
|
||||
}
|
||||
}
|
||||
})();
|
||||
await finishRemoteSessionAsync({
|
||||
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],
|
||||
[
|
||||
'agent-device daemon',
|
||||
startedDaemon &&
|
||||
(async () => {
|
||||
try {
|
||||
if (eventCollection) {
|
||||
await stopAgentDeviceEventCollectionSafelyAsync({
|
||||
eventCollection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await startedDaemon.stopAsync();
|
||||
}
|
||||
})(),
|
||||
'session host',
|
||||
(async () => {
|
||||
if (warm) {
|
||||
// The warm host owns the daemon too; keep it alive through the final downloads.
|
||||
await Promise.allSettled([toolTeardown]);
|
||||
}
|
||||
await sessionHost?.finishAsync();
|
||||
})(),
|
||||
],
|
||||
['session host', sessionHost?.finishAsync()],
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,27 +15,33 @@ 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';
|
||||
import { runOnWarmIosSimulatorAsync } from '../utils/warmDeviceSessionHost';
|
||||
import { type WarmIosSession } from '../utils/warmIosSession';
|
||||
import { withLocalEgressSession } from '../utils/localEgressSession';
|
||||
import {
|
||||
type ServeSimApplicationOptions,
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
createPublicPreviewInputProvider,
|
||||
createServeSimLaunchInputProviders,
|
||||
omitNgrokAuthtoken,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
validateServeSimLaunchOptions,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
parseNetworkCaptureInputs,
|
||||
} from '../utils/networkCaptureFields';
|
||||
import { createStartupTasks } from '../utils/startupTasks';
|
||||
import {
|
||||
type ServeSimApplicationOptions,
|
||||
validateServeSimLaunchOptions,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
import { downloadBuildAsync } from './downloadBuild';
|
||||
import { installBuildAsync } from './installBuild';
|
||||
@@ -49,23 +55,30 @@ import {
|
||||
runAgentDeviceRemoteSessionAsync,
|
||||
} from './startAgentDeviceRemoteSession';
|
||||
import { startAndroidEmulatorAsync } from './startAndroidEmulator';
|
||||
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
|
||||
import { readIosApplicationIdentifierAsync } from '../utils/iosAppArtifact';
|
||||
|
||||
const ANDROID_DEVICE_NAME = 'EasAndroidDevice01' as AndroidVirtualDeviceName;
|
||||
|
||||
/**
|
||||
* Starts a device session, downloading the app alongside daemon startup.
|
||||
* Starts a device session using an optional local app artifact, or one it downloads.
|
||||
* On iOS, serve-sim boots the selected Simulator, then installs and launches
|
||||
* the downloaded app. Build-tools verifies any egress guard before install and launch.
|
||||
* the app. Build-tools verifies any egress guard before install and launch.
|
||||
*/
|
||||
export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
export function createStartAgentDeviceSessionBuildFunction(
|
||||
ctx: CustomBuildContext,
|
||||
{ warm = false }: { warm?: boolean } = {}
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_agent_device_session',
|
||||
id: warm ? 'start_warm_agent_device_session' : 'start_agent_device_session',
|
||||
name: 'Start agent-device session',
|
||||
__metricsId: 'eas/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',
|
||||
@@ -93,7 +106,13 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
// Application. Pass at most one of build_id and application_archive_url.
|
||||
// Application. Pass at most one of install_app_path, build_id and application_archive_url.
|
||||
BuildStepInput.createProvider({
|
||||
id: 'install_app_path',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
// Deprecated: remove once workflows pass install_app_path.
|
||||
BuildStepInput.createProvider({
|
||||
id: 'build_id',
|
||||
required: false,
|
||||
@@ -104,16 +123,7 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'launch_args',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'open_url',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
...createServeSimLaunchInputProviders(),
|
||||
// Session.
|
||||
...createNetworkCaptureInputProviders(),
|
||||
createPublicPreviewInputProvider(),
|
||||
@@ -144,22 +154,41 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
// Validate everything before any expensive work starts.
|
||||
const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(stepEnv);
|
||||
const env = omitNgrokAuthtoken(stepEnv);
|
||||
const installAppPath = inputs.install_app_path.value as string | undefined;
|
||||
const buildId = inputs.build_id.value as string | undefined;
|
||||
const applicationArchiveUrl = inputs.application_archive_url.value as string | undefined;
|
||||
if (buildId && applicationArchiveUrl) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
|
||||
'Pass only one of build_id or application_archive_url.'
|
||||
);
|
||||
}
|
||||
const hasApplication = Boolean(buildId || applicationArchiveUrl);
|
||||
const launchAppIdentifier = inputs.launch_app_identifier.value as string | undefined;
|
||||
const launchArgs = parseLaunchArgsInput(inputs.launch_args.value);
|
||||
const openUrl =
|
||||
inputs.open_url.value === undefined ? undefined : parseOpenUrlInput(inputs.open_url.value);
|
||||
if (!hasApplication && (launchArgs.length > 0 || openUrl)) {
|
||||
const download = buildId || applicationArchiveUrl;
|
||||
if ([installAppPath, buildId, applicationArchiveUrl].filter(Boolean).length > 1) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
|
||||
'Pass only one of install_app_path, build_id or application_archive_url.'
|
||||
);
|
||||
}
|
||||
if (download && launchAppIdentifier !== undefined) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
'launch_args and open_url need an application: pass build_id or application_archive_url.'
|
||||
'launch_app_identifier cannot be combined with build_id or application_archive_url.'
|
||||
);
|
||||
}
|
||||
if (
|
||||
!installAppPath &&
|
||||
!download &&
|
||||
!launchAppIdentifier &&
|
||||
(launchArgs.length > 0 || openUrl)
|
||||
) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
'launch_args and open_url need an application: pass install_app_path or launch_app_identifier.'
|
||||
);
|
||||
}
|
||||
if (!isIos && launchAppIdentifier !== undefined) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
'launch_app_identifier is only supported for iOS Simulator sessions. Pass install_app_path for Android.'
|
||||
);
|
||||
}
|
||||
if (isIos) {
|
||||
@@ -167,6 +196,9 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
} 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(
|
||||
{
|
||||
@@ -176,87 +208,123 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
{ runtimePlatform }
|
||||
);
|
||||
|
||||
const tasks = createStartupTasks(logger, signal);
|
||||
let downloaded: Promise<string> | undefined;
|
||||
let applicationReady: Promise<ServeSimApplicationOptions | void> | undefined;
|
||||
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, startupSignal);
|
||||
let booted: Promise<void> | undefined;
|
||||
let sessionOwnsTeardown = false;
|
||||
let downloaded: Promise<string> | undefined;
|
||||
let applicationReady: Promise<ServeSimApplicationOptions | void> | undefined;
|
||||
|
||||
try {
|
||||
if (isIos) {
|
||||
// Select Xcode before serve-sim boots the device or any Simulator tools run.
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
tasks.signal.throwIfAborted();
|
||||
const { iosStartup } = isIos
|
||||
? await resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator: true,
|
||||
deviceIdentifier,
|
||||
env,
|
||||
logger,
|
||||
signal: tasks.signal,
|
||||
})
|
||||
: {};
|
||||
const iosSimulatorUdid = iosStartup?.iosSimulatorUdid;
|
||||
if (iosSimulatorUdid) {
|
||||
logger.info(`Selected iOS Simulator: ${iosSimulatorUdid}.`);
|
||||
}
|
||||
|
||||
const booted = iosSimulatorUdid
|
||||
? undefined
|
||||
: tasks.run('Android Emulator boot', async taskLogger => {
|
||||
const logcatDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
|
||||
);
|
||||
tasks.signal.throwIfAborted();
|
||||
await startAndroidEmulatorAsync({
|
||||
deviceName: ANDROID_DEVICE_NAME,
|
||||
systemImagePackage: `${inputs.system_image_package.value}`,
|
||||
deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined,
|
||||
lcdWidth: inputs.lcd_width.value as number | undefined,
|
||||
lcdHeight: inputs.lcd_height.value as number | undefined,
|
||||
lcdDensity: inputs.lcd_density.value as number | undefined,
|
||||
logcatDirectory,
|
||||
try {
|
||||
if (isIos) {
|
||||
// Select Xcode before serve-sim boots the device or any Simulator tools run.
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
tasks.signal.throwIfAborted();
|
||||
const { launch, iosStartup } = isIos
|
||||
? await resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator: true,
|
||||
deviceIdentifier: warmSession?.device.udid ?? deviceIdentifier,
|
||||
installAppPath,
|
||||
launchAppIdentifier,
|
||||
// A downloaded app gets its launch options after the download.
|
||||
launchArgs: download ? [] : launchArgs,
|
||||
openUrl: download ? undefined : openUrl,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
});
|
||||
|
||||
downloaded = hasApplication
|
||||
? tasks.run('build download', async taskLogger => {
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: taskLogger,
|
||||
...(buildId ? { buildId } : { applicationArchiveUrl: applicationArchiveUrl! }),
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
deviceRunSessionWorkerApi: buildId ? getDeviceRunSessionWorkerApi(ctx) : null,
|
||||
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
|
||||
extensions: isIos ? ['app'] : ['apk'],
|
||||
logger,
|
||||
signal: tasks.signal,
|
||||
});
|
||||
return artifactPath;
|
||||
})
|
||||
: undefined;
|
||||
})
|
||||
: {};
|
||||
const iosSimulatorUdid = iosStartup?.iosSimulatorUdid;
|
||||
if (iosSimulatorUdid) {
|
||||
logger.info(`Selected iOS Simulator: ${iosSimulatorUdid}.`);
|
||||
}
|
||||
|
||||
const download = downloaded;
|
||||
applicationReady = download
|
||||
? tasks.run(isIos ? 'app preparation' : 'app install and launch', async taskLogger => {
|
||||
if (booted) {
|
||||
await tasks.untilAborted(booted);
|
||||
}
|
||||
const artifactPath = await download;
|
||||
tasks.signal.throwIfAborted();
|
||||
if (isIos) {
|
||||
const applicationIdentifier = await readIosApplicationIdentifierAsync({
|
||||
artifactPath,
|
||||
env,
|
||||
});
|
||||
booted = iosSimulatorUdid
|
||||
? Promise.resolve()
|
||||
: tasks.run('Android Emulator boot', async taskLogger => {
|
||||
const logcatDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
|
||||
);
|
||||
tasks.signal.throwIfAborted();
|
||||
return {
|
||||
installAppPath: artifactPath,
|
||||
launchAppIdentifier: applicationIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
};
|
||||
await startAndroidEmulatorAsync({
|
||||
deviceName: ANDROID_DEVICE_NAME,
|
||||
systemImagePackage: `${inputs.system_image_package.value}`,
|
||||
deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined,
|
||||
lcdWidth: inputs.lcd_width.value as number | undefined,
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
downloaded = download
|
||||
? tasks.run('build download', async taskLogger => {
|
||||
const source = buildId
|
||||
? { buildId }
|
||||
: applicationArchiveUrl
|
||||
? { applicationArchiveUrl }
|
||||
: undefined;
|
||||
if (!source) {
|
||||
throw new Error('No app source was provided for download.');
|
||||
}
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: taskLogger,
|
||||
...source,
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
deviceRunSessionWorkerApi: buildId ? getDeviceRunSessionWorkerApi(ctx) : null,
|
||||
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
|
||||
extensions: isIos ? ['app'] : ['apk'],
|
||||
signal: tasks.signal,
|
||||
});
|
||||
return artifactPath;
|
||||
})
|
||||
: undefined;
|
||||
const downloadedApp = downloaded;
|
||||
|
||||
if (isIos && downloadedApp) {
|
||||
applicationReady = tasks.run('app preparation', async () => {
|
||||
const artifactPath = await downloadedApp;
|
||||
tasks.signal.throwIfAborted();
|
||||
const applicationIdentifier = await readIosApplicationIdentifierAsync({
|
||||
artifactPath,
|
||||
env,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
return {
|
||||
installAppPath: artifactPath,
|
||||
launchAppIdentifier: applicationIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
};
|
||||
});
|
||||
} else if (isIos && launch?.launchAppIdentifier) {
|
||||
applicationReady = Promise.resolve({
|
||||
...launch,
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
installAppPath,
|
||||
});
|
||||
} else if (!isIos && (installAppPath || downloadedApp)) {
|
||||
applicationReady = tasks.run('app install and launch', async taskLogger => {
|
||||
await tasks.untilAborted(booted!);
|
||||
const artifactPath = installAppPath ?? (await downloadedApp);
|
||||
if (!artifactPath) {
|
||||
throw new Error('No app artifact was prepared.');
|
||||
}
|
||||
tasks.signal.throwIfAborted();
|
||||
const { applicationIdentifier, activityName } = await installBuildAsync({
|
||||
artifactPath,
|
||||
runtimePlatform,
|
||||
@@ -273,39 +341,76 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
})
|
||||
: undefined;
|
||||
});
|
||||
}
|
||||
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
runtimePlatform,
|
||||
sessionEnv,
|
||||
packageVersion: inputs.package_version.value as string | undefined,
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined,
|
||||
maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined,
|
||||
capture,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
tasks,
|
||||
device: iosSimulatorUdid
|
||||
? {
|
||||
...iosStartup,
|
||||
iosSimulatorUdid,
|
||||
application: applicationReady,
|
||||
}
|
||||
: {
|
||||
booted: booted!,
|
||||
ready: applicationReady ?? tasks.untilAborted(booted!),
|
||||
},
|
||||
});
|
||||
sessionOwnsTeardown = true;
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
runtimePlatform,
|
||||
sessionEnv,
|
||||
packageVersion: inputs.package_version.value as string | undefined,
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined,
|
||||
maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined,
|
||||
capture,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
tasks,
|
||||
warm: warmSession,
|
||||
onStarted: () => startupWatch.completeAsync(),
|
||||
device: iosSimulatorUdid
|
||||
? {
|
||||
iosSimulatorUdid,
|
||||
bootEnv: iosStartup?.bootEnv,
|
||||
application: applicationReady,
|
||||
}
|
||||
: {
|
||||
booted,
|
||||
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([booted, downloaded, applicationReady]);
|
||||
}
|
||||
};
|
||||
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 {
|
||||
tasks.abort(new Error('Agent-device session ended.'));
|
||||
await Promise.allSettled([downloaded, applicationReady]);
|
||||
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;
|
||||
|
||||
@@ -22,6 +22,12 @@ import {
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import {
|
||||
WarmSessionAdoptionError,
|
||||
createWarmDeviceSessionServer,
|
||||
runOnWarmIosSimulatorAsync,
|
||||
} from '../utils/warmDeviceSessionHost';
|
||||
import { type WarmIosSession } from '../utils/warmIosSession';
|
||||
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
@@ -39,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,
|
||||
@@ -73,13 +81,14 @@ const AppiumInstalledDriversSchema = z.record(
|
||||
);
|
||||
|
||||
export function createStartAppiumRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
ctx: CustomBuildContext,
|
||||
{ warm = false }: { warm?: boolean } = {}
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_appium_remote_session',
|
||||
id: warm ? 'start_warm_appium_remote_session' : 'start_appium_remote_session',
|
||||
name: 'Start Appium remote session',
|
||||
__metricsId: 'eas/start_appium_remote_session',
|
||||
__metricsId: warm ? 'eas/start_warm_appium_remote_session' : 'eas/start_appium_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createIosSessionStartupInputProviders(),
|
||||
@@ -107,253 +116,327 @@ 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 { launch, iosStartup } = await resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
|
||||
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
|
||||
installAppPath: inputs.install_app_path?.value as string | undefined,
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value,
|
||||
env,
|
||||
const startupStop = new AbortController();
|
||||
const watchSignal = signal
|
||||
? AbortSignal.any([signal, startupStop.signal])
|
||||
: startupStop.signal;
|
||||
const startupWatch = watchDeviceRunSessionStartup({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
signal,
|
||||
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
|
||||
});
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture?.value,
|
||||
networkCaptureFields: inputs.network_capture_fields?.value,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
const versionSpec = resolveAppium3VersionSpec(packageVersion);
|
||||
|
||||
logger.info(
|
||||
`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
const device: AppiumDevice = iosStartup
|
||||
? {
|
||||
platformName: 'iOS',
|
||||
automationName: 'XCUITest',
|
||||
driverName: 'xcuitest',
|
||||
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;
|
||||
let appiumHome: string | undefined;
|
||||
let eventCollection: Awaited<ReturnType<typeof startAppiumEventCollectionAsync>> | undefined;
|
||||
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let sessionFailed = false;
|
||||
const startHost = () =>
|
||||
startDeviceSessionHostAsync(ctx, {
|
||||
...iosStartup,
|
||||
previewDeviceId: device.udid,
|
||||
separateLogPhase: true,
|
||||
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,
|
||||
deviceIdentifier: warmSession?.device.udid ?? deviceIdentifier,
|
||||
installAppPath: inputs.install_app_path?.value as string | undefined,
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
signal: startupSignal,
|
||||
onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined,
|
||||
...launch,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
}).catch(error => {
|
||||
if (iosStartup) {
|
||||
startupAbortController.abort(error);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
if (iosStartup) {
|
||||
hostStartup = asyncResult(startHost());
|
||||
}
|
||||
// Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the
|
||||
// simulator stream with it, and UiAutomator2 merges long recordings with it. The
|
||||
// macOS session image does not ship ffmpeg, so install it concurrently with Appium.
|
||||
// Both owned installs settle before proceeding; a shared FFmpeg install keeps its owner.
|
||||
const [appiumInstall] = await Promise.all([
|
||||
asyncResult(
|
||||
installAppiumAsync({
|
||||
versionSpec,
|
||||
driverName: device.driverName,
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture?.value,
|
||||
networkCaptureFields: inputs.network_capture_fields?.value,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
const versionSpec = resolveAppium3VersionSpec(packageVersion);
|
||||
|
||||
logger.info(
|
||||
`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
const device: AppiumDevice = iosStartup
|
||||
? {
|
||||
platformName: 'iOS',
|
||||
automationName: 'XCUITest',
|
||||
driverName: 'xcuitest',
|
||||
udid: iosStartup.iosSimulatorUdid,
|
||||
}
|
||||
: await resolveAppiumDeviceAsync({ runtimePlatform, env });
|
||||
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let appiumProcess: ReturnType<typeof spawnDetached> | undefined;
|
||||
let appiumHome: string | undefined;
|
||||
let eventCollection:
|
||||
| Awaited<ReturnType<typeof startAppiumEventCollectionAsync>>
|
||||
| undefined;
|
||||
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let sessionFailed = false;
|
||||
let sessionError: unknown;
|
||||
const startHost = () =>
|
||||
startDeviceSessionHostAsync(
|
||||
ctx,
|
||||
{
|
||||
...iosStartup,
|
||||
previewDeviceId: device.udid,
|
||||
separateLogPhase: true,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
signal: setupSignal,
|
||||
onError: iosStartup ? error => startupAbortController.abort(error) : undefined,
|
||||
}).catch(error => {
|
||||
if (iosStartup) {
|
||||
startupAbortController.abort(error);
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
),
|
||||
asyncResult(
|
||||
ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger, signal: setupSignal })
|
||||
),
|
||||
]);
|
||||
const installation = appiumInstall.enforceValue();
|
||||
appiumHome = installation.appiumHome;
|
||||
setupSignal?.throwIfAborted();
|
||||
const { appiumBinPath, appiumEnv } = installation;
|
||||
const wdaCapabilities = await preparePreinstalledWdaAsync({
|
||||
runtimeIdentifier: iosStartup
|
||||
? (
|
||||
await IosSimulatorUtils.getDeviceAsync({
|
||||
udid: iosStartup.iosSimulatorUdid as IosSimulatorUuid,
|
||||
env,
|
||||
})
|
||||
)?.runtime
|
||||
: device.runtimeIdentifier,
|
||||
appiumHome,
|
||||
appiumBinPath,
|
||||
appiumEnv,
|
||||
logger,
|
||||
signal: setupSignal,
|
||||
});
|
||||
|
||||
setupSignal?.throwIfAborted();
|
||||
const useFdaHelper =
|
||||
runtimePlatform === BuildRuntimePlatform.DARWIN && fs.existsSync(FDA_HELPER);
|
||||
appiumProcess = spawnDetached({
|
||||
command: useFdaHelper ? FDA_HELPER : appiumBinPath,
|
||||
args: [
|
||||
...(useFdaHelper ? [appiumBinPath] : []),
|
||||
'--address',
|
||||
APPIUM_HOST,
|
||||
'--port',
|
||||
String(APPIUM_PORT),
|
||||
'--base-path',
|
||||
'/',
|
||||
'--log-level',
|
||||
'error',
|
||||
// Appium 3 gates session listing (GET /appium/sessions) behind the
|
||||
// session_discovery insecure feature. We rely on it to poll for
|
||||
// Appium Event Timings, so enable it for all drivers.
|
||||
'--allow-insecure',
|
||||
'*:session_discovery',
|
||||
'--default-capabilities',
|
||||
JSON.stringify({
|
||||
'appium:eventTimings': true,
|
||||
// Without it, XCUITest restarted the booted Simulator at session start, which
|
||||
// closed the app serve-sim had launched. EAS VMs never show the Simulator window.
|
||||
...(device.platformName === 'iOS' ? { 'appium:isHeadless': true } : {}),
|
||||
...wdaCapabilities,
|
||||
}),
|
||||
],
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
});
|
||||
await waitForAppiumReadyAsync({ appiumProcess, logger, signal: setupSignal });
|
||||
if (hostStartup) {
|
||||
sessionHost = (await hostStartup).enforceValue();
|
||||
}
|
||||
setupSignal?.throwIfAborted();
|
||||
eventCollection = await startAppiumEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
appiumUrl: `http://${APPIUM_HOST}:${APPIUM_PORT}/`,
|
||||
logger,
|
||||
});
|
||||
appiumTunnel = await startNgrokTunnelAsync({
|
||||
port: APPIUM_PORT,
|
||||
subdomainPrefix: 'appium',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
healthCheck: { path: '/status' },
|
||||
logger,
|
||||
});
|
||||
|
||||
// The preview host uses the same device selected for Appium.
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
|
||||
const webPreview = await sessionHost.openPreviewAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
});
|
||||
|
||||
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
appiumUrl: appiumTunnel.url,
|
||||
capabilities: {
|
||||
platformName: device.platformName,
|
||||
'appium:automationName': device.automationName,
|
||||
'appium:udid': device.udid,
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken },
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
signal: startupSignal,
|
||||
onStartupError: error => startupAbortController.abort(error),
|
||||
...launch,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
},
|
||||
webPreviewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
if (sessionStart === 'stopped') {
|
||||
return;
|
||||
}
|
||||
...(warmSession ? [createWarmDeviceSessionServer(ctx, logger, warmSession)] : [])
|
||||
).catch(error => {
|
||||
startupAbortController.abort(error);
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
if (iosStartup) {
|
||||
hostStartup = asyncResult(startHost());
|
||||
}
|
||||
// Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the
|
||||
// simulator stream with it, and UiAutomator2 merges long recordings with it. The
|
||||
// macOS session image does not ship ffmpeg, so install it concurrently with Appium.
|
||||
// Both owned installs settle before proceeding; a shared FFmpeg install keeps its owner.
|
||||
const [appiumInstall] = await Promise.all([
|
||||
asyncResult(
|
||||
installAppiumAsync({
|
||||
versionSpec,
|
||||
driverName: device.driverName,
|
||||
env,
|
||||
logger,
|
||||
signal: startupSignal,
|
||||
onError: error => startupAbortController.abort(error),
|
||||
}).catch(error => {
|
||||
startupAbortController.abort(error);
|
||||
throw error;
|
||||
})
|
||||
),
|
||||
asyncResult(
|
||||
ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
signal: startupSignal,
|
||||
})
|
||||
),
|
||||
]);
|
||||
const installation = appiumInstall.enforceValue();
|
||||
appiumHome = installation.appiumHome;
|
||||
startupSignal?.throwIfAborted();
|
||||
const { appiumBinPath, appiumEnv } = installation;
|
||||
const wdaCapabilities = await preparePreinstalledWdaAsync({
|
||||
runtimeIdentifier: iosStartup
|
||||
? (
|
||||
await IosSimulatorUtils.getDeviceAsync({
|
||||
udid: iosStartup.iosSimulatorUdid as IosSimulatorUuid,
|
||||
env,
|
||||
})
|
||||
)?.runtime
|
||||
: device.runtimeIdentifier,
|
||||
appiumHome,
|
||||
appiumBinPath,
|
||||
appiumEnv,
|
||||
logger,
|
||||
signal: startupSignal,
|
||||
});
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
sessionFailed = true;
|
||||
throw setupSignal?.aborted ? setupSignal.reason : error;
|
||||
} finally {
|
||||
startupAbortController.abort(new Error('Appium session ended.'));
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
teardown: [
|
||||
['Appium tunnel', appiumTunnel?.stopAsync()],
|
||||
[
|
||||
'Appium server',
|
||||
(async () => {
|
||||
try {
|
||||
await eventCollection?.stopAsync();
|
||||
} finally {
|
||||
try {
|
||||
await appiumProcess?.stopAsync();
|
||||
} finally {
|
||||
if (appiumHome) {
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
})(),
|
||||
startupSignal?.throwIfAborted();
|
||||
const useFdaHelper =
|
||||
runtimePlatform === BuildRuntimePlatform.DARWIN && fs.existsSync(FDA_HELPER);
|
||||
appiumProcess = spawnDetached({
|
||||
command: useFdaHelper ? FDA_HELPER : appiumBinPath,
|
||||
args: [
|
||||
...(useFdaHelper ? [appiumBinPath] : []),
|
||||
'--address',
|
||||
APPIUM_HOST,
|
||||
'--port',
|
||||
String(APPIUM_PORT),
|
||||
'--base-path',
|
||||
'/',
|
||||
'--log-level',
|
||||
'error',
|
||||
// Appium 3 gates session listing (GET /appium/sessions) behind the
|
||||
// session_discovery insecure feature. We rely on it to poll for
|
||||
// Appium Event Timings, so enable it for all drivers.
|
||||
'--allow-insecure',
|
||||
'*:session_discovery',
|
||||
'--default-capabilities',
|
||||
JSON.stringify({
|
||||
'appium:eventTimings': true,
|
||||
// Without it, XCUITest restarted the booted Simulator at session start, which
|
||||
// closed the app serve-sim had launched. EAS VMs never show the Simulator window.
|
||||
...(device.platformName === 'iOS' ? { 'appium:isHeadless': true } : {}),
|
||||
...wdaCapabilities,
|
||||
}),
|
||||
],
|
||||
['session host', hostStartup?.then(result => result.value?.finishAsync())],
|
||||
],
|
||||
});
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
});
|
||||
await waitForAppiumReadyAsync({ appiumProcess, logger, signal: startupSignal });
|
||||
if (hostStartup) {
|
||||
sessionHost = (await hostStartup).enforceValue();
|
||||
}
|
||||
startupSignal?.throwIfAborted();
|
||||
eventCollection = await startAppiumEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
appiumUrl: `http://${APPIUM_HOST}:${APPIUM_PORT}/`,
|
||||
logger,
|
||||
});
|
||||
appiumTunnel = await startNgrokTunnelAsync({
|
||||
port: APPIUM_PORT,
|
||||
subdomainPrefix: 'appium',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
healthCheck: { path: '/status' },
|
||||
logger,
|
||||
});
|
||||
|
||||
// The preview host uses the same device selected for Appium.
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
|
||||
startupSignal.throwIfAborted();
|
||||
const webPreview = await sessionHost.openPreviewAsync();
|
||||
|
||||
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal: startupSignal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
appiumUrl: appiumTunnel.url,
|
||||
capabilities: {
|
||||
platformName: device.platformName,
|
||||
'appium:automationName': device.automationName,
|
||||
'appium:udid': device.udid,
|
||||
},
|
||||
webPreviewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
if (sessionStart === 'stopped') {
|
||||
return;
|
||||
}
|
||||
|
||||
await startupWatch.completeAsync();
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error === startupSignal.reason &&
|
||||
startupSignal.reason instanceof DeviceRunSessionStoppedError
|
||||
) {
|
||||
throw startupSignal.reason;
|
||||
}
|
||||
sessionFailed = true;
|
||||
sessionError =
|
||||
error instanceof AggregateError
|
||||
? error
|
||||
: startupSignal.aborted &&
|
||||
!(startupSignal.reason instanceof DeviceRunSessionStoppedError)
|
||||
? startupSignal.reason
|
||||
: error;
|
||||
throw sessionError;
|
||||
} finally {
|
||||
startupAbortController.abort(new Error('Appium session ended.'));
|
||||
const appiumStopErrors: unknown[] = [];
|
||||
const appiumTeardown = (async () => {
|
||||
try {
|
||||
await eventCollection?.stopAsync();
|
||||
} finally {
|
||||
try {
|
||||
await appiumProcess?.stopAsync();
|
||||
} catch (error) {
|
||||
appiumStopErrors.push(error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (appiumHome) {
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
teardown: [
|
||||
['Appium tunnel', appiumTunnel?.stopAsync()],
|
||||
['Appium server', appiumTeardown],
|
||||
[
|
||||
'session host',
|
||||
hostStartup?.then(async result => {
|
||||
if (warmSession) {
|
||||
await Promise.allSettled([appiumTeardown]);
|
||||
}
|
||||
if (result.reason instanceof AggregateError) {
|
||||
throw result.reason;
|
||||
}
|
||||
await result.value?.finishAsync();
|
||||
}),
|
||||
],
|
||||
],
|
||||
});
|
||||
if (sessionError instanceof WarmSessionAdoptionError && appiumStopErrors.length) {
|
||||
throw new AggregateError(
|
||||
[sessionError, ...appiumStopErrors],
|
||||
'Could not stop Appium after warm adoption failed; a cold session cannot start safely.'
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
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();
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -529,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,
|
||||
@@ -541,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();
|
||||
|
||||
@@ -12,7 +12,6 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
@@ -21,6 +20,12 @@ import {
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import {
|
||||
createWarmDeviceSessionServer,
|
||||
runOnWarmIosSimulatorAsync,
|
||||
} from '../utils/warmDeviceSessionHost';
|
||||
import { type WarmIosSession } from '../utils/warmIosSession';
|
||||
import { validateControllerVersion } from '../utils/controllerVersion';
|
||||
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
@@ -36,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,
|
||||
@@ -84,13 +91,14 @@ const ArgentToolServerStateSchema = z.object({
|
||||
type ArgentToolServerState = z.infer<typeof ArgentToolServerStateSchema>;
|
||||
|
||||
export function createStartArgentRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
ctx: CustomBuildContext,
|
||||
{ warm = false }: { warm?: boolean } = {}
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_argent_remote_session',
|
||||
id: warm ? 'start_warm_argent_remote_session' : 'start_argent_remote_session',
|
||||
name: 'Start argent remote session',
|
||||
__metricsId: 'eas/start_argent_remote_session',
|
||||
__metricsId: warm ? 'eas/start_warm_argent_remote_session' : 'eas/start_argent_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createIosSessionStartupInputProviders(),
|
||||
@@ -129,314 +137,392 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
|
||||
validateControllerVersion({
|
||||
packageName: ARGENT_PACKAGE_NAME,
|
||||
minimumVersion: MIN_ARGENT_REMOTE_SESSION_VERSION,
|
||||
packageVersion,
|
||||
logger,
|
||||
});
|
||||
const versionSpec = packageVersion ?? 'latest';
|
||||
const { runtimePlatform } = global;
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
|
||||
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
|
||||
installAppPath: inputs.install_app_path?.value as string | undefined,
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value,
|
||||
env,
|
||||
const startupStop = new AbortController();
|
||||
const watchSignal = signal
|
||||
? AbortSignal.any([signal, startupStop.signal])
|
||||
: startupStop.signal;
|
||||
const startupWatch = watchDeviceRunSessionStartup({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
signal,
|
||||
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
|
||||
});
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture?.value,
|
||||
networkCaptureFields: inputs.network_capture_fields?.value,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
logger.info(
|
||||
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
|
||||
// Start the potentially slow installation while Argent is being prepared.
|
||||
// On Linux expo-device-hub calls this again and awaits the same in-flight
|
||||
// setup before launching. On macOS this remains non-blocking, so only a
|
||||
// recording started in the first moments may miss ffmpeg.
|
||||
// Never rejects, so `void` is safe.
|
||||
void ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
|
||||
|
||||
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
|
||||
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: Awaited<ReturnType<typeof startArgentEventCollectionAsync>> | undefined;
|
||||
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let argentServer: ReturnType<typeof spawnDetached> | undefined;
|
||||
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let sessionFailed = false;
|
||||
const startHost = () =>
|
||||
startDeviceSessionHostAsync(ctx, {
|
||||
...iosStartup,
|
||||
separateLogPhase: true,
|
||||
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,
|
||||
deviceIdentifier: warmSession?.device.udid ?? deviceIdentifier,
|
||||
installAppPath: inputs.install_app_path?.value as string | undefined,
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value,
|
||||
env,
|
||||
logger,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
signal: startupSignal,
|
||||
onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined,
|
||||
...launch,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
}).catch(error => {
|
||||
if (iosStartup) {
|
||||
startupAbortController.abort(error);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
if (iosStartup) {
|
||||
hostStartup = asyncResult(startHost());
|
||||
}
|
||||
logger.info('Enabling the Argent artifacts list endpoint flag.');
|
||||
const enableArtifacts = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
|
||||
]);
|
||||
await runArgentSetupCommandAsync(
|
||||
enableArtifacts.command,
|
||||
enableArtifacts.args,
|
||||
{ env, logger },
|
||||
setupSignal
|
||||
);
|
||||
|
||||
logger.info('Enabling the Argent tool-server event log flag.');
|
||||
const enableEventLog = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_EVENT_LOG_FLAG,
|
||||
]);
|
||||
await runArgentSetupCommandAsync(
|
||||
enableEventLog.command,
|
||||
enableEventLog.args,
|
||||
{ env, logger },
|
||||
setupSignal
|
||||
);
|
||||
|
||||
logger.info('Disabling the Argent video watermark flag.');
|
||||
const disableVideoWatermark = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'disable',
|
||||
ARGENT_VIDEO_WATERMARK_FLAG,
|
||||
]);
|
||||
// The watermark is cosmetic, so a failure here must not fail the session.
|
||||
try {
|
||||
await runArgentSetupCommandAsync(
|
||||
disableVideoWatermark.command,
|
||||
disableVideoWatermark.args,
|
||||
{ env, logger },
|
||||
setupSignal
|
||||
);
|
||||
} catch (err) {
|
||||
setupSignal?.throwIfAborted();
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.'
|
||||
);
|
||||
}
|
||||
|
||||
setupSignal?.throwIfAborted();
|
||||
const startServer = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
'start',
|
||||
'--port',
|
||||
'0',
|
||||
'--idle-timeout',
|
||||
'0',
|
||||
'--force',
|
||||
]);
|
||||
logger.info(
|
||||
`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.`
|
||||
);
|
||||
// Keep Argent itself in foreground mode under the detached process. This preserves
|
||||
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
const secrets: string[] = [];
|
||||
argentServer = spawnDetached({
|
||||
command: startServer.command,
|
||||
args: startServer.args,
|
||||
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH, ARGENT_EMULATOR_NO_WINDOW: '1' },
|
||||
logger,
|
||||
secrets,
|
||||
});
|
||||
if (argentServer.pid === undefined) {
|
||||
throw new SystemError(
|
||||
'Failed to start Argent: could not determine the PID of the launched process.'
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`);
|
||||
let toolServerPort: number;
|
||||
let toolServerToken: string | undefined;
|
||||
try {
|
||||
const toolServerState = await waitForArgentToolServerStateAsync({
|
||||
stateDir: ARGENT_STATE_DIR,
|
||||
ancestorPid: argentServer.pid,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
getExitError: argentServer.getExitError,
|
||||
signal: setupSignal,
|
||||
});
|
||||
toolServerPort = toolServerState.port;
|
||||
toolServerToken = toolServerState.token;
|
||||
if (toolServerToken) {
|
||||
secrets.push(toolServerToken);
|
||||
}
|
||||
} catch (err) {
|
||||
const output = argentServer.getOutput();
|
||||
throw new SystemError(
|
||||
`${
|
||||
err instanceof Error ? err.message : `Timed out waiting for argent tool-server state.`
|
||||
}${output ? `\nArgent tool-server output:\n${output}` : ''}`
|
||||
);
|
||||
}
|
||||
if (hostStartup) {
|
||||
sessionHost = (await hostStartup).enforceValue();
|
||||
}
|
||||
setupSignal?.throwIfAborted();
|
||||
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
|
||||
const artifactPollSignal = signal
|
||||
? AbortSignal.any([signal, artifactPollAbortController.signal])
|
||||
: artifactPollAbortController.signal;
|
||||
artifactPollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
toolsUrl: `http://127.0.0.1:${toolServerPort}`,
|
||||
toolsAuthToken: toolServerToken,
|
||||
logger,
|
||||
signal: artifactPollSignal,
|
||||
});
|
||||
|
||||
eventCollection = await startArgentEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
eventLogPath: ARGENT_EVENT_LOG_PATH,
|
||||
logger,
|
||||
});
|
||||
|
||||
toolsTunnel = await startNgrokTunnelAsync({
|
||||
port: toolServerPort,
|
||||
subdomainPrefix: 'argent',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
rewriteHostHeader: true,
|
||||
healthCheck: { path: '/tools', statuses: [200, 401] },
|
||||
logger,
|
||||
});
|
||||
const publicToolsUrl = toolsTunnel.url;
|
||||
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
|
||||
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
|
||||
const webPreview = await sessionHost.openPreviewAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
});
|
||||
logger.info(
|
||||
webPreview.sharePageUrl
|
||||
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
|
||||
: 'Private preview URL unavailable for this session.'
|
||||
);
|
||||
|
||||
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
toolsUrl: publicToolsUrl,
|
||||
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
|
||||
webPreviewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture?.value,
|
||||
networkCaptureFields: inputs.network_capture_fields?.value,
|
||||
},
|
||||
logger,
|
||||
});
|
||||
if (sessionStart === 'stopped') {
|
||||
return;
|
||||
}
|
||||
{ runtimePlatform }
|
||||
);
|
||||
logger.info(
|
||||
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
maxDurationSeconds,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
sessionFailed = true;
|
||||
throw setupSignal?.aborted ? setupSignal.reason : error;
|
||||
} finally {
|
||||
startupAbortController.abort(new Error('Argent session ended.'));
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
teardown: [
|
||||
['Argent tunnel', toolsTunnel?.stopAsync()],
|
||||
[
|
||||
'Argent tool-server',
|
||||
(async () => {
|
||||
try {
|
||||
if (eventCollection) {
|
||||
await stopArgentEventCollectionSafelyAsync({
|
||||
eventCollection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
});
|
||||
// Start the potentially slow installation while Argent is being prepared.
|
||||
// On Linux expo-device-hub calls this again and awaits the same in-flight
|
||||
// setup before launching. On macOS this remains non-blocking, so only a
|
||||
// recording started in the first moments may miss ffmpeg.
|
||||
// Never rejects, so `void` is safe.
|
||||
void ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
|
||||
|
||||
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
|
||||
const argentExec = (args: string[]): { command: string; args: string[] } =>
|
||||
resolvePackageExec(packageManager, args);
|
||||
|
||||
const artifactPollAbortController = new AbortController();
|
||||
let artifactPollingPromise: Promise<void> | undefined;
|
||||
let eventCollection:
|
||||
| Awaited<ReturnType<typeof startArgentEventCollectionAsync>>
|
||||
| undefined;
|
||||
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let argentServer: ReturnType<typeof spawnDetached> | undefined;
|
||||
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let sessionFailed = false;
|
||||
const startHost = () =>
|
||||
startDeviceSessionHostAsync(
|
||||
ctx,
|
||||
{
|
||||
...iosStartup,
|
||||
separateLogPhase: true,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken },
|
||||
signal: startupSignal,
|
||||
onStartupError: error => startupAbortController.abort(error),
|
||||
...launch,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
},
|
||||
...(warmSession ? [createWarmDeviceSessionServer(ctx, logger, warmSession)] : [])
|
||||
).catch(error => {
|
||||
startupAbortController.abort(error);
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
if (iosStartup) {
|
||||
hostStartup = asyncResult(startHost());
|
||||
}
|
||||
let toolServerPort: number;
|
||||
let toolServerToken: string | undefined;
|
||||
if (warmSession) {
|
||||
// Adoption prepares the launcher's Argent server for the claimed device.
|
||||
sessionHost = (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
|
||||
const tools = warmSession.tools;
|
||||
if (!tools) {
|
||||
throw new Error('The warm Argent server was not prepared.');
|
||||
}
|
||||
toolServerPort = tools.port;
|
||||
toolServerToken = tools.token;
|
||||
} else {
|
||||
logger.info('Enabling the Argent artifacts list endpoint flag.');
|
||||
const enableArtifacts = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
|
||||
]);
|
||||
await runArgentSetupCommandAsync(
|
||||
enableArtifacts.command,
|
||||
enableArtifacts.args,
|
||||
{ env, logger },
|
||||
startupSignal
|
||||
);
|
||||
|
||||
logger.info('Enabling the Argent tool-server event log flag.');
|
||||
const enableEventLog = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_EVENT_LOG_FLAG,
|
||||
]);
|
||||
await runArgentSetupCommandAsync(
|
||||
enableEventLog.command,
|
||||
enableEventLog.args,
|
||||
{ env, logger },
|
||||
startupSignal
|
||||
);
|
||||
|
||||
logger.info('Disabling the Argent video watermark flag.');
|
||||
const disableVideoWatermark = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'disable',
|
||||
ARGENT_VIDEO_WATERMARK_FLAG,
|
||||
]);
|
||||
// The watermark is cosmetic, so a failure here must not fail the session.
|
||||
try {
|
||||
await runArgentSetupCommandAsync(
|
||||
disableVideoWatermark.command,
|
||||
disableVideoWatermark.args,
|
||||
{ env, logger },
|
||||
startupSignal
|
||||
);
|
||||
} catch (err) {
|
||||
startupSignal?.throwIfAborted();
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.'
|
||||
);
|
||||
}
|
||||
|
||||
startupSignal?.throwIfAborted();
|
||||
const startServer = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
'start',
|
||||
'--port',
|
||||
'0',
|
||||
'--idle-timeout',
|
||||
'0',
|
||||
'--force',
|
||||
]);
|
||||
logger.info(
|
||||
`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.`
|
||||
);
|
||||
// Keep Argent itself in foreground mode under the detached process. This preserves
|
||||
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
const secrets: string[] = [];
|
||||
argentServer = spawnDetached({
|
||||
command: startServer.command,
|
||||
args: startServer.args,
|
||||
env: {
|
||||
...env,
|
||||
ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH,
|
||||
ARGENT_EMULATOR_NO_WINDOW: '1',
|
||||
},
|
||||
logger,
|
||||
secrets,
|
||||
});
|
||||
if (argentServer.pid === undefined) {
|
||||
throw new SystemError(
|
||||
'Failed to start Argent: could not determine the PID of the launched process.'
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`);
|
||||
try {
|
||||
const toolServerState = await waitForArgentToolServerStateAsync({
|
||||
stateDir: ARGENT_STATE_DIR,
|
||||
ancestorPid: argentServer.pid,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
getExitError: argentServer.getExitError,
|
||||
signal: startupSignal,
|
||||
});
|
||||
toolServerPort = toolServerState.port;
|
||||
toolServerToken = toolServerState.token;
|
||||
if (toolServerToken) {
|
||||
secrets.push(toolServerToken);
|
||||
}
|
||||
} catch (err) {
|
||||
const output = argentServer.getOutput();
|
||||
throw new SystemError(
|
||||
`${
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: `Timed out waiting for argent tool-server state.`
|
||||
}${output ? `\nArgent tool-server output:\n${output}` : ''}`
|
||||
);
|
||||
}
|
||||
if (hostStartup) {
|
||||
sessionHost = (await hostStartup).enforceValue();
|
||||
}
|
||||
startupSignal?.throwIfAborted();
|
||||
}
|
||||
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
|
||||
const artifactPollSignal = signal
|
||||
? AbortSignal.any([signal, artifactPollAbortController.signal])
|
||||
: artifactPollAbortController.signal;
|
||||
artifactPollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
toolsUrl: `http://127.0.0.1:${toolServerPort}`,
|
||||
toolsAuthToken: toolServerToken,
|
||||
logger,
|
||||
signal: artifactPollSignal,
|
||||
});
|
||||
|
||||
eventCollection = await startArgentEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
eventLogPath: warmSession?.argentEventLogPath ?? ARGENT_EVENT_LOG_PATH,
|
||||
logger,
|
||||
});
|
||||
|
||||
toolsTunnel = await startNgrokTunnelAsync({
|
||||
port: toolServerPort,
|
||||
subdomainPrefix: 'argent',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
rewriteHostHeader: true,
|
||||
healthCheck: { path: '/tools', statuses: [200, 401] },
|
||||
logger,
|
||||
});
|
||||
const publicToolsUrl = toolsTunnel.url;
|
||||
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
|
||||
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
|
||||
startupSignal.throwIfAborted();
|
||||
const webPreview = await sessionHost.openPreviewAsync();
|
||||
logger.info(
|
||||
webPreview.sharePageUrl
|
||||
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
|
||||
: 'Private preview URL unavailable for this session.'
|
||||
);
|
||||
|
||||
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal: startupSignal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
toolsUrl: publicToolsUrl,
|
||||
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
|
||||
webPreviewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
if (sessionStart === 'stopped') {
|
||||
return;
|
||||
}
|
||||
|
||||
await startupWatch.completeAsync();
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
maxDurationSeconds,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
|
||||
}
|
||||
artifactPollAbortController.abort();
|
||||
try {
|
||||
await artifactPollingPromise;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture(
|
||||
'Could not finish Argent remote session artifact polling',
|
||||
error
|
||||
);
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Could not finish Argent remote session artifact polling.'
|
||||
);
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
startupSignal.reason instanceof DeviceRunSessionStoppedError &&
|
||||
!(error instanceof AggregateError)
|
||||
) {
|
||||
throw startupSignal.reason;
|
||||
}
|
||||
sessionFailed = true;
|
||||
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 () => {
|
||||
try {
|
||||
if (eventCollection) {
|
||||
await stopArgentEventCollectionSafelyAsync({
|
||||
eventCollection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
artifactPollAbortController.abort();
|
||||
try {
|
||||
await artifactPollingPromise;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not finish Argent remote session artifact polling', error);
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Could not finish Argent remote session artifact polling.'
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await argentServer?.stopAsync();
|
||||
}
|
||||
})();
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
teardown: [
|
||||
['Argent tunnel', toolsTunnel?.stopAsync()],
|
||||
['Argent tool-server', toolTeardown],
|
||||
[
|
||||
'session host',
|
||||
hostStartup?.then(async result => {
|
||||
if (warmSession) {
|
||||
// The warm host owns Argent too; leave it alive until the final downloads finish.
|
||||
await Promise.allSettled([toolTeardown]);
|
||||
}
|
||||
} finally {
|
||||
await argentServer?.stopAsync();
|
||||
}
|
||||
})(),
|
||||
if (result.reason instanceof AggregateError) {
|
||||
throw result.reason;
|
||||
}
|
||||
await result.value?.finishAsync();
|
||||
}),
|
||||
],
|
||||
],
|
||||
['session host', hostStartup?.then(result => result.value?.finishAsync())],
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
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();
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -464,36 +550,6 @@ export async function stopArgentEventCollectionSafelyAsync({
|
||||
}
|
||||
}
|
||||
|
||||
export function warnIfArgentPackageVersionCannotBeVerified({
|
||||
packageVersion,
|
||||
logger,
|
||||
}: {
|
||||
packageVersion: string | undefined;
|
||||
logger: bunyan;
|
||||
}): void {
|
||||
if (!packageVersion || packageVersion === 'latest') {
|
||||
return;
|
||||
}
|
||||
|
||||
const validVersion = semver.valid(packageVersion);
|
||||
if (!validVersion) {
|
||||
logger.warn(
|
||||
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer, ` +
|
||||
`but package_version "${packageVersion}" is not an exact semver version that EAS can verify. ` +
|
||||
`Continuing and letting the package manager resolve it.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (semver.lt(validVersion, MIN_ARGENT_REMOTE_SESSION_VERSION)) {
|
||||
throw new SystemError(
|
||||
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer. ` +
|
||||
`The requested package_version "${packageVersion}" is too old for the EAS remote-session API. ` +
|
||||
`Use "latest" or pass an exact version >= ${MIN_ARGENT_REMOTE_SESSION_VERSION}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function runArgentSetupCommandAsync(
|
||||
command: string,
|
||||
args: string[],
|
||||
|
||||
@@ -7,7 +7,13 @@ import {
|
||||
} from '@expo/steps';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
|
||||
import { startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import {
|
||||
createWarmDeviceSessionServer,
|
||||
runOnWarmIosSimulatorAsync,
|
||||
} from '../utils/warmDeviceSessionHost';
|
||||
import { type WarmIosSession } from '../utils/warmIosSession';
|
||||
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
@@ -38,13 +44,16 @@ import {
|
||||
const STARTUP_TIMEOUT_MS = 60_000;
|
||||
|
||||
export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
ctx: CustomBuildContext,
|
||||
{ warm = false }: { warm?: boolean } = {}
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_serve_sim_remote_session',
|
||||
id: warm ? 'start_warm_serve_sim_remote_session' : 'start_serve_sim_remote_session',
|
||||
name: 'Start web preview remote session',
|
||||
__metricsId: 'eas/start_serve_sim_remote_session',
|
||||
__metricsId: warm
|
||||
? 'eas/start_warm_serve_sim_remote_session'
|
||||
: 'eas/start_serve_sim_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createIosSessionStartupInputProviders(),
|
||||
@@ -88,103 +97,155 @@ 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 { launch, iosStartup } = await resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
|
||||
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
|
||||
installAppPath: inputs.install_app_path?.value as string | undefined,
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value,
|
||||
env,
|
||||
const startupStop = new AbortController();
|
||||
const watchSignal = signal
|
||||
? AbortSignal.any([signal, startupStop.signal])
|
||||
: startupStop.signal;
|
||||
const startupWatch = watchDeviceRunSessionStartup({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
signal,
|
||||
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
|
||||
});
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture?.value,
|
||||
networkCaptureFields: inputs.network_capture_fields?.value,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
|
||||
logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`);
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
|
||||
const sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
...iosStartup,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
signal,
|
||||
packageVersion,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
});
|
||||
|
||||
try {
|
||||
const webPreview = await sessionHost.openPreviewAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
});
|
||||
logger.info(
|
||||
webPreview.sharePageUrl
|
||||
? `Preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
|
||||
: 'Private preview URL unavailable for this session.'
|
||||
);
|
||||
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
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,
|
||||
deviceIdentifier: warmSession?.device.udid ?? deviceIdentifier,
|
||||
installAppPath: inputs.install_app_path?.value as string | undefined,
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value,
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
// WEB_PREVIEW_ONLY sessions read webPreviewUrl and webPreviewToken. Legacy SERVE_SIM
|
||||
// sessions read previewUrl and previewToken. The server drops the keys that the
|
||||
// session type does not use.
|
||||
remoteConfig: {
|
||||
webPreviewUrl: webPreview.previewPageUrl,
|
||||
previewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken
|
||||
? { webPreviewToken: webPreview.previewToken, previewToken: webPreview.previewToken }
|
||||
: {}),
|
||||
},
|
||||
logger,
|
||||
signal: startupSignal,
|
||||
});
|
||||
if (sessionStart === 'stopped') {
|
||||
return;
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture?.value,
|
||||
networkCaptureFields: inputs.network_capture_fields?.value,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
|
||||
logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`);
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
const sessionHost = await startDeviceSessionHostAsync(
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
maxDurationSeconds,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
getLastPreviewActivityAt: () => sessionHost.getLastPreviewActivityAt(),
|
||||
}
|
||||
: undefined,
|
||||
{
|
||||
...iosStartup,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken },
|
||||
signal: startupSignal,
|
||||
onStartupError: error => startupAbortController.abort(error),
|
||||
packageVersion,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
networkCapture,
|
||||
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
|
||||
? `Preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
|
||||
: 'Private preview URL unavailable for this session.'
|
||||
);
|
||||
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal: startupSignal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
// WEB_PREVIEW_ONLY sessions read webPreviewUrl and webPreviewToken. Legacy SERVE_SIM
|
||||
// sessions read previewUrl and previewToken. The server drops the keys that the
|
||||
// session type does not use.
|
||||
remoteConfig: {
|
||||
webPreviewUrl: webPreview.previewPageUrl,
|
||||
previewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken
|
||||
? {
|
||||
webPreviewToken: webPreview.previewToken,
|
||||
previewToken: webPreview.previewToken,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
if (sessionStart === 'stopped') {
|
||||
return;
|
||||
}
|
||||
|
||||
await startupWatch.completeAsync();
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
maxDurationSeconds,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
getLastPreviewActivityAt: () => sessionHost.getLastPreviewActivityAt(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} finally {
|
||||
await sessionHost.finishAsync();
|
||||
}
|
||||
};
|
||||
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 sessionHost.finishAsync();
|
||||
await startupWatch.finishAsync();
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { BuildJob, Platform } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
spawnAsync,
|
||||
} from '@expo/steps';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import fs from 'fs-extra';
|
||||
import { Client } from '@urql/core';
|
||||
import { graphql } from 'gql.tada';
|
||||
import path from 'node:path';
|
||||
|
||||
import { parseInfoPlistBuffer, readIpaInfoAsync } from './readIpaInfo';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
|
||||
export function createUpdateResolvedVersionBuildFunction(
|
||||
ctx: CustomBuildContext<BuildJob>
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'update_resolved_version',
|
||||
name: 'Update resolved version',
|
||||
__metricsId: 'eas/update_resolved_version',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'application_archive_path',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs }) => {
|
||||
try {
|
||||
const archivePathInput = inputs.application_archive_path.value as string | undefined;
|
||||
if (!archivePathInput) {
|
||||
stepCtx.logger.info('No application archive path provided, skipping.');
|
||||
return;
|
||||
}
|
||||
const archivePath = path.resolve(stepCtx.workingDirectory, archivePathInput);
|
||||
|
||||
const buildId = ctx.env.EAS_BUILD_ID;
|
||||
if (!buildId) {
|
||||
stepCtx.logger.info('EAS_BUILD_ID not set, skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { appVersion, appBuildVersion } =
|
||||
ctx.job.platform === Platform.IOS
|
||||
? await extractIosVersionAsync(archivePath)
|
||||
: await extractAndroidVersionAsync(archivePath);
|
||||
|
||||
if (!appVersion && !appBuildVersion) {
|
||||
stepCtx.logger.info('No resolved version found, skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
stepCtx.logger.info(
|
||||
`Resolved version: ${appVersion ?? 'N/A'} (${appBuildVersion ?? 'N/A'})`
|
||||
);
|
||||
|
||||
await updateResolvedVersionAsync(ctx.graphqlClient, buildId, {
|
||||
appVersion,
|
||||
appBuildVersion,
|
||||
});
|
||||
|
||||
stepCtx.logger.info('Updated resolved version on EAS.');
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Failed to update resolved version', error);
|
||||
stepCtx.logger.warn({ err: error }, 'Failed to update resolved version (non-fatal)');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function extractIosVersionAsync(
|
||||
archivePath: string
|
||||
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
|
||||
const ext = path.extname(archivePath).toLowerCase();
|
||||
|
||||
if (ext === '.app') {
|
||||
return await extractSimulatorAppVersionAsync(archivePath);
|
||||
}
|
||||
|
||||
const ipaInfo = await readIpaInfoAsync(archivePath);
|
||||
return {
|
||||
appVersion: ipaInfo.bundleShortVersion,
|
||||
appBuildVersion: ipaInfo.bundleVersion,
|
||||
};
|
||||
}
|
||||
|
||||
async function extractSimulatorAppVersionAsync(
|
||||
appPath: string
|
||||
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
|
||||
const infoPlistPath = path.join(appPath, 'Info.plist');
|
||||
|
||||
if (!(await fs.pathExists(infoPlistPath))) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const infoPlistBuffer = await fs.readFile(infoPlistPath);
|
||||
const infoPlist = parseInfoPlistBuffer(infoPlistBuffer);
|
||||
|
||||
return {
|
||||
appVersion:
|
||||
typeof infoPlist.CFBundleShortVersionString === 'string'
|
||||
? infoPlist.CFBundleShortVersionString
|
||||
: undefined,
|
||||
appBuildVersion:
|
||||
typeof infoPlist.CFBundleVersion === 'string' ? infoPlist.CFBundleVersion : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function extractAndroidVersionAsync(
|
||||
archivePath: string
|
||||
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
|
||||
const ext = path.extname(archivePath).toLowerCase();
|
||||
|
||||
if (ext === '.apk') {
|
||||
return await extractVersionFromApkAsync(archivePath);
|
||||
} else if (ext === '.aab') {
|
||||
return await extractVersionFromAabAsync(archivePath);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
async function extractVersionFromApkAsync(
|
||||
apkPath: string
|
||||
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
|
||||
const result = await spawnAsync('aapt2', ['dump', 'badging', apkPath], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
return parseAaptOutput(result.stdout);
|
||||
}
|
||||
|
||||
async function extractVersionFromAabAsync(
|
||||
aabPath: string
|
||||
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
|
||||
const result = await spawnAsync('bundletool', ['dump', 'manifest', '--bundle', aabPath], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
return parseManifestXml(result.stdout);
|
||||
}
|
||||
|
||||
export function parseAaptOutput(output: string): { appVersion?: string; appBuildVersion?: string } {
|
||||
// aapt2 does not escape apostrophes inside versionName, so stop at the next known
|
||||
// badging field (or the end of the line) rather than at the first closing quote.
|
||||
const versionNameMatch = output.match(
|
||||
/versionName='(.*?)'(?= split=| platformBuildVersionName=| platformBuildVersionCode=| compileSdkVersion=|\s*$)/m
|
||||
);
|
||||
const versionCodeMatch = output.match(/versionCode='([^']+)'/);
|
||||
|
||||
return {
|
||||
appVersion: versionNameMatch?.[1] || undefined,
|
||||
appBuildVersion: versionCodeMatch?.[1],
|
||||
};
|
||||
}
|
||||
|
||||
export function parseManifestXml(xml: string): { appVersion?: string; appBuildVersion?: string } {
|
||||
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_' });
|
||||
const parsed = parser.parse(xml);
|
||||
|
||||
const manifest = parsed?.manifest;
|
||||
if (!manifest) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const versionName = manifest['@_android:versionName'];
|
||||
const versionCode = manifest['@_android:versionCode'];
|
||||
|
||||
return {
|
||||
appVersion: versionName != null ? String(versionName) : undefined,
|
||||
appBuildVersion: versionCode != null ? String(versionCode) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateResolvedVersionAsync(
|
||||
graphqlClient: Client,
|
||||
buildId: string,
|
||||
{
|
||||
appVersion,
|
||||
appBuildVersion,
|
||||
}: {
|
||||
appVersion: string | undefined;
|
||||
appBuildVersion: string | undefined;
|
||||
}
|
||||
): Promise<void> {
|
||||
const result = await graphqlClient
|
||||
.mutation(
|
||||
graphql(`
|
||||
mutation UpdateResolvedVersionMutation(
|
||||
$buildId: ID!
|
||||
$appVersion: String
|
||||
$appBuildVersion: String
|
||||
) {
|
||||
build {
|
||||
updateBuildMetadata(
|
||||
buildId: $buildId
|
||||
metadata: {
|
||||
appVersion: $appVersion
|
||||
appBuildVersion: $appBuildVersion
|
||||
}
|
||||
) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`),
|
||||
{
|
||||
buildId,
|
||||
appVersion,
|
||||
appBuildVersion,
|
||||
}
|
||||
)
|
||||
.toPromise();
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
}
|
||||
@@ -57,11 +57,19 @@ type ActiveIosSimulatorRecording = IosSimulatorRecording & {
|
||||
hasExited: () => boolean;
|
||||
};
|
||||
|
||||
type AdoptedIosSimulatorRecording = {
|
||||
udid: string;
|
||||
serverToken: string;
|
||||
collectAsync(): Promise<void>;
|
||||
};
|
||||
|
||||
type IosSimulatorRecordingSession = {
|
||||
env: Env;
|
||||
logger: bunyan;
|
||||
recordingsRootDirectory: string;
|
||||
activeRecordings: Map<IosSimulatorUuid, ActiveIosSimulatorRecording>;
|
||||
adoptedRecordings: Map<string, AdoptedIosSimulatorRecording>;
|
||||
runtimeDisplayNames: Map<string, string>;
|
||||
completedRecordings: IosSimulatorRecording[];
|
||||
recordingFailureCounts: Map<IosSimulatorUuid, number>;
|
||||
/** Failed attempts for the whole session; unlike recordingFailureCounts, never reset. */
|
||||
@@ -81,6 +89,52 @@ export namespace IosSimulatorRecordingUtils {
|
||||
serveSimPackageSpec ??= packageSpec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserves this job's recording output before warm serve-sim adoption, which starts the
|
||||
* recording. The poller skips that device, so it never starts a second recorder.
|
||||
*/
|
||||
export async function prepareAdoptedRecordingAsync({
|
||||
udid,
|
||||
deviceName,
|
||||
serverToken,
|
||||
}: {
|
||||
udid: string;
|
||||
deviceName: string;
|
||||
serverToken: string;
|
||||
}): Promise<{ output: string; recordingId: string } | undefined> {
|
||||
const session = activeIosSimulatorRecordingSession;
|
||||
if (!session) {
|
||||
return undefined;
|
||||
}
|
||||
udid = udid.toUpperCase();
|
||||
const recordingId = randomUUID();
|
||||
const output = path.join(session.recordingsRootDirectory, recordingId);
|
||||
const startedAt = new Date();
|
||||
await mkdir(output, { recursive: true });
|
||||
session.adoptedRecordings.set(recordingId, {
|
||||
udid,
|
||||
serverToken,
|
||||
async collectAsync() {
|
||||
// Keep real footage eligible for the manifest wait if serve-sim shutdown was unconfirmed.
|
||||
// The uploader preserves the manifest's partial or failed status.
|
||||
await access(path.join(output, 'session.json')).catch(() =>
|
||||
access(path.join(output, 'recording.mp4'))
|
||||
);
|
||||
session.completedRecordings.push({
|
||||
id: recordingId,
|
||||
udid: udid as IosSimulatorUuid,
|
||||
deviceName,
|
||||
runtimeDisplayName: session.runtimeDisplayNames.get(udid) ?? 'Unknown iOS runtime',
|
||||
outputDirectory: output,
|
||||
startedAt,
|
||||
getOutput: () => '',
|
||||
hasStarted: () => true,
|
||||
});
|
||||
},
|
||||
});
|
||||
return { output, recordingId };
|
||||
}
|
||||
|
||||
export async function startAsync({ env, logger }: { env: Env; logger: bunyan }): Promise<void> {
|
||||
if (activeIosSimulatorRecordingSession) {
|
||||
logger.info('iOS Simulator screen recording polling is already running.');
|
||||
@@ -95,6 +149,8 @@ export namespace IosSimulatorRecordingUtils {
|
||||
logger,
|
||||
recordingsRootDirectory,
|
||||
activeRecordings: new Map(),
|
||||
adoptedRecordings: new Map(),
|
||||
runtimeDisplayNames: new Map(),
|
||||
completedRecordings: [],
|
||||
recordingFailureCounts: new Map(),
|
||||
failedAttemptCounts: new Map(),
|
||||
@@ -190,6 +246,17 @@ export namespace IosSimulatorRecordingUtils {
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
[...session.adoptedRecordings.values()].map(recording =>
|
||||
recording.collectAsync().catch(err => {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Could not collect the warm Simulator video recording after shutdown.'
|
||||
);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const completedRecordings = [...session.completedRecordings].sort(
|
||||
(a, b) => a.startedAt.getTime() - b.startedAt.getTime()
|
||||
);
|
||||
@@ -243,6 +310,9 @@ async function pollIosSimulatorRecordingsAsync(
|
||||
break;
|
||||
}
|
||||
listDevicesErrorCount = 0;
|
||||
for (const device of bootedDevices) {
|
||||
session.runtimeDisplayNames.set(device.udid.toUpperCase(), device.runtimeDisplayName);
|
||||
}
|
||||
const readyServers = new Map(
|
||||
(await readServeSimServersAsync(SERVE_SIM_STATE_DIR)).flatMap(server =>
|
||||
server.token ? [[server.udid, server.token] as const] : []
|
||||
@@ -262,7 +332,14 @@ async function pollIosSimulatorRecordingsAsync(
|
||||
for (const device of bootedDevices) {
|
||||
const token = readyServers.get(device.udid);
|
||||
const packageSpec = serveSimPackageSpec;
|
||||
if (!token || !packageSpec) {
|
||||
if (
|
||||
!token ||
|
||||
!packageSpec ||
|
||||
[...session.adoptedRecordings.values()].some(
|
||||
recording =>
|
||||
recording.udid === device.udid.toUpperCase() && recording.serverToken === token
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (session.serveBearerTokens.get(device.udid) !== token) {
|
||||
|
||||
@@ -3,18 +3,20 @@ import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { type ChildProcess } from 'node:child_process';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { rm, writeFile } from 'node:fs/promises';
|
||||
import { readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils';
|
||||
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../serveSimMetricsRecorder');
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
@@ -566,3 +568,171 @@ test('records every booted device with the first serve-sim package', async () =>
|
||||
currentTime += 311_000;
|
||||
await IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
});
|
||||
|
||||
test('does not request adopted video when job recording is disabled', async () => {
|
||||
await expect(
|
||||
IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
|
||||
udid,
|
||||
deviceName: 'iPhone 17 Pro',
|
||||
serverToken: 'job-token',
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each(['complete', 'failed'])(
|
||||
'collects an adopted %s recording unchanged after host shutdown without a control request',
|
||||
async status => {
|
||||
let releaseDevices!: (devices: never[]) => void;
|
||||
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
releaseDevices = resolve as never;
|
||||
})
|
||||
);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:3200', token: 'job-token' }]);
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
const recording = await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
|
||||
udid,
|
||||
deviceName: 'iPhone 17 Pro',
|
||||
serverToken: 'job-token',
|
||||
});
|
||||
if (!recording) {
|
||||
throw new Error('Expected the adopted recording output.');
|
||||
}
|
||||
releaseDevices([{ udid, name: 'iPhone 17 Pro', runtimeDisplayName: 'iOS 26.4' }] as never);
|
||||
await setTimeout(20);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
const manifest = JSON.stringify({
|
||||
recording: 'recording.mp4',
|
||||
status,
|
||||
...(status === 'failed' && { error: 'Recording interrupted; partial footage preserved.' }),
|
||||
});
|
||||
await writeFile(`${recording.output}/session.json`, manifest);
|
||||
jest.mocked(turtleFetch).mockRejectedValue(new Error('serve-sim has stopped'));
|
||||
expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toEqual([
|
||||
{
|
||||
udid,
|
||||
deviceName: 'iPhone 17 Pro',
|
||||
runtimeDisplayName: 'iOS 26.4',
|
||||
directory: recording.output,
|
||||
},
|
||||
]);
|
||||
expect(await readFile(`${recording.output}/session.json`, 'utf8')).toBe(manifest);
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
test('waits for an adopted recording manifest when its MP4 already exists', async () => {
|
||||
jest.mocked(readServeSimServersAsync).mockResolvedValue([]);
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
const recording = await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
|
||||
udid,
|
||||
deviceName: 'iPhone 17 Pro',
|
||||
serverToken: 'job-token',
|
||||
});
|
||||
if (!recording) {
|
||||
throw new Error('Expected the adopted recording output.');
|
||||
}
|
||||
await writeFile(`${recording.output}/recording.mp4`, 'partial footage');
|
||||
let finished = false;
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger }).then(recordings => {
|
||||
finished = true;
|
||||
return recordings;
|
||||
});
|
||||
await setTimeout(20);
|
||||
expect(finished).toBe(false);
|
||||
const manifest = JSON.stringify({ recording: 'recording.mp4' });
|
||||
await writeFile(`${recording.output}/session.json`, manifest);
|
||||
|
||||
expect(await finishing).toEqual([
|
||||
{
|
||||
udid,
|
||||
deviceName: 'iPhone 17 Pro',
|
||||
runtimeDisplayName: 'iOS 26.4',
|
||||
directory: recording.output,
|
||||
},
|
||||
]);
|
||||
expect(await readFile(`${recording.output}/session.json`, 'utf8')).toBe(manifest);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('bounds the manifest wait for an adopted MP4 and reports missing metadata', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
jest.mocked(readServeSimServersAsync).mockResolvedValue([]);
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
const recording = await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
|
||||
udid,
|
||||
deviceName: 'iPhone 17 Pro',
|
||||
serverToken: 'job-token',
|
||||
});
|
||||
if (!recording) {
|
||||
throw new Error('Expected the adopted recording output.');
|
||||
}
|
||||
await writeFile(`${recording.output}/recording.mp4`, 'partial footage');
|
||||
let finished = false;
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger }).then(recordings => {
|
||||
finished = true;
|
||||
return recordings;
|
||||
});
|
||||
await setTimeout(50);
|
||||
expect(finished).toBe(false);
|
||||
currentTime += 311_000;
|
||||
|
||||
expect(await finishing).toEqual([]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ recorderOutput: '' },
|
||||
'Screen recording for iPhone 17 Pro ended without a manifest; its footage cannot be uploaded.'
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'iOS Simulator screen recording ended without a manifest',
|
||||
{ level: 'warning', extras: { deviceName: 'iPhone 17 Pro' } }
|
||||
);
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not wait for an empty adopted recording reservation', async () => {
|
||||
jest.mocked(readServeSimServersAsync).mockResolvedValue([]);
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
|
||||
udid,
|
||||
deviceName: 'iPhone',
|
||||
serverToken: 'job-token',
|
||||
});
|
||||
expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toEqual([]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
'Could not collect the warm Simulator video recording after shutdown.'
|
||||
);
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a reserved adopted recording does not suppress recording a replacement server token', async () => {
|
||||
let releaseDevices!: (devices: never[]) => void;
|
||||
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
releaseDevices = resolve as never;
|
||||
})
|
||||
);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:3200', token: 'replacement-token' }]);
|
||||
mockFailingRecorder();
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
|
||||
udid,
|
||||
deviceName: 'iPhone',
|
||||
serverToken: 'old-token',
|
||||
});
|
||||
jest.mocked(turtleFetch).mockRejectedValue(new Error('old server stopped'));
|
||||
releaseDevices([{ udid, name: 'iPhone', runtimeDisplayName: 'iOS 26.4' }] as never);
|
||||
await waitForRecorderSpawnAsync();
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import fetch from 'node-fetch';
|
||||
import { Readable } from 'node:stream';
|
||||
import timers from 'node:timers/promises';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
@@ -15,6 +16,7 @@ jest.mock('../deviceRunSessionArtifacts');
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('node-fetch');
|
||||
|
||||
const sleepWithSignal = timers.setTimeout;
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
async function readStreamAsync(stream: NodeJS.ReadableStream): Promise<void> {
|
||||
@@ -276,6 +278,14 @@ describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
describe(pollAgentDeviceArtifactsForUploadAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
// Keep fake-clock tests deterministic; cancellation tests use the native abortable timer.
|
||||
jest
|
||||
.spyOn(timers, 'setTimeout')
|
||||
.mockImplementation((delay, value, options) =>
|
||||
options?.signal
|
||||
? sleepWithSignal(delay, value, options)
|
||||
: new Promise(resolve => setTimeout(resolve, delay, value))
|
||||
);
|
||||
jest.mocked(fetch).mockReset();
|
||||
jest.mocked(Sentry.capture).mockReset();
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockReset();
|
||||
@@ -284,6 +294,257 @@ describe(pollAgentDeviceArtifactsForUploadAsync, () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllTimers();
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('cancels a warm poller during its wait without another inventory request', async () => {
|
||||
jest.useRealTimers();
|
||||
const controller = new AbortController();
|
||||
jest.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ artifacts: [] })));
|
||||
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
daemonUrl: 'http://127.0.0.1:1234',
|
||||
daemonToken: 'token',
|
||||
logger: createLoggerMock(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
controller.abort();
|
||||
await polling;
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ signal: controller.signal })
|
||||
);
|
||||
});
|
||||
|
||||
it('cancels an in-flight warm artifact transfer and drains the poller', async () => {
|
||||
jest.useRealTimers();
|
||||
const controller = new AbortController();
|
||||
let downloading!: () => void;
|
||||
const started = new Promise<void>(resolve => {
|
||||
downloading = resolve;
|
||||
});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ artifacts: [{ id: 'new-artifact', filename: 'new.png' }] }))
|
||||
)
|
||||
.mockImplementationOnce(async (_url, options) => {
|
||||
const signal = options?.signal as AbortSignal;
|
||||
expect(signal).toBe(controller.signal);
|
||||
downloading();
|
||||
return await new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
});
|
||||
});
|
||||
const logger = createLoggerMock();
|
||||
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
daemonUrl: 'http://127.0.0.1:1234',
|
||||
daemonToken: 'token',
|
||||
logger,
|
||||
signal: controller.signal,
|
||||
});
|
||||
await started;
|
||||
controller.abort(new Error('session ended'));
|
||||
await polling;
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(uploadDeviceRunSessionArtifactAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('finishes an active cloud upload and collects final artifacts after normal stop', async () => {
|
||||
jest.useRealTimers();
|
||||
const stopPolling = new AbortController();
|
||||
const cancellation = new AbortController();
|
||||
let releaseUpload!: () => void;
|
||||
let uploadStarted!: () => void;
|
||||
const pendingUpload = new Promise<void>(resolve => {
|
||||
releaseUpload = resolve;
|
||||
});
|
||||
const started = new Promise<void>(resolve => {
|
||||
uploadStarted = resolve;
|
||||
});
|
||||
let inventories = 0;
|
||||
const first = { id: 'first', filename: 'first.png' };
|
||||
const last = { id: 'last', filename: 'last.png' };
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockImplementation(async url =>
|
||||
String(url).endsWith('/artifacts')
|
||||
? new Response(
|
||||
JSON.stringify({ artifacts: ++inventories === 1 ? [first] : [first, last] })
|
||||
)
|
||||
: new Response(Readable.from(['png']))
|
||||
);
|
||||
let transferSignal: AbortSignal | undefined;
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_ctx, options) => {
|
||||
await readStreamAsync(options.openStream());
|
||||
if (options.artifactId === 'first') {
|
||||
transferSignal = options.signal;
|
||||
uploadStarted();
|
||||
await pendingUpload;
|
||||
}
|
||||
});
|
||||
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
daemonUrl: 'http://127.0.0.1:1234',
|
||||
daemonToken: 'token',
|
||||
logger: createLoggerMock(),
|
||||
signal: cancellation.signal,
|
||||
stopPollingSignal: stopPolling.signal,
|
||||
});
|
||||
let finished = false;
|
||||
void polling.then(() => {
|
||||
finished = true;
|
||||
});
|
||||
try {
|
||||
await started;
|
||||
stopPolling.abort();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(transferSignal?.aborted).toBe(false);
|
||||
expect(finished).toBe(false);
|
||||
releaseUpload();
|
||||
await polling;
|
||||
expect(inventories).toBe(2);
|
||||
expect(
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mock.calls.map(([, options]) => options.artifactId)
|
||||
).toEqual(['first', 'last']);
|
||||
} finally {
|
||||
releaseUpload();
|
||||
cancellation.abort();
|
||||
await polling;
|
||||
}
|
||||
});
|
||||
|
||||
it('checks for an artifact created between the last poll and normal stop', async () => {
|
||||
jest.useRealTimers();
|
||||
const stopPolling = new AbortController();
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ artifacts: [] })))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ artifacts: [{ id: 'last', filename: 'last.png' }] }))
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(Readable.from(['png'])));
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_ctx, options) => {
|
||||
await readStreamAsync(options.openStream());
|
||||
});
|
||||
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
daemonUrl: 'http://127.0.0.1:1234',
|
||||
daemonToken: 'token',
|
||||
logger: createLoggerMock(),
|
||||
stopPollingSignal: stopPolling.signal,
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
stopPolling.abort();
|
||||
await polling;
|
||||
expect(fetch).toHaveBeenCalledTimes(3);
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ artifactId: 'last' })
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a failed final inventory after normal stop', async () => {
|
||||
jest.useRealTimers();
|
||||
const stopPolling = new AbortController();
|
||||
const logger = createLoggerMock();
|
||||
const error = new Error('daemon is not ready');
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ artifacts: [] })))
|
||||
.mockRejectedValueOnce(error);
|
||||
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
daemonUrl: 'http://127.0.0.1:1234',
|
||||
daemonToken: 'token',
|
||||
logger,
|
||||
stopPollingSignal: stopPolling.signal,
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
stopPolling.abort();
|
||||
await polling;
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: error, failedArtifactListCount: 1 },
|
||||
'Could not list agent-device remote session artifacts.'
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cancels a graceful collector immediately when the job is cancelled', async () => {
|
||||
jest.useRealTimers();
|
||||
const stopPolling = new AbortController();
|
||||
const cancellation = new AbortController();
|
||||
let entered!: () => void;
|
||||
const started = new Promise<void>(resolve => {
|
||||
entered = resolve;
|
||||
});
|
||||
jest.mocked(fetch).mockImplementation(async (_url, options) => {
|
||||
const signal = options?.signal as AbortSignal;
|
||||
entered();
|
||||
return await new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
});
|
||||
});
|
||||
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
daemonUrl: 'http://127.0.0.1:1234',
|
||||
daemonToken: 'token',
|
||||
logger: createLoggerMock(),
|
||||
signal: cancellation.signal,
|
||||
stopPollingSignal: stopPolling.signal,
|
||||
});
|
||||
await started;
|
||||
cancellation.abort();
|
||||
await polling;
|
||||
expect(stopPolling.signal.aborted).toBe(false);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cancels a stuck transfer after the normal-stop drain budget expires', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
|
||||
const stopPolling = new AbortController();
|
||||
const logger = createLoggerMock();
|
||||
let entered!: () => void;
|
||||
const started = new Promise<void>(resolve => {
|
||||
entered = resolve;
|
||||
});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ artifacts: [{ id: 'large', filename: 'large.mp4' }] }))
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(Readable.from(['video'])));
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_ctx, options) => {
|
||||
await readStreamAsync(options.openStream());
|
||||
entered();
|
||||
return await new Promise((_resolve, reject) => {
|
||||
options.signal!.addEventListener('abort', () => reject(options.signal!.reason), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
daemonUrl: 'http://127.0.0.1:1234',
|
||||
daemonToken: 'token',
|
||||
logger,
|
||||
stopPollingSignal: stopPolling.signal,
|
||||
});
|
||||
await started;
|
||||
stopPolling.abort();
|
||||
await jest.advanceTimersByTimeAsync(30_000);
|
||||
await polling;
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Timed out draining agent-device artifacts; some session artifacts may not have been uploaded.'
|
||||
);
|
||||
});
|
||||
|
||||
it('reports artifact listing errors on every fifth consecutive failure', async () => {
|
||||
|
||||
@@ -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,57 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
import { createColdIosSessionAsync } from '../coldIosSession';
|
||||
import { spawnDetached } from '../remoteDeviceRunSession';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../remoteDeviceRunSession', () => ({
|
||||
fetchWebPreviewTurnArgsAsync: jest.fn(async () => []),
|
||||
findAvailablePortAsync: jest.fn(async () => 3200),
|
||||
spawnDetached: jest.fn(() => ({ stopAsync: jest.fn() })),
|
||||
}));
|
||||
|
||||
const STATE_FILE = '/tmp/expo-simulator-startup.json';
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as any;
|
||||
|
||||
async function startServerAsync(signal?: AbortSignal) {
|
||||
const server = await createColdIosSessionAsync({} as any, { env: {}, logger });
|
||||
return await server.startAsync({
|
||||
command: 'serve-sim',
|
||||
args: [],
|
||||
env: {},
|
||||
logger,
|
||||
secrets: [],
|
||||
stopGracePeriodMs: 1000,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: 'current-boot\n' } as never);
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
await fs.mkdir('/tmp', { recursive: true });
|
||||
await fs.writeFile(STATE_FILE, JSON.stringify({ status: 'stopping' }));
|
||||
});
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', platform);
|
||||
});
|
||||
|
||||
it('starts serve-sim only after warm Simulator cleanup finished', async () => {
|
||||
const starting = startServerAsync();
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
await fs.writeFile(STATE_FILE, JSON.stringify({ status: 'disabled', bootUuid: 'current-boot' }));
|
||||
await starting;
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not start serve-sim when the job is cancelled while cleanup runs', async () => {
|
||||
const controller = new AbortController();
|
||||
const starting = startServerAsync(controller.signal);
|
||||
// Cleanup is still running; the cancellation alone must end the wait.
|
||||
controller.abort(new Error('cancelled'));
|
||||
await expect(starting).rejects.toThrow('cancelled');
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
|
||||
import { validateControllerVersion } from '../controllerVersion';
|
||||
|
||||
describe(validateControllerVersion, () => {
|
||||
const minimumVersion = '0.16.0';
|
||||
const controller = { packageName: 'example-controller', minimumVersion };
|
||||
const warn = jest.fn();
|
||||
const logger = { warn } as unknown as bunyan;
|
||||
const supportedVersion = '999.0.0';
|
||||
const oldVersion = '0.0.0';
|
||||
|
||||
beforeEach(() => {
|
||||
warn.mockClear();
|
||||
});
|
||||
|
||||
it('allows the default latest package version', () => {
|
||||
expect(() =>
|
||||
validateControllerVersion({ ...controller, packageVersion: undefined, logger })
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
validateControllerVersion({ ...controller, packageVersion: 'latest', logger })
|
||||
).not.toThrow();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows exact supported semver versions', () => {
|
||||
expect(() =>
|
||||
validateControllerVersion({ ...controller, packageVersion: minimumVersion, logger })
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
validateControllerVersion({ ...controller, packageVersion: supportedVersion, logger })
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
validateControllerVersion({ ...controller, packageVersion: `v${supportedVersion}`, logger })
|
||||
).not.toThrow();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects exact semver versions that are too old', () => {
|
||||
expect(() =>
|
||||
validateControllerVersion({ ...controller, packageVersion: oldVersion, logger })
|
||||
).toThrow(SystemError);
|
||||
expect(() =>
|
||||
validateControllerVersion({ ...controller, packageVersion: oldVersion, logger })
|
||||
).toThrow(`Use "latest" or pass an exact version >= ${minimumVersion}`);
|
||||
});
|
||||
|
||||
it('warns for package tags or ranges that cannot be verified', () => {
|
||||
expect(() =>
|
||||
validateControllerVersion({ ...controller, packageVersion: 'next', logger })
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
validateControllerVersion({ ...controller, packageVersion: `^${minimumVersion}`, logger })
|
||||
).not.toThrow();
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Continuing and letting the package manager resolve it.')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readIosApplicationIdentifierAsync } from '../iosAppArtifact';
|
||||
import { isPreinstalledExpoGoAsync, readIosApplicationIdentifierAsync } from '../iosAppArtifact';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.unmock('fs');
|
||||
@@ -61,3 +61,59 @@ describe(readIosApplicationIdentifierAsync, () => {
|
||||
).rejects.toMatchObject({ errorCode: 'EAS_INSTALL_BUILD_MISSING_IDENTIFIER' });
|
||||
});
|
||||
});
|
||||
|
||||
describe(isPreinstalledExpoGoAsync, () => {
|
||||
const preinstalled = {
|
||||
bundleIdentifier: 'host.exp.Exponent',
|
||||
shortVersion: '58.0.7',
|
||||
version: '58.0.7',
|
||||
};
|
||||
function plist(values: Record<string, string>) {
|
||||
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => ({
|
||||
stdout: `${values[args[1]]}\n`,
|
||||
})) as never);
|
||||
}
|
||||
const app = (shortVersion: string, bundleIdentifier = 'host.exp.Exponent') => ({
|
||||
CFBundleIdentifier: bundleIdentifier,
|
||||
CFBundleShortVersionString: shortVersion,
|
||||
CFBundleVersion: shortVersion,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(spawn).mockReset();
|
||||
});
|
||||
|
||||
it('matches the preinstalled Expo Go without asking the Simulator', async () => {
|
||||
plist(app('58.0.7'));
|
||||
await expect(
|
||||
isPreinstalledExpoGoAsync({ appPath: '/job/Expo Go.app', preinstalled, env: {} })
|
||||
).resolves.toBe(true);
|
||||
expect(jest.mocked(spawn).mock.calls.every(([command]) => command === 'plutil')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match a different Expo Go version', async () => {
|
||||
plist(app('58.0.6'));
|
||||
await expect(
|
||||
isPreinstalledExpoGoAsync({ appPath: '/job/Expo Go.app', preinstalled, env: {} })
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('never matches a custom build, even with the same version', async () => {
|
||||
plist(app('58.0.7', 'com.example.app'));
|
||||
await expect(
|
||||
isPreinstalledExpoGoAsync({
|
||||
appPath: '/job/Example.app',
|
||||
preinstalled: { ...preinstalled, bundleIdentifier: 'com.example.app' },
|
||||
env: {},
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not match an app it cannot read', async () => {
|
||||
jest.mocked(spawn).mockRejectedValue(new Error('plutil failed'));
|
||||
await expect(
|
||||
isPreinstalledExpoGoAsync({ appPath: '/job/Expo Go.app', preinstalled, env: {} })
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -1087,7 +1087,13 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockReset();
|
||||
jest.mocked(spawn).mockReset();
|
||||
jest.mocked(ngrok.forward).mockReset();
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
url: () => 'https://preview.example.test',
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
@@ -1150,6 +1156,7 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
it('gives serve-sim its recording grace period on shutdown', async () => {
|
||||
const spawnDetachedSpy = jest.spyOn(remoteDeviceRunSession, 'spawnDetached');
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
@@ -1167,6 +1174,7 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
'passes private sharing flags directly to serve-sim version %s',
|
||||
async packageVersion => {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
@@ -1195,6 +1203,7 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
const privatePreviewUrl =
|
||||
'https://expo.dev/accounts/owner/projects/app/simulator-sessions/session-id/preview';
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
@@ -1202,7 +1211,7 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
disableShare: true,
|
||||
privatePreviewUrl,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
|
||||
const preview = await host.openPreviewAsync();
|
||||
|
||||
const serveSimArgs = jest
|
||||
.mocked(spawn)
|
||||
@@ -1222,13 +1231,14 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
disableShare: true,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
|
||||
const preview = await host.openPreviewAsync();
|
||||
|
||||
const serveSimArgs = jest
|
||||
.mocked(spawn)
|
||||
@@ -1255,13 +1265,14 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>);
|
||||
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
packageVersion,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
|
||||
const preview = await host.openPreviewAsync();
|
||||
|
||||
const spawnCalls = jest.mocked(spawn).mock.calls;
|
||||
expect(spawnCalls[0]).toEqual([
|
||||
@@ -1318,12 +1329,13 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
} as never);
|
||||
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
|
||||
const preview = await host.openPreviewAsync();
|
||||
|
||||
expect(preview.previewToken).toBe('tok-1');
|
||||
expect(preview.apiUrl).toBe('https://preview.example.test');
|
||||
@@ -1353,12 +1365,13 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
} as never);
|
||||
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: { ...env, EXPO_STAGING: '1' },
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
|
||||
const preview = await host.openPreviewAsync();
|
||||
|
||||
expect(preview.previewPageUrl).toMatch(
|
||||
/^https:\/\/staging\.expo\.dev\/simulator-preview\/[a-f0-9]{32}$/
|
||||
@@ -1378,6 +1391,7 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
|
||||
await expect(
|
||||
startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
@@ -1395,12 +1409,13 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
} as never);
|
||||
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
|
||||
const preview = await host.openPreviewAsync();
|
||||
|
||||
expect(preview.previewToken).toBe('hub-token');
|
||||
const [, args] = jest.mocked(spawn).mock.calls.find(([command]) => command === 'npx')!;
|
||||
@@ -1417,13 +1432,14 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
} as never);
|
||||
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
packageVersion,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
|
||||
const preview = await host.openPreviewAsync();
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
@@ -1459,13 +1475,14 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
|
||||
const bunEnv = { ...env, EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' };
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: bunEnv,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
packageVersion: '4.5.6',
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
|
||||
const preview = await host.openPreviewAsync();
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
@@ -1494,12 +1511,13 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
} as never);
|
||||
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: { ...env, EAS_FALLBACK_PACKAGE_MANAGER: 'bun' },
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
|
||||
const preview = await host.openPreviewAsync();
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
@@ -1525,6 +1543,7 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
} as never);
|
||||
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
@@ -1551,6 +1570,7 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
it('refuses to launch an application on Linux, where expo-device-hub cannot', async () => {
|
||||
await expect(
|
||||
startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
@@ -1831,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();
|
||||
@@ -1849,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();
|
||||
@@ -2030,6 +2094,65 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote configuration cancellation', () => {
|
||||
beforeEach(() => jest.mocked(turtleFetch).mockReset());
|
||||
|
||||
it('does not report readiness for an already stopped startup', async () => {
|
||||
const controller = new AbortController();
|
||||
const stopped = new Error('startup stopped');
|
||||
controller.abort(stopped);
|
||||
|
||||
await expect(
|
||||
remoteDeviceRunSession.uploadRemoteSessionConfigAsync({
|
||||
ctx: createCtxMock(),
|
||||
deviceRunSessionId: 'drs-id',
|
||||
remoteConfig: {},
|
||||
logger: createLoggerMock(),
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toBe(stopped);
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards cancellation to the in-flight readiness HTTP request', async () => {
|
||||
const controller = new AbortController();
|
||||
const ctx = createCtxMock();
|
||||
ctx.env.EAS_DEVICE_RUN_SESSION_TOKEN = 'worker-token';
|
||||
const stopped = new Error('session cancelled');
|
||||
let entered!: () => void;
|
||||
const requested = new Promise<void>(resolve => {
|
||||
entered = resolve;
|
||||
});
|
||||
let requestAborted = false;
|
||||
jest.mocked(turtleFetch).mockImplementation(
|
||||
async (_url, _method, options) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
entered();
|
||||
options!.signal!.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
requestAborted = true;
|
||||
reject(new Error('ready request aborted'));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
})
|
||||
);
|
||||
const reporting = remoteDeviceRunSession.uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
remoteConfig: {},
|
||||
logger: createLoggerMock(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
await requested;
|
||||
controller.abort(stopped);
|
||||
|
||||
await expect(reporting).rejects.toBe(stopped);
|
||||
expect(requestAborted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
const spawnMock = jest.mocked(spawn);
|
||||
let binDirectory: string;
|
||||
|
||||
@@ -22,29 +22,17 @@ it('uploads NDJSON with explicit user-app scope', async () => {
|
||||
kind: 'simulator-log',
|
||||
filename: 'app-logs.ndjson',
|
||||
size: 11,
|
||||
attempts: 1,
|
||||
metadata: expect.objectContaining({ scope: 'user-apps', udid: 'A' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('skips empty and missing files', async () => {
|
||||
await uploadServeSimLogsFileAsync(ctx, args);
|
||||
await writeFile(filePath, '');
|
||||
await uploadServeSimLogsFileAsync(ctx, args);
|
||||
expect(uploadDeviceRunSessionArtifactAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aborts the upload on a file read error without an unhandled error', async () => {
|
||||
it('warns and resolves when the upload fails', async () => {
|
||||
await writeFile(filePath, '{}\n');
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { openStream, signal }) => {
|
||||
const aborted = new Promise<void>((_resolve, reject) => {
|
||||
signal!.addEventListener('abort', () => reject(signal!.reason), { once: true });
|
||||
});
|
||||
openStream().destroy(new Error('read failure'));
|
||||
await aborted;
|
||||
});
|
||||
.mockRejectedValueOnce(new Error('storage unavailable'));
|
||||
await expect(uploadServeSimLogsFileAsync(ctx, args)).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import { uploadDeviceRunSessionFileAsync } from '../uploadDeviceRunSessionFile';
|
||||
|
||||
jest.mock('../deviceRunSessionArtifacts');
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const upload = jest.mocked(uploadDeviceRunSessionArtifactAsync);
|
||||
const describeFile = (size: number) => ({
|
||||
deviceRunSessionId: 'session',
|
||||
artifactId: 'log',
|
||||
name: `log (${size} bytes)`,
|
||||
filename: 'log.txt',
|
||||
kind: 'simulator-log',
|
||||
});
|
||||
let directory: string;
|
||||
let filePath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(path.join(os.tmpdir(), 'device-run-session-file-'));
|
||||
filePath = path.join(directory, 'log.txt');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function uploadFile(options: { signal?: AbortSignal; attempts?: number } = {}): Promise<boolean> {
|
||||
return uploadDeviceRunSessionFileAsync(ctx, { filePath, describe: describeFile, ...options });
|
||||
}
|
||||
|
||||
it('skips an empty file without uploading', async () => {
|
||||
await writeFile(filePath, '');
|
||||
expect(await uploadFile()).toBe(false);
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a missing file before uploading', async () => {
|
||||
await expect(uploadFile()).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('describes the file by its size and forwards attempts and the caller signal', async () => {
|
||||
await writeFile(filePath, 'abc');
|
||||
const controller = new AbortController();
|
||||
expect(await uploadFile({ signal: controller.signal, attempts: 2 })).toBe(true);
|
||||
expect(upload).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ name: 'log (3 bytes)', size: 3, attempts: 2, filename: 'log.txt' })
|
||||
);
|
||||
const { signal } = upload.mock.calls[0][1];
|
||||
const reason = new Error('stop');
|
||||
controller.abort(reason);
|
||||
expect(signal?.reason).toBe(reason);
|
||||
});
|
||||
|
||||
it('aborts the upload on a file read error and ignores an aborted request body', async () => {
|
||||
await writeFile(filePath, '{}\n');
|
||||
const readError = Object.assign(new Error('read failed'), { code: 'EIO' });
|
||||
upload.mockImplementationOnce(async (_ctx, { openStream, signal }) => {
|
||||
const aborted = openStream();
|
||||
aborted.destroy(Object.assign(new Error('request body aborted'), { name: 'AbortError' }));
|
||||
await new Promise<void>(resolve => aborted.once('close', resolve));
|
||||
expect(signal?.aborted).toBe(false);
|
||||
const failed = openStream();
|
||||
failed.destroy(readError);
|
||||
await new Promise<void>(resolve => failed.once('close', resolve));
|
||||
expect(signal?.reason).toBe(readError);
|
||||
});
|
||||
expect(await uploadFile()).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils';
|
||||
import { fetchWebPreviewTurnArgsAsync } from '../remoteDeviceRunSession';
|
||||
import {
|
||||
WarmSessionAdoptionError,
|
||||
createWarmDeviceSessionServer,
|
||||
runOnWarmIosSimulatorAsync,
|
||||
} from '../warmDeviceSessionHost';
|
||||
import { type WarmIosSession, tryClaimWarmIosSessionAsync } from '../warmIosSession';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../IosSimulatorRecordingUtils');
|
||||
jest.mock('../warmIosSession');
|
||||
jest.mock('../remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../remoteDeviceRunSession'),
|
||||
fetchWebPreviewTurnArgsAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
let warm: WarmIosSession;
|
||||
|
||||
function launch(signal?: AbortSignal) {
|
||||
return {
|
||||
command: 'bunx',
|
||||
args: ['serve-sim', '--port', '3200'],
|
||||
env: {},
|
||||
logger,
|
||||
signal,
|
||||
secrets: [],
|
||||
stopGracePeriodMs: 1_000,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(fetchWebPreviewTurnArgsAsync).mockResolvedValue(['--turn', 'creds']);
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '' } as never);
|
||||
jest
|
||||
.mocked(IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync)
|
||||
.mockResolvedValue({ output: '/recordings/id', recordingId: 'id' });
|
||||
warm = {
|
||||
device: { udid: 'UDID', name: 'iPhone 17' },
|
||||
server: { pid: 10, port: 3200, token: 'serve-token' },
|
||||
agentDeviceStateDir: '/agent',
|
||||
argentEventLogPath: '/argent/events',
|
||||
preinstalledExpoGo: {
|
||||
bundleIdentifier: 'host.exp.Exponent',
|
||||
shortVersion: '2.0',
|
||||
version: '1',
|
||||
},
|
||||
prepareToolsAsync: jest.fn().mockResolvedValue(undefined),
|
||||
process: {
|
||||
pid: 10,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('adopts the warm device on the warm port with its recording, then prepares tools', async () => {
|
||||
const server = createWarmDeviceSessionServer(ctx, logger, warm);
|
||||
expect(server).toMatchObject({
|
||||
port: 3200,
|
||||
requiresPreviewRetirement: true,
|
||||
preinstalledApp: warm.preinstalledExpoGo,
|
||||
});
|
||||
const handle = await server.startAsync(launch());
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bunx',
|
||||
[
|
||||
'serve-sim',
|
||||
'--port',
|
||||
'3200',
|
||||
'--single-device',
|
||||
'UDID',
|
||||
'--adopt',
|
||||
'--recording-output',
|
||||
'/recordings/id',
|
||||
'--recording-id',
|
||||
'id',
|
||||
],
|
||||
expect.objectContaining({ stdio: 'pipe' })
|
||||
);
|
||||
expect(warm.prepareToolsAsync).toHaveBeenCalled();
|
||||
expect(handle.pid).toBe(10);
|
||||
await handle.stopAsync();
|
||||
expect(warm.process.stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['adoption', () => jest.mocked(spawn).mockRejectedValue(new Error('adopt --turn creds failed'))],
|
||||
[
|
||||
'tool preparation',
|
||||
() => jest.mocked(warm.prepareToolsAsync).mockRejectedValue(new Error('no tool')),
|
||||
],
|
||||
])('reports a %s failure as an adoption error without its details', async (_name, setup) => {
|
||||
setup();
|
||||
const error = await createWarmDeviceSessionServer(ctx, logger, warm)
|
||||
.startAsync(launch())
|
||||
.catch(error => error);
|
||||
expect(error).toBeInstanceOf(WarmSessionAdoptionError);
|
||||
expect(String(error)).not.toContain('creds');
|
||||
});
|
||||
|
||||
it('reports a cancellation as a cancellation', async () => {
|
||||
const controller = new AbortController();
|
||||
jest.mocked(spawn).mockImplementation((() => {
|
||||
controller.abort(new Error('cancelled'));
|
||||
return Promise.reject(new Error('killed'));
|
||||
}) as never);
|
||||
await expect(
|
||||
createWarmDeviceSessionServer(ctx, logger, warm).startAsync(launch(controller.signal))
|
||||
).rejects.toThrow('cancelled');
|
||||
});
|
||||
|
||||
describe(runOnWarmIosSimulatorAsync, () => {
|
||||
const claim = { engine: 'serve-sim' as const, env: {}, logger };
|
||||
|
||||
it('runs cold when there is no warm Simulator to claim', async () => {
|
||||
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(undefined);
|
||||
const run = jest.fn().mockResolvedValue('cold');
|
||||
await expect(runOnWarmIosSimulatorAsync(claim, run)).resolves.toBe('cold');
|
||||
expect(run).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('runs on the claimed warm Simulator', async () => {
|
||||
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm);
|
||||
const run = jest.fn().mockResolvedValue('warm');
|
||||
await expect(runOnWarmIosSimulatorAsync(claim, run)).resolves.toBe('warm');
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledWith(warm);
|
||||
});
|
||||
|
||||
it('stops warmup and runs cold after an adoption failure', async () => {
|
||||
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm);
|
||||
const run = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new WarmSessionAdoptionError())
|
||||
.mockResolvedValueOnce('cold');
|
||||
await expect(runOnWarmIosSimulatorAsync(claim, run)).resolves.toBe('cold');
|
||||
expect(warm.process.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenLastCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('does not run cold after a failure during the session', async () => {
|
||||
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm);
|
||||
const error = new Error('session failed');
|
||||
const run = jest.fn().mockRejectedValue(error);
|
||||
await expect(runOnWarmIosSimulatorAsync(claim, run)).rejects.toBe(error);
|
||||
expect(warm.process.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([new Error('session failed'), new WarmSessionAdoptionError()])(
|
||||
'preserves both errors and does not run cold when cleanup fails (%s)',
|
||||
async error => {
|
||||
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm);
|
||||
const cleanupError = new Error('cleanup failed');
|
||||
jest.mocked(warm.process.stopAsync).mockRejectedValue(cleanupError);
|
||||
const run = jest.fn().mockRejectedValue(error);
|
||||
await expect(runOnWarmIosSimulatorAsync(claim, run)).rejects.toMatchObject({
|
||||
errors: [error, cleanupError],
|
||||
});
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
'waits for cleanup before settling (adoption failure: %s)',
|
||||
async adoption => {
|
||||
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm);
|
||||
const error = adoption ? new WarmSessionAdoptionError() : new Error('session failed');
|
||||
let releaseCleanup = () => {};
|
||||
let cleanupStarted = () => {};
|
||||
const started = new Promise<void>(resolve => {
|
||||
cleanupStarted = resolve;
|
||||
});
|
||||
jest.mocked(warm.process.stopAsync).mockImplementation(() => {
|
||||
cleanupStarted();
|
||||
return new Promise<void>(resolve => {
|
||||
releaseCleanup = resolve;
|
||||
});
|
||||
});
|
||||
const run = jest.fn().mockRejectedValueOnce(error).mockResolvedValue('cold');
|
||||
const running = runOnWarmIosSimulatorAsync(claim, run);
|
||||
let settled = false;
|
||||
const result = running.then(
|
||||
value => {
|
||||
settled = true;
|
||||
return value;
|
||||
},
|
||||
error => {
|
||||
settled = true;
|
||||
return error;
|
||||
}
|
||||
);
|
||||
await started;
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
releaseCleanup();
|
||||
expect(await result).toBe(adoption ? 'cold' : error);
|
||||
expect(run).toHaveBeenCalledTimes(adoption ? 2 : 1);
|
||||
}
|
||||
);
|
||||
|
||||
it('does not retry cold when cancellation occurs during cleanup', async () => {
|
||||
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm);
|
||||
const controller = new AbortController();
|
||||
const cancellation = new Error('cancelled');
|
||||
jest.mocked(warm.process.stopAsync).mockImplementation(async () => {
|
||||
controller.abort(cancellation);
|
||||
});
|
||||
const run = jest.fn().mockRejectedValue(new WarmSessionAdoptionError());
|
||||
await expect(
|
||||
runOnWarmIosSimulatorAsync({ ...claim, signal: controller.signal }, run)
|
||||
).rejects.toBe(cancellation);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { runSimulatorLauncherAsync, stopWarmSimulatorAsync } from '../../../utils/warmSimulator';
|
||||
import { readLocalEgressHandoffAsync } from '../localEgress';
|
||||
import { tryClaimWarmIosSessionAsync } from '../warmIosSession';
|
||||
|
||||
jest.mock('../../../utils/warmSimulator');
|
||||
jest.mock('../localEgress');
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const env = { EAS_USE_WARM_SIMULATOR: '1' };
|
||||
const claim = {
|
||||
status: 'claimed',
|
||||
device: { udid: 'UDID', name: 'iPhone 17' },
|
||||
server: { pid: 10, port: 3200, token: 'serve-token', url: 'http://127.0.0.1:3200' },
|
||||
agentDeviceStateDir: '/agent',
|
||||
argentEventLogPath: '/argent/events',
|
||||
preinstalledExpoGo: { bundleIdentifier: 'host.exp.Exponent', shortVersion: '2.0', version: '1' },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(stopWarmSimulatorAsync).mockResolvedValue();
|
||||
jest
|
||||
.mocked(runSimulatorLauncherAsync)
|
||||
.mockImplementation(async ([command]) => (command === 'claim' ? JSON.stringify(claim) : '{}'));
|
||||
});
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('does not claim when the job does not use the warm Simulator', async () => {
|
||||
await expect(
|
||||
tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env: {}, logger })
|
||||
).resolves.toBeUndefined();
|
||||
expect(runSimulatorLauncherAsync).not.toHaveBeenCalled();
|
||||
expect(stopWarmSimulatorAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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',
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
expect(runSimulatorLauncherAsync).toHaveBeenCalledWith(['claim', 'serve-sim', 'iPhone 17'], {
|
||||
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' },
|
||||
preinstalledExpoGo: claim.preinstalledExpoGo,
|
||||
});
|
||||
await session!.prepareToolsAsync();
|
||||
expect(runSimulatorLauncherAsync).toHaveBeenLastCalledWith(['prepare-tools'], {
|
||||
env,
|
||||
signal: undefined,
|
||||
});
|
||||
expect(session!.tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it('takes the preinstalled Expo Go that prepare-tools reports', async () => {
|
||||
const expoGo = { bundleIdentifier: 'host.exp.Exponent', shortVersion: '3.0', version: '2' };
|
||||
jest
|
||||
.mocked(runSimulatorLauncherAsync)
|
||||
.mockImplementation(async ([command]) =>
|
||||
command === 'claim'
|
||||
? JSON.stringify({ ...claim, preinstalledExpoGo: undefined })
|
||||
: JSON.stringify({ preinstalledExpoGo: expoGo })
|
||||
);
|
||||
const session = await tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger });
|
||||
expect(session!.preinstalledExpoGo).toBeUndefined();
|
||||
await session!.prepareToolsAsync();
|
||||
expect(session!.preinstalledExpoGo).toEqual(expoGo);
|
||||
});
|
||||
|
||||
it('reads the agent-device server after tool preparation', async () => {
|
||||
jest
|
||||
.spyOn(fs, 'readFile')
|
||||
.mockResolvedValue(JSON.stringify({ pid: 20, httpPort: 4310, token: 'agent-token' }));
|
||||
const session = await tryClaimWarmIosSessionAsync({ engine: 'agent-device', env, logger });
|
||||
await session!.prepareToolsAsync();
|
||||
expect(fs.readFile).toHaveBeenCalledWith(path.join('/agent', 'daemon.json'), 'utf8');
|
||||
expect(session!.tools).toEqual({ pid: 20, port: 4310, token: 'agent-token' });
|
||||
});
|
||||
|
||||
it('uses the Argent identity validated by the launcher without scanning state files again', async () => {
|
||||
const tools = { pid: 30, port: 3201, token: 'argent-token' };
|
||||
const readDirectory = jest.spyOn(fs, 'readdir');
|
||||
const readFile = jest.spyOn(fs, 'readFile');
|
||||
jest
|
||||
.mocked(runSimulatorLauncherAsync)
|
||||
.mockImplementation(async ([command]) =>
|
||||
JSON.stringify(command === 'claim' ? claim : { tools })
|
||||
);
|
||||
const session = await tryClaimWarmIosSessionAsync({ engine: 'argent', env, logger });
|
||||
await session?.prepareToolsAsync();
|
||||
expect(session?.tools).toEqual(tools);
|
||||
expect(readDirectory).not.toHaveBeenCalled();
|
||||
expect(readFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects Argent preparation when the launcher returns no validated identity', async () => {
|
||||
const session = await tryClaimWarmIosSessionAsync({ engine: 'argent', env, logger });
|
||||
await expect(session?.prepareToolsAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a failed claim',
|
||||
{},
|
||||
() => jest.mocked(runSimulatorLauncherAsync).mockRejectedValue(new Error('no session')),
|
||||
],
|
||||
['a pinned package version', { packageVersion: '1.2.3' }, () => {}],
|
||||
['a job Simulator setting', { env: { ...env, SIMCTL_CHILD_FOO: '1' } }, () => {}],
|
||||
[
|
||||
'a local egress session',
|
||||
{},
|
||||
() => jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue({ port: 1 } as never),
|
||||
],
|
||||
])('stops warmup and returns nothing for %s', async (_name, options, setup) => {
|
||||
setup();
|
||||
await expect(
|
||||
tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger, ...options })
|
||||
).resolves.toBeUndefined();
|
||||
expect(stopWarmSimulatorAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects when warmup cleanup cannot be confirmed', async () => {
|
||||
jest.mocked(runSimulatorLauncherAsync).mockRejectedValue(new Error('no session'));
|
||||
jest.mocked(stopWarmSimulatorAsync).mockRejectedValue(new Error('cleanup failed'));
|
||||
await expect(tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger })).rejects.toThrow(
|
||||
'cleanup failed'
|
||||
);
|
||||
});
|
||||
|
||||
it('stops the claimed session once', async () => {
|
||||
const session = await tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger });
|
||||
await Promise.all([session!.process.stopAsync(), session!.process.stopAsync()]);
|
||||
expect(stopWarmSimulatorAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -6,15 +6,16 @@ import fetch from 'node-fetch';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { setTimeout as sleepAsync } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
|
||||
|
||||
const AGENT_DEVICE_ARTIFACT_UPLOAD_POLL_INTERVAL_MS = 5_000;
|
||||
const AGENT_DEVICE_ARTIFACT_CLEANUP_TIMEOUT_MS = 30_000;
|
||||
|
||||
const AgentDeviceArtifactSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -45,69 +46,128 @@ export async function pollAgentDeviceArtifactsForUploadAsync(
|
||||
daemonUrl,
|
||||
daemonToken,
|
||||
logger,
|
||||
signal,
|
||||
stopPollingSignal,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
daemonUrl: string;
|
||||
daemonToken: string;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
stopPollingSignal?: AbortSignal;
|
||||
}
|
||||
): Promise<void> {
|
||||
logger.info('Started polling agent-device daemon for artifacts.');
|
||||
const uploadedArtifactIds = new Set<string>();
|
||||
let listArtifactsErrorCount = 0;
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
const artifacts = await listAgentDeviceArtifactsAsync({ daemonUrl, daemonToken });
|
||||
listArtifactsErrorCount = 0;
|
||||
for (const artifact of artifacts) {
|
||||
if (uploadedArtifactIds.has(artifact.id)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await uploadAgentDeviceArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
daemonUrl,
|
||||
daemonToken,
|
||||
artifact,
|
||||
logger,
|
||||
});
|
||||
uploadedArtifactIds.add(artifact.id);
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not upload agent-device remote session artifact', error);
|
||||
logger.warn({ err: error }, 'Could not upload agent-device remote session artifact.');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof AgentDeviceArtifactsUnsupportedError) {
|
||||
logger.warn(
|
||||
'agent-device daemon does not expose artifact inventory; remote session artifact uploads are disabled.'
|
||||
);
|
||||
// Ending normal polling must not cancel a transfer that the session already produced.
|
||||
const cleanup = new AbortController();
|
||||
const transferSignal = stopPollingSignal
|
||||
? AbortSignal.any([cleanup.signal, ...(signal ? [signal] : [])])
|
||||
: signal;
|
||||
const pollingSignal = stopPollingSignal
|
||||
? AbortSignal.any([stopPollingSignal, ...(signal ? [signal] : [])])
|
||||
: signal;
|
||||
let cleanupTimer: NodeJS.Timeout | undefined;
|
||||
const startCleanupDeadline = (): void => {
|
||||
cleanupTimer ??= setTimeout(() => cleanup.abort(), AGENT_DEVICE_ARTIFACT_CLEANUP_TIMEOUT_MS);
|
||||
};
|
||||
stopPollingSignal?.addEventListener('abort', startCleanupDeadline, { once: true });
|
||||
if (stopPollingSignal?.aborted) {
|
||||
startCleanupDeadline();
|
||||
}
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const finalInventory = stopPollingSignal?.aborted;
|
||||
if (transferSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
listArtifactsErrorCount += 1;
|
||||
if (listArtifactsErrorCount % 5 === 0) {
|
||||
Sentry.capture('Could not list agent-device remote session artifacts', error);
|
||||
logger.warn(
|
||||
{ err: error, failedArtifactListCount: listArtifactsErrorCount },
|
||||
'Could not list agent-device remote session artifacts.'
|
||||
);
|
||||
try {
|
||||
const artifacts = await listAgentDeviceArtifactsAsync({
|
||||
daemonUrl,
|
||||
daemonToken,
|
||||
signal: transferSignal,
|
||||
});
|
||||
listArtifactsErrorCount = 0;
|
||||
for (const artifact of artifacts) {
|
||||
if (uploadedArtifactIds.has(artifact.id)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await uploadAgentDeviceArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
daemonUrl,
|
||||
daemonToken,
|
||||
artifact,
|
||||
logger,
|
||||
...(transferSignal ? { signal: transferSignal } : {}),
|
||||
});
|
||||
uploadedArtifactIds.add(artifact.id);
|
||||
} catch (err) {
|
||||
if (transferSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not upload agent-device remote session artifact', error);
|
||||
logger.warn({ err: error }, 'Could not upload agent-device remote session artifact.');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (transferSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
if (err instanceof AgentDeviceArtifactsUnsupportedError) {
|
||||
logger.warn(
|
||||
'agent-device daemon does not expose artifact inventory; remote session artifact uploads are disabled.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
listArtifactsErrorCount += 1;
|
||||
// The final inventory has no later retry, so always report its failure.
|
||||
if (finalInventory || listArtifactsErrorCount % 5 === 0) {
|
||||
Sentry.capture('Could not list agent-device remote session artifacts', error);
|
||||
logger.warn(
|
||||
{ err: error, failedArtifactListCount: listArtifactsErrorCount },
|
||||
'Could not list agent-device remote session artifacts.'
|
||||
);
|
||||
}
|
||||
}
|
||||
if (finalInventory) {
|
||||
return;
|
||||
}
|
||||
await sleepAsync(AGENT_DEVICE_ARTIFACT_UPLOAD_POLL_INTERVAL_MS, undefined, {
|
||||
signal: pollingSignal,
|
||||
}).catch(error => {
|
||||
if (!pollingSignal?.aborted) {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(cleanupTimer);
|
||||
stopPollingSignal?.removeEventListener('abort', startCleanupDeadline);
|
||||
if (cleanup.signal.aborted) {
|
||||
logger.warn(
|
||||
'Timed out draining agent-device artifacts; some session artifacts may not have been uploaded.'
|
||||
);
|
||||
}
|
||||
await sleepAsync(AGENT_DEVICE_ARTIFACT_UPLOAD_POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAgentDeviceArtifactsAsync({
|
||||
daemonUrl,
|
||||
daemonToken,
|
||||
signal,
|
||||
}: {
|
||||
daemonUrl: string;
|
||||
daemonToken: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<AgentDeviceArtifact[]> {
|
||||
const response = await fetch(new URL('/artifacts', daemonUrl).toString(), {
|
||||
...(signal ? { signal } : {}),
|
||||
headers: { Authorization: `Bearer ${daemonToken}` },
|
||||
});
|
||||
if (response.status === 404) {
|
||||
@@ -133,14 +193,17 @@ export async function uploadAgentDeviceArtifactAsync(
|
||||
daemonToken,
|
||||
artifact,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
daemonUrl: string;
|
||||
daemonToken: string;
|
||||
artifact: AgentDeviceArtifact;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
logger.info(`Downloading artifact ${artifact.filename}.`);
|
||||
const temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), 'agent-device-artifact-'));
|
||||
try {
|
||||
@@ -150,6 +213,7 @@ export async function uploadAgentDeviceArtifactAsync(
|
||||
daemonUrl,
|
||||
daemonToken,
|
||||
destinationPath: temporaryArtifactPath,
|
||||
signal,
|
||||
});
|
||||
const { size } = await stat(temporaryArtifactPath);
|
||||
logger.info(`Uploading artifact ${artifact.filename} (${formatBytes(size)}).`);
|
||||
@@ -164,6 +228,7 @@ export async function uploadAgentDeviceArtifactAsync(
|
||||
: {}),
|
||||
size,
|
||||
openStream: () => createReadStream(temporaryArtifactPath),
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
} finally {
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
@@ -175,16 +240,19 @@ async function downloadAgentDeviceArtifactToFileAsync({
|
||||
daemonUrl,
|
||||
daemonToken,
|
||||
destinationPath,
|
||||
signal,
|
||||
}: {
|
||||
artifact: AgentDeviceArtifact;
|
||||
daemonUrl: string;
|
||||
daemonToken: string;
|
||||
destinationPath: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const response = await fetch(
|
||||
new URL(`/artifacts/${encodeURIComponent(artifact.id)}`, daemonUrl).toString(),
|
||||
{
|
||||
headers: { Authorization: `Bearer ${daemonToken}` },
|
||||
...(signal ? { signal } : {}),
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
@@ -197,5 +265,9 @@ async function downloadAgentDeviceArtifactToFileAsync({
|
||||
`Agent-device artifact ${artifact.id} response did not include a readable body.`
|
||||
);
|
||||
}
|
||||
await pipeline(response.body, createWriteStream(destinationPath));
|
||||
if (signal) {
|
||||
await pipeline(response.body, createWriteStream(destinationPath), { signal });
|
||||
} else {
|
||||
await pipeline(response.body, createWriteStream(destinationPath));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import type { BuildStepEnv } from '@expo/steps';
|
||||
|
||||
import type { CustomBuildContext } from '../../customBuildContext';
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { waitForWarmSimulatorCleanupAsync } from '../../utils/warmSimulatorCleanup';
|
||||
import type { DeviceSessionServer } from './deviceSessionHost';
|
||||
import {
|
||||
fetchWebPreviewTurnArgsAsync,
|
||||
findAvailablePortAsync,
|
||||
spawnDetached,
|
||||
} from './remoteDeviceRunSession';
|
||||
|
||||
/** Creates the cold iOS server handle; start and setup run when the host calls them. */
|
||||
export async function createColdIosSessionAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
env,
|
||||
logger,
|
||||
iosSimulatorUdid,
|
||||
}: { env: BuildStepEnv; logger: bunyan; iosSimulatorUdid?: string }
|
||||
): Promise<DeviceSessionServer> {
|
||||
const port = await findAvailablePortAsync();
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { logger });
|
||||
return {
|
||||
port,
|
||||
turnArgs: Promise.resolve(turnArgs),
|
||||
requiresPreviewRetirement: false,
|
||||
async startAsync({ command, args, env, logger, signal, secrets, stopGracePeriodMs }) {
|
||||
// serve-sim boots the device, so warm Simulator cleanup must finish first.
|
||||
await waitForWarmSimulatorCleanupAsync(env, { signal });
|
||||
signal?.throwIfAborted();
|
||||
return spawnDetached({ command, args, env, logger, secrets, stopGracePeriodMs });
|
||||
},
|
||||
async setupSimulatorAsync({ signal, logger }) {
|
||||
try {
|
||||
await IosSimulatorUtils.disableApsdAsync({
|
||||
udid: iosSimulatorUdid as IosSimulatorUuid,
|
||||
env,
|
||||
});
|
||||
} catch (err) {
|
||||
signal.throwIfAborted();
|
||||
logger.warn({ err }, 'Failed to disable apsd in the Simulator.');
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
},
|
||||
// A cold launch either returns its handle or starts no process.
|
||||
async stopAsync() {},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import semver from 'semver';
|
||||
|
||||
export function validateControllerVersion({
|
||||
packageName,
|
||||
minimumVersion,
|
||||
packageVersion,
|
||||
logger,
|
||||
}: {
|
||||
packageName: string;
|
||||
minimumVersion: string;
|
||||
packageVersion: string | undefined;
|
||||
logger: bunyan;
|
||||
}): void {
|
||||
if (!packageVersion || packageVersion === 'latest') {
|
||||
return;
|
||||
}
|
||||
const requirement = `Remote sessions require ${packageName}@${minimumVersion} or newer.`;
|
||||
const version = semver.valid(packageVersion);
|
||||
if (!version) {
|
||||
logger.warn(
|
||||
`${requirement} package_version "${packageVersion}" is not an exact semver version that EAS can verify. ` +
|
||||
'Continuing and letting the package manager resolve it.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (semver.lt(version, minimumVersion)) {
|
||||
throw new SystemError(
|
||||
`${requirement} The requested package_version "${packageVersion}" is too old for the EAS remote-session API. ` +
|
||||
`Use "latest" or pass an exact version >= ${minimumVersion}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -49,9 +49,15 @@ import {
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
import { startLogPhase } from '../../utils/logPhase';
|
||||
import { runServeSimActionAsync, stageServeSimAppAsync } from './serveSimActions';
|
||||
import {
|
||||
type IosAppIdentity,
|
||||
isPreinstalledExpoGoAsync,
|
||||
readIosApplicationIdentifierAsync,
|
||||
} from './iosAppArtifact';
|
||||
import { type ServeSimActivityPolling, startServeSimActivityPolling } from './serveSimActivity';
|
||||
import { verifyLocalEgressGuardAsync } from './localEgressGuard';
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { createColdIosSessionAsync } from './coldIosSession';
|
||||
import { createStartupTasks } from './startupTasks';
|
||||
|
||||
const WEB_PREVIEW_HOST = '127.0.0.1';
|
||||
@@ -362,12 +368,13 @@ export type DeviceWebPreview = {
|
||||
};
|
||||
|
||||
export type DeviceSessionHost = {
|
||||
openPreviewAsync(options: { baseDomain: string; authtoken: string }): Promise<DeviceWebPreview>;
|
||||
/** The preview tunnel opened during startup, or a new one after closeAsync. */
|
||||
openPreviewAsync(): Promise<DeviceWebPreview>;
|
||||
/** Last observed preview input. Always `undefined` on Android, which keeps no event log. */
|
||||
getLastPreviewActivityAt(): Date | undefined;
|
||||
/**
|
||||
* Terminal and idempotent. Never rejects: a failed finalization, host stop or upload is logged
|
||||
* and reported, so callers need no error handling around it.
|
||||
* Terminal and idempotent. Finalization, host-stop and upload failures are logged and reported.
|
||||
* Warm preview-tunnel cleanup can reject when retirement cannot be confirmed.
|
||||
*/
|
||||
finishAsync(): Promise<void>;
|
||||
};
|
||||
@@ -379,11 +386,13 @@ type AndroidSessionRecording = {
|
||||
env: BuildStepEnv;
|
||||
};
|
||||
|
||||
type DeviceSessionHostOptions = {
|
||||
export type DeviceSessionHostOptions = {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
/** The host opens this preview tunnel while the server starts. */
|
||||
preview: { baseDomain: string; authtoken: string };
|
||||
separateLogPhase?: boolean;
|
||||
startupTimeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
@@ -405,12 +414,69 @@ type DeviceSessionHostOptions = {
|
||||
networkCaptureFields?: string[];
|
||||
} & ServeSimLaunchOptions;
|
||||
|
||||
export type DeviceSessionServerLaunch = {
|
||||
command: string;
|
||||
args: string[];
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
/** Redacted from server output. The host adds the preview token after launch. */
|
||||
secrets: string[];
|
||||
stopGracePeriodMs: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The server process behind a session host. A cold server is launched for this session; a warm
|
||||
* server was prepared before the job and is adopted. The host runs the same readiness, app
|
||||
* actions and finalization for both.
|
||||
*/
|
||||
export type DeviceSessionServer = {
|
||||
port: number;
|
||||
turnArgs: Promise<string[]>;
|
||||
/** Cold iOS setup runs after control readiness, alongside app installation and before launch. */
|
||||
setupSimulatorAsync?(options: { signal: AbortSignal; logger: bunyan }): Promise<void>;
|
||||
/** An app the server's launcher installed and never launched. */
|
||||
preinstalledApp?: IosAppIdentity;
|
||||
/** Another session can start on this machine, so an unconfirmed tunnel close fails cleanup. */
|
||||
requiresPreviewRetirement: boolean;
|
||||
startAsync(launch: DeviceSessionServerLaunch): Promise<DetachedProcessHandle>;
|
||||
/** Stops the server after startAsync failed. Finalization stops the returned handle. */
|
||||
stopAsync(): Promise<void>;
|
||||
};
|
||||
|
||||
/** Fetches TURN before the host starts its collectors. */
|
||||
async function createAndroidDeviceSessionServerAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{ logger }: { logger: bunyan }
|
||||
): Promise<DeviceSessionServer> {
|
||||
const port = await findAvailablePortAsync();
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { logger });
|
||||
return {
|
||||
port,
|
||||
turnArgs: Promise.resolve(turnArgs),
|
||||
requiresPreviewRetirement: false,
|
||||
async startAsync({ command, args, env, logger, secrets, stopGracePeriodMs }) {
|
||||
return spawnDetached({ command, args, env, logger, secrets, stopGracePeriodMs });
|
||||
},
|
||||
// A cold launch either returns its handle or starts no process.
|
||||
async stopAsync() {},
|
||||
};
|
||||
}
|
||||
|
||||
/** Without a server, the host launches a cold one. */
|
||||
export async function startDeviceSessionHostAsync(
|
||||
ctx: CustomBuildContext,
|
||||
options: DeviceSessionHostOptions
|
||||
options: DeviceSessionHostOptions,
|
||||
server?: DeviceSessionServer
|
||||
): Promise<DeviceSessionHost> {
|
||||
if (!options.separateLogPhase) {
|
||||
return await startDeviceSessionHostInternalAsync(ctx, options, () => {}, options.logger);
|
||||
return await startDeviceSessionHostInternalAsync(
|
||||
ctx,
|
||||
options,
|
||||
() => {},
|
||||
options.logger,
|
||||
server
|
||||
);
|
||||
}
|
||||
const phase = startLogPhase(options.logger, 'Simulator preview');
|
||||
let ready = false;
|
||||
@@ -420,7 +486,8 @@ export async function startDeviceSessionHostAsync(
|
||||
{ ...options, logger: phase.logger },
|
||||
successful =>
|
||||
phase.end(ready && successful ? BuildPhaseResult.SUCCESS : BuildPhaseResult.FAIL),
|
||||
options.logger
|
||||
options.logger,
|
||||
server
|
||||
);
|
||||
ready = true;
|
||||
return host;
|
||||
@@ -455,9 +522,11 @@ async function startDeviceSessionHostInternalAsync(
|
||||
networkCapture = false,
|
||||
networkCaptureFields = [],
|
||||
bootEnv = {},
|
||||
preview,
|
||||
}: DeviceSessionHostOptions,
|
||||
onFinished: (successful: boolean) => void,
|
||||
artifactLogger: bunyan
|
||||
artifactLogger: bunyan,
|
||||
providedServer?: DeviceSessionServer
|
||||
): Promise<DeviceSessionHost> {
|
||||
signal?.throwIfAborted();
|
||||
const isAndroid = runtimePlatform === BuildRuntimePlatform.LINUX;
|
||||
@@ -509,95 +578,28 @@ async function startDeviceSessionHostInternalAsync(
|
||||
const subdomainId = randomBytes(16).toString('hex');
|
||||
const previewPageUrl = simulatorPreviewPageUrl(env, subdomainId);
|
||||
const sharePageUrl = disableShare ? privatePreviewUrl : previewPageUrl;
|
||||
const port = await findAvailablePortAsync();
|
||||
const server =
|
||||
providedServer ??
|
||||
(isAndroid
|
||||
? await createAndroidDeviceSessionServerAsync(ctx, { logger })
|
||||
: await createColdIosSessionAsync(ctx, { env, logger, iosSimulatorUdid }));
|
||||
const port = server.port;
|
||||
const serverName = isAndroid ? 'expo-device-hub' : 'serve-sim';
|
||||
const packageSpec = isAndroid
|
||||
? createExpoDeviceHubPackageSpec(packageVersion)
|
||||
: createServeSimPackageSpec(packageVersion);
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { logger });
|
||||
const localEgress = !isAndroid ? await readLocalEgressHandoffAsync() : null;
|
||||
const previewExec = resolvePackageExec(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
isAndroid
|
||||
? createExpoDeviceHubArgs({
|
||||
port,
|
||||
device: previewDevice,
|
||||
turnArgs,
|
||||
packageVersion,
|
||||
recordingDirectory: recording?.directory,
|
||||
websiteArgs: websiteOriginArgs(env),
|
||||
frameAncestorArgs: frameAncestorArgs(frameAncestors),
|
||||
corsOriginArgs: corsOriginArgs(corsOrigins),
|
||||
})
|
||||
: createServeSimArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
packageVersion,
|
||||
iosSimulatorUdid: previewDevice,
|
||||
websiteArgs: websiteOriginArgs(env),
|
||||
frameAncestorArgs: frameAncestorArgs(frameAncestors),
|
||||
corsOriginArgs: corsOriginArgs(corsOrigins),
|
||||
// serve-sim adds its bearer token to --share-url; private links are for worker logs only.
|
||||
shareUrl: disableShare ? undefined : previewPageUrl,
|
||||
disableShare,
|
||||
installAppPath: iosSimulatorUdid ? undefined : installAppPath,
|
||||
launchAppIdentifier: iosSimulatorUdid ? undefined : launchAppIdentifier,
|
||||
launchArgs: iosSimulatorUdid ? [] : launchArgs,
|
||||
openUrl: iosSimulatorUdid ? undefined : openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
networkCaptureProxy: localEgress
|
||||
? `http://${LOCAL_EGRESS_PROXY_HOST}:${localEgress.port}`
|
||||
: undefined,
|
||||
})
|
||||
);
|
||||
logger.info(
|
||||
`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port} via ${previewExec.command}.`
|
||||
);
|
||||
// Redact these credentials before forwarding process output to build logs.
|
||||
const secrets = [
|
||||
...turnArgs.filter((_, index) => turnArgs[index - 1] === '--turn-credential'),
|
||||
...(recording ? [recording.controlToken] : []),
|
||||
];
|
||||
const turnArgsResult = asyncResult(server.turnArgs);
|
||||
const screenshots = await startDeviceRunSessionScreenshotsAsync(ctx, {
|
||||
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
|
||||
logger: artifactLogger,
|
||||
});
|
||||
let previewServer: DetachedProcessHandle;
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
previewServer = spawnDetached({
|
||||
command: previewExec.command,
|
||||
args: previewExec.args,
|
||||
env: {
|
||||
...env,
|
||||
...bootEnv,
|
||||
...(isAndroid ? {} : { TMPDIR: serveSimTmpdir, SERVE_SIM_STATE_DIR: serveSimStateDir }),
|
||||
EXPO_DEVICE_HUB_SCREENSHOT_DIRECTORY: screenshots.directory,
|
||||
...(recording ? { EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN: recording.controlToken } : {}),
|
||||
// The Hub would resolve its state directory from the step's env, which may set its own
|
||||
// TMPDIR or state directory, so it writes its token where the host reads it below.
|
||||
...(isAndroid ? { EXPO_DEVICE_HUB_STATE_DIR } : {}),
|
||||
},
|
||||
// Only an Android host records through expo-device-hub; serve-sim records on iOS.
|
||||
stopGracePeriodMs: recording
|
||||
? RECORDING_STOP_GRACE_PERIOD_MS
|
||||
: SERVE_SIM_STOP_GRACE_PERIOD_MS,
|
||||
logger,
|
||||
secrets,
|
||||
});
|
||||
} catch (error) {
|
||||
// Nothing was spawned, so nothing can still write into the directory.
|
||||
await screenshots.finishAsync(true);
|
||||
throw error;
|
||||
}
|
||||
|
||||
let stagedAppDirectory: string | undefined;
|
||||
let simulatorSetup: ReturnType<typeof asyncResult<void>> | undefined;
|
||||
const readinessController = new AbortController();
|
||||
const readinessSignal = AbortSignal.any([startup.signal, readinessController.signal]);
|
||||
let previewToken: string | undefined;
|
||||
let previewTask: Promise<DeviceWebPreview> | null = null;
|
||||
const previewRetirementErrors: unknown[] = [];
|
||||
let finishTask: Promise<void> | null = null;
|
||||
let hostReady = false;
|
||||
let sessionPreview: ReturnType<typeof startDeviceRunSessionPreview> | null = null;
|
||||
@@ -606,86 +608,239 @@ async function startDeviceSessionHostInternalAsync(
|
||||
let previewFailed = false;
|
||||
let previewActivity: ServeSimActivityPolling | null = null;
|
||||
|
||||
const host: DeviceSessionHost = {
|
||||
openPreviewAsync({ baseDomain, authtoken }) {
|
||||
if (finishTask) {
|
||||
return Promise.reject(
|
||||
new SystemError('Cannot open a preview after session host finalization.')
|
||||
);
|
||||
}
|
||||
if (previewTask) {
|
||||
return previewTask;
|
||||
}
|
||||
const opening = (async (): Promise<DeviceWebPreview> => {
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix: 'web-preview',
|
||||
subdomainId,
|
||||
baseDomain,
|
||||
authtoken,
|
||||
healthCheck: { path: isAndroid ? '/readyz' : '/healthz' },
|
||||
logger,
|
||||
});
|
||||
if (finishTask) {
|
||||
await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Late preview tunnel close', timeoutMs: 5_000 },
|
||||
async () => await tunnel.stopAsync()
|
||||
).catch(err => logger.warn({ err }, 'Could not close a late preview tunnel.'));
|
||||
throw new SystemError('Session host finalized while the preview was opening.');
|
||||
}
|
||||
let closeTask: Promise<void> | null = null;
|
||||
previewFailed = false;
|
||||
return {
|
||||
previewPageUrl,
|
||||
sharePageUrl,
|
||||
apiUrl: tunnel.url,
|
||||
previewToken,
|
||||
closeAsync() {
|
||||
return (closeTask ??= (async () => {
|
||||
try {
|
||||
await tunnel.stopAsync();
|
||||
} finally {
|
||||
if (previewTask === opening) {
|
||||
previewTask = null;
|
||||
}
|
||||
}
|
||||
})());
|
||||
},
|
||||
};
|
||||
})();
|
||||
previewTask = opening;
|
||||
// A failed tunnel may be retried without restarting capture.
|
||||
void opening.catch(() => {
|
||||
previewFailed = true;
|
||||
if (previewTask === opening) {
|
||||
previewTask = null;
|
||||
}
|
||||
function openPreviewAsync(): Promise<DeviceWebPreview> {
|
||||
if (finishTask) {
|
||||
return Promise.reject(
|
||||
new SystemError('Cannot open a preview after session host finalization.')
|
||||
);
|
||||
}
|
||||
if (previewTask) {
|
||||
return previewTask;
|
||||
}
|
||||
const opening = (async (): Promise<DeviceWebPreview> => {
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix: 'web-preview',
|
||||
subdomainId,
|
||||
baseDomain: preview.baseDomain,
|
||||
authtoken: preview.authtoken,
|
||||
healthCheck: { path: isAndroid ? '/readyz' : '/healthz' },
|
||||
logger,
|
||||
});
|
||||
return opening;
|
||||
},
|
||||
if (finishTask) {
|
||||
await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Late preview tunnel close', timeoutMs: 5_000 },
|
||||
async () => await tunnel.stopAsync()
|
||||
).catch(err => {
|
||||
if (server.requiresPreviewRetirement) {
|
||||
previewRetirementErrors.push(err);
|
||||
}
|
||||
logger.warn({ err }, 'Could not close a late preview tunnel.');
|
||||
});
|
||||
throw new SystemError('Session host finalized while the preview was opening.');
|
||||
}
|
||||
let closeTask: Promise<void> | null = null;
|
||||
previewFailed = false;
|
||||
return {
|
||||
previewPageUrl,
|
||||
sharePageUrl,
|
||||
apiUrl: tunnel.url,
|
||||
get previewToken() {
|
||||
return previewToken;
|
||||
},
|
||||
closeAsync() {
|
||||
return (closeTask ??= (async () => {
|
||||
try {
|
||||
await tunnel.stopAsync();
|
||||
} catch (error) {
|
||||
if (server.requiresPreviewRetirement) {
|
||||
previewRetirementErrors.push(error);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (previewTask === opening) {
|
||||
previewTask = null;
|
||||
}
|
||||
}
|
||||
})());
|
||||
},
|
||||
};
|
||||
})();
|
||||
previewTask = opening;
|
||||
// A failed tunnel may be retried without restarting capture.
|
||||
void opening.catch(() => {
|
||||
previewFailed = true;
|
||||
if (previewTask === opening) {
|
||||
previewTask = null;
|
||||
}
|
||||
});
|
||||
return opening;
|
||||
}
|
||||
const earlyPreview = asyncResult(
|
||||
withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Preview tunnel opening', timeoutMs, signal: startup.signal },
|
||||
async () => await openPreviewAsync()
|
||||
).catch(error => {
|
||||
if (!finishTask) {
|
||||
startup.abort(error);
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
);
|
||||
function retirePreviewAsync(): Promise<boolean> {
|
||||
return withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Preview tunnel retirement', timeoutMs: 5_000 },
|
||||
async () => {
|
||||
const preview = await previewTask?.catch(() => null);
|
||||
await preview?.closeAsync();
|
||||
}
|
||||
).then(
|
||||
() => true,
|
||||
err => {
|
||||
if (server.requiresPreviewRetirement && !previewRetirementErrors.includes(err)) {
|
||||
previewRetirementErrors.push(err);
|
||||
}
|
||||
logger.warn(
|
||||
{ err },
|
||||
`Could not close the ${serverName} preview tunnel within its deadline.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
);
|
||||
}
|
||||
const secrets: string[] = [];
|
||||
let previewServer: DetachedProcessHandle;
|
||||
try {
|
||||
const turnArgs = (await turnArgsResult).enforceValue();
|
||||
const localEgress = !isAndroid ? await readLocalEgressHandoffAsync() : null;
|
||||
const previewExec = resolvePackageExec(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
isAndroid
|
||||
? createExpoDeviceHubArgs({
|
||||
port,
|
||||
device: previewDevice,
|
||||
turnArgs,
|
||||
packageVersion,
|
||||
recordingDirectory: recording?.directory,
|
||||
websiteArgs: websiteOriginArgs(env),
|
||||
frameAncestorArgs: frameAncestorArgs(frameAncestors),
|
||||
corsOriginArgs: corsOriginArgs(corsOrigins),
|
||||
})
|
||||
: createServeSimArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
packageVersion,
|
||||
iosSimulatorUdid: previewDevice,
|
||||
websiteArgs: websiteOriginArgs(env),
|
||||
frameAncestorArgs: frameAncestorArgs(frameAncestors),
|
||||
corsOriginArgs: corsOriginArgs(corsOrigins),
|
||||
// serve-sim adds its bearer token to --share-url; private links are for worker logs only.
|
||||
shareUrl: disableShare ? undefined : previewPageUrl,
|
||||
disableShare,
|
||||
installAppPath: iosSimulatorUdid ? undefined : installAppPath,
|
||||
launchAppIdentifier: iosSimulatorUdid ? undefined : launchAppIdentifier,
|
||||
launchArgs: iosSimulatorUdid ? [] : launchArgs,
|
||||
openUrl: iosSimulatorUdid ? undefined : openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
networkCaptureProxy: localEgress
|
||||
? `http://${LOCAL_EGRESS_PROXY_HOST}:${localEgress.port}`
|
||||
: undefined,
|
||||
})
|
||||
);
|
||||
logger.info(
|
||||
`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port} via ${previewExec.command}.`
|
||||
);
|
||||
// Redact these credentials before forwarding process output to build logs.
|
||||
secrets.push(
|
||||
...turnArgs.filter((_, index) => turnArgs[index - 1] === '--turn-credential'),
|
||||
...(recording ? [recording.controlToken] : [])
|
||||
);
|
||||
startup.signal.throwIfAborted();
|
||||
const launchOptions = {
|
||||
command: previewExec.command,
|
||||
args: previewExec.args,
|
||||
env: {
|
||||
...env,
|
||||
...bootEnv,
|
||||
...(isAndroid ? {} : { TMPDIR: serveSimTmpdir, SERVE_SIM_STATE_DIR: serveSimStateDir }),
|
||||
EXPO_DEVICE_HUB_SCREENSHOT_DIRECTORY: screenshots.directory,
|
||||
...(recording ? { EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN: recording.controlToken } : {}),
|
||||
// The Hub must write its token in the directory read by this host.
|
||||
...(isAndroid ? { EXPO_DEVICE_HUB_STATE_DIR } : {}),
|
||||
},
|
||||
logger,
|
||||
};
|
||||
previewServer = await server.startAsync({
|
||||
...launchOptions,
|
||||
signal: startup.signal,
|
||||
secrets,
|
||||
// Only an Android host records through expo-device-hub; serve-sim records on iOS.
|
||||
stopGracePeriodMs: recording
|
||||
? RECORDING_STOP_GRACE_PERIOD_MS
|
||||
: SERVE_SIM_STOP_GRACE_PERIOD_MS,
|
||||
});
|
||||
} catch (error) {
|
||||
startup.abort(error);
|
||||
// Mark the opening tunnel as retired; its late-open path closes it.
|
||||
finishTask = Promise.resolve();
|
||||
await Promise.allSettled([simulatorSetup, retirePreviewAsync()]);
|
||||
let hostStopped = true;
|
||||
try {
|
||||
await server.stopAsync();
|
||||
} catch (stopError) {
|
||||
hostStopped = false;
|
||||
logger.warn({ err: stopError }, 'Could not stop the session server after startup failed.');
|
||||
}
|
||||
// A warm server may already be writing here even if its adoption command failed.
|
||||
await screenshots.finishAsync(hostStopped);
|
||||
if (previewRetirementErrors.length) {
|
||||
throw new AggregateError(
|
||||
[error, ...previewRetirementErrors],
|
||||
'The warm Simulator preview tunnel could not be closed. Another session cannot start safely.'
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const host: DeviceSessionHost = {
|
||||
openPreviewAsync,
|
||||
getLastPreviewActivityAt() {
|
||||
return previewActivity?.getLastActivityObservedAt();
|
||||
},
|
||||
finishAsync() {
|
||||
readinessController.abort(new Error('Simulator preview ended.'));
|
||||
return (finishTask ??= finishDeviceSessionHostAsync(ctx, {
|
||||
previewTask,
|
||||
stopSessionPreviewAsync: async () => {
|
||||
previewSetupController.abort();
|
||||
await previewSetupTask;
|
||||
await sessionPreview?.stopAsync();
|
||||
await previewActivity?.stopAsync();
|
||||
},
|
||||
previewServer,
|
||||
screenshots,
|
||||
serverName,
|
||||
port,
|
||||
// A host that never answered /readyz has nothing to finalize or upload.
|
||||
recording: hostReady ? recording : null,
|
||||
logger,
|
||||
artifactLogger,
|
||||
onStopped: successful => onFinished(successful && !previewFailed),
|
||||
})
|
||||
return (finishTask ??= (async () => {
|
||||
const previewRetirement = retirePreviewAsync();
|
||||
if (server.requiresPreviewRetirement) {
|
||||
// Confirm the tunnel is closed before stop, so the next session cannot reach this one.
|
||||
await previewRetirement;
|
||||
}
|
||||
await finishDeviceSessionHostAsync(ctx, {
|
||||
previewRetirement,
|
||||
stopSessionPreviewAsync: async () => {
|
||||
previewSetupController.abort();
|
||||
await previewSetupTask;
|
||||
await sessionPreview?.stopAsync();
|
||||
await previewActivity?.stopAsync();
|
||||
},
|
||||
previewServer,
|
||||
screenshots,
|
||||
serverName,
|
||||
port,
|
||||
// A host that never answered /readyz has nothing to finalize or upload.
|
||||
recording: hostReady ? recording : null,
|
||||
logger,
|
||||
artifactLogger,
|
||||
onStopped: successful => onFinished(successful && !previewFailed),
|
||||
});
|
||||
if (previewRetirementErrors.length) {
|
||||
throw new AggregateError(
|
||||
previewRetirementErrors,
|
||||
'The warm Simulator preview tunnel could not be closed. Another session cannot start safely.'
|
||||
);
|
||||
}
|
||||
})()
|
||||
.finally(async () => {
|
||||
await simulatorSetup;
|
||||
if (stagedAppDirectory) {
|
||||
@@ -695,6 +850,12 @@ async function startDeviceSessionHostInternalAsync(
|
||||
.catch(err => {
|
||||
artifactLogger.warn({ err }, 'Could not finish the simulator preview.');
|
||||
onFinished(false);
|
||||
if (previewRetirementErrors.length) {
|
||||
throw new AggregateError(
|
||||
[err, ...previewRetirementErrors],
|
||||
'The warm Simulator preview tunnel could not be closed. Another session cannot start safely.'
|
||||
);
|
||||
}
|
||||
}));
|
||||
},
|
||||
};
|
||||
@@ -713,7 +874,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
})
|
||||
);
|
||||
if (iosSimulatorUdid) {
|
||||
previewToken = await waitForServeSimControlAsync({
|
||||
const token = await waitForServeSimControlAsync({
|
||||
device: iosSimulatorUdid,
|
||||
port,
|
||||
previewServer,
|
||||
@@ -721,7 +882,8 @@ async function startDeviceSessionHostInternalAsync(
|
||||
timeoutMs: startupTimeoutMs ?? timeoutMs,
|
||||
signal: readinessSignal,
|
||||
});
|
||||
secrets.push(previewToken);
|
||||
previewToken = token;
|
||||
secrets.push(token);
|
||||
if (Object.keys(bootEnv).length > 0) {
|
||||
await startup.untilAborted(
|
||||
verifyLocalEgressGuardAsync({ udid: iosSimulatorUdid as IosSimulatorUuid, env, logger })
|
||||
@@ -729,22 +891,16 @@ async function startDeviceSessionHostInternalAsync(
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
// APSD cleanup runs alongside app startup and readiness; teardown joins the task.
|
||||
simulatorSetup = asyncResult(
|
||||
startup.untilAborted(
|
||||
startup.run('Simulator setup', async taskLogger => {
|
||||
try {
|
||||
await IosSimulatorUtils.disableApsdAsync({
|
||||
udid: iosSimulatorUdid as IosSimulatorUuid,
|
||||
env,
|
||||
});
|
||||
} catch (err) {
|
||||
startup.signal.throwIfAborted();
|
||||
taskLogger.warn({ err }, 'Failed to disable apsd in the Simulator.');
|
||||
}
|
||||
startup.signal.throwIfAborted();
|
||||
})
|
||||
)
|
||||
);
|
||||
const setupSimulatorAsync = server.setupSimulatorAsync;
|
||||
if (setupSimulatorAsync) {
|
||||
simulatorSetup = asyncResult(
|
||||
startup.untilAborted(
|
||||
startup.run('Simulator setup', taskLogger =>
|
||||
setupSimulatorAsync({ signal: startup.signal, logger: taskLogger })
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
const app = (application ? await startup.untilAborted(application) : undefined) ?? {
|
||||
installAppPath,
|
||||
launchAppIdentifier,
|
||||
@@ -755,18 +911,39 @@ async function startDeviceSessionHostInternalAsync(
|
||||
const action = (name: string, params: Record<string, unknown>) =>
|
||||
runServeSimActionAsync({
|
||||
port,
|
||||
token: previewToken!,
|
||||
token,
|
||||
action: name,
|
||||
params,
|
||||
timeoutMs: startupTimeoutMs ?? timeoutMs,
|
||||
signal: startup.signal,
|
||||
});
|
||||
if (app.installAppPath) {
|
||||
// The bundle that has not run yet: just installed, or preinstalled and never launched.
|
||||
let notYetLaunched =
|
||||
!app.installAppPath && app.launchAppIdentifier === server.preinstalledApp?.bundleIdentifier
|
||||
? server.preinstalledApp?.bundleIdentifier
|
||||
: undefined;
|
||||
if (
|
||||
app.installAppPath &&
|
||||
server.preinstalledApp &&
|
||||
(await isPreinstalledExpoGoAsync({
|
||||
appPath: app.installAppPath,
|
||||
preinstalled: server.preinstalledApp,
|
||||
env,
|
||||
}))
|
||||
) {
|
||||
logger.info('This Expo Go version is already installed on the Simulator.');
|
||||
notYetLaunched = server.preinstalledApp.bundleIdentifier;
|
||||
} else if (app.installAppPath) {
|
||||
const staged = await stageServeSimAppAsync(app.installAppPath, serveSimTmpdir);
|
||||
stagedAppDirectory = staged.directory;
|
||||
signal?.throwIfAborted();
|
||||
await action('app.install', { udid: iosSimulatorUdid, path: staged.path });
|
||||
notYetLaunched = await readIosApplicationIdentifierAsync({
|
||||
artifactPath: app.installAppPath,
|
||||
env,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
startup.signal.throwIfAborted();
|
||||
if (app.launchAppIdentifier) {
|
||||
signal?.throwIfAborted();
|
||||
await action('app.launch', {
|
||||
@@ -774,6 +951,8 @@ async function startDeviceSessionHostInternalAsync(
|
||||
bundleId: app.launchAppIdentifier,
|
||||
launchArgs: app.launchArgs ?? [],
|
||||
openUrl: app.openUrl,
|
||||
// An app that has not run yet needs no terminate check; any other app keeps it.
|
||||
...(notYetLaunched === app.launchAppIdentifier ? { restart: false } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -835,11 +1014,19 @@ async function startDeviceSessionHostInternalAsync(
|
||||
logger.warn({ err }, 'Could not start refreshing the session preview.');
|
||||
}
|
||||
});
|
||||
(await earlyPreview).enforceValue();
|
||||
signal?.throwIfAborted();
|
||||
return host;
|
||||
} catch (error) {
|
||||
onStartupError?.(error);
|
||||
await host.finishAsync();
|
||||
try {
|
||||
await host.finishAsync();
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError(
|
||||
[error, cleanupError],
|
||||
'Device session startup failed, and cleanup also failed.'
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -888,7 +1075,7 @@ async function waitForServeSimControlAsync({
|
||||
async function finishDeviceSessionHostAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
previewTask,
|
||||
previewRetirement,
|
||||
stopSessionPreviewAsync,
|
||||
previewServer,
|
||||
screenshots,
|
||||
@@ -899,7 +1086,7 @@ async function finishDeviceSessionHostAsync(
|
||||
artifactLogger,
|
||||
onStopped,
|
||||
}: {
|
||||
previewTask: Promise<DeviceWebPreview> | null;
|
||||
previewRetirement: Promise<boolean>;
|
||||
stopSessionPreviewAsync: () => Promise<void>;
|
||||
previewServer: DetachedProcessHandle;
|
||||
screenshots: { finishAsync(hostStopped: boolean): Promise<void> };
|
||||
@@ -914,21 +1101,6 @@ async function finishDeviceSessionHostAsync(
|
||||
// Stop capturing before the host stops, so the last thumbnail shows the session, not shutdown.
|
||||
await stopSessionPreviewAsync();
|
||||
const hostExited = previewServer.getExitError() !== undefined;
|
||||
// Native ngrok operations have no scoped cancellation. Retire a late listener too.
|
||||
const retirePreview = withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Preview tunnel retirement', timeoutMs: 5_000 },
|
||||
async () => {
|
||||
// A preview that failed to open, or was closed by its opener after finish began, has no tunnel left.
|
||||
const preview = await previewTask?.catch(() => null);
|
||||
await preview?.closeAsync();
|
||||
}
|
||||
).then(
|
||||
() => true,
|
||||
err => {
|
||||
logger.warn({ err }, `Could not close the ${serverName} preview tunnel within its deadline.`);
|
||||
return false;
|
||||
}
|
||||
);
|
||||
let finalization: AndroidRecordingFinalization | null = null;
|
||||
if (recording) {
|
||||
// stopAsync signals the whole process group, including capture's encoder.
|
||||
@@ -949,7 +1121,7 @@ async function finishDeviceSessionHostAsync(
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `Could not stop the ${serverName} session host.`);
|
||||
}
|
||||
const previewRetired = await retirePreview;
|
||||
const previewRetired = await previewRetirement;
|
||||
onStopped(hostStopped && !hostExited && previewRetired);
|
||||
await screenshots.finishAsync(hostStopped);
|
||||
// A Hub that never recorded has logged its reason and left nothing to upload.
|
||||
|
||||
@@ -39,3 +39,54 @@ export async function readIosApplicationIdentifierAsync({
|
||||
}
|
||||
return applicationIdentifier;
|
||||
}
|
||||
|
||||
const EXPO_GO_BUNDLE_IDENTIFIER = 'host.exp.Exponent';
|
||||
|
||||
export type IosAppIdentity = { bundleIdentifier: string; shortVersion: string; version: string };
|
||||
|
||||
/**
|
||||
* Expo Go releases are identified by their version, so a preinstalled copy with the same version is
|
||||
* the same app. Custom builds can reuse a version, so they are always installed.
|
||||
*/
|
||||
export async function isPreinstalledExpoGoAsync({
|
||||
appPath,
|
||||
preinstalled,
|
||||
env,
|
||||
}: {
|
||||
appPath: string;
|
||||
preinstalled: IosAppIdentity;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<boolean> {
|
||||
if (preinstalled.bundleIdentifier !== EXPO_GO_BUNDLE_IDENTIFIER) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const requested = await readIosAppIdentityAsync(appPath, env);
|
||||
return (
|
||||
requested.bundleIdentifier === preinstalled.bundleIdentifier &&
|
||||
requested.shortVersion === preinstalled.shortVersion &&
|
||||
requested.version === preinstalled.version
|
||||
);
|
||||
} catch {
|
||||
// An app that cannot be read is installed as usual.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readIosAppIdentityAsync(
|
||||
appPath: string,
|
||||
env: BuildStepEnv
|
||||
): Promise<IosAppIdentity> {
|
||||
const infoPlistPath = path.join(appPath, 'Info.plist');
|
||||
const [bundleIdentifier, shortVersion, version] = await Promise.all(
|
||||
['CFBundleIdentifier', 'CFBundleShortVersionString', 'CFBundleVersion'].map(async key =>
|
||||
(
|
||||
await spawn('plutil', ['-extract', key, 'raw', '-o', '-', infoPlistPath], {
|
||||
stdio: 'pipe',
|
||||
env,
|
||||
})
|
||||
).stdout.trim()
|
||||
)
|
||||
);
|
||||
return { bundleIdentifier, shortVersion, version };
|
||||
}
|
||||
@@ -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'
|
||||
@@ -607,7 +614,7 @@ export function isProcessRunning(pid: number): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
async function stopDetachedProcessAsync(
|
||||
export async function stopDetachedProcessAsync(
|
||||
pid: number | undefined,
|
||||
gracePeriodMs = 5_000
|
||||
): Promise<void> {
|
||||
@@ -773,7 +780,7 @@ export function createIosSessionStartupInputProviders(): ReturnType<
|
||||
}
|
||||
|
||||
export type ServeSimApplicationOptions = ServeSimLaunchOptions & {
|
||||
installAppPath: string;
|
||||
installAppPath?: string;
|
||||
launchAppIdentifier: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
|
||||
import { uploadDeviceRunSessionFileAsync } from './uploadDeviceRunSessionFile';
|
||||
|
||||
export async function uploadServeSimLogsFileAsync(
|
||||
ctx: CustomBuildContext,
|
||||
@@ -22,21 +20,22 @@ export async function uploadServeSimLogsFileAsync(
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { size } = await stat(filePath);
|
||||
if (size === 0) {
|
||||
return;
|
||||
}
|
||||
const fileController = new AbortController();
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId: `simulator-log-${udid}`,
|
||||
name: `App logs (${udid.slice(0, 8)})`,
|
||||
filename: 'app-logs.ndjson',
|
||||
kind: 'simulator-log',
|
||||
metadata: { __eas_type: 'simulator-log', udid, scope: 'user-apps', source: 'serve-sim/logs' },
|
||||
size,
|
||||
openStream: () => createReadStream(filePath).on('error', err => fileController.abort(err)),
|
||||
signal: AbortSignal.any([fileController.signal, ...(signal ? [signal] : [])]),
|
||||
await uploadDeviceRunSessionFileAsync(ctx, {
|
||||
filePath,
|
||||
describe: () => ({
|
||||
deviceRunSessionId,
|
||||
artifactId: `simulator-log-${udid}`,
|
||||
name: `App logs (${udid.slice(0, 8)})`,
|
||||
filename: 'app-logs.ndjson',
|
||||
kind: 'simulator-log',
|
||||
metadata: {
|
||||
__eas_type: 'simulator-log',
|
||||
udid,
|
||||
scope: 'user-apps',
|
||||
source: 'serve-sim/logs',
|
||||
},
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
type ArtifactMetadata,
|
||||
uploadDeviceRunSessionArtifactAsync,
|
||||
} from './deviceRunSessionArtifacts';
|
||||
|
||||
/** Upload a finalized file, reopening it for each attempt. */
|
||||
export async function uploadDeviceRunSessionFileAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
filePath,
|
||||
describe,
|
||||
signal,
|
||||
attempts = 1,
|
||||
}: {
|
||||
filePath: string;
|
||||
describe: (size: number) => Omit<ArtifactMetadata, 'size'>;
|
||||
signal?: AbortSignal;
|
||||
attempts?: number;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
const { size } = await stat(filePath);
|
||||
if (size === 0) {
|
||||
return false;
|
||||
}
|
||||
const readController = new AbortController();
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...describe(size),
|
||||
size,
|
||||
openStream: () =>
|
||||
createReadStream(filePath).on('error', err => {
|
||||
// An HTTP error aborts one request body, not the remaining upload attempts.
|
||||
if (err.name !== 'AbortError') {
|
||||
readController.abort(err);
|
||||
}
|
||||
}),
|
||||
signal: AbortSignal.any([readController.signal, ...(signal ? [signal] : [])]),
|
||||
attempts,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { IosSimulatorRecordingUtils } from './IosSimulatorRecordingUtils';
|
||||
import { type DeviceSessionServer } from './deviceSessionHost';
|
||||
import { fetchWebPreviewTurnArgsAsync } from './remoteDeviceRunSession';
|
||||
import { type WarmIosSession, tryClaimWarmIosSessionAsync } from './warmIosSession';
|
||||
|
||||
/** Adoption failed before app actions, so the caller can stop warmup and start cold. */
|
||||
export class WarmSessionAdoptionError extends Error {
|
||||
constructor() {
|
||||
// The adoption command line includes TURN credentials, so the cause stays out of the message.
|
||||
super('Could not adopt the warm Simulator for this job.');
|
||||
this.name = 'WarmSessionAdoptionError';
|
||||
}
|
||||
}
|
||||
|
||||
/** A session server that adopts the warm serve-sim for the job's device instead of launching one. */
|
||||
export function createWarmDeviceSessionServer(
|
||||
ctx: CustomBuildContext,
|
||||
logger: bunyan,
|
||||
warm: WarmIosSession
|
||||
): DeviceSessionServer {
|
||||
const { udid, name } = warm.device;
|
||||
async function stopAsync(): Promise<void> {
|
||||
await warm.process.stopAsync();
|
||||
}
|
||||
return {
|
||||
port: warm.server.port,
|
||||
turnArgs: fetchWebPreviewTurnArgsAsync(ctx, { logger }),
|
||||
// Set by prepare-tools, which the host runs before it installs the app.
|
||||
get preinstalledApp() {
|
||||
return warm.preinstalledExpoGo;
|
||||
},
|
||||
requiresPreviewRetirement: true,
|
||||
stopAsync,
|
||||
async startAsync({ command, args, env, signal }) {
|
||||
try {
|
||||
// serve-sim starts this job's recording when it adopts the device.
|
||||
const recording = await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
|
||||
udid,
|
||||
deviceName: name,
|
||||
serverToken: warm.server.token,
|
||||
});
|
||||
const recordingArgs = recording
|
||||
? ['--recording-output', recording.output, '--recording-id', recording.recordingId]
|
||||
: [];
|
||||
const timeout = AbortSignal.timeout(360_000);
|
||||
await spawn(command, [...args, '--single-device', udid, '--adopt', ...recordingArgs], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
||||
});
|
||||
await warm.prepareToolsAsync(signal);
|
||||
} catch {
|
||||
signal?.throwIfAborted();
|
||||
throw new WarmSessionAdoptionError();
|
||||
}
|
||||
return { ...warm.process, stopAsync };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an iOS session on the warm Simulator when the job can claim it, else cold. After a
|
||||
* callback failure, it stops warmup. Only an adoption failure can retry cold.
|
||||
*/
|
||||
export async function runOnWarmIosSimulatorAsync<T>(
|
||||
claim: Parameters<typeof tryClaimWarmIosSessionAsync>[0],
|
||||
run: (warm?: WarmIosSession) => Promise<T>
|
||||
): Promise<T> {
|
||||
const warm = await tryClaimWarmIosSessionAsync(claim);
|
||||
if (!warm) {
|
||||
return await run(undefined);
|
||||
}
|
||||
try {
|
||||
return await run(warm);
|
||||
} catch (error) {
|
||||
try {
|
||||
await warm.process.stopAsync();
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError(
|
||||
[error, cleanupError],
|
||||
'Could not stop the warm Simulator after failure.'
|
||||
);
|
||||
}
|
||||
if (!(error instanceof WarmSessionAdoptionError)) {
|
||||
throw error;
|
||||
}
|
||||
claim.logger.warn('Could not adopt the warm Simulator; starting a cold Simulator.');
|
||||
claim.signal?.throwIfAborted();
|
||||
return await run(undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type BuildStepEnv } from '@expo/steps';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { runSimulatorLauncherAsync, stopWarmSimulatorAsync } from '../../utils/warmSimulator';
|
||||
import { readLocalEgressHandoffAsync } from './localEgress';
|
||||
import { type DetachedProcessHandle, isProcessRunning } from './remoteDeviceRunSession';
|
||||
|
||||
const ServiceSchema = z.object({ pid: z.number(), port: z.number(), token: z.string() });
|
||||
const ClaimSchema = z.object({
|
||||
device: z.object({ udid: z.string(), name: z.string() }),
|
||||
server: ServiceSchema,
|
||||
agentDeviceStateDir: z.string(),
|
||||
argentEventLogPath: z.string(),
|
||||
preinstalledExpoGo: z
|
||||
.object({ bundleIdentifier: z.string(), shortVersion: z.string(), version: z.string() })
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type Service = z.infer<typeof ServiceSchema>;
|
||||
|
||||
/** The warm Simulator that the launcher handed to this job. */
|
||||
export type WarmIosSession = z.infer<typeof ClaimSchema> & {
|
||||
/** The selected tool's server; set by prepareToolsAsync. */
|
||||
tools?: Service;
|
||||
/** Runs after adoption: the launcher prepares the selected tool for the adopted device. */
|
||||
prepareToolsAsync(signal?: AbortSignal): Promise<void>;
|
||||
process: DetachedProcessHandle;
|
||||
};
|
||||
|
||||
/**
|
||||
* Claims the warm Simulator. The launcher checks the session; build-tools only uses it. On any
|
||||
* failure, warmup stops and the caller uses a cold start; a failed stop rejects.
|
||||
*/
|
||||
export async function tryClaimWarmIosSessionAsync({
|
||||
engine,
|
||||
requestedDevice,
|
||||
packageVersion,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
engine: 'serve-sim' | 'agent-device' | 'argent' | 'appium';
|
||||
requestedDevice?: string;
|
||||
packageVersion?: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<WarmIosSession | undefined> {
|
||||
if (env.EAS_USE_WARM_SIMULATOR !== '1') {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
// Warm tools run the latest release with the VM's Simulator settings.
|
||||
if (packageVersion && packageVersion !== 'latest') {
|
||||
throw new Error('A pinned package_version needs a cold start.');
|
||||
}
|
||||
const setting = Object.keys(env).find(
|
||||
name => name === 'SERVE_SIM_ADDITIONAL_DYLIBS' || name.startsWith('SIMCTL_CHILD_')
|
||||
);
|
||||
if (setting) {
|
||||
throw new Error(`${setting} needs a cold start.`);
|
||||
}
|
||||
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 }))
|
||||
);
|
||||
logger.info(`Claimed the warm Simulator ${claim.device.name} (${claim.device.udid}).`);
|
||||
let stopping: Promise<void> | undefined;
|
||||
const session: WarmIosSession = {
|
||||
...claim,
|
||||
async prepareToolsAsync(prepareSignal = signal) {
|
||||
const prepared = ClaimSchema.pick({ preinstalledExpoGo: true })
|
||||
.extend({ tools: ServiceSchema.optional() })
|
||||
.parse(
|
||||
JSON.parse(
|
||||
await runSimulatorLauncherAsync(['prepare-tools'], { env, signal: prepareSignal })
|
||||
)
|
||||
);
|
||||
// The launcher reports the Expo Go it preinstalled once its install has finished.
|
||||
session.preinstalledExpoGo = prepared.preinstalledExpoGo ?? session.preinstalledExpoGo;
|
||||
if (engine === 'agent-device') {
|
||||
const daemon = z
|
||||
.object({ pid: z.number(), httpPort: z.number(), token: z.string() })
|
||||
.parse(await readJsonAsync(path.join(claim.agentDeviceStateDir, 'daemon.json')));
|
||||
session.tools = { pid: daemon.pid, port: daemon.httpPort, token: daemon.token };
|
||||
} else if (engine === 'argent') {
|
||||
session.tools = ServiceSchema.parse(prepared.tools);
|
||||
}
|
||||
},
|
||||
process: {
|
||||
pid: claim.server.pid,
|
||||
getOutput: () => '',
|
||||
getExitError: () =>
|
||||
isProcessRunning(claim.server.pid) &&
|
||||
(!session.tools || isProcessRunning(session.tools.pid))
|
||||
? undefined
|
||||
: new Error('A service of the warm Simulator session has exited.'),
|
||||
stopAsync: () => (stopping ??= stopWarmSimulatorAsync(env)),
|
||||
},
|
||||
};
|
||||
return session;
|
||||
} catch (error) {
|
||||
logger.warn({ err: error }, 'Could not use the warm Simulator; stopping it for a cold start.');
|
||||
await stopWarmSimulatorAsync(env);
|
||||
signal?.throwIfAborted();
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonAsync(file: string): Promise<unknown> {
|
||||
return JSON.parse(await fs.readFile(file, 'utf8'));
|
||||
}
|
||||
@@ -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,61 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
import { runSimulatorLauncherAsync, stopWarmSimulatorAsync } from '../warmSimulator';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '{"ok":true}' } as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
});
|
||||
|
||||
it('runs the launcher version that start recorded and returns its output', async () => {
|
||||
jest.spyOn(fs, 'readFile').mockResolvedValue('{"launcherVersion":"27.1.0"}');
|
||||
await expect(runSimulatorLauncherAsync(['claim', 'serve-sim'], { env: {} })).resolves.toBe(
|
||||
'{"ok":true}'
|
||||
);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['x', '@expo/[email protected]', 'claim', 'serve-sim'],
|
||||
expect.objectContaining({ stdio: 'pipe' })
|
||||
);
|
||||
expect(jest.mocked(spawn).mock.calls[0][2]).not.toHaveProperty('logger');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{}, 'bun', ['x']],
|
||||
[{ EAS_OVERRIDE_PACKAGE_MANAGER: 'npm' }, 'npx', ['--yes']],
|
||||
])(
|
||||
'stops with the latest launcher and the configured package manager for %j',
|
||||
async (env, command, prefix) => {
|
||||
jest
|
||||
.spyOn(fs, 'readFile')
|
||||
.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }));
|
||||
await stopWarmSimulatorAsync(env);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
command,
|
||||
[...prefix, '@expo/simulator-launcher@latest', 'stop'],
|
||||
expect.objectContaining({ env })
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects when the launcher cannot confirm cleanup', async () => {
|
||||
jest.spyOn(fs, 'readFile').mockResolvedValue('{}');
|
||||
jest.mocked(spawn).mockRejectedValue(new Error('cleanup failed'));
|
||||
await expect(stopWarmSimulatorAsync({})).rejects.toThrow('cleanup failed');
|
||||
});
|
||||
|
||||
it('does nothing on other platforms', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
await stopWarmSimulatorAsync({});
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -121,7 +121,7 @@ export async function uploadApplicationArchive(
|
||||
patternOrPath: string;
|
||||
rootDir: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
): Promise<string[]> {
|
||||
const applicationArchives = await findArtifacts({ rootDir, patternOrPath, logger });
|
||||
const artifactsSizes = await getArtifactsSizes(applicationArchives);
|
||||
logger.info(`Application archives:`);
|
||||
@@ -137,6 +137,8 @@ export async function uploadApplicationArchive(
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
return applicationArchives;
|
||||
}
|
||||
|
||||
async function getArtifactsSizes(artifacts: string[]): Promise<Record<string, number | undefined>> {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { type BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageExec,
|
||||
} from './packageManager';
|
||||
|
||||
/**
|
||||
* Runs a Simulator launcher command with the version that `start` recorded, which warmup cached.
|
||||
* The output can contain credentials, so it is returned and never logged.
|
||||
*/
|
||||
export async function runSimulatorLauncherAsync(
|
||||
launcherArgs: string[],
|
||||
{ env, signal }: { env: BuildStepEnv; signal?: AbortSignal }
|
||||
): Promise<string> {
|
||||
const version = await fs
|
||||
.readFile('/tmp/expo-simulator-startup.json', 'utf8')
|
||||
.then(text => JSON.parse(text).launcherVersion)
|
||||
.catch(() => undefined);
|
||||
const { command, args } = resolvePackageExec(
|
||||
resolveConfiguredPackageManager(env, PackageManager.BUN),
|
||||
[
|
||||
`@expo/simulator-launcher@${typeof version === 'string' ? version : 'latest'}`,
|
||||
...launcherArgs,
|
||||
]
|
||||
);
|
||||
const { stdout } = await spawn(command, args, { env, stdio: 'pipe', signal, timeout: 425_000 });
|
||||
return stdout;
|
||||
}
|
||||
|
||||
/** Stops warmup; resolves only after the launcher confirms cleanup. */
|
||||
export async function stopWarmSimulatorAsync(env: BuildStepEnv): Promise<void> {
|
||||
if (process.platform === 'darwin') {
|
||||
await runSimulatorLauncherAsync(['stop'], { env });
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
# create-eas-build-function
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "27.3.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.3.0"
|
||||
"@expo/steps": "27.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @expo/downloader
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "27.3.0",
|
||||
"version": "27.4.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# eas-build-cache-provider
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "27.3.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,13 @@
|
||||
# @expo/eas-build-job
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "27.3.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.3.0",
|
||||
"@expo/logger": "27.4.1",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "27.3.0",
|
||||
"@expo/turtle-spawn": "27.4.1",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# eas-cli
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Show the Expo Simulators hint after a successful build from November 10 instead of November 5. ([#4801](https://github.com/expo/eas-cli/pull/4801) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
|
||||
## 27.3.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.3.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.3.0",
|
||||
"@expo/eas-json": "27.3.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.3.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.3.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-05T18: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,13 @@
|
||||
# @expo/eas-json
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"version": "27.3.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.3.0",
|
||||
"@expo/eas-build-job": "27.4.1",
|
||||
"chalk": "4.1.2",
|
||||
"env-string": "1.0.1",
|
||||
"fs-extra": "11.2.0",
|
||||
|
||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user