mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 03:38:08 -10:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc25602d65 | ||
|
|
261836aaf6 | ||
|
|
55e746a0e7 | ||
|
|
821e7f85f5 | ||
|
|
6f0f90bd99 | ||
|
|
6c545d3460 | ||
|
|
243c8b8023 | ||
|
|
fe6c35ef73 | ||
|
|
a29b1d1d70 | ||
|
|
92e1b01bd7 | ||
|
|
9adcdfab1c | ||
|
|
444c926bb8 | ||
|
|
3e73b077ce | ||
|
|
3350e5c6f3 | ||
|
|
3ffbbb2f45 | ||
|
|
4e453e5d9c | ||
|
|
4f7ba2d5a3 |
No files matched your search
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Explain missing background task identifiers reported by App Store Connect.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Explain App Store Connect NOT_AUTHORIZED responses in the shared API client.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Explain App Store Connect UNEXPECTED_ERROR responses in the shared API client.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Explain missing app icons reported by App Store Connect, including codes 90022 and 90023, while preserving Apple’s validation details.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Use single-device previews when the session already has a valid device ID.
|
||||
@@ -19,7 +19,9 @@
|
||||
"@expo/downloader",
|
||||
"@expo/turtle-spawn",
|
||||
"@expo/template-file",
|
||||
"create-eas-build-function"
|
||||
"create-eas-build-function",
|
||||
"@expo/vm-tools",
|
||||
"@expo/simulator-launcher"
|
||||
]
|
||||
],
|
||||
"linked": [],
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Add an experiment that suggests Expo Update to coding agents after a build when the Free plan account is within 5 builds of the platform limit.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
List failed Google Play commit attempts in order, including network failures, when the outcome is uncertain.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Add `eas observe:traces` to list request trace spans (or a summary of span names and counts), `eas observe:trace` to display one trace as a span tree, and span rows in the `eas observe:session` timeline.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/worker': patch
|
||||
---
|
||||
|
||||
Run package-manager version queries in parallel during worker preparation.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/worker': patch
|
||||
---
|
||||
|
||||
Start the Xcode version check in the background during worker tool preparation. Apply the Xcode 26.0 upload workaround when the query finishes, without delaying job startup.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Remove the Free plan build limit guidance experiment for coding agents from `eas build`.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/worker': patch
|
||||
---
|
||||
|
||||
Allow jobs to skip installing the CocoaPods cache proxy with EAS_BUILD_DISABLE_COCOAPODS_CACHE_PLUGIN=1.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Preserve indented and incomplete Xcode compiler errors in formatted build logs, including the final diagnostic when logging stops.
|
||||
@@ -2,6 +2,52 @@
|
||||
|
||||
This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
## [27.3.0](https://github.com/expo/eas-cli/releases/tag/v27.3.0) - 2026-11-02
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-build-job] Add optional Observe error context to workflow jobs. ([#4787](https://github.com/expo/eas-cli/pull/4787) by [@AbbanMustafa](https://github.com/AbbanMustafa))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Bump `@expo/apple-utils` to `2.3.0` to fix iOS capability sync when App Store Connect rejects enabling, disabling or changing a capability. ([#4790](https://github.com/expo/eas-cli/pull/4790) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Keep already linked App Groups, Merchant IDs and iCloud containers when capability sync links another identifier. ([#4584](https://github.com/expo/eas-cli/pull/4584) by [@ceydaakin](https://github.com/ceydaakin))
|
||||
- [worker] Mask secrets in every record the worker process writes to its stdout: the build log records and the worker's own records. ([#4782](https://github.com/expo/eas-cli/pull/4782) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [worker][build-tools] Stop warmup in the background for jobs that do not use the warm Simulator, so job setup does not wait for it. Before build-tools boots or clones a Simulator, it waits until the launcher's state file records that cleanup finished, and fails if cleanup does not finish in time. ([#4721](https://github.com/expo/eas-cli/pull/4721) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Recover unhealthy device-session tunnels without changing their URLs and block further traffic when shutdown starts, even if ngrok cleanup times out. ([#4526](https://github.com/expo/eas-cli/pull/4526) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Stop sending the SSH host when registering an SSH session. ([#4784](https://github.com/expo/eas-cli/pull/4784) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
## [27.2.0](https://github.com/expo/eas-cli/releases/tag/v27.2.0) - 2026-10-29
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Use single-device previews when the session already has a valid device ID. ([#4733](https://github.com/expo/eas-cli/pull/4733) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add an experiment that suggests Expo Update to coding agents after a build when the Free plan account is within 5 builds of the platform limit. ([#4758](https://github.com/expo/eas-cli/pull/4758) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [eas-cli] Add `eas observe:traces` to list request trace spans (or a summary of span names and counts), `eas observe:trace` to display one trace as a span tree, and span rows in the `eas observe:session` timeline. ([#4772](https://github.com/expo/eas-cli/pull/4772) by [@kadikraman](https://github.com/kadikraman))
|
||||
- [simulator-launcher] Accept a comma-separated list of warm Simulators in `EXPO_SIMULATOR_DEVICE`; the first is the default. One serve-sim serves every listed device on one port. After the default device is ready, warmup preinstalls the latest stable Expo Go from one download and tries to disable apsd on the other devices, until a job claims one. A claim can select any listed device and reports its preinstalled Expo Go. ([#4764](https://github.com/expo/eas-cli/pull/4764) by [@gwdp](https://github.com/gwdp))
|
||||
- [simulator-launcher] Add `@expo/simulator-launcher`, which warms an iOS Simulator at VM boot and hands it to one EAS job (`start`, `claim`, `prepare-tools`, `stop`). ([#4722](https://github.com/expo/eas-cli/pull/4722) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Show a one-time Expo Simulators hint after a successful build. ([#4768](https://github.com/expo/eas-cli/pull/4768) by [@FiberJW](https://github.com/FiberJW))
|
||||
- [vm-tools] Add a macOS startup script loader that runs `/usr/local/share/vm-tools/startup.sh` once per VM boot. ([#4725](https://github.com/expo/eas-cli/pull/4725) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Explain missing background task identifiers reported by App Store Connect. ([#3465](https://github.com/expo/eas-cli/pull/3465) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Explain App Store Connect NOT_AUTHORIZED responses in the shared API client. ([#3462](https://github.com/expo/eas-cli/pull/3462) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Explain App Store Connect UNEXPECTED_ERROR responses in the shared API client. ([#3467](https://github.com/expo/eas-cli/pull/3467) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Explain missing app icons reported by App Store Connect, including codes 90022 and 90023, while preserving Apple’s validation details. ([#3466](https://github.com/expo/eas-cli/pull/3466) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] List failed Google Play commit attempts in order, including network failures, when the outcome is uncertain. ([#4660](https://github.com/expo/eas-cli/pull/4660) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [worker] Run package-manager version queries in parallel during worker preparation. ([#4710](https://github.com/expo/eas-cli/pull/4710) by [@gwdp](https://github.com/gwdp))
|
||||
- [worker] Start the Xcode version check in the background during worker tool preparation. Apply the Xcode 26.0 upload workaround when the query finishes, without delaying job startup. ([#4707](https://github.com/expo/eas-cli/pull/4707) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Fix a five-minute cleanup delay when a device session is canceled during startup and its screen recorder fails before connecting. ([#4761](https://github.com/expo/eas-cli/pull/4761) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Remove the Free plan build limit guidance experiment for coding agents from `eas build`. ([#4757](https://github.com/expo/eas-cli/pull/4757) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [worker] Allow jobs to skip installing the CocoaPods cache proxy with EAS_BUILD_DISABLE_COCOAPODS_CACHE_PLUGIN=1. ([#4709](https://github.com/expo/eas-cli/pull/4709) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Handle TURN requests for a stopped device session without reporting a warning. ([#4762](https://github.com/expo/eas-cli/pull/4762) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Preserve indented and incomplete Xcode compiler errors in formatted build logs, including the final diagnostic when logging stops. ([#4365](https://github.com/expo/eas-cli/pull/4365) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [simulator-launcher] Add the state, request command and service helpers for the warm Simulator launcher. The package stays private until #4722 adds its CLI entry point. ([#4766](https://github.com/expo/eas-cli/pull/4766) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [27.1.0](https://github.com/expo/eas-cli/releases/tag/v27.1.0) - 2026-10-28
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "27.1.0",
|
||||
"PRODUCTION": "27.1.0"
|
||||
"STAGING": "27.2.0",
|
||||
"PRODUCTION": "27.2.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "27.1.0",
|
||||
"version": "27.3.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,5 +1,30 @@
|
||||
# @expo/build-tools
|
||||
|
||||
## 27.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stop warmup in the background for jobs that do not use the warm Simulator, so job setup does not wait for it. Before build-tools boots or clones a Simulator, it waits until the launcher's state file records that cleanup finished, and fails if cleanup does not finish in time. ([#4721](https://github.com/expo/eas-cli/pull/4721) by [@gwdp](https://github.com/gwdp))
|
||||
- Recover unhealthy device-session tunnels without changing their URLs and block further traffic when shutdown starts, even if ngrok cleanup times out. ([#4526](https://github.com/expo/eas-cli/pull/4526) by [@gwdp](https://github.com/gwdp))
|
||||
- Stop sending the SSH host when registering an SSH session. ([#4784](https://github.com/expo/eas-cli/pull/4784) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
## 27.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Use single-device previews when the session already has a valid device ID. ([#4733](https://github.com/expo/eas-cli/pull/4733) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Explain missing background task identifiers reported by App Store Connect. ([#3465](https://github.com/expo/eas-cli/pull/3465) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Explain App Store Connect NOT_AUTHORIZED responses in the shared API client. ([#3462](https://github.com/expo/eas-cli/pull/3462) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Explain App Store Connect UNEXPECTED_ERROR responses in the shared API client. ([#3467](https://github.com/expo/eas-cli/pull/3467) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Explain missing app icons reported by App Store Connect, including codes 90022 and 90023, while preserving Apple’s validation details. ([#3466](https://github.com/expo/eas-cli/pull/3466) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- List failed Google Play commit attempts in order, including network failures, when the outcome is uncertain. ([#4660](https://github.com/expo/eas-cli/pull/4660) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Fix a five-minute cleanup delay when a device session is canceled during startup and its screen recorder fails before connecting. ([#4761](https://github.com/expo/eas-cli/pull/4761) by [@gwdp](https://github.com/gwdp))
|
||||
- Handle TURN requests for a stopped device session without reporting a warning. ([#4762](https://github.com/expo/eas-cli/pull/4762) by [@gwdp](https://github.com/gwdp))
|
||||
- Preserve indented and incomplete Xcode compiler errors in formatted build logs, including the final diagnostic when logging stops. ([#4365](https://github.com/expo/eas-cli/pull/4365) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## 27.1.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "27.1.0",
|
||||
"version": "27.3.0",
|
||||
"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.1.0",
|
||||
"@expo/eas-build-job": "27.1.0",
|
||||
"@expo/downloader": "27.3.0",
|
||||
"@expo/eas-build-job": "27.3.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "27.1.0",
|
||||
"@expo/logger": "27.3.0",
|
||||
"@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.1.0",
|
||||
"@expo/template-file": "27.1.0",
|
||||
"@expo/turtle-spawn": "27.1.0",
|
||||
"@expo/steps": "27.3.0",
|
||||
"@expo/template-file": "27.3.0",
|
||||
"@expo/turtle-spawn": "27.3.0",
|
||||
"@expo/xcpretty": "^4.4.7",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
|
||||
@@ -167,6 +167,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
subdomainPrefix: 'agent-device',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
healthCheck: { path: '/health' },
|
||||
logger: taskLogger,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
|
||||
@@ -274,6 +274,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
subdomainPrefix: 'appium',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
healthCheck: { path: '/status' },
|
||||
logger,
|
||||
});
|
||||
|
||||
|
||||
@@ -340,6 +340,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
rewriteHostHeader: true,
|
||||
healthCheck: { path: '/tools', statuses: [200, 401] },
|
||||
logger,
|
||||
});
|
||||
const publicToolsUrl = toolsTunnel.url;
|
||||
|
||||
@@ -36,7 +36,7 @@ const SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS =
|
||||
const SERVE_SIM_FINISH_TIMEOUT_MS =
|
||||
SERVE_SIM_CONTROL_REQUEST_TIMEOUT_MS + SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS;
|
||||
const SERVE_SIM_FORCE_STOP_TIMEOUT_MS = 5_000;
|
||||
const SERVE_SIM_RECORDING_STARTED = 'serve-sim:recording-started';
|
||||
const SERVE_SIM_RECORDING_STARTED_LINE = /(?:^|\n)serve-sim:recording-started\r?\n/;
|
||||
// The serve-sim package the session host started; every recorder runs `record-video` from it.
|
||||
let serveSimPackageSpec: string | null = null;
|
||||
|
||||
@@ -507,14 +507,19 @@ function captureProcessOutput(recordingProcess: ChildProcess): {
|
||||
} {
|
||||
let output = '';
|
||||
let started = false;
|
||||
const appendChunk = (chunk: Buffer | string): void => {
|
||||
output = `${output}${chunk.toString()}`;
|
||||
if (output.includes(SERVE_SIM_RECORDING_STARTED)) {
|
||||
started = true;
|
||||
}
|
||||
output = output.slice(-16_384);
|
||||
const captureStreamOutput = (stream: ChildProcess['stdout']): void => {
|
||||
let streamOutput = '';
|
||||
stream?.on('data', (chunk: Buffer | string) => {
|
||||
const text = chunk.toString();
|
||||
output = `${output}${text}`.slice(-16_384);
|
||||
streamOutput = `${streamOutput}${text}`;
|
||||
if (SERVE_SIM_RECORDING_STARTED_LINE.test(streamOutput)) {
|
||||
started = true;
|
||||
}
|
||||
streamOutput = streamOutput.slice(-16_384);
|
||||
});
|
||||
};
|
||||
recordingProcess.stdout?.on('data', appendChunk);
|
||||
recordingProcess.stderr?.on('data', appendChunk);
|
||||
captureStreamOutput(recordingProcess.stdout);
|
||||
captureStreamOutput(recordingProcess.stderr);
|
||||
return { getOutput: () => output, hasStarted: () => started };
|
||||
}
|
||||
@@ -144,7 +144,10 @@ test('starts serve-sim record-video and returns only a finalized recording', asy
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await setTimeout(20);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
stderr.write('serve-sim:recording-started\n');
|
||||
child.stdout?.emit('data', 'Downloading');
|
||||
stderr.write('serve-sim:recording-');
|
||||
child.stdout?.emit('data', ' package');
|
||||
stderr.write('started\r\n');
|
||||
stderr.write('x'.repeat(17_000));
|
||||
const recordings = await finishing;
|
||||
|
||||
@@ -362,6 +365,32 @@ test('keeps a recorder active after its package-manager wrapper exits', async ()
|
||||
expect(recordings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('does not mistake bundled source in an error for a recording start', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
const recorder = mockRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
recorder.child.stderr?.emit(
|
||||
'data',
|
||||
'const marker = "serve-sim:recording-started";\nError: No running serve-sim session found.\n'
|
||||
);
|
||||
recorder.fail(new Error('recorder exited before connecting'));
|
||||
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
try {
|
||||
expect(await Promise.race([finishing.then(() => true), setTimeout(250, false)])).toBe(true);
|
||||
} finally {
|
||||
currentTime += 311_000;
|
||||
await finishing;
|
||||
}
|
||||
expect(recorder.child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not signal a recorder that exits before it reports its start', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
|
||||
+43
@@ -7,6 +7,7 @@ import { setImmediate } from 'node:timers/promises';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import RemoteLoggerStream from '../../../logging/RemoteLoggerStream';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { downloadBuildAsync } from '../../functions/downloadBuild';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
@@ -266,6 +267,48 @@ describe('with a device run session token', () => {
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles a stopped-session TURN response without a warning or retry', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{ errors: [{ code: 'DEVICE_RUN_SESSION_ALREADY_STOPPED', message: 'Stopped.' }] },
|
||||
400
|
||||
)
|
||||
);
|
||||
|
||||
await expect(fetchWebPreviewTurnArgsAsync(ctx, { logger })).resolves.toEqual([]);
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Device run session was stopped before TURN credentials were fetched.'
|
||||
);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([undefined, 'VALIDATION_ERROR'])(
|
||||
'still warns on a TURN failure with code %s',
|
||||
async code => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{ errors: [{ code, message: 'Device run session has already stopped.' }] },
|
||||
400
|
||||
)
|
||||
);
|
||||
|
||||
await expect(fetchWebPreviewTurnArgsAsync(ctx, { logger })).resolves.toEqual([]);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalled();
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('creates artifact upload sessions through the worker API', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
|
||||
@@ -0,0 +1,873 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import { once } from 'node:events';
|
||||
import { createServer } from 'node:http';
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
import { type AddressInfo, createConnection, createServer as createTcpServer } from 'node:net';
|
||||
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startNgrokTunnelAsync } from '../ngrokTunnel';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
jest.mock('node:timers', () => ({
|
||||
setTimeout: (...args: Parameters<typeof setTimeout>) => setTimeout(...args),
|
||||
clearTimeout: (...args: Parameters<typeof clearTimeout>) => clearTimeout(...args),
|
||||
}));
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
|
||||
const { Response } = jest.requireActual<typeof import('node-fetch')>('node-fetch');
|
||||
const url = 'https://web-preview-fixed.example.test';
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const options = {
|
||||
port: 4321,
|
||||
subdomainPrefix: 'web-preview',
|
||||
subdomainId: 'fixed',
|
||||
baseDomain: 'example.test',
|
||||
authtoken: 'token',
|
||||
rewriteHostHeader: true,
|
||||
healthCheck: { path: '/healthz' },
|
||||
logger,
|
||||
};
|
||||
|
||||
function listener(publicUrl = url) {
|
||||
return { url: () => publicUrl, close: jest.fn().mockResolvedValue(undefined) };
|
||||
}
|
||||
|
||||
let initial: ReturnType<typeof listener>;
|
||||
let replacement: ReturnType<typeof listener>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
|
||||
jest.clearAllMocks();
|
||||
initial = listener();
|
||||
replacement = listener();
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockResolvedValue(replacement as never);
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockReset()
|
||||
.mockResolvedValue(new Response(undefined, { status: 200 }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
function failPublicProbes() {
|
||||
jest.mocked(turtleFetch).mockImplementation(
|
||||
async target =>
|
||||
new Response(undefined, {
|
||||
status: target.startsWith('https:') ? 503 : 200,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
it('keeps tunnels without a health check unsupervised and closes them on stop', async () => {
|
||||
const tunnel = await startNgrokTunnelAsync({ ...options, healthCheck: undefined });
|
||||
await jest.advanceTimersByTimeAsync(90_000);
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
await tunnel.stopAsync();
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('probes the public service and keeps a healthy listener open', async () => {
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(3);
|
||||
expect(turtleFetch).toHaveBeenCalledWith(
|
||||
`${url}/healthz`,
|
||||
'GET',
|
||||
expect.objectContaining({
|
||||
retries: 0,
|
||||
redirect: 'manual',
|
||||
headers: expect.objectContaining({ 'ngrok-skip-browser-warning': 'true' }),
|
||||
})
|
||||
);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('reopens after three failed public probes with a healthy local service, preserving the endpoint', async () => {
|
||||
failPublicProbes();
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(30_000);
|
||||
expect(initial.close).not.toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
expect(jest.mocked(ngrok.forward).mock.calls[1][0]).toEqual({
|
||||
addr: expect.any(Number),
|
||||
authtoken: 'token',
|
||||
domain: 'web-preview-fixed.example.test',
|
||||
request_header_add: ['Host:localhost:4321'],
|
||||
force_new_session: true,
|
||||
});
|
||||
expect(tunnel.url).toBe(url);
|
||||
jest.mocked(turtleFetch).mockResolvedValue(new Response(undefined, { status: 200 }));
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('healthy again'));
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not reopen when the local service is unhealthy', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValue(new Response(undefined, { status: 503 }));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(90_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
expect(initial.close).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('resets consecutive failures after a successful public probe', async () => {
|
||||
failPublicProbes();
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(30_000);
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response(undefined, { status: 200 }));
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
await jest.advanceTimersByTimeAsync(30_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('retries a failed reopen with backoff', async () => {
|
||||
failPublicProbes();
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValue(replacement as never);
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
await jest.advanceTimersByTimeAsync(1_999);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('closes a listener that finishes opening after shutdown, with idempotent cleanup', async () => {
|
||||
failPublicProbes();
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
resolveOpening = resolve;
|
||||
})
|
||||
);
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
const stopping = tunnel.stopAsync();
|
||||
expect(tunnel.stopAsync()).toBe(stopping);
|
||||
const stopped = jest.fn();
|
||||
void stopping.then(stopped);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(stopped).not.toHaveBeenCalled();
|
||||
resolveOpening(replacement as never);
|
||||
await stopping;
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(120_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('waits for a replacement acquired during shutdown to finish closing', async () => {
|
||||
failPublicProbes();
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
let resolveClose!: () => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
|
||||
replacement.close.mockReturnValue(new Promise<void>(resolve => (resolveClose = resolve)));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
let stopped = false;
|
||||
const stopping = tunnel.stopAsync().then(() => {
|
||||
stopped = true;
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
resolveOpening(replacement as never);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
const stoppedBeforeClose = stopped;
|
||||
resolveClose();
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
await stopping;
|
||||
expect(stoppedBeforeClose).toBe(false);
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([0, 4, 5, 10])('retires a reopen cancelled after %s promise turns', async turns => {
|
||||
failPublicProbes();
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
resolveOpening(replacement as never);
|
||||
for (let turn = 0; turn < turns; turn++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
await tunnel.stopAsync();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries a rejected close when a reopen finishes after shutdown', async () => {
|
||||
failPublicProbes();
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
const stopping = tunnel.stopAsync();
|
||||
replacement.close.mockRejectedValueOnce(new Error('temporary unlisten failure'));
|
||||
resolveOpening(replacement as never);
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
await stopping;
|
||||
expect(replacement.close).toHaveBeenCalledTimes(2);
|
||||
const warnings = jest.mocked(logger.warn).mock.calls.length;
|
||||
await jest.advanceTimersByTimeAsync(120_000);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(warnings);
|
||||
});
|
||||
|
||||
it('fails stop if an in-flight reopen still has not settled by the stop deadline', async () => {
|
||||
failPublicProbes();
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
const rejected = expect(tunnel.stopAsync()).rejects.toThrow(
|
||||
'Ngrok tunnel web-preview-fixed.example.test stop timed out after 4000ms'
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
await rejected;
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
resolveOpening(replacement as never);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries an old listener while a reopen outlasts shutdown', async () => {
|
||||
failPublicProbes();
|
||||
initial.close.mockRejectedValueOnce(new Error('agent disconnected'));
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
const rejected = expect(tunnel.stopAsync()).rejects.toThrow(
|
||||
'Ngrok tunnel web-preview-fixed.example.test stop timed out after 4000ms'
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
await rejected;
|
||||
const oldCloseCalls = initial.close.mock.calls.length;
|
||||
resolveOpening(replacement as never);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(oldCloseCalls).toBe(2);
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('fails stop if a pending reopen arrives but cannot be retired', async () => {
|
||||
failPublicProbes();
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
|
||||
replacement.close.mockRejectedValue(new Error('unlisten failed'));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
const rejected = expect(tunnel.stopAsync()).rejects.toThrow(
|
||||
'Ngrok tunnel web-preview-fixed.example.test stop timed out'
|
||||
);
|
||||
resolveOpening(replacement as never);
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
await rejected;
|
||||
expect(replacement.close.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Could not retire late ngrok tunnel web-preview-fixed.example.test.'
|
||||
);
|
||||
const warnings = jest.mocked(logger.warn).mock.calls.length;
|
||||
await jest.advanceTimersByTimeAsync(120_000);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(warnings);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('rejects a changed public URL and closes the replacement', async () => {
|
||||
failPublicProbes();
|
||||
const changed = listener('https://different.example.test');
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockResolvedValue(changed as never);
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
expect(changed.close).toHaveBeenCalledTimes(1);
|
||||
expect(tunnel.url).toBe(url);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
expect.stringContaining('Could not reopen')
|
||||
);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('treats ngrok error responses as failures even if their status is accepted', async () => {
|
||||
jest.mocked(turtleFetch).mockImplementation(
|
||||
async target =>
|
||||
new Response(undefined, {
|
||||
status: 200,
|
||||
headers: target.startsWith('https:') ? { 'ngrok-error-code': 'ERR_NGROK_3200' } : {},
|
||||
})
|
||||
);
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reopens on a fresh agent session even if closing the old listener fails', async () => {
|
||||
failPublicProbes();
|
||||
initial.close.mockRejectedValueOnce(new Error('agent disconnected'));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
expect(ngrok.forward).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ force_new_session: true })
|
||||
);
|
||||
expect(ngrok.disconnect).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
expect(initial.close).toHaveBeenCalledTimes(2);
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries a rejected close before reopening an endpoint that remains bound', async () => {
|
||||
failPublicProbes();
|
||||
let domainOccupied = true;
|
||||
initial.close
|
||||
.mockRejectedValueOnce(new Error('agent disconnected'))
|
||||
.mockImplementation(async () => {
|
||||
domainOccupied = false;
|
||||
});
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockImplementation(async () => {
|
||||
if (domainOccupied) {
|
||||
throw new Error('endpoint is still bound');
|
||||
}
|
||||
return replacement as never;
|
||||
});
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
await jest.advanceTimersByTimeAsync(2_000);
|
||||
expect(initial.close).toHaveBeenCalledTimes(2);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(3);
|
||||
expect(tunnel.url).toBe(url);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retires an old listener even after its replacement is healthy', async () => {
|
||||
failPublicProbes();
|
||||
initial.close.mockRejectedValueOnce(new Error('agent disconnected'));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
jest.mocked(turtleFetch).mockResolvedValue(new Response(undefined, { status: 200 }));
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
expect(initial.close).toHaveBeenCalledTimes(2);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('retries an old listener without repeating its close warning on every probe', async () => {
|
||||
failPublicProbes();
|
||||
initial.close.mockRejectedValue(new Error('old session disconnected'));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
const warnings = jest.mocked(logger.warn).mock.calls.length;
|
||||
expect(warnings).toBeGreaterThan(0);
|
||||
jest.mocked(turtleFetch).mockResolvedValue(new Response(undefined, { status: 200 }));
|
||||
await jest.advanceTimersByTimeAsync(120_000);
|
||||
expect(initial.close.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(warnings);
|
||||
const rejected = expect(tunnel.stopAsync()).rejects.toThrow(
|
||||
'Ngrok tunnel web-preview-fixed.example.test stop timed out'
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
await rejected;
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries a rejected close during shutdown', async () => {
|
||||
const failure = new Error('agent disconnected');
|
||||
initial.close.mockRejectedValueOnce(failure);
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
const stopping = tunnel.stopAsync();
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
await stopping;
|
||||
expect(initial.close).toHaveBeenCalledTimes(2);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: failure },
|
||||
'Could not stop ngrok tunnel web-preview-fixed.example.test.'
|
||||
);
|
||||
});
|
||||
|
||||
it('logs the first close rejection once while bounded shutdown retries it', async () => {
|
||||
const failure = new Error('agent disconnected');
|
||||
initial.close.mockRejectedValue(failure);
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
const rejected = expect(tunnel.stopAsync()).rejects.toThrow('stop timed out');
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
await rejected;
|
||||
expect(initial.close.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: failure },
|
||||
'Could not stop ngrok tunnel web-preview-fixed.example.test.'
|
||||
);
|
||||
});
|
||||
|
||||
it('bounds shutdown during recovery without duplicating a pending native close', async () => {
|
||||
failPublicProbes();
|
||||
let finishClose!: () => void;
|
||||
initial.close.mockReturnValueOnce(new Promise<void>(resolve => (finishClose = resolve)));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
const stopping = tunnel.stopAsync();
|
||||
expect(tunnel.stopAsync()).toBe(stopping);
|
||||
const rejected = expect(stopping).rejects.toThrow(
|
||||
'Ngrok tunnel web-preview-fixed.example.test stop timed out after 4000ms'
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
await rejected;
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
const warnings = jest.mocked(logger.warn).mock.calls.length;
|
||||
finishClose();
|
||||
await jest.advanceTimersByTimeAsync(120_000);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(warnings);
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('bounds a stalled listener close before reopening', async () => {
|
||||
failPublicProbes();
|
||||
initial.close.mockImplementationOnce(async () => await new Promise(() => {}));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(49_999);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
const stopping = tunnel.stopAsync();
|
||||
const rejected = expect(stopping).rejects.toThrow(
|
||||
'Ngrok tunnel web-preview-fixed.example.test stop timed out'
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
await rejected;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not queue more native opens while a timed-out open is still pending', async () => {
|
||||
failPublicProbes();
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
resolveOpening = resolve;
|
||||
})
|
||||
)
|
||||
.mockResolvedValue(replacement as never);
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
const late = listener();
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(180_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
resolveOpening(late as never);
|
||||
await jest.advanceTimersByTimeAsync(30_000);
|
||||
expect(late.close).toHaveBeenCalledTimes(1);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('bounds initial tunnel creation and retires a late listener', async () => {
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
resolveOpening = resolve;
|
||||
})
|
||||
);
|
||||
const opening = startNgrokTunnelAsync(options);
|
||||
const rejected = expect(opening).rejects.toThrow('Ngrok tunnel open timed out');
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
await rejected;
|
||||
resolveOpening(initial as never);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries a rejected close after initial tunnel creation timed out', async () => {
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
|
||||
const opening = startNgrokTunnelAsync(options);
|
||||
const rejected = expect(opening).rejects.toThrow('Ngrok tunnel open timed out');
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
await rejected;
|
||||
initial.close.mockRejectedValueOnce(new Error('temporary unlisten failure'));
|
||||
resolveOpening(initial as never);
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
expect(initial.close).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('retires only the late listener without blaming it for an older stuck listener', async () => {
|
||||
failPublicProbes();
|
||||
initial.close.mockRejectedValue(new Error('old session disconnected'));
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)))
|
||||
.mockResolvedValue(replacement as never);
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
const late = listener();
|
||||
resolveOpening(late as never);
|
||||
jest.mocked(turtleFetch).mockResolvedValue(new Response(undefined, { status: 200 }));
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
expect(late.close).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
jest
|
||||
.mocked(logger.warn)
|
||||
.mock.calls.some(([, message]) => message?.includes('Could not retire late'))
|
||||
).toBe(false);
|
||||
} finally {
|
||||
initial.close.mockResolvedValue(undefined);
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('recovers using actual HTTP health probes with a local ngrok adapter', async () => {
|
||||
const actualFetch = jest.requireActual<typeof import('node-fetch')>('node-fetch').default;
|
||||
const realSetTimeout = jest.requireActual<typeof import('node:timers')>('node:timers').setTimeout;
|
||||
let broken = true;
|
||||
const local = createServer((_req, res) => {
|
||||
res.writeHead(200);
|
||||
res.end('healthy');
|
||||
});
|
||||
const publicServer = createServer((_req, res) => {
|
||||
res.writeHead(broken ? 503 : 200);
|
||||
res.end('probe');
|
||||
});
|
||||
await Promise.all(
|
||||
[local, publicServer].map(
|
||||
server => new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
)
|
||||
);
|
||||
const publicUrl = `http://127.0.0.1:${(publicServer.address() as AddressInfo).port}`;
|
||||
let completedRequests = 0;
|
||||
jest.mocked(turtleFetch).mockImplementation(async (target, method, requestOptions) => {
|
||||
const response = await actualFetch(target, { ...requestOptions, method });
|
||||
completedRequests++;
|
||||
return response;
|
||||
});
|
||||
const first = listener(publicUrl);
|
||||
const next = listener(publicUrl);
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(first as never)
|
||||
.mockImplementation(async () => {
|
||||
broken = false;
|
||||
return next as never;
|
||||
});
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
...options,
|
||||
port: (local.address() as AddressInfo).port,
|
||||
});
|
||||
try {
|
||||
for (let count = 0; count < 3; count++) {
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
for (let turns = 0; turns < 50 && completedRequests < (count + 1) * 2; turns++) {
|
||||
await new Promise(resolve => realSetTimeout(resolve, 10));
|
||||
}
|
||||
expect(completedRequests).toBe((count + 1) * 2);
|
||||
}
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
expect(first.close).toHaveBeenCalledTimes(1);
|
||||
expect((await actualFetch(`${tunnel.url}/healthz`)).status).toBe(200);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
await Promise.all(
|
||||
[local, publicServer].map(
|
||||
server =>
|
||||
new Promise<void>((resolve, reject) =>
|
||||
server.close(err => (err ? reject(err) : resolve()))
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
expect(next.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('blocks HTTP and existing WebSockets on Stop even when ngrok cannot close', async () => {
|
||||
jest.useRealTimers();
|
||||
const actualFetch = jest.requireActual<typeof import('node-fetch')>('node-fetch').default;
|
||||
let requests = 0;
|
||||
let messages = 0;
|
||||
const backend = createServer((_request, response) => {
|
||||
requests++;
|
||||
response.end('owned backend');
|
||||
});
|
||||
const websockets = new WebSocketServer({ server: backend });
|
||||
websockets.on('connection', socket => {
|
||||
socket.on('message', data => {
|
||||
messages++;
|
||||
socket.send(data);
|
||||
});
|
||||
});
|
||||
backend.listen(0, '127.0.0.1');
|
||||
await once(backend, 'listening');
|
||||
const backendPort = (backend.address() as AddressInfo).port;
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
...options,
|
||||
port: backendPort,
|
||||
healthCheck: undefined,
|
||||
});
|
||||
const targetPort = (jest.mocked(ngrok.forward).mock.calls[0][0] as ngrok.Config).addr;
|
||||
const targetUrl = `http://127.0.0.1:${targetPort}`;
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${targetPort}`);
|
||||
const opened = once(socket, 'open');
|
||||
try {
|
||||
expect(await (await actualFetch(targetUrl)).text()).toBe('owned backend');
|
||||
await opened;
|
||||
const echoed = once(socket, 'message');
|
||||
socket.send('before-stop');
|
||||
expect((await echoed)[0].toString()).toBe('before-stop');
|
||||
const closed = once(socket, 'close');
|
||||
initial.close.mockRejectedValue(new Error('agent disconnected'));
|
||||
const stopping = tunnel.stopAsync();
|
||||
const rejected = expect(stopping).rejects.toThrow('stop timed out after 4000ms');
|
||||
await expect(actualFetch(targetUrl, { timeout: 1_000 })).rejects.toThrow();
|
||||
await closed;
|
||||
expect(requests).toBe(1);
|
||||
expect(messages).toBe(1);
|
||||
expect(await (await actualFetch(`http://127.0.0.1:${backendPort}`)).text()).toBe(
|
||||
'owned backend'
|
||||
);
|
||||
await rejected;
|
||||
await expect(actualFetch(targetUrl, { timeout: 1_000 })).rejects.toThrow();
|
||||
expect(requests).toBe(2);
|
||||
expect(messages).toBe(1);
|
||||
const competing = createTcpServer();
|
||||
const conflict = once(competing, 'error');
|
||||
competing.listen(targetPort as number, '127.0.0.1');
|
||||
expect((await conflict)[0]).toMatchObject({ code: 'EADDRINUSE' });
|
||||
} finally {
|
||||
socket.terminate();
|
||||
for (const client of websockets.clients) {
|
||||
client.terminate();
|
||||
}
|
||||
websockets.close();
|
||||
backend.closeAllConnections();
|
||||
await new Promise<void>(resolve => backend.close(() => resolve()));
|
||||
await tunnel.stopAsync().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('releases its local target when initial tunnel creation fails', async () => {
|
||||
jest.useRealTimers();
|
||||
jest.mocked(ngrok.forward).mockReset().mockRejectedValue(new Error('forward failed'));
|
||||
await expect(startNgrokTunnelAsync(options)).rejects.toThrow('forward failed');
|
||||
const targetPort = (jest.mocked(ngrok.forward).mock.calls[0][0] as ngrok.Config).addr;
|
||||
const server = createServer();
|
||||
server.listen(targetPort as number, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('preserves a full response for a half-closed slow reader', async () => {
|
||||
jest.useRealTimers();
|
||||
const payload = Buffer.alloc(4 * 1024 * 1024, 37);
|
||||
const backend = createTcpServer({ allowHalfOpen: true }, socket => {
|
||||
socket.resume();
|
||||
socket.on('end', () => socket.end(payload));
|
||||
});
|
||||
backend.listen(0, '127.0.0.1');
|
||||
await once(backend, 'listening');
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
...options,
|
||||
port: (backend.address() as AddressInfo).port,
|
||||
healthCheck: undefined,
|
||||
});
|
||||
const targetPort = (jest.mocked(ngrok.forward).mock.calls[0][0] as ngrok.Config).addr as number;
|
||||
const socket = createConnection({ host: '127.0.0.1', port: targetPort, allowHalfOpen: true });
|
||||
try {
|
||||
const chunks: Buffer[] = [];
|
||||
socket.on('data', chunk => chunks.push(chunk));
|
||||
const ended = once(socket, 'end');
|
||||
socket.pause();
|
||||
socket.end('request');
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
socket.resume();
|
||||
await ended;
|
||||
expect(Buffer.concat(chunks).equals(payload)).toBe(true);
|
||||
} finally {
|
||||
socket.destroy();
|
||||
await tunnel.stopAsync();
|
||||
await new Promise<void>(resolve => backend.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['resolve', 'reject'])(
|
||||
'releases the target after timed-out startup later %s',
|
||||
async outcome => {
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
let rejectOpening!: (error: Error) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockReturnValue(
|
||||
new Promise((resolve, reject) => {
|
||||
resolveOpening = resolve;
|
||||
rejectOpening = reject;
|
||||
})
|
||||
);
|
||||
const opening = startNgrokTunnelAsync(options);
|
||||
const rejected = expect(opening).rejects.toThrow('open timed out');
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
await rejected;
|
||||
const targetPort = (jest.mocked(ngrok.forward).mock.calls[0][0] as ngrok.Config).addr as number;
|
||||
if (outcome === 'resolve') {
|
||||
resolveOpening(replacement as never);
|
||||
} else {
|
||||
rejectOpening(new Error('forward failed'));
|
||||
}
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
const server = createTcpServer();
|
||||
server.listen(targetPort, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
}
|
||||
);
|
||||
|
||||
it('drains a full upload when the backend ends its response early', async () => {
|
||||
jest.useRealTimers();
|
||||
const payload = Buffer.alloc(4 * 1024 * 1024, 51);
|
||||
let received!: Buffer;
|
||||
let receivedRequest!: () => void;
|
||||
const completed = new Promise<void>(resolve => {
|
||||
receivedRequest = resolve;
|
||||
});
|
||||
const backend = createTcpServer({ allowHalfOpen: true }, socket => {
|
||||
const chunks: Buffer[] = [];
|
||||
socket.on('data', chunk => chunks.push(chunk));
|
||||
socket.on('end', () => {
|
||||
received = Buffer.concat(chunks);
|
||||
receivedRequest();
|
||||
});
|
||||
socket.pause();
|
||||
socket.end('early response');
|
||||
setTimeout(() => socket.resume(), 100);
|
||||
});
|
||||
backend.listen(0, '127.0.0.1');
|
||||
await once(backend, 'listening');
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
...options,
|
||||
port: (backend.address() as AddressInfo).port,
|
||||
healthCheck: undefined,
|
||||
});
|
||||
const targetPort = (jest.mocked(ngrok.forward).mock.calls[0][0] as ngrok.Config).addr as number;
|
||||
const socket = createConnection({ host: '127.0.0.1', port: targetPort, allowHalfOpen: true });
|
||||
try {
|
||||
socket.resume();
|
||||
socket.end(payload);
|
||||
await completed;
|
||||
expect(received.equals(payload)).toBe(true);
|
||||
} finally {
|
||||
socket.destroy();
|
||||
await tunnel.stopAsync();
|
||||
await new Promise<void>(resolve => backend.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
@@ -932,7 +932,7 @@ describe(waitForWebPreviewReadyAsync, () => {
|
||||
|
||||
describe(startNgrokTunnelAsync, () => {
|
||||
it.each(['rejected', 'stalled'] as const)(
|
||||
'retains a %s close failure on repeated stops',
|
||||
'bounds %s listener retirement and reuses the stop promise',
|
||||
async mode => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
@@ -954,14 +954,12 @@ describe(startNgrokTunnelAsync, () => {
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
const stopping = tunnel.stopAsync();
|
||||
const assertion = expect(stopping).rejects.toThrow(
|
||||
mode === 'rejected' ? 'close failed' : 'Ngrok tunnel stop timed out after 4000ms.'
|
||||
);
|
||||
const assertion = expect(stopping).rejects.toThrow(/stop timed out after 4000ms/);
|
||||
expect(tunnel.stopAsync()).toBe(stopping);
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
await assertion;
|
||||
await expect(tunnel.stopAsync()).rejects.toThrow();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(close).toHaveBeenCalled();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
@@ -985,7 +983,7 @@ describe(startNgrokTunnelAsync, () => {
|
||||
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
addr: 4321,
|
||||
addr: expect.any(Number),
|
||||
authtoken: 'token',
|
||||
domain: expect.stringMatching(/^web-preview-[a-f0-9]{32}\.eas-simulator\.ngrok\.dev$/),
|
||||
})
|
||||
@@ -1302,7 +1300,10 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
recordingDirectory,
|
||||
}),
|
||||
]);
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ addr: expect.any(Number) })
|
||||
);
|
||||
expect(ngrok.forward).not.toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(preview.apiUrl).toBe('https://android-preview.example.test');
|
||||
|
||||
await host.finishAsync();
|
||||
@@ -1438,7 +1439,10 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
packageVersion,
|
||||
}),
|
||||
]);
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ addr: expect.any(Number) })
|
||||
);
|
||||
expect(ngrok.forward).not.toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(preview.apiUrl).toBe('https://ios-preview.example.test');
|
||||
|
||||
await host.finishAsync();
|
||||
|
||||
@@ -623,6 +623,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
subdomainId,
|
||||
baseDomain,
|
||||
authtoken,
|
||||
healthCheck: { path: isAndroid ? '/readyz' : '/healthz' },
|
||||
logger,
|
||||
});
|
||||
if (finishTask) {
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { once } from 'node:events';
|
||||
import { type AddressInfo, type Socket, createConnection, createServer } from 'node:net';
|
||||
import { clearTimeout, setTimeout } from 'node:timers';
|
||||
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
|
||||
const PROBE_INTERVAL_MS = 15_000;
|
||||
const FAILURE_THRESHOLD = 3;
|
||||
const STOP_TIMEOUT_MS = 4_000;
|
||||
|
||||
export type NgrokTunnelHandle = {
|
||||
url: string;
|
||||
subdomainId: string;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
export async function startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix,
|
||||
subdomainId = randomBytes(16).toString('hex'),
|
||||
baseDomain,
|
||||
authtoken,
|
||||
rewriteHostHeader,
|
||||
healthCheck,
|
||||
logger,
|
||||
}: {
|
||||
port: number;
|
||||
subdomainPrefix: string;
|
||||
subdomainId?: string;
|
||||
baseDomain: string;
|
||||
authtoken: string;
|
||||
rewriteHostHeader?: boolean;
|
||||
healthCheck?: { path: string; statuses?: number[] };
|
||||
logger: bunyan;
|
||||
}): Promise<NgrokTunnelHandle> {
|
||||
const domain = `${subdomainPrefix}-${subdomainId}.${baseDomain}`;
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
const sockets = new Set<Socket>();
|
||||
const target = createServer({ allowHalfOpen: true }, client => {
|
||||
if (signal.aborted) {
|
||||
client.destroy();
|
||||
return;
|
||||
}
|
||||
const upstream = createConnection({ host: '127.0.0.1', port, allowHalfOpen: true });
|
||||
for (const socket of [client, upstream]) {
|
||||
sockets.add(socket);
|
||||
socket.on('error', () => {
|
||||
client.destroy();
|
||||
upstream.destroy();
|
||||
});
|
||||
socket.on('close', () => sockets.delete(socket));
|
||||
}
|
||||
client.pipe(upstream).pipe(client);
|
||||
});
|
||||
target.listen(0, '127.0.0.1');
|
||||
await once(target, 'listening');
|
||||
target.unref();
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
for (const socket of sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
const config: ngrok.Config = {
|
||||
addr: (target.address() as AddressInfo).port,
|
||||
authtoken,
|
||||
domain,
|
||||
...(rewriteHostHeader ? { request_header_add: [`Host:localhost:${port}`] } : {}),
|
||||
};
|
||||
|
||||
const ownedListeners = new Set<ngrok.Listener>();
|
||||
const retiredListeners = new Set<ngrok.Listener>();
|
||||
const closingListeners = new Map<ngrok.Listener, Promise<void>>();
|
||||
let pendingOpen: Promise<ngrok.Listener> | undefined;
|
||||
let currentListener: ngrok.Listener | undefined;
|
||||
function releaseTarget(): void {
|
||||
if (signal.aborted && !pendingOpen && ownedListeners.size === 0 && target.listening) {
|
||||
target.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function closeAsync(
|
||||
listener: ngrok.Listener,
|
||||
closeSignal: AbortSignal,
|
||||
logFailure = true
|
||||
): Promise<void> {
|
||||
retiredListeners.add(listener);
|
||||
let closing = closingListeners.get(listener);
|
||||
if (!closing) {
|
||||
closing = Promise.resolve().then(async () => await listener.close());
|
||||
closingListeners.set(listener, closing);
|
||||
void closing.then(
|
||||
() => {
|
||||
closingListeners.delete(listener);
|
||||
retiredListeners.delete(listener);
|
||||
ownedListeners.delete(listener);
|
||||
releaseTarget();
|
||||
},
|
||||
() => closingListeners.delete(listener)
|
||||
);
|
||||
}
|
||||
try {
|
||||
await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Ngrok tunnel close', timeoutMs: 5_000, signal: closeSignal },
|
||||
async () => await closing
|
||||
);
|
||||
} catch (err) {
|
||||
if (logFailure && !closeSignal.aborted) {
|
||||
logger.warn({ err }, `Could not stop ngrok tunnel ${domain}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function retireListenersAsync(retirementSignal: AbortSignal): Promise<void> {
|
||||
let logFailure = true;
|
||||
while (retiredListeners.size > 0) {
|
||||
retirementSignal.throwIfAborted();
|
||||
await Promise.all(
|
||||
[...retiredListeners].map(retired => closeAsync(retired, retirementSignal, logFailure))
|
||||
);
|
||||
logFailure = false;
|
||||
if (retiredListeners.size > 0) {
|
||||
await waitAsync(250, retirementSignal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function retireLateListenerAsync(listener: ngrok.Listener): Promise<void> {
|
||||
retiredListeners.add(listener);
|
||||
try {
|
||||
await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Late ngrok tunnel retirement', timeoutMs: STOP_TIMEOUT_MS },
|
||||
async retirementSignal => {
|
||||
while (retiredListeners.has(listener)) {
|
||||
retirementSignal.throwIfAborted();
|
||||
await closeAsync(listener, retirementSignal, false);
|
||||
if (retiredListeners.has(listener)) {
|
||||
await waitAsync(250, retirementSignal);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `Could not retire late ngrok tunnel ${domain}.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function openAsync(forceNewSession = false): Promise<ngrok.Listener> {
|
||||
if (pendingOpen) {
|
||||
throw new SystemError(`A previous ngrok tunnel open for ${domain} is still pending.`);
|
||||
}
|
||||
let acquired: ngrok.Listener | undefined;
|
||||
let retirement: Promise<void> | undefined;
|
||||
const retireAcquired = () => {
|
||||
if (acquired) {
|
||||
retirement ??= retireLateListenerAsync(acquired);
|
||||
}
|
||||
};
|
||||
try {
|
||||
return await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Ngrok tunnel open', timeoutMs: 15_000, signal },
|
||||
async openSignal => {
|
||||
openSignal.throwIfAborted();
|
||||
const opening = ngrok
|
||||
.forward({
|
||||
...config,
|
||||
...(forceNewSession ? { force_new_session: true } : {}),
|
||||
})
|
||||
.then(listener => {
|
||||
acquired = listener;
|
||||
ownedListeners.add(listener);
|
||||
return listener;
|
||||
})
|
||||
.finally(() => {
|
||||
pendingOpen = undefined;
|
||||
});
|
||||
pendingOpen = opening;
|
||||
void opening
|
||||
.then(() => {
|
||||
if (openSignal.aborted) {
|
||||
retireAcquired();
|
||||
}
|
||||
})
|
||||
.catch(() => releaseTarget());
|
||||
const listener = await opening;
|
||||
openSignal.throwIfAborted();
|
||||
return listener;
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
retireAcquired();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Starting ngrok tunnel ${domain} -> http://localhost:${port}.`);
|
||||
let publicUrl: string | null;
|
||||
try {
|
||||
currentListener = await openAsync();
|
||||
publicUrl = currentListener.url();
|
||||
if (!publicUrl) {
|
||||
await closeAsync(currentListener, signal);
|
||||
currentListener = undefined;
|
||||
throw new SystemError(`ngrok tunnel for ${domain} did not return a public URL.`);
|
||||
}
|
||||
} catch (error) {
|
||||
controller.abort();
|
||||
releaseTarget();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const url = publicUrl;
|
||||
|
||||
async function probeAsync(
|
||||
baseUrl: string,
|
||||
{ path, statuses = [200] }: NonNullable<typeof healthCheck>
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Ngrok tunnel health probe', timeoutMs: 5_000, signal },
|
||||
async probeSignal => {
|
||||
const requestController = new AbortController();
|
||||
try {
|
||||
const response = await turtleFetch(new URL(path, baseUrl).href, 'GET', {
|
||||
retries: 0,
|
||||
redirect: 'manual',
|
||||
shouldThrowOnNotOk: false,
|
||||
headers: { 'ngrok-skip-browser-warning': 'true', 'Cache-Control': 'no-cache' },
|
||||
signal: AbortSignal.any([probeSignal, requestController.signal]),
|
||||
});
|
||||
return !response.headers.has('ngrok-error-code') && statuses.includes(response.status);
|
||||
} finally {
|
||||
requestController.abort();
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function superviseAsync(check: NonNullable<typeof healthCheck>): Promise<void> {
|
||||
let failures = 0;
|
||||
let attempts = 0;
|
||||
let localUnhealthy = false;
|
||||
while (
|
||||
await waitAsync(
|
||||
currentListener || localUnhealthy
|
||||
? PROBE_INTERVAL_MS
|
||||
: Math.min(1_000 * 2 ** Math.min(attempts, 5), 30_000),
|
||||
signal
|
||||
)
|
||||
) {
|
||||
await Promise.all([...retiredListeners].map(retired => closeAsync(retired, signal, false)));
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
if (currentListener && (await probeAsync(url, check))) {
|
||||
if (failures > 0 || attempts > 0 || localUnhealthy) {
|
||||
logger.info(`Ngrok tunnel ${domain} is healthy again.`);
|
||||
}
|
||||
failures = 0;
|
||||
attempts = 0;
|
||||
localUnhealthy = false;
|
||||
continue;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
if (!(await probeAsync(`http://127.0.0.1:${port}`, check))) {
|
||||
if (!localUnhealthy && !signal.aborted) {
|
||||
logger.warn(`Local service on port ${port} is unhealthy; waiting for it to recover.`);
|
||||
}
|
||||
localUnhealthy = true;
|
||||
failures = 0;
|
||||
continue;
|
||||
}
|
||||
localUnhealthy = false;
|
||||
if (currentListener) {
|
||||
failures++;
|
||||
if (failures < FAILURE_THRESHOLD) {
|
||||
continue;
|
||||
}
|
||||
logger.warn(`Ngrok tunnel ${domain} failed ${failures} health probes; reopening it.`);
|
||||
await closeAsync(currentListener, signal);
|
||||
currentListener = undefined;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
attempts++;
|
||||
try {
|
||||
const reopened = await openAsync(/* forceNewSession */ true);
|
||||
if (reopened.url() !== url) {
|
||||
await closeAsync(reopened, signal);
|
||||
throw new SystemError(
|
||||
`Reopened ngrok tunnel for ${domain} returned a different public URL.`
|
||||
);
|
||||
}
|
||||
currentListener = reopened;
|
||||
failures = 0;
|
||||
} catch (err) {
|
||||
if (!signal.aborted && (attempts === 1 || attempts % 5 === 0)) {
|
||||
logger.warn(
|
||||
{ err, attempt: attempts },
|
||||
`Could not reopen ngrok tunnel ${domain}; will retry.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const supervision = healthCheck
|
||||
? superviseAsync(healthCheck).catch(err => {
|
||||
if (!signal.aborted) {
|
||||
logger.warn({ err }, `Ngrok tunnel supervision stopped for ${domain}.`);
|
||||
}
|
||||
})
|
||||
: Promise.resolve();
|
||||
let stopTask: Promise<void> | undefined;
|
||||
return {
|
||||
url,
|
||||
subdomainId,
|
||||
stopAsync: () =>
|
||||
(stopTask ??= (async () => {
|
||||
controller.abort();
|
||||
await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: `Ngrok tunnel ${domain} stop`, timeoutMs: STOP_TIMEOUT_MS },
|
||||
async stopSignal => {
|
||||
await supervision;
|
||||
if (currentListener) {
|
||||
retiredListeners.add(currentListener);
|
||||
currentListener = undefined;
|
||||
}
|
||||
await Promise.all([pendingOpen?.catch(() => {}), retireListenersAsync(stopSignal)]);
|
||||
await retireListenersAsync(stopSignal);
|
||||
}
|
||||
);
|
||||
releaseTarget();
|
||||
})()),
|
||||
};
|
||||
}
|
||||
|
||||
function waitAsync(ms: number, signal: AbortSignal): Promise<boolean> {
|
||||
if (signal.aborted) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return new Promise(resolve => {
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
resolve(false);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
resolve(true);
|
||||
}, ms);
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
@@ -9,9 +9,7 @@ import {
|
||||
spawnAsync,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import { z } from 'zod';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import { createServer } from 'node:net';
|
||||
import path from 'node:path';
|
||||
@@ -524,6 +522,14 @@ export async function fetchWebPreviewTurnArgsAsync(
|
||||
}
|
||||
return args;
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof DeviceRunSessionWorkerApiError &&
|
||||
err.code === 'DEVICE_RUN_SESSION_ALREADY_STOPPED'
|
||||
) {
|
||||
logger.info('Device run session was stopped before TURN credentials were fetched.');
|
||||
return [];
|
||||
}
|
||||
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not fetch Cloudflare TURN ICE servers', error, { level: 'warning' });
|
||||
logger.warn(
|
||||
@@ -959,63 +965,7 @@ export async function findAvailablePortAsync(): Promise<number> {
|
||||
return address.port;
|
||||
}
|
||||
|
||||
export type NgrokTunnelHandle = {
|
||||
url: string;
|
||||
subdomainId: string;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
export async function startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix,
|
||||
subdomainId: subdomainIdArg,
|
||||
baseDomain,
|
||||
authtoken,
|
||||
rewriteHostHeader,
|
||||
logger,
|
||||
}: {
|
||||
port: number;
|
||||
subdomainPrefix: string;
|
||||
subdomainId?: string;
|
||||
baseDomain: string;
|
||||
authtoken: string;
|
||||
rewriteHostHeader?: boolean;
|
||||
logger: bunyan;
|
||||
}): Promise<NgrokTunnelHandle> {
|
||||
const subdomainId = subdomainIdArg ?? randomBytes(16).toString('hex');
|
||||
const domain = `${subdomainPrefix}-${subdomainId}.${baseDomain}`;
|
||||
logger.info(`Starting ngrok tunnel ${domain} -> http://localhost:${port}.`);
|
||||
// Run the ngrok agent in-process via the SDK; it keeps the session alive until
|
||||
// the process exits, and the step blocks forever to hold it open.
|
||||
const listener = await ngrok.forward({
|
||||
addr: port,
|
||||
authtoken,
|
||||
domain,
|
||||
...(rewriteHostHeader ? { request_header_add: [`Host:localhost:${port}`] } : {}),
|
||||
});
|
||||
const url = listener.url();
|
||||
if (!url) {
|
||||
await listener.close();
|
||||
throw new SystemError(`ngrok tunnel for ${domain} did not return a public URL.`);
|
||||
}
|
||||
let stopTask: Promise<void> | undefined;
|
||||
return {
|
||||
url,
|
||||
subdomainId,
|
||||
stopAsync: () =>
|
||||
(stopTask ??= (async () => {
|
||||
try {
|
||||
await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Ngrok tunnel stop', timeoutMs: 4_000 },
|
||||
async () => await listener.close()
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn({ err: error }, `Could not stop ngrok tunnel ${domain}.`);
|
||||
throw error;
|
||||
}
|
||||
})()),
|
||||
};
|
||||
}
|
||||
export { type NgrokTunnelHandle, startNgrokTunnelAsync } from './ngrokTunnel';
|
||||
|
||||
export async function waitForFileAsync<T>({
|
||||
filePath,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { setTimeout } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { retryAsync } from './retry';
|
||||
import { waitForWarmSimulatorCleanupAsync } from './warmSimulatorCleanup';
|
||||
|
||||
export type IosSimulatorUuid = string & z.BRAND<'IosSimulatorUuid'>;
|
||||
export type IosSimulatorName = string & z.BRAND<'IosSimulatorName'>;
|
||||
@@ -119,6 +120,7 @@ export namespace IosSimulatorUtils {
|
||||
destinationDeviceName: IosSimulatorName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<void> {
|
||||
await waitForWarmSimulatorCleanupAsync(env);
|
||||
await spawn('xcrun', ['simctl', 'clone', sourceDeviceIdentifier, destinationDeviceName], {
|
||||
env,
|
||||
});
|
||||
@@ -131,6 +133,7 @@ export namespace IosSimulatorUtils {
|
||||
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<void> {
|
||||
await waitForWarmSimulatorCleanupAsync(env);
|
||||
try {
|
||||
const devices = await getAvailableDevicesAsync({ env, filter: 'available' });
|
||||
const device = devices.find(
|
||||
@@ -231,6 +234,7 @@ export namespace IosSimulatorUtils {
|
||||
env: NodeJS.ProcessEnv;
|
||||
launchdEnvironment?: Record<string, string>;
|
||||
}): Promise<void> {
|
||||
await waitForWarmSimulatorCleanupAsync(env);
|
||||
const bootEnv = { ...env };
|
||||
for (const [name, value] of Object.entries(launchdEnvironment)) {
|
||||
bootEnv[`SIMCTL_CHILD_${name}`] = value;
|
||||
@@ -253,6 +257,7 @@ export namespace IosSimulatorUtils {
|
||||
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<{ udid: IosSimulatorUuid }> {
|
||||
await waitForWarmSimulatorCleanupAsync(env);
|
||||
const bootstatusResult = await spawn(
|
||||
'xcrun',
|
||||
['simctl', 'bootstatus', deviceIdentifier, '-b'],
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from 'node:path';
|
||||
|
||||
import { IosSimulatorUtils } from '../IosSimulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
import { waitForWarmSimulatorCleanupAsync } from '../warmSimulatorCleanup';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
@@ -14,6 +15,7 @@ jest.mock('@expo/turtle-spawn', () => ({
|
||||
jest.mock('../retry', () => ({
|
||||
retryAsync: jest.fn(async fn => await fn(0)),
|
||||
}));
|
||||
jest.mock('../warmSimulatorCleanup');
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
@@ -21,6 +23,7 @@ describe('IosSimulatorUtils', () => {
|
||||
beforeEach(() => {
|
||||
mockedSpawn.mockReset();
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
jest.mocked(waitForWarmSimulatorCleanupAsync).mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.getAvailableRuntimesAsync, () => {
|
||||
@@ -151,6 +154,30 @@ describe('IosSimulatorUtils', () => {
|
||||
}) as any);
|
||||
}
|
||||
|
||||
it('waits for warm cleanup before checking device state and writing accessibility prefs', async () => {
|
||||
let finishCleanup: (() => void) | undefined;
|
||||
jest.mocked(waitForWarmSimulatorCleanupAsync).mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise<void>(resolve => {
|
||||
finishCleanup = resolve;
|
||||
})
|
||||
);
|
||||
mockAvailableDevices([device()]);
|
||||
|
||||
const setup = IosSimulatorUtils.enableAccessibilitySettingsAsync({
|
||||
deviceIdentifier: device().udid as any,
|
||||
env: {},
|
||||
});
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
expect(finishCleanup).toBeDefined();
|
||||
finishCleanup?.();
|
||||
await setup;
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('plutil', expect.anything(), {
|
||||
env: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('writes accessibility prefs for an exact shutdown simulator match', async () => {
|
||||
mockAvailableDevices([
|
||||
device({
|
||||
|
||||
@@ -76,11 +76,7 @@ describe(getSshRelayServerUrl, () => {
|
||||
});
|
||||
|
||||
describe(startSshSessionAsync, () => {
|
||||
const config1: SshConnectionConfig = {
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'secret-1',
|
||||
};
|
||||
const config1: SshConnectionConfig = { type: 'upterm-v1', secret: 'secret-1' };
|
||||
const config2: SshConnectionConfig = { ...config1, secret: 'secret-2' };
|
||||
const target = { type: 'JOB_RUN', id: 'jr-1' } as const;
|
||||
|
||||
@@ -133,7 +129,7 @@ describe(startSshSessionAsync, () => {
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
target: { type: 'JOB_RUN', id: 'jr-1' },
|
||||
connectionConfig: { ...config1, reconnecting: false, type: 'UPTERM_V1' },
|
||||
connectionConfig: { type: 'UPTERM_V1', secret: 'secret-1', reconnecting: false },
|
||||
sessionSettings: { idleTimeoutSeconds: 900 },
|
||||
});
|
||||
expect(idleTimeoutSeconds).toBe(900);
|
||||
@@ -155,7 +151,7 @@ describe(startSshSessionAsync, () => {
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
target: { type: 'BUILD', id: 'b-1' },
|
||||
connectionConfig: { ...config1, reconnecting: false, type: 'UPTERM_V1' },
|
||||
connectionConfig: { type: 'UPTERM_V1', secret: 'secret-1', reconnecting: false },
|
||||
sessionSettings: { idleTimeoutSeconds: 900 },
|
||||
});
|
||||
});
|
||||
@@ -242,7 +238,7 @@ describe(startSshSessionAsync, () => {
|
||||
);
|
||||
expect(mutation).toHaveBeenLastCalledWith(expect.anything(), {
|
||||
target: { type: 'JOB_RUN', id: 'jr-1' },
|
||||
connectionConfig: { ...config2, reconnecting: false, type: 'UPTERM_V1' },
|
||||
connectionConfig: { type: 'UPTERM_V1', secret: 'secret-2', reconnecting: false },
|
||||
sessionSettings: { idleTimeoutSeconds: 300 },
|
||||
});
|
||||
});
|
||||
@@ -298,7 +294,7 @@ describe(startSshSessionAsync, () => {
|
||||
expect(host.redialAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mutation).toHaveBeenLastCalledWith(expect.anything(), {
|
||||
target: { type: 'JOB_RUN', id: 'jr-1' },
|
||||
connectionConfig: { ...config2, reconnecting: false, type: 'UPTERM_V1' },
|
||||
connectionConfig: { type: 'UPTERM_V1', secret: 'secret-2', reconnecting: false },
|
||||
sessionSettings: { idleTimeoutSeconds: 300 },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { sleepAsync } from '../retry';
|
||||
import {
|
||||
connectionConfigFromUptermSession,
|
||||
redactConnectionSecrets,
|
||||
redactSpawnErrorForLog,
|
||||
resolveUptermGcsObjectName,
|
||||
@@ -42,49 +41,6 @@ describe(resolveUptermGcsObjectName, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(connectionConfigFromUptermSession, () => {
|
||||
it('parses sessionId and host from session current fields', () => {
|
||||
expect(
|
||||
connectionConfigFromUptermSession({
|
||||
sessionId: 'TOKENabc123',
|
||||
host: 'ssh://relay.expo.dev:22',
|
||||
})
|
||||
).toEqual({
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'TOKENabc123',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a bare hostname', () => {
|
||||
expect(connectionConfigFromUptermSession({ sessionId: 'tok', host: 'relay.expo.dev' })).toEqual(
|
||||
{
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'tok',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a non-default port so the CLI can pass it to ssh -p', () => {
|
||||
expect(
|
||||
connectionConfigFromUptermSession({
|
||||
sessionId: 'tok',
|
||||
host: 'ssh://relay.expo.dev:2222',
|
||||
})
|
||||
).toEqual({
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev:2222',
|
||||
secret: 'tok',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when the host is not a parseable url', () => {
|
||||
expect(connectionConfigFromUptermSession({ sessionId: 'tok', host: 'ssh://' })).toBeNull();
|
||||
expect(connectionConfigFromUptermSession({ sessionId: 'tok', host: 'ssh://[' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(redactConnectionSecrets, () => {
|
||||
it('strips the userinfo secret from a connection string', () => {
|
||||
expect(
|
||||
@@ -257,11 +213,7 @@ describe(startUptermHostAsync, () => {
|
||||
relayServerUrl: 'wss://relay.expo.dev',
|
||||
});
|
||||
|
||||
expect(host.connectionConfig).toEqual({
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'TOKENx',
|
||||
});
|
||||
expect(host.connectionConfig).toEqual({ type: 'upterm-v1', secret: 'TOKENx' });
|
||||
expect(host.isAlive()).toBe(true);
|
||||
expect(mockedDownloadFile).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('upterm', ['version'], {
|
||||
@@ -369,6 +321,21 @@ describe(startUptermHostAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps polling until upterm reports a session id', async () => {
|
||||
sessionStdout = JSON.stringify({ host: 'ssh://relay.expo.dev:22', clientCount: 0 });
|
||||
mockedSleep.mockImplementationOnce(async () => {
|
||||
sessionStdout = JSON.stringify({
|
||||
sessionId: 'TOKENy',
|
||||
host: 'ssh://relay.expo.dev:22',
|
||||
clientCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
|
||||
expect(host.connectionConfig).toEqual({ type: 'upterm-v1', secret: 'TOKENy' });
|
||||
});
|
||||
|
||||
it('tears down and rethrows when the host never registers with the relay', async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(0);
|
||||
@@ -420,11 +387,7 @@ describe(startUptermHostAsync, () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
const config = await host.redialAsync();
|
||||
|
||||
expect(config).toEqual({
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'TOKENx',
|
||||
});
|
||||
expect(config).toEqual({ type: 'upterm-v1', secret: 'TOKENx' });
|
||||
expect(host.connectionConfig).toEqual(config);
|
||||
});
|
||||
|
||||
@@ -462,11 +425,7 @@ describe(startUptermHostAsync, () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
hostProcess.catch = () => new Promise<void>(() => {});
|
||||
|
||||
await expect(host.redialAsync()).resolves.toEqual({
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'TOKENx',
|
||||
});
|
||||
await expect(host.redialAsync()).resolves.toEqual({ type: 'upterm-v1', secret: 'TOKENx' });
|
||||
expect(mockedSleep).toHaveBeenCalledWith(5_000);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
import { waitForWarmSimulatorCleanupAsync } from '../warmSimulatorCleanup';
|
||||
|
||||
const STATE_FILE = '/tmp/expo-simulator-startup.json';
|
||||
const STARTUP_SCRIPT = '/usr/local/share/vm-tools/startup.sh';
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
|
||||
async function writeStateAsync(state: object): Promise<void> {
|
||||
await fs.mkdir('/tmp', { recursive: true });
|
||||
await fs.writeFile(STATE_FILE, JSON.stringify({ bootUuid: 'current-boot', ...state }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: 'current-boot\n' } as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', platform);
|
||||
});
|
||||
|
||||
it('continues at once on a VM without warmup', async () => {
|
||||
await expect(waitForWarmSimulatorCleanupAsync({})).resolves.toBeUndefined();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores a previous boot cleanup record and reads the current boot only once', async () => {
|
||||
await writeStateAsync({ status: 'disabled', bootUuid: 'old-boot' });
|
||||
let finished = false;
|
||||
const waiting = waitForWarmSimulatorCleanupAsync({}, { pollMs: 5 }).then(() => {
|
||||
finished = true;
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 30));
|
||||
expect(finished).toBe(false);
|
||||
await writeStateAsync({ status: 'disabled' });
|
||||
await waiting;
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not accept a cleanup record without a boot ID', async () => {
|
||||
await writeStateAsync({ status: 'disabled', bootUuid: undefined });
|
||||
await expect(waitForWarmSimulatorCleanupAsync({}, { timeoutMs: 20, pollMs: 5 })).rejects.toThrow(
|
||||
'Warm Simulator cleanup did not finish'
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['failed', 'empty'])('fails closed when the current boot ID is %s', async mode => {
|
||||
await writeStateAsync({ status: 'disabled' });
|
||||
if (mode === 'failed') {
|
||||
jest.mocked(spawn).mockRejectedValueOnce(new Error('sysctl failed'));
|
||||
} else {
|
||||
jest.mocked(spawn).mockResolvedValueOnce({ stdout: '' } as never);
|
||||
}
|
||||
await expect(waitForWarmSimulatorCleanupAsync({})).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('waits for cleanup when warmup is installed but has not written state yet', async () => {
|
||||
await fs.mkdir('/usr/local/share/vm-tools', { recursive: true });
|
||||
await fs.writeFile(STARTUP_SCRIPT, '');
|
||||
let finished = false;
|
||||
const waiting = waitForWarmSimulatorCleanupAsync({}, { pollMs: 5 }).then(() => {
|
||||
finished = true;
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 30));
|
||||
expect(finished).toBe(false);
|
||||
await writeStateAsync({ status: 'starting' });
|
||||
await new Promise(resolve => setTimeout(resolve, 30));
|
||||
expect(finished).toBe(false);
|
||||
await writeStateAsync({ status: 'disabled' });
|
||||
await waiting;
|
||||
});
|
||||
|
||||
it('times out when installed warmup never writes state', async () => {
|
||||
await fs.mkdir('/usr/local/share/vm-tools', { recursive: true });
|
||||
await fs.writeFile(STARTUP_SCRIPT, '');
|
||||
await expect(waitForWarmSimulatorCleanupAsync({}, { timeoutMs: 20, pollMs: 5 })).rejects.toThrow(
|
||||
'Warm Simulator cleanup did not finish; the Simulator cannot be used.'
|
||||
);
|
||||
});
|
||||
|
||||
it('waits until the launcher records that cleanup finished', async () => {
|
||||
await writeStateAsync({ status: 'ready' });
|
||||
let finished = false;
|
||||
const waiting = waitForWarmSimulatorCleanupAsync({}, { pollMs: 5 }).then(() => {
|
||||
finished = true;
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 30));
|
||||
expect(finished).toBe(false);
|
||||
await writeStateAsync({ status: 'stopping' });
|
||||
await new Promise(resolve => setTimeout(resolve, 30));
|
||||
expect(finished).toBe(false);
|
||||
await writeStateAsync({ status: 'disabled' });
|
||||
await waiting;
|
||||
expect(finished).toBe(true);
|
||||
});
|
||||
|
||||
it('fails with the recorded error when cleanup does not finish in time', async () => {
|
||||
await writeStateAsync({ status: 'stopping', error: 'serve-sim did not exit' });
|
||||
await expect(waitForWarmSimulatorCleanupAsync({}, { timeoutMs: 20, pollMs: 5 })).rejects.toThrow(
|
||||
'Warm Simulator cleanup did not finish (serve-sim did not exit); the Simulator cannot be used.'
|
||||
);
|
||||
});
|
||||
|
||||
it('stops waiting when the job is cancelled', async () => {
|
||||
await writeStateAsync({ status: 'stopping' });
|
||||
const controller = new AbortController();
|
||||
const waiting = waitForWarmSimulatorCleanupAsync({}, { pollMs: 5, signal: controller.signal });
|
||||
controller.abort(new Error('cancelled'));
|
||||
await expect(waiting).rejects.toThrow('cancelled');
|
||||
});
|
||||
|
||||
it('does not wait for a job that uses the warm Simulator', async () => {
|
||||
await writeStateAsync({ status: 'claimed' });
|
||||
await expect(
|
||||
waitForWarmSimulatorCleanupAsync({ EAS_USE_WARM_SIMULATOR: '1' }, { timeoutMs: 0 })
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does nothing outside macOS', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
await writeStateAsync({ status: 'ready' });
|
||||
await expect(waitForWarmSimulatorCleanupAsync({}, { timeoutMs: 0 })).resolves.toBeUndefined();
|
||||
});
|
||||
@@ -37,11 +37,9 @@ const CONNECTION_STARTUP_TIMEOUT_MS = 60_000;
|
||||
const PROCESS_EXIT_TIMEOUT_MS = 5_000;
|
||||
const CLIENT_COUNT_READ_ATTEMPTS = 4;
|
||||
const CLIENT_COUNT_READ_RETRY_MS = 500;
|
||||
const DEFAULT_SSH_PORT = '22';
|
||||
|
||||
export type SshConnectionConfig = {
|
||||
type: 'upterm-v1';
|
||||
host: string;
|
||||
secret: string;
|
||||
};
|
||||
|
||||
@@ -55,30 +53,10 @@ export type UptermHost = {
|
||||
|
||||
const UptermSessionJsonZ = z.object({
|
||||
sessionId: z.string().min(1),
|
||||
host: z.string().min(1),
|
||||
clientCount: z.number().optional(),
|
||||
});
|
||||
type UptermSessionJson = z.infer<typeof UptermSessionJsonZ>;
|
||||
|
||||
export function connectionConfigFromUptermSession(
|
||||
parsed: Pick<UptermSessionJson, 'sessionId' | 'host'>
|
||||
): SshConnectionConfig | null {
|
||||
let host = parsed.host;
|
||||
if (host.includes('://')) {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(host);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
host = url.port && url.port !== DEFAULT_SSH_PORT ? `${url.hostname}:${url.port}` : url.hostname;
|
||||
}
|
||||
if (!host) {
|
||||
return null;
|
||||
}
|
||||
return { type: 'upterm-v1', host, secret: parsed.sessionId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip upterm session secrets out of text before it reaches a log or an error message. upterm
|
||||
* prints the session id as the userinfo of the connect line it advertises
|
||||
@@ -196,10 +174,7 @@ async function waitForConnectionConfigAsync(
|
||||
if (adminSocketPath) {
|
||||
const session = await readCurrentSessionJsonAsync(uptermPath, adminSocketPath);
|
||||
if (session) {
|
||||
const connectionConfig = connectionConfigFromUptermSession(session);
|
||||
if (connectionConfig) {
|
||||
return connectionConfig;
|
||||
}
|
||||
return { type: 'upterm-v1', secret: session.sessionId };
|
||||
}
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { existsSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { setTimeout as sleepAsync } from 'node:timers/promises';
|
||||
|
||||
const LAUNCHER_STATE_FILE = '/tmp/expo-simulator-startup.json';
|
||||
const STARTUP_SCRIPT = '/usr/local/share/vm-tools/startup.sh';
|
||||
|
||||
/**
|
||||
* The worker stops warmup in the background for a job that does not use the warm Simulator. Wait
|
||||
* here, before the job changes a Simulator, until the launcher records that its cleanup finished.
|
||||
*/
|
||||
export async function waitForWarmSimulatorCleanupAsync(
|
||||
env: NodeJS.ProcessEnv,
|
||||
{
|
||||
timeoutMs = 150_000,
|
||||
pollMs = 250,
|
||||
signal,
|
||||
}: { timeoutMs?: number; pollMs?: number; signal?: AbortSignal } = {}
|
||||
): Promise<void> {
|
||||
if (process.platform !== 'darwin' || env.EAS_USE_WARM_SIMULATOR === '1') {
|
||||
return;
|
||||
}
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let bootUuid: string | undefined;
|
||||
for (;;) {
|
||||
signal?.throwIfAborted();
|
||||
let state: { status?: unknown; error?: unknown; bootUuid?: unknown };
|
||||
try {
|
||||
state = JSON.parse(await fs.readFile(LAUNCHER_STATE_FILE, 'utf8'));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT' && !existsSync(STARTUP_SCRIPT)) {
|
||||
return;
|
||||
}
|
||||
// Warmup may still be loading; only disabled confirms cleanup on an enabled VM.
|
||||
state = {};
|
||||
}
|
||||
if (state.status === 'disabled' && typeof state.bootUuid === 'string') {
|
||||
bootUuid ??= (
|
||||
await spawn('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid'], {
|
||||
signal,
|
||||
timeout: Math.max(1, deadline - Date.now()),
|
||||
})
|
||||
).stdout.trim();
|
||||
if (!bootUuid) {
|
||||
throw new SystemError('Could not read the current VM boot ID.');
|
||||
}
|
||||
if (state.bootUuid === bootUuid) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new SystemError(
|
||||
`Warm Simulator cleanup did not finish${typeof state.error === 'string' ? ` (${state.error})` : ''}; the Simulator cannot be used.`
|
||||
);
|
||||
}
|
||||
// The signal is checked at each poll, so a cancelled job stops within one interval.
|
||||
await sleepAsync(pollMs);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
# create-eas-build-function
|
||||
|
||||
## 27.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "27.1.0",
|
||||
"version": "27.3.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "27.1.0"
|
||||
"@expo/steps": "27.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @expo/downloader
|
||||
|
||||
## 27.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "27.1.0",
|
||||
"version": "27.3.0",
|
||||
"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.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "27.1.0",
|
||||
"version": "27.3.0",
|
||||
"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,15 @@
|
||||
# @expo/eas-build-job
|
||||
|
||||
## 27.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add optional Observe error context to workflow jobs. ([#4787](https://github.com/expo/eas-cli/pull/4787) by [@AbbanMustafa](https://github.com/AbbanMustafa))
|
||||
|
||||
## 27.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "27.1.0",
|
||||
"version": "27.3.0",
|
||||
"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.1.0",
|
||||
"@expo/logger": "27.3.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "27.1.0",
|
||||
"@expo/turtle-spawn": "27.3.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ArchiveSourceType, BuildTrigger, StaticWorkflowInterpolationContextZ } from '../common';
|
||||
import { Generic } from '../generic';
|
||||
|
||||
function createContext() {
|
||||
return {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'Investigate error',
|
||||
filename: 'observe.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: { id: '123e4567-e89b-42d3-a456-426614174000', slug: 'example' },
|
||||
account: { id: 'account-id', name: 'example' },
|
||||
observe: {
|
||||
event: {
|
||||
id: '123e4567-e89b-42d3-a456-426614174001',
|
||||
type: 'new_error' as const,
|
||||
occurredAt: '2026-10-29T12:00:00.000Z',
|
||||
appId: '123e4567-e89b-42d3-a456-426614174000',
|
||||
fingerprint: { value: 'error-fingerprint', version: 'v1' },
|
||||
severity: 'fatal' as const,
|
||||
environment: 'production',
|
||||
platform: 'ios' as const,
|
||||
deviceOs: 'iOS',
|
||||
exception: { type: 'Error', message: 'Example error' },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('Observe workflow context', () => {
|
||||
it('preserves the event when validating a generic job', () => {
|
||||
const context = createContext();
|
||||
const job = {
|
||||
projectArchive: { type: ArchiveSourceType.GIT, gitCommitHash: 'abc123', gitRef: null },
|
||||
steps: [{ run: 'echo "${{ observe.event.exception.message }}"', shell: 'sh' }],
|
||||
secrets: { robotAccessToken: 'token', environmentSecrets: [] },
|
||||
expoDevUrl: context.workflow.url,
|
||||
builderEnvironment: { image: 'ubuntu', env: {} },
|
||||
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
|
||||
appId: context.app.id,
|
||||
initiatingUserId: '123e4567-e89b-42d3-a456-426614174002',
|
||||
workflowInterpolationContext: context,
|
||||
};
|
||||
|
||||
const parsed = Generic.JobZ.parse(job);
|
||||
|
||||
expect(parsed.workflowInterpolationContext).toEqual(context);
|
||||
});
|
||||
|
||||
it('accepts nullable event metadata and an omitted device OS', () => {
|
||||
const context = createContext();
|
||||
const nullableContext = {
|
||||
...context,
|
||||
observe: {
|
||||
event: {
|
||||
...context.observe.event,
|
||||
fingerprint: { value: 'error-fingerprint', version: null },
|
||||
severity: 'error',
|
||||
environment: null,
|
||||
platform: null,
|
||||
deviceOs: undefined,
|
||||
exception: { type: null, message: null },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(nullableContext)).toEqual(nullableContext);
|
||||
});
|
||||
|
||||
it('preserves additional event fields from a newer producer', () => {
|
||||
const context = createContext();
|
||||
const extendedContext = {
|
||||
...context,
|
||||
observe: {
|
||||
...context.observe,
|
||||
event: {
|
||||
...context.observe.event,
|
||||
additionalMetadata: 'future value',
|
||||
exception: { ...context.observe.event.exception, stacktrace: 'future stack trace' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(extendedContext)).toEqual(extendedContext);
|
||||
});
|
||||
|
||||
it('rejects an Observe context without an event', () => {
|
||||
expect(
|
||||
StaticWorkflowInterpolationContextZ.safeParse({ ...createContext(), observe: {} }).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -344,6 +344,24 @@ const SentryContextZ = z.looseObject({
|
||||
resolution_type: z.string().optional(),
|
||||
});
|
||||
|
||||
const ObserveContextZ = z.looseObject({
|
||||
event: z.looseObject({
|
||||
id: z.uuid(),
|
||||
type: z.literal('new_error'),
|
||||
occurredAt: z.iso.datetime(),
|
||||
appId: z.uuid(),
|
||||
fingerprint: z.looseObject({
|
||||
value: z.string().min(1),
|
||||
version: z.string().min(1).nullable(),
|
||||
}),
|
||||
severity: z.enum(['fatal', 'error']),
|
||||
environment: z.string().nullable(),
|
||||
platform: z.enum(['ios', 'android']).nullable(),
|
||||
deviceOs: z.string().optional(),
|
||||
exception: z.looseObject({ type: z.string().nullable(), message: z.string().nullable() }),
|
||||
}),
|
||||
});
|
||||
|
||||
export const StaticWorkflowInterpolationContextZ = z.object({
|
||||
after: z.record(
|
||||
z.string(),
|
||||
@@ -381,6 +399,7 @@ export const StaticWorkflowInterpolationContextZ = z.object({
|
||||
app_store_connect: AppStoreConnectContextZ.optional(),
|
||||
// We need to .optional() to support jobs that are not triggered by a Sentry event.
|
||||
sentry: SentryContextZ.optional(),
|
||||
observe: ObserveContextZ.optional(),
|
||||
});
|
||||
|
||||
export type StaticWorkflowInterpolationContext = z.infer<
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
# eas-cli
|
||||
|
||||
## 27.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Bump `@expo/apple-utils` to `2.3.0` to fix iOS capability sync when App Store Connect rejects enabling, disabling or changing a capability. ([#4790](https://github.com/expo/eas-cli/pull/4790) by [@douglowder](https://github.com/douglowder))
|
||||
- Keep already linked App Groups, Merchant IDs and iCloud containers when capability sync links another identifier. ([#4584](https://github.com/expo/eas-cli/pull/4584) by [@ceydaakin](https://github.com/ceydaakin))
|
||||
|
||||
## 27.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add an experiment that suggests Expo Update to coding agents after a build when the Free plan account is within 5 builds of the platform limit. ([#4758](https://github.com/expo/eas-cli/pull/4758) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- Add `eas observe:traces` to list request trace spans (or a summary of span names and counts), `eas observe:trace` to display one trace as a span tree, and span rows in the `eas observe:session` timeline. ([#4772](https://github.com/expo/eas-cli/pull/4772) by [@kadikraman](https://github.com/kadikraman))
|
||||
- Show a one-time Expo Simulators hint after a successful build. ([#4768](https://github.com/expo/eas-cli/pull/4768) by [@FiberJW](https://github.com/FiberJW))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Remove the Free plan build limit guidance experiment for coding agents from `eas build`. ([#4757](https://github.com/expo/eas-cli/pull/4757) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
|
||||
## 27.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
+221
-146
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "27.1.0",
|
||||
"version": "27.3.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -44,16 +44,16 @@
|
||||
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/apple-utils": "2.2.1",
|
||||
"@expo/apple-utils": "2.3.0",
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "27.1.0",
|
||||
"@expo/eas-json": "27.1.0",
|
||||
"@expo/eas-build-job": "27.3.0",
|
||||
"@expo/eas-json": "27.3.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/experimentation": "0.0.5",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "27.1.0",
|
||||
"@expo/logger": "27.3.0",
|
||||
"@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.1.0",
|
||||
"@expo/steps": "27.3.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
|
||||
@@ -71,6 +71,7 @@ export enum BuildEvent {
|
||||
BUILD_REQUEST_FAIL = 'build cli build request fail',
|
||||
FREE_TIER_LIMIT_CHECKOUT_PROMPT = 'build cli free tier limit checkout prompt',
|
||||
FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP = 'build cli free tier near limit agent updates tip',
|
||||
SIMULATORS_LAUNCH_HINT_SHOWN = 'build cli simulators launch hint shown',
|
||||
|
||||
BUILD_STATUS_COMMAND = 'build cli build status',
|
||||
|
||||
|
||||
@@ -143,6 +143,7 @@ export async function runBuildAndSubmitAsync({
|
||||
}): Promise<{
|
||||
buildIds: string[];
|
||||
buildProfiles?: ProfileData<'build'>[];
|
||||
builds?: MaybeBuildFragment[];
|
||||
}> {
|
||||
await vcsClient.ensureRepoExistsAsync();
|
||||
await ensureRepoIsCleanAsync(vcsClient, flags.nonInteractive);
|
||||
@@ -380,6 +381,7 @@ export async function runBuildAndSubmitAsync({
|
||||
return {
|
||||
buildIds: startedBuilds.map(({ build }) => build.id),
|
||||
buildProfiles,
|
||||
builds,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { boolish } from 'getenv';
|
||||
|
||||
import { Analytics, BuildEvent } from '../analytics/AnalyticsManager';
|
||||
import { createGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { UserPreferenceMutation } from '../graphql/mutations/UserPreferenceMutation';
|
||||
import { UserPreferenceQuery } from '../graphql/queries/UserPreferenceQuery';
|
||||
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_HINT_PREFERENCE_KEY = 'simulators-launch-cli-hint-seen';
|
||||
const REQUEST_TIMEOUT_MS = 3000;
|
||||
const SIMULATORS_QUICKSTART_URL =
|
||||
'https://docs.expo.dev/preview/simulators/get-started/?utm_source=eas-cli&utm_medium=cli&utm_campaign=simulators-launch';
|
||||
|
||||
export async function maybeShowSimulatorsLaunchHintAsync(
|
||||
authenticationInfo: LoggedInAuthenticationInfo,
|
||||
analytics: Analytics,
|
||||
{ actor, nonInteractive }: { actor: Actor; nonInteractive: boolean }
|
||||
): Promise<void> {
|
||||
if (
|
||||
nonInteractive ||
|
||||
boolish('CI', false) ||
|
||||
Date.now() < SIMULATORS_LAUNCH_AT.getTime() ||
|
||||
(actor.__typename !== 'User' && actor.__typename !== 'SSOUser')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const graphqlClient = createGraphqlClient(authenticationInfo, {
|
||||
requestTimeoutMs: REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
const value = await UserPreferenceQuery.getValueAsync(
|
||||
graphqlClient,
|
||||
SIMULATORS_LAUNCH_HINT_PREFERENCE_KEY
|
||||
);
|
||||
if (value && typeof value === 'object' && 'isDismissed' in value && value.isDismissed) {
|
||||
return;
|
||||
}
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
`✨ New: run your build in Expo Simulators, cloud Android and iOS simulators for you and your agents. ${link(SIMULATORS_QUICKSTART_URL)}`
|
||||
);
|
||||
analytics.logEvent(BuildEvent.SIMULATORS_LAUNCH_HINT_SHOWN, {});
|
||||
await UserPreferenceMutation.setAsync(graphqlClient, SIMULATORS_LAUNCH_HINT_PREFERENCE_KEY, {
|
||||
isDismissed: true,
|
||||
});
|
||||
} catch (error) {
|
||||
Log.debug('Failed to show the Expo Simulators hint', error);
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,13 @@ import path from 'path';
|
||||
|
||||
import { LocalBuildMode } from '../../build/local';
|
||||
import { BuildFlags, runBuildAndSubmitAsync } from '../../build/runBuildAndSubmit';
|
||||
import { maybeShowSimulatorsLaunchHintAsync } from '../../build/simulatorsLaunchHint';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { StatuspageServiceName } from '../../graphql/generated';
|
||||
import { BuildStatus, StatuspageServiceName } from '../../graphql/generated';
|
||||
import Log, { link } from '../../log';
|
||||
import { RequestedPlatform, selectRequestedPlatformAsync } from '../../platform';
|
||||
import { selectAsync } from '../../prompts';
|
||||
@@ -176,7 +177,7 @@ export default class Build extends EasCommand {
|
||||
|
||||
const flagsWithPlatform = await this.ensurePlatformSelectedAsync(flags);
|
||||
|
||||
const { buildProfiles } = await runBuildAndSubmitAsync({
|
||||
const { buildProfiles, builds } = await runBuildAndSubmitAsync({
|
||||
graphqlClient,
|
||||
analytics,
|
||||
vcsClient,
|
||||
@@ -192,6 +193,13 @@ export default class Build extends EasCommand {
|
||||
buildProfiles,
|
||||
nonInteractive: flags.nonInteractive,
|
||||
});
|
||||
|
||||
if (builds?.some(build => build?.status === BuildStatus.Finished)) {
|
||||
await maybeShowSimulatorsLaunchHintAsync(authenticationInfo, analytics, {
|
||||
actor,
|
||||
nonInteractive: flags.nonInteractive,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeFlags(
|
||||
|
||||
@@ -187,6 +187,69 @@ describe(syncCapabilityIdentifiersForEntitlementsAsync, () => {
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it(`keeps already linked capability identifiers when linking another one`, async () => {
|
||||
const Apple = require('@expo/apple-utils');
|
||||
mockCapabilities(Apple);
|
||||
const bundleId = {
|
||||
context: {},
|
||||
updateBundleIdCapabilityAsync: jest.fn(),
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
|
||||
const result = await syncCapabilityIdentifiersForEntitlementsAsync(bundleId, {
|
||||
'com.apple.security.application-groups': ['group.expo', 'group.bacon'],
|
||||
});
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
created: ['group.bacon'],
|
||||
linked: ['group.bacon'],
|
||||
});
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledTimes(1);
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenLastCalledWith([
|
||||
{
|
||||
capabilityType: 'APP_GROUPS',
|
||||
option: 'ON',
|
||||
relationships: {
|
||||
appGroups: ['XXX-group-1', 'XXX-group-2'],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it(`keeps already linked capability identifiers when linking an existing unlinked one`, async () => {
|
||||
const Apple = require('@expo/apple-utils');
|
||||
mockCapabilities(Apple);
|
||||
Apple.AppGroup.getAsync.mockResolvedValue([
|
||||
mockItems.appGroup.expo,
|
||||
{ id: 'XXX-group-2', attributes: { identifier: 'group.bacon' } },
|
||||
]);
|
||||
const bundleId = {
|
||||
context: {},
|
||||
updateBundleIdCapabilityAsync: jest.fn(),
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
|
||||
const result = await syncCapabilityIdentifiersForEntitlementsAsync(bundleId, {
|
||||
'com.apple.security.application-groups': ['group.expo', 'group.bacon'],
|
||||
});
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
created: [],
|
||||
linked: ['group.bacon'],
|
||||
});
|
||||
expect(Apple.AppGroup.createAsync).not.toHaveBeenCalled();
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledTimes(1);
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenLastCalledWith([
|
||||
{
|
||||
capabilityType: 'APP_GROUPS',
|
||||
option: 'ON',
|
||||
relationships: {
|
||||
appGroups: ['XXX-group-1', 'XXX-group-2'],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it(`doesn't perform duplicate CapabilityModel.getAsync calls`, async () => {
|
||||
const Apple = require('@expo/apple-utils');
|
||||
mockCapabilities(Apple);
|
||||
|
||||
@@ -94,7 +94,8 @@ export async function syncCapabilityIdentifiersForEntitlementsAsync(
|
||||
(remoteLinkedIds?.[CapabilityModel.type] ?? []).map(model => model.id)
|
||||
);
|
||||
|
||||
// A list of server IDs for linking.
|
||||
// A list of server IDs for linking. The update replaces the linked identifiers,
|
||||
// so this includes the ones that are already linked.
|
||||
const capabilityIdOpaqueIds: string[] = [];
|
||||
|
||||
// Iterate through all the local IDs and see if they exist on the server.
|
||||
@@ -106,8 +107,8 @@ export async function syncCapabilityIdentifiersForEntitlementsAsync(
|
||||
if (!alreadyLinkedOpaqueIds.has(remoteIdModel.id)) {
|
||||
// Link the existing identifier to this bundle.
|
||||
linkedIds.push(remoteIdModel.attributes.identifier);
|
||||
capabilityIdOpaqueIds.push(remoteIdModel.id);
|
||||
}
|
||||
capabilityIdOpaqueIds.push(remoteIdModel.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -133,7 +134,9 @@ export async function syncCapabilityIdentifiersForEntitlementsAsync(
|
||||
capabilityIdOpaqueIds.push(remoteIdModel.id);
|
||||
}
|
||||
|
||||
if (capabilityIdOpaqueIds.length) {
|
||||
// The update replaces the linked identifiers, so it is sent only when one of them is not
|
||||
// linked yet. An identifier removed from the entitlements therefore stays linked.
|
||||
if (capabilityIdOpaqueIds.some(id => !alreadyLinkedOpaqueIds.has(id))) {
|
||||
updateRequest.push({
|
||||
capabilityType: classifier.capability,
|
||||
option: CapabilityTypeOption.ON,
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @expo/eas-json
|
||||
|
||||
## 27.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"version": "27.1.0",
|
||||
"version": "27.3.0",
|
||||
"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.1.0",
|
||||
"@expo/eas-build-job": "27.3.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-string": "1.0.1",
|
||||
"fs-extra": "11.2.0",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# eas-cli-local-build-plugin
|
||||
|
||||
## 27.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "27.1.0",
|
||||
"version": "27.3.0",
|
||||
"description": "Tool for running EAS compatible builds on a local machine.",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
@@ -26,12 +26,12 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/build-tools": "27.1.0",
|
||||
"@expo/build-tools": "27.3.0",
|
||||
"@expo/bunyan": "^4.0.1",
|
||||
"@expo/eas-build-job": "27.1.0",
|
||||
"@expo/logger": "27.1.0",
|
||||
"@expo/eas-build-job": "27.3.0",
|
||||
"@expo/logger": "27.3.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"@expo/turtle-spawn": "27.1.0",
|
||||
"@expo/turtle-spawn": "27.3.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-paths": "^2.2.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @expo/logger
|
||||
|
||||
## 27.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/logger",
|
||||
"version": "27.1.0",
|
||||
"version": "27.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# @expo/simulator-launcher
|
||||
|
||||
## 27.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Accept a comma-separated list of warm Simulators in `EXPO_SIMULATOR_DEVICE`; the first is the default. One serve-sim serves every listed device on one port. After the default device is ready, warmup preinstalls the latest stable Expo Go from one download and tries to disable apsd on the other devices, until a job claims one. A claim can select any listed device and reports its preinstalled Expo Go. ([#4764](https://github.com/expo/eas-cli/pull/4764) by [@gwdp](https://github.com/gwdp))
|
||||
- Add `@expo/simulator-launcher`, which warms an iOS Simulator at VM boot and hands it to one EAS job (`start`, `claim`, `prepare-tools`, `stop`). ([#4722](https://github.com/expo/eas-cli/pull/4722) by [@gwdp](https://github.com/gwdp))
|
||||
@@ -0,0 +1,80 @@
|
||||
# @expo/simulator-launcher
|
||||
|
||||
Warm iOS Simulators before an EAS job arrives. Requires macOS.
|
||||
The image installs vm-tools and writes its startup script at
|
||||
`/usr/local/share/vm-tools/startup.sh`:
|
||||
|
||||
```sh
|
||||
export EXPO_SIMULATOR_STARTUP=1
|
||||
export EXPO_SIMULATOR_DEVICE="iPhone Duo,iPhone 18"
|
||||
exec bunx @expo/simulator-launcher@latest start
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
- `start`: run one orphan serve-sim for the configured devices. Its default device becomes ready
|
||||
first; additional devices warm in the background. Argent and agent-device prestart on the default.
|
||||
- `claim ENGINE [DEVICE]`: reserve one job and return its selected device plus the existing server's
|
||||
identity, port and credentials. Engines: `serve-sim`, `agent-device`, `argent`.
|
||||
No DEVICE means the first configured device. Claim does not boot the selected device or wait for
|
||||
its tool. Build-tools privately adopts that device on the existing serve-sim port.
|
||||
- `prepare-tools`: continue the consumed claim after adoption. Keep global Argent, reuse the default
|
||||
agent-device runner or prepare a runner for the selected device.
|
||||
- `stop`: permanently end warmup for this boot and wait for all owned resources to be cleaned up.
|
||||
|
||||
```sh
|
||||
# Build-tools reads LAUNCHER_VERSION from the version recorded by start.
|
||||
bun x --no-install "@expo/simulator-launcher@$LAUNCHER_VERSION" claim serve-sim
|
||||
# After private serve-sim adoption confirms the selected device and native ACK:
|
||||
bun x --no-install "@expo/simulator-launcher@$LAUNCHER_VERSION" prepare-tools
|
||||
bun x --no-install "@expo/simulator-launcher@$LAUNCHER_VERSION" stop
|
||||
```
|
||||
|
||||
There is no copied launcher bundle. Build-tools uses the recorded package version for claim/stop;
|
||||
Warmup primes Bun’s exact-version cache before ready, with two attempts of up to 30 seconds. Its claim/stop calls use `--no-install`, retrying
|
||||
once with the registry only when Bun cannot find that cached binary. Priming failures warn.
|
||||
Before start has run,
|
||||
stop uses the normal package-manager path and still disables later warmup for this boot.
|
||||
|
||||
This flow requires the serve-sim release supporting port-based device adoption.
|
||||
|
||||
One durable state file, `/tmp/expo-simulator-startup.json`, holds the lifecycle, launcher version,
|
||||
selected device, server credentials, claim and native identity. It is independent of HOME/TMPDIR. A short boot-scoped lock
|
||||
serializes updates; an abandoned lock fails closed. This private format requires a fresh VM.
|
||||
|
||||
## Environment
|
||||
|
||||
- `EXPO_SIMULATOR_STARTUP=1` enables warmup.
|
||||
- `EXPO_SIMULATOR_PREINSTALL_EXPO_GO=0` turns off the Expo Go preinstall.
|
||||
- `EXPO_SIMULATOR_DEVICE` is a comma-separated list of exact device names or UDIDs. Entries are
|
||||
trimmed and must each identify one available device. The first is the default. Unset keeps
|
||||
the shortest available iPhone name, as does an empty or blank value. Empty list entries, missing,
|
||||
ambiguous or duplicate devices fail clearly.
|
||||
- `SERVE_SIM_VERSION`, `AGENT_DEVICE_VERSION`, `ARGENT_VERSION`: each defaults to `latest`.
|
||||
- `AGENT_DEVICE_STATE_DIR`, `ARGENT_EVENT_LOG`: tool state/output locations.
|
||||
- Agent-device idle timers default to off. Override with `AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS`
|
||||
and `AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS` (milliseconds).
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Ready means the default device booted and serve-sim answers. The apsd step and the Expo Go
|
||||
preinstall run in the background on every listed device. A claim does not wait for them; stop
|
||||
joins them.
|
||||
The second device may not have started yet. Its startup failure leaves the default available.
|
||||
Claim reserves a device; it does not report that device ready. Private adoption must confirm
|
||||
the same server PID/port, selected-device readiness and native video/capture before tool setup
|
||||
and app actions. Job-time version and authentication checks remain authoritative.
|
||||
- Agent-device prestarts on the default device only. A secondary claim prepares its runner after
|
||||
adoption and old-runner cleanup finish. One device-independent Argent server is reused for the claimed device.
|
||||
- Warmup downloads/extracts the latest stable Expo Go once and reuses it for the second device.
|
||||
Each initial device gets a preinstall attempt; ordinary failures warn. Claim prevents new
|
||||
preinstalls. `prepare-tools` waits for an install still running on the claimed device and
|
||||
reports its version, because the job installs its app next. Installs on other devices continue;
|
||||
adoption may shut such a device down, and its install then fails with a warning. Terminal stop
|
||||
drains them and the download cleanup. Job app installs remain
|
||||
authoritative. Replacements do not preinstall again. The VM image provides ffmpeg.
|
||||
- One start attempt and one accepted claim per boot. Failed cleanup never permits another session.
|
||||
Stop's caller timeout is not a global cleanup deadline or proof that teardown completed.
|
||||
A build-tools claim timeout uses normal startup only after confirmed terminal cleanup.
|
||||
|
||||
Claim and tool JSON include credentials; capture them rather than streaming them to job logs.
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
require('../dist/index').runCliAsync();
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Config } from '@jest/types';
|
||||
|
||||
import baseConfig from '../../jest/jest.shared.config';
|
||||
|
||||
const config: Config.InitialOptions = {
|
||||
...baseConfig,
|
||||
rootDir: 'src',
|
||||
testMatch: ['**/__tests__/*.test.ts'],
|
||||
setupFiles: ['<rootDir>/__tests__/fixturePids.ts'],
|
||||
clearMocks: true,
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@expo/simulator-launcher",
|
||||
"version": "27.3.0",
|
||||
"description": "Launch and claim Simulator sessions on EAS workers",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/expo/eas-cli.git",
|
||||
"directory": "packages/simulator-launcher"
|
||||
},
|
||||
"bin": "bin/simulator-launcher",
|
||||
"files": [
|
||||
"bin",
|
||||
"dist/index.js"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "jest --config jest.config.ts",
|
||||
"build": "ncc build src/main.ts -o dist --no-cache --no-source-map-register",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepack": "yarn build",
|
||||
"rebuild": "yarn build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "22.20.5",
|
||||
"@vercel/ncc": "^0.38.1",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.4.12",
|
||||
"typescript": "^6.0.3",
|
||||
"zod": "^4.3.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { type ChildProcess } from 'node:child_process';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
import { createArgentTool } from '../utils/argent';
|
||||
import {
|
||||
commands,
|
||||
isProcessOwnedByServiceAsync,
|
||||
stopProcessAndDescendantsAsync,
|
||||
} from '../utils/processes';
|
||||
import { type WarmTool } from '../utils/tools';
|
||||
import { deferred, until } from './helpers';
|
||||
|
||||
jest.mock('node:fs/promises');
|
||||
jest.mock('../utils/processes');
|
||||
let child: ChildProcess;
|
||||
let result: ReturnType<typeof deferred<string>>;
|
||||
let controller: AbortController;
|
||||
let onFailure: jest.Mock;
|
||||
let tool: WarmTool;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
child = Object.assign(new EventEmitter(), {
|
||||
pid: 90001,
|
||||
exitCode: null,
|
||||
signalCode: null,
|
||||
}) as ChildProcess;
|
||||
result = deferred<string>();
|
||||
void result.promise.catch(() => {});
|
||||
controller = new AbortController();
|
||||
onFailure = jest.fn();
|
||||
// The old factory ignores this callback; its start also resolves without readiness.
|
||||
const createTool: (onFailure: (error: unknown) => void) => WarmTool = createArgentTool;
|
||||
tool = createTool(onFailure);
|
||||
jest.mocked(commands.start).mockReturnValue({ child, resultPromise: result.promise });
|
||||
jest.mocked(isProcessOwnedByServiceAsync).mockResolvedValue(true);
|
||||
jest.mocked(fs.readdir).mockResolvedValue(['tool-server.json'] as never);
|
||||
jest
|
||||
.mocked(fs.readFile)
|
||||
.mockResolvedValue(JSON.stringify({ pid: 90001, port: 3201, token: 'fixture-token' }));
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue(new Response(JSON.stringify({ tools: [] })));
|
||||
jest.mocked(stopProcessAndDescendantsAsync).mockResolvedValue();
|
||||
});
|
||||
afterEach(() => {
|
||||
controller.abort();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does not report ready until its owned server answers the authenticated tools request', async () => {
|
||||
const response = deferred<Response>();
|
||||
jest.mocked(fetch).mockReturnValue(response.promise);
|
||||
let ready = false;
|
||||
const started = tool.startAsync('PHONE', controller.signal).then(() => {
|
||||
ready = true;
|
||||
});
|
||||
await until(() => jest.mocked(fetch).mock.calls.length > 0 || ready);
|
||||
expect(ready).toBe(false);
|
||||
response.resolve(new Response(JSON.stringify({ tools: [] })));
|
||||
await started;
|
||||
expect(commands.start).toHaveBeenCalledWith('bunx', expect.arrayContaining(['server', 'start']), {
|
||||
detached: true,
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledWith('http://127.0.0.1:3201/tools', {
|
||||
headers: { Authorization: 'Bearer fixture-token' },
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an exit during the readiness request, even before the wrapper finishes closing', async () => {
|
||||
const response = deferred<Response>();
|
||||
jest.mocked(fetch).mockReturnValue(response.promise);
|
||||
const started = tool.startAsync('PHONE', controller.signal);
|
||||
await until(() => jest.mocked(fetch).mock.calls.length > 0);
|
||||
Object.assign(child, { exitCode: 1 });
|
||||
child.emit('exit', 1, null);
|
||||
response.resolve(new Response(JSON.stringify({ tools: [] })));
|
||||
await expect(started).rejects.toThrow('Argent exited before its server became ready');
|
||||
expect(onFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('observes an unexpected exit after readiness exactly once', async () => {
|
||||
await tool.startAsync('PHONE', controller.signal);
|
||||
child.emit('exit', 1, null);
|
||||
result.reject(new Error('wrapper failed'));
|
||||
await Promise.resolve();
|
||||
expect(onFailure).toHaveBeenCalledTimes(1);
|
||||
expect(onFailure.mock.calls[0][0].message).toContain('Argent server exited unexpectedly');
|
||||
});
|
||||
|
||||
it('ignores a foreign state file without sending its token to the local port', async () => {
|
||||
jest.mocked(isProcessOwnedByServiceAsync).mockResolvedValue(false);
|
||||
const started = tool.startAsync('PHONE', controller.signal).catch(error => error);
|
||||
await until(() => jest.mocked(isProcessOwnedByServiceAsync).mock.calls.length > 0);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
controller.abort(new Error('cancelled'));
|
||||
expect((await started).code).toBe('ABORT_ERR');
|
||||
});
|
||||
|
||||
it('retries an invalid tools response and does not notify for intentional stop', async () => {
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(new Response('{}'))
|
||||
.mockImplementation(async () => new Response(JSON.stringify({ tools: [] })));
|
||||
await tool.startAsync('PHONE', controller.signal);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
jest.mocked(stopProcessAndDescendantsAsync).mockImplementation(async () => {
|
||||
child.emit('exit', 0, null);
|
||||
});
|
||||
await tool.stopAsync();
|
||||
expect(onFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not notify for cancelled tool teardown', async () => {
|
||||
await tool.startAsync('PHONE', controller.signal);
|
||||
controller.abort();
|
||||
child.emit('exit', 0, null);
|
||||
expect(onFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never publishes ready when cancellation arrives during the tools request', async () => {
|
||||
const response = deferred<Response>();
|
||||
jest.mocked(fetch).mockReturnValue(response.promise);
|
||||
const started = tool.startAsync('PHONE', controller.signal);
|
||||
await until(() => jest.mocked(fetch).mock.calls.length > 0);
|
||||
controller.abort(new Error('cancelled'));
|
||||
response.resolve(new Response(JSON.stringify({ tools: [] })));
|
||||
await expect(started).rejects.toThrow('cancelled');
|
||||
expect(onFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('bounds readiness when the server never becomes available', async () => {
|
||||
const now = Date.now();
|
||||
jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockReturnValueOnce(now)
|
||||
.mockReturnValue(now + 120_000);
|
||||
await expect(tool.startAsync('PHONE', controller.signal)).rejects.toThrow(
|
||||
'Timed out waiting for the Argent server'
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries missing or incomplete state and transient HTTP failures before becoming ready', async () => {
|
||||
jest
|
||||
.mocked(fs.readdir)
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('state directory not written yet'), { code: 'ENOENT' })
|
||||
)
|
||||
.mockResolvedValue(['events.jsonl', 'tool-server-old.json', 'tool-server.json'] as never);
|
||||
jest
|
||||
.mocked(fs.readFile)
|
||||
.mockRejectedValueOnce(new Error('state disappeared during startup'))
|
||||
.mockResolvedValue(JSON.stringify({ pid: 90001, port: 3201, token: 'fixture-token' }));
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValueOnce(new Error('connection refused'))
|
||||
.mockResolvedValueOnce(new Response('unauthorized', { status: 401 }))
|
||||
.mockImplementation(async () => new Response(JSON.stringify({ tools: [] })));
|
||||
await tool.startAsync('PHONE', controller.signal);
|
||||
expect(fs.readdir).toHaveBeenCalledTimes(3);
|
||||
expect(fetch).toHaveBeenCalledTimes(3);
|
||||
expect(onFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a successful wrapper exit while no state directory is available', async () => {
|
||||
jest.mocked(fs.readdir).mockImplementation(async () => {
|
||||
result.resolve('');
|
||||
throw new Error('state directory unavailable');
|
||||
});
|
||||
await expect(tool.startAsync('PHONE', controller.signal)).rejects.toThrow(
|
||||
'Argent exited before its server became ready'
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(onFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an exit during the final ownership check before publishing readiness', async () => {
|
||||
const owned = deferred<boolean>();
|
||||
jest
|
||||
.mocked(isProcessOwnedByServiceAsync)
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockReturnValue(owned.promise);
|
||||
const started = tool.startAsync('PHONE', controller.signal);
|
||||
await until(() => jest.mocked(isProcessOwnedByServiceAsync).mock.calls.length === 2);
|
||||
child.emit('error', new Error('server failed during validation'));
|
||||
owned.resolve(true);
|
||||
await expect(started).rejects.toThrow('Argent exited before its server became ready');
|
||||
expect(onFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { commands } from '../utils/processes';
|
||||
import { startOwnedWrapperAsync } from './fixtures/ownedWrapper';
|
||||
|
||||
it('an early claim survives unused-tool wrapper exit while the selected server and Simulator stay owned', async () => {
|
||||
const fixture = await startOwnedWrapperAsync();
|
||||
const environment = { ...process.env };
|
||||
let session:
|
||||
| ReturnType<typeof import('../commands/start').createWarmSimulatorSession>
|
||||
| undefined;
|
||||
try {
|
||||
const { createWarmSimulatorSession } = await import('../commands/start');
|
||||
const simulator = await import('../utils/simulator');
|
||||
const serveSim = await import('../utils/serveSim');
|
||||
const processes = await import('../utils/processes');
|
||||
const agentDevice = await import('../utils/agentDevice');
|
||||
const argent = await import('../utils/argent');
|
||||
const expoGo = await import('../utils/expoGo');
|
||||
const controller = new AbortController();
|
||||
const running = commands.runAsync.bind(commands);
|
||||
process.env.EXPO_SIMULATOR_DEVICE = 'DUO';
|
||||
process.env.AGENT_DEVICE_STATE_DIR = '/owned-wrapper-fixture/agent';
|
||||
process.env.ARGENT_EVENT_LOG = '/owned-wrapper-fixture/argent-events';
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation((file, args, signal) =>
|
||||
file === 'bun' || (file === 'ps' && args[1] === String(process.pid))
|
||||
? Promise.resolve('controller-start')
|
||||
: running(file, args, signal)
|
||||
);
|
||||
jest
|
||||
.spyOn(simulator, 'listAvailableSimulatorsAsync')
|
||||
.mockResolvedValue([{ udid: 'DUO', name: 'iPhone Duo' }]);
|
||||
jest.spyOn(simulator, 'waitForSimulatorBootAsync').mockResolvedValue();
|
||||
jest.spyOn(simulator, 'disableApsdAsync').mockResolvedValue();
|
||||
const shutdown = jest.spyOn(simulator, 'shutdownSimulatorAsync').mockResolvedValue();
|
||||
const stopServer = jest.spyOn(processes, 'stopProcessAndDescendantsAsync').mockResolvedValue();
|
||||
jest.spyOn(agentDevice, 'createAgentDeviceTool').mockReturnValue({
|
||||
name: 'agent-device',
|
||||
startAsync: async () => {},
|
||||
stopAsync: fixture.stopAsync,
|
||||
});
|
||||
jest
|
||||
.spyOn(argent, 'createArgentTool')
|
||||
.mockReturnValue({ name: 'argent', startAsync: async () => {}, stopAsync: async () => {} });
|
||||
jest.spyOn(expoGo, 'startExpoGoPreinstall').mockReturnValue({
|
||||
preinstallOnDeviceAsync: async () => {},
|
||||
claimAsync: async () => undefined,
|
||||
stopAsync: async () => {},
|
||||
});
|
||||
jest.spyOn(serveSim, 'startServeSim').mockReturnValue({
|
||||
child: { pid: 90001 } as import('node:child_process').ChildProcess,
|
||||
resultPromise: new Promise(() => {}),
|
||||
});
|
||||
jest.spyOn(serveSim, 'waitForServeSimReadyAsync').mockResolvedValue({
|
||||
startupDevice: 'DUO',
|
||||
pid: 90001,
|
||||
nativeStartTime: 'fixture-start',
|
||||
port: 3200,
|
||||
url: 'http://127.0.0.1:3200',
|
||||
token: 'fixture-token',
|
||||
});
|
||||
const onFailure = jest.fn(error => controller.abort(error));
|
||||
session = createWarmSimulatorSession({
|
||||
signal: controller.signal,
|
||||
throwIfStoppingAsync: async () => controller.signal.throwIfAborted(),
|
||||
onPreparationFailure: onFailure,
|
||||
});
|
||||
await session.prepareAsync();
|
||||
expect(await session.claimAsync({ engine: 'serve-sim' })).toMatchObject({
|
||||
device: { udid: 'DUO' },
|
||||
});
|
||||
await session.prepareToolsAsync();
|
||||
await fixture.stopAsync();
|
||||
expect(fixture.job.child.exitCode).toBe(23);
|
||||
expect(onFailure).not.toHaveBeenCalled();
|
||||
expect(controller.signal.aborted).toBe(false);
|
||||
expect(stopServer).not.toHaveBeenCalled();
|
||||
expect(shutdown).not.toHaveBeenCalled();
|
||||
await session.stopAsync();
|
||||
expect(shutdown).toHaveBeenCalledWith('DUO');
|
||||
} finally {
|
||||
await session?.stopAsync().catch(() => {});
|
||||
jest.restoreAllMocks();
|
||||
process.env = environment;
|
||||
await fixture.cleanupAsync();
|
||||
}
|
||||
}, 10_000);
|
||||
@@ -0,0 +1,636 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { startExpoGoPreinstall } from '../utils/expoGo';
|
||||
import { commands } from '../utils/processes';
|
||||
import { deferred, until } from './helpers';
|
||||
|
||||
const runCommandAsync = commands.runAsync.bind(commands);
|
||||
function createArchive(entries: { name: string; contents: Buffer }[]): Buffer {
|
||||
const blocks: Buffer[] = [];
|
||||
for (const { name, contents } of entries) {
|
||||
const header = Buffer.alloc(512);
|
||||
header.write(name);
|
||||
header.write('0000644\0', 100);
|
||||
header.write(contents.length.toString(8).padStart(11, '0') + '\0', 124);
|
||||
header.fill(32, 148, 156);
|
||||
header.write('0', 156);
|
||||
const checksum = header.reduce((sum, byte) => sum + byte, 0);
|
||||
header.write(checksum.toString(8).padStart(6, '0') + '\0 ', 148);
|
||||
blocks.push(header, contents, Buffer.alloc((512 - (contents.length % 512)) % 512));
|
||||
}
|
||||
return gzipSync(Buffer.concat([...blocks, Buffer.alloc(1024)]));
|
||||
}
|
||||
function installCalls() {
|
||||
return jest.mocked(commands.runAsync).mock.calls.filter(([file]) => file === 'xcrun');
|
||||
}
|
||||
|
||||
let directory: string;
|
||||
let archive: Buffer;
|
||||
let controller: AbortController;
|
||||
let warmup: ReturnType<typeof startOn> | undefined;
|
||||
const stableUrl = 'https://example.test/Expo-Go-stable.tar.gz';
|
||||
const sdkVersions = {
|
||||
'57.0.0': {
|
||||
releaseNoteUrl: 'https://example.test/57',
|
||||
iosClientUrl: 'https://example.test/old.tar.gz',
|
||||
},
|
||||
'58.0.0': { releaseNoteUrl: 'https://example.test/58', iosClientUrl: stableUrl },
|
||||
'59.0.0': {
|
||||
isBeta: true,
|
||||
releaseNoteUrl: 'https://example.test/beta',
|
||||
iosClientUrl: 'https://example.test/beta.tar.gz',
|
||||
},
|
||||
'60.0.0': { iosClientUrl: 'https://example.test/canary.tar.gz' },
|
||||
'61.1.0': {
|
||||
releaseNoteUrl: 'https://example.test/not-published',
|
||||
iosClientUrl: 'https://example.test/not-published.tar.gz',
|
||||
},
|
||||
};
|
||||
|
||||
/** Starts the shared download and preinstalls on one device, as warmup does for each device. */
|
||||
function startOn(udid: string, boot: Promise<unknown>) {
|
||||
const preinstall = startExpoGoPreinstall(controller.signal);
|
||||
return {
|
||||
...preinstall,
|
||||
resultPromise: preinstall.preinstallOnDeviceAsync(udid, boot, controller.signal),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'expo-go-unit-'));
|
||||
archive = createArchive([
|
||||
{ name: 'Info.plist', contents: Buffer.from('<plist>fixture</plist>') },
|
||||
]);
|
||||
jest.spyOn(os, 'tmpdir').mockReturnValue(directory);
|
||||
jest
|
||||
.spyOn(global, 'fetch')
|
||||
.mockImplementation(async url =>
|
||||
url === 'https://api.expo.dev/v2/versions'
|
||||
? Response.json({ iosUrl: 'https://example.test/legacy.tar.gz', sdkVersions })
|
||||
: new Response(Uint8Array.from(archive))
|
||||
);
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async (file, args, signal) =>
|
||||
file === '/usr/bin/tar' ? await runCommandAsync(file, args, signal) : ''
|
||||
);
|
||||
jest.spyOn(commands, 'throwIfCommandCloseTimedOut').mockImplementation(() => {});
|
||||
jest.spyOn(process.stderr, 'write').mockReturnValue(true);
|
||||
controller = new AbortController();
|
||||
warmup = undefined;
|
||||
});
|
||||
afterEach(async () => {
|
||||
controller.abort();
|
||||
await warmup?.stopAsync('UDID').catch(() => {});
|
||||
jest.restoreAllMocks();
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('uses the website stable release policy, extracts flat contents as .app and installs only after boot', async () => {
|
||||
const boot = deferred();
|
||||
warmup = startOn('UDID', boot.promise);
|
||||
try {
|
||||
await until(() => jest.mocked(fetch).mock.calls.length === 2);
|
||||
expect(fetch).toHaveBeenLastCalledWith(
|
||||
stableUrl,
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'expo-simulator-launcher',
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(installCalls()).toHaveLength(0);
|
||||
boot.resolve();
|
||||
await warmup.resultPromise;
|
||||
expect(installCalls()).toHaveLength(1);
|
||||
const [file, args, signal] = installCalls()[0];
|
||||
expect(file).toBe('xcrun');
|
||||
expect(args.slice(0, 3)).toEqual(['simctl', 'install', 'UDID']);
|
||||
expect(path.basename(args[3])).toBe('ExpoGo.app');
|
||||
expect(signal).toBeUndefined(); // Never cancel an in-flight installd transaction through simctl.
|
||||
// The shared download stays until a claim or stop releases it; stop waits for the removal.
|
||||
expect((await fs.stat(path.dirname(args[3]))).isDirectory()).toBe(true);
|
||||
await warmup.stopAsync('UDID');
|
||||
await expect(fs.stat(path.dirname(args[3]))).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
} finally {
|
||||
boot.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
it('claim aborts a pending download without waiting for it, and a late fetch cannot install', async () => {
|
||||
const pending = deferred<Response>();
|
||||
jest.mocked(fetch).mockReturnValueOnce(pending.promise);
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
try {
|
||||
await until(() => jest.mocked(fetch).mock.calls.length > 0);
|
||||
await warmup.claimAsync('UDID');
|
||||
expect(jest.mocked(fetch).mock.calls[0][1]?.signal?.aborted).toBe(true);
|
||||
pending.resolve(Response.json({ sdkVersions })); // Even an uncooperative late fetch cannot install.
|
||||
await warmup.stopAsync('UDID');
|
||||
expect(installCalls()).toHaveLength(0);
|
||||
expect(
|
||||
(await fs.readdir(directory)).filter(name => name.startsWith('simulator-expo-go-'))
|
||||
).toEqual([]);
|
||||
} finally {
|
||||
pending.resolve(Response.json({ sdkVersions }));
|
||||
}
|
||||
});
|
||||
|
||||
it('claim only waits for the native install; terminal stop also drains temporary-file cleanup', async () => {
|
||||
const installation = deferred<string>();
|
||||
const cleanup = deferred();
|
||||
const remove = fs.rm.bind(fs);
|
||||
let cleanupStarted = false;
|
||||
jest.spyOn(fs, 'rm').mockImplementation(async (file, options) => {
|
||||
cleanupStarted = true;
|
||||
await cleanup.promise;
|
||||
return await remove(file, options);
|
||||
});
|
||||
jest
|
||||
.mocked(commands.runAsync)
|
||||
.mockImplementation(async (file, args) =>
|
||||
file === '/usr/bin/tar' ? await runCommandAsync(file, args) : await installation.promise
|
||||
);
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
try {
|
||||
await until(() => installCalls().length > 0);
|
||||
let claimed = false;
|
||||
const claiming = warmup.claimAsync('UDID').then(() => {
|
||||
claimed = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(claimed).toBe(false);
|
||||
installation.resolve('');
|
||||
await claiming;
|
||||
expect(claimed).toBe(true);
|
||||
await until(() => cleanupStarted);
|
||||
let stopped = false;
|
||||
const stopping = warmup.stopAsync('UDID').then(() => {
|
||||
stopped = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(stopped).toBe(false);
|
||||
cleanup.resolve();
|
||||
await stopping;
|
||||
} finally {
|
||||
installation.resolve('');
|
||||
cleanup.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
it('claim during extraction returns without waiting but cannot begin an install afterward', async () => {
|
||||
const extraction = deferred();
|
||||
let extracting = false;
|
||||
jest.mocked(commands.runAsync).mockImplementation(async (file, args) => {
|
||||
expect(file).toBe('/usr/bin/tar');
|
||||
extracting = true;
|
||||
await extraction.promise;
|
||||
return await runCommandAsync(file, args);
|
||||
});
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
try {
|
||||
await until(() => extracting);
|
||||
await warmup.claimAsync('UDID');
|
||||
expect(installCalls()).toHaveLength(0);
|
||||
let stopped = false;
|
||||
const stopping = warmup.stopAsync('UDID').then(() => {
|
||||
stopped = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(stopped).toBe(false);
|
||||
extraction.resolve();
|
||||
await stopping;
|
||||
expect(installCalls()).toHaveLength(0);
|
||||
} finally {
|
||||
extraction.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['metadata', 'archive', 'install'])(
|
||||
'warns on an ordinary %s failure without failing warmup',
|
||||
async phase => {
|
||||
if (phase === 'metadata') {
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 403 }));
|
||||
}
|
||||
if (phase === 'archive') {
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(Response.json({ sdkVersions }))
|
||||
.mockResolvedValueOnce(new Response('', { status: 503 }));
|
||||
}
|
||||
if (phase === 'install') {
|
||||
jest.mocked(commands.runAsync).mockImplementation(async (file, args) => {
|
||||
if (file === '/usr/bin/tar') {
|
||||
return await runCommandAsync(file, args);
|
||||
}
|
||||
throw Object.assign(new Error('install failed'), { code: 1 });
|
||||
});
|
||||
}
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
await warmup.claimAsync('UDID');
|
||||
await warmup.stopAsync('UDID');
|
||||
expect(process.stderr.write).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Could not preinstall Expo Go')
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['signal', 'close timeout', 'temp removal'])(
|
||||
'fails closed on %s instead of treating it as optional installation failure',
|
||||
async kind => {
|
||||
const error = Object.assign(new Error(`uncertain ${kind}`), {
|
||||
code: kind === 'signal' ? null : 1,
|
||||
});
|
||||
if (kind === 'temp removal') {
|
||||
jest.spyOn(fs, 'rm').mockRejectedValueOnce(error);
|
||||
} else {
|
||||
jest.mocked(commands.runAsync).mockImplementation(async (file, args) => {
|
||||
if (file === '/usr/bin/tar') {
|
||||
return await runCommandAsync(file, args);
|
||||
}
|
||||
if (kind === 'close timeout') {
|
||||
jest.mocked(commands.throwIfCommandCloseTimedOut).mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
await warmup.resultPromise.catch(() => {});
|
||||
// The claim does not wait for the removal; stop does, and never confirms a failed one.
|
||||
await warmup.claimAsync('UDID').catch(() => {});
|
||||
await expect(warmup.stopAsync('UDID')).rejects.toBe(error);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['valid', 'traversal', 'malformed'] as const)(
|
||||
'drains the real owned extractor for a %s archive before removing temp files or settling stop',
|
||||
async kind => {
|
||||
const contents = Buffer.alloc(4 * 1024 * 1024, 97);
|
||||
archive =
|
||||
kind === 'malformed'
|
||||
? Buffer.from('not a gzip or tar archive')
|
||||
: createArchive([
|
||||
...(kind === 'traversal'
|
||||
? [{ name: '../escaped', contents: Buffer.from('unsafe') }]
|
||||
: []),
|
||||
{ name: 'Info.plist', contents: Buffer.from('<plist>fixture</plist>') },
|
||||
{ name: 'ordinary-file', contents },
|
||||
]);
|
||||
const start = commands.start.bind(commands);
|
||||
let extractor: ReturnType<typeof commands.start> | undefined;
|
||||
let extractorClosed = false;
|
||||
const remove = fs.rm.bind(fs);
|
||||
const removing = jest.spyOn(fs, 'rm').mockImplementation(async (file, options) => {
|
||||
expect(extractorClosed).toBe(true);
|
||||
if (kind === 'traversal') {
|
||||
await expect(fs.access(path.join(String(file), 'escaped'))).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
});
|
||||
}
|
||||
if (kind !== 'malformed') {
|
||||
expect(
|
||||
(await fs.readFile(path.join(String(file), 'ExpoGo.app', 'ordinary-file'))).equals(
|
||||
contents
|
||||
)
|
||||
).toBe(true);
|
||||
}
|
||||
return await remove(file, options);
|
||||
});
|
||||
jest.spyOn(commands, 'start').mockImplementation((file, args, options) => {
|
||||
expect(file).toBe('/usr/bin/tar');
|
||||
expect(args).toEqual([
|
||||
'-xzf',
|
||||
expect.stringMatching(/\/expo-go.tar.gz$/),
|
||||
'-C',
|
||||
expect.stringMatching(/\/ExpoGo.app$/),
|
||||
]);
|
||||
expect(options?.signal).toBeUndefined();
|
||||
extractor = start(file, args, options);
|
||||
extractor.child.once('close', () => {
|
||||
extractorClosed = true;
|
||||
});
|
||||
expect(extractor.child.kill('SIGSTOP')).toBe(true);
|
||||
return extractor;
|
||||
});
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
try {
|
||||
await until(() => extractor !== undefined);
|
||||
await warmup.claimAsync('UDID'); // Only forbids new install; extraction remains owned and pending.
|
||||
expect(extractorClosed).toBe(false);
|
||||
let stopped = false;
|
||||
const stopping = warmup.stopAsync('UDID').then(() => {
|
||||
stopped = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(stopped).toBe(false);
|
||||
expect(removing).not.toHaveBeenCalled();
|
||||
extractor!.child.kill('SIGCONT');
|
||||
await stopping;
|
||||
expect(extractorClosed).toBe(true);
|
||||
expect(removing).toHaveBeenCalledTimes(1);
|
||||
expect(installCalls()).toHaveLength(0);
|
||||
expect(await fs.readdir(directory)).toEqual([]);
|
||||
if (kind !== 'valid') {
|
||||
const extractionError = await extractor!.resultPromise.catch(error => error);
|
||||
expect(extractionError.code).toBeGreaterThan(0);
|
||||
}
|
||||
} finally {
|
||||
// Reap only this test's owned child, including an assertion failure while it is stopped.
|
||||
if (extractor?.child.exitCode == null && extractor?.child.signalCode == null) {
|
||||
extractor?.child.kill('SIGCONT');
|
||||
extractor?.child.kill('SIGKILL');
|
||||
}
|
||||
await extractor?.resultPromise.catch(() => {});
|
||||
removing.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps owned temp files and rejects terminal stop when extractor close is uncertain', async () => {
|
||||
const failure = new Error('extractor close not proved');
|
||||
const remove = jest.spyOn(fs, 'rm');
|
||||
jest.mocked(commands.runAsync).mockImplementationOnce(async file => {
|
||||
expect(file).toBe('/usr/bin/tar');
|
||||
jest.mocked(commands.throwIfCommandCloseTimedOut).mockImplementation(() => {
|
||||
throw failure;
|
||||
});
|
||||
throw failure;
|
||||
});
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
await expect(warmup.resultPromise).rejects.toBe(failure);
|
||||
await expect(warmup.stopAsync('UDID')).rejects.toBe(failure);
|
||||
await expect(warmup.claimAsync('UDID')).rejects.toBe(failure);
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
expect((await fs.readdir(directory)).some(name => name.startsWith('simulator-expo-go-'))).toBe(
|
||||
true
|
||||
);
|
||||
expect(installCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each(['traversal', 'malformed'] as const)(
|
||||
'never installs after a real %s archive failure without a claim',
|
||||
async kind => {
|
||||
archive =
|
||||
kind === 'malformed'
|
||||
? Buffer.from('not an archive')
|
||||
: createArchive([
|
||||
{ name: '../escaped', contents: Buffer.from('unsafe') },
|
||||
{ name: 'Info.plist', contents: Buffer.from('<plist>fixture</plist>') },
|
||||
]);
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
expect(installCalls()).toHaveLength(0);
|
||||
expect(process.stderr.write).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Could not preinstall Expo Go')
|
||||
);
|
||||
await warmup.stopAsync('UDID');
|
||||
expect(await fs.readdir(directory)).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
it('claim returns the identity read from the Expo Go it installed', async () => {
|
||||
const plist: Record<string, string> = {
|
||||
CFBundleIdentifier: 'host.exp.Exponent',
|
||||
CFBundleShortVersionString: '58.0.7',
|
||||
CFBundleVersion: '58.0.7',
|
||||
};
|
||||
jest
|
||||
.mocked(commands.runAsync)
|
||||
.mockImplementation(async (file, args, signal) =>
|
||||
file === '/usr/bin/tar'
|
||||
? await runCommandAsync(file, args, signal)
|
||||
: file === 'plutil'
|
||||
? `${plist[args[1]]}\n`
|
||||
: ''
|
||||
);
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
expect(await warmup.claimAsync('UDID')).toEqual({
|
||||
udid: 'UDID',
|
||||
bundleIdentifier: 'host.exp.Exponent',
|
||||
shortVersion: '58.0.7',
|
||||
version: '58.0.7',
|
||||
});
|
||||
const plistPath = jest
|
||||
.mocked(commands.runAsync)
|
||||
.mock.calls.find(([file]) => file === 'plutil')![1];
|
||||
expect(path.basename(path.dirname(plistPath[5]))).toBe('ExpoGo.app');
|
||||
});
|
||||
|
||||
it('claim returns no identity when the preinstall fails', async () => {
|
||||
jest
|
||||
.mocked(commands.runAsync)
|
||||
.mockImplementation(async (file, args, signal) =>
|
||||
file === '/usr/bin/tar'
|
||||
? await runCommandAsync(file, args, signal)
|
||||
: file === 'xcrun'
|
||||
? Promise.reject(Object.assign(new Error('install failed'), { code: 1 }))
|
||||
: ''
|
||||
);
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
expect(await warmup.claimAsync('UDID')).toBeUndefined();
|
||||
expect(jest.mocked(commands.runAsync).mock.calls.some(([file]) => file === 'plutil')).toBe(false);
|
||||
});
|
||||
|
||||
it('downloads and extracts once for two devices, then removes the shared app on stop', async () => {
|
||||
warmup = startOn('FIRST', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
const app = installCalls()[0][1][3];
|
||||
expect((await fs.stat(app)).isDirectory()).toBe(true);
|
||||
await warmup.preinstallOnDeviceAsync('SECOND', Promise.resolve(), controller.signal);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
jest.mocked(commands.runAsync).mock.calls.filter(([file]) => file === '/usr/bin/tar')
|
||||
).toHaveLength(1);
|
||||
expect(installCalls().map(([, args]) => args.slice(2))).toEqual([
|
||||
['FIRST', app],
|
||||
['SECOND', app],
|
||||
]);
|
||||
const claimed = await warmup.claimAsync('SECOND');
|
||||
await warmup.stopAsync('SECOND');
|
||||
await expect(fs.stat(app)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
expect(claimed?.udid).toBe('SECOND');
|
||||
});
|
||||
it('a claim prevents secondary preinstall from starting and releases the retained archive', async () => {
|
||||
warmup = startOn('FIRST', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
await warmup.claimAsync('FIRST');
|
||||
await warmup.preinstallOnDeviceAsync('SECOND', Promise.resolve(), controller.signal);
|
||||
await warmup.stopAsync('FIRST');
|
||||
expect(installCalls()).toHaveLength(1);
|
||||
expect(await fs.readdir(directory)).toEqual([]);
|
||||
});
|
||||
it('claim and stop drain a secondary native install without cancelling its transaction', async () => {
|
||||
const install = deferred<string>();
|
||||
const original = jest.mocked(commands.runAsync).getMockImplementation()!;
|
||||
warmup = startOn('FIRST', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
jest
|
||||
.mocked(commands.runAsync)
|
||||
.mockImplementation((file, args, signal) =>
|
||||
file === 'xcrun' && args.includes('SECOND') ? install.promise : original(file, args, signal)
|
||||
);
|
||||
const secondary = warmup.preinstallOnDeviceAsync('SECOND', Promise.resolve(), controller.signal);
|
||||
let claimed = false;
|
||||
try {
|
||||
await until(() => installCalls().length === 2);
|
||||
const claiming = warmup.claimAsync('SECOND').then(() => {
|
||||
claimed = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(claimed).toBe(false);
|
||||
expect(installCalls()[1][2]).toBeUndefined();
|
||||
install.resolve('');
|
||||
await claiming;
|
||||
await secondary;
|
||||
await warmup.stopAsync('FIRST');
|
||||
expect(await fs.readdir(directory)).toEqual([]);
|
||||
} finally {
|
||||
install.resolve('');
|
||||
await secondary;
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['signal', 'close timeout'])(
|
||||
'isolates a secondary %s failure from the already-installed default',
|
||||
async kind => {
|
||||
warmup = startOn('FIRST', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
const failure = Object.assign(new Error('secondary install failed'), { code: null });
|
||||
const original = jest.mocked(commands.runAsync).getMockImplementation()!;
|
||||
jest.mocked(commands.runAsync).mockImplementation((file, args, signal) => {
|
||||
if (file === 'xcrun' && args.includes('SECOND')) {
|
||||
if (kind === 'close timeout') {
|
||||
jest.mocked(commands.throwIfCommandCloseTimedOut).mockImplementation(() => {
|
||||
throw failure;
|
||||
});
|
||||
}
|
||||
return Promise.reject(failure);
|
||||
}
|
||||
return original(file, args, signal);
|
||||
});
|
||||
await expect(
|
||||
warmup.preinstallOnDeviceAsync('SECOND', Promise.resolve(), controller.signal)
|
||||
).rejects.toBe(failure);
|
||||
expect((await warmup.claimAsync('FIRST'))?.udid).toBe('FIRST');
|
||||
await expect(warmup.claimAsync('SECOND')).rejects.toBe(failure);
|
||||
if (kind === 'close timeout') {
|
||||
await expect(warmup.stopAsync('FIRST')).rejects.toBe(failure);
|
||||
} else {
|
||||
await expect(warmup.stopAsync('FIRST')).resolves.toBeUndefined();
|
||||
}
|
||||
await expect(warmup.stopAsync('SECOND')).rejects.toBe(failure);
|
||||
}
|
||||
);
|
||||
it('a concurrent stop cannot miss a secondary install failure while shared cleanup drains', async () => {
|
||||
warmup = startOn('FIRST', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
const installation = deferred<string>();
|
||||
const original = jest.mocked(commands.runAsync).getMockImplementation()!;
|
||||
jest
|
||||
.mocked(commands.runAsync)
|
||||
.mockImplementation((file, args, signal) =>
|
||||
file === 'xcrun' && args.includes('SECOND')
|
||||
? installation.promise
|
||||
: original(file, args, signal)
|
||||
);
|
||||
const secondary = warmup.preinstallOnDeviceAsync('SECOND', Promise.resolve(), controller.signal);
|
||||
void secondary.catch(() => {});
|
||||
try {
|
||||
await until(() => installCalls().length === 2);
|
||||
const stopping = warmup.stopAsync('SECOND');
|
||||
void stopping.catch(() => {});
|
||||
const failure = Object.assign(new Error('secondary killed'), { code: null });
|
||||
installation.reject(failure);
|
||||
await expect(stopping).rejects.toBe(failure);
|
||||
await expect(secondary).rejects.toBe(failure);
|
||||
} finally {
|
||||
installation.resolve('');
|
||||
await secondary.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('preinstalls all three configured devices from one shared archive through the session owner', async () => {
|
||||
const { createWarmSimulatorSession } = await import('../commands/start');
|
||||
const simulator = await import('../utils/simulator');
|
||||
const serveSim = await import('../utils/serveSim');
|
||||
const processes = await import('../utils/processes');
|
||||
const agentDevice = await import('../utils/agentDevice');
|
||||
const argent = await import('../utils/argent');
|
||||
const devices = ['FIRST', 'SECOND', 'THIRD'].map(udid => ({ udid, name: `iPhone ${udid}` }));
|
||||
const environment = { ...process.env };
|
||||
process.env.EXPO_SIMULATOR_DEVICE = devices.map(device => device.udid).join(',');
|
||||
process.env.AGENT_DEVICE_STATE_DIR = path.join(directory, 'agent');
|
||||
process.env.ARGENT_EVENT_LOG = path.join(directory, 'argent-events');
|
||||
jest.spyOn(simulator, 'listAvailableSimulatorsAsync').mockResolvedValue(devices);
|
||||
jest.spyOn(simulator, 'waitForSimulatorBootAsync').mockResolvedValue();
|
||||
jest.spyOn(simulator, 'disableApsdAsync').mockResolvedValue();
|
||||
jest.spyOn(simulator, 'shutdownSimulatorAsync').mockResolvedValue();
|
||||
jest.spyOn(processes, 'stopProcessAndDescendantsAsync').mockResolvedValue();
|
||||
jest.spyOn(agentDevice, 'createAgentDeviceTool').mockReturnValue({
|
||||
name: 'agent-device',
|
||||
startAsync: async () => {},
|
||||
stopAsync: async () => {},
|
||||
});
|
||||
jest
|
||||
.spyOn(argent, 'createArgentTool')
|
||||
.mockReturnValue({ name: 'argent', startAsync: async () => {}, stopAsync: async () => {} });
|
||||
jest.spyOn(serveSim, 'startServeSim').mockReturnValue({
|
||||
child: { pid: 90001 } as import('node:child_process').ChildProcess,
|
||||
resultPromise: new Promise(() => {}),
|
||||
});
|
||||
jest.spyOn(serveSim, 'waitForServeSimReadyAsync').mockResolvedValue({
|
||||
startupDevice: 'FIRST',
|
||||
pid: 90001,
|
||||
port: 3200,
|
||||
nativeStartTime: 'start-time',
|
||||
url: 'http://127.0.0.1:3200',
|
||||
token: 'fixture-token',
|
||||
});
|
||||
const original = jest.mocked(commands.runAsync).getMockImplementation()!;
|
||||
jest.mocked(commands.runAsync).mockImplementation(async (file, args, signal) => {
|
||||
if (file === 'ps') {
|
||||
return 'controller-start';
|
||||
}
|
||||
if (file === 'plutil') {
|
||||
return args.includes('CFBundleIdentifier') ? 'host.exp.Exponent' : '58.0.7';
|
||||
}
|
||||
return original(file, args, signal);
|
||||
});
|
||||
const onFailure = jest.fn();
|
||||
const session = createWarmSimulatorSession({
|
||||
signal: controller.signal,
|
||||
throwIfStoppingAsync: async () => controller.signal.throwIfAborted(),
|
||||
onPreparationFailure: onFailure,
|
||||
});
|
||||
try {
|
||||
await session.prepareAsync();
|
||||
await until(() => installCalls().length === 3);
|
||||
await session.claimAsync({ engine: 'serve-sim', requestedDevice: 'THIRD' });
|
||||
const prepared = await session.prepareToolsAsync();
|
||||
expect(prepared.preinstalledExpoGo).toEqual({
|
||||
udid: 'THIRD',
|
||||
bundleIdentifier: 'host.exp.Exponent',
|
||||
shortVersion: '58.0.7',
|
||||
version: '58.0.7',
|
||||
});
|
||||
expect(installCalls().map(([, args]) => args[2])).toEqual(['FIRST', 'SECOND', 'THIRD']);
|
||||
expect(new Set(installCalls().map(([, args]) => args[3])).size).toBe(1);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
jest.mocked(commands.runAsync).mock.calls.filter(([file]) => file === '/usr/bin/tar')
|
||||
).toHaveLength(1);
|
||||
expect(onFailure).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await session.stopAsync();
|
||||
process.env = environment;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
// Test fixtures use these PIDs for fake child processes. Fail fast instead of letting a test
|
||||
// reach the real process.kill with one of them, where it could signal an unrelated process.
|
||||
const FIXTURE_PIDS = new Set([10, 11, 12, 20, 30, 40, 41]);
|
||||
const isFixturePid = (pid: number): boolean =>
|
||||
FIXTURE_PIDS.has(Math.abs(pid)) || (Math.abs(pid) >= 90000 && Math.abs(pid) < 90100);
|
||||
|
||||
const realKill = process.kill.bind(process);
|
||||
process.kill = ((pid: number, signal?: string | number) => {
|
||||
if (isFixturePid(pid)) {
|
||||
throw new Error(`A test tried to signal fixture PID ${pid} through the real process.kill.`);
|
||||
}
|
||||
return realKill(pid, signal);
|
||||
}) as typeof process.kill;
|
||||
@@ -0,0 +1,56 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as sleepAsync } from 'node:timers/promises';
|
||||
|
||||
import { commands, stopProcessAndDescendantsAsync } from '../../utils/processes';
|
||||
|
||||
export async function startOwnedWrapperAsync() {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'launcher-owned-wrapper-'));
|
||||
const readyPath = path.join(directory, 'ready.json');
|
||||
const job = commands.start(
|
||||
process.execPath,
|
||||
[path.join(__dirname, 'stop-wrapper.cjs'), path.join(__dirname, 'stop-leaf.cjs'), readyPath],
|
||||
{ captureStdout: true, detached: true }
|
||||
);
|
||||
const realKill = process.kill.bind(process);
|
||||
const stop = stopProcessAndDescendantsAsync;
|
||||
let confirmed = false;
|
||||
let stopping: Promise<void> | undefined;
|
||||
const cleanupAsync = async () => {
|
||||
if (!confirmed && job.child.pid) {
|
||||
try {
|
||||
realKill(-job.child.pid, 'SIGKILL');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error;
|
||||
}
|
||||
}
|
||||
await job.resultPromise.catch(() => {});
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
};
|
||||
try {
|
||||
let leaves: number[] = [];
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (!leaves.length) {
|
||||
const ready = await fs.readFile(readyPath, 'utf8').catch(() => undefined);
|
||||
if (ready) leaves = JSON.parse(ready);
|
||||
else {
|
||||
if (Date.now() >= deadline) throw new Error('The disposable wrapper did not become ready.');
|
||||
await sleepAsync(10);
|
||||
}
|
||||
}
|
||||
return {
|
||||
job,
|
||||
leaves,
|
||||
realKill,
|
||||
cleanupAsync,
|
||||
stopAsync: () =>
|
||||
(stopping ??= stop(job).then(() => {
|
||||
confirmed = true;
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
await cleanupAsync();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
process.on('SIGTERM', () => process.exit(23));
|
||||
setInterval(() => {}, 1000);
|
||||
process.send('ready');
|
||||
@@ -0,0 +1,20 @@
|
||||
const { spawn } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const children = [0, 1].map(() =>
|
||||
spawn(process.execPath, [process.argv[2]], {
|
||||
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
||||
})
|
||||
);
|
||||
// Like a supervisor, forward parent termination and report the child's final status.
|
||||
process.on('SIGTERM', () => {
|
||||
for (const child of children) child.kill('SIGTERM');
|
||||
});
|
||||
let ready = 0;
|
||||
for (const child of children) {
|
||||
child.on('message', () => {
|
||||
if (++ready === children.length) {
|
||||
fs.writeFileSync(process.argv[3], JSON.stringify(children.map(child => child.pid)));
|
||||
}
|
||||
});
|
||||
child.on('exit', code => process.exit(code ?? 1));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export function deferred<T = void>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
/** Polls `predicate` for up to 2 seconds, then asserts it. */
|
||||
export async function until(predicate: () => boolean): Promise<void> {
|
||||
for (let i = 0; i < 400 && !predicate(); i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
expect(predicate()).toBe(true);
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
import { type ChildProcess } from 'node:child_process';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { claimAsync, prepareToolsAsync } from '../commands/claim';
|
||||
import { runWarmSimulatorSessionAsync, startAsync } from '../commands/start';
|
||||
import { stopAsync } from '../commands/stop';
|
||||
import { mainAsync } from '../main';
|
||||
import { createArgentTool } from '../utils/argent';
|
||||
import { startExpoGoPreinstall } from '../utils/expoGo';
|
||||
import { type ChildProcessHandle, commands } from '../utils/processes';
|
||||
import * as processes from '../utils/processes';
|
||||
import * as serveSim from '../utils/serveSim';
|
||||
import { shutdownSimulatorAsync } from '../utils/simulator';
|
||||
import {
|
||||
LAUNCHER_STATE_FILE,
|
||||
readLauncherStateAsync,
|
||||
updateLauncherStateAsync,
|
||||
waitForLauncherStateAsync,
|
||||
} from '../utils/state';
|
||||
import { deferred, until } from './helpers';
|
||||
|
||||
jest.mock('../utils/expoGo');
|
||||
jest.mock('../utils/argent');
|
||||
const boot = 'boot-one';
|
||||
let root: string;
|
||||
let environment: NodeJS.ProcessEnv;
|
||||
let active: Promise<unknown>[];
|
||||
let jobs: ChildProcessHandle[];
|
||||
const platform = process.platform;
|
||||
const deviceList = {
|
||||
devices: {
|
||||
ios: [
|
||||
{ name: 'iPhone 17', udid: 'PHONE' },
|
||||
{ name: 'iPhone 17 Pro', udid: 'PRO' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const nativeFs = {
|
||||
readFile: fs.readFile.bind(fs),
|
||||
writeFile: fs.writeFile.bind(fs),
|
||||
rename: fs.rename.bind(fs),
|
||||
rm: fs.rm.bind(fs),
|
||||
mkdir: fs.mkdir.bind(fs),
|
||||
rmdir: fs.rmdir.bind(fs),
|
||||
stat: fs.stat.bind(fs),
|
||||
};
|
||||
const remap = (file: unknown): unknown =>
|
||||
typeof file === 'string' && file.startsWith(LAUNCHER_STATE_FILE)
|
||||
? path.join(root, 'control', path.basename(file))
|
||||
: file;
|
||||
|
||||
async function status(value: string) {
|
||||
return await waitForLauncherStateAsync(boot, state => state?.status === value, 2_000);
|
||||
}
|
||||
async function beginSession(): Promise<Promise<unknown>[]> {
|
||||
await updateLauncherStateAsync(boot, () => ({
|
||||
bootUuid: boot,
|
||||
status: 'starting',
|
||||
launcherVersion: require('../../package.json').version,
|
||||
controllerPid: process.pid,
|
||||
}));
|
||||
const completion = runWarmSimulatorSessionAsync(boot).catch(error => error);
|
||||
active.push(completion);
|
||||
return [completion];
|
||||
}
|
||||
beforeEach(async () => {
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), 'launcher-session-'));
|
||||
jest.spyOn(os, 'homedir').mockReturnValue(root);
|
||||
await fs.mkdir(path.join(root, 'control'));
|
||||
await fs.mkdir(path.join(root, '.argent'));
|
||||
await fs.writeFile(
|
||||
path.join(root, '.argent/tool-server.json'),
|
||||
JSON.stringify({ pid: 90002, port: 3201, token: 'fixture-token' })
|
||||
);
|
||||
jest.spyOn(processes, 'isProcessOwnedByServiceAsync').mockResolvedValue(true);
|
||||
jest
|
||||
.spyOn(global, 'fetch')
|
||||
.mockImplementation(async () => new Response(JSON.stringify({ tools: [] })));
|
||||
for (const name of ['readFile', 'writeFile', 'rename', 'rm', 'mkdir', 'rmdir', 'stat'] as const) {
|
||||
jest.spyOn(fs, name).mockImplementation(((...args: unknown[]) => {
|
||||
args[0] = remap(args[0]);
|
||||
if (name === 'rename') {
|
||||
args[1] = remap(args[1]);
|
||||
}
|
||||
return (nativeFs[name] as (...args: unknown[]) => unknown)(...args);
|
||||
}) as never);
|
||||
}
|
||||
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
environment = { ...process.env };
|
||||
process.env.EXPO_SIMULATOR_STARTUP = '1';
|
||||
process.env.ARGENT_EVENT_LOG = path.join(root, 'argent-events');
|
||||
active = [];
|
||||
jobs = [];
|
||||
jest
|
||||
.mocked(startExpoGoPreinstall)
|
||||
.mockReset()
|
||||
.mockReturnValue({
|
||||
preinstallOnDeviceAsync: jest.fn(async () => {}),
|
||||
claimAsync: jest.fn(async () => undefined),
|
||||
stopAsync: jest.fn(async () => {}),
|
||||
});
|
||||
jest.spyOn(commands, 'runAsync').mockImplementation(async (file, args) => {
|
||||
if (file === '/usr/sbin/sysctl') {
|
||||
return boot;
|
||||
}
|
||||
if (args.includes('lstart=')) {
|
||||
return 'Thu Oct 22 10:00:00 2026';
|
||||
}
|
||||
if (args.includes('devices')) {
|
||||
return JSON.stringify(deviceList);
|
||||
}
|
||||
if (args.includes('launchctl') && args.includes('list')) {
|
||||
throw Object.assign(new Error('apsd is not loaded'), { code: 113 });
|
||||
}
|
||||
return '';
|
||||
});
|
||||
jest.spyOn(commands, 'start').mockImplementation((_file, args) => {
|
||||
const job = {
|
||||
child: Object.assign(new EventEmitter(), {
|
||||
pid: 90000 + jobs.length,
|
||||
exitCode: null,
|
||||
signalCode: null,
|
||||
kill: jest.fn(),
|
||||
}) as unknown as ChildProcess,
|
||||
resultPromise: args.includes('server') ? new Promise<string>(() => {}) : Promise.resolve(''),
|
||||
};
|
||||
jobs.push(job);
|
||||
return job;
|
||||
});
|
||||
// Fixture PIDs are fake; never signal them.
|
||||
jest.spyOn(processes, 'stopProcessAndDescendantsAsync').mockResolvedValue();
|
||||
jest.spyOn(serveSim, 'waitForServeSimReadyAsync').mockResolvedValue({
|
||||
startupDevice: 'PHONE',
|
||||
pid: 90000,
|
||||
nativeStartTime: 'native-start',
|
||||
port: 3200,
|
||||
url: 'http://127.0.0.1:3200',
|
||||
token: 'fixture-token',
|
||||
});
|
||||
jest.mocked(createArgentTool).mockImplementation(() => ({
|
||||
name: 'argent',
|
||||
startAsync: async () => {},
|
||||
stopAsync: async () => {},
|
||||
}));
|
||||
});
|
||||
afterEach(async () => {
|
||||
if (active.length) {
|
||||
process.emit('SIGTERM', 'SIGTERM');
|
||||
}
|
||||
await Promise.allSettled(active);
|
||||
jest.restoreAllMocks();
|
||||
process.env = environment;
|
||||
Object.defineProperty(process, 'platform', { value: platform });
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('concurrent starts acquire one session and record the exact package version', async () => {
|
||||
const results: unknown[] = [];
|
||||
const starts = [0, 1].map(() =>
|
||||
startAsync(boot).then(
|
||||
value => results.push(value),
|
||||
error => results.push(error)
|
||||
)
|
||||
);
|
||||
active.push(...starts);
|
||||
await status('ready');
|
||||
await until(() => results.length === 1);
|
||||
expect(results[0]).toBeInstanceOf(Error);
|
||||
expect((await readLauncherStateAsync(boot))?.launcherVersion).toBe(
|
||||
require('../../package.json').version
|
||||
);
|
||||
expect(jest.mocked(serveSim.waitForServeSimReadyAsync).mock.calls).toHaveLength(1);
|
||||
await stopAsync(boot);
|
||||
await Promise.all(starts);
|
||||
expect((await readLauncherStateAsync(boot))?.status).toBe('disabled');
|
||||
});
|
||||
|
||||
test('stop before start makes late startup a resource-free disabled no-op', async () => {
|
||||
expect(await stopAsync(boot)).toBe(0);
|
||||
expect(await startAsync(boot)).toBe(0);
|
||||
expect(commands.runAsync).not.toHaveBeenCalled();
|
||||
expect(commands.start).not.toHaveBeenCalled();
|
||||
expect(await readLauncherStateAsync(boot)).toEqual({ bootUuid: boot, status: 'disabled' });
|
||||
});
|
||||
|
||||
test('stop under different HOME and TMPDIR waits for the same active session cleanup', async () => {
|
||||
const [completion] = await beginSession();
|
||||
await status('ready');
|
||||
const drain = deferred();
|
||||
jest.mocked(processes.stopProcessAndDescendantsAsync).mockImplementation(async () => {
|
||||
await drain.promise;
|
||||
});
|
||||
process.env.HOME = path.join(root, 'other-home');
|
||||
process.env.TMPDIR = path.join(root, 'other-tmp');
|
||||
jest.mocked(os.homedir).mockReturnValue(process.env.HOME);
|
||||
let done = false;
|
||||
const stopping = mainAsync(['stop']).then(value => {
|
||||
done = true;
|
||||
return value;
|
||||
});
|
||||
try {
|
||||
await status('stopping');
|
||||
expect(done).toBe(false);
|
||||
drain.resolve();
|
||||
expect(await stopping).toBe(0);
|
||||
expect(await completion).toBeUndefined();
|
||||
expect((await readLauncherStateAsync(boot))?.status).toBe('disabled');
|
||||
} finally {
|
||||
drain.resolve();
|
||||
await stopping;
|
||||
}
|
||||
});
|
||||
|
||||
test('ready contains one server identity and effective paths in the single state file', async () => {
|
||||
const [completion] = await beginSession();
|
||||
const ready = await status('ready');
|
||||
expect(ready).toMatchObject({
|
||||
controllerPid: process.pid,
|
||||
device: { udid: 'PHONE' },
|
||||
server: { startupDevice: 'PHONE', pid: 90000, nativeStartTime: 'native-start', port: 3200 },
|
||||
});
|
||||
expect(ready?.agentDeviceStateDir).toContain(root);
|
||||
expect(await fs.readdir(path.join(root, 'control'))).toEqual([
|
||||
path.basename(LAUNCHER_STATE_FILE),
|
||||
]);
|
||||
await stopAsync(boot);
|
||||
expect(await completion).toBeUndefined();
|
||||
});
|
||||
|
||||
test.each([undefined, 'phone', 'iPhone 17 Pro'])(
|
||||
'claim reserves %s without changing the server or booting devices',
|
||||
async selector => {
|
||||
const [completion] = await beginSession();
|
||||
await status('ready');
|
||||
const output = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
await claimAsync(boot, 'serve-sim', selector);
|
||||
const result = JSON.parse(String(output.mock.calls.at(-1)![0]));
|
||||
expect(result).toMatchObject({
|
||||
status: 'claimed',
|
||||
engine: 'serve-sim',
|
||||
device: { udid: selector === 'iPhone 17 Pro' ? 'PRO' : 'PHONE' },
|
||||
server: { startupDevice: 'PHONE', port: 3200, pid: 90000 },
|
||||
});
|
||||
expect(result.device).not.toHaveProperty('port');
|
||||
expect(
|
||||
jest.mocked(commands.runAsync).mock.calls.filter(([, args]) => args.includes('bootstatus'))
|
||||
).toHaveLength(0);
|
||||
await expect(claimAsync(boot, 'argent')).rejects.toThrow('available');
|
||||
await stopAsync(boot);
|
||||
expect(await completion).toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
test.each([
|
||||
[undefined, undefined],
|
||||
['invalid', undefined],
|
||||
['serve-sim', ' '],
|
||||
])('invalid claim %s/%s does not acquire or mutate state', async (engine, device) => {
|
||||
const [completion] = await beginSession();
|
||||
await status('ready');
|
||||
const before = await readLauncherStateAsync(boot);
|
||||
await expect(claimAsync(boot, engine, device)).rejects.toThrow(
|
||||
'Invalid Simulator session request'
|
||||
);
|
||||
expect(await readLauncherStateAsync(boot)).toEqual(before);
|
||||
await stopAsync(boot);
|
||||
await completion;
|
||||
});
|
||||
|
||||
test('the post-adopt preparation command uses the consumed claim and returns toolsReady', async () => {
|
||||
const [completion] = await beginSession();
|
||||
await status('ready');
|
||||
const output = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
await expect(prepareToolsAsync(boot)).rejects.toThrow('Claim and adopt');
|
||||
await claimAsync(boot, 'agent-device');
|
||||
expect((await readLauncherStateAsync(boot))?.toolsReady).toBeUndefined();
|
||||
await prepareToolsAsync(boot);
|
||||
expect(JSON.parse(String(output.mock.calls.at(-1)![0]))).toMatchObject({
|
||||
status: 'claimed',
|
||||
engine: 'agent-device',
|
||||
toolsReady: true,
|
||||
prepareToolsRequested: true,
|
||||
});
|
||||
await stopAsync(boot);
|
||||
expect(await completion).toBeUndefined();
|
||||
});
|
||||
|
||||
test('prepare-tools returns the validated Argent identity past an earlier stale same-port record', async () => {
|
||||
jest
|
||||
.mocked(createArgentTool)
|
||||
.mockImplementation(jest.requireActual('../utils/argent').createArgentTool);
|
||||
await fs.writeFile(
|
||||
path.join(root, '.argent/tool-server-old.json'),
|
||||
JSON.stringify({ pid: 90099, port: 3201, token: 'stale-token' })
|
||||
);
|
||||
jest
|
||||
.spyOn(fs, 'readdir')
|
||||
.mockResolvedValue(['tool-server-old.json', 'tool-server.json'] as never);
|
||||
jest
|
||||
.mocked(processes.isProcessOwnedByServiceAsync)
|
||||
.mockImplementation(async pid => pid !== 90099);
|
||||
const [completion] = await beginSession();
|
||||
await status('ready');
|
||||
const output = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
await claimAsync(boot, 'argent');
|
||||
await prepareToolsAsync(boot);
|
||||
expect(JSON.parse(String(output.mock.calls.at(-1)?.[0]))).toMatchObject({
|
||||
toolsReady: true,
|
||||
tools: { pid: 90002, port: 3201, token: 'fixture-token' },
|
||||
});
|
||||
expect(fetch).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ headers: { Authorization: 'Bearer stale-token' } })
|
||||
);
|
||||
await stopAsync(boot);
|
||||
expect(await completion).toBeUndefined();
|
||||
});
|
||||
|
||||
test('stop during post-adopt preparation prevents toolsReady and waits for owned drain', async () => {
|
||||
const [completion] = await beginSession();
|
||||
await status('ready');
|
||||
jest.spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
await claimAsync(boot, 'serve-sim');
|
||||
const entered = deferred();
|
||||
const release = deferred();
|
||||
let preparationSignal: AbortSignal;
|
||||
jest
|
||||
.mocked(serveSim.waitForServeSimReadyAsync)
|
||||
.mockImplementationOnce(async (_device, _server, signal) => {
|
||||
preparationSignal = signal;
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
signal.throwIfAborted();
|
||||
throw new Error('unreachable');
|
||||
});
|
||||
const preparing = prepareToolsAsync(boot).catch(error => error);
|
||||
try {
|
||||
await entered.promise;
|
||||
const stopping = stopAsync(boot);
|
||||
await status('stopping');
|
||||
await until(() => preparationSignal.aborted);
|
||||
release.resolve();
|
||||
await stopping;
|
||||
expect(await preparing).toBeInstanceOf(Error);
|
||||
expect((await readLauncherStateAsync(boot))?.toolsReady).not.toBe(true);
|
||||
expect(await completion).toBeUndefined();
|
||||
} finally {
|
||||
release.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
test('claim does not wait for the Expo Go install; prepare-tools does, and stop drains cleanup', async () => {
|
||||
const info = {
|
||||
udid: 'PHONE',
|
||||
bundleIdentifier: 'host.exp.Exponent',
|
||||
shortVersion: '58.0.7',
|
||||
version: '58.0.7',
|
||||
};
|
||||
const install = deferred<typeof info>();
|
||||
const cleanup = deferred();
|
||||
jest.mocked(startExpoGoPreinstall).mockReturnValue({
|
||||
// The default device's preinstall stays open until its cleanup finishes.
|
||||
preinstallOnDeviceAsync: jest.fn(() => cleanup.promise),
|
||||
claimAsync: jest.fn(() => install.promise),
|
||||
stopAsync: jest.fn(() => cleanup.promise),
|
||||
});
|
||||
const [completion] = await beginSession();
|
||||
await status('ready');
|
||||
jest.spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
try {
|
||||
await claimAsync(boot, 'serve-sim');
|
||||
expect(await readLauncherStateAsync(boot)).not.toHaveProperty('preinstalledExpoGo');
|
||||
let prepared = false;
|
||||
const preparing = prepareToolsAsync(boot).then(() => {
|
||||
prepared = true;
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
expect(prepared).toBe(false);
|
||||
install.resolve(info);
|
||||
await preparing;
|
||||
expect(await readLauncherStateAsync(boot)).toMatchObject({
|
||||
toolsReady: true,
|
||||
preinstalledExpoGo: info,
|
||||
});
|
||||
const stopping = stopAsync(boot);
|
||||
await status('stopping');
|
||||
expect((await readLauncherStateAsync(boot))?.status).not.toBe('disabled');
|
||||
cleanup.resolve();
|
||||
await stopping;
|
||||
await completion;
|
||||
} finally {
|
||||
install.resolve(info);
|
||||
cleanup.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
test('preparation failure confirms cleanup before publishing disabled with its cause', async () => {
|
||||
jest
|
||||
.mocked(serveSim.waitForServeSimReadyAsync)
|
||||
.mockRejectedValueOnce(new Error('native readiness failed'));
|
||||
const [completion] = await beginSession();
|
||||
expect(((await completion) as Error).message).toBe('native readiness failed');
|
||||
expect(processes.stopProcessAndDescendantsAsync).toHaveBeenCalled();
|
||||
expect(await readLauncherStateAsync(boot)).toMatchObject({
|
||||
status: 'disabled',
|
||||
error: 'native readiness failed',
|
||||
});
|
||||
});
|
||||
|
||||
test('cleanup failure leaves stopping with a cause and never publishes disabled', async () => {
|
||||
const [completion] = await beginSession();
|
||||
await status('ready');
|
||||
jest
|
||||
.mocked(processes.stopProcessAndDescendantsAsync)
|
||||
.mockRejectedValue(new Error('child not drained'));
|
||||
process.emit('SIGTERM', 'SIGTERM');
|
||||
expect(await completion).toBeInstanceOf(AggregateError);
|
||||
expect(await readLauncherStateAsync(boot)).toMatchObject({
|
||||
status: 'stopping',
|
||||
error: expect.stringContaining('cleanup could not be confirmed'),
|
||||
});
|
||||
});
|
||||
|
||||
test('stop waits on a versionless active state', async () => {
|
||||
await updateLauncherStateAsync(boot, () => ({
|
||||
bootUuid: boot,
|
||||
status: 'ready',
|
||||
controllerPid: 9000,
|
||||
}));
|
||||
const stopping = stopAsync(boot);
|
||||
await status('stopping');
|
||||
await updateLauncherStateAsync(boot, state => ({ ...state!, status: 'disabled' }));
|
||||
expect(await stopping).toBe(0);
|
||||
});
|
||||
|
||||
test('--version prints the exact package version without touching boot or state', async () => {
|
||||
const output = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
expect(await mainAsync(['--version'])).toBe(0);
|
||||
expect(output).toHaveBeenCalledWith(`${require('../../package.json').version}\n`);
|
||||
expect(commands.runAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each(['install', 'adopt', 'run'])('rejects removed command %s', async command => {
|
||||
await expect(mainAsync([command])).rejects.toThrow('Usage:');
|
||||
expect(commands.runAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
test.each(['linux', 'win32'])('rejects %s before state or native work', async target => {
|
||||
Object.defineProperty(process, 'platform', { value: target });
|
||||
await expect(mainAsync(['start'])).rejects.toThrow('requires macOS');
|
||||
expect(await fs.readdir(path.join(root, 'control'))).toEqual([]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['Shutdown', true],
|
||||
['Booted', false],
|
||||
])('native shutdown error for %s is confirmed done: %s', async (state, done) => {
|
||||
const failure = new Error('shutdown failed');
|
||||
jest.mocked(commands.runAsync).mockImplementation(async (_file, args) => {
|
||||
if (args.includes('shutdown')) {
|
||||
throw failure;
|
||||
}
|
||||
return JSON.stringify({ devices: { ios: [{ udid: 'OLD', state }] } });
|
||||
});
|
||||
const result = shutdownSimulatorAsync('OLD');
|
||||
if (done) {
|
||||
await expect(result).resolves.toBeUndefined();
|
||||
} else {
|
||||
await expect(result).rejects.toBe(failure);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { startOwnedWrapperAsync } from './fixtures/ownedWrapper';
|
||||
|
||||
it('confirms cleanup when a real owned wrapper propagates intentionally stopped children as exit 23', async () => {
|
||||
const fixture = await startOwnedWrapperAsync();
|
||||
const { job, leaves, realKill } = fixture;
|
||||
try {
|
||||
const kill = jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (pid === job.child.pid && (job.child.exitCode !== null || job.child.signalCode !== null)) {
|
||||
throw new Error('An exited wrapper PID must never be probed or signalled.');
|
||||
}
|
||||
return realKill(pid, signal);
|
||||
});
|
||||
await expect(fixture.stopAsync()).resolves.toBeUndefined();
|
||||
expect(job.child.exitCode).toBe(23);
|
||||
await expect(job.resultPromise).rejects.toMatchObject({ code: 23 });
|
||||
expect(
|
||||
kill.mock.calls.filter(([, signal]) => signal === 'SIGTERM').map(([pid]) => pid)
|
||||
).toEqual(expect.arrayContaining(leaves));
|
||||
for (const pid of leaves) {
|
||||
expect(() => realKill(pid, 0)).toThrow();
|
||||
}
|
||||
} finally {
|
||||
jest.restoreAllMocks();
|
||||
await fixture.cleanupAsync();
|
||||
}
|
||||
}, 10_000);
|
||||
@@ -0,0 +1,640 @@
|
||||
import { type ChildProcess, spawn } from 'node:child_process';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
jest.mock('node:child_process', () => ({ spawn: jest.fn() }));
|
||||
let commands: typeof import('../utils/processes').commands;
|
||||
let stopProcessAndDescendantsAsync: typeof import('../utils/processes').stopProcessAndDescendantsAsync;
|
||||
let terminateProcessGroupAsync: typeof import('../utils/processes').terminateProcessGroupAsync;
|
||||
let child: ChildProcess;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.isolateModules(() => {
|
||||
({
|
||||
commands,
|
||||
stopProcessAndDescendantsAsync,
|
||||
terminateProcessGroupAsync,
|
||||
} = require('../utils/processes'));
|
||||
});
|
||||
child = Object.assign(new EventEmitter(), {
|
||||
pid: 90001,
|
||||
exitCode: null,
|
||||
signalCode: null,
|
||||
stdout: new PassThrough(),
|
||||
kill: jest.fn().mockReturnValue(true),
|
||||
unref: jest.fn(),
|
||||
}) as unknown as ChildProcess;
|
||||
jest.mocked(spawn).mockReturnValue(child);
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('captures output and waits for close rather than wrapper exit', async () => {
|
||||
const job = commands.start('command', [], { captureStdout: true });
|
||||
let finished = false;
|
||||
const result = job.resultPromise.then(output => {
|
||||
finished = true;
|
||||
return output;
|
||||
});
|
||||
expect(child.listenerCount('close')).toBe(1);
|
||||
child.stdout!.emit('data', Buffer.from('output'));
|
||||
child.emit('exit', 0, null);
|
||||
await Promise.resolve();
|
||||
expect(finished).toBe(false);
|
||||
child.emit('close', 0, null);
|
||||
await expect(result).resolves.toBe('output');
|
||||
expect(() => commands.throwIfCommandCloseTimedOut()).not.toThrow();
|
||||
});
|
||||
|
||||
for (const code of ['ABORT_ERR', 'ENOENT']) {
|
||||
test(`preserves original ${code} error and observes close before awaiting`, async () => {
|
||||
const error = Object.assign(new Error('original failure'), { code });
|
||||
const job = commands.start('command', []);
|
||||
let finished = false;
|
||||
const result = job.resultPromise.catch(error => {
|
||||
finished = true;
|
||||
return error;
|
||||
});
|
||||
expect(child.listenerCount('close')).toBe(1);
|
||||
child.emit('error', error);
|
||||
await Promise.resolve();
|
||||
expect(finished).toBe(false);
|
||||
child.emit('close', null, null);
|
||||
await expect(result).resolves.toBe(error);
|
||||
});
|
||||
}
|
||||
|
||||
test('nonzero exits reject only after close and retain exit code', async () => {
|
||||
const job = commands.start('command', []);
|
||||
const result = job.resultPromise.catch(error => error);
|
||||
child.emit('exit', 7, null);
|
||||
child.emit('close', 7, null);
|
||||
expect((await result).code).toBe(7);
|
||||
});
|
||||
|
||||
for (const event of ['error', 'exit']) {
|
||||
test(`bounded close drain fails teardown even after late close: ${event}`, async () => {
|
||||
const original = new Error('original abort');
|
||||
const job = commands.start('command', []);
|
||||
const result = job.resultPromise.catch(error => error);
|
||||
if (event === 'error') {
|
||||
child.emit('error', original);
|
||||
} else {
|
||||
child.emit('exit', 0, null);
|
||||
}
|
||||
await jest.advanceTimersByTimeAsync(130_000);
|
||||
const failure = await result;
|
||||
if (event === 'error') {
|
||||
expect(failure).toBe(original);
|
||||
} else {
|
||||
expect(failure.message).toContain('did not finish closing');
|
||||
}
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
|
||||
expect(child.unref).toHaveBeenCalled();
|
||||
expect(() => commands.throwIfCommandCloseTimedOut()).toThrow('did not finish closing');
|
||||
child.emit('close', 0, null);
|
||||
expect(() => commands.throwIfCommandCloseTimedOut()).toThrow('did not finish closing');
|
||||
});
|
||||
}
|
||||
|
||||
test('descendants drain before their parent, then the owned child close is awaited', async () => {
|
||||
const alive = new Set([90001, 90002]);
|
||||
const signals: number[] = [];
|
||||
jest
|
||||
.mocked(child.kill)
|
||||
.mockImplementation(signal => process.kill(child.pid!, signal ?? 'SIGTERM'));
|
||||
jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (!alive.has(pid)) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ESRCH' });
|
||||
}
|
||||
if (signal === 'SIGTERM') {
|
||||
signals.push(pid);
|
||||
alive.delete(pid);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async (file, args) =>
|
||||
file === 'pgrep' && args[1] === '90001' ? '90002\n' : ''
|
||||
);
|
||||
let closed!: () => void;
|
||||
const resultPromise = new Promise<string>(resolve => {
|
||||
closed = () => resolve('');
|
||||
});
|
||||
let finished = false;
|
||||
const stopping = stopProcessAndDescendantsAsync({ child, resultPromise }).then(() => {
|
||||
finished = true;
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(signals).toEqual([90002, 90001]);
|
||||
expect(finished).toBe(false);
|
||||
closed();
|
||||
await stopping;
|
||||
expect(finished).toBe(true);
|
||||
});
|
||||
|
||||
test('an exited foreground wrapper is failed teardown; never inspect its possibly reused PID', async () => {
|
||||
Object.defineProperty(child, 'exitCode', { value: 0 });
|
||||
const kill = jest.spyOn(process, 'kill');
|
||||
await expect(
|
||||
stopProcessAndDescendantsAsync({ child, resultPromise: Promise.resolve('') })
|
||||
).rejects.toThrow('child processes');
|
||||
expect(kill).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
stopProcessAndDescendantsAsync({ child, resultPromise: Promise.resolve('') }, true)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('a descendant that ignores TERM prevents stop success at the deadline', async () => {
|
||||
jest.spyOn(process, 'kill').mockImplementation((_pid, signal) => {
|
||||
if (signal === 'SIGTERM') {
|
||||
Object.defineProperty(child, 'exitCode', { value: 0 });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async (file, args) =>
|
||||
file === 'pgrep' ? (args[1] === '90001' ? '90002' : '') : 'S'
|
||||
);
|
||||
const result = stopProcessAndDescendantsAsync({
|
||||
child,
|
||||
resultPromise: Promise.resolve(''),
|
||||
}).catch(error => error);
|
||||
await jest.advanceTimersByTimeAsync(130_100);
|
||||
expect((await result).message).toContain('90002 is still running');
|
||||
expect(jest.mocked(process.kill).mock.calls.filter(([, signal]) => signal === 'SIGTERM')).toEqual(
|
||||
[[90002, 'SIGTERM']]
|
||||
);
|
||||
});
|
||||
|
||||
test('ps failure cannot fabricate descendant teardown', async () => {
|
||||
jest.spyOn(process, 'kill').mockReturnValue(true);
|
||||
jest.spyOn(commands, 'runAsync').mockImplementation(async file => {
|
||||
if (file === 'pgrep') {
|
||||
return '';
|
||||
}
|
||||
throw new Error('ps failed');
|
||||
});
|
||||
await expect(
|
||||
stopProcessAndDescendantsAsync({ child, resultPromise: Promise.resolve('') })
|
||||
).rejects.toThrow('ps failed');
|
||||
});
|
||||
|
||||
test('normal wrapper exit during drain still drains every known child and awaits owned close without probing its reused PID', async () => {
|
||||
const job = commands.start('wrapper', []);
|
||||
const signals: number[] = [];
|
||||
jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (pid === child.pid) {
|
||||
throw new Error('Possibly reused wrapper PID must not be probed or signalled.');
|
||||
}
|
||||
if (signal === 'SIGTERM') {
|
||||
signals.push(pid);
|
||||
Object.defineProperty(child, 'exitCode', { value: 0 });
|
||||
child.emit('exit', 0, null);
|
||||
return true;
|
||||
}
|
||||
if (signals.includes(pid)) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ESRCH' });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async (file, args) =>
|
||||
file === 'pgrep' && args[1] === '90001' ? '90002\n90003' : ''
|
||||
);
|
||||
let finished = false;
|
||||
const stopping = stopProcessAndDescendantsAsync(job).then(() => {
|
||||
finished = true;
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(signals).toEqual([90002, 90003]);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
expect(finished).toBe(false);
|
||||
child.emit('close', 0, null);
|
||||
await stopping;
|
||||
expect(finished).toBe(true);
|
||||
});
|
||||
|
||||
test('wrapper exit before completed descendant discovery fails without inspecting discovered PIDs', async () => {
|
||||
const kill = jest.spyOn(process, 'kill');
|
||||
jest.spyOn(commands, 'runAsync').mockImplementation(async () => {
|
||||
Object.defineProperty(child, 'exitCode', { value: 0 });
|
||||
return '90002';
|
||||
});
|
||||
await expect(
|
||||
stopProcessAndDescendantsAsync({ child, resultPromise: Promise.resolve('') })
|
||||
).rejects.toThrow('before its child processes could be identified');
|
||||
expect(commands.runAsync).toHaveBeenCalledTimes(1);
|
||||
expect(kill).not.toHaveBeenCalled();
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ exitCode: 1, signalCode: null },
|
||||
{ exitCode: null, signalCode: 'SIGTERM' },
|
||||
])('unexpected wrapper exit during drain fails closed: %j', async exit => {
|
||||
const signals: number[] = [];
|
||||
jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (signal === 'SIGTERM') {
|
||||
signals.push(pid);
|
||||
Object.assign(child, exit);
|
||||
}
|
||||
if (!signal && signals.includes(pid)) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ESRCH' });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async (file, args) =>
|
||||
file === 'pgrep' && args[1] === '90001' ? '90002' : ''
|
||||
);
|
||||
await expect(
|
||||
stopProcessAndDescendantsAsync({ child, resultPromise: Promise.resolve('') })
|
||||
).rejects.toThrow('exited unexpectedly while its child processes were stopping');
|
||||
expect(signals).toEqual([90002]);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('normal wrapper exit after draining does not bypass a missing close timeout', async () => {
|
||||
const job = commands.start('wrapper', []);
|
||||
let descendantAlive = true;
|
||||
jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (pid !== 90002) {
|
||||
throw new Error('Possibly reused wrapper PID must not be probed or signalled.');
|
||||
}
|
||||
if (signal === 'SIGTERM') {
|
||||
descendantAlive = false;
|
||||
Object.defineProperty(child, 'exitCode', { value: 0 });
|
||||
child.emit('exit', 0, null);
|
||||
} else if (!descendantAlive) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ESRCH' });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async (file, args) =>
|
||||
file === 'pgrep' && args[1] === '90001' ? '90002' : ''
|
||||
);
|
||||
const stopping = stopProcessAndDescendantsAsync(job).catch(error => error);
|
||||
await jest.advanceTimersByTimeAsync(130_001);
|
||||
expect((await stopping).message).toContain('did not finish closing');
|
||||
child.emit('close', 0, null);
|
||||
expect(() => commands.throwIfCommandCloseTimedOut()).toThrow('did not finish closing');
|
||||
});
|
||||
|
||||
describe('terminateProcessGroupAsync', () => {
|
||||
/** Models the group led by 90001; `members` are its live processes. */
|
||||
function group(members: Set<number>, { ignoresTerm = false, survivesKill = false } = {}) {
|
||||
const signals: [number, string | number | undefined][] = [];
|
||||
jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (pid !== -90001) {
|
||||
throw new Error(`Only the process group may be signalled, not ${pid}.`);
|
||||
}
|
||||
if (members.size === 0) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ESRCH' });
|
||||
}
|
||||
if (signal) {
|
||||
signals.push([pid, signal]);
|
||||
}
|
||||
if ((signal === 'SIGTERM' && !ignoresTerm) || (signal === 'SIGKILL' && !survivesKill)) {
|
||||
members.clear();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return signals;
|
||||
}
|
||||
|
||||
it('signals the whole group once and waits for it to empty', async () => {
|
||||
const signals = group(new Set([90001, 90002, 90003]));
|
||||
await terminateProcessGroupAsync({ child, resultPromise: Promise.resolve('') });
|
||||
expect(signals).toEqual([[-90001, 'SIGTERM']]);
|
||||
});
|
||||
|
||||
it('kills a group that ignores TERM after the grace period', async () => {
|
||||
jest.useRealTimers();
|
||||
const signals = group(new Set([90001, 90004]), { ignoresTerm: true });
|
||||
await terminateProcessGroupAsync({ child, resultPromise: Promise.resolve('') }, 50);
|
||||
expect(signals).toEqual([
|
||||
[-90001, 'SIGTERM'],
|
||||
[-90001, 'SIGKILL'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('fails when the group survives SIGKILL', async () => {
|
||||
jest.useRealTimers();
|
||||
group(new Set([90001, 90004]), { ignoresTerm: true, survivesKill: true });
|
||||
const stopping = terminateProcessGroupAsync(
|
||||
{ child, resultPromise: Promise.resolve('') },
|
||||
50
|
||||
).catch(error => error);
|
||||
expect((await stopping).message).toContain('process group 90001 is still running');
|
||||
});
|
||||
|
||||
it('never signals a reused PID after its group has exited', async () => {
|
||||
jest.useRealTimers();
|
||||
const members = new Set([90001, 90002]);
|
||||
const signals = group(members);
|
||||
const groupKill = jest.mocked(process.kill).getMockImplementation()!;
|
||||
// After TERM empties the group, 90001's number belongs to an unrelated live process.
|
||||
jest
|
||||
.mocked(process.kill)
|
||||
.mockImplementation((pid, signal) => (pid === 90001 ? true : groupKill(pid, signal)));
|
||||
await terminateProcessGroupAsync({ child, resultPromise: Promise.resolve('') }, 50);
|
||||
expect(signals).toEqual([[-90001, 'SIGTERM']]);
|
||||
expect(jest.mocked(process.kill).mock.calls.some(([pid]) => pid > 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EPERM from exited processes awaiting reap', () => {
|
||||
const eperm = (): never => {
|
||||
throw Object.assign(new Error('kill EPERM'), { code: 'EPERM' });
|
||||
};
|
||||
|
||||
it('treats a group with only zombie members as stopped', async () => {
|
||||
jest.useRealTimers();
|
||||
let terminated = false;
|
||||
jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
expect(pid).toBe(-90001);
|
||||
if (signal === 'SIGTERM') {
|
||||
terminated = true;
|
||||
return true;
|
||||
}
|
||||
// After TERM, the only member left is the leader awaiting reap.
|
||||
return terminated ? eperm() : true;
|
||||
});
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async file => (file === 'ps' ? ' 90001 Z\n 412 Ss\n' : ''));
|
||||
await terminateProcessGroupAsync({ child, resultPromise: Promise.resolve('') }, 50);
|
||||
expect(
|
||||
jest.mocked(process.kill).mock.calls.filter(([, signal]) => signal === 'SIGKILL')
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('still fails closed when a live member cannot be signalled', async () => {
|
||||
jest.useRealTimers();
|
||||
jest.spyOn(process, 'kill').mockImplementation(() => eperm());
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async file => (file === 'ps' ? ' 90001 S\n' : ''));
|
||||
const stopping = terminateProcessGroupAsync(
|
||||
{ child, resultPromise: Promise.resolve('') },
|
||||
50
|
||||
).catch(error => error);
|
||||
expect((await stopping).message).toContain('process group 90001 is still running');
|
||||
});
|
||||
|
||||
it('lets the tree stop finish a descendant that exited before its signal', async () => {
|
||||
jest
|
||||
.spyOn(process, 'kill')
|
||||
.mockImplementation((pid, signal) =>
|
||||
pid === 90002 && signal === 'SIGTERM' ? eperm() : pid === 90002 ? eperm() : true
|
||||
);
|
||||
jest.mocked(child.kill).mockImplementation(() => {
|
||||
Object.defineProperty(child, 'exitCode', { value: 0 });
|
||||
return true;
|
||||
});
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async (file, args) =>
|
||||
file === 'pgrep' ? (args[1] === '90001' ? '90002\n' : '') : file === 'ps' ? 'Z\n' : ''
|
||||
);
|
||||
await expect(
|
||||
stopProcessAndDescendantsAsync({ child, resultPromise: Promise.resolve('') })
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('serve-sim leads its own process group', () => {
|
||||
const { startServeSim } = require('../utils/serveSim');
|
||||
startServeSim(['PHONE'], new AbortController().signal);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bunx',
|
||||
expect.arrayContaining(['PHONE', '--port', '3200']),
|
||||
expect.objectContaining({ detached: true })
|
||||
);
|
||||
});
|
||||
|
||||
test('the fixture PID guard keeps tests away from the real process.kill', () => {
|
||||
expect(() => process.kill(90002, 0)).toThrow('fixture PID 90002');
|
||||
expect(() => process.kill(-90001, 'SIGKILL')).toThrow('fixture PID -90001');
|
||||
expect(() => process.kill(11, 0)).toThrow('fixture PID 11');
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ exitCode: 23, signalCode: null },
|
||||
{ exitCode: 143, signalCode: null },
|
||||
{ exitCode: null, signalCode: 'SIGTERM' },
|
||||
])(
|
||||
'owned-group wrapper exit after intentional child stop still drains siblings and owned close: %j',
|
||||
async exit => {
|
||||
const job = commands.start('wrapper', [], { captureStdout: true, detached: true });
|
||||
const signalled = new Set<number>();
|
||||
jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (pid === -90001 || signalled.has(pid)) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ESRCH' });
|
||||
}
|
||||
if (pid === child.pid) {
|
||||
throw new Error('An exited wrapper PID must not be probed.');
|
||||
}
|
||||
if (signal === 'SIGTERM') {
|
||||
signalled.add(pid);
|
||||
Object.assign(child, exit);
|
||||
child.emit('exit', exit.exitCode, exit.signalCode);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async (file, args) =>
|
||||
file === 'pgrep' && args[1] === '90001' ? '90002 90003' : ''
|
||||
);
|
||||
let finished = false;
|
||||
const stopping = stopProcessAndDescendantsAsync(job).then(() => {
|
||||
finished = true;
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect([...signalled]).toEqual([90002, 90003]);
|
||||
expect(finished).toBe(false);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
child.emit('close', exit.exitCode, exit.signalCode);
|
||||
await stopping;
|
||||
expect(finished).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
test('an owned group cannot excuse wrapper exit before descendant discovery or intentional signals', async () => {
|
||||
const job = commands.start('wrapper', [], { captureStdout: true, detached: true });
|
||||
const kill = jest.spyOn(process, 'kill');
|
||||
jest.spyOn(commands, 'runAsync').mockImplementation(async () => {
|
||||
Object.assign(child, { exitCode: 143 });
|
||||
child.emit('exit', 143, null);
|
||||
return '90002';
|
||||
});
|
||||
await expect(stopProcessAndDescendantsAsync(job)).rejects.toThrow(
|
||||
'before its child processes could be identified'
|
||||
);
|
||||
expect(kill).not.toHaveBeenCalled();
|
||||
child.emit('close', 143, null);
|
||||
});
|
||||
|
||||
test.each([0, 143])(
|
||||
'wrapper exit %s before the first intentional signal remains a failure even for an owned group',
|
||||
async exitCode => {
|
||||
const job = commands.start('wrapper', [], { captureStdout: true, detached: true });
|
||||
let signalled = false;
|
||||
const kill = jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (pid === 90001 || pid === -90001) {
|
||||
throw new Error('Exited wrapper must not be probed or signalled.');
|
||||
}
|
||||
if (signal === 'SIGTERM') {
|
||||
signalled = true;
|
||||
return true;
|
||||
}
|
||||
if (signalled) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ESRCH' });
|
||||
}
|
||||
Object.assign(child, { exitCode });
|
||||
child.emit('exit', exitCode, null);
|
||||
return true;
|
||||
});
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async (file, args) =>
|
||||
file === 'pgrep' && args[1] === '90001' ? '90002' : ''
|
||||
);
|
||||
await expect(stopProcessAndDescendantsAsync(job)).rejects.toThrow('exited unexpectedly');
|
||||
expect(kill.mock.calls.filter(([, signal]) => signal === 'SIGTERM')).toEqual([
|
||||
[90002, 'SIGTERM'],
|
||||
]);
|
||||
child.emit('close', exitCode, null);
|
||||
}
|
||||
);
|
||||
|
||||
test('owned group retirement drains a child respawned outside the initial snapshot before close', async () => {
|
||||
const job = commands.start('wrapper', [], { captureStdout: true, detached: true });
|
||||
let oldChild = true;
|
||||
let respawn = false;
|
||||
const kill = jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (pid === 90001 || pid === 90003) {
|
||||
throw new Error('Do not probe an exited wrapper or guess a respawned PID.');
|
||||
}
|
||||
if (pid === -90001) {
|
||||
if (!respawn) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ESRCH' });
|
||||
}
|
||||
if (signal === 'SIGTERM') {
|
||||
respawn = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (!oldChild) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ESRCH' });
|
||||
}
|
||||
if (signal === 'SIGTERM') {
|
||||
oldChild = false;
|
||||
respawn = true;
|
||||
Object.assign(child, { exitCode: 143 });
|
||||
child.emit('exit', 143, null);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async (file, args) =>
|
||||
file === 'pgrep' && args[1] === '90001' ? '90002' : ''
|
||||
);
|
||||
let finished = false;
|
||||
const stopping = stopProcessAndDescendantsAsync(job).then(() => {
|
||||
finished = true;
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(respawn).toBe(false);
|
||||
expect(finished).toBe(false);
|
||||
expect(kill.mock.calls.filter(([, signal]) => signal === 'SIGTERM')).toEqual([
|
||||
[90002, 'SIGTERM'],
|
||||
[-90001, 'SIGTERM'],
|
||||
]);
|
||||
child.emit('close', 143, null);
|
||||
await stopping;
|
||||
});
|
||||
|
||||
test('expected owned wrapper termination cannot bypass the sticky close timeout', async () => {
|
||||
const job = commands.start('wrapper', [], { captureStdout: true, detached: true });
|
||||
let stopped = false;
|
||||
jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (pid === -90001 || stopped) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ESRCH' });
|
||||
}
|
||||
if (pid !== 90002) {
|
||||
throw new Error('The exited wrapper must not be probed.');
|
||||
}
|
||||
if (signal === 'SIGTERM') {
|
||||
stopped = true;
|
||||
Object.assign(child, { exitCode: 143 });
|
||||
child.emit('exit', 143, null);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async (file, args) =>
|
||||
file === 'pgrep' && args[1] === '90001' ? '90002' : ''
|
||||
);
|
||||
const stopping = stopProcessAndDescendantsAsync(job).catch(error => error);
|
||||
await jest.advanceTimersByTimeAsync(130_001);
|
||||
expect((await stopping).message).toContain('did not finish closing');
|
||||
child.emit('close', 143, null);
|
||||
expect(() => commands.throwIfCommandCloseTimedOut()).toThrow('did not finish closing');
|
||||
});
|
||||
|
||||
test('tries every known sibling before reporting a descendant drain failure', async () => {
|
||||
const failure = new Error('first child could not be verified');
|
||||
const signalled = new Set<number>();
|
||||
jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (signal === 'SIGTERM') {
|
||||
signalled.add(pid);
|
||||
return true;
|
||||
}
|
||||
if (pid === 90003 && signalled.has(pid)) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ESRCH' });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
jest.spyOn(commands, 'runAsync').mockImplementation(async (file, args) => {
|
||||
if (file === 'pgrep') {
|
||||
return args[1] === '90001' ? '90002 90003' : '';
|
||||
}
|
||||
if (args[1] === '90002') {
|
||||
throw failure;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
await expect(
|
||||
stopProcessAndDescendantsAsync({ child, resultPromise: Promise.resolve('') })
|
||||
).rejects.toBe(failure);
|
||||
expect([...signalled]).toEqual([90002, 90003]);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('rejects a process-group ID that does not belong to its owned child before any native probe', async () => {
|
||||
const kill = jest.spyOn(process, 'kill');
|
||||
const run = jest.spyOn(commands, 'runAsync');
|
||||
await expect(
|
||||
stopProcessAndDescendantsAsync({
|
||||
child,
|
||||
resultPromise: Promise.resolve(''),
|
||||
processGroupId: 90002,
|
||||
})
|
||||
).rejects.toThrow('does not belong');
|
||||
expect(kill).not.toHaveBeenCalled();
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { type ChildProcess } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
import {
|
||||
type ChildProcessHandle,
|
||||
commands,
|
||||
isProcessOwnedByServiceAsync,
|
||||
} from '../utils/processes';
|
||||
import { waitForServeSimReadyAsync } from '../utils/serveSim';
|
||||
import { deferred, until } from './helpers';
|
||||
|
||||
jest.mock('node:fs/promises');
|
||||
jest.mock('../utils/processes');
|
||||
let child: ChildProcess;
|
||||
let server: ChildProcessHandle;
|
||||
let controller: AbortController;
|
||||
const state = {
|
||||
pid: 90002,
|
||||
port: 3202,
|
||||
device: 'PHONE',
|
||||
url: 'http://127.0.0.1:3202',
|
||||
token: 'fixture-token',
|
||||
};
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(commands.runAsync).mockResolvedValue('native-start');
|
||||
child = { pid: 90001, exitCode: null, signalCode: null } as ChildProcess;
|
||||
server = { child, resultPromise: new Promise<string>(() => {}) };
|
||||
controller = new AbortController();
|
||||
jest.mocked(fs.readFile).mockResolvedValue(JSON.stringify(state));
|
||||
jest.mocked(isProcessOwnedByServiceAsync).mockResolvedValue(true);
|
||||
jest
|
||||
.spyOn(global, 'fetch')
|
||||
.mockImplementation(
|
||||
async () => new Response(JSON.stringify({ status: 'ready', device: 'PHONE' }))
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
controller.abort();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('ignores a matching old server until the new launch owns the registered process', async () => {
|
||||
jest.mocked(isProcessOwnedByServiceAsync).mockResolvedValue(false);
|
||||
let ready = false;
|
||||
const waiting = waitForServeSimReadyAsync('PHONE', server, controller.signal, 3202).then(() => {
|
||||
ready = true;
|
||||
});
|
||||
await until(() => jest.mocked(fs.readFile).mock.calls.length > 0);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(ready).toBe(false);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
jest.mocked(isProcessOwnedByServiceAsync).mockResolvedValue(true);
|
||||
await waiting;
|
||||
expect(fetch).toHaveBeenCalledWith('http://127.0.0.1:3202/readyz', {
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects the launch exiting during a successful readiness response without waiting for close', async () => {
|
||||
const response = deferred<Response>();
|
||||
jest.mocked(fetch).mockReturnValue(response.promise);
|
||||
const waiting = waitForServeSimReadyAsync('PHONE', server, controller.signal, 3202);
|
||||
await until(() => jest.mocked(fetch).mock.calls.length > 0);
|
||||
Object.assign(child, { exitCode: 1 });
|
||||
response.resolve(new Response(JSON.stringify({ status: 'ready', device: 'PHONE' })));
|
||||
await expect(waiting).rejects.toThrow('serve-sim exited before PHONE became ready');
|
||||
});
|
||||
|
||||
it('does not probe a state file without a process identity', async () => {
|
||||
jest.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ ...state, pid: undefined }));
|
||||
const waiting = waitForServeSimReadyAsync('PHONE', server, controller.signal, 3202).catch(
|
||||
error => error
|
||||
);
|
||||
await until(() => jest.mocked(fs.readFile).mock.calls.length > 0);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(isProcessOwnedByServiceAsync).not.toHaveBeenCalled();
|
||||
controller.abort();
|
||||
expect((await waiting).code).toBe('ABORT_ERR');
|
||||
});
|
||||
|
||||
it('cannot report ready after cancellation during the response', async () => {
|
||||
const response = deferred<Response>();
|
||||
jest.mocked(fetch).mockReturnValue(response.promise);
|
||||
const waiting = waitForServeSimReadyAsync('PHONE', server, controller.signal, 3202);
|
||||
await until(() => jest.mocked(fetch).mock.calls.length > 0);
|
||||
controller.abort(new Error('cancelled'));
|
||||
response.resolve(new Response(JSON.stringify({ status: 'ready', device: 'PHONE' })));
|
||||
await expect(waiting).rejects.toThrow('cancelled');
|
||||
});
|
||||
|
||||
it('times out rather than accepting a server that never becomes ready', async () => {
|
||||
const now = Date.now();
|
||||
jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockReturnValueOnce(now)
|
||||
.mockReturnValue(now + 120_000);
|
||||
await expect(waitForServeSimReadyAsync('PHONE', server, controller.signal, 3202)).rejects.toThrow(
|
||||
'Timed out waiting for serve-sim on PHONE'
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries HTTP errors, a refused connection and readiness for another device', async () => {
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(new Response('unavailable', { status: 503 }))
|
||||
.mockRejectedValueOnce(new Error('connection refused'))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ status: 'ready', device: 'OTHER' })))
|
||||
.mockImplementation(
|
||||
async () => new Response(JSON.stringify({ status: 'ready', device: 'PHONE' }))
|
||||
);
|
||||
await waitForServeSimReadyAsync('PHONE', server, controller.signal, 3202);
|
||||
expect(fetch).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { type ChildProcess } from 'node:child_process';
|
||||
|
||||
import { commands, isProcessOwnedByServiceAsync } from '../utils/processes';
|
||||
import { deferred } from './helpers';
|
||||
|
||||
let child: ChildProcess;
|
||||
let controller: AbortController;
|
||||
beforeEach(() => {
|
||||
child = { pid: 90001, exitCode: null, signalCode: null } as ChildProcess;
|
||||
controller = new AbortController();
|
||||
jest.spyOn(commands, 'runAsync');
|
||||
});
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('accepts its live owned root without probing a PID', async () => {
|
||||
await expect(isProcessOwnedByServiceAsync(90001, child, controller.signal)).resolves.toBe(true);
|
||||
expect(commands.runAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
it('walks a descendant to its owned root and rejects a foreign tree', async () => {
|
||||
jest.mocked(commands.runAsync).mockResolvedValueOnce('90003').mockResolvedValueOnce('90001');
|
||||
await expect(isProcessOwnedByServiceAsync(90002, child, controller.signal)).resolves.toBe(true);
|
||||
expect(commands.runAsync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'ps',
|
||||
['-p', '90002', '-o', 'ppid='],
|
||||
controller.signal
|
||||
);
|
||||
jest.mocked(commands.runAsync).mockResolvedValue('1');
|
||||
await expect(isProcessOwnedByServiceAsync(90004, child, controller.signal)).resolves.toBe(false);
|
||||
});
|
||||
it('rejects a root that exits during ancestry discovery without probing its reused PID', async () => {
|
||||
const parent = deferred<string>();
|
||||
jest.mocked(commands.runAsync).mockReturnValue(parent.promise);
|
||||
const owned = isProcessOwnedByServiceAsync(90002, child, controller.signal);
|
||||
Object.assign(child, { exitCode: 1 });
|
||||
parent.resolve('90001');
|
||||
await expect(owned).resolves.toBe(false);
|
||||
expect(commands.runAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('stops at a cycle, a gone process, or cancellation', async () => {
|
||||
jest.mocked(commands.runAsync).mockResolvedValue('90002');
|
||||
await expect(isProcessOwnedByServiceAsync(90002, child, controller.signal)).resolves.toBe(false);
|
||||
jest.mocked(commands.runAsync).mockRejectedValue(Object.assign(new Error('gone'), { code: 1 }));
|
||||
await expect(isProcessOwnedByServiceAsync(90002, child, controller.signal)).resolves.toBe(false);
|
||||
controller.abort(new Error('cancelled'));
|
||||
await expect(isProcessOwnedByServiceAsync(90001, child, controller.signal)).rejects.toThrow(
|
||||
'cancelled'
|
||||
);
|
||||
});
|
||||
it('preserves unexpected process-query failures', async () => {
|
||||
jest.mocked(commands.runAsync).mockRejectedValue(new Error('ps failed'));
|
||||
await expect(isProcessOwnedByServiceAsync(90002, child, controller.signal)).rejects.toThrow(
|
||||
'ps failed'
|
||||
);
|
||||
});
|
||||
|
||||
it('bounds ancestry discovery even when every parent exists but none belongs to this service', async () => {
|
||||
let parent = 90100;
|
||||
jest.mocked(commands.runAsync).mockImplementation(async () => String(parent++));
|
||||
await expect(isProcessOwnedByServiceAsync(90002, child, controller.signal)).resolves.toBe(false);
|
||||
expect(commands.runAsync).toHaveBeenCalledTimes(32);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { selectWarmupSimulators } from '../utils/simulator';
|
||||
|
||||
const devices = [
|
||||
{ udid: 'DUO', name: 'iPhone Duo' },
|
||||
{ udid: 'PHONE18', name: 'iPhone 18' },
|
||||
{ udid: 'PRO18', name: 'iPhone 18 Pro' },
|
||||
];
|
||||
|
||||
test('keeps the shortest available iPhone as the default when unset', () => {
|
||||
expect(selectWarmupSimulators(devices)).toEqual([devices[1]]);
|
||||
});
|
||||
test('splits and trims a list, preserving its default and accepting case-insensitive UDIDs', () => {
|
||||
expect(selectWarmupSimulators(devices, ' iPhone 18 , duo ')).toEqual([devices[1], devices[0]]);
|
||||
});
|
||||
test.each(['iPhone Duo,', ',iPhone 18', 'iPhone Duo, ,iPhone 18'])(
|
||||
'rejects an empty entry in %j',
|
||||
selectors => {
|
||||
expect(() => selectWarmupSimulators(devices, selectors)).toThrow('nonempty');
|
||||
}
|
||||
);
|
||||
test('rejects a missing or ambiguous device instead of choosing arbitrarily', () => {
|
||||
expect(() => selectWarmupSimulators(devices, 'missing')).toThrow('found 0');
|
||||
expect(() =>
|
||||
selectWarmupSimulators([...devices, { udid: 'OTHER', name: 'iPhone Duo' }], 'iPhone Duo')
|
||||
).toThrow('found 2');
|
||||
});
|
||||
test('rejects two selectors for the same device', () => {
|
||||
expect(() => selectWarmupSimulators(devices, 'iPhone Duo,duo')).toThrow('more than once');
|
||||
});
|
||||
test.each(['', ' ', '\n'])('keeps the default iPhone for blank configuration %j', selectors => {
|
||||
expect(selectWarmupSimulators(devices, selectors)).toEqual([devices[1]]);
|
||||
});
|
||||
|
||||
test('observes an orphan-owned boot without requesting a new boot', async () => {
|
||||
const { commands } = await import('../utils/processes');
|
||||
const { waitForSimulatorBootAsync } = await import('../utils/simulator');
|
||||
const run = jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async (_file, args) =>
|
||||
args.includes('devices')
|
||||
? JSON.stringify({ devices: { ios: [{ udid: 'DUO', state: 'Booted' }] } })
|
||||
: ''
|
||||
);
|
||||
try {
|
||||
await waitForSimulatorBootAsync('DUO', new AbortController().signal);
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'bootstatus', 'DUO'],
|
||||
expect.any(AbortSignal)
|
||||
);
|
||||
expect(run.mock.calls.some(([, args]) => args.includes('-b') || args.includes('boot'))).toBe(
|
||||
false
|
||||
);
|
||||
} finally {
|
||||
run.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a claim can cancel boot observation without issuing a native command', async () => {
|
||||
const { commands } = await import('../utils/processes');
|
||||
const { waitForSimulatorBootAsync } = await import('../utils/simulator');
|
||||
const run = jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockRejectedValue(new Error('unexpected native command'));
|
||||
const cancel = new AbortController();
|
||||
cancel.abort(new Error('claimed'));
|
||||
try {
|
||||
await expect(waitForSimulatorBootAsync('DUO', cancel.signal)).rejects.toThrow('claimed');
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
run.mockRestore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,556 @@
|
||||
import { type ChildProcess } from 'node:child_process';
|
||||
|
||||
import { createWarmSimulatorSession } from '../commands/start';
|
||||
import { createArgentTool } from '../utils/argent';
|
||||
import { startExpoGoPreinstall } from '../utils/expoGo';
|
||||
import {
|
||||
type ChildProcessHandle,
|
||||
commands,
|
||||
stopProcessAndDescendantsAsync,
|
||||
} from '../utils/processes';
|
||||
import { startServeSim, waitForServeSimReadyAsync } from '../utils/serveSim';
|
||||
import {
|
||||
disableApsdAsync,
|
||||
shutdownSimulatorAsync,
|
||||
waitForSimulatorBootAsync,
|
||||
} from '../utils/simulator';
|
||||
import { deferred, until } from './helpers';
|
||||
|
||||
jest.mock('../utils/expoGo');
|
||||
jest.mock('../utils/argent');
|
||||
jest.mock('../utils/processes');
|
||||
jest.mock('../utils/serveSim');
|
||||
jest.mock('../utils/simulator', () => ({
|
||||
...jest.requireActual('../utils/simulator'),
|
||||
shutdownSimulatorAsync: jest.fn(),
|
||||
disableApsdAsync: jest.fn(),
|
||||
waitForSimulatorBootAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const defaultDevice = { udid: 'DUO', name: 'iPhone Duo' };
|
||||
const secondary = { udid: 'PHONE18', name: 'iPhone 18' };
|
||||
const otherDevice = { udid: 'OTHER', name: 'iPhone Pro' };
|
||||
const serverInfo = {
|
||||
startupDevice: 'DUO',
|
||||
pid: 10,
|
||||
nativeStartTime: 'server-start',
|
||||
port: 3200,
|
||||
url: 'http://127.0.0.1:3200',
|
||||
token: 'fixture-token',
|
||||
};
|
||||
function handle(pid: number): ChildProcessHandle {
|
||||
return { child: { pid } as ChildProcess, resultPromise: Promise.resolve('') };
|
||||
}
|
||||
const server = handle(10);
|
||||
const agent = handle(11);
|
||||
const argent = handle(12);
|
||||
let controller: AbortController;
|
||||
let env: NodeJS.ProcessEnv;
|
||||
let onPreparationFailure: jest.Mock;
|
||||
const starts = (word: string) =>
|
||||
jest.mocked(commands.start).mock.calls.filter(([, args]) => args.includes(word));
|
||||
function createSession() {
|
||||
return createWarmSimulatorSession({
|
||||
signal: controller.signal,
|
||||
throwIfStoppingAsync: async () => controller.signal.throwIfAborted(),
|
||||
onPreparationFailure,
|
||||
});
|
||||
}
|
||||
|
||||
/** The default device's Expo Go preinstall finishes with `install`; later devices finish at once. */
|
||||
function mockDefaultExpoGoInstall(install: Promise<void>) {
|
||||
const preinstall = {
|
||||
preinstallOnDeviceAsync: jest.fn().mockReturnValueOnce(install).mockResolvedValue(undefined),
|
||||
claimAsync: jest.fn(async () => undefined),
|
||||
stopAsync: jest.fn(async () => {}),
|
||||
};
|
||||
jest.mocked(startExpoGoPreinstall).mockReturnValue(preinstall);
|
||||
return preinstall;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
env = { ...process.env };
|
||||
process.env.EXPO_SIMULATOR_DEVICE = 'iPhone Duo,iPhone 18';
|
||||
process.env.AGENT_DEVICE_STATE_DIR = '/fixture/agent';
|
||||
process.env.ARGENT_EVENT_LOG = '/fixture/argent-events';
|
||||
controller = new AbortController();
|
||||
onPreparationFailure = jest.fn(error => controller.abort(error));
|
||||
jest.mocked(commands.runAsync).mockImplementation(async (_file, args) => {
|
||||
if (args.includes('devices')) {
|
||||
return JSON.stringify({ devices: { ios: [defaultDevice, secondary, otherDevice] } });
|
||||
}
|
||||
if (args.includes('launchctl') && args.includes('list')) {
|
||||
throw Object.assign(new Error('not loaded'), { code: 113 });
|
||||
}
|
||||
return 'controller-start';
|
||||
});
|
||||
jest
|
||||
.mocked(commands.start)
|
||||
.mockImplementation((_file, args) => (args.includes('prepare') ? agent : argent));
|
||||
jest.mocked(createArgentTool).mockImplementation(() => ({
|
||||
name: 'argent',
|
||||
startAsync: async () => {
|
||||
commands.start('bunx', ['argent', 'server']);
|
||||
},
|
||||
stopAsync: async () => {
|
||||
await stopProcessAndDescendantsAsync(argent);
|
||||
},
|
||||
}));
|
||||
jest.mocked(startServeSim).mockReturnValue(server);
|
||||
jest.mocked(waitForServeSimReadyAsync).mockResolvedValue(serverInfo);
|
||||
jest.mocked(waitForSimulatorBootAsync).mockResolvedValue();
|
||||
jest.mocked(stopProcessAndDescendantsAsync).mockResolvedValue();
|
||||
jest.mocked(shutdownSimulatorAsync).mockResolvedValue();
|
||||
jest
|
||||
.mocked(disableApsdAsync)
|
||||
.mockImplementation(jest.requireActual('../utils/simulator').disableApsdAsync);
|
||||
jest.mocked(startExpoGoPreinstall).mockReturnValue({
|
||||
preinstallOnDeviceAsync: jest.fn(async () => {}),
|
||||
claimAsync: jest.fn(async () => undefined),
|
||||
stopAsync: jest.fn(async () => {}),
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
process.env = env;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('creates a stoppable owner before any native work and does not start after stop', async () => {
|
||||
const session = createSession();
|
||||
expect(commands.runAsync).not.toHaveBeenCalled();
|
||||
await session.stopAsync();
|
||||
await expect(session.prepareAsync()).rejects.toThrow();
|
||||
expect(startServeSim).not.toHaveBeenCalled();
|
||||
expect(shutdownSimulatorAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stop joins pending device selection without starting a late server', async () => {
|
||||
const listing = deferred<string>();
|
||||
jest.mocked(commands.runAsync).mockReturnValueOnce(listing.promise);
|
||||
const session = createSession();
|
||||
const preparing = session.prepareAsync();
|
||||
void preparing.catch(() => {});
|
||||
let stopped = false;
|
||||
const stopping = session.stopAsync().then(() => {
|
||||
stopped = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(stopped).toBe(false);
|
||||
listing.resolve(JSON.stringify({ devices: { ios: [defaultDevice] } }));
|
||||
await stopping;
|
||||
await expect(preparing).rejects.toThrow();
|
||||
expect(startServeSim).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts one orphan with all configured devices and keeps its identity separate from selection', async () => {
|
||||
const session = createSession();
|
||||
const info = await session.prepareAsync();
|
||||
expect(startServeSim).toHaveBeenCalledWith(['DUO', 'PHONE18'], expect.any(AbortSignal));
|
||||
expect(info).toMatchObject({
|
||||
device: defaultDevice,
|
||||
server: serverInfo,
|
||||
controllerPid: process.pid,
|
||||
controllerStartTime: 'controller-start',
|
||||
});
|
||||
const selected = await session.claimAsync({ engine: 'serve-sim', requestedDevice: 'OTHER' });
|
||||
expect(selected.device).toEqual(otherDevice);
|
||||
expect(startServeSim).toHaveBeenCalledTimes(1);
|
||||
expect(commands.runAsync).not.toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
expect.arrayContaining(['bootstatus']),
|
||||
expect.anything()
|
||||
);
|
||||
await session.stopAsync();
|
||||
expect(shutdownSimulatorAsync).toHaveBeenCalledWith('DUO');
|
||||
expect(shutdownSimulatorAsync).toHaveBeenCalledWith('PHONE18');
|
||||
expect(shutdownSimulatorAsync).toHaveBeenCalledWith('OTHER');
|
||||
});
|
||||
|
||||
it('publishes default readiness without joining Expo Go or requested-tool startup', async () => {
|
||||
const install = deferred();
|
||||
const preparing = deferred<string>();
|
||||
mockDefaultExpoGoInstall(install.promise);
|
||||
jest
|
||||
.mocked(commands.start)
|
||||
.mockImplementation((_file, args) =>
|
||||
args.includes('prepare') ? { ...agent, resultPromise: preparing.promise } : argent
|
||||
);
|
||||
const session = createSession();
|
||||
try {
|
||||
await session.prepareAsync();
|
||||
await expect(session.claimAsync({ engine: 'agent-device' })).resolves.toMatchObject({
|
||||
device: defaultDevice,
|
||||
});
|
||||
expect(starts('prepare')).toHaveLength(1);
|
||||
} finally {
|
||||
preparing.resolve('');
|
||||
install.resolve();
|
||||
await session.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('primes the exact version once before readiness', async () => {
|
||||
const priming = deferred<string>();
|
||||
const run = jest.mocked(commands.runAsync).getMockImplementation()!;
|
||||
jest
|
||||
.mocked(commands.runAsync)
|
||||
.mockImplementation((file, args, signal) =>
|
||||
file === 'bun' ? priming.promise : run(file, args, signal)
|
||||
);
|
||||
const session = createSession();
|
||||
let ready = false;
|
||||
const preparing = session.prepareAsync().then(() => {
|
||||
ready = true;
|
||||
});
|
||||
await until(() => jest.mocked(commands.runAsync).mock.calls.some(([file]) => file === 'bun'));
|
||||
expect(ready).toBe(false);
|
||||
expect(jest.mocked(commands.runAsync).mock.calls.filter(([file]) => file === 'bun')).toHaveLength(
|
||||
1
|
||||
);
|
||||
priming.resolve('25.3.0');
|
||||
await preparing;
|
||||
await session.stopAsync();
|
||||
});
|
||||
|
||||
it('consumed claim forbids the secondary preinstall of a device still booting', async () => {
|
||||
const preinstall = mockDefaultExpoGoInstall(Promise.resolve());
|
||||
jest.mocked(waitForSimulatorBootAsync).mockImplementation(async (udid, signal) => {
|
||||
if (udid === 'PHONE18') {
|
||||
await new Promise((_resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
|
||||
);
|
||||
}
|
||||
});
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
await until(() => jest.mocked(waitForSimulatorBootAsync).mock.calls.length === 1);
|
||||
await session.claimAsync({ engine: 'serve-sim' });
|
||||
await session.stopAsync();
|
||||
expect(preinstall.preinstallOnDeviceAsync).not.toHaveBeenCalledWith(
|
||||
'PHONE18',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('a secondary boot observation failure leaves the claimed default usable', async () => {
|
||||
jest
|
||||
.mocked(waitForSimulatorBootAsync)
|
||||
.mockImplementation(async udid =>
|
||||
udid === 'PHONE18' ? Promise.reject(new Error('optional boot failed')) : undefined
|
||||
);
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
// serve-sim's ready covers the default device's boot; only the second device is checked.
|
||||
await until(() => jest.mocked(waitForSimulatorBootAsync).mock.calls.length === 1);
|
||||
expect(waitForSimulatorBootAsync).toHaveBeenCalledWith('PHONE18', expect.any(AbortSignal));
|
||||
await expect(session.claimAsync({ engine: 'serve-sim' })).resolves.toMatchObject({
|
||||
device: defaultDevice,
|
||||
});
|
||||
expect(onPreparationFailure).not.toHaveBeenCalled();
|
||||
await session.stopAsync();
|
||||
});
|
||||
|
||||
it('claim latches preinstalls before async selection; prepare-tools waits for the selected install', async () => {
|
||||
const installation = deferred<undefined>();
|
||||
const preinstall = startExpoGoPreinstall(controller.signal);
|
||||
jest
|
||||
.mocked(preinstall.claimAsync)
|
||||
.mockImplementation(udid =>
|
||||
udid === 'PHONE18' ? installation.promise : Promise.resolve(undefined)
|
||||
);
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
const claiming = session.claimAsync({ engine: 'serve-sim', requestedDevice: 'iPhone 18' });
|
||||
expect(preinstall.claimAsync).toHaveBeenCalledWith('DUO');
|
||||
expect(preinstall.claimAsync).toHaveBeenCalledWith('PHONE18');
|
||||
expect((await claiming).device).toEqual(secondary);
|
||||
let prepared = false;
|
||||
const preparing = session.prepareToolsAsync().then(() => {
|
||||
prepared = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(prepared).toBe(false);
|
||||
installation.resolve(undefined);
|
||||
await preparing;
|
||||
await session.stopAsync();
|
||||
});
|
||||
|
||||
it('unused preinstall failure does not reject a default claim, but terminal stop retains uncertainty', async () => {
|
||||
const failure = new Error('unused install not drained');
|
||||
const preinstall = startExpoGoPreinstall(controller.signal);
|
||||
jest.mocked(preinstall.claimAsync).mockImplementation(async udid => {
|
||||
if (udid === 'PHONE18') {
|
||||
throw failure;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
jest.mocked(preinstall.stopAsync).mockImplementation(async udid => {
|
||||
if (udid === 'PHONE18') {
|
||||
throw failure;
|
||||
}
|
||||
});
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
await expect(session.claimAsync({ engine: 'serve-sim' })).resolves.toMatchObject({
|
||||
device: defaultDevice,
|
||||
});
|
||||
await expect(session.stopAsync()).rejects.toMatchObject({ errors: [failure] });
|
||||
expect(shutdownSimulatorAsync).toHaveBeenCalledWith('DUO');
|
||||
});
|
||||
|
||||
it('EXPO_SIMULATOR_PREINSTALL_EXPO_GO=0 skips the Expo Go preinstall', async () => {
|
||||
process.env.EXPO_SIMULATOR_PREINSTALL_EXPO_GO = '0';
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
await session.claimAsync({ engine: 'serve-sim' });
|
||||
await expect(session.prepareToolsAsync()).resolves.toEqual({});
|
||||
expect(startExpoGoPreinstall).not.toHaveBeenCalled();
|
||||
await session.stopAsync();
|
||||
});
|
||||
|
||||
it('retains selected preinstall failure instead of handing over an uncertain install', async () => {
|
||||
const failure = new Error('selected install failed');
|
||||
const preinstall = startExpoGoPreinstall(controller.signal);
|
||||
jest.mocked(preinstall.claimAsync).mockRejectedValue(failure);
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
await session.claimAsync({ engine: 'serve-sim' });
|
||||
await expect(session.prepareToolsAsync()).rejects.toBe(failure);
|
||||
await session.stopAsync();
|
||||
});
|
||||
|
||||
it('reuses the default runner after adoption without preparing it twice', async () => {
|
||||
const preparation = deferred<string>();
|
||||
jest
|
||||
.mocked(commands.start)
|
||||
.mockImplementation((_file, args) =>
|
||||
args.includes('prepare') ? { ...agent, resultPromise: preparation.promise } : argent
|
||||
);
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
await session.claimAsync({ engine: 'agent-device' });
|
||||
let ready = false;
|
||||
const tools = session.prepareToolsAsync().then(() => {
|
||||
ready = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(ready).toBe(false);
|
||||
preparation.resolve('');
|
||||
await tools;
|
||||
expect(starts('prepare')).toHaveLength(1);
|
||||
await session.stopAsync();
|
||||
});
|
||||
|
||||
it('starts the selected runner only in the post-adopt phase, after old runner retirement', async () => {
|
||||
const retirement = deferred();
|
||||
jest.mocked(stopProcessAndDescendantsAsync).mockImplementation(async handle => {
|
||||
if (handle === agent) {
|
||||
await retirement.promise;
|
||||
}
|
||||
});
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
await session.claimAsync({ engine: 'agent-device', requestedDevice: 'PHONE18' });
|
||||
expect(starts('prepare')).toHaveLength(1);
|
||||
let finished = false;
|
||||
const tools = session.prepareToolsAsync().then(() => {
|
||||
finished = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(finished).toBe(false);
|
||||
expect(starts('prepare')).toHaveLength(1);
|
||||
retirement.resolve();
|
||||
await tools;
|
||||
expect(starts('prepare')[1][1]).toEqual(expect.arrayContaining(['--udid', 'PHONE18']));
|
||||
await session.stopAsync();
|
||||
});
|
||||
|
||||
it('keeps global Argent when selecting another device', async () => {
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
await session.claimAsync({ engine: 'argent', requestedDevice: 'PHONE18' });
|
||||
await session.prepareToolsAsync();
|
||||
expect(starts('server')).toHaveLength(1);
|
||||
expect(stopProcessAndDescendantsAsync).not.toHaveBeenCalledWith(argent);
|
||||
await session.stopAsync();
|
||||
});
|
||||
|
||||
it('rejects a changed server identity before starting the selected runner', async () => {
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
await session.claimAsync({ engine: 'agent-device', requestedDevice: 'PHONE18' });
|
||||
jest.mocked(waitForServeSimReadyAsync).mockResolvedValueOnce({ ...serverInfo, pid: 99 });
|
||||
await expect(session.prepareToolsAsync()).rejects.toThrow('does not match');
|
||||
expect(starts('prepare')).toHaveLength(1);
|
||||
await session.stopAsync();
|
||||
});
|
||||
|
||||
it('drains partial preparation, all shutdowns and sibling failures before rejecting cleanup', async () => {
|
||||
const readyFailure = new Error('server not ready');
|
||||
jest.mocked(waitForServeSimReadyAsync).mockRejectedValue(readyFailure);
|
||||
const failure = new Error('server stop failed');
|
||||
jest.mocked(stopProcessAndDescendantsAsync).mockImplementation(async handle => {
|
||||
if (handle === server) {
|
||||
throw failure;
|
||||
}
|
||||
});
|
||||
const delayed = deferred();
|
||||
jest.mocked(shutdownSimulatorAsync).mockImplementation(async udid => {
|
||||
if (udid === 'PHONE18') {
|
||||
await delayed.promise;
|
||||
}
|
||||
});
|
||||
const session = createSession();
|
||||
await expect(session.prepareAsync()).rejects.toBe(readyFailure);
|
||||
let finished = false;
|
||||
const stopping = session.stopAsync().finally(() => {
|
||||
finished = true;
|
||||
});
|
||||
void stopping.catch(() => {});
|
||||
await until(() => jest.mocked(shutdownSimulatorAsync).mock.calls.length === 2);
|
||||
expect(finished).toBe(false);
|
||||
delayed.resolve();
|
||||
await expect(stopping).rejects.toMatchObject({ errors: [failure] });
|
||||
});
|
||||
|
||||
it('serve-sim tool preparation does not gate on unused tool stops, but terminal cleanup drains them', async () => {
|
||||
const retirement = deferred();
|
||||
jest.mocked(stopProcessAndDescendantsAsync).mockImplementation(async handle => {
|
||||
if (handle === agent) {
|
||||
await retirement.promise;
|
||||
}
|
||||
});
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
await session.claimAsync({ engine: 'serve-sim' });
|
||||
await session.prepareToolsAsync();
|
||||
let stopped = false;
|
||||
const stopping = session.stopAsync().then(() => {
|
||||
stopped = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(stopped).toBe(false);
|
||||
retirement.resolve();
|
||||
await stopping;
|
||||
});
|
||||
|
||||
it('a failed unused-tool stop after the claim keeps the session and the stop retries it', async () => {
|
||||
const failure = new Error('Argent is still running');
|
||||
const stop = jest.mocked(stopProcessAndDescendantsAsync).mockImplementation(async handle => {
|
||||
if (handle === argent) {
|
||||
throw failure;
|
||||
}
|
||||
});
|
||||
const warning = jest.spyOn(process.stderr, 'write').mockReturnValue(true);
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
await session.claimAsync({ engine: 'serve-sim' });
|
||||
await until(() => warning.mock.calls.some(([text]) => String(text).includes('retrying at stop')));
|
||||
await session.prepareToolsAsync();
|
||||
expect(controller.signal.aborted).toBe(false);
|
||||
// The retry fails too, so the stop reports it.
|
||||
await expect(session.stopAsync()).rejects.toMatchObject({ errors: [failure] });
|
||||
expect(stop.mock.calls.filter(([handle]) => handle === argent)).toHaveLength(2);
|
||||
expect(shutdownSimulatorAsync).toHaveBeenCalledWith('DUO');
|
||||
});
|
||||
|
||||
it('starts an owned nonblocking apsd attempt after selected adoption and drains it before shutdown', async () => {
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
await session.claimAsync({ engine: 'serve-sim', requestedDevice: 'OTHER' });
|
||||
const setup = deferred();
|
||||
const disable = jest.mocked(disableApsdAsync).mockImplementation(() => setup.promise);
|
||||
try {
|
||||
await session.prepareToolsAsync();
|
||||
// The setup runs in the background.
|
||||
await until(() => disable.mock.calls.some(([udid]) => udid === 'OTHER'));
|
||||
expect(disable).toHaveBeenCalledWith('OTHER', expect.any(AbortSignal));
|
||||
let stopped = false;
|
||||
const stopping = session.stopAsync().then(() => {
|
||||
stopped = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(stopped).toBe(false);
|
||||
expect(shutdownSimulatorAsync).not.toHaveBeenCalled();
|
||||
setup.resolve();
|
||||
await stopping;
|
||||
expect(shutdownSimulatorAsync).toHaveBeenCalledWith('OTHER');
|
||||
} finally {
|
||||
setup.resolve();
|
||||
await session.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not repeat completed default-device setup after adoption', async () => {
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
jest.mocked(disableApsdAsync).mockClear();
|
||||
await session.claimAsync({ engine: 'serve-sim' });
|
||||
await session.prepareToolsAsync();
|
||||
expect(disableApsdAsync).not.toHaveBeenCalled();
|
||||
await session.stopAsync();
|
||||
});
|
||||
|
||||
it('reports ready before apsd finishes; the claim does not wait for it and stop joins it', async () => {
|
||||
const disabled = deferred();
|
||||
const signals = new Map<string, AbortSignal>();
|
||||
const disable = jest.mocked(disableApsdAsync).mockImplementation(
|
||||
(udid, signal) =>
|
||||
new Promise((resolve, reject) => {
|
||||
signals.set(udid, signal);
|
||||
if (udid === 'DUO') {
|
||||
void disabled.promise.then(resolve);
|
||||
}
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
})
|
||||
);
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
await until(() => signals.has('DUO') && signals.has('PHONE18'));
|
||||
// The claim returns while both apsd steps still run; stop cancels and joins them.
|
||||
await session.claimAsync({ engine: 'serve-sim' });
|
||||
expect(signals.get('DUO')!.aborted).toBe(false);
|
||||
expect(signals.get('PHONE18')!.aborted).toBe(false);
|
||||
await session.stopAsync();
|
||||
expect(disable.mock.calls.filter(([udid]) => udid === 'DUO')).toHaveLength(1);
|
||||
expect(signals.get('DUO')!.aborted).toBe(true);
|
||||
expect(signals.get('PHONE18')!.aborted).toBe(true);
|
||||
disabled.resolve();
|
||||
});
|
||||
|
||||
it('does not report a background failure when a stop aborts the default setup', async () => {
|
||||
const disable = jest
|
||||
.mocked(disableApsdAsync)
|
||||
.mockImplementation(
|
||||
(_udid, signal) =>
|
||||
new Promise((_resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
|
||||
)
|
||||
);
|
||||
const session = createSession();
|
||||
const preparing = session.prepareAsync().catch(() => {});
|
||||
await until(() => disable.mock.calls.length > 0);
|
||||
await session.stopAsync();
|
||||
await preparing;
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
});
|
||||
|
||||
it('warns on ordinary adopted-device apsd failure without failing the claimed session', async () => {
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
await session.claimAsync({ engine: 'serve-sim', requestedDevice: 'OTHER' });
|
||||
jest.mocked(disableApsdAsync).mockRejectedValue(new Error('apsd remains loaded'));
|
||||
const warning = jest.spyOn(process.stderr, 'write').mockReturnValue(true);
|
||||
await session.prepareToolsAsync();
|
||||
// The setup runs in the background; wait for its warning before stopping.
|
||||
await until(() =>
|
||||
warning.mock.calls.some(([text]) => String(text).includes('Could not disable apsd on OTHER'))
|
||||
);
|
||||
await session.stopAsync();
|
||||
expect(onPreparationFailure).not.toHaveBeenCalled();
|
||||
expect(warning.mock.calls.map(([text]) => text).join('')).toContain(
|
||||
'Could not disable apsd on OTHER; continuing'
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { claimAsync } from '../commands/claim';
|
||||
import { stopAsync } from '../commands/stop';
|
||||
import {
|
||||
LAUNCHER_STATE_FILE,
|
||||
readLauncherStateAsync,
|
||||
updateLauncherStateAsync,
|
||||
waitForLauncherStateAsync,
|
||||
} from '../utils/state';
|
||||
import { deferred } from './helpers';
|
||||
|
||||
const boot = 'boot-one';
|
||||
let root: string;
|
||||
const info = {
|
||||
device: { udid: 'PHONE', name: 'iPhone 17' },
|
||||
server: {
|
||||
startupDevice: 'PHONE',
|
||||
pid: 9001,
|
||||
nativeStartTime: 'native-start',
|
||||
port: 3200,
|
||||
url: 'http://127.0.0.1:3200',
|
||||
token: 'fixture-token',
|
||||
},
|
||||
controllerPid: 9000,
|
||||
controllerStartTime: 'Thu Oct 22 10:00:00 2026',
|
||||
agentDeviceStateDir: '/agent',
|
||||
argentEventLogPath: '/argent/events',
|
||||
};
|
||||
const directory = () => path.join(root, 'control');
|
||||
|
||||
const nativeFs = {
|
||||
readFile: fs.readFile.bind(fs),
|
||||
writeFile: fs.writeFile.bind(fs),
|
||||
rename: fs.rename.bind(fs),
|
||||
rm: fs.rm.bind(fs),
|
||||
mkdir: fs.mkdir.bind(fs),
|
||||
rmdir: fs.rmdir.bind(fs),
|
||||
stat: fs.stat.bind(fs),
|
||||
};
|
||||
const remap = (file: unknown): unknown =>
|
||||
typeof file === 'string' && file.startsWith(LAUNCHER_STATE_FILE)
|
||||
? path.join(root, 'control', path.basename(file))
|
||||
: file;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), 'launcher-state-'));
|
||||
jest.spyOn(os, 'homedir').mockReturnValue(root);
|
||||
await fs.mkdir(path.join(root, 'control'));
|
||||
for (const name of ['readFile', 'writeFile', 'rename', 'rm', 'mkdir', 'rmdir', 'stat'] as const) {
|
||||
jest.spyOn(fs, name).mockImplementation(((...args: unknown[]) => {
|
||||
args[0] = remap(args[0]);
|
||||
if (name === 'rename') {
|
||||
args[1] = remap(args[1]);
|
||||
}
|
||||
return (nativeFs[name] as (...args: unknown[]) => unknown)(...args);
|
||||
}) as never);
|
||||
}
|
||||
});
|
||||
afterEach(async () => {
|
||||
jest.restoreAllMocks();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('missing and old-boot state are unavailable, while access errors propagate', async () => {
|
||||
expect(await readLauncherStateAsync(boot)).toBeUndefined();
|
||||
await updateLauncherStateAsync('old-boot', () => ({ bootUuid: 'old-boot', status: 'disabled' }));
|
||||
expect(await readLauncherStateAsync(boot)).toBeUndefined();
|
||||
const error = Object.assign(new Error('state inaccessible'), { code: 'EACCES' });
|
||||
jest.spyOn(fs, 'readFile').mockRejectedValueOnce(error);
|
||||
await expect(readLauncherStateAsync(boot)).rejects.toBe(error);
|
||||
});
|
||||
|
||||
test('old-boot locks are ignored without being removed or stolen', async () => {
|
||||
await fs.mkdir(`${LAUNCHER_STATE_FILE}.old-boot.lock`, { recursive: true });
|
||||
await stopAsync(boot);
|
||||
expect(await readLauncherStateAsync(boot)).toEqual({ bootUuid: boot, status: 'disabled' });
|
||||
expect((await fs.stat(`${LAUNCHER_STATE_FILE}.old-boot.lock`)).isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
test('a current-boot crash lock times out fail-closed and remains untouched', async () => {
|
||||
const lock = `${LAUNCHER_STATE_FILE}.${boot}.lock`;
|
||||
await fs.mkdir(lock, { recursive: true });
|
||||
let calls = 0;
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => (calls++ ? 5_001 : 0));
|
||||
await expect(stopAsync(boot)).rejects.toThrow('state is locked');
|
||||
expect((await fs.stat(lock)).isDirectory()).toBe(true);
|
||||
expect(await readLauncherStateAsync(boot)).toBeUndefined();
|
||||
});
|
||||
|
||||
for (const operation of ['callback', 'writeFile', 'rename'] as const) {
|
||||
test(`${operation} failure preserves old state and releases only the owned temporary files/lock`, async () => {
|
||||
await updateLauncherStateAsync(boot, () => ({ bootUuid: boot, status: 'starting' }));
|
||||
const error = Object.assign(new Error('disk error'), { code: 'EIO' });
|
||||
if (operation !== 'callback') {
|
||||
jest.spyOn(fs, operation).mockRejectedValueOnce(error);
|
||||
}
|
||||
await expect(
|
||||
updateLauncherStateAsync(boot, state => {
|
||||
if (operation === 'callback') {
|
||||
throw error;
|
||||
}
|
||||
return { ...state!, status: 'ready' };
|
||||
})
|
||||
).rejects.toBe(error);
|
||||
expect((await readLauncherStateAsync(boot))?.status).toBe('starting');
|
||||
expect(await fs.readdir(directory())).toEqual([path.basename(LAUNCHER_STATE_FILE)]);
|
||||
});
|
||||
}
|
||||
|
||||
test('cancellation after the temporary write cannot publish the next state', async () => {
|
||||
await updateLauncherStateAsync(boot, () => ({ bootUuid: boot, status: 'starting' }));
|
||||
const abort = new AbortController();
|
||||
const write = nativeFs.writeFile;
|
||||
jest.spyOn(fs, 'writeFile').mockImplementation((async (file, data, ...args) => {
|
||||
await write(remap(file) as string, data, ...args);
|
||||
abort.abort(new Error('cancelled'));
|
||||
}) as typeof fs.writeFile);
|
||||
await expect(
|
||||
updateLauncherStateAsync(boot, state => ({ ...state!, status: 'ready' }), abort.signal)
|
||||
).rejects.toThrow('cancelled');
|
||||
expect((await readLauncherStateAsync(boot))?.status).toBe('starting');
|
||||
expect(await fs.readdir(directory())).toEqual([path.basename(LAUNCHER_STATE_FILE)]);
|
||||
});
|
||||
|
||||
test.each(['ready', 'claimed'] as const)(
|
||||
'a pending %s writer publishes complete JSON before a stop, which cannot be overwritten',
|
||||
async publication => {
|
||||
await updateLauncherStateAsync(boot, () => ({
|
||||
bootUuid: boot,
|
||||
status: publication === 'ready' ? 'starting' : 'claiming',
|
||||
engine: 'serve-sim',
|
||||
...info,
|
||||
}));
|
||||
const writing = deferred();
|
||||
const release = deferred();
|
||||
const write = nativeFs.writeFile;
|
||||
jest.spyOn(fs, 'writeFile').mockImplementation((async (file, data, ...args) => {
|
||||
if (JSON.parse(String(data)).status === publication) {
|
||||
await write(remap(file) as string, '{"bootUuid":');
|
||||
writing.resolve();
|
||||
await release.promise;
|
||||
await write(remap(file) as string, data);
|
||||
return;
|
||||
}
|
||||
await write(remap(file) as string, data, ...args);
|
||||
}) as typeof fs.writeFile);
|
||||
const ready = updateLauncherStateAsync(boot, state => ({ ...state!, status: publication }));
|
||||
try {
|
||||
await writing.promise;
|
||||
expect((await readLauncherStateAsync(boot))?.status).toBe(
|
||||
publication === 'ready' ? 'starting' : 'claiming'
|
||||
);
|
||||
const stopping = stopAsync(boot);
|
||||
release.resolve();
|
||||
await ready;
|
||||
await waitForLauncherStateAsync(boot, state => state?.status === 'stopping', 1_000);
|
||||
await expect(
|
||||
updateLauncherStateAsync(boot, state => {
|
||||
if (state?.status !== 'ready') {
|
||||
throw new Error('stop already won');
|
||||
}
|
||||
return { ...state, status: 'claimed' };
|
||||
})
|
||||
).rejects.toThrow('stop already won');
|
||||
await updateLauncherStateAsync(boot, state => ({ ...state!, status: 'disabled' }));
|
||||
expect(await stopping).toBe(0);
|
||||
} finally {
|
||||
release.resolve();
|
||||
await ready;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test('two job claims acquire only one ready session and the result includes its metadata', async () => {
|
||||
await updateLauncherStateAsync(boot, () => ({ bootUuid: boot, status: 'ready', ...info }));
|
||||
const output = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
const results: unknown[] = [];
|
||||
const claims = ['serve-sim', 'argent'].map(engine =>
|
||||
claimAsync(boot, engine).then(
|
||||
value => results.push(value),
|
||||
error => results.push(error)
|
||||
)
|
||||
);
|
||||
await waitForLauncherStateAsync(boot, state => state?.status === 'claiming', 1_000);
|
||||
for (let i = 0; i < 100 && results.length === 0; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
expect(results[0]).toBeInstanceOf(Error);
|
||||
await updateLauncherStateAsync(boot, state => ({ ...state!, status: 'claimed' }));
|
||||
await Promise.all(claims);
|
||||
expect(results.filter(value => value === 0)).toHaveLength(1);
|
||||
expect(JSON.parse(String(output.mock.calls[0][0]))).toMatchObject({
|
||||
...info,
|
||||
status: 'claimed',
|
||||
bootUuid: boot,
|
||||
});
|
||||
await expect(claimAsync(boot, 'serve-sim')).rejects.toThrow('available');
|
||||
});
|
||||
|
||||
test('a claim fails when the launcher hands over a different device than requested', async () => {
|
||||
await updateLauncherStateAsync(boot, () => ({ bootUuid: boot, status: 'ready', ...info }));
|
||||
const claim = expect(claimAsync(boot, 'serve-sim', 'iPad Pro')).rejects.toThrow(
|
||||
'claimed iPhone 17, not iPad Pro'
|
||||
);
|
||||
await waitForLauncherStateAsync(boot, state => state?.status === 'claiming', 1_000);
|
||||
await updateLauncherStateAsync(boot, state => ({ ...state!, status: 'claimed' }));
|
||||
await claim;
|
||||
});
|
||||
|
||||
test('an interrupted claim reports the launcher error', async () => {
|
||||
await updateLauncherStateAsync(boot, () => ({ bootUuid: boot, status: 'ready', ...info }));
|
||||
const claim = expect(claimAsync(boot, 'serve-sim')).rejects.toThrow(
|
||||
'cleanup may still be running. serve-sim exited'
|
||||
);
|
||||
await waitForLauncherStateAsync(boot, state => state?.status === 'claiming', 1_000);
|
||||
await updateLauncherStateAsync(boot, state => ({
|
||||
...state!,
|
||||
status: 'stopping',
|
||||
error: 'serve-sim exited',
|
||||
}));
|
||||
await claim;
|
||||
});
|
||||
|
||||
test.each([
|
||||
[undefined, undefined],
|
||||
['unsupported', undefined],
|
||||
['serve-sim', ' '],
|
||||
])(
|
||||
'invalid claim engine/selector %s/%s cannot mutate or acquire ready state',
|
||||
async (engine, selector) => {
|
||||
await updateLauncherStateAsync(boot, () => ({ bootUuid: boot, status: 'ready', ...info }));
|
||||
const before = await readLauncherStateAsync(boot);
|
||||
await expect(claimAsync(boot, engine, selector)).rejects.toThrow(
|
||||
'Invalid Simulator session request'
|
||||
);
|
||||
expect(await readLauncherStateAsync(boot)).toEqual(before);
|
||||
expect(await fs.readdir(directory())).toEqual([path.basename(LAUNCHER_STATE_FILE)]);
|
||||
}
|
||||
);
|
||||
|
||||
test('concurrent resource-free stops leave one disabled record without extra ownership files', async () => {
|
||||
expect(await Promise.all([stopAsync(boot), stopAsync(boot)])).toEqual([0, 0]);
|
||||
expect(await fs.readdir(directory())).toEqual([path.basename(LAUNCHER_STATE_FILE)]);
|
||||
expect(await readLauncherStateAsync(boot)).toEqual({ bootUuid: boot, status: 'disabled' });
|
||||
});
|
||||
|
||||
test('stopping is not cleanup completion and polling stays bounded', async () => {
|
||||
await updateLauncherStateAsync(boot, () => ({
|
||||
bootUuid: boot,
|
||||
status: 'stopping',
|
||||
error: 'cleanup incomplete',
|
||||
}));
|
||||
await expect(
|
||||
waitForLauncherStateAsync(boot, state => state?.status === 'disabled', 1)
|
||||
).rejects.toThrow('cleanup may still be running');
|
||||
expect((await readLauncherStateAsync(boot))?.status).toBe('stopping');
|
||||
});
|
||||
|
||||
test('the single state record keeps server credentials private to its OS user', async () => {
|
||||
await updateLauncherStateAsync(boot, () => ({ bootUuid: boot, status: 'ready', ...info }));
|
||||
expect((await fs.stat(LAUNCHER_STATE_FILE)).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { type ChildProcess } from 'node:child_process';
|
||||
|
||||
import { createAgentDeviceTool } from '../utils/agentDevice';
|
||||
import { commands, stopProcessAndDescendantsAsync } from '../utils/processes';
|
||||
import { type WarmTool, startTool } from '../utils/tools';
|
||||
import { deferred } from './helpers';
|
||||
|
||||
jest.mock('../utils/processes');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(commands.runAsync).mockResolvedValue('');
|
||||
jest.mocked(stopProcessAndDescendantsAsync).mockResolvedValue();
|
||||
});
|
||||
|
||||
it('prepares at once, starts after serve-sim, and reports a failure before cancellation', async () => {
|
||||
const serveSimReady = deferred();
|
||||
const failure = new Error('start failed');
|
||||
const tool: WarmTool = {
|
||||
name: 'argent',
|
||||
prepareAsync: jest.fn(async () => {}),
|
||||
startAsync: jest.fn(async () => {
|
||||
throw failure;
|
||||
}),
|
||||
stopAsync: jest.fn(async () => {}),
|
||||
};
|
||||
const onFailure = jest.fn();
|
||||
const started = startTool(
|
||||
tool,
|
||||
'PHONE',
|
||||
serveSimReady.promise,
|
||||
new AbortController().signal,
|
||||
onFailure
|
||||
);
|
||||
expect(tool.prepareAsync).toHaveBeenCalledTimes(1);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(tool.startAsync).not.toHaveBeenCalled();
|
||||
serveSimReady.resolve();
|
||||
await expect(started.ready).rejects.toBe(failure);
|
||||
expect(tool.startAsync).toHaveBeenCalledWith('PHONE', expect.any(AbortSignal));
|
||||
expect(onFailure).toHaveBeenCalledWith(failure);
|
||||
});
|
||||
|
||||
it('stops agent-device mid-prepare without reporting it, and stops the daemon', async () => {
|
||||
const preparing = deferred<string>();
|
||||
const prepare = { child: { pid: 11 } as ChildProcess, resultPromise: preparing.promise };
|
||||
jest.mocked(commands.start).mockReturnValue(prepare);
|
||||
const onFailure = jest.fn();
|
||||
const started = startTool(
|
||||
createAgentDeviceTool(),
|
||||
'PHONE',
|
||||
Promise.resolve(),
|
||||
new AbortController().signal,
|
||||
onFailure
|
||||
);
|
||||
try {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(commands.start).toHaveBeenCalledTimes(1);
|
||||
expect(commands.start).toHaveBeenCalledWith(
|
||||
'bunx',
|
||||
expect.arrayContaining(['prepare', 'ios-runner']),
|
||||
{ detached: true }
|
||||
);
|
||||
// The prepare process is still running; the stop does not wait for it to exit by itself.
|
||||
await started.stopAsync();
|
||||
expect(stopProcessAndDescendantsAsync).toHaveBeenCalledWith(prepare, true);
|
||||
expect(commands.runAsync).toHaveBeenCalledWith(
|
||||
'bunx',
|
||||
expect.arrayContaining(['daemon', 'stop']),
|
||||
expect.any(AbortSignal)
|
||||
);
|
||||
expect(onFailure).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
preparing.resolve('');
|
||||
}
|
||||
});
|
||||
|
||||
it('stops the agent-device daemon even when the prepare process cannot be stopped', async () => {
|
||||
const prepare = { child: { pid: 11 } as ChildProcess, resultPromise: Promise.resolve('') };
|
||||
const failure = new Error('prepare stop failed');
|
||||
jest.mocked(commands.start).mockReturnValue(prepare);
|
||||
jest.mocked(stopProcessAndDescendantsAsync).mockRejectedValueOnce(failure);
|
||||
const tool = createAgentDeviceTool();
|
||||
await tool.startAsync('PHONE', new AbortController().signal);
|
||||
await expect(tool.stopAsync()).rejects.toBe(failure);
|
||||
expect(commands.runAsync).toHaveBeenCalledWith(
|
||||
'bunx',
|
||||
expect.arrayContaining(['daemon', 'stop']),
|
||||
expect.any(AbortSignal)
|
||||
);
|
||||
// The prepare process stays owned, so a later stop tries it again.
|
||||
await tool.stopAsync();
|
||||
expect(jest.mocked(stopProcessAndDescendantsAsync).mock.calls).toEqual([
|
||||
[prepare, true],
|
||||
[prepare, true],
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports a synchronous prepare throw through ready instead of throwing from startTool', async () => {
|
||||
const failure = new Error('prepare threw');
|
||||
const tool: WarmTool = {
|
||||
name: 'argent',
|
||||
prepareAsync: () => {
|
||||
throw failure;
|
||||
},
|
||||
startAsync: jest.fn(async () => {}),
|
||||
stopAsync: jest.fn(async () => {}),
|
||||
};
|
||||
const onFailure = jest.fn();
|
||||
const started = startTool(
|
||||
tool,
|
||||
'PHONE',
|
||||
Promise.resolve(),
|
||||
new AbortController().signal,
|
||||
onFailure
|
||||
);
|
||||
await expect(started.ready).rejects.toBe(failure);
|
||||
expect(tool.startAsync).not.toHaveBeenCalled();
|
||||
expect(onFailure).toHaveBeenCalledWith(failure);
|
||||
});
|
||||
|
||||
it('keeps both errors when the agent-device prepare and daemon stops fail', async () => {
|
||||
const prepare = { child: { pid: 11 } as ChildProcess, resultPromise: Promise.resolve('') };
|
||||
const prepareFailure = new Error('prepare stop failed');
|
||||
const daemonFailure = new Error('daemon stop failed');
|
||||
jest.mocked(commands.start).mockReturnValue(prepare);
|
||||
jest.mocked(stopProcessAndDescendantsAsync).mockRejectedValueOnce(prepareFailure);
|
||||
jest.mocked(commands.runAsync).mockRejectedValueOnce(daemonFailure);
|
||||
const tool = createAgentDeviceTool();
|
||||
await tool.startAsync('PHONE', new AbortController().signal);
|
||||
await expect(tool.stopAsync()).rejects.toMatchObject({ errors: [prepareFailure, daemonFailure] });
|
||||
});
|
||||
|
||||
it('drains independent tool preparation after serve-sim readiness rejects', async () => {
|
||||
const prepared = deferred();
|
||||
const failure = new Error('serve-sim readiness failed');
|
||||
const tool: WarmTool = {
|
||||
name: 'argent',
|
||||
prepareAsync: () => prepared.promise,
|
||||
startAsync: jest.fn(async () => {}),
|
||||
stopAsync: jest.fn(async () => {}),
|
||||
};
|
||||
const started = startTool(
|
||||
tool,
|
||||
'PHONE',
|
||||
Promise.reject(failure),
|
||||
new AbortController().signal,
|
||||
jest.fn()
|
||||
);
|
||||
await expect(started.ready).rejects.toBe(failure);
|
||||
let stopped = false;
|
||||
const stopping = started.stopAsync().then(() => {
|
||||
stopped = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(stopped).toBe(false);
|
||||
expect(tool.stopAsync).not.toHaveBeenCalled();
|
||||
prepared.resolve();
|
||||
await stopping;
|
||||
expect(tool.startAsync).not.toHaveBeenCalled();
|
||||
expect(tool.stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
ClaimInputSchema,
|
||||
WarmSessionInfoSchema,
|
||||
updateLauncherStateAsync,
|
||||
waitForLauncherStateAsync,
|
||||
} from '../utils/state';
|
||||
|
||||
export async function claimAsync(
|
||||
bootUuid: string,
|
||||
engine?: string,
|
||||
requestedDevice?: string
|
||||
): Promise<number> {
|
||||
const input = ClaimInputSchema.safeParse({ engine, requestedDevice });
|
||||
if (!input.success) {
|
||||
throw new Error(
|
||||
`Invalid Simulator session request. Choose ${ClaimInputSchema.shape.engine.options.join(', ')} and an optional device name or UDID.`
|
||||
);
|
||||
}
|
||||
const acquired = await updateLauncherStateAsync(bootUuid, state => {
|
||||
if (state?.status !== 'ready') {
|
||||
throw new Error('No warm Simulator session is available to claim on this VM.');
|
||||
}
|
||||
WarmSessionInfoSchema.parse(state);
|
||||
return { ...state, ...input.data, status: 'claiming' };
|
||||
});
|
||||
const claimed = await waitForLauncherStateAsync(
|
||||
bootUuid,
|
||||
state => {
|
||||
if (!state || state.status === 'stopping' || state.status === 'disabled') {
|
||||
throw new Error(
|
||||
`Simulator preparation was interrupted; cleanup may still be running.${state?.error ? ` ${state.error}` : ''}`
|
||||
);
|
||||
}
|
||||
if (
|
||||
state.controllerPid !== acquired.controllerPid ||
|
||||
state.controllerStartTime !== acquired.controllerStartTime ||
|
||||
state.engine !== engine
|
||||
) {
|
||||
throw new Error('The Simulator launcher returned a different session or engine.');
|
||||
}
|
||||
return state.status === 'claimed';
|
||||
},
|
||||
420_000
|
||||
);
|
||||
const { device } = WarmSessionInfoSchema.parse(claimed);
|
||||
if (
|
||||
requestedDevice &&
|
||||
device.name !== requestedDevice &&
|
||||
device.udid.toLowerCase() !== requestedDevice.toLowerCase()
|
||||
) {
|
||||
throw new Error(`The Simulator launcher claimed ${device.name}, not ${requestedDevice}.`);
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(claimed)}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Continue the consumed claim after private serve-sim adoption, without acquiring another job. */
|
||||
export async function prepareToolsAsync(bootUuid: string): Promise<number> {
|
||||
const acquired = await updateLauncherStateAsync(bootUuid, state => {
|
||||
if (state?.status !== 'claimed') {
|
||||
throw new Error('Claim and adopt the warm Simulator session before preparing its tools.');
|
||||
}
|
||||
WarmSessionInfoSchema.parse(state);
|
||||
return { ...state, prepareToolsRequested: true };
|
||||
});
|
||||
const ready = await waitForLauncherStateAsync(
|
||||
bootUuid,
|
||||
state => {
|
||||
if (
|
||||
!state ||
|
||||
state.status !== 'claimed' ||
|
||||
state.controllerPid !== acquired.controllerPid ||
|
||||
state.controllerStartTime !== acquired.controllerStartTime ||
|
||||
state.device?.udid !== acquired.device?.udid ||
|
||||
state.engine !== acquired.engine
|
||||
) {
|
||||
throw new Error(
|
||||
'Warm Simulator tool preparation was interrupted; cleanup may still be running.'
|
||||
);
|
||||
}
|
||||
return state.toolsReady === true;
|
||||
},
|
||||
420_000
|
||||
);
|
||||
process.stdout.write(`${JSON.stringify(ready)}\n`);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
import { once } from 'node:events';
|
||||
import { setTimeout as sleepAsync } from 'node:timers/promises';
|
||||
|
||||
import { createAgentDeviceTool } from '../utils/agentDevice';
|
||||
import { createArgentTool } from '../utils/argent';
|
||||
import { startExpoGoPreinstall } from '../utils/expoGo';
|
||||
import {
|
||||
type ChildProcessHandle,
|
||||
commands,
|
||||
stopProcessAndDescendantsAsync,
|
||||
} from '../utils/processes';
|
||||
import { startServeSim, waitForServeSimReadyAsync } from '../utils/serveSim';
|
||||
import {
|
||||
disableApsdAsync,
|
||||
listAvailableSimulatorsAsync,
|
||||
selectSimulators,
|
||||
selectWarmupSimulators,
|
||||
shutdownSimulatorAsync,
|
||||
waitForSimulatorBootAsync,
|
||||
} from '../utils/simulator';
|
||||
import {
|
||||
type ClaimInput,
|
||||
ClaimInputSchema,
|
||||
type LauncherState,
|
||||
type PreinstalledApp,
|
||||
type SimulatorDevice,
|
||||
type ToolServer,
|
||||
type WarmSessionInfo,
|
||||
readLauncherStateAsync,
|
||||
updateLauncherStateAsync,
|
||||
} from '../utils/state';
|
||||
import { type StartedTool, type WarmTool, startTool } from '../utils/tools';
|
||||
|
||||
const LAUNCHER_VERSION: string = require('../../package.json').version;
|
||||
|
||||
type WarmSimulatorSession = {
|
||||
prepareAsync(): Promise<WarmSessionInfo>;
|
||||
claimAsync(request: ClaimInput): Promise<{ device: SimulatorDevice }>;
|
||||
prepareToolsAsync(): Promise<{ preinstalledExpoGo?: PreinstalledApp; tools?: ToolServer }>;
|
||||
stopAsync(): Promise<void>;
|
||||
};
|
||||
|
||||
export async function startAsync(bootUuid: string): Promise<number> {
|
||||
const initial = await updateLauncherStateAsync(bootUuid, state => {
|
||||
if (state?.status === 'disabled') {
|
||||
return state;
|
||||
}
|
||||
if (state) {
|
||||
throw new Error(
|
||||
'Simulator preparation has already been started or disabled for this VM boot.'
|
||||
);
|
||||
}
|
||||
return {
|
||||
bootUuid,
|
||||
status: 'starting',
|
||||
controllerPid: process.pid,
|
||||
launcherVersion: LAUNCHER_VERSION,
|
||||
};
|
||||
});
|
||||
if (initial.status === 'disabled') {
|
||||
return 0;
|
||||
}
|
||||
await runWarmSimulatorSessionAsync(bootUuid);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function runWarmSimulatorSessionAsync(bootUuid: string): Promise<void> {
|
||||
const shutdown = new AbortController();
|
||||
const signal = shutdown.signal;
|
||||
const interruption = new Error(
|
||||
'Simulator preparation was interrupted; cleanup may still be running.'
|
||||
);
|
||||
const requestShutdown = (): void => shutdown.abort(interruption);
|
||||
process.on('SIGTERM', requestShutdown);
|
||||
process.on('SIGINT', requestShutdown);
|
||||
const watcherAbort = new AbortController();
|
||||
let sessionError: unknown;
|
||||
let warm: WarmSessionInfo | undefined;
|
||||
function requireOwnedState(
|
||||
state: LauncherState | undefined,
|
||||
expected?: LauncherState['status']
|
||||
): LauncherState {
|
||||
if (state?.status === 'stopping' || state?.status === 'disabled') {
|
||||
requestShutdown();
|
||||
}
|
||||
if (
|
||||
!state ||
|
||||
state.controllerPid !== process.pid ||
|
||||
(expected && state.status !== expected) ||
|
||||
(warm && state.controllerStartTime && state.controllerStartTime !== warm.controllerStartTime)
|
||||
) {
|
||||
throw interruption;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
async function throwIfStoppingAsync(): Promise<void> {
|
||||
signal.throwIfAborted();
|
||||
requireOwnedState(await readLauncherStateAsync(bootUuid));
|
||||
signal.throwIfAborted();
|
||||
}
|
||||
const session = createWarmSimulatorSession({
|
||||
signal,
|
||||
throwIfStoppingAsync,
|
||||
onPreparationFailure(error) {
|
||||
if (!signal.aborted) {
|
||||
sessionError = error;
|
||||
}
|
||||
shutdown.abort(error);
|
||||
},
|
||||
});
|
||||
const watcher = (async () => {
|
||||
while (!watcherAbort.signal.aborted) {
|
||||
await throwIfStoppingAsync();
|
||||
await sleepAsync(100, undefined, { signal: watcherAbort.signal });
|
||||
}
|
||||
})().catch(error => {
|
||||
if (!watcherAbort.signal.aborted && !signal.aborted) {
|
||||
sessionError ??= error;
|
||||
requestShutdown();
|
||||
}
|
||||
});
|
||||
async function prepareDefaultDeviceAsync(): Promise<void> {
|
||||
await throwIfStoppingAsync();
|
||||
const prepared = await session.prepareAsync();
|
||||
warm = prepared;
|
||||
await updateLauncherStateAsync(
|
||||
bootUuid,
|
||||
state => ({
|
||||
...requireOwnedState(state, 'starting'),
|
||||
...prepared,
|
||||
status: 'ready',
|
||||
}),
|
||||
signal
|
||||
);
|
||||
}
|
||||
|
||||
async function handleOneClaimAsync(): Promise<void> {
|
||||
while (!signal.aborted) {
|
||||
const state = requireOwnedState(await readLauncherStateAsync(bootUuid));
|
||||
if (state.status === 'claiming') {
|
||||
const request = ClaimInputSchema.parse(state);
|
||||
const { device } = await session.claimAsync(request);
|
||||
// Stop can arrive during native work; recheck ownership under the state lock.
|
||||
await updateLauncherStateAsync(
|
||||
bootUuid,
|
||||
current => {
|
||||
const owned = requireOwnedState(current, 'claiming');
|
||||
if (
|
||||
owned.engine !== request.engine ||
|
||||
owned.requestedDevice !== request.requestedDevice
|
||||
) {
|
||||
throw interruption;
|
||||
}
|
||||
return {
|
||||
...owned,
|
||||
status: 'claimed',
|
||||
device,
|
||||
};
|
||||
},
|
||||
signal
|
||||
);
|
||||
return;
|
||||
}
|
||||
await sleepAsync(100, undefined, { signal });
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
}
|
||||
|
||||
async function finishSessionAsync(): Promise<void> {
|
||||
await updateLauncherStateAsync(bootUuid, state => ({
|
||||
...requireOwnedState(state),
|
||||
status: 'stopping',
|
||||
...(sessionError !== undefined
|
||||
? { error: sessionError instanceof Error ? sessionError.message : String(sessionError) }
|
||||
: {}),
|
||||
})).catch(error => {
|
||||
sessionError ??= error;
|
||||
});
|
||||
try {
|
||||
await session.stopAsync();
|
||||
} catch (error) {
|
||||
await updateLauncherStateAsync(bootUuid, state => ({
|
||||
...requireOwnedState(state),
|
||||
status: 'stopping',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
// Only confirmed cleanup may publish disabled, which lets a job safely start cold.
|
||||
await updateLauncherStateAsync(bootUuid, state => ({
|
||||
...requireOwnedState(state, 'stopping'),
|
||||
status: 'disabled',
|
||||
}));
|
||||
if (sessionError) {
|
||||
throw sessionError;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
try {
|
||||
if (process.env.EXPO_SIMULATOR_STARTUP !== '1') {
|
||||
process.stderr.write('EXPO_SIMULATOR_STARTUP is not 1; Simulator warmup is disabled.\n');
|
||||
requestShutdown();
|
||||
}
|
||||
await prepareDefaultDeviceAsync();
|
||||
await handleOneClaimAsync();
|
||||
while (!signal.aborted) {
|
||||
const state = requireOwnedState(await readLauncherStateAsync(bootUuid), 'claimed');
|
||||
if (state.prepareToolsRequested && !state.toolsReady) {
|
||||
const prepared = await session.prepareToolsAsync();
|
||||
await updateLauncherStateAsync(
|
||||
bootUuid,
|
||||
current => ({
|
||||
...requireOwnedState(current, 'claimed'),
|
||||
...prepared,
|
||||
toolsReady: true,
|
||||
}),
|
||||
signal
|
||||
);
|
||||
break;
|
||||
}
|
||||
await sleepAsync(100, undefined, { signal });
|
||||
}
|
||||
if (!signal.aborted) {
|
||||
await once(signal, 'abort');
|
||||
}
|
||||
} catch (error) {
|
||||
if (!signal.aborted) {
|
||||
sessionError ??= error;
|
||||
}
|
||||
}
|
||||
|
||||
await finishSessionAsync();
|
||||
} finally {
|
||||
watcherAbort.abort();
|
||||
await watcher;
|
||||
process.off('SIGTERM', requestShutdown);
|
||||
process.off('SIGINT', requestShutdown);
|
||||
}
|
||||
}
|
||||
|
||||
/** Own one orphan process and its tool/preinstall work before any preparation awaits. */
|
||||
export function createWarmSimulatorSession({
|
||||
signal: parentSignal,
|
||||
throwIfStoppingAsync,
|
||||
onPreparationFailure,
|
||||
}: {
|
||||
signal: AbortSignal;
|
||||
throwIfStoppingAsync(): Promise<void>;
|
||||
onPreparationFailure(error: unknown): void;
|
||||
}): WarmSimulatorSession {
|
||||
const lifetime = new AbortController();
|
||||
const signal = AbortSignal.any([parentSignal, lifetime.signal]);
|
||||
const warmTools = [createAgentDeviceTool(), createArgentTool(onPreparationFailure)];
|
||||
const startedTools = new Map<WarmTool, StartedTool>();
|
||||
const ownedDevices = new Set<string>();
|
||||
// Each device's apsd step; only the terminal stop joins them.
|
||||
const simulatorSetups = new Map<string, Promise<void>>();
|
||||
let preparation: Promise<WarmSessionInfo> | undefined;
|
||||
let server: ChildProcessHandle | undefined;
|
||||
let info: WarmSessionInfo;
|
||||
let availableDevices: SimulatorDevice[];
|
||||
let warmDevices: SimulatorDevice[] = [];
|
||||
let request: ClaimInput | undefined;
|
||||
let stopping = false;
|
||||
let expoGo: ReturnType<typeof startExpoGoPreinstall> | undefined;
|
||||
let selectedPreinstall: Promise<PreinstalledApp | undefined> = Promise.resolve(undefined);
|
||||
let launcherCachePriming: Promise<void> = Promise.resolve();
|
||||
let toolRetirement: Promise<PromiseSettledResult<void>[]> = Promise.resolve([]);
|
||||
|
||||
/**
|
||||
* Sets up each device once `ready` settles and the device booted: disable apsd and preinstall
|
||||
* Expo Go in parallel. Claim does not wait for this; stop joins it.
|
||||
*/
|
||||
async function startSimulatorsSetupAsync(
|
||||
udids: string[],
|
||||
ready: Promise<unknown>
|
||||
): Promise<void> {
|
||||
await Promise.all(
|
||||
udids.map(async udid => {
|
||||
if (simulatorSetups.has(udid)) {
|
||||
return;
|
||||
}
|
||||
// serve-sim boots every device; its ready already covers the default device's boot.
|
||||
const booted =
|
||||
udid === warmDevices[0]?.udid
|
||||
? ready
|
||||
: ready.then(() => waitForSimulatorBootAsync(udid, signal));
|
||||
const apsd = booted.then(() =>
|
||||
disableApsdAsync(udid, signal).catch(error => {
|
||||
signal.throwIfAborted();
|
||||
process.stderr.write(`Could not disable apsd on ${udid}; continuing: ${error}\n`);
|
||||
})
|
||||
);
|
||||
simulatorSetups.set(udid, apsd);
|
||||
void apsd.catch(() => {
|
||||
if (simulatorSetups.get(udid) === apsd) {
|
||||
simulatorSetups.delete(udid);
|
||||
}
|
||||
});
|
||||
await Promise.all([
|
||||
apsd,
|
||||
booted.then(() => expoGo?.preinstallOnDeviceAsync(udid, Promise.resolve(), signal)),
|
||||
]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveDeviceAsync(selector: string): Promise<SimulatorDevice> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const matches = selectSimulators(availableDevices, selector);
|
||||
if (matches.length === 1) {
|
||||
return matches[0];
|
||||
}
|
||||
if (matches.length > 1 || attempt === 1) {
|
||||
throw new Error(
|
||||
`Could not select one available Simulator for ${selector}. Use an exact available device name or UDID.`
|
||||
);
|
||||
}
|
||||
await throwIfStoppingAsync();
|
||||
availableDevices = await listAvailableSimulatorsAsync(signal);
|
||||
}
|
||||
}
|
||||
|
||||
async function primeLauncherCacheAsync(): Promise<void> {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
await commands.runAsync(
|
||||
'bun',
|
||||
['x', `@expo/simulator-launcher@${LAUNCHER_VERSION}`, '--version'],
|
||||
AbortSignal.any([signal, AbortSignal.timeout(30_000)])
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
signal.throwIfAborted();
|
||||
if (attempt === 1) {
|
||||
process.stderr.write(
|
||||
`Could not cache the Simulator launcher version; claim/stop may need the registry: ${error}\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareDefaultAsync(): Promise<WarmSessionInfo> {
|
||||
signal.throwIfAborted();
|
||||
availableDevices = await listAvailableSimulatorsAsync(signal);
|
||||
signal.throwIfAborted();
|
||||
warmDevices = selectWarmupSimulators(availableDevices, process.env.EXPO_SIMULATOR_DEVICE);
|
||||
const controllerStartTime = (
|
||||
await commands.runAsync('ps', ['-p', String(process.pid), '-o', 'lstart='], signal)
|
||||
).trim();
|
||||
signal.throwIfAborted();
|
||||
if (!controllerStartTime) {
|
||||
throw new Error(
|
||||
'Could not read the Simulator launcher process start time; the warm session cannot be verified.'
|
||||
);
|
||||
}
|
||||
const first = warmDevices[0];
|
||||
// serve-sim owns all boot/selection work, including partial boots during failed adoption.
|
||||
server = startServeSim(
|
||||
warmDevices.map(device => device.udid),
|
||||
signal
|
||||
);
|
||||
for (const device of warmDevices) {
|
||||
ownedDevices.add(device.udid);
|
||||
}
|
||||
const ready = waitForServeSimReadyAsync(first.udid, server, signal);
|
||||
void ready.catch(() => {});
|
||||
if (process.env.EXPO_SIMULATOR_PREINSTALL_EXPO_GO !== '0') {
|
||||
expoGo = startExpoGoPreinstall(signal);
|
||||
}
|
||||
for (const tool of warmTools) {
|
||||
startedTools.set(tool, startTool(tool, first.udid, ready, signal, onPreparationFailure));
|
||||
}
|
||||
startSimulatorsSetupAsync(
|
||||
warmDevices.map(device => device.udid),
|
||||
ready
|
||||
).catch(error => {
|
||||
if (!signal.aborted) {
|
||||
process.stderr.write(`Could not finish Simulator warmup; continuing: ${error}\n`);
|
||||
}
|
||||
});
|
||||
launcherCachePriming = primeLauncherCacheAsync();
|
||||
const [serverInfo] = await Promise.all([ready, launcherCachePriming]);
|
||||
const agentDeviceStateDir = process.env.AGENT_DEVICE_STATE_DIR;
|
||||
const argentEventLogPath = process.env.ARGENT_EVENT_LOG;
|
||||
if (!agentDeviceStateDir || !argentEventLogPath) {
|
||||
throw new Error('The warm Simulator tool paths are not configured.');
|
||||
}
|
||||
info = {
|
||||
device: first,
|
||||
server: serverInfo,
|
||||
controllerPid: process.pid,
|
||||
controllerStartTime,
|
||||
agentDeviceStateDir,
|
||||
argentEventLogPath,
|
||||
};
|
||||
return info;
|
||||
}
|
||||
|
||||
return {
|
||||
async prepareAsync() {
|
||||
return await (preparation ??= prepareDefaultAsync());
|
||||
},
|
||||
async claimAsync(input) {
|
||||
if (request || stopping) {
|
||||
throw new Error('This warm Simulator session has already been claimed or is stopping.');
|
||||
}
|
||||
request = input;
|
||||
// Latch every installation before any asynchronous selection, so no new one starts.
|
||||
// Installs already running continue. Adoption may shut down an unused device during its
|
||||
// install, which then fails with a warning; stop joins them all.
|
||||
const preinstalls = new Map(
|
||||
warmDevices.map(device => {
|
||||
const result = expoGo?.claimAsync(device.udid) ?? Promise.resolve(undefined);
|
||||
void result.catch(() => {});
|
||||
return [device.udid, result] as const;
|
||||
})
|
||||
);
|
||||
await throwIfStoppingAsync();
|
||||
const selected = input.requestedDevice
|
||||
? await resolveDeviceAsync(input.requestedDevice)
|
||||
: warmDevices[0];
|
||||
ownedDevices.add(selected.udid);
|
||||
const requestedTool = warmTools.find(tool => tool.name === input.engine);
|
||||
const sameDevice = selected.udid === warmDevices[0].udid;
|
||||
toolRetirement = Promise.allSettled(
|
||||
[...startedTools]
|
||||
.filter(
|
||||
([tool]) => tool !== requestedTool || (tool.name === 'agent-device' && !sameDevice)
|
||||
)
|
||||
.map(([tool, started]) => {
|
||||
startedTools.delete(tool);
|
||||
// The job owns the device now; the terminal stop retries this tool.
|
||||
return started.stopAsync().catch(error => {
|
||||
process.stderr.write(`Could not stop ${tool.name}; retrying at stop: ${error}\n`);
|
||||
throw error;
|
||||
});
|
||||
})
|
||||
);
|
||||
// The claim does not wait for warmup work; prepare-tools waits for this device's install.
|
||||
selectedPreinstall = preinstalls.get(selected.udid) ?? Promise.resolve(undefined);
|
||||
info = { ...info, device: selected };
|
||||
return { device: selected };
|
||||
},
|
||||
async prepareToolsAsync() {
|
||||
if (!request || stopping || !server) {
|
||||
throw new Error('Claim a warm Simulator session before preparing its tools.');
|
||||
}
|
||||
const { engine } = request;
|
||||
// This phase cannot start a runner against the old device before private adoption finishes.
|
||||
const adopted = await waitForServeSimReadyAsync(
|
||||
info.device.udid,
|
||||
server,
|
||||
signal,
|
||||
info.server.port
|
||||
);
|
||||
if (
|
||||
adopted.pid !== info.server.pid ||
|
||||
adopted.nativeStartTime !== info.server.nativeStartTime ||
|
||||
adopted.url !== info.server.url ||
|
||||
adopted.token !== info.server.token
|
||||
) {
|
||||
throw new Error('The adopted serve-sim process does not match the claimed warm server.');
|
||||
}
|
||||
// A device outside the warm list still needs its setup. A started setup is reused. This
|
||||
// runs in the background but is joined before teardown.
|
||||
startSimulatorsSetupAsync([info.device.udid], Promise.resolve()).catch(() => {});
|
||||
const tool = warmTools.find(tool => tool.name === engine);
|
||||
if (tool?.name === 'agent-device' && !startedTools.has(tool)) {
|
||||
const results = await toolRetirement;
|
||||
const failed = results.find(result => result.status === 'rejected');
|
||||
if (failed?.status === 'rejected') {
|
||||
throw failed.reason;
|
||||
}
|
||||
}
|
||||
await throwIfStoppingAsync();
|
||||
let tools: ToolServer | void = undefined;
|
||||
if (tool) {
|
||||
let started = startedTools.get(tool);
|
||||
if (!started) {
|
||||
started = startTool(
|
||||
tool,
|
||||
info.device.udid,
|
||||
Promise.resolve(),
|
||||
signal,
|
||||
onPreparationFailure
|
||||
);
|
||||
startedTools.set(tool, started);
|
||||
}
|
||||
tools = await started.ready;
|
||||
}
|
||||
// The job installs its app after this, so a running Expo Go install must finish first.
|
||||
const preinstalledExpoGo = await selectedPreinstall;
|
||||
return {
|
||||
...(preinstalledExpoGo ? { preinstalledExpoGo } : {}),
|
||||
...(tools ? { tools } : {}),
|
||||
};
|
||||
},
|
||||
async stopAsync() {
|
||||
stopping = true;
|
||||
const cancellations = [...startedTools.values()].map(tool => tool.cancelAsync());
|
||||
lifetime.abort();
|
||||
await Promise.allSettled(cancellations);
|
||||
await preparation?.catch(() => {});
|
||||
await Promise.allSettled(simulatorSetups.values());
|
||||
const preinstall = await Promise.allSettled(
|
||||
warmDevices.map(device => expoGo?.stopAsync(device.udid))
|
||||
);
|
||||
await toolRetirement;
|
||||
await launcherCachePriming.catch(() => {});
|
||||
const errors = preinstall.flatMap(result =>
|
||||
result.status === 'rejected' ? [result.reason] : []
|
||||
);
|
||||
const attemptAsync = async (operation: Promise<void>) => {
|
||||
await operation.catch(error => errors.push(error));
|
||||
};
|
||||
// Keep handles after failure so terminal cleanup never mistakes wrapper exit for drain.
|
||||
for (const started of startedTools.values()) {
|
||||
await attemptAsync(started.stopAsync());
|
||||
}
|
||||
for (const tool of warmTools) {
|
||||
if (!startedTools.has(tool) && server) {
|
||||
await attemptAsync(tool.stopAsync());
|
||||
}
|
||||
}
|
||||
if (server) {
|
||||
await attemptAsync(
|
||||
stopProcessAndDescendantsAsync(server).then(() => {
|
||||
server = undefined;
|
||||
})
|
||||
);
|
||||
}
|
||||
await Promise.all(
|
||||
[...ownedDevices].map(udid =>
|
||||
attemptAsync(
|
||||
shutdownSimulatorAsync(udid).then(() => {
|
||||
ownedDevices.delete(udid);
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
try {
|
||||
commands.throwIfCommandCloseTimedOut();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
if (errors.length) {
|
||||
throw new AggregateError(
|
||||
errors,
|
||||
'Warm Simulator cleanup could not be confirmed; services or devices may still be running.'
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { updateLauncherStateAsync, waitForLauncherStateAsync } from '../utils/state';
|
||||
|
||||
export async function stopAsync(bootUuid: string): Promise<number> {
|
||||
const state = await updateLauncherStateAsync(bootUuid, current =>
|
||||
!current || current.status === 'disabled'
|
||||
? { ...current, bootUuid, status: 'disabled' }
|
||||
: { ...current, status: 'stopping' }
|
||||
);
|
||||
if (state.status !== 'disabled') {
|
||||
await waitForLauncherStateAsync(bootUuid, current => current?.status === 'disabled', 140_000);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { claimAsync, prepareToolsAsync } from './commands/claim';
|
||||
import { startAsync } from './commands/start';
|
||||
import { stopAsync } from './commands/stop';
|
||||
import { commands } from './utils/processes';
|
||||
|
||||
export async function mainAsync(args = process.argv.slice(2)): Promise<number> {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error('simulator-launcher requires macOS (darwin).');
|
||||
}
|
||||
if (args.length === 1 && args[0] === '--version') {
|
||||
process.stdout.write(`${require('../package.json').version}\n`);
|
||||
return 0;
|
||||
}
|
||||
const [command, engine, requestedDevice] = args;
|
||||
if (
|
||||
command !== 'start' &&
|
||||
command !== 'stop' &&
|
||||
command !== 'claim' &&
|
||||
command !== 'prepare-tools'
|
||||
) {
|
||||
throw new Error('Usage: simulator-launcher <start|stop|claim ENGINE [DEVICE]|prepare-tools>');
|
||||
}
|
||||
const bootUuid = (
|
||||
await commands.runAsync('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid'])
|
||||
).trim();
|
||||
if (!bootUuid) {
|
||||
throw new Error(
|
||||
'Could not read the VM boot identifier (kern.bootsessionuuid); startup cannot continue.'
|
||||
);
|
||||
}
|
||||
switch (command) {
|
||||
case 'start':
|
||||
return await startAsync(bootUuid);
|
||||
case 'stop':
|
||||
return await stopAsync(bootUuid);
|
||||
case 'claim':
|
||||
return await claimAsync(bootUuid, engine, requestedDevice);
|
||||
case 'prepare-tools':
|
||||
return await prepareToolsAsync(bootUuid);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCliAsync(): Promise<void> {
|
||||
try {
|
||||
process.exitCode = await mainAsync();
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(__filename)) {
|
||||
void runCliAsync();
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type ChildProcessHandle, commands, stopProcessAndDescendantsAsync } from './processes';
|
||||
import { type WarmTool, untilAbortedAsync } from './tools';
|
||||
|
||||
const packageSpec = (): string => `agent-device@${process.env.AGENT_DEVICE_VERSION || 'latest'}`;
|
||||
|
||||
/** `prepare ios-runner` builds and starts the runner and its daemon; it exits once both are up. */
|
||||
export function createAgentDeviceTool(): WarmTool {
|
||||
const stateDir = (process.env.AGENT_DEVICE_STATE_DIR ||= path.join(
|
||||
os.homedir(),
|
||||
'Library/Caches/expo-simulator-startup/agent-device'
|
||||
));
|
||||
process.env.AGENT_DEVICE_RETAIN_ARTIFACTS ||= '1';
|
||||
process.env.AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS ||= '0';
|
||||
process.env.AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS ||= '0';
|
||||
process.env.AGENT_DEVICE_IOS_RUNNER_DETACH ||= '0';
|
||||
let prepareProcess: ChildProcessHandle | undefined;
|
||||
return {
|
||||
name: 'agent-device',
|
||||
async startAsync(udid, signal) {
|
||||
signal.throwIfAborted();
|
||||
prepareProcess = commands.start(
|
||||
'bunx',
|
||||
[
|
||||
packageSpec(),
|
||||
'prepare',
|
||||
'ios-runner',
|
||||
'--platform',
|
||||
'ios',
|
||||
'--udid',
|
||||
udid,
|
||||
'--daemon-server-mode',
|
||||
'http',
|
||||
'--daemon-transport',
|
||||
'http',
|
||||
'--timeout',
|
||||
'240000',
|
||||
'--json',
|
||||
],
|
||||
{ detached: true }
|
||||
);
|
||||
await untilAbortedAsync(prepareProcess.resultPromise, signal);
|
||||
},
|
||||
async stopAsync() {
|
||||
const errors: unknown[] = [];
|
||||
if (prepareProcess) {
|
||||
await stopProcessAndDescendantsAsync(prepareProcess, /* allowCompletedProcess */ true).then(
|
||||
() => (prepareProcess = undefined),
|
||||
error => errors.push(error)
|
||||
);
|
||||
}
|
||||
// The daemon outlives the prepare process, so it stops even if the prepare stop failed.
|
||||
// A stop that cannot finish fails instead of blocking cleanup forever.
|
||||
await commands
|
||||
.runAsync(
|
||||
'bunx',
|
||||
[packageSpec(), 'daemon', 'stop', '--clean', '--state-dir', stateDir],
|
||||
AbortSignal.timeout(120_000)
|
||||
)
|
||||
.catch(error => errors.push(error));
|
||||
if (errors.length > 1) {
|
||||
throw new AggregateError(errors, 'agent-device could not be stopped.');
|
||||
}
|
||||
if (errors.length) {
|
||||
throw errors[0];
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as sleepAsync } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
type ChildProcessHandle,
|
||||
commands,
|
||||
isProcessOwnedByServiceAsync,
|
||||
stopProcessAndDescendantsAsync,
|
||||
} from './processes';
|
||||
import { type WarmTool } from './tools';
|
||||
|
||||
const packageSpec = (): string => `@swmansion/argent@${process.env.ARGENT_VERSION || 'latest'}`;
|
||||
|
||||
const ArgentStateSchema = z.object({
|
||||
pid: z.number().int().min(2),
|
||||
port: z.literal(3201),
|
||||
token: z.string().min(1),
|
||||
});
|
||||
const ToolsSchema = z.object({ tools: z.array(z.unknown()) });
|
||||
|
||||
export function createArgentTool(onUnexpectedExit: (error: unknown) => void): WarmTool {
|
||||
process.env.ARGENT_EVENT_LOG ||= path.join(os.homedir(), '.argent/tool-server-events.jsonl');
|
||||
process.env.ARGENT_EMULATOR_NO_WINDOW ||= '1';
|
||||
let serverProcess: ChildProcessHandle | undefined;
|
||||
let stopping = false;
|
||||
return {
|
||||
name: 'argent',
|
||||
async prepareAsync(signal) {
|
||||
signal.throwIfAborted();
|
||||
// The VM image provides ffmpeg; installing it here would delay every warm session.
|
||||
await commands.runAsync('ffmpeg', ['-version'], signal).catch(error => {
|
||||
signal.throwIfAborted();
|
||||
process.stderr.write(`ffmpeg is unavailable; Argent video tools may not work: ${error}\n`);
|
||||
});
|
||||
commands.throwIfCommandCloseTimedOut();
|
||||
|
||||
for (const [verb, flag] of [
|
||||
['enable', 'artifacts-list-endpoint'],
|
||||
['enable', 'tool-server-event-log'],
|
||||
['disable', 'video-watermark'],
|
||||
]) {
|
||||
signal.throwIfAborted();
|
||||
await commands.runAsync('bunx', [packageSpec(), verb, flag], signal).catch(error => {
|
||||
if (verb !== 'disable') {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
},
|
||||
async startAsync(_udid, signal) {
|
||||
signal.throwIfAborted();
|
||||
stopping = false;
|
||||
serverProcess = commands.start(
|
||||
'bunx',
|
||||
[
|
||||
packageSpec(),
|
||||
'server',
|
||||
'start',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'3201',
|
||||
'--idle-timeout',
|
||||
'0',
|
||||
],
|
||||
{ detached: true }
|
||||
);
|
||||
const server = serverProcess;
|
||||
let ready = false;
|
||||
let failure: Error | undefined;
|
||||
const recordExit = (cause?: unknown): void => {
|
||||
if (failure) {
|
||||
return;
|
||||
}
|
||||
failure = new Error(
|
||||
ready
|
||||
? 'Argent server exited unexpectedly.'
|
||||
: 'Argent exited before its server became ready.',
|
||||
{ cause }
|
||||
);
|
||||
if (ready && !stopping && !signal.aborted) {
|
||||
onUnexpectedExit(failure);
|
||||
}
|
||||
};
|
||||
server.child.once('exit', () => recordExit());
|
||||
server.child.once('error', recordExit);
|
||||
void server.resultPromise.then(() => recordExit(), recordExit);
|
||||
const stateDirectory = path.join(os.homedir(), '.argent');
|
||||
const deadline = Date.now() + 120_000;
|
||||
for (;;) {
|
||||
signal.throwIfAborted();
|
||||
if (failure) {
|
||||
throw failure;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error('Timed out waiting for the Argent server on port 3201 to become ready.');
|
||||
}
|
||||
const entries = await fs.readdir(stateDirectory).catch(() => []);
|
||||
for (const entry of entries) {
|
||||
if (!/^tool-server.*\.json$/.test(entry)) {
|
||||
continue;
|
||||
}
|
||||
const state = await fs
|
||||
.readFile(path.join(stateDirectory, entry), 'utf8')
|
||||
.then(contents => ArgentStateSchema.parse(JSON.parse(contents)))
|
||||
.catch(() => undefined);
|
||||
if (!state || !(await isProcessOwnedByServiceAsync(state.pid, server.child, signal))) {
|
||||
continue;
|
||||
}
|
||||
const tools = await fetch('http://127.0.0.1:3201/tools', {
|
||||
headers: { Authorization: `Bearer ${state.token}` },
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(2_000)]),
|
||||
})
|
||||
.then(async response =>
|
||||
response.ok ? ToolsSchema.safeParse(await response.json()).success : false
|
||||
)
|
||||
.catch(() => false);
|
||||
signal.throwIfAborted();
|
||||
if (failure) {
|
||||
throw failure;
|
||||
}
|
||||
if (tools && (await isProcessOwnedByServiceAsync(state.pid, server.child, signal))) {
|
||||
signal.throwIfAborted();
|
||||
if (failure) {
|
||||
throw failure;
|
||||
}
|
||||
ready = true;
|
||||
return state;
|
||||
}
|
||||
}
|
||||
await sleepAsync(250, undefined, { signal });
|
||||
}
|
||||
},
|
||||
async stopAsync() {
|
||||
stopping = true;
|
||||
if (serverProcess) {
|
||||
await stopProcessAndDescendantsAsync(serverProcess);
|
||||
serverProcess = undefined;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Writable } from 'node:stream';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { commands } from './processes';
|
||||
import { type PreinstalledApp } from './state';
|
||||
|
||||
const VERSIONS_URL = 'https://api.expo.dev/v2/versions';
|
||||
const HEADERS = { Accept: 'application/json', 'User-Agent': 'expo-simulator-launcher' };
|
||||
const VersionsSchema = z.object({
|
||||
sdkVersions: z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
releaseNoteUrl: z.string().optional(),
|
||||
isBeta: z.boolean().optional(),
|
||||
iosClientUrl: z.string().optional(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Downloads the latest stable Expo Go once, for every warm device. Claim forbids later installs;
|
||||
* started native installs and the shared archive stay owned until claim or stop releases them.
|
||||
*/
|
||||
export function startExpoGoPreinstall(signal: AbortSignal): {
|
||||
claimAsync(selectedUdid: string): Promise<PreinstalledApp | undefined>;
|
||||
preinstallOnDeviceAsync(udid: string, boot: Promise<unknown>, signal: AbortSignal): Promise<void>;
|
||||
stopAsync(selectedUdid: string): Promise<void>;
|
||||
} {
|
||||
const downloadController = new AbortController();
|
||||
const downloadSignal = AbortSignal.any([
|
||||
signal,
|
||||
downloadController.signal,
|
||||
AbortSignal.timeout(120_000),
|
||||
]);
|
||||
const directory = fs.mkdtemp(path.join(os.tmpdir(), 'simulator-expo-go-'));
|
||||
const installations = new Map<string, Promise<void>>();
|
||||
const installed = new Map<string, PreinstalledApp>();
|
||||
const operations = new Set<Promise<void>>();
|
||||
const failures = new Map<string, unknown>();
|
||||
let cleanup: Promise<void> | undefined;
|
||||
const archive = (async () => {
|
||||
const directoryPath = await directory;
|
||||
const response = await fetch(VERSIONS_URL, { headers: HEADERS, signal: downloadSignal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Expo Go versions returned HTTP ${response.status}.`);
|
||||
}
|
||||
const { sdkVersions } = VersionsSchema.parse(await response.json());
|
||||
// Same latest stable release policy as expo.dev/go (GoScene/utils.ts).
|
||||
const sdk = Object.keys(sdkVersions)
|
||||
.filter(sdk => /\b(\d*)\.0\.0/.test(sdk))
|
||||
.slice(-10)
|
||||
.reverse()
|
||||
.find(sdk => sdkVersions[sdk].releaseNoteUrl && !sdkVersions[sdk].isBeta);
|
||||
const url = sdk && sdkVersions[sdk].iosClientUrl;
|
||||
if (!url || new URL(url).protocol !== 'https:') {
|
||||
throw new Error('No stable Expo Go Simulator archive is available.');
|
||||
}
|
||||
const archive = await fetch(url, { headers: HEADERS, signal: downloadSignal });
|
||||
if (!archive.ok || !archive.body) {
|
||||
throw new Error(`Expo Go archive returned HTTP ${archive.status}.`);
|
||||
}
|
||||
const archivePath = path.join(directoryPath, 'expo-go.tar.gz');
|
||||
await archive.body.pipeTo(Writable.toWeb(createWriteStream(archivePath)), {
|
||||
signal: downloadSignal,
|
||||
});
|
||||
downloadSignal.throwIfAborted();
|
||||
// Wait for the owned extractor to close, including on bad archives and claim/stop.
|
||||
const appPath = path.join(directoryPath, 'ExpoGo.app');
|
||||
await fs.mkdir(appPath);
|
||||
await commands.runAsync('/usr/bin/tar', ['-xzf', archivePath, '-C', appPath]);
|
||||
downloadSignal.throwIfAborted();
|
||||
await fs.access(path.join(appPath, 'Info.plist'));
|
||||
|
||||
return { appPath, sdk };
|
||||
})();
|
||||
void archive.catch(() => {});
|
||||
|
||||
async function preinstallAsync(
|
||||
target: string,
|
||||
boot: Promise<unknown>,
|
||||
deviceSignal: AbortSignal
|
||||
): Promise<void> {
|
||||
void boot.catch(() => {});
|
||||
let installation: Promise<void> | undefined;
|
||||
const installSignal = AbortSignal.any([signal, deviceSignal, downloadController.signal]);
|
||||
try {
|
||||
const { appPath, sdk } = await archive;
|
||||
await boot;
|
||||
installSignal.throwIfAborted();
|
||||
// No await between checking the claim latch and registering the native install.
|
||||
process.stderr.write(`Preinstalling Expo Go for SDK ${sdk} on Simulator ${target}.\n`);
|
||||
installation = commands.runAsync('xcrun', ['simctl', 'install', target, appPath]).then(
|
||||
async () => {
|
||||
process.stderr.write('Expo Go preinstall finished.\n');
|
||||
const info = await readPreinstalledAppAsync(target, appPath).catch(error => {
|
||||
process.stderr.write(`Could not read the preinstalled Expo Go version: ${error}\n`);
|
||||
return undefined;
|
||||
});
|
||||
if (info) {
|
||||
installed.set(target, info);
|
||||
}
|
||||
},
|
||||
error => {
|
||||
commands.throwIfCommandCloseTimedOut();
|
||||
if (error.code === null) {
|
||||
throw error;
|
||||
}
|
||||
process.stderr.write(`Could not preinstall Expo Go on ${target}: ${error}\n`);
|
||||
}
|
||||
);
|
||||
installations.set(target, installation);
|
||||
await installation;
|
||||
} catch (error) {
|
||||
commands.throwIfCommandCloseTimedOut();
|
||||
if (installation) {
|
||||
throw error;
|
||||
}
|
||||
if (!installSignal.aborted) {
|
||||
process.stderr.write(`Could not preinstall Expo Go on ${target}: ${error}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanUpAsync(): Promise<void> {
|
||||
return await (cleanup ??= (async () => {
|
||||
// Install failures belong to a device; cleanup still drains every operation.
|
||||
await Promise.allSettled([...operations]);
|
||||
await archive.catch(() => {});
|
||||
// Unproved close never permits temporary-file removal or a stopped acknowledgement.
|
||||
commands.throwIfCommandCloseTimedOut();
|
||||
await fs.rm(await directory, { recursive: true, force: true });
|
||||
})());
|
||||
}
|
||||
return {
|
||||
async preinstallOnDeviceAsync(target, boot, deviceSignal) {
|
||||
if (downloadController.signal.aborted || cleanup) {
|
||||
return;
|
||||
}
|
||||
const operation = preinstallAsync(target, boot, deviceSignal).catch(error => {
|
||||
failures.set(target, error);
|
||||
throw error;
|
||||
});
|
||||
operations.add(operation);
|
||||
await operation;
|
||||
},
|
||||
async claimAsync(selectedUdid) {
|
||||
downloadController.abort();
|
||||
void cleanUpAsync().catch(() => {});
|
||||
await installations.get(selectedUdid);
|
||||
if (failures.has(selectedUdid)) {
|
||||
throw failures.get(selectedUdid);
|
||||
}
|
||||
return installed.get(selectedUdid);
|
||||
},
|
||||
async stopAsync(selectedUdid) {
|
||||
// The session owner must still confirm Simulator shutdown before publishing disabled.
|
||||
downloadController.abort();
|
||||
await cleanUpAsync();
|
||||
commands.throwIfCommandCloseTimedOut();
|
||||
if (failures.has(selectedUdid)) {
|
||||
throw failures.get(selectedUdid);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function readPreinstalledAppAsync(udid: string, appPath: string): Promise<PreinstalledApp> {
|
||||
const infoPlistPath = path.join(appPath, 'Info.plist');
|
||||
const [bundleIdentifier, shortVersion, version] = await Promise.all(
|
||||
['CFBundleIdentifier', 'CFBundleShortVersionString', 'CFBundleVersion'].map(async key =>
|
||||
(await commands.runAsync('plutil', ['-extract', key, 'raw', '-o', '-', infoPlistPath])).trim()
|
||||
)
|
||||
);
|
||||
return { udid, bundleIdentifier, shortVersion, version };
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import { type ChildProcess, spawn } from 'node:child_process';
|
||||
import { setTimeout as sleepAsync } from 'node:timers/promises';
|
||||
|
||||
export type ChildProcessHandle = {
|
||||
child: ChildProcess;
|
||||
resultPromise: Promise<string>;
|
||||
/** Present only when this child was started as the leader of its own process group. */
|
||||
processGroupId?: number;
|
||||
};
|
||||
let commandCloseDrainError: Error | undefined;
|
||||
|
||||
export const commands = {
|
||||
start(
|
||||
file: string,
|
||||
args: string[],
|
||||
{
|
||||
signal,
|
||||
captureStdout = false,
|
||||
detached = false,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
captureStdout?: boolean;
|
||||
/** Lead a new process group so cleanup can stop children that respawn. */
|
||||
detached?: boolean;
|
||||
} = {}
|
||||
): ChildProcessHandle {
|
||||
const child = spawn(file, args, {
|
||||
stdio: captureStdout ? ['ignore', 'pipe', 'inherit'] : 'inherit',
|
||||
signal,
|
||||
detached,
|
||||
});
|
||||
let stdout = '';
|
||||
let error: Error | undefined;
|
||||
let closeDrainTimer: NodeJS.Timeout | undefined;
|
||||
const resultPromise = new Promise<string>((resolve, reject) => {
|
||||
const startCloseDrainTimeout = (): void => {
|
||||
closeDrainTimer ??= setTimeout(() => {
|
||||
commandCloseDrainError ||= new Error(
|
||||
`${file} did not finish closing within 130 seconds after exit or error; Simulator cleanup could not be confirmed.`,
|
||||
{
|
||||
cause: error,
|
||||
}
|
||||
);
|
||||
try {
|
||||
child.kill('SIGKILL');
|
||||
} catch {
|
||||
/* Teardown remains failed. */
|
||||
}
|
||||
child.stdout?.destroy();
|
||||
child.unref();
|
||||
reject(error ?? commandCloseDrainError);
|
||||
}, 130_000);
|
||||
};
|
||||
child.stdout?.on('data', data => {
|
||||
stdout += data;
|
||||
});
|
||||
child.once('error', failure => {
|
||||
error = failure;
|
||||
startCloseDrainTimeout();
|
||||
});
|
||||
child.once('exit', (code, signal) => {
|
||||
if (code !== 0) {
|
||||
error ||= Object.assign(new Error(`${file} exited with ${signal ?? code}.`), { code });
|
||||
}
|
||||
startCloseDrainTimeout();
|
||||
});
|
||||
child.once('close', () => {
|
||||
clearTimeout(closeDrainTimer);
|
||||
error ? reject(error) : resolve(stdout);
|
||||
});
|
||||
});
|
||||
void resultPromise.catch(() => {}); // Observe long-lived launches before startup joins or cleanup await them.
|
||||
return {
|
||||
child,
|
||||
resultPromise,
|
||||
...(detached && child.pid ? { processGroupId: child.pid } : {}),
|
||||
};
|
||||
},
|
||||
async runAsync(file: string, args: string[], signal?: AbortSignal): Promise<string> {
|
||||
return await commands.start(file, args, { signal, captureStdout: true }).resultPromise;
|
||||
},
|
||||
throwIfCommandCloseTimedOut(): void {
|
||||
if (commandCloseDrainError) {
|
||||
throw commandCloseDrainError;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** Checks ancestry only while the service's owned child is still alive. */
|
||||
export async function isProcessOwnedByServiceAsync(
|
||||
pid: number,
|
||||
child: ChildProcess,
|
||||
signal: AbortSignal
|
||||
): Promise<boolean> {
|
||||
const seen = new Set<number>();
|
||||
for (let depth = 0; depth < 32; depth++) {
|
||||
signal.throwIfAborted();
|
||||
if (
|
||||
!Number.isSafeInteger(pid) ||
|
||||
pid <= 1 ||
|
||||
!child.pid ||
|
||||
child.exitCode != null ||
|
||||
child.signalCode != null ||
|
||||
seen.has(pid)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (pid === child.pid) {
|
||||
return true;
|
||||
}
|
||||
seen.add(pid);
|
||||
try {
|
||||
pid = Number(
|
||||
(await commands.runAsync('ps', ['-p', String(pid), '-o', 'ppid='], signal)).trim()
|
||||
);
|
||||
} catch (error) {
|
||||
signal.throwIfAborted();
|
||||
if ((error as { code?: unknown }).code === 1) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** EPERM means the process exists but cannot be signalled, as for an exited one awaiting reap. */
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ESRCH') {
|
||||
return false;
|
||||
}
|
||||
if (code === 'EPERM') {
|
||||
return true;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Signals a process; false when it is gone, true when it was signalled or cannot be. */
|
||||
function signalProcess(pid: number, signal: NodeJS.Signals): boolean {
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ESRCH') {
|
||||
return false;
|
||||
}
|
||||
if (code !== 'EPERM') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* macOS reports EPERM for a group whose only members have exited and await reaping, so EPERM
|
||||
* is resolved by checking for a member that is not a zombie.
|
||||
*/
|
||||
async function isProcessGroupRunningAsync(groupId: number): Promise<boolean> {
|
||||
try {
|
||||
process.kill(-groupId, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ESRCH') {
|
||||
return false;
|
||||
}
|
||||
if (code !== 'EPERM') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const members = await commands.runAsync('ps', ['-A', '-o', 'pgid=,stat=']);
|
||||
return members
|
||||
.trim()
|
||||
.split('\n')
|
||||
.some(line => {
|
||||
const [pgid, stat = ''] = line.trim().split(/\s+/);
|
||||
return Number(pgid) === groupId && !stat.startsWith('Z');
|
||||
});
|
||||
}
|
||||
|
||||
async function stopProcessTreeAsync(
|
||||
pid: number,
|
||||
deadlineMs: number,
|
||||
ownedChildProcess?: ChildProcess,
|
||||
stopIntent?: { child: ChildProcess; descendantSignalled: boolean }
|
||||
): Promise<void> {
|
||||
const children = await commands.runAsync('pgrep', ['-P', String(pid)]).catch(error => {
|
||||
if (error.code !== 1) {
|
||||
throw error;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
if (
|
||||
ownedChildProcess &&
|
||||
(ownedChildProcess.exitCode != null || ownedChildProcess.signalCode != null)
|
||||
) {
|
||||
throw new Error(
|
||||
'A Simulator service launcher exited before its child processes could be identified; cleanup could not be confirmed.'
|
||||
);
|
||||
}
|
||||
const descendants = await Promise.allSettled(
|
||||
children
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map(child => stopProcessTreeAsync(Number(child), deadlineMs, undefined, stopIntent))
|
||||
);
|
||||
const failedDescendant = descendants.find(result => result.status === 'rejected');
|
||||
if (failedDescendant?.status === 'rejected') {
|
||||
throw failedDescendant.reason;
|
||||
}
|
||||
if (
|
||||
ownedChildProcess &&
|
||||
(ownedChildProcess.exitCode != null || ownedChildProcess.signalCode != null)
|
||||
) {
|
||||
// A wrapper can propagate its deliberately stopped child's nonzero status. The caller still
|
||||
// requires the owned group to drain and the owned close event; never probe this PID again.
|
||||
if (stopIntent ? stopIntent.descendantSignalled : ownedChildProcess.exitCode === 0) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
'A Simulator service launcher exited unexpectedly while its child processes were stopping; cleanup could not be confirmed.'
|
||||
);
|
||||
}
|
||||
if (!isProcessRunning(pid)) {
|
||||
return;
|
||||
}
|
||||
const wrapperWasRunning =
|
||||
stopIntent && stopIntent.child.exitCode == null && stopIntent.child.signalCode == null;
|
||||
try {
|
||||
if (ownedChildProcess) {
|
||||
if (!ownedChildProcess.kill('SIGTERM')) {
|
||||
throw new Error(
|
||||
'A Simulator service launcher exited before it could be stopped safely; cleanup could not be confirmed.'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!signalProcess(pid, 'SIGTERM')) {
|
||||
return;
|
||||
}
|
||||
if (stopIntent && wrapperWasRunning) {
|
||||
stopIntent.descendantSignalled = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') {
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// ChildProcess is authoritative once its exit is observed; the numeric PID could be reused.
|
||||
const hasExited = (): boolean =>
|
||||
ownedChildProcess !== undefined &&
|
||||
(ownedChildProcess.exitCode != null || ownedChildProcess.signalCode != null);
|
||||
while (!hasExited() && isProcessRunning(pid)) {
|
||||
const state = await commands
|
||||
.runAsync('ps', ['-p', String(pid), '-o', 'state='])
|
||||
.catch(error => {
|
||||
if (error.code === 1 && (hasExited() || !isProcessRunning(pid))) {
|
||||
return '';
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (hasExited() || state.trim().startsWith('Z') || !isProcessRunning(pid)) {
|
||||
return;
|
||||
}
|
||||
if (Date.now() >= deadlineMs) {
|
||||
throw new Error(
|
||||
`Simulator service process ${pid} is still running; cleanup could not be confirmed.`
|
||||
);
|
||||
}
|
||||
await sleepAsync(100);
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopProcessAndDescendantsAsync(
|
||||
processHandle: ChildProcessHandle,
|
||||
allowCompletedProcess = false
|
||||
): Promise<void> {
|
||||
const { child } = processHandle;
|
||||
const groupId = processHandle.processGroupId;
|
||||
if (groupId !== undefined && groupId !== child.pid) {
|
||||
throw new Error('The Simulator service process group does not belong to its owned launcher.');
|
||||
}
|
||||
if (child.pid && child.exitCode == null && child.signalCode == null) {
|
||||
await stopProcessTreeAsync(
|
||||
child.pid,
|
||||
Date.now() + 130_000,
|
||||
child,
|
||||
groupId === undefined ? undefined : { child, descendantSignalled: false }
|
||||
);
|
||||
if (groupId !== undefined) {
|
||||
// A supervisor can respawn children after the initial snapshot. Its exclusive group must
|
||||
// also be empty; draining only the children we discovered is not sufficient.
|
||||
await terminateProcessGroupAsync(processHandle);
|
||||
}
|
||||
} else if (child.pid && !allowCompletedProcess) {
|
||||
// Never inspect a reused wrapper PID or infer that its descendants have also exited.
|
||||
throw new Error(
|
||||
'A Simulator service exited before its child processes could be stopped; cleanup could not be confirmed.'
|
||||
);
|
||||
}
|
||||
await processHandle.resultPromise.catch(() => {});
|
||||
commands.throwIfCommandCloseTimedOut();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops a process group that has nothing to finalize. The handle must lead its own group
|
||||
* (`commands.start(..., { detached: true })`), so children spawned or respawned during the stop are
|
||||
* signalled too, and the group ID cannot be reused while any member is alive.
|
||||
*/
|
||||
export async function terminateProcessGroupAsync(
|
||||
processHandle: ChildProcessHandle,
|
||||
graceMs = 5_000
|
||||
): Promise<void> {
|
||||
const groupId = processHandle.child.pid;
|
||||
if (!groupId) {
|
||||
throw new Error('A Simulator service has no process group; cleanup could not be confirmed.');
|
||||
}
|
||||
for (const signal of ['SIGTERM', 'SIGKILL'] as const) {
|
||||
if (!(await isProcessGroupRunningAsync(groupId)) || !signalProcess(-groupId, signal)) {
|
||||
break;
|
||||
}
|
||||
const deadline = Date.now() + graceMs;
|
||||
while ((await isProcessGroupRunningAsync(groupId)) && Date.now() < deadline) {
|
||||
await sleepAsync(100);
|
||||
}
|
||||
}
|
||||
if (await isProcessGroupRunningAsync(groupId)) {
|
||||
throw new Error(
|
||||
`Simulator service process group ${groupId} is still running; cleanup could not be confirmed.`
|
||||
);
|
||||
}
|
||||
await processHandle.resultPromise.catch(() => {});
|
||||
commands.throwIfCommandCloseTimedOut();
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as sleepAsync } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type ChildProcessHandle, commands, isProcessOwnedByServiceAsync } from './processes';
|
||||
import { type WarmServer } from './state';
|
||||
|
||||
const SERVE_SIM_READY_TIMEOUT_MS = 120_000;
|
||||
// The job needs the token from this file; /readyz itself is not token-gated.
|
||||
const ServeSimStateSchema = z.object({
|
||||
pid: z.number().int().min(2),
|
||||
port: z.number().int().min(1).max(65535),
|
||||
device: z.string(),
|
||||
url: z.string(),
|
||||
token: z.string().min(1),
|
||||
});
|
||||
|
||||
export function startServeSim(
|
||||
devices: string[],
|
||||
signal: AbortSignal,
|
||||
port = 3200
|
||||
): ChildProcessHandle {
|
||||
signal.throwIfAborted();
|
||||
return commands.start(
|
||||
'bunx',
|
||||
[
|
||||
`@expo/serve-sim@${process.env.SERVE_SIM_VERSION || 'latest'}`,
|
||||
...devices,
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
String(port),
|
||||
'--require-token',
|
||||
'--orphan',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'h264',
|
||||
'--max-dimension',
|
||||
'1600',
|
||||
'--mjpeg-quality',
|
||||
'0.55',
|
||||
'--video-bitrate',
|
||||
'10000000',
|
||||
'--video-fps',
|
||||
'60',
|
||||
'--disable-share',
|
||||
'--quiet',
|
||||
],
|
||||
// The orphan owns boot, device selection and its native children for this VM's job.
|
||||
{ detached: true }
|
||||
);
|
||||
}
|
||||
|
||||
/** A job reads serve-sim's state and calls /readyz as soon as its claim returns. */
|
||||
export async function waitForServeSimReadyAsync(
|
||||
udid: string,
|
||||
server: ChildProcessHandle,
|
||||
signal: AbortSignal,
|
||||
port = 3200
|
||||
): Promise<WarmServer> {
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
let exited = false;
|
||||
// Handle both outcomes: a rejection here would otherwise be unhandled and end the launcher.
|
||||
const markExited = (): void => {
|
||||
exited = true;
|
||||
};
|
||||
void server.resultPromise.then(markExited, markExited);
|
||||
const throwIfExited = (): void => {
|
||||
if (exited || server.child.exitCode != null || server.child.signalCode != null) {
|
||||
throw new Error(`serve-sim exited before ${udid} became ready.`);
|
||||
}
|
||||
};
|
||||
const stateDirectory = process.env.SERVE_SIM_STATE_DIR || path.join(os.tmpdir(), 'serve-sim');
|
||||
const stateFile = path.join(stateDirectory, `server-${udid}.json`);
|
||||
const deadline = Date.now() + SERVE_SIM_READY_TIMEOUT_MS;
|
||||
for (;;) {
|
||||
signal.throwIfAborted();
|
||||
throwIfExited();
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`Timed out waiting for serve-sim on ${udid} to become ready.`);
|
||||
}
|
||||
const state = await fs
|
||||
.readFile(stateFile, 'utf8')
|
||||
.then(contents => ServeSimStateSchema.parse(JSON.parse(contents)))
|
||||
.catch(() => undefined);
|
||||
// Only the local serve-sim this launcher started may be probed; the job checks the same.
|
||||
if (state && state.url !== url) {
|
||||
throw new Error(`serve-sim on ${udid} reports ${state.url}, not ${url}.`);
|
||||
}
|
||||
if (state && (state.port !== port || state.device.toUpperCase() !== udid.toUpperCase())) {
|
||||
throw new Error('The warm serve-sim registration reports a different device or port.');
|
||||
}
|
||||
let readyForDevice = false;
|
||||
if (state && (await isProcessOwnedByServiceAsync(state.pid, server.child, signal))) {
|
||||
try {
|
||||
const response = await fetch(`${url}/readyz`, {
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(2_000)]),
|
||||
});
|
||||
const ready = response.ok
|
||||
? ((await response.json()) as { status?: string; device?: string })
|
||||
: {};
|
||||
readyForDevice =
|
||||
ready.status === 'ready' && ready.device?.toUpperCase() === udid.toUpperCase();
|
||||
} catch {
|
||||
// serve-sim writes its state and answers /readyz only once it is listening.
|
||||
}
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
throwIfExited();
|
||||
if (
|
||||
readyForDevice &&
|
||||
state &&
|
||||
(await isProcessOwnedByServiceAsync(state.pid, server.child, signal))
|
||||
) {
|
||||
signal.throwIfAborted();
|
||||
throwIfExited();
|
||||
const nativeStartTime = (
|
||||
await commands.runAsync('ps', ['-p', String(state.pid), '-o', 'lstart='], signal)
|
||||
).trim();
|
||||
signal.throwIfAborted();
|
||||
throwIfExited();
|
||||
if (
|
||||
!nativeStartTime ||
|
||||
!(await isProcessOwnedByServiceAsync(state.pid, server.child, signal))
|
||||
) {
|
||||
throw new Error('The warm serve-sim process identity could not be confirmed.');
|
||||
}
|
||||
return {
|
||||
startupDevice: udid,
|
||||
pid: state.pid,
|
||||
nativeStartTime,
|
||||
port,
|
||||
url,
|
||||
token: state.token,
|
||||
};
|
||||
}
|
||||
await sleepAsync(250, undefined, { signal });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { z } from 'zod';
|
||||
import { setTimeout as sleepAsync } from 'node:timers/promises';
|
||||
|
||||
import { commands } from './processes';
|
||||
import { type SimulatorDevice, SimulatorDeviceSchema } from './state';
|
||||
|
||||
const AvailableSimulatorsSchema = z.object({
|
||||
devices: z.record(
|
||||
z.string(),
|
||||
z.array(SimulatorDeviceSchema.extend({ isAvailable: z.boolean().optional() }))
|
||||
),
|
||||
});
|
||||
const SimulatorStatesSchema = z.object({
|
||||
devices: z.record(z.string(), z.array(z.object({ udid: z.string(), state: z.string() }))),
|
||||
});
|
||||
|
||||
export async function listAvailableSimulatorsAsync(
|
||||
signal: AbortSignal
|
||||
): Promise<SimulatorDevice[]> {
|
||||
const { devices } = AvailableSimulatorsSchema.parse(
|
||||
JSON.parse(
|
||||
await commands.runAsync('xcrun', ['simctl', 'list', 'devices', 'available', '--json'], signal)
|
||||
)
|
||||
);
|
||||
return Object.values(devices)
|
||||
.flat()
|
||||
.filter(device => device.isAvailable !== false);
|
||||
}
|
||||
|
||||
/** Devices matching an exact name or UDID, or else the available iPhone with the shortest name. */
|
||||
export function selectSimulators(devices: SimulatorDevice[], selector?: string): SimulatorDevice[] {
|
||||
return selector
|
||||
? devices.filter(
|
||||
device => device.name === selector || device.udid.toLowerCase() === selector.toLowerCase()
|
||||
)
|
||||
: devices
|
||||
.filter(device => device.name.startsWith('iPhone'))
|
||||
.sort((a, b) => a.name.length - b.name.length)
|
||||
.slice(0, 1);
|
||||
}
|
||||
|
||||
/** Resolve every configured warm device before native work; the first is the claim default. */
|
||||
export function selectWarmupSimulators(
|
||||
devices: SimulatorDevice[],
|
||||
selectors?: string
|
||||
): SimulatorDevice[] {
|
||||
const entries = !selectors?.trim()
|
||||
? [undefined]
|
||||
: selectors.split(',').map(value => value.trim());
|
||||
const selected = new Set<string>();
|
||||
return entries.map(selector => {
|
||||
if (selector === '') {
|
||||
throw new Error('EXPO_SIMULATOR_DEVICE must contain nonempty device names or UDIDs.');
|
||||
}
|
||||
const matches = selectSimulators(devices, selector);
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(
|
||||
`Expected one available Simulator for ${selector ?? 'the default iPhone'}, found ${matches.length}. Use an exact device name or UDID.`
|
||||
);
|
||||
}
|
||||
const device = matches[0];
|
||||
const udid = device.udid.toLowerCase();
|
||||
if (selected.has(udid)) {
|
||||
throw new Error(
|
||||
`Simulator ${device.name} (${device.udid}) appears more than once in EXPO_SIMULATOR_DEVICE.`
|
||||
);
|
||||
}
|
||||
selected.add(udid);
|
||||
return device;
|
||||
});
|
||||
}
|
||||
|
||||
/** Observe an orphan-owned warm boot without starting another boot or killing it on claim. */
|
||||
export async function waitForSimulatorBootAsync(udid: string, signal: AbortSignal): Promise<void> {
|
||||
const deadline = Date.now() + 120_000;
|
||||
while (Date.now() < deadline) {
|
||||
signal.throwIfAborted();
|
||||
const { devices } = SimulatorStatesSchema.parse(
|
||||
JSON.parse(await commands.runAsync('xcrun', ['simctl', 'list', 'devices', '--json'], signal))
|
||||
);
|
||||
if (
|
||||
Object.values(devices)
|
||||
.flat()
|
||||
.some(device => device.udid === udid && device.state === 'Booted')
|
||||
) {
|
||||
await commands.runAsync('xcrun', ['simctl', 'bootstatus', udid], signal);
|
||||
return;
|
||||
}
|
||||
await sleepAsync(250, undefined, { signal });
|
||||
}
|
||||
throw new Error(`Timed out waiting for serve-sim to boot Simulator ${udid}.`);
|
||||
}
|
||||
|
||||
/** `simctl shutdown` fails for a device that is already shut down; that device is done. */
|
||||
export async function shutdownSimulatorAsync(udid: string): Promise<void> {
|
||||
// A shutdown that cannot finish fails instead of blocking cleanup forever.
|
||||
const signal = AbortSignal.timeout(120_000);
|
||||
try {
|
||||
await commands.runAsync('xcrun', ['simctl', 'shutdown', udid], signal);
|
||||
} catch (error) {
|
||||
const { devices } = SimulatorStatesSchema.parse(
|
||||
JSON.parse(await commands.runAsync('xcrun', ['simctl', 'list', 'devices', '--json'], signal))
|
||||
);
|
||||
if (
|
||||
Object.values(devices)
|
||||
.flat()
|
||||
.find(device => device.udid === udid)?.state !== 'Shutdown'
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Push notifications are not used in warm sessions, and apsd's work competes with app installs. */
|
||||
export async function disableApsdAsync(udid: string, signal: AbortSignal): Promise<void> {
|
||||
const launchctl = (...args: string[]): Promise<string> =>
|
||||
commands.runAsync('xcrun', ['simctl', 'spawn', udid, 'launchctl', ...args], signal);
|
||||
let lastError: unknown;
|
||||
for (const domain of ['user/foreground', 'system']) {
|
||||
const service = `${domain}/com.apple.apsd`;
|
||||
try {
|
||||
await launchctl('disable', service);
|
||||
// bootout fails when apsd is already gone; the list check below decides.
|
||||
await launchctl('bootout', service).catch(error => {
|
||||
lastError = error;
|
||||
});
|
||||
// launchctl list exits 113 for a service that is not loaded.
|
||||
const loaded = await launchctl('list', 'com.apple.apsd').then(
|
||||
() => true,
|
||||
error => (error.code === 113 ? false : Promise.reject(error))
|
||||
);
|
||||
if (!loaded) {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
signal.throwIfAborted();
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
throw lastError ?? new Error('apsd is still loaded in the Simulator.');
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import { setTimeout as sleepAsync } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const LAUNCHER_STATE_FILE = '/tmp/expo-simulator-startup.json';
|
||||
|
||||
const NonBlankStringSchema = z.string().regex(/\S/, 'Must not be blank.');
|
||||
export const SimulatorDeviceSchema = z.object({
|
||||
udid: NonBlankStringSchema,
|
||||
name: NonBlankStringSchema,
|
||||
});
|
||||
export const WarmServerSchema = z.object({
|
||||
startupDevice: NonBlankStringSchema,
|
||||
pid: z.number().int().gt(1),
|
||||
nativeStartTime: NonBlankStringSchema,
|
||||
port: z.number().int().min(1).max(65535),
|
||||
url: z.string().url(),
|
||||
token: NonBlankStringSchema,
|
||||
});
|
||||
const ToolServerSchema = WarmServerSchema.pick({ pid: true, port: true, token: true });
|
||||
export const ClaimInputSchema = z.object({
|
||||
engine: z.enum(['serve-sim', 'agent-device', 'argent']),
|
||||
requestedDevice: NonBlankStringSchema.optional(),
|
||||
});
|
||||
/** The Expo Go this launcher installed on `udid` and never launched. */
|
||||
export const PreinstalledAppSchema = z.object({
|
||||
udid: NonBlankStringSchema,
|
||||
bundleIdentifier: NonBlankStringSchema,
|
||||
shortVersion: z.string(),
|
||||
version: z.string(),
|
||||
});
|
||||
export const WarmSessionInfoSchema = z.object({
|
||||
// The default before claim, then the device selected for the job.
|
||||
device: SimulatorDeviceSchema,
|
||||
server: WarmServerSchema,
|
||||
controllerPid: z.number().int().gt(1),
|
||||
controllerStartTime: NonBlankStringSchema,
|
||||
agentDeviceStateDir: NonBlankStringSchema,
|
||||
argentEventLogPath: NonBlankStringSchema,
|
||||
});
|
||||
export const LauncherStateSchema = WarmSessionInfoSchema.partial().extend({
|
||||
bootUuid: NonBlankStringSchema,
|
||||
launcherVersion: NonBlankStringSchema.optional(),
|
||||
status: z.enum(['starting', 'ready', 'claiming', 'claimed', 'stopping', 'disabled']),
|
||||
...ClaimInputSchema.partial().shape,
|
||||
preinstalledExpoGo: PreinstalledAppSchema.optional(),
|
||||
prepareToolsRequested: z.boolean().optional(),
|
||||
toolsReady: z.boolean().optional(),
|
||||
tools: ToolServerSchema.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
export type ClaimInput = z.infer<typeof ClaimInputSchema>;
|
||||
export type LauncherState = z.infer<typeof LauncherStateSchema>;
|
||||
export type WarmSessionInfo = z.infer<typeof WarmSessionInfoSchema>;
|
||||
export type WarmServer = z.infer<typeof WarmServerSchema>;
|
||||
export type ToolServer = z.infer<typeof ToolServerSchema>;
|
||||
export type SimulatorDevice = z.infer<typeof SimulatorDeviceSchema>;
|
||||
export type PreinstalledApp = z.infer<typeof PreinstalledAppSchema>;
|
||||
|
||||
export async function readLauncherStateAsync(bootUuid: string): Promise<LauncherState | undefined> {
|
||||
let contents: string;
|
||||
try {
|
||||
contents = await fs.readFile(LAUNCHER_STATE_FILE, 'utf8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const state = LauncherStateSchema.parse(JSON.parse(contents));
|
||||
return state.bootUuid === bootUuid ? state : undefined;
|
||||
}
|
||||
|
||||
/** Serialize only state I/O. The callback is synchronous; native work belongs outside this lock. */
|
||||
export async function updateLauncherStateAsync(
|
||||
bootUuid: string,
|
||||
update: (state: LauncherState | undefined) => LauncherState,
|
||||
signal?: AbortSignal
|
||||
): Promise<LauncherState> {
|
||||
const lock = `${LAUNCHER_STATE_FILE}.${bootUuid}.lock`;
|
||||
const deadline = Date.now() + 5_000;
|
||||
for (;;) {
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
await fs.mkdir(lock);
|
||||
break;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||
throw error;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(
|
||||
'Simulator state is locked by another command; cleanup cannot be confirmed.'
|
||||
);
|
||||
}
|
||||
await sleepAsync(50, undefined, { signal });
|
||||
}
|
||||
}
|
||||
const temporaryPath = `${LAUNCHER_STATE_FILE}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
const state = LauncherStateSchema.parse(update(await readLauncherStateAsync(bootUuid)));
|
||||
if (state.bootUuid !== bootUuid) {
|
||||
throw new Error('Simulator state belongs to a different VM boot.');
|
||||
}
|
||||
// The claimed server credentials belong to this OS user, like serve-sim's own registry.
|
||||
await fs.writeFile(temporaryPath, `${JSON.stringify(state)}\n`, { flag: 'wx', mode: 0o600 });
|
||||
signal?.throwIfAborted();
|
||||
await fs.rename(temporaryPath, LAUNCHER_STATE_FILE);
|
||||
return state;
|
||||
} finally {
|
||||
try {
|
||||
await fs.rm(temporaryPath, { force: true });
|
||||
} finally {
|
||||
await fs.rmdir(lock);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForLauncherStateAsync(
|
||||
bootUuid: string,
|
||||
isComplete: (state: LauncherState | undefined) => boolean,
|
||||
timeoutMs: number
|
||||
): Promise<LauncherState | undefined> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
const state = await readLauncherStateAsync(bootUuid);
|
||||
if (isComplete(state)) {
|
||||
return state;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(
|
||||
'Timed out waiting for the Simulator launcher; cleanup may still be running.'
|
||||
);
|
||||
}
|
||||
await sleepAsync(100);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { type ClaimInput, type ToolServer } from './state';
|
||||
|
||||
/** A tool the warm session can hand to one job. */
|
||||
export type WarmTool = {
|
||||
name: Exclude<ClaimInput['engine'], 'serve-sim'>;
|
||||
/** Work without Simulator access, such as configuration; runs as soon as the tool starts. */
|
||||
prepareAsync?(signal: AbortSignal): Promise<void>;
|
||||
/**
|
||||
* Starts after serve-sim is ready and resolves when the tool is ready. It must
|
||||
* settle promptly once `signal` aborts: claim-time stops wait for it.
|
||||
*/
|
||||
startAsync(udid: string, signal: AbortSignal): Promise<ToolServer | void>;
|
||||
/**
|
||||
* Stops the tool and its native state after its startup settled. A handle whose stop failed stays
|
||||
* owned and is never restarted, so a later stop retries it.
|
||||
*/
|
||||
stopAsync(): Promise<void>;
|
||||
};
|
||||
|
||||
export type StartedTool = {
|
||||
ready: Promise<ToolServer | void>;
|
||||
/** Cancels a startup that is still running, without stopping the tool. */
|
||||
cancelAsync(): Promise<void>;
|
||||
stopAsync(): Promise<void>;
|
||||
};
|
||||
|
||||
/** Starts `tool` once serve-sim is ready. A failure before cancellation goes to `onFailure`. */
|
||||
export function startTool(
|
||||
tool: WarmTool,
|
||||
udid: string,
|
||||
serveSimReady: Promise<unknown>,
|
||||
signal: AbortSignal,
|
||||
onFailure: (error: unknown) => void
|
||||
): StartedTool {
|
||||
const controller = new AbortController();
|
||||
const toolSignal = AbortSignal.any([signal, controller.signal]);
|
||||
// The executor catches a synchronous throw, so the caller's cleanup still runs.
|
||||
const prepared = new Promise<void>(resolve => resolve(tool.prepareAsync?.(toolSignal)));
|
||||
const ready = Promise.all([serveSimReady, prepared]).then(() =>
|
||||
tool.startAsync(udid, toolSignal)
|
||||
);
|
||||
void ready.catch(error => {
|
||||
if (!controller.signal.aborted) {
|
||||
onFailure(error);
|
||||
}
|
||||
});
|
||||
const cancelAsync = async (): Promise<void> => {
|
||||
controller.abort();
|
||||
// Readiness can reject before independent configuration has closed its child process.
|
||||
await Promise.allSettled([ready, prepared]);
|
||||
};
|
||||
return {
|
||||
ready,
|
||||
cancelAsync,
|
||||
async stopAsync() {
|
||||
await cancelAsync();
|
||||
await tool.stopAsync();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Rejects once `signal` aborts, so a stop never waits for `promise` to settle on its own. */
|
||||
export async function untilAbortedAsync<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
signal.throwIfAborted();
|
||||
let abort: (() => void) | undefined;
|
||||
const aborted = new Promise<never>((_resolve, reject) => {
|
||||
abort = () => reject(signal.reason);
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
});
|
||||
try {
|
||||
return await Promise.race([promise, aborted]);
|
||||
} finally {
|
||||
if (abort) {
|
||||
signal.removeEventListener('abort', abort);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "node",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"target": "es2022",
|
||||
"module": "commonjs",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["node", "jest"],
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
# @expo/steps
|
||||
|
||||
## 27.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/steps",
|
||||
"version": "27.1.0",
|
||||
"version": "27.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -27,8 +27,8 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/eas-build-job": "27.1.0",
|
||||
"@expo/logger": "27.1.0",
|
||||
"@expo/eas-build-job": "27.3.0",
|
||||
"@expo/logger": "27.3.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"arg": "^5.0.2",
|
||||
"fast-glob": "^3.3.2",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @expo/template-file
|
||||
|
||||
## 27.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user