mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8df11a04ee | ||
|
|
c9d198a894 | ||
|
|
4b70eb14fb | ||
|
|
c224ba58ff | ||
|
|
8d5a2e3332 | ||
|
|
b95b7c930a | ||
|
|
8ed5c16c7b | ||
|
|
fa5dee437f | ||
|
|
65ef172a78 | ||
|
|
b15ea359b6 | ||
|
|
ab86b647cf | ||
|
|
f6669461cd | ||
|
|
5f560f8fc2 | ||
|
|
a3e4617d1c | ||
|
|
2e45595250 | ||
|
|
0c768729a9 | ||
|
|
f37ad5e137 | ||
|
|
4d3cba95af | ||
|
|
d57eadf887 | ||
|
|
ccba9a0c8d | ||
|
|
3fc8dadd63 | ||
|
|
50dcb89a0c | ||
|
|
84b3bc6354 | ||
|
|
2749518a31 | ||
|
|
7819779519 | ||
|
|
1eee88fc8f | ||
|
|
ab50bdf55a | ||
|
|
ec9896ae23 | ||
|
|
21a16849c1 | ||
|
|
8501f3e7d8 | ||
|
|
eedff0c99d | ||
|
|
c7e9fe56de | ||
|
|
b8505a90f1 |
No files matched your search
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Stop an agent-device session that is still starting up when it is stopped.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Stop an Appium session that is still starting up when it is stopped.
|
||||
Report temporary-directory cleanup failures when Stop interrupts Appium installation.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Stop an Argent session that is still starting up when it is stopped.
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Record and upload Android emulator process output as a simulator session artifact by default. Add explicit source selection and a simulator CLI flag to opt in to recording and uploading device-wide logcat. Unselected sources are not recorded by simulator sessions.
|
||||
|
||||
Simulator sessions retain the last 20 MiB of each selected Android log source across launches and retries. Each log file is a fixed-size ring on disk, so the end of the final attempt is always kept; older output is dropped first, and artifacts remain uncompressed.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Show the Expo Simulators hint after a successful build from November 12 instead of November 10.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Stop a web-preview session that is still starting up when it is stopped.
|
||||
@@ -28,7 +28,9 @@ jobs:
|
||||
contains(github.event.pull_request.labels.*.name, 'no changelog') ||
|
||||
github.event.pull_request.user.login == 'dependabot[bot]'
|
||||
}}
|
||||
BASE_REF: ${{ github.event.pull_request.base.sha || 'origin/main' }}
|
||||
# The base branch, not `pull_request.base.sha`: that SHA can be older than the base that the
|
||||
# checked-out merge commit contains, and the diff then includes other people's changes.
|
||||
BASE_REF: origin/${{ github.base_ref || 'main' }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
|
||||
@@ -2,6 +2,67 @@
|
||||
|
||||
This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
## [27.7.1](https://github.com/expo/eas-cli/releases/tag/v27.7.1) - 2026-11-10
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli][eas-json][build-tools][eas-build-job][steps][local-build-plugin][worker][simulator-launcher] Update `node-forge`, `tar`, `nanoid`, `minimatch`, `joi`, `ajv`, `yaml`, and `zod` to versions with fixes for published security advisories. ([#4847](https://github.com/expo/eas-cli/pull/4847) by [@tchayen](https://github.com/tchayen))
|
||||
- [build-tools] Strip the GitHub repository access token from the git remote before a sandbox agent starts. ([#4826](https://github.com/expo/eas-cli/pull/4826) by [@sswrk](https://github.com/sswrk))
|
||||
|
||||
## [27.7.0](https://github.com/expo/eas-cli/releases/tag/v27.7.0) - 2026-11-10
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--git-branch` and `--git-commit-hash` to `eas workflow:run` to record the branch and commit of the uploaded project. ([#4815](https://github.com/expo/eas-cli/pull/4815) by [@hSATAC](https://github.com/hSATAC))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Record Android emulator output and, when selected, device logcat during simulator sessions, and upload the last 20 MiB of each as a session artifact. ([#4822](https://github.com/expo/eas-cli/pull/4822) by [@vonovak](https://github.com/vonovak))
|
||||
- [eas-cli] Stop spinners from flickering when their text wraps onto a second line, such as the `simulator:start` queue message in a narrow terminal. ([#4850](https://github.com/expo/eas-cli/pull/4850) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [27.6.0](https://github.com/expo/eas-cli/releases/tag/v27.6.0) - 2026-11-10
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Reuse compatible prefetched Appium and XCUITest files without removing them during cleanup. Resolve explicit package versions, including `latest`, and reuse prepared tools only when they match. Use the launcher's runtime serve-sim version for warm adoption and recordings, including prereleases. ([#4837](https://github.com/expo/eas-cli/pull/4837) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add the `--property key=value` flag to `eas observe:events` to filter events by a property value. ([#4812](https://github.com/expo/eas-cli/pull/4812) by [@Ubax](https://github.com/Ubax))
|
||||
- [simulator-launcher] Prepare pinned Appium, XCUITest and WebDriverAgent files with `prefetch`. ([#4836](https://github.com/expo/eas-cli/pull/4836) by [@gwdp](https://github.com/gwdp))
|
||||
- [simulator-launcher] Add `simulator-launcher prefetch` to cache Expo Go downloads. ([#4834](https://github.com/expo/eas-cli/pull/4834) by [@gwdp](https://github.com/gwdp))
|
||||
- [simulator-launcher] Use caller-supplied serve-sim, agent-device and Argent versions and include exact version inputs in claims. ([#4845](https://github.com/expo/eas-cli/pull/4845) by [@gwdp](https://github.com/gwdp))
|
||||
- [simulator-launcher] Add `simulator-launcher warmup` so consumers can warm up Simulators, install Expo Go and prepare native tool caches. The command stops its tools and shuts down the selected Simulators before returning. ([#4838](https://github.com/expo/eas-cli/pull/4838) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Stop waiting for `com.apple.datamigrator` to exit before an iOS Simulator on iOS 27.2 or later counts as ready; there it stays resident, so the wait never finished. Earlier runtimes keep the wait. Take the readiness screenshot from the first integrated screen when the device lists several, so the iPhone Duo on iOS 27.2 passes the check. ([#4816](https://github.com/expo/eas-cli/pull/4816) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Stop suggesting other event names in `eas observe:events` when an event name with `--session-id`, `--app-version`, `--build-number`, or `--update-id` returns no events. ([#4813](https://github.com/expo/eas-cli/pull/4813) by [@Ubax](https://github.com/Ubax))
|
||||
- [build-tools] Require the sandbox worker token for sandbox jobs and remove the sandbox GraphQL fallback. ([#4808](https://github.com/expo/eas-cli/pull/4808) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [vm-tools] Run the macOS VM-tools service with interactive scheduling so Simulator startup and preview processes avoid launchd's default CPU and I/O throttling. ([#4833](https://github.com/expo/eas-cli/pull/4833) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [27.5.0](https://github.com/expo/eas-cli/releases/tag/v27.5.0) - 2026-11-05
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Import ordered action configurations with `eas simulator:control --import` and show progress for each action. ([#4686](https://github.com/expo/eas-cli/pull/4686) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Reuse simulator control connections with `--keep-alive` or `simulator:control:connection open`. Set an idle timeout, inspect the connection, or close it without stopping the simulator session. Keep-alive currently supports macOS and Linux. ([#4791](https://github.com/expo/eas-cli/pull/4791) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Start simulator sessions from saved settings and apply control actions after readiness. ([#4687](https://github.com/expo/eas-cli/pull/4687) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Run `eas simulator` commands also as `eas simulators`, `eas emulator` and `eas emulators`, for example `eas emulators:start` or `eas simulators:list`. These aliases are hidden from help. ([#4794](https://github.com/expo/eas-cli/pull/4794) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] List `eas workflow:ssh` in `eas --help`. ([#4793](https://github.com/expo/eas-cli/pull/4793) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Stop an agent-device session that is still starting up when it is stopped. ([#4666](https://github.com/expo/eas-cli/pull/4666) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Stop an Appium session that is still starting up when it is stopped. ([#4668](https://github.com/expo/eas-cli/pull/4668) by [@gwdp](https://github.com/gwdp))
|
||||
Report temporary-directory cleanup failures when Stop interrupts Appium installation.
|
||||
- [build-tools] Stop an Argent session that is still starting up when it is stopped. ([#4667](https://github.com/expo/eas-cli/pull/4667) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Find the personal account by its owner instead of assuming that its name equals the username. ([#3922](https://github.com/expo/eas-cli/pull/3922) by [@byronkarlen](https://github.com/byronkarlen))
|
||||
- [build-tools][worker] Use the sandbox worker token for sandbox jobs when www provides one, and keep Expo credentials out of the commands of those jobs. ([#4807](https://github.com/expo/eas-cli/pull/4807) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [eas-cli] Show the Expo Simulators hint after a successful build from November 12 instead of November 10. ([#4817](https://github.com/expo/eas-cli/pull/4817) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [build-tools] Stop a web-preview session that is still starting up when it is stopped. ([#4669](https://github.com/expo/eas-cli/pull/4669) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [build-tools] Extract the worker API client core from the device run session worker API client. ([#4806](https://github.com/expo/eas-cli/pull/4806) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
## [27.4.1](https://github.com/expo/eas-cli/releases/tag/v27.4.1) - 2026-11-04
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "27.4.1",
|
||||
"PRODUCTION": "27.4.1"
|
||||
"STAGING": "27.7.0",
|
||||
"PRODUCTION": "27.7.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "27.4.1",
|
||||
"version": "27.7.1",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,5 +1,40 @@
|
||||
# @expo/build-tools
|
||||
|
||||
## 27.7.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Update `node-forge`, `tar`, `nanoid`, `minimatch`, `joi`, `ajv`, `yaml`, and `zod` to versions with fixes for published security advisories. ([#4847](https://github.com/expo/eas-cli/pull/4847) by [@tchayen](https://github.com/tchayen))
|
||||
- Strip the GitHub repository access token from the git remote before a sandbox agent starts. ([#4826](https://github.com/expo/eas-cli/pull/4826) by [@sswrk](https://github.com/sswrk))
|
||||
|
||||
## 27.7.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Record Android emulator output and, when selected, device logcat during simulator sessions, and upload the last 20 MiB of each as a session artifact. ([#4822](https://github.com/expo/eas-cli/pull/4822) by [@vonovak](https://github.com/vonovak))
|
||||
|
||||
## 27.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Reuse compatible prefetched Appium and XCUITest files without removing them during cleanup. Resolve explicit package versions, including `latest`, and reuse prepared tools only when they match. Use the launcher's runtime serve-sim version for warm adoption and recordings, including prereleases. ([#4837](https://github.com/expo/eas-cli/pull/4837) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stop waiting for `com.apple.datamigrator` to exit before an iOS Simulator on iOS 27.2 or later counts as ready; there it stays resident, so the wait never finished. Earlier runtimes keep the wait. Take the readiness screenshot from the first integrated screen when the device lists several, so the iPhone Duo on iOS 27.2 passes the check. ([#4816](https://github.com/expo/eas-cli/pull/4816) by [@hSATAC](https://github.com/hSATAC))
|
||||
- Require the sandbox worker token for sandbox jobs and remove the sandbox GraphQL fallback. ([#4808](https://github.com/expo/eas-cli/pull/4808) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
## 27.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stop an agent-device session that is still starting up when it is stopped. ([#4666](https://github.com/expo/eas-cli/pull/4666) by [@gwdp](https://github.com/gwdp))
|
||||
- Stop an Appium session that is still starting up when it is stopped. ([#4668](https://github.com/expo/eas-cli/pull/4668) by [@gwdp](https://github.com/gwdp))
|
||||
Report temporary-directory cleanup failures when Stop interrupts Appium installation.
|
||||
- Stop an Argent session that is still starting up when it is stopped. ([#4667](https://github.com/expo/eas-cli/pull/4667) by [@gwdp](https://github.com/gwdp))
|
||||
- Use the sandbox worker token for sandbox jobs when www provides one, and keep Expo credentials out of the commands of those jobs. ([#4807](https://github.com/expo/eas-cli/pull/4807) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- Stop a web-preview session that is still starting up when it is stopped. ([#4669](https://github.com/expo/eas-cli/pull/4669) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 27.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "27.4.1",
|
||||
"version": "27.7.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -42,17 +42,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "27.4.1",
|
||||
"@expo/eas-build-job": "27.4.1",
|
||||
"@expo/downloader": "27.7.1",
|
||||
"@expo/eas-build-job": "27.7.1",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "27.4.1",
|
||||
"@expo/logger": "27.7.1",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "27.4.1",
|
||||
"@expo/template-file": "27.4.1",
|
||||
"@expo/turtle-spawn": "27.4.1",
|
||||
"@expo/steps": "27.7.1",
|
||||
"@expo/template-file": "27.7.1",
|
||||
"@expo/turtle-spawn": "27.7.1",
|
||||
"@expo/xcpretty": "^4.4.7",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
@@ -63,11 +63,11 @@
|
||||
"fast-xml-parser": "^4.4.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
"gql.tada": "^1.11.3",
|
||||
"joi": "^17.13.1",
|
||||
"joi": "^17.13.9",
|
||||
"jose": "5.10.0",
|
||||
"lodash": "^4.18.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"node-forge": "^1.3.1",
|
||||
"node-forge": "^1.4.3",
|
||||
"node-pty": "1.2.0-beta.15",
|
||||
"node-stream-zip": "1.15.0",
|
||||
"nullthrows": "^1.1.1",
|
||||
@@ -77,11 +77,11 @@
|
||||
"resolve-from": "^5.0.0",
|
||||
"retry": "^0.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"tar": "7.5.19",
|
||||
"tar": "7.5.22",
|
||||
"uuid": "9.0.1",
|
||||
"ws": "8.21.1",
|
||||
"yaml": "^2.8.1",
|
||||
"zod": "^4.3.5"
|
||||
"yaml": "^2.9.1",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/repack-app": "~0.9.0",
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
BuildPhase,
|
||||
BuildPhaseResult,
|
||||
BuildTrigger,
|
||||
EnvironmentSecretType,
|
||||
Job,
|
||||
Metadata,
|
||||
Platform,
|
||||
@@ -31,6 +32,34 @@ describe('BuildContext', () => {
|
||||
(fs.readdir as unknown as jest.Mock).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('keeps the sandbox worker token out of the env and keeps the other secrets', () => {
|
||||
const ctx = new BuildContext(
|
||||
{
|
||||
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
|
||||
secrets: {
|
||||
environmentSecrets: [
|
||||
{
|
||||
name: 'EAS_SANDBOX_WORKER_TOKEN',
|
||||
value: 'sandbox-worker-token',
|
||||
type: EnvironmentSecretType.STRING,
|
||||
},
|
||||
{ name: 'KEEP', value: 'value', type: EnvironmentSecretType.STRING },
|
||||
],
|
||||
},
|
||||
} as Job,
|
||||
{
|
||||
env: { __API_SERVER_URL: 'https://api.expo.test' },
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
|
||||
expect(ctx.env.EAS_SANDBOX_WORKER_TOKEN).toBeUndefined();
|
||||
expect(ctx.env.KEEP).toBe('value');
|
||||
});
|
||||
|
||||
it('should merge secrets', async () => {
|
||||
const robotAccessToken = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/eas-environment-secrets/', { recursive: true });
|
||||
|
||||
@@ -361,6 +361,67 @@ describe('Datadog singleton', () => {
|
||||
expect(turtleFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('POSTs distribution metrics to the sandbox worker API with the sandbox worker token', () => {
|
||||
turtleFetchMock.mockResolvedValueOnce({} as Response);
|
||||
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
sandboxWorkerToken: 'sandbox-token',
|
||||
target: { kind: 'sandbox' },
|
||||
});
|
||||
|
||||
Datadog.distribution('eas.workflow.hook', 1, { hook: 'on_start' });
|
||||
|
||||
expect(turtleFetchMock).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/sandbox-worker/metrics',
|
||||
'POST',
|
||||
{
|
||||
json: {
|
||||
metrics: [
|
||||
{
|
||||
name: 'eas.workflow.hook',
|
||||
type: 'distribution',
|
||||
value: 1,
|
||||
tags: { hook: 'on_start', target: 'job_run' },
|
||||
},
|
||||
],
|
||||
},
|
||||
headers: { 'expo-sandbox-worker-token': 'sandbox-token' },
|
||||
retries: 2,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('labels metric upload failures for sandbox targets with the sandbox kind', async () => {
|
||||
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
sandboxWorkerToken: 'sandbox-token',
|
||||
target: { kind: 'sandbox' },
|
||||
});
|
||||
|
||||
Datadog.distribution('eas.workflow.hook', 1);
|
||||
await flushPromises();
|
||||
|
||||
expect(sentryCaptureMock).toHaveBeenCalledWith(
|
||||
'Failed to report turtle sandbox metric',
|
||||
expect.any(Error),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('does not report logs for sandbox targets', () => {
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
sandboxWorkerToken: 'sandbox-token',
|
||||
target: { kind: 'sandbox' },
|
||||
});
|
||||
|
||||
Datadog.log('Custom build saw user-provided function');
|
||||
|
||||
expect(turtleFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flushes pending metric uploads', async () => {
|
||||
let resolveUpload!: (response: Response) => void;
|
||||
turtleFetchMock.mockReturnValueOnce(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { fetchAndCheckoutRefAsync } from '../git';
|
||||
import { fetchAndCheckoutRefAsync, stripRepositoryCredentialsAsync } from '../git';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
|
||||
@@ -147,3 +147,64 @@ describe(fetchAndCheckoutRefAsync, () => {
|
||||
expect(error.message).not.toContain('ghs_secret123');
|
||||
});
|
||||
});
|
||||
|
||||
describe(stripRepositoryCredentialsAsync, () => {
|
||||
const token = 'ghs_secret123';
|
||||
|
||||
function mockRemoteUrl(url: string): void {
|
||||
jest.mocked(spawn).mockResolvedValueOnce({ stdout: `${url}\n` } as never);
|
||||
}
|
||||
|
||||
it('strips the token from the origin remote', async () => {
|
||||
mockRemoteUrl(`https://x-access-token:${token}@github.com/expo/app.git`);
|
||||
|
||||
await stripRepositoryCredentialsAsync({ repositoryDirectory });
|
||||
|
||||
expect(spawn).toHaveBeenNthCalledWith(1, 'git', ['remote', 'get-url', 'origin'], {
|
||||
cwd: repositoryDirectory,
|
||||
});
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'git',
|
||||
['remote', 'set-url', 'origin', 'https://github.com/expo/app.git'],
|
||||
{ cwd: repositoryDirectory }
|
||||
);
|
||||
expect(spawn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps the host and port of a GitHub Enterprise remote', async () => {
|
||||
mockRemoteUrl(`https://x-access-token:${token}@ghe.example.com:8443/org/app.git`);
|
||||
|
||||
await stripRepositoryCredentialsAsync({ repositoryDirectory });
|
||||
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'git',
|
||||
['remote', 'set-url', 'origin', 'https://ghe.example.com:8443/org/app.git'],
|
||||
{ cwd: repositoryDirectory }
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when the origin remote carries no credentials', async () => {
|
||||
mockRemoteUrl('https://github.com/expo/app.git');
|
||||
|
||||
await stripRepositoryCredentialsAsync({ repositoryDirectory });
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws when the remote URL cannot be rewritten', async () => {
|
||||
mockRemoteUrl(`https://x-access-token:${token}@github.com/expo/app.git`);
|
||||
const gitError = Object.assign(new Error('git exited with non-zero code: 128'), {
|
||||
stderr: "fatal: No such remote 'origin'",
|
||||
});
|
||||
jest.mocked(spawn).mockRejectedValueOnce(gitError);
|
||||
|
||||
const promise = stripRepositoryCredentialsAsync({ repositoryDirectory });
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(promise).rejects.toMatchObject({ cause: gitError });
|
||||
const error = await promise.catch(err => err);
|
||||
expect(error.message).not.toContain(token);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ArchiveSourceType,
|
||||
BuildMode,
|
||||
BuildTrigger,
|
||||
EnvironmentSecretType,
|
||||
ErrorCode,
|
||||
Job,
|
||||
Platform,
|
||||
@@ -116,6 +117,73 @@ describe('projectSources', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('fetches a sandbox project source from the sandbox worker router with only the worker token', async () => {
|
||||
const gitCommitHash = randomBytes(20).toString('hex');
|
||||
const ctx = new BuildContext(
|
||||
{
|
||||
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash,
|
||||
},
|
||||
secrets: {
|
||||
robotAccessToken: 'sandbox-worker-token',
|
||||
environmentSecrets: [
|
||||
{
|
||||
name: 'EAS_SANDBOX_WORKER_TOKEN',
|
||||
type: EnvironmentSecretType.STRING,
|
||||
value: 'sandbox-worker-token',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as Job,
|
||||
{
|
||||
env: {
|
||||
__API_SERVER_URL: 'https://api.expo.dev',
|
||||
EAS_BUILD_ID: randomUUID(),
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
},
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
type: ArchiveSourceType.GIT,
|
||||
},
|
||||
}),
|
||||
}) as Response
|
||||
);
|
||||
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://api.expo.dev/v2/sandbox-worker/project-archive',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'expo-sandbox-worker-token': 'sandbox-worker-token' },
|
||||
})
|
||||
);
|
||||
expect(shallowCloneRepositoryAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
archiveSource: expect.objectContaining({
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['http', 'network', 'json', 'schema', 'missing-url', 'PATH', 'GCS', 'R2'])(
|
||||
'throws a system error if refresh fails (%s)',
|
||||
async failure => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArchiveSource, ArchiveSourceType, UserError } from '@expo/eas-build-job';
|
||||
import { ArchiveSource, ArchiveSourceType, SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
@@ -99,6 +99,45 @@ export async function fetchAndCheckoutRefAsync({
|
||||
}
|
||||
}
|
||||
|
||||
export async function stripRepositoryCredentialsAsync({
|
||||
repositoryDirectory,
|
||||
}: {
|
||||
repositoryDirectory: string;
|
||||
}): Promise<void> {
|
||||
let remoteUrl: URL;
|
||||
try {
|
||||
const { stdout } = await spawn('git', ['remote', 'get-url', 'origin'], {
|
||||
cwd: repositoryDirectory,
|
||||
});
|
||||
remoteUrl = new URL(stdout.trim());
|
||||
} catch (err) {
|
||||
throw new SystemError('Failed to read the origin remote URL of the project repository.', {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
|
||||
if (!remoteUrl.username && !remoteUrl.password) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await spawn('git', ['remote', 'set-url', 'origin', stripCredentialsFromGitUrl(remoteUrl)], {
|
||||
cwd: repositoryDirectory,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new SystemError('Failed to strip credentials from the origin remote URL.', {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function stripCredentialsFromGitUrl(url: URL): string {
|
||||
const strippedUrl = new URL(url.toString());
|
||||
strippedUrl.username = '';
|
||||
strippedUrl.password = '';
|
||||
return strippedUrl.toString();
|
||||
}
|
||||
|
||||
function getSanitizedGitUrl(maybeGitUrl: string): string | null {
|
||||
try {
|
||||
const url = new URL(maybeGitUrl);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ArchiveSourceType } from '@expo/eas-build-job';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ProjectArchiveSourceSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal(ArchiveSourceType.GIT),
|
||||
repositoryUrl: z.string().url(),
|
||||
gitRef: z.string().nullable(),
|
||||
gitCommitHash: z.string(),
|
||||
}),
|
||||
z.object({ type: z.literal(ArchiveSourceType.URL), url: z.string().url() }),
|
||||
z.object({ type: z.literal(ArchiveSourceType.NONE) }),
|
||||
]);
|
||||
|
||||
export type ProjectArchiveSource = z.infer<typeof ProjectArchiveSourceSchema>;
|
||||
@@ -10,7 +10,9 @@ import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { shallowCloneRepositoryAsync } from './git';
|
||||
import { type ProjectArchiveSource, ProjectArchiveSourceSchema } from './projectArchiveSource';
|
||||
import { BuildContext } from '../context';
|
||||
import { getSandboxWorkerApi } from '../steps/utils/sandboxWorkerApi';
|
||||
import { uploadContentWithSignedUrl } from '../storage/uploadWithSignedUrl';
|
||||
import { turtleFetch } from '../utils/turtleFetch';
|
||||
|
||||
@@ -245,7 +247,18 @@ async function uploadProjectMetadataAsync(
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>) {
|
||||
async function fetchProjectArchiveSourceAsync(
|
||||
ctx: BuildContext<Job>
|
||||
): Promise<ProjectArchiveSource> {
|
||||
const sandboxWorkerApi = getSandboxWorkerApi(ctx);
|
||||
if (sandboxWorkerApi) {
|
||||
return await sandboxWorkerApi.getProjectArchiveSourceAsync({
|
||||
timeoutMs: 20000,
|
||||
retries: 3,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
}
|
||||
|
||||
const taskId = nullthrows(ctx.env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const expoApiServerURL = nullthrows(ctx.env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
const robotAccessToken = nullthrows(
|
||||
@@ -283,20 +296,7 @@ async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>) {
|
||||
);
|
||||
}
|
||||
|
||||
const dataResult = z
|
||||
.object({
|
||||
data: z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal(ArchiveSourceType.GIT),
|
||||
repositoryUrl: z.string().url(),
|
||||
gitRef: z.string().nullable(),
|
||||
gitCommitHash: z.string(),
|
||||
}),
|
||||
z.object({ type: z.literal(ArchiveSourceType.URL), url: z.string().url() }),
|
||||
z.object({ type: z.literal(ArchiveSourceType.NONE) }),
|
||||
]),
|
||||
})
|
||||
.safeParse(jsonResult.value);
|
||||
const dataResult = z.object({ data: ProjectArchiveSourceSchema }).safeParse(jsonResult.value);
|
||||
if (!dataResult.success) {
|
||||
throw new Error(
|
||||
`Unexpected data from server (${response.status}): ${z.prettifyError(dataResult.error)}`
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
BuildPhase,
|
||||
BuildPhaseResult,
|
||||
Env,
|
||||
type EnvironmentSecret,
|
||||
EnvironmentSecretType,
|
||||
Job,
|
||||
LogMarker,
|
||||
@@ -18,6 +19,7 @@ import path from 'path';
|
||||
|
||||
import { resolveBuildPhaseErrorAsync } from './buildErrors/detectError';
|
||||
import { Datadog } from './datadog';
|
||||
import { SANDBOX_WORKER_TOKEN_ENV } from './steps/utils/sandboxWorkerApi';
|
||||
import { readAppConfig } from './utils/appConfig';
|
||||
import { createTemporaryEnvironmentSecretFile } from './utils/environmentSecrets';
|
||||
import { PackageManager, resolvePackageManager } from './utils/packageManager';
|
||||
@@ -408,7 +410,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
const environmentSecretsDirectory = path.join(this.workingdir, 'eas-environment-secrets');
|
||||
|
||||
const environmentSecrets: Record<string, string> = {};
|
||||
for (const { name, type, value } of job.secrets.environmentSecrets) {
|
||||
for (const { name, type, value } of job.secrets.environmentSecrets.filter(isJobSecret)) {
|
||||
if (type === EnvironmentSecretType.STRING) {
|
||||
environmentSecrets[name] = value;
|
||||
} else {
|
||||
@@ -422,3 +424,9 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
return environmentSecrets;
|
||||
}
|
||||
}
|
||||
|
||||
const EXCLUDED_JOB_SECRET_NAMES: ReadonlySet<string> = new Set([SANDBOX_WORKER_TOKEN_ENV]);
|
||||
|
||||
function isJobSecret({ name }: EnvironmentSecret): boolean {
|
||||
return !EXCLUDED_JOB_SECRET_NAMES.has(name);
|
||||
}
|
||||
@@ -1,23 +1,30 @@
|
||||
import { Sentry } from './sentry';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from './steps/utils/deviceRunSessionWorkerApi';
|
||||
import { SANDBOX_WORKER_TOKEN_HEADER } from './steps/utils/sandboxWorkerApi';
|
||||
import { turtleFetch } from './utils/turtleFetch';
|
||||
|
||||
type MetricsTarget =
|
||||
| { kind: 'build'; turtleBuildId: string }
|
||||
| { kind: 'jobRun'; turtleJobRunId: string }
|
||||
// A device run session job with a session token. The token names the session and its job run.
|
||||
| { kind: 'deviceRunSession' };
|
||||
| { kind: 'deviceRunSession' }
|
||||
| { kind: 'sandbox' };
|
||||
|
||||
type DatadogSetupOptions =
|
||||
| {
|
||||
expoApiV2BaseUrl: string;
|
||||
robotAccessToken: string;
|
||||
target: Exclude<MetricsTarget, { kind: 'deviceRunSession' }>;
|
||||
target: Exclude<MetricsTarget, { kind: 'deviceRunSession' | 'sandbox' }>;
|
||||
}
|
||||
| {
|
||||
expoApiV2BaseUrl: string;
|
||||
deviceRunSessionToken: string;
|
||||
target: Extract<MetricsTarget, { kind: 'deviceRunSession' }>;
|
||||
}
|
||||
| {
|
||||
expoApiV2BaseUrl: string;
|
||||
sandboxWorkerToken: string;
|
||||
target: Extract<MetricsTarget, { kind: 'sandbox' }>;
|
||||
};
|
||||
|
||||
let setupOptions: DatadogSetupOptions | null = null;
|
||||
@@ -41,6 +48,12 @@ function getMetricsRequest(options: DatadogSetupOptions): {
|
||||
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: options.deviceRunSessionToken },
|
||||
};
|
||||
}
|
||||
if ('sandboxWorkerToken' in options) {
|
||||
return {
|
||||
path: 'sandbox-worker/metrics',
|
||||
headers: { [SANDBOX_WORKER_TOKEN_HEADER]: options.sandboxWorkerToken },
|
||||
};
|
||||
}
|
||||
const { target, robotAccessToken } = options;
|
||||
return {
|
||||
path:
|
||||
|
||||
@@ -43,5 +43,6 @@ export * from './generic';
|
||||
|
||||
export { Datadog } from './datadog';
|
||||
export { getDeviceRunSessionTokenFromEnvironmentSecrets } from './steps/utils/deviceRunSessionWorkerApi';
|
||||
export { getSandboxWorkerTokenFromEnvironmentSecrets } from './steps/utils/sandboxWorkerApi';
|
||||
export { Sentry } from './sentry';
|
||||
export * from './runtimeSettings';
|
||||
+39
-15
@@ -4,7 +4,7 @@ import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { AndroidEmulatorLogCapture } from '../../../utils/androidEmulatorLogCapture';
|
||||
import { AndroidEmulatorLogRing } from '../../../utils/androidEmulatorLogCapture';
|
||||
import { getAndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogs';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { stopLocalEgressResourcesAsync } from '../../utils/localEgress';
|
||||
@@ -447,32 +447,27 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
});
|
||||
await step.executeAsync();
|
||||
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ logSources: ['emulator-output'] })
|
||||
expect.objectContaining({
|
||||
logCapture: expect.objectContaining({ sources: ['emulator-output'] }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('passes an explicit empty source list and the workflow capture to Android boot', async () => {
|
||||
const capture = new AndroidEmulatorLogCapture();
|
||||
const capture = new AndroidEmulatorLogRing({ sources: [] });
|
||||
jest.mocked(getAndroidEmulatorLogCapture).mockReturnValue(capture);
|
||||
const env = { DEVICE_RUN_SESSION_ID: 'session' };
|
||||
await runStep(
|
||||
BuildRuntimePlatform.LINUX,
|
||||
{ android_log_sources: [] },
|
||||
undefined,
|
||||
ctx,
|
||||
false,
|
||||
env
|
||||
);
|
||||
expect(getAndroidEmulatorLogCapture).toHaveBeenCalledWith(expect.anything(), env);
|
||||
await runStep(BuildRuntimePlatform.LINUX, { log_sources: [] }, undefined, ctx, false, env);
|
||||
expect(getAndroidEmulatorLogCapture).toHaveBeenCalledWith(expect.anything(), env, []);
|
||||
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ logSources: [], logCapture: capture })
|
||||
expect.objectContaining({ logCapture: capture })
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid Android log sources before boot or downloading', async () => {
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.LINUX, {
|
||||
android_log_sources: ['unknown'],
|
||||
log_sources: ['unknown'],
|
||||
build_id: 'build-id',
|
||||
})
|
||||
).rejects.toThrow();
|
||||
@@ -480,6 +475,24 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects Android log sources on an iOS session before boot or downloading', async () => {
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, {
|
||||
log_sources: ['device-logcat'],
|
||||
build_id: 'build-id',
|
||||
})
|
||||
).rejects.toThrow('Input "log_sources" selects Android emulator logs');
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('boots an iOS session with an explicit empty source list', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN, { log_sources: [] });
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalled();
|
||||
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('boots the Android Emulator with the device inputs', async () => {
|
||||
await runStep(BuildRuntimePlatform.LINUX, {
|
||||
device_identifier: 'pixel_7',
|
||||
@@ -669,7 +682,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual([
|
||||
'android_log_sources',
|
||||
'log_sources',
|
||||
'device_identifier',
|
||||
'system_image_package',
|
||||
'lcd_width',
|
||||
@@ -874,6 +887,17 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('passes the resolved version to agent-device after a warm version mismatch', async () => {
|
||||
jest
|
||||
.mocked(runOnWarmIosSimulatorAsync)
|
||||
.mockImplementation((_claim, run) => run(undefined, '999.1.0'));
|
||||
await runStep(BuildRuntimePlatform.DARWIN, { package_version: 'latest' }, undefined, ctx, true);
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ packageVersion: '999.1.0' })
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts only one application source', async () => {
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app', build_id: 'id' })
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '../../../utils/AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../../../utils/retry';
|
||||
import { resolveAndroidEmulatorLocalEgressAsync } from '../../utils/androidLocalEgress';
|
||||
import { AndroidEmulatorLogCapture } from '../../../utils/androidEmulatorLogCapture';
|
||||
import { AndroidEmulatorLogRing } from '../../../utils/androidEmulatorLogCapture';
|
||||
import { getAndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogs';
|
||||
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
|
||||
|
||||
@@ -101,25 +101,25 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
|
||||
it('preserves an explicit empty source list and one capture through boot retries and clones', async () => {
|
||||
mockedAndroidUtils.waitForReadyAsync.mockRejectedValueOnce(new Error('retry boot'));
|
||||
const capture = new AndroidEmulatorLogCapture();
|
||||
const capture = new AndroidEmulatorLogRing({ sources: [] });
|
||||
jest.mocked(getAndroidEmulatorLogCapture).mockReturnValue(capture);
|
||||
await createStep(
|
||||
{ android_log_sources: [], count: 2 },
|
||||
{ log_sources: [], count: 2 },
|
||||
{ DEVICE_RUN_SESSION_ID: 'session' }
|
||||
).executeAsync();
|
||||
expect(getAndroidEmulatorLogCapture).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ DEVICE_RUN_SESSION_ID: 'session' })
|
||||
expect.objectContaining({ DEVICE_RUN_SESSION_ID: 'session' }),
|
||||
[]
|
||||
);
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledTimes(4);
|
||||
for (const [options] of mockedAndroidUtils.startAsync.mock.calls) {
|
||||
expect(options.logSources).toEqual([]);
|
||||
expect(options.logCapture).toBe(capture);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid sources before starting the emulator', async () => {
|
||||
await expect(createStep({ android_log_sources: ['unknown'] }).executeAsync()).rejects.toThrow();
|
||||
await expect(createStep({ log_sources: ['unknown'] }).executeAsync()).rejects.toThrow();
|
||||
expect(mockedAndroidUtils.startAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -144,8 +144,7 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
deviceName: 'EasAndroidDevice01',
|
||||
env: expect.any(Object),
|
||||
logcatDirectory: '/tmp/logcat-directory',
|
||||
logSources: ['emulator-output', 'device-logcat'],
|
||||
logCapture: expect.any(AndroidEmulatorLogCapture),
|
||||
logCapture: expect.objectContaining({ sources: ['emulator-output', 'device-logcat'] }),
|
||||
});
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith({
|
||||
env: expect.any(Object),
|
||||
|
||||
+498
-4
@@ -1,7 +1,10 @@
|
||||
import { type WarmIosSession } from '../../utils/warmIosSession';
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
import plist from 'plist';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import * as timersPromises from 'node:timers/promises';
|
||||
|
||||
@@ -9,6 +12,7 @@ import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { PackageManager, resolveConfiguredPackageManager } from '../../../utils/packageManager';
|
||||
import * as processes from '../../../utils/processes';
|
||||
import { sleepAsync } from '../../../utils/retry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
@@ -98,6 +102,46 @@ jest.mock('../../utils/localEgressSession', () => ({
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as never;
|
||||
const preparedHome = path.join(os.homedir(), 'Library/Caches/expo-simulator-startup/appium');
|
||||
const preparedManifest = path.join(path.dirname(preparedHome), 'prepared.json');
|
||||
const preparedBin = path.join(preparedHome, 'node_modules', '.bin', 'appium');
|
||||
const preparedRunner = path.join(
|
||||
preparedHome,
|
||||
'preinstalled-wda',
|
||||
'WebDriverAgentRunner-Runner.app'
|
||||
);
|
||||
|
||||
const driverList = ['driver', 'list', '--installed', '--json'];
|
||||
|
||||
/** Today's `driver list` output: the prepared home lists xcuitest, a new home lists nothing. */
|
||||
function driverListStdout(command: string): string {
|
||||
return JSON.stringify(command === preparedBin ? { xcuitest: { installed: true } } : {});
|
||||
}
|
||||
|
||||
/** Writes the Appium home that `simulator-launcher prefetch` prepares for reuse. */
|
||||
function writePreparedHome(): void {
|
||||
fs.mkdirSync(path.dirname(preparedBin), { recursive: true });
|
||||
fs.writeFileSync(preparedBin, '');
|
||||
writeInstalledVersion('appium', '3.9.0');
|
||||
writeInstalledVersion('appium-xcuitest-driver', '12.22.8');
|
||||
fs.mkdirSync(preparedRunner, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(preparedRunner, 'Info.plist'),
|
||||
plist.build({ CFBundleExecutable: 'Runner' })
|
||||
);
|
||||
fs.writeFileSync(path.join(preparedRunner, 'Runner'), 'executable', { mode: 0o755 });
|
||||
writePreparedManifest({ version: '3.9.0', xcuitest: '12.22.8', home: preparedHome });
|
||||
}
|
||||
|
||||
function writePreparedManifest(appium: unknown): void {
|
||||
fs.writeFileSync(preparedManifest, `${JSON.stringify({ tools: { appium } })}\n`);
|
||||
}
|
||||
|
||||
function writeInstalledVersion(name: string, version: string): void {
|
||||
const directory = path.join(preparedHome, 'node_modules', name);
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
fs.writeFileSync(path.join(directory, 'package.json'), JSON.stringify({ name, version }));
|
||||
}
|
||||
|
||||
describe(resolveAppium3VersionSpec, () => {
|
||||
it('uses the worker-supported Appium 3 version by default', () => {
|
||||
@@ -172,9 +216,9 @@ describe(installAppiumAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock);
|
||||
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => {
|
||||
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => {
|
||||
if (args.includes('--json')) {
|
||||
return { stdout: JSON.stringify({}) };
|
||||
return { stdout: driverListStdout(command) };
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as never);
|
||||
@@ -208,6 +252,255 @@ describe(installAppiumAsync, () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the xcuitest home prefetched without installing', async () => {
|
||||
writePreparedHome();
|
||||
const info = jest.fn();
|
||||
const result = await installAppiumAsync({
|
||||
versionSpec: '^3',
|
||||
driverName: 'xcuitest',
|
||||
env: { EXISTING: 'value' },
|
||||
logger: { info } as never,
|
||||
});
|
||||
expect(jest.mocked(spawn).mock.calls).toEqual([
|
||||
[
|
||||
preparedBin,
|
||||
driverList,
|
||||
{ env: { EXISTING: 'value', APPIUM_HOME: preparedHome }, stdio: 'pipe' },
|
||||
],
|
||||
]);
|
||||
expect(info).toHaveBeenCalledWith(
|
||||
'Using the prefetched Appium home (appium 3.9.0, xcuitest 12.22.8).'
|
||||
);
|
||||
expect(result).toEqual({
|
||||
appiumHome: preparedHome,
|
||||
appiumBinPath: preparedBin,
|
||||
appiumEnv: { EXISTING: 'value', APPIUM_HOME: preparedHome },
|
||||
prepared: true,
|
||||
});
|
||||
|
||||
const env = { EAS_OVERRIDE_PACKAGE_MANAGER: 'nope' };
|
||||
const expected = (() => {
|
||||
try {
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM);
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
})();
|
||||
const onError = jest.fn();
|
||||
await expect(
|
||||
installAppiumAsync({ versionSpec: '^3', driverName: 'xcuitest', env, logger, onError })
|
||||
).rejects.toEqual(expected);
|
||||
expect(onError).toHaveBeenCalledWith(expected);
|
||||
expect(jest.mocked(spawn).mock.calls.map(([, args]) => args)).toEqual([driverList, driverList]);
|
||||
});
|
||||
|
||||
it('reuses WDA with binary bundle metadata', async () => {
|
||||
writePreparedHome();
|
||||
fs.writeFileSync(
|
||||
path.join(preparedRunner, 'Info.plist'),
|
||||
Buffer.from(
|
||||
'YnBsaXN0MDDRAQJfEBJDRkJ1bmRsZUV4ZWN1dGFibGVWUnVubmVyCAsgAAAAAAAAAQEAAAAAAAAAAwAAAAAAAAAAAAAAAAAAACc=',
|
||||
'base64'
|
||||
)
|
||||
);
|
||||
const result = await installAppiumAsync({
|
||||
versionSpec: '^3',
|
||||
driverName: 'xcuitest',
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
expect(result.prepared).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a version spec it does not satisfy', { versionSpec: '3.5.0' }, () => {}],
|
||||
['the uiautomator2 driver', { driverName: 'uiautomator2' as const }, () => {}],
|
||||
['no prepared.json', {}, () => fs.rmSync(preparedManifest)],
|
||||
['an invalid prepared.json', {}, () => fs.writeFileSync(preparedManifest, 'not json')],
|
||||
['a prepared.json without xcuitest', {}, () => writePreparedManifest({ version: '3.9.0' })],
|
||||
[
|
||||
'an older per-tool manifest',
|
||||
{},
|
||||
() => {
|
||||
fs.rmSync(preparedManifest);
|
||||
fs.writeFileSync(
|
||||
path.join(preparedHome, 'prepared.json'),
|
||||
JSON.stringify({
|
||||
appium: '3.9.0',
|
||||
xcuitest: '12.22.8',
|
||||
home: preparedHome,
|
||||
})
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'a home prepared at another path',
|
||||
{},
|
||||
() =>
|
||||
writePreparedManifest({
|
||||
version: '3.9.0',
|
||||
xcuitest: '12.22.8',
|
||||
home: '/Users/other/appium',
|
||||
}),
|
||||
],
|
||||
['another installed Appium', {}, () => writeInstalledVersion('appium', '2.19.0')],
|
||||
[
|
||||
'another installed xcuitest driver',
|
||||
{},
|
||||
() => writeInstalledVersion('appium-xcuitest-driver', '12.0.0'),
|
||||
],
|
||||
[
|
||||
'no xcuitest driver',
|
||||
{},
|
||||
() =>
|
||||
fs.rmSync(path.join(preparedHome, 'node_modules', 'appium-xcuitest-driver'), {
|
||||
recursive: true,
|
||||
}),
|
||||
],
|
||||
['no runner', {}, () => fs.rmSync(preparedRunner, { recursive: true })],
|
||||
['no runner metadata', {}, () => fs.rmSync(path.join(preparedRunner, 'Info.plist'))],
|
||||
[
|
||||
'invalid runner metadata',
|
||||
{},
|
||||
() => fs.writeFileSync(path.join(preparedRunner, 'Info.plist'), 'invalid'),
|
||||
],
|
||||
['no runner executable', {}, () => fs.rmSync(path.join(preparedRunner, 'Runner'))],
|
||||
[
|
||||
'a non-executable runner binary',
|
||||
{},
|
||||
() => fs.chmodSync(path.join(preparedRunner, 'Runner'), 0o644),
|
||||
],
|
||||
['an empty runner binary', {}, () => fs.writeFileSync(path.join(preparedRunner, 'Runner'), '')],
|
||||
[
|
||||
'a directory as runner binary',
|
||||
{},
|
||||
() => {
|
||||
fs.rmSync(path.join(preparedRunner, 'Runner'));
|
||||
fs.mkdirSync(path.join(preparedRunner, 'Runner'));
|
||||
},
|
||||
],
|
||||
[
|
||||
'an executable outside the bundle',
|
||||
{},
|
||||
() =>
|
||||
fs.writeFileSync(
|
||||
path.join(preparedRunner, 'Info.plist'),
|
||||
plist.build({ CFBundleExecutable: '../outside' })
|
||||
),
|
||||
],
|
||||
['an unreadable runner', {}, () => fs.chmodSync(preparedRunner, 0o000)],
|
||||
[
|
||||
'a runner that is a file',
|
||||
{},
|
||||
() => {
|
||||
fs.rmSync(preparedRunner, { recursive: true });
|
||||
// Readable and executable, so only the directory check rejects it.
|
||||
fs.writeFileSync(preparedRunner, '', { mode: 0o755 });
|
||||
},
|
||||
],
|
||||
])('installs as before for %s', async (_name, options, breakHome) => {
|
||||
writePreparedHome();
|
||||
breakHome();
|
||||
const before = vol.toJSON(preparedHome);
|
||||
const versionSpec = 'versionSpec' in options ? options.versionSpec : '^3';
|
||||
const resolveRequestedVersionAsync = jest.fn(async () => {
|
||||
throw new Error('registry unavailable');
|
||||
});
|
||||
const result = await installAppiumAsync({
|
||||
versionSpec,
|
||||
resolveRequestedVersionAsync,
|
||||
driverName: 'driverName' in options ? options.driverName : 'xcuitest',
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
try {
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'npm',
|
||||
['install', '--no-audit', `appium@${versionSpec}`],
|
||||
expect.objectContaining({ cwd: result.appiumHome })
|
||||
);
|
||||
expect(path.basename(result.appiumHome)).toMatch(/^eas-appium-home-/);
|
||||
expect(result.prepared).toBe(false);
|
||||
expect(resolveRequestedVersionAsync).not.toHaveBeenCalled();
|
||||
expect(vol.toJSON(preparedHome)).toEqual(before);
|
||||
} finally {
|
||||
await fs.promises.rm(result.appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a driver list without xcuitest', async () => ({ stdout: '{}' })],
|
||||
['a failed driver list', async () => Promise.reject(new Error('appium exited with 1.'))],
|
||||
])('installs as before after %s in the prepared home', async (_name, list) => {
|
||||
writePreparedHome();
|
||||
jest.mocked(spawn).mockImplementationOnce(list as never);
|
||||
const before = vol.toJSON(preparedHome);
|
||||
const result = await installAppiumAsync({
|
||||
versionSpec: '^3',
|
||||
driverName: 'xcuitest',
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
try {
|
||||
expect(jest.mocked(spawn).mock.calls.slice(0, 2)).toEqual([
|
||||
[preparedBin, driverList, expect.objectContaining({ stdio: 'pipe' })],
|
||||
[
|
||||
'npm',
|
||||
['install', '--no-audit', 'appium@^3'],
|
||||
expect.objectContaining({ cwd: result.appiumHome }),
|
||||
],
|
||||
]);
|
||||
expect(result.prepared).toBe(false);
|
||||
expect(vol.toJSON(preparedHome)).toEqual(before);
|
||||
} finally {
|
||||
await fs.promises.rm(result.appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('kills the prepared-home driver list on a stop and installs nothing', async () => {
|
||||
writePreparedHome();
|
||||
const before = vol.toJSON(preparedHome);
|
||||
const controller = new AbortController();
|
||||
const failure = new Error('session stopped');
|
||||
const child = { pid: 4242, kill: jest.fn() };
|
||||
let finishList!: (error: Error) => void;
|
||||
let listStarted!: () => void;
|
||||
const started = new Promise<void>(resolve => {
|
||||
listStarted = resolve;
|
||||
});
|
||||
jest.mocked(spawn).mockImplementationOnce(() => {
|
||||
listStarted();
|
||||
return Object.assign(
|
||||
new Promise((_resolve, reject) => {
|
||||
finishList = reject;
|
||||
}),
|
||||
{ child }
|
||||
) as never;
|
||||
});
|
||||
const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
|
||||
const running = installAppiumAsync({
|
||||
versionSpec: '^3',
|
||||
driverName: 'xcuitest',
|
||||
env: {},
|
||||
logger,
|
||||
signal: controller.signal,
|
||||
});
|
||||
running.catch(() => {});
|
||||
try {
|
||||
await started;
|
||||
controller.abort(failure);
|
||||
expect(killGroup).toHaveBeenCalledWith(child, 'SIGKILL');
|
||||
finishList(new Error('killed'));
|
||||
await expect(running).rejects.toBe(failure);
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(vol.toJSON(preparedHome)).toEqual(before);
|
||||
} finally {
|
||||
finishList?.(new Error('test cleanup'));
|
||||
killGroup.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('installs Appium with bun add when EAS_OVERRIDE_PACKAGE_MANAGER is bun', async () => {
|
||||
const result = await installAppiumAsync({
|
||||
versionSpec: '3.5.0',
|
||||
@@ -302,6 +595,115 @@ describe('createStartAppiumRemoteSessionBuildFunction', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(
|
||||
[BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX].flatMap(platform =>
|
||||
['^3', '3.x'].map(version => [platform, version] as const)
|
||||
)
|
||||
)(
|
||||
'starts cold Appium on %s with cached installer range %s and no registry lookup',
|
||||
async (runtimePlatform, packageVersion) => {
|
||||
// Android must ignore prepared XCUITest files too.
|
||||
if (runtimePlatform === BuildRuntimePlatform.LINUX) {
|
||||
writePreparedHome();
|
||||
}
|
||||
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (args[0] === 'view') {
|
||||
throw new Error('registry unavailable');
|
||||
}
|
||||
return { stdout: '{}' };
|
||||
}) as never);
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid: 'simulator' }] as never);
|
||||
jest
|
||||
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
|
||||
.mockResolvedValue([{ serialId: 'emulator-5554', state: 'device' }] as never);
|
||||
const launched = new Error('launch captured');
|
||||
jest.mocked(spawnDetached).mockImplementation(() => {
|
||||
throw launched;
|
||||
});
|
||||
const buildFunction = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: packageVersion },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {
|
||||
DEVICE_RUN_SESSION_ID: 'session',
|
||||
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
|
||||
NGROK_AUTHTOKEN: 'token',
|
||||
EAS_OVERRIDE_PACKAGE_MANAGER: 'bun',
|
||||
},
|
||||
} as never
|
||||
)
|
||||
).rejects.toBe(launched);
|
||||
expect(jest.mocked(spawn).mock.calls.some(([, args]) => args[0] === 'view')).toBe(false);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['add', `appium@${packageVersion}`],
|
||||
expect.anything()
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(
|
||||
['latest', '^3', '3.x'].flatMap(requested =>
|
||||
['3.9.0', '3.10.0'].map(latest => [requested, latest])
|
||||
)
|
||||
)('checks %s before using the prepared Appium version (%s)', async (requested, latest) => {
|
||||
writePreparedHome();
|
||||
const before = vol.toJSON(preparedHome);
|
||||
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => ({
|
||||
stdout:
|
||||
args[0] === 'view'
|
||||
? JSON.stringify(latest)
|
||||
: args.includes('--json')
|
||||
? driverListStdout(command)
|
||||
: '',
|
||||
})) as never);
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid: 'simulator' }] as never);
|
||||
const launched = new Error('launch captured');
|
||||
jest.mocked(spawnDetached).mockImplementation(() => {
|
||||
throw launched;
|
||||
});
|
||||
const buildFunction = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: requested },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {
|
||||
DEVICE_RUN_SESSION_ID: 'session',
|
||||
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
|
||||
NGROK_AUTHTOKEN: 'token',
|
||||
},
|
||||
} as never
|
||||
)
|
||||
).rejects.toBe(launched);
|
||||
const installs = jest.mocked(spawn).mock.calls.filter(([, args]) => args[0] === 'install');
|
||||
if (latest === '3.9.0') {
|
||||
expect(installs).toHaveLength(0);
|
||||
expect(jest.mocked(spawnDetached).mock.calls[0][0].env.APPIUM_HOME).toBe(preparedHome);
|
||||
} else {
|
||||
expect(installs).toHaveLength(1);
|
||||
expect(installs[0][1]).toContain('[email protected]');
|
||||
}
|
||||
expect(vol.toJSON(preparedHome)).toEqual(before);
|
||||
});
|
||||
|
||||
it('claims the warm Simulator for Appium without its package version', async () => {
|
||||
jest.mocked(runOnWarmIosSimulatorAsync).mockResolvedValue(undefined);
|
||||
const buildFunction = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext, {
|
||||
@@ -369,8 +771,8 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
|
||||
state: 'Shutdown',
|
||||
} as never);
|
||||
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => ({
|
||||
stdout: args.includes('--json') ? '{}' : '',
|
||||
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => ({
|
||||
stdout: args.includes('--json') ? driverListStdout(command) : '',
|
||||
})) as never);
|
||||
jest
|
||||
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
|
||||
@@ -625,6 +1027,73 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
return stopWarm;
|
||||
}
|
||||
|
||||
it.each(['cancelled lookup', 'AbortError lookup', 'resolved lookup'])(
|
||||
'recovers from failed warm adoption with a %s',
|
||||
async stage => {
|
||||
writePreparedHome();
|
||||
const stopWarm = mockWarmSession();
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
const adoptionError = new WarmSessionAdoptionError();
|
||||
let failHost!: (error: Error) => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failHost = reject;
|
||||
})
|
||||
);
|
||||
let lookups = 0;
|
||||
jest.mocked(spawn).mockImplementation((async (command, args, options) => {
|
||||
if (args[0] !== 'view') {
|
||||
return { stdout: args.includes('--json') ? driverListStdout(command) : '' };
|
||||
}
|
||||
lookups++;
|
||||
if (lookups === 1 && stage !== 'resolved lookup') {
|
||||
return await new Promise((_resolve, reject) => {
|
||||
options!.signal!.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
reject(
|
||||
stage === 'AbortError lookup'
|
||||
? new DOMException('Aborted', 'AbortError')
|
||||
: options!.signal!.reason
|
||||
);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
failHost(adoptionError);
|
||||
});
|
||||
}
|
||||
return { stdout: JSON.stringify('3.9.0') };
|
||||
}) as typeof spawn);
|
||||
if (stage === 'resolved lookup') {
|
||||
const process = jest.mocked(spawnDetached).getMockImplementation()!;
|
||||
jest.mocked(spawnDetached).mockImplementationOnce(options => {
|
||||
failHost(adoptionError);
|
||||
return process(options);
|
||||
});
|
||||
}
|
||||
|
||||
await expect(
|
||||
runSessionAsync(
|
||||
logger,
|
||||
undefined,
|
||||
BuildRuntimePlatform.DARWIN,
|
||||
{
|
||||
boot_simulator: { value: true },
|
||||
device_identifier: { value: 'iPhone 17' },
|
||||
package_version: { value: '^3' },
|
||||
},
|
||||
true
|
||||
)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(lookups).toBe(stage === 'resolved lookup' ? 1 : 2);
|
||||
expect(stopWarm).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(2);
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(stage === 'resolved lookup' ? 2 : 1);
|
||||
expect(fs.existsSync(preparedRunner)).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['event collection', 'Appium process'])(
|
||||
'waits for warm %s teardown before finishing the session host',
|
||||
async stage => {
|
||||
@@ -1102,6 +1571,31 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
);
|
||||
});
|
||||
|
||||
it('a Darwin session uses the prepared Appium home and keeps it', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
writePreparedHome();
|
||||
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockResolvedValue([
|
||||
{
|
||||
udid: 'chosen-ios-udid',
|
||||
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-4',
|
||||
} as never,
|
||||
]);
|
||||
await runSessionAsync(
|
||||
{ info: jest.fn(), warn: jest.fn() },
|
||||
undefined,
|
||||
BuildRuntimePlatform.DARWIN
|
||||
);
|
||||
expect(jest.mocked(spawnDetached).mock.calls[0][0].env.APPIUM_HOME).toBe(preparedHome);
|
||||
expect(preparePreinstalledWdaAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ appiumHome: preparedHome, prepared: true })
|
||||
);
|
||||
expect(jest.mocked(spawn).mock.calls.map(([command, args]) => [command, args])).toEqual([
|
||||
[preparedBin, driverList],
|
||||
]);
|
||||
expect(stopAppium).toHaveBeenCalledTimes(1);
|
||||
expect(fs.existsSync(preparedManifest)).toBe(true);
|
||||
});
|
||||
|
||||
it('starts serve-sim on the selected iOS device while Appium installation is pending', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockClear();
|
||||
|
||||
+23
@@ -322,6 +322,29 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the resolved Argent version after a warm mismatch', async () => {
|
||||
jest
|
||||
.mocked(runOnWarmIosSimulatorAsync)
|
||||
.mockImplementation((_claim, run) => run(undefined, '999.1.0'));
|
||||
await createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext, { warm: true }).fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as never,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: 'latest' },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
expect(spawnDetached).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ args: expect.arrayContaining(['@swmansion/[email protected]']) })
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps watching startup when warm adoption retries cold', async () => {
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
@@ -1,11 +1,51 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
import {
|
||||
ArchiveSourceType,
|
||||
EnvironmentSecretType,
|
||||
SystemError,
|
||||
UserError,
|
||||
} from '@expo/eas-build-job';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
import { stripRepositoryCredentialsAsync } from '../../../common/git';
|
||||
import * as sandboxDaemon from '../../utils/sandboxDaemon';
|
||||
import { SandboxWorkerApi } from '../../utils/sandboxWorkerApi';
|
||||
import { createStartSandboxBuildFunction } from '../startSandbox';
|
||||
|
||||
import { createStartSandboxBuildFunction, markSandboxReadyAsync } from '../startSandbox';
|
||||
const { AbortError, Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
jest.mock('../../../common/git');
|
||||
|
||||
const checkedOutStepCtx = { logger: {}, global: { wasCheckedOut: () => true } } as any;
|
||||
const notCheckedOutStepCtx = { logger: {}, global: { wasCheckedOut: () => false } } as any;
|
||||
|
||||
const URL_JOB = {
|
||||
projectArchive: { type: ArchiveSourceType.URL, url: 'https://example.com/project.tar.gz' },
|
||||
};
|
||||
|
||||
const context = {
|
||||
job: {
|
||||
...URL_JOB,
|
||||
secrets: {
|
||||
environmentSecrets: [
|
||||
{
|
||||
name: 'EAS_SANDBOX_WORKER_TOKEN',
|
||||
type: EnvironmentSecretType.STRING,
|
||||
value: 'worker-token',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
};
|
||||
const inputs = { sandbox_id: { value: 'sandbox-id' } };
|
||||
|
||||
describe('sandbox build functions', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ completion: 'duration', duration: 0.02 },
|
||||
{ completion: 'cancellation', duration: 60 },
|
||||
@@ -16,49 +56,41 @@ describe('sandbox build functions', () => {
|
||||
const controller = new AbortController();
|
||||
let ready!: () => void;
|
||||
const stopAsync = jest.fn(async () => {});
|
||||
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockResolvedValue({
|
||||
jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockResolvedValue({
|
||||
ready: new Promise<void>(resolve => {
|
||||
ready = resolve;
|
||||
}),
|
||||
stopAsync,
|
||||
});
|
||||
const mutation = jest.fn(() => ({ toPromise: async () => ({ data: {} }) }));
|
||||
const markReady = jest
|
||||
.spyOn(SandboxWorkerApi.prototype, 'markReadyAsync')
|
||||
.mockResolvedValue(undefined);
|
||||
const logger = { info: jest.fn(), child: jest.fn() };
|
||||
logger.child.mockReturnValue(logger);
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
graphqlClient: { mutation },
|
||||
} as any);
|
||||
try {
|
||||
const pending = fn.fn!({ logger } as any, {
|
||||
inputs: {
|
||||
sandbox_id: { value: 'sandbox-id' },
|
||||
max_duration_seconds: { value: duration },
|
||||
},
|
||||
outputs: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 40));
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
const fn = createStartSandboxBuildFunction(context as any);
|
||||
const pending = fn.fn!({ logger } as any, {
|
||||
inputs: { ...inputs, max_duration_seconds: { value: duration } },
|
||||
outputs: {},
|
||||
env: {},
|
||||
signal: controller.signal,
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 40));
|
||||
expect(markReady).not.toHaveBeenCalled();
|
||||
expect(stopAsync).not.toHaveBeenCalled();
|
||||
ready();
|
||||
if (completion === 'cancellation') {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(stopAsync).not.toHaveBeenCalled();
|
||||
ready();
|
||||
if (completion === 'cancellation') {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(stopAsync).not.toHaveBeenCalled();
|
||||
controller.abort();
|
||||
}
|
||||
await pending;
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
const timeoutMessage = `Sandbox reached its maximum runtime of ${duration} seconds. Stopping.`;
|
||||
if (completion === 'duration') {
|
||||
expect(logger.info).toHaveBeenCalledWith(timeoutMessage);
|
||||
} else {
|
||||
expect(logger.info).not.toHaveBeenCalledWith(timeoutMessage);
|
||||
}
|
||||
} finally {
|
||||
start.mockRestore();
|
||||
controller.abort();
|
||||
}
|
||||
await pending;
|
||||
expect(markReady).toHaveBeenCalledTimes(1);
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
const timeoutMessage = `Sandbox reached its maximum runtime of ${duration} seconds. Stopping.`;
|
||||
if (completion === 'duration') {
|
||||
expect(logger.info).toHaveBeenCalledWith(timeoutMessage);
|
||||
} else {
|
||||
expect(logger.info).not.toHaveBeenCalledWith(timeoutMessage);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -67,177 +99,221 @@ describe('sandbox build functions', () => {
|
||||
'rejects invalid maximum duration %s before starting the daemon',
|
||||
async duration => {
|
||||
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync');
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
} as any);
|
||||
try {
|
||||
const result = fn.fn!({} as any, {
|
||||
inputs: {
|
||||
sandbox_id: { value: 'sandbox-id' },
|
||||
max_duration_seconds: { value: duration },
|
||||
},
|
||||
outputs: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
|
||||
});
|
||||
await expect(result).rejects.toBeInstanceOf(UserError);
|
||||
await expect(result).rejects.toThrow(
|
||||
`Invalid sandbox maximum duration: ${duration}. Provide a finite number greater than zero, or omit max_duration_seconds.`
|
||||
);
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
start.mockRestore();
|
||||
}
|
||||
const fn = createStartSandboxBuildFunction(context as any);
|
||||
const result = fn.fn!({} as any, {
|
||||
inputs: { ...inputs, max_duration_seconds: { value: duration } },
|
||||
outputs: {},
|
||||
env: {},
|
||||
});
|
||||
await expect(result).rejects.toBeInstanceOf(UserError);
|
||||
await expect(result).rejects.toThrow(
|
||||
`Invalid sandbox maximum duration: ${duration}. Provide a finite number greater than zero, or omit max_duration_seconds.`
|
||||
);
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('aborts an active ready request and keeps its authentication headers', async () => {
|
||||
it('connects with the worker token from the job secrets, which the step env does not have', async () => {
|
||||
const env = { PREPARED_BY_EARLIER_STEP: 'value' };
|
||||
const startupError = new Error('stop after checking options');
|
||||
const start = jest
|
||||
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
|
||||
.mockRejectedValue(startupError);
|
||||
const fn = createStartSandboxBuildFunction(context as any);
|
||||
|
||||
await expect(fn.fn!({ logger: {} } as any, { inputs, outputs: {}, env })).rejects.toBe(
|
||||
startupError
|
||||
);
|
||||
expect(start.mock.calls[0][0]).toMatchObject({ credential: 'worker-token', env });
|
||||
});
|
||||
|
||||
it('does not mark the sandbox ready if startup was canceled', async () => {
|
||||
const controller = new AbortController();
|
||||
const stopAsync = jest.fn(async () => {});
|
||||
jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockImplementation(async options => {
|
||||
expect(options.signal).toBe(controller.signal);
|
||||
controller.abort();
|
||||
return { ready: Promise.resolve(), stopAsync };
|
||||
});
|
||||
const fn = createStartSandboxBuildFunction(context as any);
|
||||
|
||||
await expect(
|
||||
fn.fn!({ logger: {} } as any, { inputs, outputs: {}, env: {}, signal: controller.signal })
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('marks the sandbox ready with only the worker token and aborts the request when canceled', async () => {
|
||||
const controller = new AbortController();
|
||||
const stopAsync = jest.fn(async () => {});
|
||||
jest
|
||||
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
|
||||
.mockResolvedValue({ ready: Promise.resolve(), stopAsync });
|
||||
let requestStarted!: () => void;
|
||||
const started = new Promise<void>(resolve => {
|
||||
requestStarted = resolve;
|
||||
});
|
||||
const fetchMock = jest.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => {
|
||||
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer test-token');
|
||||
return await new Promise<Response>((_resolve, reject) => {
|
||||
init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason), { once: true });
|
||||
jest.mocked(fetch).mockImplementationOnce(async (url, init) => {
|
||||
expect(url).toBe('https://api.expo.test/v2/sandbox-worker/ready');
|
||||
expect(init?.headers).toEqual({ 'expo-sandbox-worker-token': 'worker-token' });
|
||||
return await new Promise((_resolve, reject) => {
|
||||
init!.signal!.addEventListener('abort', () => reject(new AbortError('Aborted')));
|
||||
requestStarted();
|
||||
});
|
||||
});
|
||||
const graphqlClient = new Client({
|
||||
url: 'https://api.expo.test/graphql',
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: { headers: { Authorization: 'Bearer test-token' } },
|
||||
const fn = createStartSandboxBuildFunction(context as any);
|
||||
const pending = fn.fn!({ logger: {} } as any, {
|
||||
inputs,
|
||||
outputs: {},
|
||||
env: {},
|
||||
signal: controller.signal,
|
||||
});
|
||||
try {
|
||||
const pending = markSandboxReadyAsync(
|
||||
{ graphqlClient } as any,
|
||||
'sandbox-id',
|
||||
controller.signal
|
||||
);
|
||||
const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' });
|
||||
await started;
|
||||
controller.abort();
|
||||
await rejected;
|
||||
} finally {
|
||||
fetchMock.mockRestore();
|
||||
}
|
||||
const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' });
|
||||
|
||||
await started;
|
||||
controller.abort();
|
||||
await rejected;
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('does not mark the sandbox ready if startup was canceled', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
it('throws a system error and stops the daemon when readiness fails', async () => {
|
||||
const stopAsync = jest.fn(async () => {});
|
||||
const start = jest
|
||||
jest
|
||||
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
|
||||
.mockImplementation(async options => {
|
||||
expect(options.signal).toBe(controller.signal);
|
||||
controller.abort();
|
||||
return { ready: Promise.resolve(), stopAsync };
|
||||
});
|
||||
const mutation = jest.fn();
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
graphqlClient: { mutation },
|
||||
} as any);
|
||||
try {
|
||||
await expect(
|
||||
fn.fn!({ logger: {} } as any, {
|
||||
inputs: { sandbox_id: { value: 'sandbox-id' } },
|
||||
outputs: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
start.mockRestore();
|
||||
}
|
||||
.mockResolvedValue({ ready: Promise.resolve(), stopAsync });
|
||||
jest.mocked(fetch).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ errors: [{ message: 'Sandbox has stopped.' }] }), {
|
||||
status: 400,
|
||||
})
|
||||
);
|
||||
const fn = createStartSandboxBuildFunction(context as any);
|
||||
|
||||
const result = fn.fn!({ logger: {} } as any, { inputs, outputs: {}, env: {} });
|
||||
|
||||
await expect(result).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(result).rejects.toMatchObject({
|
||||
message: 'Failed to mark sandbox sandbox-id as ready.',
|
||||
cause: expect.objectContaining({ status: 400 }),
|
||||
});
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('excludes the MCP token from the command environment', async () => {
|
||||
const env = {
|
||||
PREPARED_BY_EARLIER_STEP: 'value',
|
||||
EXPO_TOKEN: 'expo-token',
|
||||
__EAS_SANDBOX_MCP_TOKEN: 'token',
|
||||
};
|
||||
|
||||
it('strips the repository credentials before starting the daemon for git sources', async () => {
|
||||
const startupError = new Error('stop after checking options');
|
||||
const start = jest
|
||||
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
|
||||
.mockRejectedValue(startupError);
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
} as any);
|
||||
try {
|
||||
await expect(
|
||||
fn.fn!({ logger: {} } as any, {
|
||||
inputs: { sandbox_id: { value: 'sandbox-id' } },
|
||||
outputs: {},
|
||||
env,
|
||||
})
|
||||
).rejects.toBe(startupError);
|
||||
expect(start.mock.calls[0][0]).toMatchObject({ credential: 'token' });
|
||||
expect(start.mock.calls[0][0].env).toEqual({
|
||||
PREPARED_BY_EARLIER_STEP: 'value',
|
||||
EXPO_TOKEN: 'expo-token',
|
||||
});
|
||||
expect(env.__EAS_SANDBOX_MCP_TOKEN).toBe('token');
|
||||
} finally {
|
||||
start.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('throws a system error when the sandbox token is missing', async () => {
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
job: {},
|
||||
env: {},
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
...context,
|
||||
job: {
|
||||
...context.job,
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash: '0123456789abcdef0123456789abcdef01234567',
|
||||
},
|
||||
},
|
||||
projectTargetDirectory: '/workingdir/build',
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
fn.fn!({} as any, {
|
||||
inputs: { sandbox_id: { value: 'sandbox-id' } },
|
||||
outputs: {},
|
||||
env: {},
|
||||
})
|
||||
).rejects.toBeInstanceOf(SystemError);
|
||||
});
|
||||
|
||||
it('throws a system error when the MCP server URL is missing', async () => {
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
job: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'sandbox-token' },
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
fn.fn!({} as any, {
|
||||
inputs: { sandbox_id: { value: 'sandbox-id' } },
|
||||
outputs: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'sandbox-token' },
|
||||
})
|
||||
).rejects.toThrow('MCP server URL is required to start the sandbox daemon.');
|
||||
});
|
||||
|
||||
it('marks the sandbox as ready', async () => {
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({ data: { sandbox: { markSandboxReady: { id: 'sandbox-id' } } } }),
|
||||
}));
|
||||
|
||||
await markSandboxReadyAsync({ graphqlClient: { mutation } } as any, 'sandbox-id');
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sandboxId: 'sandbox-id' },
|
||||
undefined
|
||||
await expect(fn.fn!(checkedOutStepCtx, { inputs, outputs: {}, env: {} })).rejects.toBe(
|
||||
startupError
|
||||
);
|
||||
expect(stripRepositoryCredentialsAsync).toHaveBeenCalledWith({
|
||||
repositoryDirectory: '/workingdir/build',
|
||||
});
|
||||
expect(jest.mocked(stripRepositoryCredentialsAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
start.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a system error when marking the sandbox as ready fails', async () => {
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({ error: new Error('request failed') }),
|
||||
}));
|
||||
it('strips the repository credentials from the source directory before eas/checkout', async () => {
|
||||
const startupError = new Error('stop after checking options');
|
||||
jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockRejectedValue(startupError);
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
...context,
|
||||
job: {
|
||||
...context.job,
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
gitRef: null,
|
||||
gitCommitHash: '0123456789abcdef0123456789abcdef01234567',
|
||||
},
|
||||
},
|
||||
projectSourceDirectory: '/workingdir/temporary-custom-build',
|
||||
projectTargetDirectory: '/workingdir/build',
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
markSandboxReadyAsync({ graphqlClient: { mutation } } as any, 'sandbox-id')
|
||||
).rejects.toThrow('Failed to mark sandbox sandbox-id as ready.');
|
||||
await expect(fn.fn!(notCheckedOutStepCtx, { inputs, outputs: {}, env: {} })).rejects.toBe(
|
||||
startupError
|
||||
);
|
||||
expect(stripRepositoryCredentialsAsync).toHaveBeenCalledWith({
|
||||
repositoryDirectory: '/workingdir/temporary-custom-build',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not start the daemon when stripping the repository credentials fails', async () => {
|
||||
const stripError = new SystemError('Failed to strip credentials from the origin remote URL.');
|
||||
jest.mocked(stripRepositoryCredentialsAsync).mockRejectedValueOnce(stripError);
|
||||
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync');
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
...context,
|
||||
job: {
|
||||
...context.job,
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
gitRef: null,
|
||||
gitCommitHash: '0123456789abcdef0123456789abcdef01234567',
|
||||
},
|
||||
},
|
||||
projectTargetDirectory: '/workingdir/build',
|
||||
} as any);
|
||||
|
||||
await expect(fn.fn!(checkedOutStepCtx, { inputs, outputs: {}, env: {} })).rejects.toBe(
|
||||
stripError
|
||||
);
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves the remote alone for non-git sources', async () => {
|
||||
const startupError = new Error('stop after checking options');
|
||||
jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockRejectedValue(startupError);
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
...context,
|
||||
projectTargetDirectory: '/workingdir/build',
|
||||
} as any);
|
||||
|
||||
await expect(fn.fn!({ logger: {} } as any, { inputs, outputs: {}, env: {} })).rejects.toBe(
|
||||
startupError
|
||||
);
|
||||
expect(stripRepositoryCredentialsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
missing: 'the sandbox worker token',
|
||||
overrides: { job: {} },
|
||||
message: 'This sandbox job has no EAS_SANDBOX_WORKER_TOKEN secret.',
|
||||
},
|
||||
{
|
||||
missing: 'the MCP server URL',
|
||||
overrides: { mcpServerUrl: undefined },
|
||||
message: 'MCP server URL is required to start the sandbox daemon.',
|
||||
},
|
||||
{
|
||||
missing: 'the API server URL',
|
||||
overrides: { expoApiV2BaseUrl: undefined },
|
||||
message: 'The API server URL is not set',
|
||||
},
|
||||
])('throws a system error without $missing', async ({ overrides, message }) => {
|
||||
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync');
|
||||
const fn = createStartSandboxBuildFunction({ ...context, ...overrides } as any);
|
||||
|
||||
const result = fn.fn!({} as any, { inputs, outputs: {}, env: {} });
|
||||
|
||||
await expect(result).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(result).rejects.toThrow(message);
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+14
@@ -224,6 +224,20 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the resolved version to cold setup after a warm version mismatch', async () => {
|
||||
jest
|
||||
.mocked(runOnWarmIosSimulatorAsync)
|
||||
.mockImplementation((_claim, run) => run(undefined, '0.14.0'));
|
||||
await createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true }).fn!(
|
||||
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
|
||||
{ inputs: { package_version: { value: 'latest' } }, outputs: {}, env } as never
|
||||
);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ packageVersion: '0.14.0' })
|
||||
);
|
||||
});
|
||||
|
||||
it('uses preview activity to reset the configured idle timeout', async () => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, { max_idle_time_minutes: { value: 5 } });
|
||||
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
|
||||
@@ -35,9 +35,9 @@ import { retryOnNetworkFailureAsync } from '../../utils/retryOnNetworkFailure';
|
||||
import { pluralize } from '../../utils/strings';
|
||||
import {
|
||||
type DeviceRunSessionWorkerApi,
|
||||
DeviceRunSessionWorkerApiError,
|
||||
getDeviceRunSessionWorkerApi,
|
||||
} from '../utils/deviceRunSessionWorkerApi';
|
||||
import { WorkerApiError } from '../utils/workerApi';
|
||||
|
||||
const streamPipeline = promisify(stream.pipeline);
|
||||
|
||||
@@ -219,7 +219,7 @@ async function fetchDeviceRunSessionApplicationArchiveUrlAsync({
|
||||
{
|
||||
operation: 'download_build.session_build_lookup',
|
||||
signal,
|
||||
isRetryable: error => error instanceof DeviceRunSessionWorkerApiError && error.isTransient,
|
||||
isRetryable: error => error instanceof WorkerApiError && error.isTransient,
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
|
||||
import { AndroidEmulatorLogSourcesSchema } from '../../utils/androidEmulatorLogSources';
|
||||
import { getAndroidEmulatorLogCapture } from '../utils/androidEmulatorLogs';
|
||||
import { parseLogSourcesInput } from '../utils/logSourcesInput';
|
||||
import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi';
|
||||
import { readIosApplicationIdentifierAsync } from '../utils/iosAppArtifact';
|
||||
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
|
||||
@@ -73,10 +73,10 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
name: 'Start agent-device session',
|
||||
__metricsId: warm ? 'eas/start_warm_agent_device_session' : 'eas/start_agent_device_session',
|
||||
inputProviders: [
|
||||
// No default: an omitted input must stay distinguishable from an explicit selection.
|
||||
BuildStepInput.createProvider({
|
||||
id: 'android_log_sources',
|
||||
id: 'log_sources',
|
||||
required: false,
|
||||
defaultValue: ['emulator-output'],
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
// Device. On iOS a Simulator name or UDID, on Android an AVD hardware profile.
|
||||
@@ -196,9 +196,10 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
} else {
|
||||
validateAndroidLaunchOptions({ launchArgs, openUrl });
|
||||
}
|
||||
const logSources = isIos
|
||||
? []
|
||||
: AndroidEmulatorLogSourcesSchema.parse(inputs.android_log_sources.value);
|
||||
const logSources = parseLogSourcesInput(inputs.log_sources.value, {
|
||||
runtimePlatform,
|
||||
fallback: isIos ? [] : ['emulator-output'],
|
||||
});
|
||||
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
|
||||
const capture = parseNetworkCaptureInputs(
|
||||
{
|
||||
@@ -216,7 +217,10 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
logger,
|
||||
startup: { signal: startupSignal, abort: reason => startup.abort(reason) },
|
||||
});
|
||||
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
|
||||
const startSessionAsync = async (
|
||||
warmSession?: WarmIosSession,
|
||||
resolvedPackageVersion = inputs.package_version.value as string | undefined
|
||||
): Promise<void> => {
|
||||
const tasks = createStartupTasks(logger, startupSignal);
|
||||
let booted: Promise<void> | undefined;
|
||||
let sessionOwnsTeardown = false;
|
||||
@@ -264,8 +268,7 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
lcdHeight: inputs.lcd_height.value as number | undefined,
|
||||
lcdDensity: inputs.lcd_density.value as number | undefined,
|
||||
logcatDirectory,
|
||||
logSources,
|
||||
logCapture: getAndroidEmulatorLogCapture(global, env),
|
||||
logCapture: getAndroidEmulatorLogCapture(global, env, logSources),
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
@@ -351,7 +354,7 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
signal,
|
||||
runtimePlatform,
|
||||
sessionEnv,
|
||||
packageVersion: inputs.package_version.value as string | undefined,
|
||||
packageVersion: resolvedPackageVersion,
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined,
|
||||
maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined,
|
||||
|
||||
@@ -23,12 +23,9 @@ import {
|
||||
import { type AndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogCapture';
|
||||
import { retryAsync } from '../../utils/retry';
|
||||
import { resolveAndroidEmulatorLocalEgressAsync } from '../utils/androidLocalEgress';
|
||||
import {
|
||||
ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
type AndroidEmulatorLogSource,
|
||||
AndroidEmulatorLogSourcesSchema,
|
||||
} from '../../utils/androidEmulatorLogSources';
|
||||
import { ALL_ANDROID_EMULATOR_LOG_SOURCES } from '../../utils/androidEmulatorLogSources';
|
||||
import { getAndroidEmulatorLogCapture } from '../utils/androidEmulatorLogs';
|
||||
import { parseLogSourcesInput } from '../utils/logSourcesInput';
|
||||
|
||||
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
|
||||
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
|
||||
@@ -41,7 +38,7 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
__metricsId: 'eas/start_android_emulator',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'android_log_sources',
|
||||
id: 'log_sources',
|
||||
required: false,
|
||||
defaultValue: [...ALL_ANDROID_EMULATOR_LOG_SOURCES],
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
@@ -92,11 +89,14 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, outputs, env }) => {
|
||||
const logSources = AndroidEmulatorLogSourcesSchema.parse(inputs.android_log_sources.value);
|
||||
const logSources = parseLogSourcesInput(inputs.log_sources.value, {
|
||||
runtimePlatform: global.runtimePlatform,
|
||||
fallback: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
});
|
||||
const logcatDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
|
||||
);
|
||||
const logCapture = getAndroidEmulatorLogCapture(global, env);
|
||||
const logCapture = getAndroidEmulatorLogCapture(global, env, logSources);
|
||||
outputs.logcat_directory.set(logcatDirectory);
|
||||
|
||||
const deviceName = `${inputs.device_name.value}` as AndroidVirtualDeviceName;
|
||||
@@ -110,7 +110,6 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
lcdHeight: inputs.lcd_height.value as number | undefined,
|
||||
lcdDensity: inputs.lcd_density.value as number | undefined,
|
||||
logcatDirectory,
|
||||
logSources,
|
||||
logCapture,
|
||||
env,
|
||||
logger,
|
||||
@@ -150,7 +149,6 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
deviceName: cloneIdentifier,
|
||||
env,
|
||||
logcatDirectory,
|
||||
logSources,
|
||||
logCapture,
|
||||
});
|
||||
cloneSerialId = startResult.serialId;
|
||||
@@ -221,7 +219,6 @@ export async function startAndroidEmulatorAsync({
|
||||
lcdHeight,
|
||||
lcdDensity,
|
||||
logcatDirectory,
|
||||
logSources,
|
||||
logCapture,
|
||||
env,
|
||||
logger,
|
||||
@@ -233,7 +230,6 @@ export async function startAndroidEmulatorAsync({
|
||||
lcdHeight?: number;
|
||||
lcdDensity?: number;
|
||||
logcatDirectory: string;
|
||||
logSources: readonly AndroidEmulatorLogSource[];
|
||||
logCapture: AndroidEmulatorLogCapture;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
@@ -316,7 +312,6 @@ export async function startAndroidEmulatorAsync({
|
||||
deviceName,
|
||||
env,
|
||||
logcatDirectory,
|
||||
logSources,
|
||||
logCapture,
|
||||
...(localEgress ? { launchGate: localEgress.launchGate } : {}),
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import semver from 'semver';
|
||||
import bplistParser from 'bplist-parser';
|
||||
import plist from 'plist';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
@@ -43,6 +45,8 @@ import {
|
||||
import { killProcessGroup } from '../../utils/processes';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { resolveSimulatorPackageVersionAsync } from '../../utils/simulatorPackageVersion';
|
||||
import { readPreparedSimulatorToolAsync } from '../../utils/warmSimulator';
|
||||
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
|
||||
import { preparePreinstalledWdaAsync } from '../utils/preinstalledWda';
|
||||
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
|
||||
@@ -79,6 +83,12 @@ const AppiumInstalledDriversSchema = z.record(
|
||||
z.string(),
|
||||
z.object({ installed: z.boolean().optional() }).passthrough()
|
||||
);
|
||||
const PreparedAppiumSchema = z.object({
|
||||
version: z.string(),
|
||||
xcuitest: z.string(),
|
||||
home: z.string(),
|
||||
});
|
||||
const PackageVersionSchema = z.object({ version: z.string() });
|
||||
|
||||
export function createStartAppiumRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext,
|
||||
@@ -127,6 +137,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
|
||||
});
|
||||
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
|
||||
let requestedVersion: string | undefined;
|
||||
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
|
||||
const startupAbortController = new AbortController();
|
||||
const startupSignal = AbortSignal.any([watchSignal, startupAbortController.signal]);
|
||||
@@ -149,7 +160,21 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
const versionSpec = resolveAppium3VersionSpec(packageVersion);
|
||||
const resolveRequestedVersionAsync =
|
||||
packageVersion === undefined
|
||||
? undefined
|
||||
: async (): Promise<string> =>
|
||||
(requestedVersion ??= await resolveSimulatorPackageVersionAsync(
|
||||
'appium',
|
||||
packageVersion,
|
||||
{ env, signal: startupSignal }
|
||||
));
|
||||
// Keep cold ranges on the configured installer's path. Tags need resolution to enforce Appium 3.
|
||||
const versionSpec = resolveAppium3VersionSpec(
|
||||
packageVersion !== undefined && !semver.validRange(packageVersion)
|
||||
? await resolveRequestedVersionAsync!()
|
||||
: packageVersion
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
@@ -211,6 +236,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
asyncResult(
|
||||
installAppiumAsync({
|
||||
versionSpec,
|
||||
resolveRequestedVersionAsync,
|
||||
driverName: device.driverName,
|
||||
env,
|
||||
logger,
|
||||
@@ -231,7 +257,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
),
|
||||
]);
|
||||
const installation = appiumInstall.enforceValue();
|
||||
appiumHome = installation.appiumHome;
|
||||
// A prepared home is shared across sessions; remove only a job-owned home after a session.
|
||||
appiumHome = installation.prepared ? undefined : installation.appiumHome;
|
||||
startupSignal?.throwIfAborted();
|
||||
const { appiumBinPath, appiumEnv } = installation;
|
||||
const wdaCapabilities = await preparePreinstalledWdaAsync({
|
||||
@@ -243,7 +270,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
})
|
||||
)?.runtime
|
||||
: device.runtimeIdentifier,
|
||||
appiumHome,
|
||||
appiumHome: installation.appiumHome,
|
||||
prepared: installation.prepared,
|
||||
appiumBinPath,
|
||||
appiumEnv,
|
||||
logger,
|
||||
@@ -418,7 +446,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
watchSignal.throwIfAborted();
|
||||
if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await runOnWarmIosSimulatorAsync(
|
||||
// package_version is the Appium version, which the job installs either way.
|
||||
// package_version is the Appium version. The job installs it or uses the prepared
|
||||
// home; the warm claim does not depend on it.
|
||||
{
|
||||
engine: 'appium',
|
||||
requestedDevice: deviceIdentifier,
|
||||
@@ -503,6 +532,7 @@ export async function resolveAppiumDeviceAsync({
|
||||
|
||||
export async function installAppiumAsync({
|
||||
versionSpec,
|
||||
resolveRequestedVersionAsync,
|
||||
driverName,
|
||||
env,
|
||||
logger,
|
||||
@@ -510,13 +540,53 @@ export async function installAppiumAsync({
|
||||
onError,
|
||||
}: {
|
||||
versionSpec: string;
|
||||
resolveRequestedVersionAsync?: () => Promise<string>;
|
||||
driverName: AppiumDevice['driverName'];
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
onError?: (error: unknown) => void;
|
||||
}): Promise<{ appiumHome: string; appiumBinPath: string; appiumEnv: BuildStepEnv }> {
|
||||
}): Promise<{
|
||||
appiumHome: string;
|
||||
appiumBinPath: string;
|
||||
appiumEnv: BuildStepEnv;
|
||||
prepared: boolean;
|
||||
}> {
|
||||
signal?.throwIfAborted();
|
||||
let prepared =
|
||||
driverName === 'xcuitest'
|
||||
? await readPreparedSimulatorToolAsync('appium', PreparedAppiumSchema)
|
||||
: undefined;
|
||||
if (
|
||||
prepared &&
|
||||
!(await isPreparedAppiumCompatibleAsync(prepared, { versionSpec, env, signal }))
|
||||
) {
|
||||
prepared = undefined;
|
||||
}
|
||||
if (prepared && resolveRequestedVersionAsync) {
|
||||
versionSpec = resolveAppium3VersionSpec(await resolveRequestedVersionAsync());
|
||||
if (!semver.satisfies(prepared.version, versionSpec)) {
|
||||
prepared = undefined;
|
||||
}
|
||||
}
|
||||
if (prepared) {
|
||||
try {
|
||||
// An invalid package manager override fails as it does without a prepared home.
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM);
|
||||
} catch (error) {
|
||||
onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
logger.info(
|
||||
`Using the prefetched Appium home (appium ${prepared.version}, xcuitest ${prepared.xcuitest}).`
|
||||
);
|
||||
return {
|
||||
appiumHome: prepared.home,
|
||||
appiumBinPath: path.join(prepared.home, 'node_modules', '.bin', 'appium'),
|
||||
appiumEnv: { ...env, APPIUM_HOME: prepared.home },
|
||||
prepared: true,
|
||||
};
|
||||
}
|
||||
const appiumHome = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-appium-home-'));
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
@@ -569,7 +639,7 @@ export async function installAppiumAsync({
|
||||
signal
|
||||
);
|
||||
}
|
||||
return { appiumHome, appiumBinPath, appiumEnv };
|
||||
return { appiumHome, appiumBinPath, appiumEnv, prepared: false };
|
||||
} catch (error) {
|
||||
onError?.(error);
|
||||
if (signal) {
|
||||
@@ -580,6 +650,67 @@ export async function installAppiumAsync({
|
||||
}
|
||||
}
|
||||
|
||||
/** A metadata hit still needs Appium-specific version, driver and runner checks before reuse. */
|
||||
async function isPreparedAppiumCompatibleAsync(
|
||||
prepared: z.infer<typeof PreparedAppiumSchema>,
|
||||
{ versionSpec, env, signal }: { versionSpec: string; env: BuildStepEnv; signal?: AbortSignal }
|
||||
): Promise<boolean> {
|
||||
const { home } = prepared;
|
||||
if (home !== path.join(os.homedir(), 'Library', 'Caches', 'expo-simulator-startup', 'appium')) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const installedVersionAsync = async (name: string): Promise<string> =>
|
||||
PackageVersionSchema.parse(
|
||||
JSON.parse(
|
||||
await fs.promises.readFile(path.join(home, 'node_modules', name, 'package.json'), 'utf8')
|
||||
)
|
||||
).version;
|
||||
if (
|
||||
!semver.satisfies(prepared.version, versionSpec) ||
|
||||
(await installedVersionAsync('appium')) !== prepared.version ||
|
||||
(await installedVersionAsync('appium-xcuitest-driver')) !== prepared.xcuitest
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const runner = path.join(home, 'preinstalled-wda', 'WebDriverAgentRunner-Runner.app');
|
||||
await fs.promises.access(runner, fs.constants.R_OK | fs.constants.X_OK);
|
||||
if (!(await fs.promises.stat(runner)).isDirectory()) {
|
||||
return false;
|
||||
}
|
||||
const metadata = await fs.promises.readFile(path.join(runner, 'Info.plist'));
|
||||
const info =
|
||||
metadata.subarray(0, 8).toString('ascii') === 'bplist00'
|
||||
? bplistParser.parseBuffer(metadata)[0]
|
||||
: plist.parse(metadata.toString('utf8'));
|
||||
const executable = z
|
||||
.object({ CFBundleExecutable: z.string().min(1) })
|
||||
.parse(info).CFBundleExecutable;
|
||||
if (path.basename(executable) !== executable || executable === '.' || executable === '..') {
|
||||
return false;
|
||||
}
|
||||
const executablePath = path.join(runner, executable);
|
||||
await fs.promises.access(executablePath, fs.constants.R_OK | fs.constants.X_OK);
|
||||
const executableStat = await fs.promises.stat(executablePath);
|
||||
if (!executableStat.isFile() || executableStat.size === 0) {
|
||||
return false;
|
||||
}
|
||||
// Today's driver list shows that the Appium CLI runs from this home. It does not load the driver.
|
||||
const { stdout } = await runAppiumSetupCommandAsync(
|
||||
path.join(home, 'node_modules', '.bin', 'appium'),
|
||||
['driver', 'list', '--installed', '--json'],
|
||||
{ env: { ...env, APPIUM_HOME: home }, stdio: 'pipe' },
|
||||
signal
|
||||
);
|
||||
if (!AppiumInstalledDriversSchema.parse(JSON.parse(stdout)).xcuitest?.installed) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runAppiumSetupCommandAsync(
|
||||
command: string,
|
||||
args: string[],
|
||||
|
||||
@@ -137,13 +137,6 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
validateControllerVersion({
|
||||
packageName: ARGENT_PACKAGE_NAME,
|
||||
minimumVersion: MIN_ARGENT_REMOTE_SESSION_VERSION,
|
||||
packageVersion,
|
||||
logger,
|
||||
});
|
||||
const versionSpec = packageVersion ?? 'latest';
|
||||
const { runtimePlatform } = global;
|
||||
const startupStop = new AbortController();
|
||||
const watchSignal = signal
|
||||
@@ -156,7 +149,17 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
|
||||
});
|
||||
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
|
||||
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
|
||||
const startSessionAsync = async (
|
||||
warmSession?: WarmIosSession,
|
||||
resolvedPackageVersion = packageVersion
|
||||
): Promise<void> => {
|
||||
const versionSpec = resolvedPackageVersion ?? 'latest';
|
||||
validateControllerVersion({
|
||||
packageName: ARGENT_PACKAGE_NAME,
|
||||
minimumVersion: MIN_ARGENT_REMOTE_SESSION_VERSION,
|
||||
packageVersion: resolvedPackageVersion,
|
||||
logger,
|
||||
});
|
||||
const startupAbortController = new AbortController();
|
||||
const startupSignal = AbortSignal.any([watchSignal, startupAbortController.signal]);
|
||||
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { ArchiveSourceType, SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import { graphql } from 'gql.tada';
|
||||
|
||||
import { stripRepositoryCredentialsAsync } from '../../common/git';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import { withLogPhaseAsync } from '../../utils/logPhase';
|
||||
import { startSandboxDaemonAsync } from '../utils/sandboxDaemon';
|
||||
|
||||
const MARK_SANDBOX_READY_MUTATION = graphql(`
|
||||
mutation MarkSandboxReady($sandboxId: ID!) {
|
||||
sandbox {
|
||||
markSandboxReady(sandboxId: $sandboxId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
import { requireSandboxWorkerApi } from '../utils/sandboxWorkerApi';
|
||||
|
||||
const RECONNECT_DELAY_MS = 1_000;
|
||||
const AWAIT_SANDBOX_STEP_NAME = 'Await sandbox completion';
|
||||
@@ -39,16 +29,11 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, signal, env }) => {
|
||||
// The daemon needs this credential, but shell commands do not.
|
||||
const { __EAS_SANDBOX_MCP_TOKEN: sandboxToken, ...commandEnv } = env;
|
||||
if (!sandboxToken) {
|
||||
throw new SystemError('__EAS_SANDBOX_MCP_TOKEN is required to start the sandbox daemon.');
|
||||
}
|
||||
const workerApi = requireSandboxWorkerApi(ctx);
|
||||
const mcpServerUrl = ctx.mcpServerUrl;
|
||||
if (!mcpServerUrl) {
|
||||
throw new SystemError('MCP server URL is required to start the sandbox daemon.');
|
||||
}
|
||||
const sandboxId = String(inputs.sandbox_id.value);
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value;
|
||||
if (
|
||||
maxDurationSeconds !== undefined &&
|
||||
@@ -61,21 +46,35 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
`Invalid sandbox maximum duration: ${String(maxDurationSeconds)}. Provide a finite number greater than zero, or omit max_duration_seconds.`
|
||||
);
|
||||
}
|
||||
if (ctx.job.projectArchive.type === ArchiveSourceType.GIT) {
|
||||
// Scripts and commands inside the sandbox could read the token from .git/config.
|
||||
await stripRepositoryCredentialsAsync({
|
||||
repositoryDirectory: stepCtx.global.wasCheckedOut()
|
||||
? ctx.projectTargetDirectory
|
||||
: ctx.projectSourceDirectory,
|
||||
});
|
||||
}
|
||||
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential: sandboxToken,
|
||||
credential: workerApi.token,
|
||||
serverUrl: mcpServerUrl,
|
||||
reconnectDelayMs: RECONNECT_DELAY_MS,
|
||||
logger: stepCtx.logger,
|
||||
signal,
|
||||
workingDirectory: stepCtx.workingDirectory,
|
||||
env: commandEnv,
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
sandboxId,
|
||||
env,
|
||||
workerApi,
|
||||
});
|
||||
try {
|
||||
await daemon.ready;
|
||||
signal?.throwIfAborted();
|
||||
await markSandboxReadyAsync(ctx, sandboxId, signal);
|
||||
await workerApi.markReadyAsync({ signal }).catch((error: unknown) => {
|
||||
signal?.throwIfAborted();
|
||||
throw new SystemError(
|
||||
`Failed to mark sandbox ${String(inputs.sandbox_id.value)} as ready.`,
|
||||
{ cause: error }
|
||||
);
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
stepCtx.logger.info('Sandbox daemon started.');
|
||||
|
||||
@@ -103,23 +102,6 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
});
|
||||
}
|
||||
|
||||
export async function markSandboxReadyAsync(
|
||||
ctx: CustomBuildContext,
|
||||
sandboxId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(MARK_SANDBOX_READY_MUTATION, { sandboxId }, graphqlAbortContext(signal))
|
||||
.toPromise();
|
||||
signal?.throwIfAborted();
|
||||
if (result.error) {
|
||||
throw new SystemError(`Failed to mark sandbox ${sandboxId} as ready.`, {
|
||||
cause: result.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForCompletionAsync(signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
|
||||
@@ -108,7 +108,10 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
|
||||
});
|
||||
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
|
||||
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
|
||||
const startSessionAsync = async (
|
||||
warmSession?: WarmIosSession,
|
||||
resolvedPackageVersion = packageVersion
|
||||
): Promise<void> => {
|
||||
const startupAbortController = new AbortController();
|
||||
const startupSignal = AbortSignal.any([watchSignal, startupAbortController.signal]);
|
||||
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
|
||||
@@ -148,7 +151,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken },
|
||||
signal: startupSignal,
|
||||
onStartupError: error => startupAbortController.abort(error),
|
||||
packageVersion,
|
||||
packageVersion: resolvedPackageVersion,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
|
||||
@@ -9,10 +9,8 @@ import RemoteLoggerStream from '../../../logging/RemoteLoggerStream';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../agentDeviceEvents';
|
||||
import {
|
||||
DEVICE_RUN_SESSION_TOKEN_HEADER,
|
||||
DeviceRunSessionWorkerApiError,
|
||||
} from '../deviceRunSessionWorkerApi';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
import { WorkerApiError } from '../workerApi';
|
||||
|
||||
const mockEventLogStream = {
|
||||
init: jest.fn(async () => undefined),
|
||||
@@ -577,7 +575,7 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
'Failed to create device run session event log upload session: ' +
|
||||
'POST /v2/device-run-session-worker/event-log-upload-session failed with status 503: ' +
|
||||
'WWW unavailable',
|
||||
cause: expect.any(DeviceRunSessionWorkerApiError),
|
||||
cause: expect.any(WorkerApiError),
|
||||
}),
|
||||
},
|
||||
'Could not persist device run session events to the artifact.'
|
||||
@@ -589,7 +587,7 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
'Failed to create device run session event log upload session: ' +
|
||||
'POST /v2/device-run-session-worker/event-log-upload-session failed with status 503: ' +
|
||||
'WWW unavailable',
|
||||
cause: expect.any(DeviceRunSessionWorkerApiError),
|
||||
cause: expect.any(WorkerApiError),
|
||||
}),
|
||||
{
|
||||
level: 'warning',
|
||||
|
||||
@@ -16,7 +16,10 @@ import { PassThrough, Readable } from 'node:stream';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { MAX_ANDROID_LOG_BYTES_PER_SOURCE } from '../../../utils/androidEmulatorLogCapture';
|
||||
import { type AndroidEmulatorLogSource } from '../../../utils/androidEmulatorLogSources';
|
||||
import {
|
||||
ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
type AndroidEmulatorLogSource,
|
||||
} from '../../../utils/androidEmulatorLogSources';
|
||||
import {
|
||||
collectAndroidEmulatorLogsAsync,
|
||||
getAndroidEmulatorLogCapture,
|
||||
@@ -30,10 +33,13 @@ jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const sessionEnv = { DEVICE_RUN_SESSION_ID: 'session' };
|
||||
const upload = jest.mocked(uploadDeviceRunSessionArtifactAsync);
|
||||
let directory: string;
|
||||
let emulatorPath: string;
|
||||
let logcatPath: string;
|
||||
let emulatorRing: string;
|
||||
let logcatRing: string;
|
||||
let global: BuildStepGlobalContext;
|
||||
let logger: ReturnType<typeof createMockLogger>;
|
||||
let uploadedContents: Map<string, string>;
|
||||
@@ -45,6 +51,8 @@ beforeEach(async () => {
|
||||
directory = await mkdtemp(path.join(os.tmpdir(), 'android-log-collection-'));
|
||||
emulatorPath = path.join(directory, 'device-attempt-1-emulator.log');
|
||||
logcatPath = path.join(directory, 'device-attempt-1-logcat.log');
|
||||
emulatorRing = path.join(directory, 'emulator-output.log');
|
||||
logcatRing = path.join(directory, 'device-logcat.log');
|
||||
upload.mockReset().mockImplementation(async (_ctx, { filename, openStream }) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of openStream()) {
|
||||
@@ -59,13 +67,20 @@ afterEach(async () => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function captureLog(
|
||||
/** `==== <launch> <ISO time> ====\n` precedes the first output of a launch. */
|
||||
const MARKER = String.raw`==== \S+ \S+ ====\n`;
|
||||
|
||||
async function captureLaunch(
|
||||
filePath: string,
|
||||
source: AndroidEmulatorLogSource,
|
||||
text: string,
|
||||
workflow = global
|
||||
): Promise<void> {
|
||||
const capture = getAndroidEmulatorLogCapture(workflow, { DEVICE_RUN_SESSION_ID: 'session' });
|
||||
const capture = getAndroidEmulatorLogCapture(
|
||||
workflow,
|
||||
sessionEnv,
|
||||
ALL_ANDROID_EMULATOR_LOG_SOURCES
|
||||
);
|
||||
const input = Readable.from([Buffer.from(text)]);
|
||||
capture.attach(input, filePath, source);
|
||||
await once(input, 'end');
|
||||
@@ -81,23 +96,28 @@ function collect(): Promise<void> {
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ env: {}, expectedBytes: 20 * 1024 * 1024 + 1 },
|
||||
{ env: { DEVICE_RUN_SESSION_ID: 'session' }, expectedBytes: 20 * 1024 * 1024 },
|
||||
])('retains $expectedBytes with workflow environment $env', async ({ env, expectedBytes }) => {
|
||||
const capture = getAndroidEmulatorLogCapture(global, env);
|
||||
const input = Readable.from([Buffer.alloc(20 * 1024 * 1024 + 1, 'x')]);
|
||||
capture.attach(input, emulatorPath, 'emulator-output');
|
||||
await once(input, 'end');
|
||||
await capture.finishAsync();
|
||||
expect((await fsPromises.stat(emulatorPath)).size).toBe(expectedBytes);
|
||||
});
|
||||
{ env: {}, file: 'device-attempt-1-emulator.log', expectedBytes: 20 * 1024 * 1024 + 1 },
|
||||
{ env: sessionEnv, file: 'emulator-output.log', expectedBytes: 20 * 1024 * 1024 },
|
||||
])(
|
||||
'retains $expectedBytes in $file with workflow environment $env',
|
||||
async ({ env, file, expectedBytes }) => {
|
||||
const capture = getAndroidEmulatorLogCapture(global, env, ALL_ANDROID_EMULATOR_LOG_SOURCES);
|
||||
const input = Readable.from([Buffer.alloc(20 * 1024 * 1024 + 1, 'x')]);
|
||||
capture.attach(input, emulatorPath, 'emulator-output');
|
||||
await once(input, 'end');
|
||||
await capture.finishAsync();
|
||||
expect((await fsPromises.stat(path.join(directory, file))).size).toBe(expectedBytes);
|
||||
}
|
||||
);
|
||||
|
||||
it('uses captured source records and ignores files outside the capture inventory', async () => {
|
||||
const first = path.join(directory, 'first.txt');
|
||||
const second = path.join(directory, 'second.txt');
|
||||
await captureLog(first, 'emulator-output', 'host output');
|
||||
await captureLog(second, 'device-logcat', 'Android output');
|
||||
await captureLog(path.join(directory, 'retry.txt'), 'emulator-output', 'retry output');
|
||||
it('uploads one artifact per source and ignores files outside the capture inventory', async () => {
|
||||
await captureLaunch(emulatorPath, 'emulator-output', 'host output');
|
||||
await captureLaunch(logcatPath, 'device-logcat', 'Android output');
|
||||
await captureLaunch(
|
||||
path.join(directory, 'retry-emulator.log'),
|
||||
'emulator-output',
|
||||
'retry output'
|
||||
);
|
||||
await writeFile(emulatorPath, 'not captured');
|
||||
const elsewhere = path.join(directory, 'elsewhere');
|
||||
await mkdir(elsewhere);
|
||||
@@ -105,17 +125,23 @@ it('uses captured source records and ignores files outside the capture inventory
|
||||
await symlink(path.join(elsewhere, 'other-emulator.log'), logcatPath);
|
||||
await collect();
|
||||
|
||||
expect([...uploadedContents.values()]).toEqual(['host output', 'Android output', 'retry output']);
|
||||
expect([...uploadedContents.keys()]).toEqual(['emulator-output.log', 'device-logcat.log']);
|
||||
expect(uploadedContents.get('emulator-output.log')).toMatch(
|
||||
new RegExp(`^${MARKER}host output\\n${MARKER}retry output$`)
|
||||
);
|
||||
expect(uploadedContents.get('device-logcat.log')).toMatch(
|
||||
new RegExp(`^${MARKER}Android output$`)
|
||||
);
|
||||
expect(upload).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
filename: 'first.txt',
|
||||
filename: 'emulator-output.log',
|
||||
kind: 'simulator-log',
|
||||
metadata: expect.objectContaining({
|
||||
platform: 'android',
|
||||
source: 'emulator-output',
|
||||
scope: 'emulator-process',
|
||||
originalSizeBytes: 11,
|
||||
originalSizeBytes: uploadedContents.get('emulator-output.log')?.length,
|
||||
omittedBytes: 0,
|
||||
truncated: false,
|
||||
}),
|
||||
@@ -126,7 +152,7 @@ it('uses captured source records and ignores files outside the capture inventory
|
||||
expect(upload).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
filename: 'second.txt',
|
||||
filename: 'device-logcat.log',
|
||||
metadata: expect.objectContaining({ source: 'device-logcat', scope: 'device' }),
|
||||
})
|
||||
);
|
||||
@@ -134,13 +160,18 @@ it('uses captured source records and ignores files outside the capture inventory
|
||||
|
||||
it('seals a live producer before upload and keeps retry bodies identical without copying files', async () => {
|
||||
const input = new PassThrough();
|
||||
const capture = getAndroidEmulatorLogCapture(global, { DEVICE_RUN_SESSION_ID: 'session' });
|
||||
const capture = getAndroidEmulatorLogCapture(
|
||||
global,
|
||||
sessionEnv,
|
||||
ALL_ANDROID_EMULATOR_LOG_SOURCES
|
||||
);
|
||||
capture.attach(input, emulatorPath, 'emulator-output');
|
||||
input.write('before');
|
||||
const contents: string[] = [];
|
||||
upload.mockImplementationOnce(async (_ctx, { size, openStream, metadata }) => {
|
||||
expect(size).toBe(6);
|
||||
expect(metadata).toEqual(expect.objectContaining({ originalSizeBytes: 6, truncated: false }));
|
||||
expect(metadata).toEqual(
|
||||
expect.objectContaining({ originalSizeBytes: size, truncated: false })
|
||||
);
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
input.write(' ignored after finalization');
|
||||
const chunks: Buffer[] = [];
|
||||
@@ -152,28 +183,30 @@ it('seals a live producer before upload and keeps retry bodies identical without
|
||||
});
|
||||
try {
|
||||
await collect();
|
||||
expect(contents).toEqual(['before', 'before']);
|
||||
expect(await readFile(emulatorPath, 'utf8')).toBe('before');
|
||||
expect(await readdir(directory)).toEqual([path.basename(emulatorPath)]);
|
||||
expect(contents).toHaveLength(2);
|
||||
expect(contents[0]).toMatch(new RegExp(`^${MARKER}before$`));
|
||||
expect(contents[1]).toBe(contents[0]);
|
||||
expect(await readFile(emulatorRing, 'utf8')).toBe(contents[0]);
|
||||
expect(await readdir(directory)).toEqual(['emulator-output.log']);
|
||||
expect(input.readableFlowing).toBe(true);
|
||||
const late = new PassThrough();
|
||||
capture.attach(late, logcatPath, 'device-logcat');
|
||||
late.end('not captured');
|
||||
expect(late.readableFlowing).toBe(true);
|
||||
await expect(fsPromises.stat(logcatPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
await expect(fsPromises.stat(logcatRing)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
} finally {
|
||||
input.end();
|
||||
}
|
||||
});
|
||||
|
||||
it('continues with the other log after a failed upload and never repeats the collection', async () => {
|
||||
await captureLog(emulatorPath, 'emulator-output', 'host output');
|
||||
await captureLog(logcatPath, 'device-logcat', 'Android output');
|
||||
await captureLaunch(emulatorPath, 'emulator-output', 'host output');
|
||||
await captureLaunch(logcatPath, 'device-logcat', 'Android output');
|
||||
upload.mockRejectedValueOnce(new Error('storage unavailable'));
|
||||
await Promise.all([collect(), collect()]);
|
||||
await collect();
|
||||
expect(upload).toHaveBeenCalledTimes(2);
|
||||
expect(uploadedContents.get(path.basename(logcatPath))).toBe('Android output');
|
||||
expect(uploadedContents.get('device-logcat.log')).toMatch(/Android output$/);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
@@ -182,8 +215,8 @@ it('continues with the other log after a failed upload and never repeats the col
|
||||
});
|
||||
|
||||
it('uses a fresh 30 second budget for the next file after an upload times out', async () => {
|
||||
await captureLog(emulatorPath, 'emulator-output', 'host output');
|
||||
await captureLog(logcatPath, 'device-logcat', 'Android output');
|
||||
await captureLaunch(emulatorPath, 'emulator-output', 'host output');
|
||||
await captureLaunch(logcatPath, 'device-logcat', 'Android output');
|
||||
const controllers: AbortController[] = [];
|
||||
const timeout = jest.spyOn(AbortSignal, 'timeout').mockImplementation(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -195,46 +228,52 @@ it('uses a fresh 30 second budget for the next file after an upload times out',
|
||||
signal?.throwIfAborted();
|
||||
});
|
||||
await expect(collect()).resolves.toBeUndefined();
|
||||
expect(uploadedContents.get(path.basename(logcatPath))).toBe('Android output');
|
||||
expect(uploadedContents.get('device-logcat.log')).toMatch(/Android output$/);
|
||||
expect(timeout.mock.calls).toEqual([[30_000], [30_000]]);
|
||||
expect(upload.mock.calls[1][1].signal?.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it('does not collect another workflow and warns when a captured file disappears', async () => {
|
||||
await captureLog(emulatorPath, 'emulator-output', 'other workflow', {} as BuildStepGlobalContext);
|
||||
const otherWorkflow = {} as BuildStepGlobalContext;
|
||||
await captureLaunch(emulatorPath, 'emulator-output', 'other workflow', otherWorkflow);
|
||||
await collect();
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
await captureLog(path.join(directory, 'missing.log'), 'emulator-output', 'removed');
|
||||
await rm(path.join(directory, 'missing.log'));
|
||||
await getAndroidEmulatorLogCapture(otherWorkflow, sessionEnv, []).finishAsync();
|
||||
await captureLaunch(path.join(directory, 'missing-emulator.log'), 'emulator-output', 'removed');
|
||||
await rm(emulatorRing);
|
||||
await expect(collect()).resolves.toBeUndefined();
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.stringContaining('missing.log')
|
||||
expect.stringContaining('emulator-output.log')
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads the tail of a truncated log with the retained size and omission metadata', async () => {
|
||||
const capture = getAndroidEmulatorLogCapture(global, { DEVICE_RUN_SESSION_ID: 'session' });
|
||||
const capture = getAndroidEmulatorLogCapture(
|
||||
global,
|
||||
sessionEnv,
|
||||
ALL_ANDROID_EMULATOR_LOG_SOURCES
|
||||
);
|
||||
const input = Readable.from([
|
||||
Buffer.concat([Buffer.from('head: '), Buffer.alloc(MAX_ANDROID_LOG_BYTES_PER_SOURCE, 'b')]),
|
||||
]);
|
||||
capture.attach(input, emulatorPath, 'emulator-output');
|
||||
await once(input, 'end');
|
||||
await collect();
|
||||
expect(upload).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
size: MAX_ANDROID_LOG_BYTES_PER_SOURCE,
|
||||
name: expect.stringContaining('truncated, last 20 MiB retained'),
|
||||
metadata: expect.objectContaining({
|
||||
originalSizeBytes: MAX_ANDROID_LOG_BYTES_PER_SOURCE + 6,
|
||||
omittedBytes: 6,
|
||||
truncated: true,
|
||||
}),
|
||||
})
|
||||
);
|
||||
const uploaded = uploadedContents.get(path.basename(emulatorPath));
|
||||
const uploaded = uploadedContents.get('emulator-output.log');
|
||||
expect(uploaded).toHaveLength(MAX_ANDROID_LOG_BYTES_PER_SOURCE);
|
||||
expect(uploaded).not.toContain('head');
|
||||
expect(uploaded).not.toContain('====');
|
||||
const [, call] = upload.mock.calls[0];
|
||||
const { originalSizeBytes } = call.metadata as { originalSizeBytes: number };
|
||||
const omitted = originalSizeBytes - MAX_ANDROID_LOG_BYTES_PER_SOURCE;
|
||||
expect(omitted).toBeGreaterThanOrEqual(6);
|
||||
expect(call).toEqual(
|
||||
expect.objectContaining({
|
||||
size: MAX_ANDROID_LOG_BYTES_PER_SOURCE,
|
||||
name: expect.stringContaining('truncated, last 20.0 MB retained'),
|
||||
metadata: expect.objectContaining({ omittedBytes: omitted, truncated: true }),
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { FetchError } from 'node-fetch';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import {
|
||||
DEVICE_RUN_SESSION_TOKEN_HEADER,
|
||||
DeviceRunSessionWorkerApi,
|
||||
DeviceRunSessionWorkerApiError,
|
||||
getDeviceRunSessionWorkerApi,
|
||||
requireDeviceRunSessionWorkerApi,
|
||||
} from '../deviceRunSessionWorkerApi';
|
||||
@@ -65,23 +63,6 @@ describe(getDeviceRunSessionWorkerApi, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('derives the API v2 base URL from __API_SERVER_URL when the context has none', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
|
||||
const api = getDeviceRunSessionWorkerApi({
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN, __API_SERVER_URL: 'https://api.expo.test' },
|
||||
} as unknown as CustomBuildContext);
|
||||
|
||||
await api!.getSessionAsync();
|
||||
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/device-run-session-worker/session',
|
||||
'GET',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a system error when the token is set but no API URL is known', () => {
|
||||
expect(() =>
|
||||
getDeviceRunSessionWorkerApi({
|
||||
@@ -268,273 +249,4 @@ describe(DeviceRunSessionWorkerApi, () => {
|
||||
'GET /v2/device-run-session-worker/application returned an unexpected response'
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the signal and timeout without enabling nested retries', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { iceServers: [] } }));
|
||||
const controller = new AbortController();
|
||||
|
||||
await createApi().fetchTurnIceServersAsync({
|
||||
signal: controller.signal,
|
||||
timeoutMs: 5000,
|
||||
retries: 1,
|
||||
});
|
||||
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'POST',
|
||||
expect.objectContaining({ signal: controller.signal, timeout: 5000, retries: 0 })
|
||||
);
|
||||
});
|
||||
|
||||
it('reports the API v2 error message and status without the token', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
code: 'UNAUTHORIZED_ERROR',
|
||||
message: 'This device run session no longer accepts worker requests.',
|
||||
},
|
||||
],
|
||||
},
|
||||
403
|
||||
)
|
||||
);
|
||||
|
||||
const error = await createApi()
|
||||
.stopAsync()
|
||||
.catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
|
||||
expect(error.status).toBe(403);
|
||||
expect(error.message).toBe(
|
||||
'POST /v2/device-run-session-worker/stop failed with status 403: This device run session no longer accepts worker requests.'
|
||||
);
|
||||
expect(error.message).not.toContain(TOKEN);
|
||||
});
|
||||
|
||||
it('reports the status text when the error response is not an API v2 error', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(
|
||||
new Response('<html>bad gateway</html>', { status: 502, statusText: 'Bad Gateway' })
|
||||
);
|
||||
|
||||
const error = await createApi()
|
||||
.getSessionAsync({ retries: 0 })
|
||||
.catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
|
||||
expect(error.status).toBe(502);
|
||||
expect(error.message).toBe(
|
||||
'GET /v2/device-run-session-worker/session failed with status 502: Bad Gateway'
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a request that failed before www responded', async () => {
|
||||
const reason = new Error('socket hang up');
|
||||
jest.mocked(turtleFetch).mockRejectedValueOnce(reason);
|
||||
|
||||
const error = await createApi()
|
||||
.getSessionAsync({ retries: 0 })
|
||||
.catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
|
||||
expect(error.status).toBeUndefined();
|
||||
expect(error.cause).toBe(reason);
|
||||
expect(error.message).toContain('failed before www responded: socket hang up');
|
||||
});
|
||||
|
||||
it('rejects a response that does not match the route contract', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'STOPPED' } }));
|
||||
|
||||
await expect(createApi().getSessionAsync({ retries: 3 })).rejects.toThrow(
|
||||
'GET /v2/device-run-session-worker/session returned an unexpected response'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a successful response that is not JSON', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('ok', { status: 200 }));
|
||||
|
||||
await expect(createApi().getSessionAsync({ retries: 3 })).rejects.toThrow(
|
||||
'GET /v2/device-run-session-worker/session returned a response that is not JSON.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('device run session worker API retries', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it.each([429, 500, 502, 503, 504])(
|
||||
'recovers from HTTP %s when starting a session',
|
||||
async status => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(new Response('unavailable', { status }))
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
|
||||
const result = createApi().startAsync(
|
||||
{ remoteConfig: { url: 'https://preview.test' } },
|
||||
{ retries: 3, timeoutMs: 30_000 }
|
||||
);
|
||||
const assertion = expect(result).resolves.toEqual({ id: 'drs-id', status: 'in-progress' });
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(2);
|
||||
expect(jest.mocked(turtleFetch).mock.calls[1]).toEqual(
|
||||
jest.mocked(turtleFetch).mock.calls[0]
|
||||
);
|
||||
expect(jest.mocked(turtleFetch).mock.calls[0][2]).toMatchObject({
|
||||
retries: 0,
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('recovers from a network failure', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockRejectedValueOnce(new Error('socket hang up'))
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { build: null } }));
|
||||
const assertion = expect(createApi().getApplicationAsync({ retries: 3 })).resolves.toEqual({
|
||||
build: null,
|
||||
});
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('exhausts three retries with exponential backoff', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockImplementation(async () => new Response('unavailable', { status: 503 }));
|
||||
const assertion = expect(
|
||||
createApi().startAsync({ remoteConfig: {} }, { retries: 3 })
|
||||
).rejects.toMatchObject({
|
||||
status: 503,
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(2);
|
||||
await jest.advanceTimersByTimeAsync(2000);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(3);
|
||||
await jest.advanceTimersByTimeAsync(4000);
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it.each([400, 401, 403, 404, 409])('does not retry HTTP %s', async status => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('refused', { status }));
|
||||
await expect(
|
||||
createApi().startAsync({ remoteConfig: {} }, { retries: 3 })
|
||||
).rejects.toMatchObject({ status });
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['status polling', (api: DeviceRunSessionWorkerApi) => api.getSessionAsync()],
|
||||
['stop', (api: DeviceRunSessionWorkerApi) => api.stopAsync()],
|
||||
[
|
||||
'screenshot application metadata',
|
||||
(api: DeviceRunSessionWorkerApi) => api.getApplicationAsync(),
|
||||
],
|
||||
['event log setup', (api: DeviceRunSessionWorkerApi) => api.createEventLogUploadSessionAsync()],
|
||||
['preview setup', (api: DeviceRunSessionWorkerApi) => api.createPreviewUploadSessionAsync()],
|
||||
[
|
||||
'TURN without an explicit retry option',
|
||||
(api: DeviceRunSessionWorkerApi) => api.fetchTurnIceServersAsync(),
|
||||
],
|
||||
])('does not retry %s by default', async (_name, call) => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('unavailable', { status: 503 }));
|
||||
await expect(call(createApi())).rejects.toMatchObject({ status: 503 });
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('honors an explicit retry count', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockImplementation(async () => new Response('unavailable', { status: 503 }));
|
||||
const assertion = expect(
|
||||
createApi().fetchTurnIceServersAsync({ retries: 1 })
|
||||
).rejects.toMatchObject({ status: 503 });
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not send another request after cancellation during backoff', async () => {
|
||||
const controller = new AbortController();
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('unavailable', { status: 503 }));
|
||||
const assertion = expect(
|
||||
createApi().getApplicationAsync({ signal: controller.signal, retries: 3 })
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
controller.abort();
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('device run session retry safety', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
it('does not replay artifact creation after a transient failure', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('unavailable', { status: 503 }));
|
||||
await expect(
|
||||
createApi().createArtifactUploadSessionAsync({
|
||||
name: 'report',
|
||||
filename: 'report.json',
|
||||
size: 1,
|
||||
})
|
||||
).rejects.toMatchObject({ status: 503 });
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not send an already cancelled request', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
createApi().getApplicationAsync({ signal: controller.signal, retries: 3 })
|
||||
).rejects.toMatchObject({
|
||||
name: 'AbortError',
|
||||
});
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['system', 'body-timeout'])(
|
||||
'retries a response body transport failure (%s)',
|
||||
async type => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const response = jsonResponse({});
|
||||
jest
|
||||
.spyOn(response, 'json')
|
||||
.mockRejectedValueOnce(new FetchError('connection interrupted', type));
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(response)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
|
||||
const assertion = expect(
|
||||
createApi().startAsync({ remoteConfig: {} }, { retries: 3 })
|
||||
).resolves.toMatchObject({ status: 'in-progress' });
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import path from 'node:path';
|
||||
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils';
|
||||
import { verifyLocalEgressGuardAsync } from '../localEgressGuard';
|
||||
import { type ServeSimApplicationOptions } from '../remoteDeviceRunSession';
|
||||
|
||||
@@ -2193,6 +2194,58 @@ it.each([false, true])(
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
['0.13.3', '@expo/[email protected]'],
|
||||
[undefined, '@expo/serve-sim@latest'],
|
||||
])(
|
||||
'uses the warm serve-sim version %s for adoption and recordings',
|
||||
async (serveSimVersion, spec) => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([
|
||||
{ udid: 'emulator-5554', token: 'preview-token', url: 'http://127.0.0.1:3200' },
|
||||
]);
|
||||
const recording = jest.spyOn(IosSimulatorRecordingUtils, 'useServeSimPackage');
|
||||
const startAsync = jest.fn(async () => ({
|
||||
pid: undefined,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopServer,
|
||||
}));
|
||||
const host = await startDeviceSessionHostAsync(
|
||||
ctx,
|
||||
{
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: { ...env, EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' },
|
||||
logger,
|
||||
iosSimulatorUdid: 'emulator-5554',
|
||||
timeoutMs: 1000,
|
||||
},
|
||||
{
|
||||
port: 3200,
|
||||
serveSimVersion,
|
||||
turnArgs: Promise.resolve([]),
|
||||
requiresPreviewRetirement: true,
|
||||
startAsync,
|
||||
stopAsync: stopServer,
|
||||
}
|
||||
);
|
||||
try {
|
||||
expect(startAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'bun',
|
||||
args: expect.arrayContaining(['x', spec]),
|
||||
})
|
||||
);
|
||||
expect(recording).toHaveBeenCalledWith(spec);
|
||||
} finally {
|
||||
await host.finishAsync();
|
||||
recording.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('opens the preview while a supplied server waits for TURN and its startup handshake', async () => {
|
||||
const turn = deferred<string[]>();
|
||||
const handshake = deferred<Awaited<ReturnType<typeof spawnDetached>>>();
|
||||
|
||||
@@ -54,6 +54,23 @@ it('downloads the driver-matched Simulator runner and retains its XCTest framewo
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the runner of a prepared Appium home without a download', async () => {
|
||||
await fs.promises.mkdir(runner, { recursive: true });
|
||||
await expect(preparePreinstalledWdaAsync({ ...options, prepared: true })).resolves.toEqual({
|
||||
'appium:usePreinstalledWDA': true,
|
||||
'appium:prebuiltWDAPath': runner,
|
||||
});
|
||||
await expect(
|
||||
preparePreinstalledWdaAsync({
|
||||
...options,
|
||||
prepared: true,
|
||||
runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-16-4',
|
||||
})
|
||||
).resolves.toEqual({});
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(runner)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
'com.apple.CoreSimulator.SimRuntime.iOS-16-4',
|
||||
|
||||
@@ -11,9 +11,8 @@ jest.mock('node-fetch', () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { SandboxDaemonErrorCode, SystemError } from '@expo/eas-build-job';
|
||||
import { SandboxDaemonErrorCode } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type Client, CombinedError } from '@urql/core';
|
||||
import fetch, { type RequestInit } from 'node-fetch';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
@@ -24,6 +23,8 @@ import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { inspect } from 'node:util';
|
||||
|
||||
import { SandboxArtifactUploadManager, startSandboxArtifactUploadAsync } from '../sandboxArtifacts';
|
||||
import { type SandboxWorkerApi } from '../sandboxWorkerApi';
|
||||
import { WorkerApiError } from '../workerApi';
|
||||
|
||||
const { AbortError, FetchError, Response } = jest.requireActual(
|
||||
'node-fetch'
|
||||
@@ -33,25 +34,18 @@ const ARTIFACT_ID = '0199c0de-7b3a-7c1e-8f00-1234567890ab';
|
||||
const SIGNED_URL = 'https://uploads.expo.test/artifact?X-Amz-Signature=secret-signature';
|
||||
const DOWNLOAD_URL = 'https://downloads.expo.test/artifact?X-Amz-Signature=download-signature';
|
||||
|
||||
const createdResult = {
|
||||
data: {
|
||||
sandbox: {
|
||||
createArtifactUploadSession: {
|
||||
artifact: { id: ARTIFACT_ID },
|
||||
uploadSession: { url: SIGNED_URL, headers: { 'If-None-Match': '*' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const finalizedResult = {
|
||||
data: { sandbox: { finalizeArtifact: { id: ARTIFACT_ID, downloadUrl: DOWNLOAD_URL } } },
|
||||
const createdSession = {
|
||||
artifact: { id: ARTIFACT_ID },
|
||||
uploadSession: { url: SIGNED_URL, headers: { 'If-None-Match': '*' } },
|
||||
};
|
||||
|
||||
describe('sandbox artifact uploads', () => {
|
||||
let directory: string;
|
||||
let filePath: string;
|
||||
let mutation: jest.Mock;
|
||||
let graphqlClient: Client;
|
||||
let workerApi: {
|
||||
createArtifactUploadSessionAsync: jest.Mock;
|
||||
finalizeArtifactAsync: jest.Mock;
|
||||
};
|
||||
let logger: { info: jest.Mock; error: jest.Mock };
|
||||
let uploadArtifactAsync: (options: {
|
||||
filePath: string;
|
||||
@@ -64,13 +58,14 @@ describe('sandbox artifact uploads', () => {
|
||||
filePath = path.join(directory, 'crash.log');
|
||||
await fs.writeFile(filePath, 'crash');
|
||||
jest.mocked(fetch).mockReset();
|
||||
mutation = jest.fn();
|
||||
workerApi = {
|
||||
createArtifactUploadSessionAsync: jest.fn(),
|
||||
finalizeArtifactAsync: jest.fn(),
|
||||
};
|
||||
logger = { info: jest.fn(), error: jest.fn() };
|
||||
graphqlClient = { mutation } as unknown as Client;
|
||||
uploadArtifactAsync = async options =>
|
||||
await startSandboxArtifactUploadAsync({
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
workerApi: workerApi as unknown as SandboxWorkerApi,
|
||||
logger: logger as unknown as bunyan,
|
||||
...options,
|
||||
});
|
||||
@@ -81,7 +76,7 @@ describe('sandbox artifact uploads', () => {
|
||||
});
|
||||
|
||||
it('returns the artifact id before the PUT completes', async () => {
|
||||
mockMutationResults(createdResult, finalizedResult);
|
||||
mockWorkerApi({ finalize: DOWNLOAD_URL });
|
||||
let respond!: (response: InstanceType<typeof Response>) => void;
|
||||
jest.mocked(fetch).mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
@@ -96,34 +91,29 @@ describe('sandbox artifact uploads', () => {
|
||||
});
|
||||
|
||||
expect(upload.id).toBe(ARTIFACT_ID);
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sandboxId: 'sandbox-id', input: { name: 'Crash log', filename: 'crash.log', size: 5 } },
|
||||
expect(workerApi.createArtifactUploadSessionAsync).toHaveBeenCalledWith(
|
||||
{ name: 'Crash log', filename: 'crash.log', size: 5 },
|
||||
expect.anything()
|
||||
);
|
||||
expect(workerApi.finalizeArtifactAsync).not.toHaveBeenCalled();
|
||||
respond(new Response('', { status: 200 }));
|
||||
await expect(upload.completed).resolves.toEqual({
|
||||
status: 'uploaded',
|
||||
downloadUrl: DOWNLOAD_URL,
|
||||
});
|
||||
expect(mutation).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
{ artifactId: ARTIFACT_ID },
|
||||
expect.anything()
|
||||
expect(workerApi.finalizeArtifactAsync).toHaveBeenCalledWith(
|
||||
ARTIFACT_ID,
|
||||
expect.objectContaining({ retries: 2 })
|
||||
);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends exactly the stat size on every attempt when the file grows', async () => {
|
||||
mutation
|
||||
.mockReturnValueOnce({
|
||||
toPromise: async () => {
|
||||
await fs.appendFile(filePath, ' and more');
|
||||
return createdResult;
|
||||
},
|
||||
})
|
||||
.mockReturnValueOnce({ toPromise: async () => finalizedResult });
|
||||
workerApi.createArtifactUploadSessionAsync.mockImplementationOnce(async () => {
|
||||
await fs.appendFile(filePath, ' and more');
|
||||
return createdSession;
|
||||
});
|
||||
workerApi.finalizeArtifactAsync.mockResolvedValueOnce(DOWNLOAD_URL);
|
||||
const bodies: string[] = [];
|
||||
const respondAfterReadingBody =
|
||||
(status: number) =>
|
||||
@@ -153,7 +143,7 @@ describe('sandbox artifact uploads', () => {
|
||||
|
||||
it('uploads an empty file with an empty body', async () => {
|
||||
await fs.writeFile(filePath, '');
|
||||
mockMutationResults(createdResult, finalizedResult);
|
||||
mockWorkerApi({ finalize: DOWNLOAD_URL });
|
||||
let body: string | undefined;
|
||||
jest.mocked(fetch).mockImplementationOnce((async (_url: unknown, init?: RequestInit) => {
|
||||
const chunks: Buffer[] = [];
|
||||
@@ -171,9 +161,8 @@ describe('sandbox artifact uploads', () => {
|
||||
});
|
||||
await upload.completed;
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sandboxId: 'sandbox-id', input: { name: 'Empty log', filename: 'crash.log', size: 0 } },
|
||||
expect(workerApi.createArtifactUploadSessionAsync).toHaveBeenCalledWith(
|
||||
{ name: 'Empty log', filename: 'crash.log', size: 0 },
|
||||
expect.anything()
|
||||
);
|
||||
expect(body).toBe('');
|
||||
@@ -181,7 +170,7 @@ describe('sandbox artifact uploads', () => {
|
||||
});
|
||||
|
||||
it('finalizes when a retry fails the upload precondition', async () => {
|
||||
mockMutationResults(createdResult, finalizedResult);
|
||||
mockWorkerApi({ finalize: DOWNLOAD_URL });
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValueOnce(
|
||||
@@ -199,33 +188,20 @@ describe('sandbox artifact uploads', () => {
|
||||
await upload.completed;
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(mutation).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
{ artifactId: ARTIFACT_ID },
|
||||
expect.anything()
|
||||
);
|
||||
expect(workerApi.finalizeArtifactAsync).toHaveBeenCalledWith(ARTIFACT_ID, expect.anything());
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenLastCalledWith(
|
||||
`Uploaded sandbox artifact "Crash log" (${ARTIFACT_ID}, 5 bytes).`
|
||||
);
|
||||
});
|
||||
|
||||
it('retries finalize after a server error and logs a failure that persists', async () => {
|
||||
mockMutationResults(
|
||||
createdResult,
|
||||
{
|
||||
error: new CombinedError({
|
||||
networkError: new Error('Bad Gateway'),
|
||||
response: { status: 502 },
|
||||
}),
|
||||
},
|
||||
{
|
||||
error: new CombinedError({
|
||||
graphQLErrors: ['Sandbox artifact "Crash log" has no uploaded file.'],
|
||||
response: { status: 200 },
|
||||
}),
|
||||
}
|
||||
);
|
||||
it('logs a finalize failure and reports the upload as failed', async () => {
|
||||
mockWorkerApi({
|
||||
finalize: new WorkerApiError(
|
||||
'POST /v2/sandbox-worker/artifacts/x/finalize failed with status 502: Bad Gateway',
|
||||
502
|
||||
),
|
||||
});
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
|
||||
|
||||
const upload = await uploadArtifactAsync({
|
||||
@@ -235,21 +211,23 @@ describe('sandbox artifact uploads', () => {
|
||||
});
|
||||
|
||||
await expect(upload.completed).resolves.toEqual({ status: 'failed' });
|
||||
expect(mutation).toHaveBeenCalledTimes(3);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
`Uploaded sandbox artifact "Crash log" (${ARTIFACT_ID}), but could not finalize it.`
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['VALIDATION_ERROR', 'UNAUTHORIZED'])(
|
||||
'rejects a %s upload session error as a bad request with the GraphQL message',
|
||||
async errorCode => {
|
||||
it.each([
|
||||
{ status: 400, code: 'VALIDATION_ERROR' },
|
||||
{ status: 403, code: 'UNAUTHORIZED' },
|
||||
])(
|
||||
'rejects a $code upload session error as a bad request with the API message',
|
||||
async ({ status, code }) => {
|
||||
const message =
|
||||
'Sandbox already has an artifact named "Crash log". Use a different name for this artifact.';
|
||||
mockMutationResults({
|
||||
error: new CombinedError({ graphQLErrors: [{ message, extensions: { errorCode } }] }),
|
||||
});
|
||||
'POST /v2/sandbox-worker/artifact-upload-sessions failed with status 400: Sandbox already has an artifact named "Crash log". Use a different name for this artifact.';
|
||||
workerApi.createArtifactUploadSessionAsync.mockRejectedValueOnce(
|
||||
new WorkerApiError(message, status, undefined, code)
|
||||
);
|
||||
|
||||
const upload = uploadArtifactAsync({
|
||||
filePath,
|
||||
@@ -265,23 +243,16 @@ describe('sandbox artifact uploads', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects an unexpected upload session error as a system error', async () => {
|
||||
mockMutationResults({
|
||||
error: new CombinedError({
|
||||
graphQLErrors: [
|
||||
{ message: 'Request failed.', extensions: { errorCode: 'UNEXPECTED_ERROR' } },
|
||||
],
|
||||
}),
|
||||
});
|
||||
it.each([
|
||||
{ status: 500, code: 'UNEXPECTED_ERROR' },
|
||||
{ status: 401, code: 'AUTHENTICATION_ERROR' },
|
||||
])('rethrows a $code upload session error unchanged', async ({ status, code }) => {
|
||||
const error = new WorkerApiError('Request failed.', status, undefined, code);
|
||||
workerApi.createArtifactUploadSessionAsync.mockRejectedValueOnce(error);
|
||||
|
||||
const upload = uploadArtifactAsync({
|
||||
filePath,
|
||||
name: 'Crash log',
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
await expect(upload).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(upload).rejects.toMatchObject({ message: 'Request failed.' });
|
||||
await expect(
|
||||
uploadArtifactAsync({ filePath, name: 'Crash log', signal: new AbortController().signal })
|
||||
).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it('rejects a missing file as a bad request without creating an upload session', async () => {
|
||||
@@ -297,11 +268,11 @@ describe('sandbox artifact uploads', () => {
|
||||
code: SandboxDaemonErrorCode.BAD_REQUEST,
|
||||
message: `File does not exist: ${missingPath}`,
|
||||
});
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
expect(workerApi.createArtifactUploadSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the signed URL out of logs', async () => {
|
||||
mockMutationResults(createdResult);
|
||||
mockWorkerApi({});
|
||||
jest.mocked(fetch).mockRejectedValue(
|
||||
new FetchError(`request to ${SIGNED_URL} failed, reason: socket hang up`, 'system', {
|
||||
code: 'ECONNRESET',
|
||||
@@ -323,7 +294,7 @@ describe('sandbox artifact uploads', () => {
|
||||
});
|
||||
|
||||
it('waits for an aborted upload before the manager reports that it stopped', async () => {
|
||||
mockMutationResults(createdResult);
|
||||
mockWorkerApi({});
|
||||
mockPutUntilAborted();
|
||||
const controller = new AbortController();
|
||||
const manager = createManager(controller.signal);
|
||||
@@ -346,36 +317,24 @@ describe('sandbox artifact uploads', () => {
|
||||
).rejects.toMatchObject({
|
||||
name: 'AbortError',
|
||||
});
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(workerApi.createArtifactUploadSessionAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
case: 'a finalized upload as uploaded with its download URL',
|
||||
finalizeResult: finalizedResult,
|
||||
finalize: DOWNLOAD_URL,
|
||||
outcome: { status: 'uploaded', downloadUrl: DOWNLOAD_URL },
|
||||
},
|
||||
{
|
||||
case: 'a failed finalization as failed',
|
||||
finalizeResult: {
|
||||
error: new CombinedError({
|
||||
graphQLErrors: ['Sandbox artifact "Crash log" has no uploaded file.'],
|
||||
response: { status: 200 },
|
||||
}),
|
||||
},
|
||||
outcome: { status: 'failed' },
|
||||
},
|
||||
{
|
||||
case: 'a finalized artifact without a download URL as failed',
|
||||
finalizeResult: {
|
||||
data: { sandbox: { finalizeArtifact: { id: ARTIFACT_ID, downloadUrl: null } } },
|
||||
},
|
||||
finalize: new WorkerApiError('Sandbox artifact "Crash log" has no uploaded file.', 400),
|
||||
outcome: { status: 'failed' },
|
||||
},
|
||||
])(
|
||||
'waits for an upload that ends within the yield time and reports $case',
|
||||
async ({ finalizeResult, outcome }) => {
|
||||
mockMutationResults(createdResult, finalizeResult);
|
||||
async ({ finalize, outcome }) => {
|
||||
mockWorkerApi({ finalize });
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
|
||||
const manager = createManager(new AbortController().signal);
|
||||
|
||||
@@ -390,12 +349,10 @@ describe('sandbox artifact uploads', () => {
|
||||
const finalizeCalledPromise = new Promise<void>(resolve => {
|
||||
finalizeCalled = resolve;
|
||||
});
|
||||
mockMutationResults(createdResult);
|
||||
mutation.mockReturnValueOnce({
|
||||
toPromise: async () => {
|
||||
finalizeCalled();
|
||||
return finalizedResult;
|
||||
},
|
||||
mockWorkerApi({});
|
||||
workerApi.finalizeArtifactAsync.mockImplementationOnce(async () => {
|
||||
finalizeCalled();
|
||||
return DOWNLOAD_URL;
|
||||
});
|
||||
let respond!: (response: InstanceType<typeof Response>) => void;
|
||||
jest.mocked(fetch).mockReturnValueOnce(
|
||||
@@ -413,11 +370,9 @@ describe('sandbox artifact uploads', () => {
|
||||
});
|
||||
|
||||
it('counts upload session creation toward the yield time', async () => {
|
||||
mutation.mockReturnValueOnce({
|
||||
toPromise: async () => {
|
||||
await delay(400);
|
||||
return createdResult;
|
||||
},
|
||||
workerApi.createArtifactUploadSessionAsync.mockImplementationOnce(async () => {
|
||||
await delay(400);
|
||||
return createdSession;
|
||||
});
|
||||
mockPutUntilAborted();
|
||||
const controller = new AbortController();
|
||||
@@ -434,8 +389,7 @@ describe('sandbox artifact uploads', () => {
|
||||
|
||||
function createManager(signal: AbortSignal): SandboxArtifactUploadManager {
|
||||
return new SandboxArtifactUploadManager({
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
workerApi: workerApi as unknown as SandboxWorkerApi,
|
||||
logger: logger as unknown as bunyan,
|
||||
signal,
|
||||
});
|
||||
@@ -451,9 +405,12 @@ describe('sandbox artifact uploads', () => {
|
||||
}) as unknown as typeof fetch);
|
||||
}
|
||||
|
||||
function mockMutationResults(...results: unknown[]): void {
|
||||
for (const result of results) {
|
||||
mutation.mockReturnValueOnce({ toPromise: async () => result });
|
||||
function mockWorkerApi({ finalize }: { finalize?: string | Error }): void {
|
||||
workerApi.createArtifactUploadSessionAsync.mockResolvedValueOnce(createdSession);
|
||||
if (finalize instanceof Error) {
|
||||
workerApi.finalizeArtifactAsync.mockRejectedValueOnce(finalize);
|
||||
} else if (finalize !== undefined) {
|
||||
workerApi.finalizeArtifactAsync.mockResolvedValueOnce(finalize);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
import { SandboxDaemonResponseZ, SystemError } from '@expo/eas-build-job';
|
||||
import Log from '@expo/logger';
|
||||
import { type Client } from '@urql/core';
|
||||
import http from 'node:http';
|
||||
import net, { AddressInfo } from 'node:net';
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
|
||||
import * as sandboxCommandImplementations from '../sandboxCommandImplementations';
|
||||
import { startSandboxDaemonAsync } from '../sandboxDaemon';
|
||||
import { type SandboxWorkerApi } from '../sandboxWorkerApi';
|
||||
|
||||
jest.unmock('@expo/logger');
|
||||
jest.unmock('fs');
|
||||
@@ -16,7 +16,7 @@ jest.unmock('node:fs/promises');
|
||||
|
||||
describe(startSandboxDaemonAsync.name, () => {
|
||||
const logger = Log.child({ buildStepId: 'sandbox-test' });
|
||||
const graphqlClient = {} as Client;
|
||||
const workerApi = {} as SandboxWorkerApi;
|
||||
it.each(['ftp://localhost', 'not a URL'])('reports invalid server URL %s', async serverUrl => {
|
||||
const errorLog = jest.spyOn(logger, 'error').mockImplementation(() => {});
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
@@ -26,8 +26,7 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
workerApi,
|
||||
});
|
||||
try {
|
||||
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
|
||||
@@ -53,8 +52,7 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
workerApi,
|
||||
});
|
||||
try {
|
||||
const socket = await connection;
|
||||
@@ -89,8 +87,7 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
workerApi,
|
||||
});
|
||||
try {
|
||||
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
|
||||
@@ -121,8 +118,7 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
signal: controller.signal,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
workerApi,
|
||||
});
|
||||
const connectionError = daemon.ready.catch(error => error);
|
||||
|
||||
@@ -151,8 +147,7 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
workerApi,
|
||||
});
|
||||
|
||||
await expect(daemon.ready).rejects.toThrow('Sandbox MCP server connection failed');
|
||||
@@ -186,8 +181,7 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
workerApi,
|
||||
});
|
||||
const socket = await connection;
|
||||
const daemon = await daemonPromise;
|
||||
@@ -219,8 +213,7 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
workerApi,
|
||||
});
|
||||
const socket = await connection;
|
||||
await daemon.ready;
|
||||
@@ -264,8 +257,7 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
workerApi,
|
||||
});
|
||||
const socket = await connection;
|
||||
await daemon.ready;
|
||||
@@ -350,8 +342,7 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
workerApi,
|
||||
});
|
||||
const socket = await connection;
|
||||
await daemon.ready;
|
||||
@@ -396,8 +387,7 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
workerApi,
|
||||
});
|
||||
const socket = await connection;
|
||||
const daemon = await daemonPromise;
|
||||
|
||||
@@ -5,7 +5,6 @@ jest.unmock('node:fs/promises');
|
||||
|
||||
import { type SandboxDaemonCommandResult, SandboxDaemonErrorCode } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type Client } from '@urql/core';
|
||||
import fetch from 'node-fetch';
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import fs, { type FileHandle } from 'node:fs/promises';
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
type SandboxDaemonCommandImplementations,
|
||||
createSandboxCommandImplementations,
|
||||
} from '../sandboxCommandImplementations';
|
||||
import { type SandboxWorkerApi } from '../sandboxWorkerApi';
|
||||
|
||||
const READ_FILE_LIMITS = { maxTextBytes: 40_000, maxImageBytes: 3_000_000 };
|
||||
const FIXTURES_DIRECTORY = path.join(__dirname, 'fixtures');
|
||||
@@ -31,22 +31,25 @@ describe('sandbox daemon commands', () => {
|
||||
let commandImplementations: SandboxDaemonCommandImplementations;
|
||||
let stopAsync: () => Promise<void>;
|
||||
let workingDirectory: string;
|
||||
let mutation: jest.Mock;
|
||||
let graphqlClient: Client;
|
||||
let workerApi: {
|
||||
createArtifactUploadSessionAsync: jest.Mock;
|
||||
finalizeArtifactAsync: jest.Mock;
|
||||
};
|
||||
let logger: bunyan;
|
||||
|
||||
beforeEach(async () => {
|
||||
workingDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'sandbox-command-executor-'));
|
||||
mutation = jest.fn();
|
||||
graphqlClient = { mutation } as unknown as Client;
|
||||
workerApi = {
|
||||
createArtifactUploadSessionAsync: jest.fn(),
|
||||
finalizeArtifactAsync: jest.fn(),
|
||||
};
|
||||
logger = { info: jest.fn(), error: jest.fn() } as unknown as bunyan;
|
||||
const controller = new AbortController();
|
||||
const commands = createSandboxCommandImplementations({
|
||||
workingDirectory,
|
||||
env: process.env,
|
||||
signal: controller.signal,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
workerApi: workerApi as unknown as SandboxWorkerApi,
|
||||
logger,
|
||||
});
|
||||
commandImplementations = commands.commandImplementations;
|
||||
@@ -75,8 +78,7 @@ describe('sandbox daemon commands', () => {
|
||||
workingDirectory,
|
||||
env: process.env,
|
||||
signal: controller.signal,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
workerApi: workerApi as unknown as SandboxWorkerApi,
|
||||
logger,
|
||||
});
|
||||
const command = commands.commandImplementations.execCommand({ cmd: 'printf should-not-run' });
|
||||
@@ -430,37 +432,17 @@ describe('sandbox daemon commands', () => {
|
||||
`${path.join(workingDirectory, 'dist')} is not a regular file. To upload a directory, create an archive first`
|
||||
),
|
||||
});
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
expect(workerApi.createArtifactUploadSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads an artifact from a path relative to the working directory and waits for it by default', async () => {
|
||||
await fs.mkdir(path.join(workingDirectory, 'logs'));
|
||||
await fs.writeFile(path.join(workingDirectory, 'logs', 'crash.log'), 'crash');
|
||||
mutation
|
||||
.mockReturnValueOnce({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
sandbox: {
|
||||
createArtifactUploadSession: {
|
||||
artifact: { id: ARTIFACT_ID },
|
||||
uploadSession: { url: 'https://uploads.expo.test/artifact', headers: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
sandbox: {
|
||||
finalizeArtifact: {
|
||||
id: ARTIFACT_ID,
|
||||
downloadUrl: 'https://downloads.expo.test/artifact',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
workerApi.createArtifactUploadSessionAsync.mockResolvedValueOnce({
|
||||
artifact: { id: ARTIFACT_ID },
|
||||
uploadSession: { url: 'https://uploads.expo.test/artifact', headers: {} },
|
||||
});
|
||||
workerApi.finalizeArtifactAsync.mockResolvedValueOnce('https://downloads.expo.test/artifact');
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
|
||||
|
||||
const result = await commandImplementations.uploadArtifact({
|
||||
@@ -473,9 +455,8 @@ describe('sandbox daemon commands', () => {
|
||||
status: 'uploaded',
|
||||
downloadUrl: 'https://downloads.expo.test/artifact',
|
||||
});
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sandboxId: 'sandbox-id', input: { name: 'Crash log', filename: 'crash.log', size: 5 } },
|
||||
expect(workerApi.createArtifactUploadSessionAsync).toHaveBeenCalledWith(
|
||||
{ name: 'Crash log', filename: 'crash.log', size: 5 },
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { ArchiveSourceType, EnvironmentSecretType, type Job } from '@expo/eas-build-job';
|
||||
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { SandboxWorkerApi, getSandboxWorkerApi } from '../sandboxWorkerApi';
|
||||
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.unmock('node-fetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
const BASE_URL = 'https://api.expo.test/v2/';
|
||||
const TOKEN = 'sandbox-worker-token-value';
|
||||
const ARTIFACT_ID = '0199c0de-7b3a-7c1e-8f00-1234567890ab';
|
||||
|
||||
function jsonResponse(body: unknown): InstanceType<typeof Response> {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
describe(getSandboxWorkerApi, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
it('returns null when the job has no sandbox worker token', () => {
|
||||
expect(
|
||||
getSandboxWorkerApi({
|
||||
job: { secrets: { environmentSecrets: [] } } as unknown as Job,
|
||||
env: { EAS_SANDBOX_WORKER_TOKEN: TOKEN },
|
||||
expoApiV2BaseUrl: BASE_URL,
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('reads the token from the job secrets', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { id: 'sandbox-id' } }));
|
||||
const api = getSandboxWorkerApi({
|
||||
job: {
|
||||
secrets: {
|
||||
environmentSecrets: [
|
||||
{ name: 'EAS_SANDBOX_WORKER_TOKEN', type: EnvironmentSecretType.STRING, value: TOKEN },
|
||||
],
|
||||
},
|
||||
} as unknown as Job,
|
||||
env: {},
|
||||
expoApiV2BaseUrl: BASE_URL,
|
||||
});
|
||||
|
||||
await api!.markReadyAsync();
|
||||
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/sandbox-worker/ready',
|
||||
'POST',
|
||||
expect.objectContaining({ headers: { 'expo-sandbox-worker-token': TOKEN } })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SandboxWorkerApi, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'getProjectArchiveSourceAsync',
|
||||
call: (api: SandboxWorkerApi) => api.getProjectArchiveSourceAsync(),
|
||||
route: 'project-archive',
|
||||
json: undefined,
|
||||
data: { type: ArchiveSourceType.URL, url: 'https://storage.expo.test/project.tar.gz' },
|
||||
expected: { type: ArchiveSourceType.URL, url: 'https://storage.expo.test/project.tar.gz' },
|
||||
},
|
||||
{
|
||||
name: 'markReadyAsync',
|
||||
call: (api: SandboxWorkerApi) => api.markReadyAsync(),
|
||||
route: 'ready',
|
||||
json: undefined,
|
||||
data: { id: 'sandbox-id' },
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
name: 'createArtifactUploadSessionAsync',
|
||||
call: (api: SandboxWorkerApi) =>
|
||||
api.createArtifactUploadSessionAsync({ name: 'Crash log', filename: 'crash.log', size: 5 }),
|
||||
route: 'artifact-upload-sessions',
|
||||
json: { name: 'Crash log', filename: 'crash.log', size: 5 },
|
||||
data: {
|
||||
artifact: { id: ARTIFACT_ID },
|
||||
uploadSession: { url: 'https://uploads.expo.test/a', headers: { 'If-None-Match': '*' } },
|
||||
},
|
||||
expected: {
|
||||
artifact: { id: ARTIFACT_ID },
|
||||
uploadSession: { url: 'https://uploads.expo.test/a', headers: { 'If-None-Match': '*' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'finalizeArtifactAsync',
|
||||
call: (api: SandboxWorkerApi) => api.finalizeArtifactAsync(ARTIFACT_ID),
|
||||
route: `artifacts/${ARTIFACT_ID}/finalize`,
|
||||
json: undefined,
|
||||
data: { id: ARTIFACT_ID, downloadUrl: 'https://downloads.expo.test/a' },
|
||||
expected: 'https://downloads.expo.test/a',
|
||||
},
|
||||
])(
|
||||
'$name calls POST /v2/sandbox-worker/$route with the sandbox worker token header',
|
||||
async ({ call, route, json, data, expected }) => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data }));
|
||||
|
||||
await expect(call(new SandboxWorkerApi(BASE_URL, TOKEN))).resolves.toEqual(expected);
|
||||
|
||||
const [url, method, options] = jest.mocked(turtleFetch).mock.calls[0];
|
||||
expect(url).toBe(`https://api.expo.test/v2/sandbox-worker/${route}`);
|
||||
expect(method).toBe('POST');
|
||||
expect(options.headers).toEqual({ 'expo-sandbox-worker-token': TOKEN });
|
||||
expect(options.json).toEqual(json);
|
||||
}
|
||||
);
|
||||
|
||||
it('escapes the artifact ID in the finalize route', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'a', downloadUrl: 'https://d.test/a' } }));
|
||||
|
||||
await new SandboxWorkerApi(BASE_URL, TOKEN).finalizeArtifactAsync('../ready');
|
||||
|
||||
expect(jest.mocked(turtleFetch).mock.calls[0][0]).toBe(
|
||||
'https://api.expo.test/v2/sandbox-worker/artifacts/..%2Fready/finalize'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@ import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { resolveSimulatorPackageVersionAsync } from '../../../utils/simulatorPackageVersion';
|
||||
import { stopWarmSimulatorAsync } from '../../../utils/warmSimulator';
|
||||
import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils';
|
||||
import { fetchWebPreviewTurnArgsAsync } from '../remoteDeviceRunSession';
|
||||
import {
|
||||
@@ -12,6 +14,8 @@ import {
|
||||
import { type WarmIosSession, tryClaimWarmIosSessionAsync } from '../warmIosSession';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/simulatorPackageVersion');
|
||||
jest.mock('../../../utils/warmSimulator');
|
||||
jest.mock('../IosSimulatorRecordingUtils');
|
||||
jest.mock('../warmIosSession');
|
||||
jest.mock('../remoteDeviceRunSession', () => ({
|
||||
@@ -63,9 +67,11 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
it('adopts the warm device on the warm port with its recording, then prepares tools', async () => {
|
||||
warm.serveSimVersion = '0.13.3';
|
||||
const server = createWarmDeviceSessionServer(ctx, logger, warm);
|
||||
expect(server).toMatchObject({
|
||||
port: 3200,
|
||||
serveSimVersion: '0.13.3',
|
||||
requiresPreviewRetirement: true,
|
||||
preinstalledApp: warm.preinstalledExpoGo,
|
||||
});
|
||||
@@ -125,7 +131,7 @@ describe(runOnWarmIosSimulatorAsync, () => {
|
||||
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(undefined);
|
||||
const run = jest.fn().mockResolvedValue('cold');
|
||||
await expect(runOnWarmIosSimulatorAsync(claim, run)).resolves.toBe('cold');
|
||||
expect(run).toHaveBeenCalledWith(undefined);
|
||||
expect(run).toHaveBeenCalledWith(undefined, undefined);
|
||||
});
|
||||
|
||||
it('runs on the claimed warm Simulator', async () => {
|
||||
@@ -133,7 +139,7 @@ describe(runOnWarmIosSimulatorAsync, () => {
|
||||
const run = jest.fn().mockResolvedValue('warm');
|
||||
await expect(runOnWarmIosSimulatorAsync(claim, run)).resolves.toBe('warm');
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledWith(warm);
|
||||
expect(run).toHaveBeenCalledWith(warm, undefined);
|
||||
});
|
||||
|
||||
it('stops warmup and runs cold after an adoption failure', async () => {
|
||||
@@ -144,7 +150,71 @@ describe(runOnWarmIosSimulatorAsync, () => {
|
||||
.mockResolvedValueOnce('cold');
|
||||
await expect(runOnWarmIosSimulatorAsync(claim, run)).resolves.toBe('cold');
|
||||
expect(warm.process.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenLastCalledWith(undefined);
|
||||
expect(run).toHaveBeenLastCalledWith(undefined, undefined);
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
'uses the resolved request for claim and setup (warm: %s)',
|
||||
async reuse => {
|
||||
jest.mocked(resolveSimulatorPackageVersionAsync).mockResolvedValue('0.14.0');
|
||||
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(reuse ? warm : undefined);
|
||||
const request = { ...claim, env: { EAS_USE_WARM_SIMULATOR: '1' }, packageVersion: 'latest' };
|
||||
const run = jest.fn().mockResolvedValue('done');
|
||||
await expect(runOnWarmIosSimulatorAsync(request, run)).resolves.toBe('done');
|
||||
expect(tryClaimWarmIosSessionAsync).toHaveBeenCalledWith({
|
||||
...request,
|
||||
packageVersion: '0.14.0',
|
||||
});
|
||||
expect(run).toHaveBeenCalledWith(reuse ? warm : undefined, '0.14.0');
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps the resolved version through an adoption failure', async () => {
|
||||
jest.mocked(resolveSimulatorPackageVersionAsync).mockResolvedValue('0.14.0');
|
||||
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm);
|
||||
const run = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new WarmSessionAdoptionError())
|
||||
.mockResolvedValue('cold');
|
||||
await runOnWarmIosSimulatorAsync(
|
||||
{ ...claim, env: { EAS_USE_WARM_SIMULATOR: '1' }, packageVersion: 'latest' },
|
||||
run
|
||||
);
|
||||
expect(resolveSimulatorPackageVersionAsync).toHaveBeenCalledTimes(1);
|
||||
expect(run.mock.calls).toEqual([
|
||||
[warm, '0.14.0'],
|
||||
[undefined, '0.14.0'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('stops warmup and fails when an explicit request cannot be resolved', async () => {
|
||||
const error = new Error('lookup failed');
|
||||
jest.mocked(resolveSimulatorPackageVersionAsync).mockRejectedValue(error);
|
||||
const run = jest.fn();
|
||||
await expect(
|
||||
runOnWarmIosSimulatorAsync(
|
||||
{ ...claim, env: { EAS_USE_WARM_SIMULATOR: '1' }, packageVersion: 'latest' },
|
||||
run
|
||||
)
|
||||
).rejects.toBe(error);
|
||||
expect(stopWarmSimulatorAsync).toHaveBeenCalledTimes(1);
|
||||
expect(tryClaimWarmIosSessionAsync).not.toHaveBeenCalled();
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves lookup and cleanup failures without starting a session', async () => {
|
||||
const lookup = new Error('lookup failed');
|
||||
const cleanup = new Error('cleanup failed');
|
||||
jest.mocked(resolveSimulatorPackageVersionAsync).mockRejectedValue(lookup);
|
||||
jest.mocked(stopWarmSimulatorAsync).mockRejectedValue(cleanup);
|
||||
const run = jest.fn();
|
||||
await expect(
|
||||
runOnWarmIosSimulatorAsync(
|
||||
{ ...claim, env: { EAS_USE_WARM_SIMULATOR: '1' }, packageVersion: 'latest' },
|
||||
run
|
||||
)
|
||||
).rejects.toMatchObject({ errors: [lookup, cleanup] });
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not run cold after a failure during the session', async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from 'node:path';
|
||||
|
||||
import { runSimulatorLauncherAsync, stopWarmSimulatorAsync } from '../../../utils/warmSimulator';
|
||||
import { readLocalEgressHandoffAsync } from '../localEgress';
|
||||
import { runOnWarmIosSimulatorAsync } from '../warmDeviceSessionHost';
|
||||
import { tryClaimWarmIosSessionAsync } from '../warmIosSession';
|
||||
|
||||
jest.mock('../../../utils/warmSimulator');
|
||||
@@ -84,6 +85,24 @@ it('takes the preinstalled Expo Go that prepare-tools reports', async () => {
|
||||
expect(session!.preinstalledExpoGo).toEqual(expoGo);
|
||||
});
|
||||
|
||||
it('keeps the exact serve-sim version from the launcher claim', async () => {
|
||||
jest
|
||||
.mocked(runSimulatorLauncherAsync)
|
||||
.mockResolvedValueOnce(JSON.stringify({ ...claim, serveSimVersion: '0.13.3' }));
|
||||
const session = await tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger });
|
||||
expect(session?.serveSimVersion).toBe('0.13.3');
|
||||
});
|
||||
|
||||
it('stops warmup when the claim contains an invalid serve-sim version', async () => {
|
||||
jest
|
||||
.mocked(runSimulatorLauncherAsync)
|
||||
.mockResolvedValueOnce(JSON.stringify({ ...claim, serveSimVersion: 'latest' }));
|
||||
await expect(
|
||||
tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger })
|
||||
).resolves.toBeUndefined();
|
||||
expect(stopWarmSimulatorAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reads the agent-device server after tool preparation', async () => {
|
||||
jest
|
||||
.spyOn(fs, 'readFile')
|
||||
@@ -121,7 +140,6 @@ it.each([
|
||||
{},
|
||||
() => jest.mocked(runSimulatorLauncherAsync).mockRejectedValue(new Error('no session')),
|
||||
],
|
||||
['a pinned package version', { packageVersion: '1.2.3' }, () => {}],
|
||||
['a job Simulator setting', { env: { ...env, SIMCTL_CHILD_FOO: '1' } }, () => {}],
|
||||
[
|
||||
'a local egress session',
|
||||
@@ -149,3 +167,69 @@ it('stops the claimed session once', async () => {
|
||||
await Promise.all([session!.process.stopAsync(), session!.process.stopAsync()]);
|
||||
expect(stopWarmSimulatorAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['serve-sim', 'serveSimVersion'],
|
||||
['agent-device', 'agentDeviceVersion'],
|
||||
['argent', 'argentVersion'],
|
||||
] as const)(
|
||||
'reuses %s only when its recorded version matches the resolved request',
|
||||
async (engine, field) => {
|
||||
jest
|
||||
.mocked(runSimulatorLauncherAsync)
|
||||
.mockResolvedValue(JSON.stringify({ ...claim, [field]: '1.2.3' }));
|
||||
await expect(
|
||||
tryClaimWarmIosSessionAsync({ engine, packageVersion: '1.2.3', env, logger })
|
||||
).resolves.toBeDefined();
|
||||
expect(stopWarmSimulatorAsync).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
tryClaimWarmIosSessionAsync({ engine, packageVersion: '1.2.4', env, logger })
|
||||
).resolves.toBeUndefined();
|
||||
expect(stopWarmSimulatorAsync).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['serve-sim', 'agent-device', 'argent'] as const)(
|
||||
'uses normal setup for %s when older claims omit its version',
|
||||
async engine => {
|
||||
await expect(
|
||||
tryClaimWarmIosSessionAsync({ engine, packageVersion: '1.2.3', env, logger })
|
||||
).resolves.toBeUndefined();
|
||||
expect(stopWarmSimulatorAsync).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('accepts a resolved prerelease pin for matching warm adoption', async () => {
|
||||
jest
|
||||
.mocked(runSimulatorLauncherAsync)
|
||||
.mockResolvedValue(JSON.stringify({ ...claim, serveSimVersion: '0.13.4-beta.1+build.2' }));
|
||||
await expect(
|
||||
tryClaimWarmIosSessionAsync({
|
||||
engine: 'serve-sim',
|
||||
packageVersion: '0.13.4-beta.1+build.2',
|
||||
env,
|
||||
logger,
|
||||
})
|
||||
).resolves.toMatchObject({ serveSimVersion: '0.13.4-beta.1+build.2' });
|
||||
expect(stopWarmSimulatorAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves exact build metadata through job resolution and warm claim', async () => {
|
||||
const version = '0.13.4-beta.1+build.2';
|
||||
jest
|
||||
.mocked(runSimulatorLauncherAsync)
|
||||
.mockResolvedValueOnce(JSON.stringify({ ...claim, serveSimVersion: version }));
|
||||
const run = jest.fn(async (session, resolvedVersion) => {
|
||||
expect(session?.serveSimVersion).toBe(version);
|
||||
expect(resolvedVersion).toBe(version);
|
||||
return 'warm';
|
||||
});
|
||||
await expect(
|
||||
runOnWarmIosSimulatorAsync(
|
||||
{ engine: 'serve-sim', packageVersion: `v${version}`, env, logger },
|
||||
run
|
||||
)
|
||||
).resolves.toBe('warm');
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(stopWarmSimulatorAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { FetchError } from 'node-fetch';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import {
|
||||
WorkerApi,
|
||||
WorkerApiError,
|
||||
type WorkerApiRequestOptions,
|
||||
getExpoApiV2BaseUrl,
|
||||
} from '../workerApi';
|
||||
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.unmock('node-fetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
type Response = InstanceType<typeof Response>;
|
||||
|
||||
const BASE_URL = 'https://api.expo.test/v2/test-worker/';
|
||||
const AUTH = { authHeader: 'expo-test-worker-token', token: 'test-worker-token-value' };
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const ThingSchema = z.object({ id: z.string() });
|
||||
|
||||
async function getThingAsync(options?: WorkerApiRequestOptions): Promise<{ id: string }> {
|
||||
return await new WorkerApi(BASE_URL, AUTH).requestAsync('GET', 'thing', ThingSchema, options);
|
||||
}
|
||||
|
||||
async function createThingAsync(
|
||||
json: Record<string, unknown>,
|
||||
options: WorkerApiRequestOptions = {}
|
||||
): Promise<{ id: string }> {
|
||||
return await new WorkerApi(BASE_URL, AUTH).requestAsync('POST', 'things', ThingSchema, {
|
||||
...options,
|
||||
json,
|
||||
});
|
||||
}
|
||||
|
||||
describe(getExpoApiV2BaseUrl, () => {
|
||||
it('uses the API v2 base URL of the context', () => {
|
||||
expect(
|
||||
getExpoApiV2BaseUrl({
|
||||
env: { __API_SERVER_URL: 'https://other.expo.test' },
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
})
|
||||
).toBe('https://api.expo.test/v2/');
|
||||
});
|
||||
|
||||
it('derives the API v2 base URL from __API_SERVER_URL when the context has none', () => {
|
||||
expect(getExpoApiV2BaseUrl({ env: { __API_SERVER_URL: 'https://api.expo.test' } })).toBe(
|
||||
'https://api.expo.test/v2/'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a system error when no API URL is known', () => {
|
||||
expect(() => getExpoApiV2BaseUrl({ env: {} })).toThrow(SystemError);
|
||||
});
|
||||
});
|
||||
|
||||
describe(WorkerApi, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
it('resolves routes under the worker router and sends only the worker auth header', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { id: 'thing-id' } }));
|
||||
|
||||
await expect(createThingAsync({ name: 'a' })).resolves.toEqual({ id: 'thing-id' });
|
||||
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledTimes(1);
|
||||
const [url, method, options] = jest.mocked(turtleFetch).mock.calls[0];
|
||||
expect(url).toBe('https://api.expo.test/v2/test-worker/things');
|
||||
expect(method).toBe('POST');
|
||||
expect(options.headers).toEqual({ 'expo-test-worker-token': 'test-worker-token-value' });
|
||||
expect(options.json).toEqual({ name: 'a' });
|
||||
expect(options).toEqual(expect.objectContaining({ retries: 0, shouldThrowOnNotOk: false }));
|
||||
});
|
||||
|
||||
it('passes the signal and timeout without enabling nested retries', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { id: 'thing-id' } }));
|
||||
const controller = new AbortController();
|
||||
|
||||
await getThingAsync({ signal: controller.signal, timeoutMs: 5000, retries: 1 });
|
||||
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'GET',
|
||||
expect.objectContaining({ signal: controller.signal, timeout: 5000, retries: 0 })
|
||||
);
|
||||
});
|
||||
|
||||
it('reports the API v2 error message, code and status without the token', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
code: 'UNAUTHORIZED_ERROR',
|
||||
message: 'This worker no longer accepts requests.',
|
||||
},
|
||||
],
|
||||
},
|
||||
403
|
||||
)
|
||||
);
|
||||
|
||||
const error = await createThingAsync({}).catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(WorkerApiError);
|
||||
expect(error.status).toBe(403);
|
||||
expect(error.code).toBe('UNAUTHORIZED_ERROR');
|
||||
expect(error.message).toBe(
|
||||
'POST /v2/test-worker/things failed with status 403: This worker no longer accepts requests.'
|
||||
);
|
||||
expect(error.message).not.toContain(AUTH.token);
|
||||
});
|
||||
|
||||
it('reports the status text when the error response is not an API v2 error', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(
|
||||
new Response('<html>bad gateway</html>', { status: 502, statusText: 'Bad Gateway' })
|
||||
);
|
||||
|
||||
const error = await getThingAsync().catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(WorkerApiError);
|
||||
expect(error.status).toBe(502);
|
||||
expect(error.message).toBe('GET /v2/test-worker/thing failed with status 502: Bad Gateway');
|
||||
});
|
||||
|
||||
it('reports a request that failed before www responded', async () => {
|
||||
const reason = new Error('socket hang up');
|
||||
jest.mocked(turtleFetch).mockRejectedValueOnce(reason);
|
||||
|
||||
const error = await getThingAsync().catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(WorkerApiError);
|
||||
expect(error.status).toBeUndefined();
|
||||
expect(error.isTransient).toBe(true);
|
||||
expect(error.cause).toBe(reason);
|
||||
expect(error.message).toContain('failed before www responded: socket hang up');
|
||||
});
|
||||
|
||||
it('rejects a response that does not match the route contract', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { id: 1 } }));
|
||||
|
||||
await expect(getThingAsync({ retries: 3 })).rejects.toThrow(
|
||||
'GET /v2/test-worker/thing returned an unexpected response'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a successful response that is not JSON', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('ok', { status: 200 }));
|
||||
|
||||
await expect(getThingAsync({ retries: 3 })).rejects.toThrow(
|
||||
'GET /v2/test-worker/thing returned a response that is not JSON.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('worker API retries', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it.each([429, 500, 502, 503, 504])('recovers from HTTP %s', async status => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(new Response('unavailable', { status }))
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'thing-id' } }));
|
||||
const result = createThingAsync({}, { retries: 3, timeoutMs: 30_000 });
|
||||
const assertion = expect(result).resolves.toEqual({ id: 'thing-id' });
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(2);
|
||||
expect(jest.mocked(turtleFetch).mock.calls[1]).toEqual(jest.mocked(turtleFetch).mock.calls[0]);
|
||||
expect(jest.mocked(turtleFetch).mock.calls[0][2]).toMatchObject({
|
||||
retries: 0,
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('recovers from a network failure', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockRejectedValueOnce(new Error('socket hang up'))
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'thing-id' } }));
|
||||
const assertion = expect(getThingAsync({ retries: 3 })).resolves.toEqual({
|
||||
id: 'thing-id',
|
||||
});
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('exhausts three retries with exponential backoff', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockImplementation(async () => new Response('unavailable', { status: 503 }));
|
||||
const assertion = expect(createThingAsync({}, { retries: 3 })).rejects.toMatchObject({
|
||||
status: 503,
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(2);
|
||||
await jest.advanceTimersByTimeAsync(2000);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(3);
|
||||
await jest.advanceTimersByTimeAsync(4000);
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it.each([400, 401, 403, 404, 409])('does not retry HTTP %s', async status => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('refused', { status }));
|
||||
await expect(createThingAsync({}, { retries: 3 })).rejects.toMatchObject({
|
||||
status,
|
||||
});
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not retry without an explicit retry count', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('unavailable', { status: 503 }));
|
||||
await expect(createThingAsync({})).rejects.toMatchObject({ status: 503 });
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('honors an explicit retry count', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockImplementation(async () => new Response('unavailable', { status: 503 }));
|
||||
const assertion = expect(getThingAsync({ retries: 1 })).rejects.toMatchObject({
|
||||
status: 503,
|
||||
});
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not send another request after cancellation during backoff', async () => {
|
||||
const controller = new AbortController();
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('unavailable', { status: 503 }));
|
||||
const assertion = expect(
|
||||
getThingAsync({ signal: controller.signal, retries: 3 })
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
controller.abort();
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not send an already cancelled request', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const assertion = expect(
|
||||
getThingAsync({ signal: controller.signal, retries: 3 })
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['system', 'body-timeout'])(
|
||||
'retries a response body transport failure (%s)',
|
||||
async type => {
|
||||
const response = jsonResponse({});
|
||||
jest
|
||||
.spyOn(response, 'json')
|
||||
.mockRejectedValueOnce(new FetchError('connection interrupted', type));
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(response)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'thing-id' } }));
|
||||
const assertion = expect(createThingAsync({}, { retries: 3 })).resolves.toEqual({
|
||||
id: 'thing-id',
|
||||
});
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(2);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -4,11 +4,13 @@ import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
AndroidEmulatorLogCapture,
|
||||
type AndroidEmulatorLogCapture,
|
||||
AndroidEmulatorLogRing,
|
||||
type FinalizedAndroidEmulatorLog,
|
||||
MAX_ANDROID_LOG_BYTES_PER_SOURCE,
|
||||
} from '../../utils/androidEmulatorLogCapture';
|
||||
import { AndroidEmulatorLogFiles } from '../../utils/androidEmulatorLogFiles';
|
||||
import { type AndroidEmulatorLogSource } from '../../utils/androidEmulatorLogSources';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { uploadDeviceRunSessionFileAsync } from './uploadDeviceRunSessionFile';
|
||||
|
||||
const UPLOAD_TIMEOUT_MS = 30_000;
|
||||
@@ -25,16 +27,19 @@ type Collection = {
|
||||
// A worker can execute more than one job; each workflow owns its capture and uploads.
|
||||
const collections = new WeakMap<BuildStepGlobalContext, Collection>();
|
||||
|
||||
/** The first launch of a workflow fixes its source selection; later calls reuse the capture. */
|
||||
export function getAndroidEmulatorLogCapture(
|
||||
global: BuildStepGlobalContext,
|
||||
env: NodeJS.ProcessEnv
|
||||
env: NodeJS.ProcessEnv,
|
||||
sources: readonly AndroidEmulatorLogSource[]
|
||||
): AndroidEmulatorLogCapture {
|
||||
let collection = collections.get(global);
|
||||
if (!collection) {
|
||||
collection = {
|
||||
capture: new AndroidEmulatorLogCapture(
|
||||
env.DEVICE_RUN_SESSION_ID ? MAX_ANDROID_LOG_BYTES_PER_SOURCE : Infinity
|
||||
),
|
||||
// Sessions upload a bounded ring per source; builds keep plain launch files.
|
||||
capture: env.DEVICE_RUN_SESSION_ID
|
||||
? new AndroidEmulatorLogRing({ sources })
|
||||
: new AndroidEmulatorLogFiles({ sources }),
|
||||
};
|
||||
collections.set(global, collection);
|
||||
}
|
||||
@@ -113,9 +118,8 @@ async function uploadLogAsync(
|
||||
return await uploadDeviceRunSessionFileAsync(ctx, {
|
||||
filePath,
|
||||
describe: size => {
|
||||
const retainedSize =
|
||||
size >= 1024 * 1024 ? `${Number((size / (1024 * 1024)).toFixed(2))} MiB` : `${size} bytes`;
|
||||
const truncationNote = size < bytesSeen ? `; truncated, last ${retainedSize} retained` : '';
|
||||
const truncationNote =
|
||||
size < bytesSeen ? `; truncated, last ${formatBytes(size)} retained` : '';
|
||||
return {
|
||||
deviceRunSessionId,
|
||||
artifactId: filename,
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { type EnvironmentSecret, EnvironmentSecretType, SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { FetchError } from 'node-fetch';
|
||||
import { type EnvironmentSecret, SystemError } from '@expo/eas-build-job';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { promiseRetryWithCondition } from '../../utils/promiseRetryWithCondition';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { getStringEnvironmentSecretValue } from '../../utils/environmentSecrets';
|
||||
import { WorkerApi, type WorkerApiRequestOptions, getExpoApiV2BaseUrl } from './workerApi';
|
||||
|
||||
/**
|
||||
* The environment secret that holds the device run session token. www gives it only to the job of
|
||||
@@ -20,8 +17,6 @@ export const DEVICE_RUN_SESSION_TOKEN_ENV = 'EAS_DEVICE_RUN_SESSION_TOKEN';
|
||||
*/
|
||||
export const DEVICE_RUN_SESSION_TOKEN_HEADER = 'expo-device-run-session-token';
|
||||
|
||||
const DEVICE_RUN_SESSION_WORKER_PATH = 'device-run-session-worker/';
|
||||
|
||||
const DeviceRunSessionStatusSchema = z.object({
|
||||
id: z.string(),
|
||||
// The status entity values that www returns.
|
||||
@@ -55,10 +50,6 @@ const DeviceRunSessionApplicationSchema = z.object({
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
const ApiV2ErrorResponseSchema = z.object({
|
||||
errors: z.array(z.object({ code: z.string().optional(), message: z.string() })).min(1),
|
||||
});
|
||||
|
||||
export type DeviceRunSessionStatus = z.infer<typeof DeviceRunSessionStatusSchema>;
|
||||
export type DeviceRunSessionUploadSession = z.infer<typeof UploadSessionSchema>['uploadSession'];
|
||||
export type DeviceRunSessionTurnIceServers = z.infer<typeof TurnIceServersSchema>['iceServers'];
|
||||
@@ -72,70 +63,48 @@ export type CreateDeviceRunSessionArtifactUploadSessionInput = {
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type RequestOptions = {
|
||||
/** Stops the request when aborted. */
|
||||
signal?: AbortSignal;
|
||||
/** node-fetch timeout of each attempt, in milliseconds. */
|
||||
timeoutMs?: number;
|
||||
/** Additional attempts for transient failures. Defaults to none; critical callers opt in. */
|
||||
retries?: number;
|
||||
logger?: bunyan;
|
||||
};
|
||||
|
||||
/**
|
||||
* A request to the device run session worker API failed. The message names the route and the
|
||||
* reason. It never includes the device run session token.
|
||||
*/
|
||||
export class DeviceRunSessionWorkerApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
/** The HTTP status of the response, or undefined when no response arrived. */
|
||||
readonly status: number | undefined,
|
||||
options?: ErrorOptions,
|
||||
readonly code?: string
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = 'DeviceRunSessionWorkerApiError';
|
||||
}
|
||||
|
||||
get isTransient(): boolean {
|
||||
return this.status === undefined || this.status >= 500;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client for the www routes under `/v2/device-run-session-worker/`. These routes act only on the
|
||||
* session that the token names, so no request carries a session ID.
|
||||
* session that the token names.
|
||||
*/
|
||||
export class DeviceRunSessionWorkerApi {
|
||||
constructor(
|
||||
private readonly expoApiV2BaseUrl: string,
|
||||
private readonly deviceRunSessionToken: string
|
||||
) {}
|
||||
private readonly client: WorkerApi;
|
||||
|
||||
public async getSessionAsync(options: RequestOptions = {}): Promise<DeviceRunSessionStatus> {
|
||||
return await this.requestAsync('GET', 'session', DeviceRunSessionStatusSchema, options);
|
||||
constructor(expoApiV2BaseUrl: string, token: string) {
|
||||
this.client = new WorkerApi(
|
||||
new URL('device-run-session-worker/', expoApiV2BaseUrl).toString(),
|
||||
{
|
||||
authHeader: DEVICE_RUN_SESSION_TOKEN_HEADER,
|
||||
token,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async getSessionAsync(
|
||||
options: WorkerApiRequestOptions = {}
|
||||
): Promise<DeviceRunSessionStatus> {
|
||||
return await this.client.requestAsync('GET', 'session', DeviceRunSessionStatusSchema, options);
|
||||
}
|
||||
|
||||
public async startAsync(
|
||||
{ remoteConfig }: { remoteConfig: Record<string, unknown> },
|
||||
options: RequestOptions = {}
|
||||
options: WorkerApiRequestOptions = {}
|
||||
): Promise<DeviceRunSessionStatus> {
|
||||
return await this.requestAsync('POST', 'start', DeviceRunSessionStatusSchema, {
|
||||
return await this.client.requestAsync('POST', 'start', DeviceRunSessionStatusSchema, {
|
||||
...options,
|
||||
json: { remoteConfig },
|
||||
});
|
||||
}
|
||||
|
||||
public async stopAsync(options: RequestOptions = {}): Promise<DeviceRunSessionStatus> {
|
||||
return await this.requestAsync('POST', 'stop', DeviceRunSessionStatusSchema, options);
|
||||
public async stopAsync(options: WorkerApiRequestOptions = {}): Promise<DeviceRunSessionStatus> {
|
||||
return await this.client.requestAsync('POST', 'stop', DeviceRunSessionStatusSchema, options);
|
||||
}
|
||||
|
||||
public async createArtifactUploadSessionAsync(
|
||||
input: CreateDeviceRunSessionArtifactUploadSessionInput,
|
||||
options: RequestOptions = {}
|
||||
options: WorkerApiRequestOptions = {}
|
||||
): Promise<DeviceRunSessionUploadSession> {
|
||||
const { uploadSession } = await this.requestAsync(
|
||||
const { uploadSession } = await this.client.requestAsync(
|
||||
'POST',
|
||||
'artifact-upload-sessions',
|
||||
UploadSessionSchema,
|
||||
@@ -145,9 +114,9 @@ export class DeviceRunSessionWorkerApi {
|
||||
}
|
||||
|
||||
public async createEventLogUploadSessionAsync(
|
||||
options: RequestOptions = {}
|
||||
options: WorkerApiRequestOptions = {}
|
||||
): Promise<DeviceRunSessionUploadSession> {
|
||||
const { uploadSession } = await this.requestAsync(
|
||||
const { uploadSession } = await this.client.requestAsync(
|
||||
'POST',
|
||||
'event-log-upload-session',
|
||||
UploadSessionSchema,
|
||||
@@ -157,9 +126,9 @@ export class DeviceRunSessionWorkerApi {
|
||||
}
|
||||
|
||||
public async createPreviewUploadSessionAsync(
|
||||
options: RequestOptions = {}
|
||||
options: WorkerApiRequestOptions = {}
|
||||
): Promise<DeviceRunSessionUploadSession> {
|
||||
const { uploadSession } = await this.requestAsync(
|
||||
const { uploadSession } = await this.client.requestAsync(
|
||||
'POST',
|
||||
'preview-upload-session',
|
||||
UploadSessionSchema,
|
||||
@@ -169,9 +138,9 @@ export class DeviceRunSessionWorkerApi {
|
||||
}
|
||||
|
||||
public async fetchTurnIceServersAsync(
|
||||
options: RequestOptions = {}
|
||||
options: WorkerApiRequestOptions = {}
|
||||
): Promise<DeviceRunSessionTurnIceServers> {
|
||||
const { iceServers } = await this.requestAsync(
|
||||
const { iceServers } = await this.client.requestAsync(
|
||||
'POST',
|
||||
'turn-ice-servers',
|
||||
TurnIceServersSchema,
|
||||
@@ -181,160 +150,26 @@ export class DeviceRunSessionWorkerApi {
|
||||
}
|
||||
|
||||
public async getApplicationAsync(
|
||||
options: RequestOptions = {}
|
||||
options: WorkerApiRequestOptions = {}
|
||||
): Promise<DeviceRunSessionApplication> {
|
||||
return await this.requestAsync(
|
||||
return await this.client.requestAsync(
|
||||
'GET',
|
||||
'application',
|
||||
DeviceRunSessionApplicationSchema,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
private async requestAsync<T>(
|
||||
method: 'GET' | 'POST',
|
||||
route: string,
|
||||
dataSchema: z.ZodType<T>,
|
||||
{ retries = 0, ...options }: RequestOptions & { json?: Record<string, unknown> }
|
||||
): Promise<T> {
|
||||
const request = () => {
|
||||
options.signal?.throwIfAborted();
|
||||
return this.requestOnceAsync(method, route, dataSchema, options);
|
||||
};
|
||||
if (retries === 0) {
|
||||
return await request();
|
||||
}
|
||||
return await promiseRetryWithCondition(
|
||||
request,
|
||||
error =>
|
||||
!options.signal?.aborted &&
|
||||
error instanceof DeviceRunSessionWorkerApiError &&
|
||||
(error.status === undefined || error.status === 429 || error.status >= 500),
|
||||
{ retries, factor: 2, minTimeout: 1000 },
|
||||
({ attemptNumber, maxAttemptsCount }) =>
|
||||
options.logger?.warn(
|
||||
`Retrying ${method} /v2/${DEVICE_RUN_SESSION_WORKER_PATH}${route} (attempt ${attemptNumber}/${maxAttemptsCount}).`
|
||||
)
|
||||
)();
|
||||
}
|
||||
|
||||
private async requestOnceAsync<T>(
|
||||
method: 'GET' | 'POST',
|
||||
route: string,
|
||||
dataSchema: z.ZodType<T>,
|
||||
{
|
||||
signal,
|
||||
timeoutMs,
|
||||
logger,
|
||||
json,
|
||||
}: Omit<RequestOptions, 'retries'> & {
|
||||
json?: Record<string, unknown>;
|
||||
}
|
||||
): Promise<T> {
|
||||
const routeDescription = `${method} /v2/${DEVICE_RUN_SESSION_WORKER_PATH}${route}`;
|
||||
const url = new URL(
|
||||
`${DEVICE_RUN_SESSION_WORKER_PATH}${route}`,
|
||||
this.expoApiV2BaseUrl
|
||||
).toString();
|
||||
|
||||
const responseResult = await asyncResult(
|
||||
turtleFetch(url, method, {
|
||||
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: this.deviceRunSessionToken },
|
||||
...(json ? { json } : {}),
|
||||
...(signal ? { signal } : {}),
|
||||
...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}),
|
||||
retries: 0,
|
||||
shouldThrowOnNotOk: false,
|
||||
logger,
|
||||
})
|
||||
);
|
||||
if (!responseResult.ok) {
|
||||
const reason = responseResult.reason;
|
||||
throw new DeviceRunSessionWorkerApiError(
|
||||
`${routeDescription} failed before www responded: ${
|
||||
reason instanceof Error ? reason.message : String(reason)
|
||||
}. Check the network connection of the worker.`,
|
||||
undefined,
|
||||
{ cause: reason }
|
||||
);
|
||||
}
|
||||
const response = responseResult.value;
|
||||
|
||||
const bodyResult = await asyncResult(response.json());
|
||||
if (!response.ok) {
|
||||
const apiError = bodyResult.ok ? ApiV2ErrorResponseSchema.safeParse(bodyResult.value) : null;
|
||||
const apiErrorMessage = apiError?.success
|
||||
? apiError.data.errors.map(error => error.message).join(' ')
|
||||
: response.statusText;
|
||||
throw new DeviceRunSessionWorkerApiError(
|
||||
`${routeDescription} failed with status ${response.status}: ${apiErrorMessage}`,
|
||||
response.status,
|
||||
undefined,
|
||||
apiError?.success ? apiError.data.errors[0]?.code : undefined
|
||||
);
|
||||
}
|
||||
if (!bodyResult.ok) {
|
||||
if (
|
||||
bodyResult.reason instanceof FetchError &&
|
||||
['system', 'body-timeout'].includes(bodyResult.reason.type)
|
||||
) {
|
||||
throw new DeviceRunSessionWorkerApiError(
|
||||
`${routeDescription} failed while reading the response body.`,
|
||||
undefined
|
||||
);
|
||||
}
|
||||
throw new DeviceRunSessionWorkerApiError(
|
||||
`${routeDescription} returned a response that is not JSON.`,
|
||||
response.status
|
||||
);
|
||||
}
|
||||
const parsed = z.object({ data: dataSchema }).safeParse(bodyResult.value);
|
||||
if (!parsed.success) {
|
||||
throw new DeviceRunSessionWorkerApiError(
|
||||
`${routeDescription} returned an unexpected response: ${parsed.error.message}`,
|
||||
response.status
|
||||
);
|
||||
}
|
||||
return parsed.data.data;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the device run session token when www gave one to this job, or null. Without a token,
|
||||
* callers keep using the robot access token and the account-wide API.
|
||||
*/
|
||||
export function getDeviceRunSessionToken(
|
||||
env: Record<string, string | undefined> | undefined
|
||||
): string | null {
|
||||
const token = env?.[DEVICE_RUN_SESSION_TOKEN_ENV];
|
||||
return token ? token : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the device run session token from the job's environment secrets, or null. It resolves
|
||||
* duplicate names like the build context env does (the last secret wins), so the worker and the
|
||||
* steps use the same token. A file secret is not a token.
|
||||
*/
|
||||
export function getDeviceRunSessionTokenFromEnvironmentSecrets(
|
||||
environmentSecrets: EnvironmentSecret[] | undefined
|
||||
): string | null {
|
||||
const secret = [...(environmentSecrets ?? [])]
|
||||
.reverse()
|
||||
.find(({ name }) => name === DEVICE_RUN_SESSION_TOKEN_ENV);
|
||||
if (!secret || secret.type !== EnvironmentSecretType.STRING || !secret.value) {
|
||||
return null;
|
||||
}
|
||||
return secret.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a client for the device run session worker API when this job has a device run session
|
||||
* token, or null when it does not.
|
||||
* token, or null when it does not. Without a token, callers keep using the robot access token and
|
||||
* the account-wide API.
|
||||
*/
|
||||
export function getDeviceRunSessionWorkerApi(
|
||||
ctx: Pick<CustomBuildContext, 'env' | 'expoApiV2BaseUrl'>
|
||||
): DeviceRunSessionWorkerApi | null {
|
||||
const token = getDeviceRunSessionToken(ctx.env);
|
||||
const token = ctx.env?.[DEVICE_RUN_SESSION_TOKEN_ENV];
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
@@ -359,16 +194,13 @@ export function requireDeviceRunSessionWorkerApi(
|
||||
return workerApi;
|
||||
}
|
||||
|
||||
function getExpoApiV2BaseUrl(ctx: Pick<CustomBuildContext, 'env' | 'expoApiV2BaseUrl'>): string {
|
||||
if (ctx.expoApiV2BaseUrl) {
|
||||
return ctx.expoApiV2BaseUrl;
|
||||
}
|
||||
if (ctx.env.__API_SERVER_URL) {
|
||||
return new URL('/v2/', ctx.env.__API_SERVER_URL).toString();
|
||||
}
|
||||
throw new SystemError(
|
||||
`${DEVICE_RUN_SESSION_TOKEN_ENV} is set, but the API server URL is not. ` +
|
||||
'The worker cannot call the device run session API without it. ' +
|
||||
'Check that the worker sets __API_SERVER_URL for device run session jobs.'
|
||||
);
|
||||
/**
|
||||
* Returns the device run session token from the job's environment secrets, or null. It resolves
|
||||
* duplicate names like the build context env does (the last secret wins), so the worker and the
|
||||
* steps use the same token. A file secret is not a token.
|
||||
*/
|
||||
export function getDeviceRunSessionTokenFromEnvironmentSecrets(
|
||||
environmentSecrets: EnvironmentSecret[] | undefined
|
||||
): string | null {
|
||||
return getStringEnvironmentSecretValue(environmentSecrets, DEVICE_RUN_SESSION_TOKEN_ENV);
|
||||
}
|
||||
@@ -432,6 +432,8 @@ export type DeviceSessionServerLaunch = {
|
||||
*/
|
||||
export type DeviceSessionServer = {
|
||||
port: number;
|
||||
/** The exact serve-sim version prepared by the warm launcher. */
|
||||
serveSimVersion?: string;
|
||||
turnArgs: Promise<string[]>;
|
||||
/** Cold iOS setup runs after control readiness, alongside app installation and before launch. */
|
||||
setupSimulatorAsync?(options: { signal: AbortSignal; logger: bunyan }): Promise<void>;
|
||||
@@ -584,6 +586,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
? await createAndroidDeviceSessionServerAsync(ctx, { logger })
|
||||
: await createColdIosSessionAsync(ctx, { env, logger, iosSimulatorUdid }));
|
||||
const port = server.port;
|
||||
packageVersion = server.serveSimVersion ?? packageVersion;
|
||||
const serverName = isAndroid ? 'expo-device-hub' : 'serve-sim';
|
||||
const packageSpec = isAndroid
|
||||
? createExpoDeviceHubPackageSpec(packageVersion)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import {
|
||||
type AndroidEmulatorLogSource,
|
||||
AndroidEmulatorLogSourcesSchema,
|
||||
} from '../../utils/androidEmulatorLogSources';
|
||||
|
||||
/**
|
||||
* `log_sources` is unprefixed like every other step input, so the platform
|
||||
* rule is checked here at runtime, the way `network_capture` is.
|
||||
*/
|
||||
export function parseLogSourcesInput(
|
||||
value: unknown,
|
||||
{
|
||||
runtimePlatform,
|
||||
fallback,
|
||||
}: { runtimePlatform: BuildRuntimePlatform; fallback: readonly AndroidEmulatorLogSource[] }
|
||||
): readonly AndroidEmulatorLogSource[] {
|
||||
if (value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
const sources = AndroidEmulatorLogSourcesSchema.parse(value);
|
||||
if (sources.length > 0 && runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
throw new UserError(
|
||||
'EAS_LOG_SOURCES_INVALID_INPUT',
|
||||
`Input "log_sources" selects Android emulator logs, and this session runs on iOS. Drop "log_sources", or run the session on Android.`
|
||||
);
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { killProcessGroup } from '../../utils/processes';
|
||||
export async function preparePreinstalledWdaAsync({
|
||||
runtimeIdentifier,
|
||||
appiumHome,
|
||||
prepared = false,
|
||||
appiumBinPath,
|
||||
appiumEnv,
|
||||
logger,
|
||||
@@ -16,6 +17,8 @@ export async function preparePreinstalledWdaAsync({
|
||||
}: {
|
||||
runtimeIdentifier: string | undefined;
|
||||
appiumHome: string;
|
||||
/** A prepared Appium home that already holds the runner. */
|
||||
prepared?: boolean;
|
||||
appiumBinPath: string;
|
||||
appiumEnv: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
@@ -32,6 +35,13 @@ export async function preparePreinstalledWdaAsync({
|
||||
|
||||
const outdir = path.join(appiumHome, 'preinstalled-wda');
|
||||
const runner = path.join(outdir, 'WebDriverAgentRunner-Runner.app');
|
||||
if (prepared) {
|
||||
logger.info('Using preinstalled WebDriverAgent to avoid xcodebuild during session creation.');
|
||||
return {
|
||||
'appium:usePreinstalledWDA': true,
|
||||
'appium:prebuiltWDAPath': runner,
|
||||
};
|
||||
}
|
||||
try {
|
||||
// The driver selects the release matching its installed WDA and the host architecture.
|
||||
// Keep the Simulator runner intact; removing its XCTest frameworks breaks launch.
|
||||
|
||||
@@ -29,9 +29,9 @@ import { createProcessOutput } from './processOutput';
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
import {
|
||||
type DeviceRunSessionStatus,
|
||||
DeviceRunSessionWorkerApiError,
|
||||
requireDeviceRunSessionWorkerApi,
|
||||
} from './deviceRunSessionWorkerApi';
|
||||
import { WorkerApiError } from './workerApi';
|
||||
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
|
||||
@@ -525,10 +525,7 @@ export async function fetchWebPreviewTurnArgsAsync(
|
||||
}
|
||||
return args;
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof DeviceRunSessionWorkerApiError &&
|
||||
err.code === 'DEVICE_RUN_SESSION_ALREADY_STOPPED'
|
||||
) {
|
||||
if (err instanceof WorkerApiError && err.code === 'DEVICE_RUN_SESSION_ALREADY_STOPPED') {
|
||||
logger.info('Device run session was stopped before TURN credentials were fetched.');
|
||||
return [];
|
||||
}
|
||||
@@ -580,10 +577,7 @@ export async function uploadRemoteSessionConfigAsync({
|
||||
return 'started';
|
||||
} catch (err) {
|
||||
signal?.throwIfAborted();
|
||||
if (
|
||||
err instanceof DeviceRunSessionWorkerApiError &&
|
||||
err.code === 'DEVICE_RUN_SESSION_ALREADY_STOPPED'
|
||||
) {
|
||||
if (err instanceof WorkerApiError && err.code === 'DEVICE_RUN_SESSION_ALREADY_STOPPED') {
|
||||
logger.info(`Device run session ${deviceRunSessionId} was stopped during startup.`);
|
||||
return 'stopped';
|
||||
}
|
||||
|
||||
@@ -2,52 +2,20 @@ import {
|
||||
type SandboxDaemonCommandResult,
|
||||
SandboxDaemonError,
|
||||
SandboxDaemonErrorCode,
|
||||
SystemError,
|
||||
} from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type Client, CombinedError } from '@urql/core';
|
||||
import { graphql } from 'gql.tada';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { Readable } from 'node:stream';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
import { SignedUrlUploadApiError, uploadWithSignedUrl } from '../../storage/uploadWithSignedUrl';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import { type SandboxArtifactUploadSession, type SandboxWorkerApi } from './sandboxWorkerApi';
|
||||
import { WorkerApiError } from './workerApi';
|
||||
|
||||
const CREATE_SANDBOX_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
|
||||
mutation CreateSandboxArtifactUploadSession(
|
||||
$sandboxId: ID!
|
||||
$input: CreateSandboxArtifactUploadSessionInput!
|
||||
) {
|
||||
sandbox {
|
||||
createArtifactUploadSession(sandboxId: $sandboxId, input: $input) {
|
||||
artifact {
|
||||
id
|
||||
}
|
||||
uploadSession {
|
||||
url
|
||||
headers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const FINALIZE_SANDBOX_ARTIFACT_MUTATION = graphql(`
|
||||
mutation FinalizeSandboxArtifact($artifactId: ID!) {
|
||||
sandbox {
|
||||
finalizeArtifact(artifactId: $artifactId) {
|
||||
id
|
||||
downloadUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const FINALIZE_ATTEMPTS = 3;
|
||||
const WORKER_API_REQUEST_TIMEOUT_MS = 15_000;
|
||||
const FINALIZE_RETRIES = 2;
|
||||
const REJECTED_UPLOAD_SESSION_ERROR_CODES = new Set(['VALIDATION_ERROR', 'UNAUTHORIZED']);
|
||||
|
||||
type SandboxArtifactUploadOutcome =
|
||||
@@ -60,8 +28,7 @@ export class SandboxArtifactUploadManager {
|
||||
|
||||
public constructor(
|
||||
private readonly options: {
|
||||
graphqlClient: Client;
|
||||
sandboxId: string;
|
||||
workerApi: SandboxWorkerApi;
|
||||
logger: bunyan;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
@@ -114,15 +81,13 @@ export class SandboxArtifactUploadManager {
|
||||
}
|
||||
|
||||
export async function startSandboxArtifactUploadAsync({
|
||||
graphqlClient,
|
||||
sandboxId,
|
||||
workerApi,
|
||||
logger,
|
||||
filePath,
|
||||
name,
|
||||
signal,
|
||||
}: {
|
||||
graphqlClient: Client;
|
||||
sandboxId: string;
|
||||
workerApi: SandboxWorkerApi;
|
||||
logger: bunyan;
|
||||
filePath: string;
|
||||
name: string;
|
||||
@@ -157,9 +122,9 @@ export async function startSandboxArtifactUploadAsync({
|
||||
artifact: { id: artifactId },
|
||||
uploadSession,
|
||||
} = await createSandboxArtifactUploadSessionAsync(
|
||||
graphqlClient,
|
||||
{ sandboxId, name, filename: path.basename(filePath), size },
|
||||
AbortSignal.any([signal, AbortSignal.timeout(15_000)])
|
||||
workerApi,
|
||||
{ name, filename: path.basename(filePath), size },
|
||||
signal
|
||||
);
|
||||
const completed = (async (): Promise<SandboxArtifactUploadOutcome> => {
|
||||
logger.info(`Uploading sandbox artifact "${name}" (${artifactId}, ${size} bytes).`);
|
||||
@@ -171,7 +136,12 @@ export async function startSandboxArtifactUploadAsync({
|
||||
}
|
||||
let downloadUrl: string;
|
||||
try {
|
||||
downloadUrl = await finalizeSandboxArtifactAsync(graphqlClient, artifactId, signal);
|
||||
downloadUrl = await workerApi.finalizeArtifactAsync(artifactId, {
|
||||
signal,
|
||||
timeoutMs: WORKER_API_REQUEST_TIMEOUT_MS,
|
||||
retries: FINALIZE_RETRIES,
|
||||
logger,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ err: error },
|
||||
@@ -186,44 +156,28 @@ export async function startSandboxArtifactUploadAsync({
|
||||
}
|
||||
|
||||
async function createSandboxArtifactUploadSessionAsync(
|
||||
graphqlClient: Client,
|
||||
{
|
||||
sandboxId,
|
||||
name,
|
||||
filename,
|
||||
size,
|
||||
}: { sandboxId: string; name: string; filename: string; size: number },
|
||||
workerApi: SandboxWorkerApi,
|
||||
input: { name: string; filename: string; size: number },
|
||||
signal: AbortSignal
|
||||
) {
|
||||
const result = await graphqlClient
|
||||
.mutation(
|
||||
CREATE_SANDBOX_ARTIFACT_UPLOAD_SESSION_MUTATION,
|
||||
{ sandboxId, input: { name, filename, size } },
|
||||
graphqlAbortContext(signal)
|
||||
)
|
||||
.toPromise();
|
||||
|
||||
signal.throwIfAborted();
|
||||
|
||||
if (result.error) {
|
||||
const { graphQLErrors } = result.error;
|
||||
const message =
|
||||
graphQLErrors.length > 0
|
||||
? graphQLErrors.map(error => error.message).join('\n')
|
||||
: `Failed to create an upload session for sandbox artifact "${name}": ${result.error.message}`;
|
||||
): Promise<SandboxArtifactUploadSession> {
|
||||
try {
|
||||
return await workerApi.createArtifactUploadSessionAsync(input, {
|
||||
signal,
|
||||
timeoutMs: WORKER_API_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
} catch (error) {
|
||||
signal.throwIfAborted();
|
||||
if (
|
||||
graphQLErrors.length > 0 &&
|
||||
graphQLErrors.every(error =>
|
||||
REJECTED_UPLOAD_SESSION_ERROR_CODES.has(error.extensions?.errorCode as string)
|
||||
)
|
||||
error instanceof WorkerApiError &&
|
||||
error.code !== undefined &&
|
||||
REJECTED_UPLOAD_SESSION_ERROR_CODES.has(error.code)
|
||||
) {
|
||||
throw new SandboxDaemonError(SandboxDaemonErrorCode.BAD_REQUEST, message, {
|
||||
cause: result.error,
|
||||
throw new SandboxDaemonError(SandboxDaemonErrorCode.BAD_REQUEST, error.message, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw new SystemError(message, { cause: result.error });
|
||||
throw error;
|
||||
}
|
||||
return result.data!.sandbox.createArtifactUploadSession;
|
||||
}
|
||||
|
||||
async function putSandboxArtifactAsync({
|
||||
@@ -234,16 +188,13 @@ async function putSandboxArtifactAsync({
|
||||
}: {
|
||||
filePath: string;
|
||||
size: number;
|
||||
uploadSession: { url: string; headers: unknown };
|
||||
uploadSession: SandboxArtifactUploadSession['uploadSession'];
|
||||
signal: AbortSignal;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await uploadWithSignedUrl(
|
||||
{
|
||||
signedUrl: {
|
||||
url: uploadSession.url,
|
||||
headers: uploadSession.headers as Record<string, string>,
|
||||
},
|
||||
signedUrl: uploadSession,
|
||||
srcGeneratorAsync: async () =>
|
||||
size === 0 ? Readable.from([]) : createReadStream(filePath, { start: 0, end: size - 1 }),
|
||||
},
|
||||
@@ -255,45 +206,3 @@ async function putSandboxArtifactAsync({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function finalizeSandboxArtifactAsync(
|
||||
graphqlClient: Client,
|
||||
artifactId: string,
|
||||
signal: AbortSignal
|
||||
): Promise<string> {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const timeoutSignal = AbortSignal.timeout(15_000);
|
||||
const finalizeSignal = AbortSignal.any([signal, timeoutSignal]);
|
||||
try {
|
||||
const result = await graphqlClient
|
||||
.mutation(
|
||||
FINALIZE_SANDBOX_ARTIFACT_MUTATION,
|
||||
{ artifactId },
|
||||
graphqlAbortContext(finalizeSignal)
|
||||
)
|
||||
.toPromise();
|
||||
finalizeSignal.throwIfAborted();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
const { downloadUrl } = result.data!.sandbox.finalizeArtifact;
|
||||
if (downloadUrl === null) {
|
||||
throw new SystemError(`Finalized sandbox artifact ${artifactId} has no download URL.`);
|
||||
}
|
||||
return downloadUrl;
|
||||
} catch (error) {
|
||||
signal.throwIfAborted();
|
||||
const retryable =
|
||||
timeoutSignal.aborted || (error instanceof CombinedError && isTransientGraphqlError(error));
|
||||
if (!retryable || attempt >= FINALIZE_ATTEMPTS) {
|
||||
throw error;
|
||||
}
|
||||
await delay(500 * attempt, undefined, { signal });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isTransientGraphqlError(error: CombinedError): boolean {
|
||||
const status: number | undefined = error.response?.status;
|
||||
return status === undefined ? error.networkError !== undefined : status >= 500;
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
type SandboxDaemonMethod,
|
||||
} from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type Client } from '@urql/core';
|
||||
import fs, { type FileHandle } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
@@ -14,6 +13,7 @@ import { performance } from 'node:perf_hooks';
|
||||
import { readBytesAsync, readTextAsync } from '../../utils/files';
|
||||
import { detectFileType } from '../../utils/files/filetypes';
|
||||
import { SandboxArtifactUploadManager } from './sandboxArtifacts';
|
||||
import { type SandboxWorkerApi } from './sandboxWorkerApi';
|
||||
import { ShellSessionManager } from './shellSessionManager';
|
||||
|
||||
const DEFAULT_EXEC_YIELD_TIME_MS = 10_000;
|
||||
@@ -30,15 +30,13 @@ export function createSandboxCommandImplementations({
|
||||
workingDirectory,
|
||||
env,
|
||||
signal,
|
||||
graphqlClient,
|
||||
sandboxId,
|
||||
workerApi,
|
||||
logger,
|
||||
}: {
|
||||
workingDirectory: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
signal: AbortSignal;
|
||||
graphqlClient: Client;
|
||||
sandboxId: string;
|
||||
workerApi: SandboxWorkerApi;
|
||||
logger: bunyan;
|
||||
}): {
|
||||
commandImplementations: SandboxDaemonCommandImplementations;
|
||||
@@ -46,8 +44,7 @@ export function createSandboxCommandImplementations({
|
||||
} {
|
||||
const sessions = new ShellSessionManager({ workingDirectory, env, signal });
|
||||
const artifactUploads = new SandboxArtifactUploadManager({
|
||||
graphqlClient,
|
||||
sandboxId,
|
||||
workerApi,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
SystemError,
|
||||
} from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type Client } from '@urql/core';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
type SandboxDaemonCommandImplementations,
|
||||
createSandboxCommandImplementations,
|
||||
} from './sandboxCommandImplementations';
|
||||
import { type SandboxWorkerApi } from './sandboxWorkerApi';
|
||||
|
||||
export interface SandboxDaemonOptions {
|
||||
credential: string;
|
||||
@@ -26,8 +26,7 @@ export interface SandboxDaemonOptions {
|
||||
signal?: AbortSignal;
|
||||
workingDirectory: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
graphqlClient: Client;
|
||||
sandboxId: string;
|
||||
workerApi: SandboxWorkerApi;
|
||||
}
|
||||
|
||||
export interface SandboxDaemon {
|
||||
@@ -49,8 +48,7 @@ export async function startSandboxDaemonAsync(
|
||||
workingDirectory: options.workingDirectory,
|
||||
env: options.env,
|
||||
signal: abortController.signal,
|
||||
graphqlClient: options.graphqlClient,
|
||||
sandboxId: options.sandboxId,
|
||||
workerApi: options.workerApi,
|
||||
logger: options.logger,
|
||||
});
|
||||
const connected = new Promise<void>((resolve, reject) => {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { type Env, type EnvironmentSecret, type Job, SystemError } from '@expo/eas-build-job';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
type ProjectArchiveSource,
|
||||
ProjectArchiveSourceSchema,
|
||||
} from '../../common/projectArchiveSource';
|
||||
import { getStringEnvironmentSecretValue } from '../../utils/environmentSecrets';
|
||||
import { WorkerApi, type WorkerApiRequestOptions, getExpoApiV2BaseUrl } from './workerApi';
|
||||
|
||||
export const SANDBOX_WORKER_TOKEN_ENV = 'EAS_SANDBOX_WORKER_TOKEN';
|
||||
|
||||
export const SANDBOX_WORKER_TOKEN_HEADER = 'expo-sandbox-worker-token';
|
||||
|
||||
const MarkReadyResponseSchema = z.object({ id: z.string() });
|
||||
|
||||
const ArtifactUploadSessionSchema = z.object({
|
||||
artifact: z.object({ id: z.string() }),
|
||||
uploadSession: z.object({
|
||||
url: z.string(),
|
||||
headers: z.record(z.string(), z.string()),
|
||||
}),
|
||||
});
|
||||
|
||||
const FinalizedArtifactSchema = z.object({
|
||||
id: z.string(),
|
||||
downloadUrl: z.string(),
|
||||
});
|
||||
|
||||
export type SandboxArtifactUploadSession = z.infer<typeof ArtifactUploadSessionSchema>;
|
||||
|
||||
export class SandboxWorkerApi {
|
||||
private readonly client: WorkerApi;
|
||||
|
||||
constructor(
|
||||
expoApiV2BaseUrl: string,
|
||||
readonly token: string
|
||||
) {
|
||||
this.client = new WorkerApi(new URL('sandbox-worker/', expoApiV2BaseUrl).toString(), {
|
||||
authHeader: SANDBOX_WORKER_TOKEN_HEADER,
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
public async getProjectArchiveSourceAsync(
|
||||
options: WorkerApiRequestOptions = {}
|
||||
): Promise<ProjectArchiveSource> {
|
||||
return await this.client.requestAsync(
|
||||
'POST',
|
||||
'project-archive',
|
||||
ProjectArchiveSourceSchema,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
public async markReadyAsync(options: WorkerApiRequestOptions = {}): Promise<void> {
|
||||
await this.client.requestAsync('POST', 'ready', MarkReadyResponseSchema, options);
|
||||
}
|
||||
|
||||
public async createArtifactUploadSessionAsync(
|
||||
input: { name: string; filename: string; size: number },
|
||||
options: WorkerApiRequestOptions = {}
|
||||
): Promise<SandboxArtifactUploadSession> {
|
||||
return await this.client.requestAsync(
|
||||
'POST',
|
||||
'artifact-upload-sessions',
|
||||
ArtifactUploadSessionSchema,
|
||||
{ ...options, json: input }
|
||||
);
|
||||
}
|
||||
|
||||
public async finalizeArtifactAsync(
|
||||
artifactId: string,
|
||||
options: WorkerApiRequestOptions = {}
|
||||
): Promise<string> {
|
||||
const { downloadUrl } = await this.client.requestAsync(
|
||||
'POST',
|
||||
`artifacts/${encodeURIComponent(artifactId)}/finalize`,
|
||||
FinalizedArtifactSchema,
|
||||
options
|
||||
);
|
||||
return downloadUrl;
|
||||
}
|
||||
}
|
||||
|
||||
export function getSandboxWorkerTokenFromEnvironmentSecrets(
|
||||
environmentSecrets: EnvironmentSecret[] | undefined
|
||||
): string | null {
|
||||
return getStringEnvironmentSecretValue(environmentSecrets, SANDBOX_WORKER_TOKEN_ENV);
|
||||
}
|
||||
|
||||
export function getSandboxWorkerApi(ctx: {
|
||||
job: Job;
|
||||
env: Env;
|
||||
expoApiV2BaseUrl?: string;
|
||||
}): SandboxWorkerApi | null {
|
||||
const token = getSandboxWorkerTokenFromEnvironmentSecrets(ctx.job.secrets?.environmentSecrets);
|
||||
return token ? new SandboxWorkerApi(getExpoApiV2BaseUrl(ctx), token) : null;
|
||||
}
|
||||
|
||||
export function requireSandboxWorkerApi(ctx: {
|
||||
job: Job;
|
||||
env: Env;
|
||||
expoApiV2BaseUrl?: string;
|
||||
}): SandboxWorkerApi {
|
||||
const workerApi = getSandboxWorkerApi(ctx);
|
||||
if (!workerApi) {
|
||||
throw new SystemError(
|
||||
`This sandbox job has no ${SANDBOX_WORKER_TOKEN_ENV} secret. ` +
|
||||
'www gives one to every sandbox job, and the worker needs it to call the sandbox API. ' +
|
||||
'Start a new sandbox, and contact support if it happens again.'
|
||||
);
|
||||
}
|
||||
return workerApi;
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import type { bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { resolveSimulatorPackageVersionAsync } from '../../utils/simulatorPackageVersion';
|
||||
import { stopWarmSimulatorAsync } from '../../utils/warmSimulator';
|
||||
import { IosSimulatorRecordingUtils } from './IosSimulatorRecordingUtils';
|
||||
import { type DeviceSessionServer } from './deviceSessionHost';
|
||||
import { fetchWebPreviewTurnArgsAsync } from './remoteDeviceRunSession';
|
||||
@@ -28,6 +30,7 @@ export function createWarmDeviceSessionServer(
|
||||
}
|
||||
return {
|
||||
port: warm.server.port,
|
||||
serveSimVersion: warm.serveSimVersion,
|
||||
turnArgs: fetchWebPreviewTurnArgsAsync(ctx, { logger }),
|
||||
// Set by prepare-tools, which the host runs before it installs the app.
|
||||
get preinstalledApp() {
|
||||
@@ -68,14 +71,34 @@ export function createWarmDeviceSessionServer(
|
||||
*/
|
||||
export async function runOnWarmIosSimulatorAsync<T>(
|
||||
claim: Parameters<typeof tryClaimWarmIosSessionAsync>[0],
|
||||
run: (warm?: WarmIosSession) => Promise<T>
|
||||
run: (warm?: WarmIosSession, packageVersion?: string) => Promise<T>
|
||||
): Promise<T> {
|
||||
const warm = await tryClaimWarmIosSessionAsync(claim);
|
||||
let packageVersion = claim.packageVersion;
|
||||
if (packageVersion !== undefined && claim.env.EAS_USE_WARM_SIMULATOR === '1') {
|
||||
try {
|
||||
packageVersion = await resolveSimulatorPackageVersionAsync(
|
||||
claim.engine,
|
||||
packageVersion,
|
||||
claim
|
||||
);
|
||||
} catch (error) {
|
||||
try {
|
||||
await stopWarmSimulatorAsync(claim.env);
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError(
|
||||
[error, cleanupError],
|
||||
'Could not stop warmup after version resolution failed.'
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const warm = await tryClaimWarmIosSessionAsync({ ...claim, packageVersion });
|
||||
if (!warm) {
|
||||
return await run(undefined);
|
||||
return await run(undefined, packageVersion);
|
||||
}
|
||||
try {
|
||||
return await run(warm);
|
||||
return await run(warm, packageVersion);
|
||||
} catch (error) {
|
||||
try {
|
||||
await warm.process.stopAsync();
|
||||
@@ -90,6 +113,6 @@ export async function runOnWarmIosSimulatorAsync<T>(
|
||||
}
|
||||
claim.logger.warn('Could not adopt the warm Simulator; starting a cold Simulator.');
|
||||
claim.signal?.throwIfAborted();
|
||||
return await run(undefined);
|
||||
return await run(undefined, packageVersion);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { type bunyan } from '@expo/logger';
|
||||
import { type BuildStepEnv } from '@expo/steps';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { runSimulatorLauncherAsync, stopWarmSimulatorAsync } from '../../utils/warmSimulator';
|
||||
@@ -9,7 +10,11 @@ import { readLocalEgressHandoffAsync } from './localEgress';
|
||||
import { type DetachedProcessHandle, isProcessRunning } from './remoteDeviceRunSession';
|
||||
|
||||
const ServiceSchema = z.object({ pid: z.number(), port: z.number(), token: z.string() });
|
||||
const PackageVersionSchema = z.string().refine(version => semver.valid(version) !== null);
|
||||
const ClaimSchema = z.object({
|
||||
serveSimVersion: PackageVersionSchema.optional(),
|
||||
agentDeviceVersion: PackageVersionSchema.optional(),
|
||||
argentVersion: PackageVersionSchema.optional(),
|
||||
device: z.object({ udid: z.string(), name: z.string() }),
|
||||
server: ServiceSchema,
|
||||
agentDeviceStateDir: z.string(),
|
||||
@@ -53,10 +58,6 @@ export async function tryClaimWarmIosSessionAsync({
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
// Warm tools run the latest release with the VM's Simulator settings.
|
||||
if (packageVersion && packageVersion !== 'latest') {
|
||||
throw new Error('A pinned package_version needs a cold start.');
|
||||
}
|
||||
const setting = Object.keys(env).find(
|
||||
name => name === 'SERVE_SIM_ADDITIONAL_DYLIBS' || name.startsWith('SIMCTL_CHILD_')
|
||||
);
|
||||
@@ -71,6 +72,15 @@ export async function tryClaimWarmIosSessionAsync({
|
||||
const claim = ClaimSchema.parse(
|
||||
JSON.parse(await runSimulatorLauncherAsync(claimArgs, { env, signal }))
|
||||
);
|
||||
const selectedVersion = {
|
||||
'serve-sim': claim.serveSimVersion,
|
||||
'agent-device': claim.agentDeviceVersion,
|
||||
argent: claim.argentVersion,
|
||||
appium: undefined,
|
||||
}[engine];
|
||||
if (packageVersion !== undefined && selectedVersion !== packageVersion) {
|
||||
throw new Error('The warm tool does not match the requested package version.');
|
||||
}
|
||||
logger.info(`Claimed the warm Simulator ${claim.device.name} (${claim.device.udid}).`);
|
||||
let stopping: Promise<void> | undefined;
|
||||
const session: WarmIosSession = {
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { type Env, SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { FetchError } from 'node-fetch';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { promiseRetryWithCondition } from '../../utils/promiseRetryWithCondition';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
|
||||
/**
|
||||
* The header and value that authenticate a worker to its www worker router. The token is not an
|
||||
* `Authorization` credential, and www accepts it only on that router.
|
||||
*/
|
||||
export type WorkerApiAuth = {
|
||||
authHeader: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type WorkerApiRequestOptions = {
|
||||
/** Stops the request when aborted. */
|
||||
signal?: AbortSignal;
|
||||
/** node-fetch timeout of each attempt, in milliseconds. */
|
||||
timeoutMs?: number;
|
||||
/** Additional attempts for transient failures. Defaults to none; critical callers opt in. */
|
||||
retries?: number;
|
||||
logger?: bunyan;
|
||||
};
|
||||
|
||||
const ApiV2ErrorResponseSchema = z.object({
|
||||
errors: z.array(z.object({ code: z.string().optional(), message: z.string() })).min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* A request to a worker API failed. The message names the route and the reason. It never includes
|
||||
* the worker token.
|
||||
*/
|
||||
export class WorkerApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
/** The HTTP status of the response, or undefined when no response arrived. */
|
||||
readonly status: number | undefined,
|
||||
options?: ErrorOptions,
|
||||
readonly code?: string
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = 'WorkerApiError';
|
||||
}
|
||||
|
||||
get isTransient(): boolean {
|
||||
return this.status === undefined || this.status >= 500;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client for one www worker router. The token names the resource the worker serves, so no request
|
||||
* carries its ID.
|
||||
*/
|
||||
export class WorkerApi {
|
||||
constructor(
|
||||
/** The URL of the worker router. It ends with a slash, so routes resolve under it. */
|
||||
private readonly baseUrl: string,
|
||||
private readonly auth: WorkerApiAuth
|
||||
) {}
|
||||
|
||||
public async requestAsync<T>(
|
||||
method: 'GET' | 'POST',
|
||||
route: string,
|
||||
dataSchema: z.ZodType<T>,
|
||||
{ retries = 0, ...options }: WorkerApiRequestOptions & { json?: Record<string, unknown> } = {}
|
||||
): Promise<T> {
|
||||
const url = new URL(route, this.baseUrl);
|
||||
const routeDescription = `${method} ${url.pathname}`;
|
||||
const request = () => {
|
||||
options.signal?.throwIfAborted();
|
||||
return this.requestOnceAsync(method, url.toString(), routeDescription, dataSchema, options);
|
||||
};
|
||||
if (retries === 0) {
|
||||
return await request();
|
||||
}
|
||||
return await promiseRetryWithCondition(
|
||||
request,
|
||||
error =>
|
||||
!options.signal?.aborted &&
|
||||
error instanceof WorkerApiError &&
|
||||
(error.status === undefined || error.status === 429 || error.status >= 500),
|
||||
{ retries, factor: 2, minTimeout: 1000 },
|
||||
({ attemptNumber, maxAttemptsCount }) =>
|
||||
options.logger?.warn(
|
||||
`Retrying ${routeDescription} (attempt ${attemptNumber}/${maxAttemptsCount}).`
|
||||
)
|
||||
)();
|
||||
}
|
||||
|
||||
private async requestOnceAsync<T>(
|
||||
method: 'GET' | 'POST',
|
||||
url: string,
|
||||
routeDescription: string,
|
||||
dataSchema: z.ZodType<T>,
|
||||
{
|
||||
signal,
|
||||
timeoutMs,
|
||||
logger,
|
||||
json,
|
||||
}: Omit<WorkerApiRequestOptions, 'retries'> & {
|
||||
json?: Record<string, unknown>;
|
||||
}
|
||||
): Promise<T> {
|
||||
const responseResult = await asyncResult(
|
||||
turtleFetch(url, method, {
|
||||
headers: { [this.auth.authHeader]: this.auth.token },
|
||||
...(json ? { json } : {}),
|
||||
...(signal ? { signal } : {}),
|
||||
...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}),
|
||||
retries: 0,
|
||||
shouldThrowOnNotOk: false,
|
||||
logger,
|
||||
})
|
||||
);
|
||||
if (!responseResult.ok) {
|
||||
const reason = responseResult.reason;
|
||||
throw new WorkerApiError(
|
||||
`${routeDescription} failed before www responded: ${
|
||||
reason instanceof Error ? reason.message : String(reason)
|
||||
}. Check the network connection of the worker.`,
|
||||
undefined,
|
||||
{ cause: reason }
|
||||
);
|
||||
}
|
||||
const response = responseResult.value;
|
||||
|
||||
const bodyResult = await asyncResult(response.json());
|
||||
if (!response.ok) {
|
||||
const apiError = bodyResult.ok ? ApiV2ErrorResponseSchema.safeParse(bodyResult.value) : null;
|
||||
const apiErrorMessage = apiError?.success
|
||||
? apiError.data.errors.map(error => error.message).join(' ')
|
||||
: response.statusText;
|
||||
throw new WorkerApiError(
|
||||
`${routeDescription} failed with status ${response.status}: ${apiErrorMessage}`,
|
||||
response.status,
|
||||
undefined,
|
||||
apiError?.success ? apiError.data.errors[0]?.code : undefined
|
||||
);
|
||||
}
|
||||
if (!bodyResult.ok) {
|
||||
if (
|
||||
bodyResult.reason instanceof FetchError &&
|
||||
['system', 'body-timeout'].includes(bodyResult.reason.type)
|
||||
) {
|
||||
throw new WorkerApiError(
|
||||
`${routeDescription} failed while reading the response body.`,
|
||||
undefined
|
||||
);
|
||||
}
|
||||
throw new WorkerApiError(
|
||||
`${routeDescription} returned a response that is not JSON.`,
|
||||
response.status
|
||||
);
|
||||
}
|
||||
const parsed = z.object({ data: dataSchema }).safeParse(bodyResult.value);
|
||||
if (!parsed.success) {
|
||||
throw new WorkerApiError(
|
||||
`${routeDescription} returned an unexpected response: ${parsed.error.message}`,
|
||||
response.status
|
||||
);
|
||||
}
|
||||
return parsed.data.data;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the API v2 base URL of the worker. It throws when the worker knows no API server URL,
|
||||
* because a worker API client cannot work without it.
|
||||
*/
|
||||
export function getExpoApiV2BaseUrl(ctx: { env: Env; expoApiV2BaseUrl?: string }): string {
|
||||
if (ctx.expoApiV2BaseUrl) {
|
||||
return ctx.expoApiV2BaseUrl;
|
||||
}
|
||||
if (ctx.env.__API_SERVER_URL) {
|
||||
return new URL('/v2/', ctx.env.__API_SERVER_URL).toString();
|
||||
}
|
||||
throw new SystemError(
|
||||
'The API server URL is not set, so the worker cannot call its worker API. ' +
|
||||
'Check that the worker sets __API_SERVER_URL.'
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import { setTimeout } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Sentry } from '../sentry';
|
||||
import { type AndroidEmulatorLogSource } from './androidEmulatorLogSources';
|
||||
import { type AndroidEmulatorLogCapture } from './androidEmulatorLogCapture';
|
||||
import { retryAsync } from './retry';
|
||||
|
||||
@@ -328,14 +327,12 @@ export namespace AndroidEmulatorUtils {
|
||||
deviceName,
|
||||
env,
|
||||
logcatDirectory,
|
||||
logSources,
|
||||
launchGate,
|
||||
logCapture,
|
||||
}: {
|
||||
deviceName: AndroidVirtualDeviceName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logcatDirectory: string;
|
||||
logSources: readonly AndroidEmulatorLogSource[];
|
||||
launchGate?: AndroidEmulatorLaunchGate;
|
||||
logCapture: AndroidEmulatorLogCapture;
|
||||
}): Promise<{
|
||||
@@ -351,13 +348,11 @@ export namespace AndroidEmulatorUtils {
|
||||
.toString(16)
|
||||
.padStart(8, '0');
|
||||
const outputName = `${safeDeviceName}-${timestamp}-${randomBytes(2).toString('hex')}`;
|
||||
if (logSources.includes('device-logcat')) {
|
||||
if (logCapture.sources.includes('device-logcat')) {
|
||||
logcatOutputPath = path.join(logcatDirectory, `${outputName}-logcat.log`);
|
||||
await fs.promises.writeFile(logcatOutputPath, '');
|
||||
}
|
||||
if (logSources.includes('emulator-output')) {
|
||||
if (logCapture.sources.includes('emulator-output')) {
|
||||
emulatorOutputPath = path.join(logcatDirectory, `${outputName}-emulator.log`);
|
||||
await fs.promises.writeFile(emulatorOutputPath, '');
|
||||
}
|
||||
} catch (err) {
|
||||
throw new SystemError(`Failed to prepare Android emulator output for ${deviceName}.`, {
|
||||
|
||||
@@ -4,6 +4,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { retryAsync } from './retry';
|
||||
@@ -202,7 +203,7 @@ export namespace IosSimulatorUtils {
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<IosSimulatorUuid> {
|
||||
if (UDID_PATTERN.test(deviceIdentifier)) {
|
||||
return deviceIdentifier as IosSimulatorUuid;
|
||||
return deviceIdentifier.toUpperCase() as IosSimulatorUuid;
|
||||
}
|
||||
const devices = await getAvailableDevicesAsync({ env, filter: 'available' });
|
||||
const device = devices.find(candidate => candidate.name === deviceIdentifier);
|
||||
@@ -284,9 +285,23 @@ export namespace IosSimulatorUtils {
|
||||
const readinessScreenshotPath = path.join(os.tmpdir(), 'eas-simulator-readiness.png');
|
||||
await retryAsync(
|
||||
async () => {
|
||||
await spawn('xcrun', ['simctl', 'io', udid, 'screenshot', readinessScreenshotPath], {
|
||||
env,
|
||||
});
|
||||
const { stdout: enumerateStdout } = await spawn(
|
||||
'xcrun',
|
||||
['simctl', 'io', udid, 'enumerate'],
|
||||
{ env }
|
||||
);
|
||||
await spawn(
|
||||
'xcrun',
|
||||
[
|
||||
'simctl',
|
||||
'io',
|
||||
udid,
|
||||
'screenshot',
|
||||
...displayFlagForScreenshot(enumerateStdout),
|
||||
readinessScreenshotPath,
|
||||
],
|
||||
{ env }
|
||||
);
|
||||
},
|
||||
{
|
||||
retryOptions: {
|
||||
@@ -298,22 +313,30 @@ export namespace IosSimulatorUtils {
|
||||
);
|
||||
await fs.promises.rm(readinessScreenshotPath, { force: true });
|
||||
|
||||
// Wait for data migration to complete before declaring the simulator ready
|
||||
// Based on WebKit's approach: https://trac.webkit.org/changeset/231452/webkit
|
||||
await retryAsync(
|
||||
async () => {
|
||||
const isDataMigrating = await isDataMigratorProcessRunning({ env });
|
||||
if (isDataMigrating) {
|
||||
throw new Error('com.apple.datamigrator still running');
|
||||
}
|
||||
},
|
||||
{
|
||||
retryOptions: {
|
||||
retries: 30 * 60,
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
const device = await getDeviceAsync({ udid, env });
|
||||
const runtimes = await getAvailableRuntimesAsync({ env });
|
||||
const iosVersion = semver.coerce(
|
||||
runtimes.find(runtime => runtime.identifier === device?.runtime)?.version
|
||||
);
|
||||
// From iOS 27.2 on, `com.apple.datamigrator` stays resident after the boot's data migration, so
|
||||
// waiting for it to exit never ends; earlier runtimes exit it within about a minute of boot.
|
||||
if (!iosVersion || semver.lt(iosVersion, '27.2.0')) {
|
||||
// Based on WebKit's approach: https://trac.webkit.org/changeset/231452/webkit
|
||||
await retryAsync(
|
||||
async () => {
|
||||
const isDataMigrating = await isDataMigratorProcessRunning({ env });
|
||||
if (isDataMigrating) {
|
||||
throw new Error('com.apple.datamigrator still running');
|
||||
}
|
||||
},
|
||||
{
|
||||
retryOptions: {
|
||||
retries: 30 * 60,
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function disableApsdAsync({
|
||||
@@ -522,6 +545,20 @@ async function isLaunchctlServiceLoadedAsync({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `simctl io screenshot` refuses to choose between the iPhone Duo's two integrated screens on
|
||||
* iOS 27.2, while the simctl in Xcode 15.3 rejects a numeric `--display` ("Device does not have a
|
||||
* '1' display port"), so the flag is passed only when there is a choice to make.
|
||||
*/
|
||||
function displayFlagForScreenshot(enumerateStdout: string): string[] {
|
||||
// `simctl io enumerate` lists each screen as an indented "(n) Name:" header followed by its fields.
|
||||
const screens = enumerateStdout.split(/^ {4}\(\d+\) .*:$/m);
|
||||
const integratedScreenIds = screens
|
||||
.filter(screen => /^\s*Screen Type: Integrated$/m.test(screen))
|
||||
.flatMap(screen => screen.match(/^\s*Screen ID: (\d+)$/m)?.[1] ?? []);
|
||||
return integratedScreenIds.length > 1 ? [`--display=${integratedScreenIds[0]}`] : [];
|
||||
}
|
||||
|
||||
function parseUdidFromBootstatusStdout(stdout: string): IosSimulatorUuid | null {
|
||||
const matches = stdout.match(/^Monitoring boot status for .+ \((.+)\)\.$/m);
|
||||
if (!matches) {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
import { AndroidEmulatorLogCapture } from '../androidEmulatorLogCapture';
|
||||
import { AndroidEmulatorLogFiles } from '../androidEmulatorLogFiles';
|
||||
import { ALL_ANDROID_EMULATOR_LOG_SOURCES } from '../androidEmulatorLogSources';
|
||||
|
||||
// We need to use real fs for cloning devices to work.
|
||||
@@ -80,8 +80,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
({ serialId } = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogCapture(Infinity),
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
logCapture: new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES }),
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
@@ -109,8 +108,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogCapture(Infinity),
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
logCapture: new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES }),
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
@@ -141,8 +139,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
|
||||
const { serialId: serialIdClone, emulatorPromise: emulatorPromiseClone } =
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogCapture(Infinity),
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
logCapture: new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES }),
|
||||
deviceName: cloneDeviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
@@ -180,8 +177,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
|
||||
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogCapture(Infinity),
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
logCapture: new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES }),
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
@@ -236,8 +232,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogCapture(Infinity),
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
logCapture: new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES }),
|
||||
deviceName,
|
||||
env: envForAttempt,
|
||||
logcatDirectory,
|
||||
@@ -321,8 +316,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
logger,
|
||||
});
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogCapture(Infinity),
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
logCapture: new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES }),
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory: testLogcatDirectory,
|
||||
@@ -416,8 +410,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
|
||||
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogCapture(Infinity),
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
logCapture: new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES }),
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
import { AndroidEmulatorLogCapture } from '../androidEmulatorLogCapture';
|
||||
import { AndroidEmulatorLogRing } from '../androidEmulatorLogCapture';
|
||||
import { AndroidEmulatorLogFiles } from '../androidEmulatorLogFiles';
|
||||
import {
|
||||
ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
AndroidEmulatorLogSourcesSchema,
|
||||
@@ -184,9 +185,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.startAsync, () => {
|
||||
let buildCapture: AndroidEmulatorLogCapture;
|
||||
let buildCapture: AndroidEmulatorLogFiles;
|
||||
beforeEach(() => {
|
||||
buildCapture = new AndroidEmulatorLogCapture(Infinity);
|
||||
buildCapture = new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES });
|
||||
});
|
||||
afterEach(async () => {
|
||||
await buildCapture.finishAsync();
|
||||
@@ -239,12 +240,13 @@ describe('AndroidEmulatorUtils', () => {
|
||||
const { child } = mockSuccessfulStart(deviceName);
|
||||
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
logCapture: buildCapture,
|
||||
deviceName,
|
||||
env: process.env,
|
||||
logcatDirectory: outputDir,
|
||||
});
|
||||
child.emit('close');
|
||||
await buildCapture.finishAsync();
|
||||
|
||||
expect(await logFile(outputDir, 'logcat')).toMatch(
|
||||
new RegExp(`^${outputDir}/eas-simulator-[a-f0-9]{8}-[a-f0-9]{4}-logcat\\.log$`)
|
||||
@@ -267,7 +269,6 @@ describe('AndroidEmulatorUtils', () => {
|
||||
// for as long as the emulator runs.
|
||||
expect(child.stdout.unref).toHaveBeenCalled();
|
||||
expect(child.stderr.unref).toHaveBeenCalled();
|
||||
child.emit('close');
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -282,13 +283,15 @@ describe('AndroidEmulatorUtils', () => {
|
||||
const { child } = mockSuccessfulStart(deviceName);
|
||||
const stdoutPipe = jest.spyOn(child.stdout, 'pipe');
|
||||
const stderrPipe = jest.spyOn(child.stderr, 'pipe');
|
||||
const prepare = jest.spyOn(buildCapture, 'prepareLogcatOutputAsync');
|
||||
const capture = new AndroidEmulatorLogFiles({
|
||||
sources: AndroidEmulatorLogSourcesSchema.parse(sources),
|
||||
});
|
||||
const prepare = jest.spyOn(capture, 'prepareLogcatOutputAsync');
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: buildCapture,
|
||||
logCapture: capture,
|
||||
deviceName,
|
||||
env: {},
|
||||
logcatDirectory: outputDir,
|
||||
logSources: AndroidEmulatorLogSourcesSchema.parse(sources),
|
||||
});
|
||||
const capturesOutput = sources.includes('emulator-output');
|
||||
const capturesLogcat = sources.includes('device-logcat');
|
||||
@@ -307,7 +310,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
child.stdout.emit('data', Buffer.from('stdout marker\n'));
|
||||
child.stderr.emit('data', Buffer.from('stderr marker\n'));
|
||||
child.emit('close');
|
||||
await buildCapture.finishAsync();
|
||||
}
|
||||
await capture.finishAsync();
|
||||
if (capturesOutput) {
|
||||
await expect(
|
||||
fs.promises.readFile(await logFile(outputDir, 'emulator'), 'utf8')
|
||||
).resolves.toBe('stdout marker\nstderr marker\n');
|
||||
@@ -323,7 +328,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
temporaryDirectories.push(outputDir);
|
||||
const deviceName = 'bounded-emulator' as AndroidVirtualDeviceName;
|
||||
const { child } = mockSuccessfulStart(deviceName);
|
||||
const capture = new AndroidEmulatorLogCapture();
|
||||
const capture = new AndroidEmulatorLogRing({ sources: ['device-logcat'] });
|
||||
const close = jest.fn();
|
||||
const createPipe = jest.spyOn(capture, 'prepareLogcatOutputAsync').mockResolvedValue({
|
||||
path: '/logcat.pipe',
|
||||
@@ -334,10 +339,13 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
env: {},
|
||||
logcatDirectory: outputDir,
|
||||
logSources: ['device-logcat'],
|
||||
logCapture: capture,
|
||||
});
|
||||
expect(createPipe).toHaveBeenCalledWith(await logFile(outputDir, 'logcat'));
|
||||
expect(createPipe).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
new RegExp(`^${outputDir}/bounded-emulator-[a-f0-9]{8}-[a-f0-9]{4}-logcat\\.log$`)
|
||||
)
|
||||
);
|
||||
expect(mockedSpawn.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining(['-logcat-output', '/logcat.pipe'])
|
||||
);
|
||||
@@ -345,7 +353,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
child.emit('close');
|
||||
await capture.finishAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(await fs.promises.readdir(outputDir)).toHaveLength(1);
|
||||
expect(await fs.promises.readdir(outputDir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('spawns the emulator directly with the same arguments when there is no launch gate', async () => {
|
||||
@@ -356,7 +364,6 @@ describe('AndroidEmulatorUtils', () => {
|
||||
const env = { ANDROID_EMULATOR_EXTRA_ARGS: '-gpu swiftshader_indirect' };
|
||||
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
logCapture: buildCapture,
|
||||
deviceName,
|
||||
env,
|
||||
@@ -416,7 +423,6 @@ describe('AndroidEmulatorUtils', () => {
|
||||
const admitAsync = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
logCapture: buildCapture,
|
||||
deviceName,
|
||||
env: {},
|
||||
@@ -481,7 +487,6 @@ describe('AndroidEmulatorUtils', () => {
|
||||
|
||||
await expect(
|
||||
AndroidEmulatorUtils.startAsync({
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
logCapture: buildCapture,
|
||||
deviceName,
|
||||
env: {},
|
||||
@@ -507,7 +512,6 @@ describe('AndroidEmulatorUtils', () => {
|
||||
try {
|
||||
await expect(
|
||||
AndroidEmulatorUtils.startAsync({
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
logCapture: buildCapture,
|
||||
deviceName,
|
||||
env: process.env,
|
||||
|
||||
@@ -252,24 +252,179 @@ describe('IosSimulatorUtils', () => {
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.waitForReadyAsync, () => {
|
||||
const readinessScreenshotPath = path.join(os.tmpdir(), 'eas-simulator-readiness.png');
|
||||
|
||||
it('takes the readiness screenshot into a writable temp file, not /dev/null', async () => {
|
||||
await IosSimulatorUtils.waitForReadyAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
});
|
||||
mockSimulator({});
|
||||
|
||||
await IosSimulatorUtils.waitForReadyAsync({ udid: 'test-udid' as any, env: process.env });
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
[
|
||||
'simctl',
|
||||
'io',
|
||||
'test-udid',
|
||||
'screenshot',
|
||||
path.join(os.tmpdir(), 'eas-simulator-readiness.png'),
|
||||
],
|
||||
['simctl', 'io', 'test-udid', 'screenshot', readinessScreenshotPath],
|
||||
{ env: process.env }
|
||||
);
|
||||
});
|
||||
|
||||
it('names the first integrated screen when the device has several (iPhone Duo on iOS 27.2)', async () => {
|
||||
// Connected screens from `xcrun simctl io <udid> enumerate` on an iPhone Duo (iOS 27.2).
|
||||
mockSimulator({
|
||||
version: '27.2',
|
||||
connectedScreens: ` Connected Screens:
|
||||
(1) LCD:
|
||||
Screen ID: 1
|
||||
Screen Type: Integrated
|
||||
(2) TVOut:
|
||||
Screen ID: 2
|
||||
Screen Type: TVOut
|
||||
(3) LCD-1:
|
||||
Screen ID: 3
|
||||
Screen Type: Integrated
|
||||
`,
|
||||
});
|
||||
|
||||
await IosSimulatorUtils.waitForReadyAsync({ udid: 'test-udid' as any, env: process.env });
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'io', 'test-udid', 'screenshot', '--display=1', readinessScreenshotPath],
|
||||
{ env: process.env }
|
||||
);
|
||||
});
|
||||
|
||||
it('takes the first listed integrated screen, skipping screens that are not integrated', async () => {
|
||||
mockSimulator({
|
||||
connectedScreens: ` Connected Screens:
|
||||
(1) TVOut:
|
||||
Screen ID: 1
|
||||
Screen Type: TVOut
|
||||
(2) LCD-1:
|
||||
Screen ID: 5
|
||||
Screen Type: Integrated
|
||||
(3) LCD:
|
||||
Screen ID: 3
|
||||
Screen Type: Integrated
|
||||
`,
|
||||
});
|
||||
|
||||
await IosSimulatorUtils.waitForReadyAsync({ udid: 'test-udid' as any, env: process.env });
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'io', 'test-udid', 'screenshot', '--display=5', readinessScreenshotPath],
|
||||
{ env: process.env }
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the plain screenshot when the device has one integrated screen', async () => {
|
||||
mockSimulator({
|
||||
connectedScreens: ` Connected Screens:
|
||||
(2) TVOut:
|
||||
Screen ID: 2
|
||||
Screen Type: TVOut
|
||||
(1) LCD:
|
||||
Screen ID: 1
|
||||
Screen Type: Integrated
|
||||
`,
|
||||
});
|
||||
|
||||
await IosSimulatorUtils.waitForReadyAsync({ udid: 'test-udid' as any, env: process.env });
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'io', 'test-udid', 'screenshot', readinessScreenshotPath],
|
||||
{ env: process.env }
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['27.2', '27.3', '28.0'])(
|
||||
'does not wait for com.apple.datamigrator to exit on iOS %s, where it stays resident',
|
||||
async version => {
|
||||
mockSimulator({ version, dataMigratorRunning: true });
|
||||
|
||||
await IosSimulatorUtils.waitForReadyAsync({ udid: 'test-udid' as any, env: process.env });
|
||||
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith('ps', expect.anything(), expect.anything());
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['27.1', '26.3.1', '18.6'])(
|
||||
'waits for com.apple.datamigrator to exit on iOS %s',
|
||||
async version => {
|
||||
mockSimulator({ version, dataMigratorRunning: true });
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.waitForReadyAsync({ udid: 'test-udid' as any, env: process.env })
|
||||
).rejects.toThrow('com.apple.datamigrator still running');
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('ps', ['-eo', 'pid,comm'], { env: process.env });
|
||||
}
|
||||
);
|
||||
|
||||
it('waits for com.apple.datamigrator to exit when the device is not listed', async () => {
|
||||
mockSimulator({ listed: false, dataMigratorRunning: true });
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.waitForReadyAsync({ udid: 'test-udid' as any, env: process.env })
|
||||
).rejects.toThrow('com.apple.datamigrator still running');
|
||||
});
|
||||
|
||||
it('waits for com.apple.datamigrator to exit when the runtime reports no version', async () => {
|
||||
mockSimulator({ version: null, dataMigratorRunning: true });
|
||||
await expect(
|
||||
IosSimulatorUtils.waitForReadyAsync({ udid: 'test-udid' as any, env: process.env })
|
||||
).rejects.toThrow('com.apple.datamigrator still running');
|
||||
});
|
||||
|
||||
function mockSimulator({
|
||||
version = '26.4',
|
||||
listed = true,
|
||||
connectedScreens = '',
|
||||
dataMigratorRunning = false,
|
||||
}: {
|
||||
version?: string | null;
|
||||
listed?: boolean;
|
||||
connectedScreens?: string;
|
||||
dataMigratorRunning?: boolean;
|
||||
}): void {
|
||||
const runtime = 'com.apple.CoreSimulator.SimRuntime.iOS-26-4';
|
||||
const otherRuntime = {
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-27-2',
|
||||
version: '27.2',
|
||||
};
|
||||
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'ps') {
|
||||
return {
|
||||
stdout: dataMigratorRunning
|
||||
? ' 123 /sbin/launchd_sim\n 456 /Library/Developer/CoreSimulator/Volumes/iOS_23E244/XPCServices/com.apple.datamigrator.xpc/com.apple.datamigrator\n'
|
||||
: ' 123 /sbin/launchd_sim\n',
|
||||
stderr: '',
|
||||
} as any;
|
||||
}
|
||||
if (args.includes('runtimes')) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
runtimes: [
|
||||
otherRuntime,
|
||||
version === null ? { identifier: runtime } : { identifier: runtime, version },
|
||||
],
|
||||
}),
|
||||
stderr: '',
|
||||
} as any;
|
||||
}
|
||||
if (args.includes('list')) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
devices: { [runtime]: listed ? [{ udid: 'test-udid' }] : [] },
|
||||
}),
|
||||
stderr: '',
|
||||
} as any;
|
||||
}
|
||||
if (args.includes('enumerate')) {
|
||||
return { stdout: connectedScreens, stderr: '' } as any;
|
||||
}
|
||||
return { stdout: '', stderr: '' } as any;
|
||||
}) as any);
|
||||
}
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.disableApsdAsync, () => {
|
||||
@@ -427,6 +582,15 @@ describe('IosSimulatorUtils', () => {
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uppercases a udid given in lowercase, matching how simctl lists devices', async () => {
|
||||
await expect(
|
||||
IosSimulatorUtils.resolveUdidAsync({
|
||||
deviceIdentifier: '8027f627-9534-4679-85bf-0f14aff228e8' as any,
|
||||
env: process.env,
|
||||
})
|
||||
).resolves.toBe('8027F627-9534-4679-85BF-0F14AFF228E8');
|
||||
});
|
||||
|
||||
it('resolves a device name to the first available device with that name', async () => {
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
|
||||
@@ -7,9 +7,10 @@ import { PassThrough, Readable } from 'node:stream';
|
||||
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
AndroidEmulatorLogCapture,
|
||||
AndroidEmulatorLogRing,
|
||||
MAX_ANDROID_LOG_BYTES_PER_SOURCE,
|
||||
} from '../androidEmulatorLogCapture';
|
||||
import { ALL_ANDROID_EMULATOR_LOG_SOURCES } from '../androidEmulatorLogSources';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
@@ -26,23 +27,25 @@ afterEach(async () => {
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function record(
|
||||
capture: AndroidEmulatorLogCapture,
|
||||
file: string,
|
||||
text: string
|
||||
): Promise<void> {
|
||||
const input = Readable.from([Buffer.from(text)]);
|
||||
capture.attach(input, file, 'emulator-output');
|
||||
await once(input, 'end');
|
||||
await capture.finishFileAsync(file);
|
||||
function createCapture(maxBytes?: number): AndroidEmulatorLogRing {
|
||||
return new AndroidEmulatorLogRing({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES, maxBytes });
|
||||
}
|
||||
|
||||
async function waitForSize(file: string, size: number): Promise<void> {
|
||||
const deadline = Date.now() + 2_000;
|
||||
while ((await fs.stat(file)).size !== size && Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
expect((await fs.stat(file)).size).toBe(size);
|
||||
/** `==== <name> <ISO time> ====\n` */
|
||||
function markerLength(name: string): number {
|
||||
return name.length + 36;
|
||||
}
|
||||
|
||||
async function record(
|
||||
capture: AndroidEmulatorLogRing,
|
||||
file: string,
|
||||
text: string,
|
||||
source: 'emulator-output' | 'device-logcat' = 'emulator-output'
|
||||
): Promise<void> {
|
||||
const input = Readable.from([Buffer.from(text)]);
|
||||
capture.attach(input, file, source);
|
||||
await once(input, 'end');
|
||||
await capture.finishFileAsync(file);
|
||||
}
|
||||
|
||||
function trackOpenedFiles(): FileHandle[] {
|
||||
@@ -56,121 +59,69 @@ function trackOpenedFiles(): FileHandle[] {
|
||||
return opened;
|
||||
}
|
||||
|
||||
it('keeps the newest bytes of a source across launches, with independent budgets for the two sources', async () => {
|
||||
const capture = new AndroidEmulatorLogCapture(10);
|
||||
const first = path.join(directory, 'first.log');
|
||||
const retry = path.join(directory, 'retry.log');
|
||||
const device = path.join(directory, 'logcat.log');
|
||||
await record(capture, first, 'first!');
|
||||
await record(capture, retry, 'second!!');
|
||||
const logcat = Readable.from([Buffer.from('device logs are separate')]);
|
||||
capture.attach(logcat, device, 'device-logcat');
|
||||
await once(logcat, 'end');
|
||||
// A partially evicted file keeps its size on disk until it is sealed.
|
||||
expect((await fs.stat(first)).size).toBe(6);
|
||||
it('keeps one ring per source across launches and names each launch that wrote', async () => {
|
||||
const capture = createCapture(120);
|
||||
const emulatorRing = path.join(directory, 'emulator-output.log');
|
||||
const logcatRing = path.join(directory, 'device-logcat.log');
|
||||
await record(capture, path.join(directory, 'first-emulator.log'), 'first!');
|
||||
await record(capture, path.join(directory, 'retry-emulator.log'), 'second!!');
|
||||
await record(capture, path.join(directory, 'one-logcat.log'), 'device log', 'device-logcat');
|
||||
const logs = await capture.finishAsync();
|
||||
const seen = markerLength('first-emulator') + 6 + 1 + markerLength('retry-emulator') + 8;
|
||||
expect(logs).toEqual([
|
||||
{ filePath: first, source: 'emulator-output', bytesSeen: 6 },
|
||||
{ filePath: retry, source: 'emulator-output', bytesSeen: 8 },
|
||||
{ filePath: device, source: 'device-logcat', bytesSeen: 24 },
|
||||
{ filePath: emulatorRing, source: 'emulator-output', bytesSeen: seen },
|
||||
{ filePath: logcatRing, source: 'device-logcat', bytesSeen: markerLength('one-logcat') + 10 },
|
||||
]);
|
||||
// The retry evicts the head of the earlier launch; its own tail stays whole.
|
||||
expect(await fs.readFile(first, 'utf8')).toBe('t!');
|
||||
expect(await fs.readFile(retry, 'utf8')).toBe('second!!');
|
||||
expect(await fs.readFile(device, 'utf8')).toBe('e separate');
|
||||
});
|
||||
|
||||
it('frees a finished file on disk once a later launch evicts all of it', async () => {
|
||||
const capture = new AndroidEmulatorLogCapture(10);
|
||||
const first = path.join(directory, 'first.log');
|
||||
const retry = path.join(directory, 'retry.log');
|
||||
await record(capture, first, 'first!');
|
||||
const input = new PassThrough();
|
||||
capture.attach(input, retry, 'emulator-output');
|
||||
input.write('second log is too long');
|
||||
await waitForSize(first, 0);
|
||||
await capture.finishAsync();
|
||||
expect(await fs.readFile(first, 'utf8')).toBe('');
|
||||
expect(await fs.readFile(retry, 'utf8')).toBe('s too long');
|
||||
});
|
||||
|
||||
it('frees a fully evicted live capture on disk and records its later output', async () => {
|
||||
const capture = new AndroidEmulatorLogCapture(10);
|
||||
const first = path.join(directory, 'first.log');
|
||||
const second = path.join(directory, 'second.log');
|
||||
const a = new PassThrough();
|
||||
const b = new PassThrough();
|
||||
capture.attach(a, first, 'emulator-output');
|
||||
capture.attach(b, second, 'emulator-output');
|
||||
// Wait between writes: two streams deliver their first chunks in either order.
|
||||
a.write('0123456789');
|
||||
await waitForSize(first, 10);
|
||||
b.write('abcdefghij');
|
||||
await waitForSize(first, 0);
|
||||
await waitForSize(second, 10);
|
||||
a.write('xy');
|
||||
await capture.finishAsync();
|
||||
expect(await fs.readFile(first, 'utf8')).toBe('xy');
|
||||
expect(await fs.readFile(second, 'utf8')).toBe('cdefghij');
|
||||
});
|
||||
|
||||
it('closes an uncapped file when its stream finishes', async () => {
|
||||
const opened = trackOpenedFiles();
|
||||
const capture = new AndroidEmulatorLogCapture(Infinity);
|
||||
const file = path.join(directory, 'build.log');
|
||||
await record(capture, file, 'ordinary build output');
|
||||
expect(opened).toHaveLength(1);
|
||||
expect(opened[0].fd).toBe(-1);
|
||||
expect(await fs.readFile(file, 'utf8')).toBe('ordinary build output');
|
||||
expect(await capture.finishAsync()).toEqual([
|
||||
{ filePath: file, source: 'emulator-output', bytesSeen: 21 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('closes the file and reports once when sealing fails', async () => {
|
||||
const opened = trackOpenedFiles();
|
||||
const capture = new AndroidEmulatorLogCapture(10);
|
||||
const file = path.join(directory, 'wrapped.log');
|
||||
await record(capture, file, '0123456789abcdef');
|
||||
const diskError = Object.assign(new Error('full'), { code: 'ENOSPC' });
|
||||
jest.spyOn(opened[0], 'truncate').mockRejectedValueOnce(diskError);
|
||||
await capture.finishAsync();
|
||||
expect(opened[0].fd).toBe(-1);
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to write Android diagnostic log',
|
||||
diskError,
|
||||
expect.anything()
|
||||
expect(await fs.readFile(emulatorRing, 'utf8')).toMatch(
|
||||
/^==== first-emulator \S+ ====\nfirst!\n==== retry-emulator \S+ ====\nsecond!!$/
|
||||
);
|
||||
expect(await fs.readFile(logcatRing, 'utf8')).toMatch(/^==== one-logcat \S+ ====\ndevice log$/);
|
||||
expect((await fs.readdir(directory)).sort()).toEqual([
|
||||
'device-logcat.log',
|
||||
'emulator-output.log',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the tail of the final launch when the ring overflows', async () => {
|
||||
const capture = createCapture(120);
|
||||
await record(capture, path.join(directory, 'first-emulator.log'), 'first!');
|
||||
await record(capture, path.join(directory, 'retry-emulator.log'), 'y'.repeat(100));
|
||||
const [log] = await capture.finishAsync();
|
||||
const result = await fs.readFile(log.filePath, 'utf8');
|
||||
expect(result).toHaveLength(120);
|
||||
expect(result.endsWith('y'.repeat(100))).toBe(true);
|
||||
expect(result).not.toContain('first!');
|
||||
expect(log.bytesSeen).toBeGreaterThan(120);
|
||||
});
|
||||
|
||||
it('keeps the tail of a wrapped ring in order', async () => {
|
||||
const capture = new AndroidEmulatorLogCapture(10);
|
||||
const capture = createCapture(10);
|
||||
const file = path.join(directory, 'wrapped.log');
|
||||
const input = new PassThrough();
|
||||
capture.attach(input, file, 'emulator-output');
|
||||
for (const piece of ['0123', '4567', '89ab', 'cdef']) {
|
||||
input.write(piece);
|
||||
}
|
||||
await capture.finishAsync();
|
||||
expect(await fs.readFile(file, 'utf8')).toBe('6789abcdef');
|
||||
const [log] = await capture.finishAsync();
|
||||
expect(await fs.readFile(log.filePath, 'utf8')).toBe('6789abcdef');
|
||||
});
|
||||
|
||||
it('reserves bytes across simultaneous captures and drains after finalization', async () => {
|
||||
const capture = new AndroidEmulatorLogCapture(10);
|
||||
it('lets overlapping launches of one source share the ring', async () => {
|
||||
const capture = createCapture(400);
|
||||
const a = new PassThrough();
|
||||
const b = new PassThrough();
|
||||
const first = path.join(directory, 'first.log');
|
||||
const second = path.join(directory, 'second.log');
|
||||
capture.attach(a, first, 'emulator-output');
|
||||
capture.attach(b, second, 'emulator-output');
|
||||
a.write('12345678');
|
||||
await waitForSize(first, 8);
|
||||
b.write('abcdefgh');
|
||||
await capture.finishAsync();
|
||||
// The least recently written capture gives up its bytes first.
|
||||
expect(await fs.readFile(first, 'utf8')).toBe('78');
|
||||
expect(await fs.readFile(second, 'utf8')).toBe('abcdefgh');
|
||||
capture.attach(a, path.join(directory, 'clone-1.log'), 'emulator-output');
|
||||
capture.attach(b, path.join(directory, 'clone-2.log'), 'emulator-output');
|
||||
a.write('from clone 1 first\n');
|
||||
b.write('from clone 2\n');
|
||||
a.write('from clone 1 second\n');
|
||||
const [log] = await capture.finishAsync();
|
||||
const result = await fs.readFile(log.filePath, 'utf8');
|
||||
expect(result).toMatch(/==== clone-1 \S+ ====\n/);
|
||||
expect(result).toMatch(/==== clone-2 \S+ ====\n/);
|
||||
expect(result).toContain('from clone 2\n');
|
||||
expect(result.indexOf('from clone 1 first')).toBeLessThan(result.indexOf('from clone 1 second'));
|
||||
expect(result).toHaveLength(log.bytesSeen);
|
||||
a.end('ignored after cleanup');
|
||||
b.end('also ignored');
|
||||
expect(a.readableFlowing).toBe(true);
|
||||
@@ -179,7 +130,7 @@ it('reserves bytes across simultaneous captures and drains after finalization',
|
||||
|
||||
it('drains a real FIFO beyond 20 MiB, retains the tail, and closes after the writer exits', async () => {
|
||||
const file = path.join(directory, 'logcat.log');
|
||||
const capture = new AndroidEmulatorLogCapture();
|
||||
const capture = createCapture();
|
||||
const pipe = await capture.prepareLogcatOutputAsync(file);
|
||||
expect((await fs.stat(pipe.path)).isFIFO()).toBe(true);
|
||||
const writer = spawn(
|
||||
@@ -208,12 +159,17 @@ it('drains a real FIFO beyond 20 MiB, retains the tail, and closes after the wri
|
||||
pipe.close();
|
||||
await pipe.closed;
|
||||
const logs = await capture.finishAsync();
|
||||
const result = await fs.readFile(file);
|
||||
const ring = path.join(directory, 'device-logcat.log');
|
||||
const result = await fs.readFile(ring);
|
||||
expect(result.length).toBe(MAX_ANDROID_LOG_BYTES_PER_SOURCE);
|
||||
expect(result.subarray(0, 4).toString()).toBe('xxxx');
|
||||
expect(result.subarray(-4).toString()).toBe('\nEND');
|
||||
expect(logs).toEqual([
|
||||
{ filePath: file, source: 'device-logcat', bytesSeen: 64 * 1024 * 1024 + 10 },
|
||||
{
|
||||
filePath: ring,
|
||||
source: 'device-logcat',
|
||||
bytesSeen: 64 * 1024 * 1024 + 10 + markerLength('logcat'),
|
||||
},
|
||||
]);
|
||||
await expect(fs.stat(pipe.path)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
} finally {
|
||||
@@ -222,33 +178,23 @@ it('drains a real FIFO beyond 20 MiB, retains the tail, and closes after the wri
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it('uses the original logcat path for ordinary builds without creating a FIFO', async () => {
|
||||
const file = path.join(directory, 'build-logcat.log');
|
||||
const capture = new AndroidEmulatorLogCapture(Infinity);
|
||||
const output = await capture.prepareLogcatOutputAsync(file);
|
||||
expect(output.path).toBe(file);
|
||||
expect(await fs.readdir(directory)).toEqual([]);
|
||||
output.close();
|
||||
await output.closed;
|
||||
expect(await capture.finishAsync()).toEqual([]);
|
||||
});
|
||||
|
||||
it('closes an unused logcat pipe when the emulator fails to launch', async () => {
|
||||
const file = path.join(directory, 'empty.log');
|
||||
const capture = new AndroidEmulatorLogCapture();
|
||||
const capture = createCapture();
|
||||
const pipe = await capture.prepareLogcatOutputAsync(file);
|
||||
pipe.close();
|
||||
await pipe.closed;
|
||||
await expect(fs.stat(pipe.path)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
const ring = path.join(directory, 'device-logcat.log');
|
||||
expect(await capture.finishAsync()).toEqual([
|
||||
{ filePath: file, source: 'device-logcat', bytesSeen: 0 },
|
||||
{ filePath: ring, source: 'device-logcat', bytesSeen: 0 },
|
||||
]);
|
||||
expect((await fs.stat(file)).size).toBe(0);
|
||||
expect((await fs.stat(ring)).size).toBe(0);
|
||||
}, 5_000);
|
||||
|
||||
it('keeps draining the FIFO after capture finishes while the emulator is alive', async () => {
|
||||
const file = path.join(directory, 'live-logcat.log');
|
||||
const capture = new AndroidEmulatorLogCapture(10);
|
||||
const capture = createCapture(10);
|
||||
const output = await capture.prepareLogcatOutputAsync(file);
|
||||
const writer = spawn(
|
||||
process.execPath,
|
||||
@@ -279,7 +225,7 @@ it('keeps draining the FIFO after capture finishes while the emulator is alive',
|
||||
expect(await once(writer, 'close')).toEqual([0, null]);
|
||||
output.close();
|
||||
await output.closed;
|
||||
expect((await fs.stat(file)).size).toBe(0);
|
||||
expect((await fs.stat(path.join(directory, 'device-logcat.log'))).size).toBe(0);
|
||||
await expect(fs.stat(output.path)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
} finally {
|
||||
writer.kill();
|
||||
@@ -289,7 +235,7 @@ it('keeps draining the FIFO after capture finishes while the emulator is alive',
|
||||
}, 10_000);
|
||||
|
||||
it('reports a disk write failure once and continues draining output', async () => {
|
||||
const capture = new AndroidEmulatorLogCapture(10);
|
||||
const capture = createCapture(10);
|
||||
const input = new PassThrough();
|
||||
const file = path.join(directory, 'failed.log');
|
||||
const diskError = Object.assign(new Error('full'), { code: 'ENOSPC' });
|
||||
@@ -307,3 +253,19 @@ it('reports a disk write failure once and continues draining output', async () =
|
||||
);
|
||||
expect(input.readableFlowing).toBe(true);
|
||||
});
|
||||
|
||||
it('closes the file and reports once when sealing fails', async () => {
|
||||
const opened = trackOpenedFiles();
|
||||
const capture = createCapture(10);
|
||||
await record(capture, path.join(directory, 'wrapped.log'), '0123456789abcdef');
|
||||
const diskError = Object.assign(new Error('full'), { code: 'ENOSPC' });
|
||||
jest.spyOn(opened[0], 'truncate').mockRejectedValueOnce(diskError);
|
||||
await capture.finishAsync();
|
||||
expect(opened[0].fd).toBe(-1);
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to write Android diagnostic log',
|
||||
diskError,
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { once } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { PassThrough, Readable } from 'node:stream';
|
||||
|
||||
import { Sentry } from '../../sentry';
|
||||
import { AndroidEmulatorLogFiles } from '../androidEmulatorLogFiles';
|
||||
import { ALL_ANDROID_EMULATOR_LOG_SOURCES } from '../androidEmulatorLogSources';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
jest.mock('../../sentry', () => ({ Sentry: { capture: jest.fn() } }));
|
||||
|
||||
let directory: string;
|
||||
beforeEach(async () => {
|
||||
directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'android-build-logs-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
jest.restoreAllMocks();
|
||||
await fs.promises.rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function trackWriteStreams(): fs.WriteStream[] {
|
||||
const streams: fs.WriteStream[] = [];
|
||||
const createWriteStream = fs.createWriteStream;
|
||||
jest.spyOn(fs, 'createWriteStream').mockImplementation((file, options) => {
|
||||
const stream = createWriteStream(file, options);
|
||||
streams.push(stream);
|
||||
return stream;
|
||||
});
|
||||
return streams;
|
||||
}
|
||||
|
||||
it('appends one plain file per launch, closes it when the launch ends, and finalizes nothing', async () => {
|
||||
const streams = trackWriteStreams();
|
||||
const capture = new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES });
|
||||
const first = path.join(directory, 'first-emulator.log');
|
||||
const stdout = Readable.from([Buffer.from('out ')]);
|
||||
const stderr = Readable.from([Buffer.from('err')]);
|
||||
capture.attach(stdout, first, 'emulator-output');
|
||||
capture.attach(stderr, first, 'emulator-output');
|
||||
await Promise.all([once(stdout, 'end'), once(stderr, 'end')]);
|
||||
await capture.finishFileAsync(first);
|
||||
expect(streams).toHaveLength(1);
|
||||
expect(streams[0].closed).toBe(true);
|
||||
expect(await fs.promises.readFile(first, 'utf8')).toBe('out err');
|
||||
const retry = path.join(directory, 'retry-emulator.log');
|
||||
const input = Readable.from([Buffer.from('retry')]);
|
||||
capture.attach(input, retry, 'emulator-output');
|
||||
await once(input, 'end');
|
||||
expect(await capture.finishAsync()).toEqual([]);
|
||||
expect(streams[1].closed).toBe(true);
|
||||
expect((await fs.promises.readdir(directory)).sort()).toEqual([
|
||||
'first-emulator.log',
|
||||
'retry-emulator.log',
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives the emulator the logcat file itself without creating a FIFO', async () => {
|
||||
const capture = new AndroidEmulatorLogFiles({ sources: ['device-logcat'] });
|
||||
const file = path.join(directory, 'build-logcat.log');
|
||||
const output = await capture.prepareLogcatOutputAsync(file);
|
||||
expect(output.path).toBe(file);
|
||||
expect(await fs.promises.readdir(directory)).toEqual(['build-logcat.log']);
|
||||
output.close();
|
||||
await output.closed;
|
||||
expect(await capture.finishAsync()).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports a write failure once and keeps draining the emulator', async () => {
|
||||
const capture = new AndroidEmulatorLogFiles({ sources: ['emulator-output'] });
|
||||
const input = new PassThrough();
|
||||
const file = path.join(directory, 'missing-directory', 'failed.log');
|
||||
capture.attach(input, file, 'emulator-output');
|
||||
input.write('first');
|
||||
await capture.finishFileAsync(file);
|
||||
input.write('second');
|
||||
input.end('more');
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to write Android diagnostic log',
|
||||
expect.objectContaining({ code: 'ENOENT' }),
|
||||
expect.anything()
|
||||
);
|
||||
expect(input.readableFlowing).toBe(true);
|
||||
});
|
||||
@@ -1,7 +1,38 @@
|
||||
import { EnvironmentSecretType } from '@expo/eas-build-job';
|
||||
import fs from 'fs/promises';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { createTemporaryEnvironmentSecretFile } from '../environmentSecrets';
|
||||
import {
|
||||
createTemporaryEnvironmentSecretFile,
|
||||
getStringEnvironmentSecretValue,
|
||||
} from '../environmentSecrets';
|
||||
|
||||
describe(getStringEnvironmentSecretValue, () => {
|
||||
it('returns the last string secret with the name, like the build env', () => {
|
||||
expect(
|
||||
getStringEnvironmentSecretValue(
|
||||
[
|
||||
{ name: 'TOKEN', type: EnvironmentSecretType.STRING, value: 'first' },
|
||||
{ name: 'OTHER', type: EnvironmentSecretType.STRING, value: 'other' },
|
||||
{ name: 'TOKEN', type: EnvironmentSecretType.STRING, value: 'last' },
|
||||
],
|
||||
'TOKEN'
|
||||
)
|
||||
).toBe('last');
|
||||
});
|
||||
|
||||
it('returns null when the last secret with the name is a file secret', () => {
|
||||
expect(
|
||||
getStringEnvironmentSecretValue(
|
||||
[
|
||||
{ name: 'TOKEN', type: EnvironmentSecretType.STRING, value: 'a' },
|
||||
{ name: 'TOKEN', type: EnvironmentSecretType.FILE, value: 'Yg==' },
|
||||
],
|
||||
'TOKEN'
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(createTemporaryEnvironmentSecretFile, () => {
|
||||
beforeEach(async () => {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { resolveSimulatorPackageVersionAsync } from '../simulatorPackageVersion';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
|
||||
beforeEach(() => jest.resetAllMocks());
|
||||
|
||||
it.each(['1.2.3', 'v1.2.3', '1.2.3-beta.1', '1.2.3-beta.1+build.2', 'v1.2.3+build.2'])(
|
||||
'compares an exact request without a registry call (%s)',
|
||||
async requested => {
|
||||
await expect(
|
||||
resolveSimulatorPackageVersionAsync('serve-sim', requested, { env: {} })
|
||||
).resolves.toBe(requested.replace(/^v/, ''));
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
['serve-sim', '@expo/serve-sim'],
|
||||
['agent-device', 'agent-device'],
|
||||
['argent', '@swmansion/argent'],
|
||||
['appium', 'appium'],
|
||||
] as const)('checks the current dist-tag for %s', async (engine, name) => {
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '"1.2.4"' } as never);
|
||||
const env = { NPM_CONFIG_REGISTRY: 'https://registry.example' };
|
||||
const signal = new AbortController().signal;
|
||||
await expect(
|
||||
resolveSimulatorPackageVersionAsync(engine, 'latest', { env, signal })
|
||||
).resolves.toBe('1.2.4');
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'npm',
|
||||
[
|
||||
'view',
|
||||
`${name}@latest`,
|
||||
'version',
|
||||
'--json',
|
||||
'--prefer-online',
|
||||
'--prefer-offline=false',
|
||||
'--offline=false',
|
||||
'--fetch-retries=0',
|
||||
],
|
||||
{ env, signal, stdio: 'pipe', timeout: 30_000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('selects the newest matching version from range metadata without relying on output order', async () => {
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '["3.9.0","3.10.0","3.8.0"]' } as never);
|
||||
await expect(resolveSimulatorPackageVersionAsync('appium', '^3', { env: {} })).resolves.toBe(
|
||||
'3.10.0'
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['not json', '{}', '[]', '"latest"'])(
|
||||
'rejects unusable registry output (%s)',
|
||||
async stdout => {
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout } as never);
|
||||
await expect(
|
||||
resolveSimulatorPackageVersionAsync('serve-sim', 'latest', { env: {} })
|
||||
).rejects.toThrow();
|
||||
}
|
||||
);
|
||||
|
||||
it('preserves registry lookup failures instead of trusting the prepared version', async () => {
|
||||
const error = new Error('registry unavailable');
|
||||
jest.mocked(spawn).mockRejectedValue(error);
|
||||
await expect(resolveSimulatorPackageVersionAsync('argent', 'latest', { env: {} })).rejects.toBe(
|
||||
error
|
||||
);
|
||||
});
|
||||
|
||||
it('does not look up a cancelled request', async () => {
|
||||
const signal = AbortSignal.abort(new Error('cancelled'));
|
||||
await expect(
|
||||
resolveSimulatorPackageVersionAsync('serve-sim', 'latest', { env: {}, signal })
|
||||
).rejects.toThrow('cancelled');
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,9 +1,18 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
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 { runSimulatorLauncherAsync, stopWarmSimulatorAsync } from '../warmSimulator';
|
||||
import {
|
||||
readPreparedSimulatorToolAsync,
|
||||
runSimulatorLauncherAsync,
|
||||
stopWarmSimulatorAsync,
|
||||
} from '../warmSimulator';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('node:timers/promises');
|
||||
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
@@ -11,12 +20,86 @@ beforeEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '{"ok":true}' } as never);
|
||||
jest.spyOn(os, 'uptime').mockReturnValue(600);
|
||||
jest.spyOn(fs, 'access').mockRejectedValue(new Error('no startup script'));
|
||||
jest.mocked(sleepAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
});
|
||||
|
||||
it('reads different prepared tool schemas without Appium-specific fields', async () => {
|
||||
const directory = path.join(os.homedir(), 'Library', 'Caches', 'expo-simulator-startup');
|
||||
const appium = { home: path.join(directory, 'appium'), version: '3.9.0', xcuitest: '12.22.8' };
|
||||
const otherTool = { version: '2.4.0' };
|
||||
const expoGo = { sdk: '55.0.0', url: 'https://example.test/ExpoGo.tar.gz' };
|
||||
const read = jest
|
||||
.spyOn(fs, 'readFile')
|
||||
.mockResolvedValue(
|
||||
JSON.stringify({ tools: { appium, 'other-tool': otherTool, 'expo-go': expoGo } })
|
||||
);
|
||||
|
||||
await expect(
|
||||
readPreparedSimulatorToolAsync(
|
||||
'appium',
|
||||
z.object({ home: z.string(), version: z.string(), xcuitest: z.string() })
|
||||
)
|
||||
).resolves.toEqual(appium);
|
||||
const prepared = await readPreparedSimulatorToolAsync(
|
||||
'other-tool',
|
||||
z.object({ version: z.string() })
|
||||
);
|
||||
expect(prepared?.version).toBe('2.4.0');
|
||||
await expect(
|
||||
readPreparedSimulatorToolAsync('expo-go', z.object({ sdk: z.string(), url: z.string() }))
|
||||
).resolves.toEqual(expoGo);
|
||||
expect(read.mock.calls).toEqual([
|
||||
[path.join(directory, 'prepared.json'), 'utf8'],
|
||||
[path.join(directory, 'prepared.json'), 'utf8'],
|
||||
[path.join(directory, 'prepared.json'), 'utf8'],
|
||||
]);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['missing-tool', 'toString'])(
|
||||
'does not invent %s metadata from schema defaults or inherited properties',
|
||||
async tool => {
|
||||
jest.spyOn(fs, 'readFile').mockResolvedValue('{"tools":{}}');
|
||||
await expect(
|
||||
readPreparedSimulatorToolAsync(tool, z.unknown().default({ version: '1.2.3' }))
|
||||
).resolves.toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['missing', 'invalid JSON', 'invalid fields', 'missing tool', 'older format'])(
|
||||
'ignores prepared tool metadata with %s',
|
||||
async failure => {
|
||||
const read = jest.spyOn(fs, 'readFile');
|
||||
switch (failure) {
|
||||
case 'missing':
|
||||
read.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }));
|
||||
break;
|
||||
case 'invalid JSON':
|
||||
read.mockResolvedValue('not JSON');
|
||||
break;
|
||||
case 'invalid fields':
|
||||
read.mockResolvedValue(JSON.stringify({ tools: { tool: { version: 42 } } }));
|
||||
break;
|
||||
case 'missing tool':
|
||||
read.mockResolvedValue('{"tools":{}}');
|
||||
break;
|
||||
case 'older format':
|
||||
read.mockResolvedValue('{"appium":"3.9.0","xcuitest":"12.22.8","home":"/old/home"}');
|
||||
break;
|
||||
}
|
||||
await expect(
|
||||
readPreparedSimulatorToolAsync('tool', z.object({ version: z.string() }))
|
||||
).resolves.toBeUndefined();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('runs the launcher version that start recorded and returns its output', async () => {
|
||||
jest.spyOn(fs, 'readFile').mockResolvedValue('{"launcherVersion":"27.1.0"}');
|
||||
await expect(runSimulatorLauncherAsync(['claim', 'serve-sim'], { env: {} })).resolves.toBe(
|
||||
@@ -59,3 +142,79 @@ it('does nothing on other platforms', async () => {
|
||||
await stopWarmSimulatorAsync({});
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const missing = Object.assign(new Error('missing'), { code: 'ENOENT' });
|
||||
const warmEnv = { EAS_USE_WARM_SIMULATOR: '1', EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' };
|
||||
|
||||
it('waits for startup to write state, then claims with the recorded launcher version', async () => {
|
||||
jest.mocked(os.uptime).mockReturnValue(10);
|
||||
jest.mocked(fs.access).mockResolvedValue();
|
||||
jest
|
||||
.spyOn(fs, 'readFile')
|
||||
.mockRejectedValueOnce(missing)
|
||||
.mockRejectedValueOnce(missing)
|
||||
.mockResolvedValue('{"launcherVersion":"27.6.0"}');
|
||||
await runSimulatorLauncherAsync(['claim', 'serve-sim'], { env: warmEnv });
|
||||
expect(sleepAsync).toHaveBeenCalled();
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['x', '@expo/[email protected]', 'claim', 'serve-sim'],
|
||||
expect.objectContaining({ env: warmEnv })
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a later session request', ['claim', 'serve-sim'], warmEnv, 300, true],
|
||||
['a system without startup', ['claim', 'serve-sim'], warmEnv, 10, false],
|
||||
['a job without warmup', ['claim', 'serve-sim'], {}, 10, true],
|
||||
['stop', ['stop'], warmEnv, 10, true],
|
||||
['prepare-tools', ['prepare-tools'], warmEnv, 10, true],
|
||||
])('does not wait for %s', async (_name, args, env, uptime, installed) => {
|
||||
jest.mocked(os.uptime).mockReturnValue(uptime);
|
||||
if (installed) {
|
||||
jest.mocked(fs.access).mockResolvedValue();
|
||||
}
|
||||
jest.spyOn(fs, 'readFile').mockRejectedValue(missing);
|
||||
await runSimulatorLauncherAsync(args, { env });
|
||||
expect(sleepAsync).not.toHaveBeenCalled();
|
||||
expect(spawn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('times out before invoking claim so its caller can stop warmup before starting cold', async () => {
|
||||
jest.mocked(os.uptime).mockReturnValue(10);
|
||||
jest.mocked(fs.access).mockResolvedValue();
|
||||
jest.spyOn(fs, 'readFile').mockRejectedValue(missing);
|
||||
let now = 0;
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
jest.mocked(sleepAsync).mockImplementation(async delay => {
|
||||
now += delay ?? 0;
|
||||
});
|
||||
await expect(runSimulatorLauncherAsync(['claim', 'serve-sim'], { env: warmEnv })).rejects.toThrow(
|
||||
'Timed out waiting for the warm Simulator launcher to start.'
|
||||
);
|
||||
expect(now).toBe(60_000);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
await stopWarmSimulatorAsync(warmEnv);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['x', '@expo/simulator-launcher@latest', 'stop'],
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('cancels the missing-state wait without invoking claim', async () => {
|
||||
jest.mocked(os.uptime).mockReturnValue(10);
|
||||
jest.mocked(fs.access).mockResolvedValue();
|
||||
jest.spyOn(fs, 'readFile').mockRejectedValue(missing);
|
||||
const controller = new AbortController();
|
||||
jest.mocked(sleepAsync).mockImplementation(async () => {
|
||||
controller.abort(new Error('cancelled'));
|
||||
});
|
||||
await expect(
|
||||
runSimulatorLauncherAsync(['claim', 'serve-sim'], {
|
||||
env: warmEnv,
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toThrow('cancelled');
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from 'node:fs';
|
||||
import { type FileHandle } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { type Readable, Writable } from 'node:stream';
|
||||
import { finished } from 'node:stream/promises';
|
||||
|
||||
@@ -15,108 +16,132 @@ export type FinalizedAndroidEmulatorLog = {
|
||||
readonly bytesSeen: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* One file is a ring of `maxBytes` bytes: `writePos` wraps and the newest
|
||||
* `retained` bytes end at it. Sealing rewrites the file in log order.
|
||||
*/
|
||||
type Capture = {
|
||||
export type AndroidLogcatOutput = {
|
||||
/** Where the emulator writes its logcat: a FIFO for sessions, the file itself for builds. */
|
||||
path: string;
|
||||
close: () => void;
|
||||
/**
|
||||
* Resolves once the pipe reader exited and the FIFO is removed. Emulator
|
||||
* launch does not wait for it, because the emulator can outlive log
|
||||
* collection; tests use it to observe the cleanup.
|
||||
*/
|
||||
closed: Promise<void>;
|
||||
};
|
||||
|
||||
/** Records emulator output per launch file; `getAndroidEmulatorLogCapture` picks the writer. */
|
||||
export type AndroidEmulatorLogCapture = {
|
||||
readonly sources: readonly AndroidEmulatorLogSource[];
|
||||
attach(input: Readable, filePath: string, source: AndroidEmulatorLogSource): void;
|
||||
finishFileAsync(filePath: string): Promise<void>;
|
||||
finishAsync(): Promise<readonly FinalizedAndroidEmulatorLog[]>;
|
||||
prepareLogcatOutputAsync(filePath: string): Promise<AndroidLogcatOutput>;
|
||||
};
|
||||
|
||||
/** One ring of `maxBytes` bytes: `writePos` wraps and the newest `retained` bytes end at it. */
|
||||
type Ring = {
|
||||
filePath: string;
|
||||
source: AndroidEmulatorLogSource;
|
||||
stream: Writable;
|
||||
inputs: Set<Readable>;
|
||||
done: Promise<void>;
|
||||
/** Runs one file operation after every earlier one on the same handle. */
|
||||
run: (operation: (handle: FileHandle) => Promise<void>) => Promise<void>;
|
||||
sealed?: Promise<void>;
|
||||
bytesSeen: number;
|
||||
writePos: number;
|
||||
retained: number;
|
||||
lastWrite: number;
|
||||
};
|
||||
|
||||
/** One emulator launch. Launches of a source can overlap: clones, or a late exit. */
|
||||
type Launch = {
|
||||
ring: Ring;
|
||||
stream: Writable;
|
||||
inputs: Set<Readable>;
|
||||
done: Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* One budget per workflow, shared by all emulator launches and retries.
|
||||
* One budget per session, shared by all emulator launches and retries.
|
||||
*
|
||||
* A source keeps the last `maxBytes` bytes it produced, across files. The
|
||||
* oldest bytes go first, so an earlier launch gives up its head to a later
|
||||
* one and the final attempt always keeps its tail, where failures explain
|
||||
* themselves. The least recently written file gives up bytes first, which
|
||||
* approximates byte age without tracking every chunk.
|
||||
*
|
||||
* Eviction is bookkeeping until a file loses all its bytes, which frees it
|
||||
* on disk at once. A partially evicted file keeps its size until it is
|
||||
* sealed, so one source occupies at most twice its budget while recording.
|
||||
* Each source has one ring file, `<source>.log` beside the launch files, so
|
||||
* the last `maxBytes` bytes of a source survive across launches and the tail
|
||||
* of the final attempt is always kept. A marker line names each launch that
|
||||
* produced output. Overlapping launches share the ring, so their lines can
|
||||
* interleave. Logcat arrives through a named pipe so the budget applies to it.
|
||||
*/
|
||||
export class AndroidEmulatorLogCapture {
|
||||
private readonly sources = new Map<AndroidEmulatorLogSource, Capture[]>();
|
||||
private readonly captures = new Map<string, Capture>();
|
||||
export class AndroidEmulatorLogRing implements AndroidEmulatorLogCapture {
|
||||
readonly sources: readonly AndroidEmulatorLogSource[];
|
||||
private readonly maxBytes: number;
|
||||
private readonly rings = new Map<AndroidEmulatorLogSource, Ring>();
|
||||
private readonly launches = new Map<string, Launch>();
|
||||
private finishing = false;
|
||||
private writes = 0;
|
||||
|
||||
constructor(private readonly maxBytes = MAX_ANDROID_LOG_BYTES_PER_SOURCE) {}
|
||||
constructor({
|
||||
sources,
|
||||
maxBytes = MAX_ANDROID_LOG_BYTES_PER_SOURCE,
|
||||
}: {
|
||||
sources: readonly AndroidEmulatorLogSource[];
|
||||
maxBytes?: number;
|
||||
}) {
|
||||
this.sources = sources;
|
||||
this.maxBytes = maxBytes;
|
||||
}
|
||||
|
||||
attach(input: Readable, filePath: string, source: AndroidEmulatorLogSource): void {
|
||||
if (this.finishing) {
|
||||
input.resume();
|
||||
return;
|
||||
}
|
||||
let capture = this.captures.get(filePath);
|
||||
if (!capture) {
|
||||
capture = this.open(filePath, source);
|
||||
this.captures.set(filePath, capture);
|
||||
const list = this.sources.get(source) ?? [];
|
||||
list.push(capture);
|
||||
this.sources.set(source, list);
|
||||
let launch = this.launches.get(filePath);
|
||||
if (!launch) {
|
||||
launch = this.startLaunch(filePath, this.ringFor(filePath, source));
|
||||
this.launches.set(filePath, launch);
|
||||
}
|
||||
capture.inputs.add(input);
|
||||
input.pipe(capture.stream, { end: false });
|
||||
launch.inputs.add(input);
|
||||
input.pipe(launch.stream, { end: false });
|
||||
}
|
||||
|
||||
async finishFileAsync(filePath: string): Promise<void> {
|
||||
const capture = this.captures.get(filePath);
|
||||
if (!capture) {
|
||||
const launch = this.launches.get(filePath);
|
||||
if (!launch) {
|
||||
return;
|
||||
}
|
||||
for (const input of capture.inputs) {
|
||||
input.unpipe(capture.stream);
|
||||
for (const input of launch.inputs) {
|
||||
input.unpipe(launch.stream);
|
||||
// Keep draining: closing a full log must never block or break the emulator.
|
||||
input.resume();
|
||||
}
|
||||
capture.inputs.clear();
|
||||
capture.stream.end();
|
||||
await capture.done;
|
||||
// Nothing can evict an uncapped file later, so release its handle now.
|
||||
if (!Number.isFinite(this.maxBytes)) {
|
||||
await this.sealAsync(capture);
|
||||
}
|
||||
launch.inputs.clear();
|
||||
launch.stream.end();
|
||||
await launch.done;
|
||||
}
|
||||
|
||||
async finishAsync(): Promise<readonly FinalizedAndroidEmulatorLog[]> {
|
||||
this.finishing = true;
|
||||
await Promise.all([...this.captures.keys()].map(file => this.finishFileAsync(file)));
|
||||
await Promise.all([...this.captures.values()].map(capture => this.sealAsync(capture)));
|
||||
await Promise.all([...this.launches.keys()].map(file => this.finishFileAsync(file)));
|
||||
await Promise.all([...this.rings.values()].map(ring => this.sealAsync(ring)));
|
||||
// Every writer is closed, so uploads can reopen these files on each retry.
|
||||
return [...this.captures].map(([filePath, { source, bytesSeen }]) => ({
|
||||
return [...this.rings.values()].map(({ filePath, source, bytesSeen }) => ({
|
||||
filePath,
|
||||
source,
|
||||
bytesSeen,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Select the output transport without exposing the capture budget to emulator launch. */
|
||||
async prepareLogcatOutputAsync(
|
||||
filePath: string
|
||||
): Promise<{ path: string; close: () => void; closed: Promise<void> }> {
|
||||
// Ordinary builds retain the emulator's direct, uncapped logcat file writer.
|
||||
if (!Number.isFinite(this.maxBytes)) {
|
||||
return { path: filePath, close: () => {}, closed: Promise.resolve() };
|
||||
}
|
||||
async prepareLogcatOutputAsync(filePath: string): Promise<AndroidLogcatOutput> {
|
||||
const pipe = await createAndroidLogcatPipeAsync(`${filePath}.pipe`);
|
||||
this.attach(pipe.stream, filePath, 'device-logcat');
|
||||
const closed = pipe.closed.then(() => this.finishFileAsync(filePath)).catch(() => {});
|
||||
return { path: pipe.path, close: pipe.close, closed };
|
||||
}
|
||||
|
||||
private open(filePath: string, source: AndroidEmulatorLogSource): Capture {
|
||||
private ringFor(filePath: string, source: AndroidEmulatorLogSource): Ring {
|
||||
let ring = this.rings.get(source);
|
||||
if (!ring) {
|
||||
ring = this.open(path.join(path.dirname(filePath), `${source}.log`), source);
|
||||
this.rings.set(source, ring);
|
||||
}
|
||||
return ring;
|
||||
}
|
||||
|
||||
private open(filePath: string, source: AndroidEmulatorLogSource): Ring {
|
||||
const file = fs.promises.open(filePath, 'w+');
|
||||
// Handle open errors even if the process never produces output.
|
||||
void file.catch(() => {});
|
||||
@@ -142,65 +167,47 @@ export class AndroidEmulatorLogCapture {
|
||||
.catch(report);
|
||||
return queue;
|
||||
};
|
||||
const record = {
|
||||
source,
|
||||
inputs: new Set<Readable>(),
|
||||
run,
|
||||
bytesSeen: 0,
|
||||
writePos: 0,
|
||||
retained: 0,
|
||||
lastWrite: 0,
|
||||
};
|
||||
return { filePath, source, run, bytesSeen: 0, writePos: 0, retained: 0 };
|
||||
}
|
||||
|
||||
private startLaunch(filePath: string, ring: Ring): Launch {
|
||||
let marked = false;
|
||||
const stream = new Writable({
|
||||
write: (chunk: Buffer, _encoding, callback) => {
|
||||
record.bytesSeen += chunk.length;
|
||||
if (failed) {
|
||||
callback();
|
||||
return;
|
||||
if (!marked) {
|
||||
marked = true;
|
||||
// Written before the first chunk only, so a silent launch leaves no trace.
|
||||
const name = path.basename(filePath, '.log');
|
||||
const marker = `==== ${name} ${new Date().toISOString()} ====\n`;
|
||||
void this.write(ring, Buffer.from(ring.bytesSeen > 0 ? `\n${marker}` : marker));
|
||||
}
|
||||
const max = this.maxBytes;
|
||||
const data = chunk.length > max ? chunk.subarray(chunk.length - max) : chunk;
|
||||
const position = record.writePos;
|
||||
const head = Math.min(data.length, max - position);
|
||||
// Account before awaiting I/O so concurrent launches share the same cap.
|
||||
record.writePos = (position + data.length) % max;
|
||||
record.retained = Math.min(record.retained + data.length, max);
|
||||
record.lastWrite = ++this.writes;
|
||||
this.evict(source);
|
||||
void run(async handle => {
|
||||
await writeAtAsync(handle, data.subarray(0, head), position);
|
||||
await writeAtAsync(handle, data.subarray(head), 0);
|
||||
}).then(() => callback());
|
||||
void this.write(ring, chunk).then(() => callback());
|
||||
},
|
||||
});
|
||||
return Object.assign(record, { stream, done: finished(stream) });
|
||||
return { ring, stream, inputs: new Set(), done: finished(stream) };
|
||||
}
|
||||
|
||||
/** Drop the oldest bytes of a source until its files fit the budget together. */
|
||||
private evict(source: AndroidEmulatorLogSource): void {
|
||||
const list = [...(this.sources.get(source) ?? [])].sort((a, b) => a.lastWrite - b.lastWrite);
|
||||
let excess = list.reduce((sum, capture) => sum + capture.retained, 0) - this.maxBytes;
|
||||
for (const capture of list) {
|
||||
if (excess <= 0) {
|
||||
return;
|
||||
}
|
||||
const dropped = Math.min(capture.retained, excess);
|
||||
capture.retained -= dropped;
|
||||
excess -= dropped;
|
||||
if (dropped > 0 && capture.retained === 0) {
|
||||
// Nothing of this file survives: free the disk after its queued writes land.
|
||||
capture.writePos = 0;
|
||||
void capture.run(handle => handle.truncate(0));
|
||||
}
|
||||
}
|
||||
/** Account synchronously, then queue the I/O, so overlapping launches share one ring consistently. */
|
||||
private write(ring: Ring, chunk: Buffer): Promise<void> {
|
||||
const max = this.maxBytes;
|
||||
ring.bytesSeen += chunk.length;
|
||||
const data = chunk.length > max ? chunk.subarray(chunk.length - max) : chunk;
|
||||
const position = ring.writePos;
|
||||
const head = Math.min(data.length, max - position);
|
||||
ring.writePos = (position + data.length) % max;
|
||||
ring.retained = Math.min(ring.retained + data.length, max);
|
||||
return ring.run(async handle => {
|
||||
await writeAtAsync(handle, data.subarray(0, head), position);
|
||||
await writeAtAsync(handle, data.subarray(head), 0);
|
||||
});
|
||||
}
|
||||
|
||||
/** Rewrite the ring in log order, drop evicted bytes, and close the file. */
|
||||
private sealAsync(capture: Capture): Promise<void> {
|
||||
capture.sealed ??= capture.run(async handle => {
|
||||
/** Rewrite the ring in log order, drop the overwritten bytes, and close the file. */
|
||||
private sealAsync(ring: Ring): Promise<void> {
|
||||
ring.sealed ??= ring.run(async handle => {
|
||||
try {
|
||||
const { retained } = capture;
|
||||
let start = capture.writePos - retained;
|
||||
const { retained } = ring;
|
||||
let start = ring.writePos - retained;
|
||||
if (start < 0) {
|
||||
start += this.maxBytes;
|
||||
}
|
||||
@@ -216,7 +223,7 @@ export class AndroidEmulatorLogCapture {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
return capture.sealed;
|
||||
return ring.sealed;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import fs from 'node:fs';
|
||||
import { type Readable } from 'node:stream';
|
||||
import { finished } from 'node:stream/promises';
|
||||
|
||||
import { Sentry } from '../sentry';
|
||||
import {
|
||||
type AndroidEmulatorLogCapture,
|
||||
type AndroidLogcatOutput,
|
||||
type FinalizedAndroidEmulatorLog,
|
||||
} from './androidEmulatorLogCapture';
|
||||
import { type AndroidEmulatorLogSource } from './androidEmulatorLogSources';
|
||||
|
||||
type LaunchFile = { stream: fs.WriteStream; inputs: Set<Readable> };
|
||||
|
||||
/**
|
||||
* Ordinary builds: one plain file per launch, appended without a budget and
|
||||
* closed when the launch ends. The emulator writes logcat to its file directly.
|
||||
* Nothing is finalized or uploaded.
|
||||
*/
|
||||
export class AndroidEmulatorLogFiles implements AndroidEmulatorLogCapture {
|
||||
readonly sources: readonly AndroidEmulatorLogSource[];
|
||||
private readonly files = new Map<string, LaunchFile>();
|
||||
|
||||
constructor({ sources }: { sources: readonly AndroidEmulatorLogSource[] }) {
|
||||
this.sources = sources;
|
||||
}
|
||||
|
||||
attach(input: Readable, filePath: string, source: AndroidEmulatorLogSource): void {
|
||||
let file = this.files.get(filePath);
|
||||
if (!file) {
|
||||
const inputs = new Set<Readable>();
|
||||
const stream = fs.createWriteStream(filePath, { flags: 'a' });
|
||||
stream.once('error', err => {
|
||||
Sentry.capture('Failed to write Android diagnostic log', err, {
|
||||
level: 'warning',
|
||||
extras: { filePath, source },
|
||||
});
|
||||
// The pipe detaches on error; keep draining so the emulator never blocks.
|
||||
for (const attached of inputs) {
|
||||
attached.resume();
|
||||
}
|
||||
});
|
||||
file = { stream, inputs };
|
||||
this.files.set(filePath, file);
|
||||
}
|
||||
file.inputs.add(input);
|
||||
input.pipe(file.stream, { end: false });
|
||||
}
|
||||
|
||||
async finishFileAsync(filePath: string): Promise<void> {
|
||||
const file = this.files.get(filePath);
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
for (const input of file.inputs) {
|
||||
input.unpipe(file.stream);
|
||||
input.resume();
|
||||
}
|
||||
file.inputs.clear();
|
||||
file.stream.end();
|
||||
// A write error was reported when it happened.
|
||||
await finished(file.stream).catch(() => {});
|
||||
}
|
||||
|
||||
async finishAsync(): Promise<readonly FinalizedAndroidEmulatorLog[]> {
|
||||
await Promise.all([...this.files.keys()].map(file => this.finishFileAsync(file)));
|
||||
return [];
|
||||
}
|
||||
|
||||
async prepareLogcatOutputAsync(filePath: string): Promise<AndroidLogcatOutput> {
|
||||
await fs.promises.writeFile(filePath, '');
|
||||
return { path: filePath, close: () => {}, closed: Promise.resolve() };
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,26 @@
|
||||
import { type EnvironmentSecret, EnvironmentSecretType } from '@expo/eas-build-job';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Returns the value of the string environment secret with this name, or null. It resolves
|
||||
* duplicate names like the build context env does (the last secret wins). A file secret has no
|
||||
* string value.
|
||||
*/
|
||||
export function getStringEnvironmentSecretValue(
|
||||
environmentSecrets: EnvironmentSecret[] | undefined,
|
||||
name: string
|
||||
): string | null {
|
||||
const secret = Array.from(environmentSecrets ?? [])
|
||||
.reverse()
|
||||
.find(secret => secret.name === name);
|
||||
if (!secret || secret.type !== EnvironmentSecretType.STRING || !secret.value) {
|
||||
return null;
|
||||
}
|
||||
return secret.value;
|
||||
}
|
||||
|
||||
export function createTemporaryEnvironmentSecretFile({
|
||||
secretsDir,
|
||||
name,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { type BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
const PackageNames = {
|
||||
'serve-sim': '@expo/serve-sim',
|
||||
'agent-device': 'agent-device',
|
||||
argent: '@swmansion/argent',
|
||||
appium: 'appium',
|
||||
};
|
||||
|
||||
/** Resolve an explicit request once, so comparison and installation use the same version. */
|
||||
export async function resolveSimulatorPackageVersionAsync(
|
||||
engine: keyof typeof PackageNames,
|
||||
requested: string,
|
||||
{ env, signal }: { env: BuildStepEnv; signal?: AbortSignal }
|
||||
): Promise<string> {
|
||||
signal?.throwIfAborted();
|
||||
const exact = semver.parse(requested);
|
||||
if (exact) {
|
||||
return exact.version + (exact.build.length ? `+${exact.build.join('.')}` : '');
|
||||
}
|
||||
// Use npm's configured registry and revalidate metadata instead of trusting a cached dist-tag.
|
||||
const { stdout } = await spawn(
|
||||
'npm',
|
||||
[
|
||||
'view',
|
||||
`${PackageNames[engine]}@${requested}`,
|
||||
'version',
|
||||
'--json',
|
||||
'--prefer-online',
|
||||
'--prefer-offline=false',
|
||||
'--offline=false',
|
||||
'--fetch-retries=0',
|
||||
],
|
||||
{ env, signal, stdio: 'pipe', timeout: 30_000 }
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
const version = z.string().refine(value => semver.valid(value) !== null);
|
||||
const versions = z.union([version, z.array(version)]).parse(JSON.parse(stdout));
|
||||
const candidates = typeof versions === 'string' ? [versions] : versions;
|
||||
const resolved = semver.rsort(candidates)[0];
|
||||
if (!resolved) {
|
||||
throw new Error(`Could not resolve the requested ${engine} package version.`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { type BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
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 { type ZodType, z } from 'zod';
|
||||
|
||||
import {
|
||||
PackageManager,
|
||||
@@ -8,6 +12,28 @@ import {
|
||||
resolvePackageExec,
|
||||
} from './packageManager';
|
||||
|
||||
/** Read one tool's prepared metadata; its consumer defines the schema and compatibility checks. */
|
||||
export async function readPreparedSimulatorToolAsync<T>(
|
||||
tool: string,
|
||||
schema: ZodType<T>
|
||||
): Promise<T | undefined> {
|
||||
const manifestPath = path.join(
|
||||
os.homedir(),
|
||||
'Library',
|
||||
'Caches',
|
||||
'expo-simulator-startup',
|
||||
'prepared.json'
|
||||
);
|
||||
try {
|
||||
const manifest = z
|
||||
.object({ tools: z.record(z.string(), z.unknown()) })
|
||||
.parse(JSON.parse(await fs.readFile(manifestPath, 'utf8')));
|
||||
return Object.hasOwn(manifest.tools, tool) ? schema.parse(manifest.tools[tool]) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a Simulator launcher command with the version that `start` recorded, which warmup cached.
|
||||
* The output can contain credentials, so it is returned and never logged.
|
||||
@@ -16,10 +42,29 @@ export async function runSimulatorLauncherAsync(
|
||||
launcherArgs: string[],
|
||||
{ env, signal }: { env: BuildStepEnv; signal?: AbortSignal }
|
||||
): Promise<string> {
|
||||
const version = await fs
|
||||
signal?.throwIfAborted();
|
||||
const contents = await fs
|
||||
.readFile('/tmp/expo-simulator-startup.json', 'utf8')
|
||||
.then(text => JSON.parse(text).launcherVersion)
|
||||
.catch(() => undefined);
|
||||
.catch(async error => {
|
||||
if (
|
||||
error.code === 'ENOENT' &&
|
||||
launcherArgs[0] === 'claim' &&
|
||||
env.EAS_USE_WARM_SIMULATOR === '1' &&
|
||||
process.platform === 'darwin' &&
|
||||
os.uptime() < 300 &&
|
||||
(await fs.access('/usr/local/share/vm-tools/startup.sh').then(
|
||||
() => true,
|
||||
() => false
|
||||
))
|
||||
) {
|
||||
return await waitForLauncherStateFileAsync(signal);
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
let version: unknown;
|
||||
try {
|
||||
version = JSON.parse(contents ?? '{}').launcherVersion;
|
||||
} catch {}
|
||||
const { command, args } = resolvePackageExec(
|
||||
resolveConfiguredPackageManager(env, PackageManager.BUN),
|
||||
[
|
||||
@@ -31,6 +76,27 @@ export async function runSimulatorLauncherAsync(
|
||||
return stdout;
|
||||
}
|
||||
|
||||
/** A session request can arrive before startup writes launcher state. */
|
||||
async function waitForLauncherStateFileAsync(signal?: AbortSignal): Promise<string> {
|
||||
const deadline = Date.now() + 60_000;
|
||||
for (;;) {
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
return await fs.readFile('/tmp/expo-simulator-startup.json', 'utf8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) {
|
||||
// The claim caller must still stop warmup before it can start cold.
|
||||
throw new Error('Timed out waiting for the warm Simulator launcher to start.');
|
||||
}
|
||||
await sleepAsync(Math.min(250, remaining), undefined, { signal });
|
||||
}
|
||||
}
|
||||
|
||||
/** Stops warmup; resolves only after the launcher confirms cleanup. */
|
||||
export async function stopWarmSimulatorAsync(env: BuildStepEnv): Promise<void> {
|
||||
if (process.platform === 'darwin') {
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# create-eas-build-function
|
||||
|
||||
## 27.7.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.7.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.6.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.5.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "27.4.1",
|
||||
"version": "27.7.1",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "27.4.1"
|
||||
"@expo/steps": "27.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# @expo/downloader
|
||||
|
||||
## 27.7.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.7.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.6.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.5.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "27.4.1",
|
||||
"version": "27.7.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# eas-build-cache-provider
|
||||
|
||||
## 27.7.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.7.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.6.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.5.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "27.4.1",
|
||||
"version": "27.7.1",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# @expo/eas-build-job
|
||||
|
||||
## 27.7.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Update `node-forge`, `tar`, `nanoid`, `minimatch`, `joi`, `ajv`, `yaml`, and `zod` to versions with fixes for published security advisories. ([#4847](https://github.com/expo/eas-cli/pull/4847) by [@tchayen](https://github.com/tchayen))
|
||||
|
||||
## 27.7.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.6.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.5.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "27.4.1",
|
||||
"version": "27.7.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,12 +25,12 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "27.4.1",
|
||||
"@expo/logger": "27.7.1",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "27.4.1",
|
||||
"joi": "^17.13.1",
|
||||
"@expo/turtle-spawn": "27.7.1",
|
||||
"joi": "^17.13.9",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.12",
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# eas-cli
|
||||
|
||||
## 27.7.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Update `node-forge`, `tar`, `nanoid`, `minimatch`, `joi`, `ajv`, `yaml`, and `zod` to versions with fixes for published security advisories. ([#4847](https://github.com/expo/eas-cli/pull/4847) by [@tchayen](https://github.com/tchayen))
|
||||
|
||||
## 27.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add `--git-branch` and `--git-commit-hash` to `eas workflow:run` to record the branch and commit of the uploaded project. ([#4815](https://github.com/expo/eas-cli/pull/4815) by [@hSATAC](https://github.com/hSATAC))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stop spinners from flickering when their text wraps onto a second line, such as the `simulator:start` queue message in a narrow terminal. ([#4850](https://github.com/expo/eas-cli/pull/4850) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## 27.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add the `--property key=value` flag to `eas observe:events` to filter events by a property value. ([#4812](https://github.com/expo/eas-cli/pull/4812) by [@Ubax](https://github.com/Ubax))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stop suggesting other event names in `eas observe:events` when an event name with `--session-id`, `--app-version`, `--build-number`, or `--update-id` returns no events. ([#4813](https://github.com/expo/eas-cli/pull/4813) by [@Ubax](https://github.com/Ubax))
|
||||
|
||||
## 27.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Import ordered action configurations with `eas simulator:control --import` and show progress for each action. ([#4686](https://github.com/expo/eas-cli/pull/4686) by [@gwdp](https://github.com/gwdp))
|
||||
- Reuse simulator control connections with `--keep-alive` or `simulator:control:connection open`. Set an idle timeout, inspect the connection, or close it without stopping the simulator session. Keep-alive currently supports macOS and Linux. ([#4791](https://github.com/expo/eas-cli/pull/4791) by [@gwdp](https://github.com/gwdp))
|
||||
- Start simulator sessions from saved settings and apply control actions after readiness. ([#4687](https://github.com/expo/eas-cli/pull/4687) by [@gwdp](https://github.com/gwdp))
|
||||
- Run `eas simulator` commands also as `eas simulators`, `eas emulator` and `eas emulators`, for example `eas emulators:start` or `eas simulators:list`. These aliases are hidden from help. ([#4794](https://github.com/expo/eas-cli/pull/4794) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- List `eas workflow:ssh` in `eas --help`. ([#4793](https://github.com/expo/eas-cli/pull/4793) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Find the personal account by its owner instead of assuming that its name equals the username. ([#3922](https://github.com/expo/eas-cli/pull/3922) by [@byronkarlen](https://github.com/byronkarlen))
|
||||
- Show the Expo Simulators hint after a successful build from November 12 instead of November 10. ([#4817](https://github.com/expo/eas-cli/pull/4817) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
|
||||
## 27.4.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
+410
-243
File diff suppressed because it is too large.
Load diff
Generated
+250
-39
@@ -1907,6 +1907,18 @@
|
||||
"name": "environmentSecrets",
|
||||
"description": "Environment secrets for an account",
|
||||
"args": [
|
||||
{
|
||||
"name": "excludeProductionOnly",
|
||||
"description": "Leave out variables whose only environment is production. In an account with environment\nvariable protection, a member without the Release Manager role may not read them, and the\nrequest fails unless it leaves them out.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "filterNames",
|
||||
"description": null,
|
||||
@@ -1951,7 +1963,20 @@
|
||||
{
|
||||
"name": "environmentVariableEnvironments",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"args": [
|
||||
{
|
||||
"name": "excludeProductionOnly",
|
||||
"description": "Leave out variables whose only environment is production. In an account with environment\nvariable protection, a member without the Release Manager role may not read them, and the\nrequest fails unless it leaves them out.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
@@ -2004,6 +2029,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "excludeProductionOnly",
|
||||
"description": "Leave out variables whose only environment is production. In an account with environment\nvariable protection, a member without the Release Manager role may not read them, and the\nrequest fails unless it leaves them out.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "filterNames",
|
||||
"description": null,
|
||||
@@ -2061,6 +2098,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "excludeProductionOnly",
|
||||
"description": "Leave out variables whose only environment is production. In an account with environment\nvariable protection, a member without the Release Manager role may not read them, and the\nrequest fails unless it leaves them out.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "filterNames",
|
||||
"description": null,
|
||||
@@ -4876,7 +4925,7 @@
|
||||
},
|
||||
{
|
||||
"name": "setEnvironmentVariableProtectionEnabled",
|
||||
"description": "Set whether environment variable protection is on for this account. When it is on, reading,\nusing, creating, changing, or deleting an environment variable whose environments are\nexclusively production needs the PUBLISH_PROTECTED permission on the account, whatever its\nvisibility, in every project of the account. Requires the ADMIN permission on the account.\nTurning protection off also requires a sudo session.",
|
||||
"description": "Set whether environment variable protection is on for this account. When it is on, reading,\nusing, creating, changing, or deleting an environment variable whose environments are\nexclusively production needs the PUBLISH_PROTECTED permission on the account, whatever its\nvisibility, in every project of the account. Requires the ADMIN permission on the account.",
|
||||
"args": [
|
||||
{
|
||||
"name": "accountID",
|
||||
@@ -11640,6 +11689,18 @@
|
||||
"name": "environmentSecrets",
|
||||
"description": "Environment secrets for an app",
|
||||
"args": [
|
||||
{
|
||||
"name": "excludeProductionOnly",
|
||||
"description": "Leave out variables whose only environment is production. In an account with environment\nvariable protection, a member without the Release Manager role may not read them, and the\nrequest fails unless it leaves them out.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "filterNames",
|
||||
"description": null,
|
||||
@@ -11684,7 +11745,20 @@
|
||||
{
|
||||
"name": "environmentVariableEnvironments",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"args": [
|
||||
{
|
||||
"name": "excludeProductionOnly",
|
||||
"description": "Leave out variables whose only environment is production. In an account with environment\nvariable protection, a member without the Release Manager role may not read them, and the\nrequest fails unless it leaves them out.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
@@ -11721,6 +11795,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "excludeProductionOnly",
|
||||
"description": "Leave out variables whose only environment is production. In an account with environment\nvariable protection, a member without the Release Manager role may not read them, and the\nrequest fails unless it leaves them out.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "filterNames",
|
||||
"description": null,
|
||||
@@ -11778,6 +11864,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "excludeProductionOnly",
|
||||
"description": "Leave out variables whose only environment is production. In an account with environment\nvariable protection, a member without the Release Manager role may not read them, and the\nrequest fails unless it leaves them out.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "filterNames",
|
||||
"description": null,
|
||||
@@ -20634,7 +20732,7 @@
|
||||
"fields": [
|
||||
{
|
||||
"name": "affectedSessions",
|
||||
"description": "Distinct sessions on this platform that hit any exception.",
|
||||
"description": "Distinct sessions on this platform that hit any exception, including installations outside the sampleRate sample.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -20650,7 +20748,7 @@
|
||||
},
|
||||
{
|
||||
"name": "affectedUsers",
|
||||
"description": "Distinct users on this platform that hit any exception.",
|
||||
"description": "Distinct users on this platform that hit any exception, including installations outside the sampleRate sample.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -20666,7 +20764,7 @@
|
||||
},
|
||||
{
|
||||
"name": "crashFreeSessions",
|
||||
"description": "Fraction (0..1) of this platform's active sessions without a fatal error.",
|
||||
"description": "Fraction (0..1) of this platform's active sessions without a fatal error. Counts only installations in the sampleRate sample.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -20682,7 +20780,7 @@
|
||||
},
|
||||
{
|
||||
"name": "crashFreeUsers",
|
||||
"description": "Fraction (0..1) of this platform's active users without a fatal error.",
|
||||
"description": "Fraction (0..1) of this platform's active users without a fatal error. Counts only installations in the sampleRate sample.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -20796,7 +20894,7 @@
|
||||
"fields": [
|
||||
{
|
||||
"name": "affectedSessions",
|
||||
"description": "Distinct sessions that hit any exception.",
|
||||
"description": "Distinct sessions that hit any exception, including installations outside the sampleRate sample.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -20812,7 +20910,7 @@
|
||||
},
|
||||
{
|
||||
"name": "affectedUsers",
|
||||
"description": "Distinct users that hit any exception.",
|
||||
"description": "Distinct users that hit any exception, including installations outside the sampleRate sample that send errors because of errorSampleRate.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -20828,7 +20926,7 @@
|
||||
},
|
||||
{
|
||||
"name": "crashFreeSessions",
|
||||
"description": "Fraction (0..1) of active sessions without a fatal error. Denominator spans app_metrics and app_events.",
|
||||
"description": "Fraction (0..1) of active sessions without a fatal error. Counts only installations in the sampleRate sample. Denominator spans app_metrics and app_events.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -20844,7 +20942,7 @@
|
||||
},
|
||||
{
|
||||
"name": "crashFreeUsers",
|
||||
"description": "Fraction (0..1) of active users without a fatal error.",
|
||||
"description": "Fraction (0..1) of active users without a fatal error. Counts only installations in the sampleRate sample.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -26227,7 +26325,7 @@
|
||||
},
|
||||
{
|
||||
"name": "crashFreeSessions",
|
||||
"description": "Fraction (0..1) of active sessions without a fatal error.",
|
||||
"description": "Fraction (0..1) of active sessions without a fatal error. Counts only installations in the sampleRate sample.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -26243,7 +26341,7 @@
|
||||
},
|
||||
{
|
||||
"name": "crashFreeUsers",
|
||||
"description": "Fraction (0..1) of active users without a fatal error.",
|
||||
"description": "Fraction (0..1) of active users without a fatal error. Counts only installations in the sampleRate sample.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -26259,7 +26357,7 @@
|
||||
},
|
||||
{
|
||||
"name": "crashedUsers",
|
||||
"description": "Distinct users that hit a fatal error in range.",
|
||||
"description": "Distinct users that hit a fatal error in range, including installations outside the sampleRate sample.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -27930,6 +28028,26 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "scopes",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "statusCodes",
|
||||
"description": null,
|
||||
@@ -28380,6 +28498,22 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "scopeName",
|
||||
"description": "The instrumentation scope that produced the span (OTLP scopeSpans.scope.name), such as\nexpo-observe.network. Empty when the span was sent without a scope or stored before scopes were kept.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "sessionId",
|
||||
"description": null,
|
||||
@@ -29074,6 +29208,26 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "scopes",
|
||||
"description": "Spans produced by any of these instrumentation scopes, such as expo-observe.network.",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "sessionId",
|
||||
"description": null,
|
||||
@@ -47370,7 +47524,7 @@
|
||||
"inputFields": [
|
||||
{
|
||||
"name": "deviceIdentifier",
|
||||
"description": "AVD hardware profile id to use for the emulator (e.g. \"pixel_9\").",
|
||||
"description": "AVD hardware profile id to use for the emulator (e.g. \"pixel_9\"). Defaults to \"medium_phone\".",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
@@ -47380,26 +47534,6 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "logSources",
|
||||
"description": "Logs to record during the session and upload afterward: emulator-output and device-logcat. Defaults to\n[\"emulator-output\"]. An empty list disables recording and uploading both sources. Device-wide logcat can contain\ncredentials and personal data; include device-logcat only when you want to retain it.\nLogs are not redacted and use the existing 90-day artifact retention policy.",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "systemImagePackage",
|
||||
"description": "Android SDK system image package to use for the emulator (e.g.\n\"system-images;android-35;google_apis;x86_64\").",
|
||||
@@ -48811,7 +48945,7 @@
|
||||
"inputFields": [
|
||||
{
|
||||
"name": "deviceIdentifier",
|
||||
"description": "Simulator device name or UDID to use for the session (e.g. \"iPhone 16 Pro\").",
|
||||
"description": "Simulator device name or UDID to use for the session (e.g. \"iPhone 16 Pro\"). Defaults to\n\"iPhone 17\".",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
@@ -52317,6 +52451,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "deviceIdentifier",
|
||||
"description": "The simulator or emulator the session starts: the requested simulator device\nname or UDID on iOS, or the AVD hardware profile id on Android, or the platform\ndefault when none is requested. Null on sessions created before it was saved.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "finishedAt",
|
||||
"description": null,
|
||||
@@ -60766,9 +60912,13 @@
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
@@ -102811,12 +102961,67 @@
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "WorkflowRunGitInput",
|
||||
"description": "Git metadata for a run started from an uploaded project. The commit must exist in the\nproject's linked GitHub repository. The run records the branch and commit but is not\nlinked to the repository.",
|
||||
"fields": null,
|
||||
"inputFields": [
|
||||
{
|
||||
"name": "branch",
|
||||
"description": "Branch name such as \"main\". Stored as refs/heads/<branch>. Fully qualified refs are rejected.",
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "commitHash",
|
||||
"description": "Full commit SHA. The commit message is read from GitHub.",
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"interfaces": null,
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "WorkflowRunInput",
|
||||
"description": null,
|
||||
"fields": null,
|
||||
"inputFields": [
|
||||
{
|
||||
"name": "git",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "WorkflowRunGitInput",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "inputs",
|
||||
"description": null,
|
||||
@@ -103466,6 +103671,12 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "OBSERVE_NEW_ERROR",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "REPACK_EXPO_GO",
|
||||
"description": null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "27.4.1",
|
||||
"version": "27.7.1",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,12 +48,12 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "27.4.1",
|
||||
"@expo/eas-json": "27.4.1",
|
||||
"@expo/eas-build-job": "27.7.1",
|
||||
"@expo/eas-json": "27.7.1",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/experimentation": "0.0.5",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "27.4.1",
|
||||
"@expo/logger": "27.7.1",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -65,7 +65,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "27.4.1",
|
||||
"@expo/steps": "27.7.1",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -74,7 +74,7 @@
|
||||
"@urql/core": "4.0.11",
|
||||
"@urql/exchange-retry": "1.2.0",
|
||||
"agent-cli-detector": "0.1.7",
|
||||
"ajv": "8.11.0",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "2.1.1",
|
||||
"better-opn": "3.0.2",
|
||||
"bplist-parser": "^0.3.0",
|
||||
@@ -101,15 +101,15 @@
|
||||
"indent-string": "4.0.0",
|
||||
"invariant": "^2.2.2",
|
||||
"jks-js": "1.1.0",
|
||||
"joi": "17.11.0",
|
||||
"joi": "17.13.9",
|
||||
"keychain": "1.5.0",
|
||||
"log-symbols": "4.1.0",
|
||||
"mime": "3.0.0",
|
||||
"minimatch": "5.1.2",
|
||||
"minimatch": "5.1.9",
|
||||
"minizlib": "3.1.0",
|
||||
"nanoid": "3.3.8",
|
||||
"nanoid": "3.3.20",
|
||||
"node-fetch": "2.6.7",
|
||||
"node-forge": "1.4.0",
|
||||
"node-forge": "1.4.3",
|
||||
"node-stream-zip": "1.15.0",
|
||||
"nullthrows": "1.1.1",
|
||||
"open": "8.4.0",
|
||||
@@ -119,13 +119,14 @@
|
||||
"promise-limit": "2.7.0",
|
||||
"promise-retry": "2.0.1",
|
||||
"prompts": "2.4.2",
|
||||
"proper-lockfile": "4.1.2",
|
||||
"qrcode-terminal": "0.12.0",
|
||||
"resolve-from": "5.0.0",
|
||||
"sandbox-cli-detector": "0.2.0",
|
||||
"semver": "7.5.4",
|
||||
"set-interval-async": "3.0.3",
|
||||
"slash": "3.0.0",
|
||||
"tar": "7.5.19",
|
||||
"tar": "7.5.22",
|
||||
"tar-stream": "3.1.7",
|
||||
"terminal-link": "2.1.1",
|
||||
"ts-deepmerge": "6.2.0",
|
||||
@@ -135,8 +136,8 @@
|
||||
"uuid": "11.1.1",
|
||||
"wrap-ansi": "7.0.0",
|
||||
"ws": "8.21.1",
|
||||
"yaml": "2.6.0",
|
||||
"zod": "^4.1.3"
|
||||
"yaml": "2.9.1",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "6.3.1",
|
||||
@@ -159,6 +160,7 @@
|
||||
"@types/pngjs": "6.0.4",
|
||||
"@types/promise-retry": "1.1.3",
|
||||
"@types/prompts": "2.4.2",
|
||||
"@types/proper-lockfile": "4.1.4",
|
||||
"@types/semver": "7.5.6",
|
||||
"@types/tar": "6.1.10",
|
||||
"@types/tar-stream": "3.1.3",
|
||||
|
||||
@@ -6,18 +6,20 @@ const { createRequire } = require('node:module');
|
||||
const root = fs.realpathSync(process.argv[2]);
|
||||
const cli = path.join(root, 'packages/eas-cli');
|
||||
const cliRequire = createRequire(path.join(cli, 'package.json'));
|
||||
const { oclif } = cliRequire('./package.json');
|
||||
cliRequire('ts-node').register({ project: path.join(cli, 'tsconfig.json'), transpileOnly: true });
|
||||
const { Config } = cliRequire('@oclif/core');
|
||||
const { Config, run } = cliRequire('@oclif/core');
|
||||
const EasCommand = require(path.join(cli, 'src/commandUtils/EasCommand')).default;
|
||||
const Control = require(path.join(cli, 'src/commands/simulator/control')).default;
|
||||
const EasHelp = require(path.join(cli, 'src/help')).default;
|
||||
const cases = JSON.parse(process.argv[3]);
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'eas-help-lifecycle-'));
|
||||
const events = [];
|
||||
let commandArgs;
|
||||
globalThis.__helpLifecycleEvents = events;
|
||||
|
||||
// Replace command work (auth/device I/O), keeping discovery, parsing and hooks real.
|
||||
EasCommand.prototype.run = async function () {
|
||||
commandArgs = this.argv;
|
||||
events.push({ event: 'run', id: this.id });
|
||||
};
|
||||
EasCommand.prototype.finally = async function () {};
|
||||
@@ -31,14 +33,26 @@ EasCommand.prototype.finally = async function () {};
|
||||
version: '1.0.0',
|
||||
oclif: {
|
||||
bin: 'eas',
|
||||
commands: { strategy: 'explicit', target: './commands.cjs' },
|
||||
additionalHelpFlags: ['-h'],
|
||||
helpClass: oclif.helpClass,
|
||||
commands: './commands',
|
||||
hooks: { prerun: './prerun.cjs', postrun: './postrun.cjs' },
|
||||
},
|
||||
})
|
||||
);
|
||||
for (const command of ['simulator/control', 'simulator/index', 'account/login']) {
|
||||
const file = path.join(directory, 'commands', `${command}.js`);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
`exports.default = require(${JSON.stringify(path.join(cli, 'src/commands', command))}).default;`
|
||||
);
|
||||
}
|
||||
const helpFile = path.join(directory, `${oclif.helpClass}.js`);
|
||||
fs.mkdirSync(path.dirname(helpFile), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(directory, 'commands.cjs'),
|
||||
`exports.default = { 'simulator:control': require(${JSON.stringify(path.join(cli, 'src/commands/simulator/control'))}).default };`
|
||||
helpFile,
|
||||
`exports.default = require(${JSON.stringify(path.join(cli, 'src/help'))}).default;`
|
||||
);
|
||||
for (const event of ['prerun', 'postrun']) {
|
||||
fs.writeFileSync(
|
||||
@@ -48,13 +62,24 @@ EasCommand.prototype.finally = async function () {};
|
||||
}
|
||||
process.env.XDG_DATA_HOME = directory;
|
||||
process.env.XDG_CACHE_HOME = directory;
|
||||
const config = await Config.load({ root: directory });
|
||||
let config = await Config.load({ root: directory });
|
||||
if (process.argv[4] === 'manifest') {
|
||||
if (!config.rootPlugin.manifest.commands['simulator:control'].relativePath) {
|
||||
throw new Error('The manifest must exercise cached command module paths');
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(directory, 'oclif.manifest.json'),
|
||||
JSON.stringify(config.rootPlugin.manifest)
|
||||
);
|
||||
config = await Config.load({ root: directory });
|
||||
}
|
||||
const results = [];
|
||||
for (const args of cases) {
|
||||
// Published manifests discover metadata without loading the command class.
|
||||
// Clear eager discovery's assignment to exercise that cold-start boundary.
|
||||
delete Control.id;
|
||||
const start = events.length;
|
||||
commandArgs = undefined;
|
||||
const write = process.stdout.write;
|
||||
let stdout = '',
|
||||
error;
|
||||
@@ -65,13 +90,13 @@ EasCommand.prototype.finally = async function () {};
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
await new EasHelp(config).showHelp(args);
|
||||
await run(args, config);
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
process.stdout.write = write;
|
||||
}
|
||||
results.push({ stdout, events: events.slice(start), error });
|
||||
results.push({ stdout, events: events.slice(start), error, commandArgs });
|
||||
}
|
||||
console.log('LIFECYCLE_RESULT ' + JSON.stringify(results));
|
||||
} finally {
|
||||
|
||||
@@ -5,47 +5,99 @@ import { promisify } from 'node:util';
|
||||
const execFileAsync = promisify(execFile);
|
||||
const root = path.resolve(__dirname, '../../../..');
|
||||
const fixture = path.join(__dirname, 'fixtures/simulator-control-lifecycle.cjs');
|
||||
const modes = ['discovery', 'manifest'];
|
||||
const actionHelp = [
|
||||
['simulator:control', 'rotate', '--help'],
|
||||
['sim:control', 'screenshot.capture', '-h', '--id', 'session-123'],
|
||||
['simulator:control', '--id', 'session-123', '--help', 'rotate'],
|
||||
['simulators:control', 'rotate', '--help'],
|
||||
['emulator:control', 'rotate', '-h'],
|
||||
['emulators:control', 'rotate', '--help'],
|
||||
];
|
||||
const incompleteFlags = [
|
||||
const configHelp = [
|
||||
['simulator:control', 'screenshot.capture', '--help', '--output'],
|
||||
['sim:control', 'rotate', '-h', '--id'],
|
||||
['simulator:start', '--help'],
|
||||
['simulator', '--help'],
|
||||
['sim:start', '-h'],
|
||||
['sim', '-h'],
|
||||
['simulators:start', '--help'],
|
||||
['emulator:start', '-h'],
|
||||
['emulators:start', '--help'],
|
||||
['simulators', '--help'],
|
||||
['emulator', '-h'],
|
||||
['emulators', '--help'],
|
||||
['simulators:control', '--help'],
|
||||
['emulator:control', '-h'],
|
||||
['emulators:control', '--help'],
|
||||
['simulator:control', '--help'],
|
||||
['sim:control', '-h'],
|
||||
['--help', 'simulator:start'],
|
||||
['--help', 'help', 'simulator:start'],
|
||||
['simulator:start', '--import', 'does-not-exist.json', '--help'],
|
||||
['simulator:control', '--import', 'does-not-exist.json', '--help'],
|
||||
];
|
||||
let results: { stdout: string; events: unknown[]; error?: string }[];
|
||||
const ordinaryHelp = [['account:login', '--help'], ['--help']];
|
||||
const literalHelp = ['simulator:control', 'clipboard.write', '--', '--help'];
|
||||
let results: {
|
||||
stdout: string;
|
||||
events: unknown[];
|
||||
error?: string;
|
||||
commandArgs?: string[];
|
||||
}[][];
|
||||
|
||||
beforeAll(async () => {
|
||||
// Load TypeScript once, outside per-case timeouts, including on coverage runners.
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
[fixture, root, JSON.stringify([...actionHelp, ...incompleteFlags])],
|
||||
{ timeout: 20_000 }
|
||||
results = await Promise.all(
|
||||
modes.map(async mode => {
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
[
|
||||
fixture,
|
||||
root,
|
||||
JSON.stringify([...actionHelp, ...configHelp, ...ordinaryHelp, literalHelp]),
|
||||
mode,
|
||||
],
|
||||
{ timeout: 20_000 }
|
||||
);
|
||||
return JSON.parse(stdout.split('LIFECYCLE_RESULT ').at(-1)!);
|
||||
})
|
||||
);
|
||||
results = JSON.parse(stdout.split('LIFECYCLE_RESULT ').at(-1)!);
|
||||
}, 30_000);
|
||||
|
||||
describe('simulator action help through real oclif lookup and hooks', () => {
|
||||
it.each(actionHelp.map((argv, index) => ({ argv, index })))(
|
||||
'keeps canonical command identity and executes lifecycle hooks: $argv',
|
||||
({ index }) => {
|
||||
expect(results[index].error).toBeUndefined();
|
||||
expect(results[index].events).toEqual([
|
||||
describe.each(modes.map((mode, index) => ({ mode, index })))(
|
||||
'simulator help through real oclif routing ($mode)',
|
||||
({ index: mode }) => {
|
||||
it.each(actionHelp.map((argv, index) => ({ argv, index })))(
|
||||
'keeps command identity and lifecycle hooks for action help: $argv',
|
||||
({ index }) => {
|
||||
expect(results[mode][index].error).toBeUndefined();
|
||||
expect(results[mode][index].events).toEqual([
|
||||
{ event: 'prerun', id: 'simulator:control' },
|
||||
{ event: 'run', id: 'simulator:control' },
|
||||
{ event: 'postrun', id: 'simulator:control' },
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([...configHelp, ...ordinaryHelp].map((argv, index) => ({ argv, index })))(
|
||||
'prints help without executing a command: $argv',
|
||||
({ index }) => {
|
||||
const { stdout, events, error } = results[mode][index + actionHelp.length];
|
||||
expect(error).toBeUndefined();
|
||||
expect(stdout).toContain('USAGE');
|
||||
expect(events).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps --help after -- as a literal action value', () => {
|
||||
const result = results[mode].at(-1)!;
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.commandArgs).toEqual(['clipboard.write', '--', '--help']);
|
||||
expect(result.events).toEqual([
|
||||
{ event: 'prerun', id: 'simulator:control' },
|
||||
{ event: 'run', id: 'simulator:control' },
|
||||
{ event: 'postrun', id: 'simulator:control' },
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(incompleteFlags.map((argv, index) => ({ argv, index })))(
|
||||
'shows ordinary help without starting the command for an incomplete flag: $argv',
|
||||
({ index }) => {
|
||||
const { stdout, events, error } = results[index + actionHelp.length];
|
||||
expect(error).toBeUndefined();
|
||||
expect(stdout).toContain('USAGE');
|
||||
expect(events).toEqual([]);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -18,6 +18,9 @@ describe(EasHelp, () => {
|
||||
['simulator:control', 'rotate', '--help'],
|
||||
['sim:control', 'screenshot.capture', '-h', '--id', 'session-123'],
|
||||
['simulator:control', '--id', 'session-123', '--help', 'rotate'],
|
||||
['simulators:control', 'rotate', '--help'],
|
||||
['emulator:control', 'rotate', '-h'],
|
||||
['emulators:control', 'rotate', '--help'],
|
||||
])('routes action help through the command: %j', async (...argv) => {
|
||||
await new EasHelp(config).showHelp(argv);
|
||||
expect(run).toHaveBeenCalledWith('simulator:control', argv.slice(1));
|
||||
@@ -29,6 +32,7 @@ describe(EasHelp, () => {
|
||||
['simulator:control', 'screenshot.capture', '--help', '--output'],
|
||||
['sim:control', 'rotate', '-h', '--id'],
|
||||
['sim:control', '-h'],
|
||||
['emulators:control', '-h'],
|
||||
['build', '--help'],
|
||||
['simulator:control', 'clipboard.write', '--', '--help'],
|
||||
])('preserves regular help and literal values: %j', async (...argv) => {
|
||||
|
||||
@@ -1,29 +1,40 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import readline from 'readline';
|
||||
import { Writable } from 'stream';
|
||||
|
||||
import type { ora as oraType } from '../ora';
|
||||
|
||||
jest.unmock('ora');
|
||||
|
||||
class FakeTTYStream extends EventEmitter {
|
||||
const CLEAR_LINE = '\x1b[2K';
|
||||
|
||||
// Implements _writev like a TTY stream, so corked writes arrive in one call.
|
||||
class FakeTTYStream extends Writable {
|
||||
isTTY = true;
|
||||
columns = 200;
|
||||
clearLineCalls = 0;
|
||||
writes: string[] = [];
|
||||
|
||||
write(): boolean {
|
||||
return true;
|
||||
override _write(chunk: Buffer, _encoding: BufferEncoding, callback: () => void): void {
|
||||
this.writes.push(chunk.toString());
|
||||
callback();
|
||||
}
|
||||
|
||||
clearLine(): boolean {
|
||||
override _writev(chunks: { chunk: Buffer }[], callback: () => void): void {
|
||||
this.writes.push(chunks.map(({ chunk }) => chunk.toString()).join(''));
|
||||
callback();
|
||||
}
|
||||
|
||||
clearLine(dir: readline.Direction): boolean {
|
||||
this.clearLineCalls++;
|
||||
return true;
|
||||
return readline.clearLine(this, dir);
|
||||
}
|
||||
|
||||
cursorTo(): boolean {
|
||||
return true;
|
||||
cursorTo(x: number): boolean {
|
||||
return readline.cursorTo(this, x);
|
||||
}
|
||||
|
||||
moveCursor(): boolean {
|
||||
return true;
|
||||
moveCursor(dx: number, dy: number): boolean {
|
||||
return readline.moveCursor(this, dx, dy);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,4 +79,43 @@ describe('ora', () => {
|
||||
spinner.stop();
|
||||
expect(stream.listenerCount('resize')).toBe(0);
|
||||
});
|
||||
|
||||
it('sends each frame as one write', () => {
|
||||
const text = 'x'.repeat(150);
|
||||
const stream = new FakeTTYStream();
|
||||
stream.columns = 80;
|
||||
const spinner = ora({
|
||||
text,
|
||||
stream: stream as unknown as NodeJS.WritableStream,
|
||||
discardStdin: false,
|
||||
}).start();
|
||||
stream.writes = [];
|
||||
|
||||
jest.advanceTimersByTime(500);
|
||||
|
||||
expect(stream.writes.length).toBeGreaterThan(1);
|
||||
for (const frame of stream.writes) {
|
||||
expect(frame.split(CLEAR_LINE)).toHaveLength(3);
|
||||
expect(frame).toContain(text);
|
||||
}
|
||||
|
||||
spinner.stop();
|
||||
});
|
||||
|
||||
it('clears the spinner and writes the final line as one write', () => {
|
||||
const stream = new FakeTTYStream();
|
||||
stream.columns = 80;
|
||||
const spinner = ora({
|
||||
text: 'x'.repeat(150),
|
||||
stream: stream as unknown as NodeJS.WritableStream,
|
||||
discardStdin: false,
|
||||
}).start();
|
||||
stream.writes = [];
|
||||
|
||||
spinner.succeed('done');
|
||||
|
||||
expect(stream.writes).toHaveLength(1);
|
||||
expect(stream.writes[0].split(CLEAR_LINE)).toHaveLength(3);
|
||||
expect(stream.writes[0]).toContain('done');
|
||||
});
|
||||
});
|
||||
@@ -202,6 +202,7 @@ describe('when already logged in', () => {
|
||||
{
|
||||
id: 'jane-id',
|
||||
name: 'jane',
|
||||
ownerUserActor: { id: 'user-id', username: 'jane' },
|
||||
viewerUserPermission2: { permissions: ownerPermissions, effectivePermissions },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -19,6 +19,16 @@ function account(
|
||||
return { id: `${name}-id`, name, viewerUserPermission2: { permissions, effectivePermissions } };
|
||||
}
|
||||
|
||||
function personalAccount(
|
||||
permissions: Permission[],
|
||||
effectivePermissions: Permission[] = permissions
|
||||
): Actor['accounts'][number] {
|
||||
return {
|
||||
...account('jane', permissions, effectivePermissions),
|
||||
ownerUserActor: { id: 'user-id', username: 'jane' },
|
||||
};
|
||||
}
|
||||
|
||||
function userActor(accounts: Actor['accounts']): Actor {
|
||||
return {
|
||||
__typename: 'User',
|
||||
@@ -46,14 +56,14 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
it('does not list accounts when only the unlimited personal account exists', async () => {
|
||||
const lines = await runAsync(userActor([account('jane', ownerPermissions)]));
|
||||
const lines = await runAsync(userActor([personalAccount(ownerPermissions)]));
|
||||
expect(lines).toEqual([chalk.green('jane'), '[email protected]']);
|
||||
});
|
||||
|
||||
it('lists the role on each account', async () => {
|
||||
const lines = await runAsync(
|
||||
userActor([
|
||||
account('jane', ownerPermissions),
|
||||
personalAccount(ownerPermissions),
|
||||
account('acme', getMockViewerUserPermission(Role.Developer).permissions),
|
||||
account('other', [Permission.Admin, Permission.View]),
|
||||
])
|
||||
@@ -64,7 +74,7 @@ it('lists the role on each account', async () => {
|
||||
});
|
||||
|
||||
it('lists the personal account when the credentials limit it', async () => {
|
||||
const lines = await runAsync(userActor([account('jane', ownerPermissions, [Permission.View])]));
|
||||
const lines = await runAsync(userActor([personalAccount(ownerPermissions, [Permission.View])]));
|
||||
expect(lines).toContain(
|
||||
`• jane (Role: Owner, ${chalk.yellow('limited to Viewer by the current credentials')})`
|
||||
);
|
||||
@@ -78,7 +88,7 @@ it('lists the personal account when the credentials limit it', async () => {
|
||||
it('shows the effective permissions when they do not match a role', async () => {
|
||||
const lines = await runAsync(
|
||||
userActor([
|
||||
account('jane', ownerPermissions),
|
||||
personalAccount(ownerPermissions),
|
||||
account('acme', getMockViewerUserPermission(Role.Admin).permissions, [
|
||||
Permission.Publish,
|
||||
Permission.View,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
buildObserveCustomEventsEmptyWithSuggestionsJson,
|
||||
buildObserveCustomEventsEmptyWithSuggestionsTable,
|
||||
buildObserveCustomEventsJson,
|
||||
buildObserveCustomEventsTable,
|
||||
} from '../../../observe/formatCustomEvents';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveEvents from '../events';
|
||||
@@ -42,6 +43,7 @@ jest.mock('../../../utils/json');
|
||||
|
||||
const mockFetchObserveCustomEventsAsync = jest.mocked(fetchObserveCustomEventsAsync);
|
||||
const mockBuildObserveCustomEventsJson = jest.mocked(buildObserveCustomEventsJson);
|
||||
const mockBuildObserveCustomEventsTable = jest.mocked(buildObserveCustomEventsTable);
|
||||
const mockBuildObserveCustomEventNamesJson = jest.mocked(buildObserveCustomEventNamesJson);
|
||||
const mockBuildObserveCustomEventsEmptyWithSuggestionsTable = jest.mocked(
|
||||
buildObserveCustomEventsEmptyWithSuggestionsTable
|
||||
@@ -299,6 +301,103 @@ describe(ObserveEvents, () => {
|
||||
expect(options.sessionId).toBe('session-xyz');
|
||||
});
|
||||
|
||||
it('passes repeated --property flags as property filters', async () => {
|
||||
const command = createCommand([
|
||||
'expo.feedback',
|
||||
'--property',
|
||||
'expo.feedback.id=fb-1',
|
||||
'--property',
|
||||
'note=a=b',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
|
||||
expect(options.propertyFilters).toEqual([
|
||||
{ key: 'expo.feedback.id', value: 'fb-1' },
|
||||
{ key: 'note', value: 'a=b' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists events instead of the event-name summary when --property is set with no positional arg', async () => {
|
||||
const command = createCommand(['--property', 'expo.feedback.id=fb-1']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchObserveCustomEventsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
|
||||
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
|
||||
expect(options.propertyFilters).toEqual([{ key: 'expo.feedback.id', value: 'fb-1' }]);
|
||||
});
|
||||
|
||||
it('rejects --property without "="', async () => {
|
||||
const command = createCommand(['my_event', '--property', 'expo.feedback.id']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/Expected key=value/);
|
||||
expect(mockFetchObserveCustomEventsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects --property with an empty key', async () => {
|
||||
const command = createCommand(['my_event', '--property', '=fb-1']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/Expected key=value/);
|
||||
expect(mockFetchObserveCustomEventsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects --property with an empty value', async () => {
|
||||
const command = createCommand(['my_event', '--property', 'note=']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/value cannot be empty/);
|
||||
expect(mockFetchObserveCustomEventsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects more than 5 --property flags', async () => {
|
||||
const command = createCommand([
|
||||
'my_event',
|
||||
'--property',
|
||||
'a=1',
|
||||
'--property',
|
||||
'b=2',
|
||||
'--property',
|
||||
'c=3',
|
||||
'--property',
|
||||
'd=4',
|
||||
'--property',
|
||||
'e=5',
|
||||
'--property',
|
||||
'f=6',
|
||||
]);
|
||||
await expect(command.runAsync()).rejects.toThrow(/--property can be used up to 5 times/);
|
||||
expect(mockFetchObserveCustomEventsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts exactly 5 --property flags', async () => {
|
||||
const command = createCommand([
|
||||
'my_event',
|
||||
'--property',
|
||||
'a=1',
|
||||
'--property',
|
||||
'b=2',
|
||||
'--property',
|
||||
'c=3',
|
||||
'--property',
|
||||
'd=4',
|
||||
'--property',
|
||||
'e=5',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
|
||||
expect(options.propertyFilters).toEqual([
|
||||
{ key: 'a', value: '1' },
|
||||
{ key: 'b', value: '2' },
|
||||
{ key: 'c', value: '3' },
|
||||
{ key: 'd', value: '4' },
|
||||
{ key: 'e', value: '5' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a repeated --property key', async () => {
|
||||
const command = createCommand(['my_event', '--property', 'a=1', '--property', 'a=2']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/key "a" is used more than once/);
|
||||
expect(mockFetchObserveCustomEventsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not pass platforms, appVersion, updateId, or sessionId when flags are not provided', async () => {
|
||||
const command = createCommand(['my_event']);
|
||||
await command.runAsync();
|
||||
@@ -308,6 +407,7 @@ describe(ObserveEvents, () => {
|
||||
expect(options.appVersion).toBeUndefined();
|
||||
expect(options.updateId).toBeUndefined();
|
||||
expect(options.sessionId).toBeUndefined();
|
||||
expect(options.propertyFilters).toBeUndefined();
|
||||
});
|
||||
|
||||
it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided with an event name', async () => {
|
||||
@@ -395,6 +495,64 @@ describe(ObserveEvents, () => {
|
||||
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['--session-id', 'session-xyz'],
|
||||
['--property', 'expo.feedback.id=fb-1'],
|
||||
])(
|
||||
'does not suggest event names when an event name and %s return 0 events',
|
||||
async (flag, value) => {
|
||||
const command = createCommand(['my_event', flag, value]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
|
||||
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).not.toHaveBeenCalled();
|
||||
expect(mockBuildObserveCustomEventsTable).toHaveBeenCalledWith(
|
||||
[],
|
||||
expect.anything(),
|
||||
expect.objectContaining({ eventName: 'my_event' })
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('emits empty events JSON without suggestions when an event name and --session-id return 0 events and --json is set', async () => {
|
||||
const pageInfo = { hasNextPage: false, hasPreviousPage: false };
|
||||
mockFetchObserveCustomEventsAsync.mockResolvedValue({ events: [], pageInfo });
|
||||
|
||||
const command = createCommand([
|
||||
'my_event',
|
||||
'--session-id',
|
||||
'session-xyz',
|
||||
'--json',
|
||||
'--non-interactive',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
|
||||
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsJson).not.toHaveBeenCalled();
|
||||
expect(mockBuildObserveCustomEventsJson).toHaveBeenCalledWith([], pageInfo);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['--app-version', '2.1.0'],
|
||||
['--build-number', '42'],
|
||||
['--update-id', 'update-xyz'],
|
||||
])(
|
||||
'does not suggest event names when an event name and %s return 0 events',
|
||||
async (flag, value) => {
|
||||
const command = createCommand(['my_event', flag, value]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
|
||||
expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).not.toHaveBeenCalled();
|
||||
expect(mockBuildObserveCustomEventsTable).toHaveBeenCalledWith(
|
||||
[],
|
||||
expect.anything(),
|
||||
expect.objectContaining({ eventName: 'my_event' })
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('emits empty-with-suggestions JSON when filtered fetch returns 0 events and --json is set', async () => {
|
||||
mockFetchObserveCustomEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
import Log from '../../log';
|
||||
import { AppObserveUserEventPropertyFilter } from '../../graphql/generated';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import { fetchObserveCustomEventsAsync } from '../../observe/fetchCustomEvents';
|
||||
import {
|
||||
@@ -33,6 +34,7 @@ import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
const DEFAULT_EVENTS_LIMIT = 10;
|
||||
const MAX_PROPERTY_FILTERS = 5;
|
||||
|
||||
export default class ObserveEvents extends EasCommand {
|
||||
static override description =
|
||||
@@ -61,6 +63,11 @@ export default class ObserveEvents extends EasCommand {
|
||||
description:
|
||||
'Filter by session ID. When no event name is given, lists the events in the session instead of the event-name summary.',
|
||||
}),
|
||||
property: Flags.custom<AppObserveUserEventPropertyFilter>({
|
||||
description: `Filter by event property (key=value, exact match). Repeat up to ${MAX_PROPERTY_FILTERS} times to require all (each key may be used once). Without an event name, lists matching events.`,
|
||||
multiple: true,
|
||||
parse: parsePropertyFilterAsync,
|
||||
})(),
|
||||
'all-events': Flags.boolean({
|
||||
description:
|
||||
'When no event name argument is provided, list all events across all event names instead of a summary of event names + counts.',
|
||||
@@ -85,6 +92,22 @@ export default class ObserveEvents extends EasCommand {
|
||||
);
|
||||
}
|
||||
|
||||
if (flags.property) {
|
||||
if (flags.property.length > MAX_PROPERTY_FILTERS) {
|
||||
throw new Error(
|
||||
`--property can be used up to ${MAX_PROPERTY_FILTERS} times, but got ${flags.property.length}.`
|
||||
);
|
||||
}
|
||||
|
||||
const keys = flags.property.map(filter => filter.key);
|
||||
const repeatedKey = keys.find((key, index) => keys.indexOf(key) !== index);
|
||||
if (repeatedKey) {
|
||||
throw new Error(
|
||||
`--property key "${repeatedKey}" is used more than once. Each key can be filtered by only one value.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
@@ -101,10 +124,10 @@ export default class ObserveEvents extends EasCommand {
|
||||
|
||||
const platforms = observePlatformsFromFlag(flags.platform);
|
||||
|
||||
// A session ID narrows to a single session, so show that session's events
|
||||
// A session ID or property filter narrows the events, so show them
|
||||
// (like --all-events) instead of the account-wide name+count summary, which
|
||||
// has no session filter.
|
||||
if (!args.eventName && !flags['all-events'] && !flags['session-id']) {
|
||||
// supports neither filter.
|
||||
if (!args.eventName && !flags['all-events'] && !flags['session-id'] && !flags.property) {
|
||||
const { names, isTruncated } = await withObservePlanGateHandlingAsync(() =>
|
||||
ObserveQuery.customEventNamesAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
@@ -144,10 +167,22 @@ export default class ObserveEvents extends EasCommand {
|
||||
updateId: flags['update-id'],
|
||||
sessionId: flags['session-id'],
|
||||
environment: flags.environment,
|
||||
propertyFilters: flags.property,
|
||||
})
|
||||
);
|
||||
|
||||
if (args.eventName && events.length === 0) {
|
||||
// A session, app version, build number, update ID, or property may not
|
||||
// contain the event even when the name is valid, so only suggest other names
|
||||
// when the event name is the sole narrowing filter.
|
||||
if (
|
||||
args.eventName &&
|
||||
events.length === 0 &&
|
||||
!flags['session-id'] &&
|
||||
!flags['app-version'] &&
|
||||
!flags['build-number'] &&
|
||||
!flags['update-id'] &&
|
||||
!flags.property
|
||||
) {
|
||||
const { names, isTruncated } = await ObserveQuery.customEventNamesAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
startTime,
|
||||
@@ -189,3 +224,16 @@ export default class ObserveEvents extends EasCommand {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Split on the first "=" so values may contain "=".
|
||||
async function parsePropertyFilterAsync(input: string): Promise<AppObserveUserEventPropertyFilter> {
|
||||
const separatorIndex = input.indexOf('=');
|
||||
if (separatorIndex <= 0) {
|
||||
throw new Error(`Invalid property filter "${input}". Expected key=value format.`);
|
||||
}
|
||||
const value = input.slice(separatorIndex + 1);
|
||||
if (!value) {
|
||||
throw new Error(`Invalid property filter "${input}". The value cannot be empty.`);
|
||||
}
|
||||
return { key: input.slice(0, separatorIndex), value };
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import { DeviceRunSessionStatus } from '../../../graphql/generated';
|
||||
import Log from '../../../log';
|
||||
import {
|
||||
createControlConnectionIdentityAsync,
|
||||
inspectControlConnectionAsync,
|
||||
openControlConnectionAsync,
|
||||
} from '../../../simulator/controlConnection';
|
||||
import { ControlConnectionInfo } from '../../../simulator/controlConnectionProtocol';
|
||||
import { loadControlSessionAsync } from '../../../simulator/controlSession';
|
||||
import { EAS_SIMULATOR_SESSION_ID, loadSimulatorEnvAsync } from '../../../simulator/env';
|
||||
import { printJsonOnlyOutput } from '../../../utils/json';
|
||||
import SimulatorControlConnection from '../control/connection';
|
||||
|
||||
jest.mock('../../../simulator/controlConnection');
|
||||
jest.mock('../../../simulator/controlSession');
|
||||
jest.mock('../../../simulator/env', () => ({
|
||||
...jest.requireActual('../../../simulator/env'),
|
||||
loadSimulatorEnvAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../utils/json');
|
||||
jest.mock('../../../log');
|
||||
|
||||
describe('simulator:control:connection', () => {
|
||||
const config = new Config({ root: __dirname });
|
||||
config.runHook = async () => ({ failures: [], successes: [] });
|
||||
const identity = {
|
||||
root: '/private/ipc',
|
||||
socketPath: '/private/ipc/socket',
|
||||
scope: 'fixture',
|
||||
credentialHash: '0'.repeat(64),
|
||||
};
|
||||
const info: ControlConnectionInfo = {
|
||||
version: 3,
|
||||
pid: 123,
|
||||
session: {
|
||||
id: 'session-one',
|
||||
name: null,
|
||||
project: '@fixture/app',
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
device: { udid: 'device-one', name: 'Fixture phone' },
|
||||
},
|
||||
busy: false,
|
||||
idleTimeoutMs: 120_000,
|
||||
idleDeadline: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(createControlConnectionIdentityAsync).mockResolvedValue(identity);
|
||||
jest.mocked(inspectControlConnectionAsync).mockResolvedValue(null);
|
||||
jest.mocked(loadSimulatorEnvAsync).mockResolvedValue();
|
||||
});
|
||||
|
||||
function command(args: string[]): SimulatorControlConnection {
|
||||
const command = new SimulatorControlConnection(args, config);
|
||||
const context = {
|
||||
projectDir: '/project',
|
||||
loggedIn: {
|
||||
graphqlClient: {},
|
||||
authenticationInfo: { accessToken: 'credential-fixture', sessionSecret: null },
|
||||
},
|
||||
};
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue(context);
|
||||
return command;
|
||||
}
|
||||
|
||||
it('opens a connection with an idle timeout in seconds', async () => {
|
||||
jest.mocked(openControlConnectionAsync).mockImplementationOnce(async (_identity, load) => {
|
||||
await load();
|
||||
return info;
|
||||
});
|
||||
await command(['open', '--id', 'session-one', '--idle-timeout', '300', '--json']).runAsync();
|
||||
expect(openControlConnectionAsync).toHaveBeenCalledWith(
|
||||
identity,
|
||||
expect.any(Function),
|
||||
300_000
|
||||
);
|
||||
expect(inspectControlConnectionAsync).not.toHaveBeenCalled();
|
||||
expect(loadControlSessionAsync).toHaveBeenCalledWith({}, 'session-one');
|
||||
});
|
||||
|
||||
it('requires a session ID when none is saved', async () => {
|
||||
const previous = process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
try {
|
||||
await expect(command(['status']).runAsync()).rejects.toThrow('No simulator session ID');
|
||||
expect(createControlConnectionIdentityAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (previous !== undefined) {
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('prints a useful message when status finds no connection', async () => {
|
||||
await command(['status', '--id', 'session-one']).runAsync();
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
'No control connection is open. Regular control commands are available.'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([true, false])('prints connection details with busy=%s', async busy => {
|
||||
jest.mocked(inspectControlConnectionAsync).mockResolvedValueOnce({
|
||||
...info,
|
||||
busy,
|
||||
idleDeadline: Date.now() + 30_000,
|
||||
session: { ...info.session, device: { udid: 'device-one' } },
|
||||
});
|
||||
await command(['status', '--id', 'session-one']).runAsync();
|
||||
expect(Log.log).toHaveBeenCalledWith('iOS Simulator · session-one');
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`Control connection ${busy ? 'busy' : 'ready'}`)
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringMatching(/^Closes in /));
|
||||
});
|
||||
|
||||
it('acknowledges closing an existing connection in JSON and text', async () => {
|
||||
jest.mocked(inspectControlConnectionAsync).mockResolvedValue(info);
|
||||
await command(['close', '--id', 'session-one', '--json']).runAsync();
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
connected: false,
|
||||
id: 'session-one',
|
||||
connection: null,
|
||||
});
|
||||
await command(['close', '--id', 'session-one']).runAsync();
|
||||
expect(Log.succeed).toHaveBeenCalledWith(
|
||||
'Control connection closing. The simulator session continues.'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns JSON for a missing connection without opening one', async () => {
|
||||
await command(['status', '--id', 'session-one', '--json']).runAsync();
|
||||
expect(inspectControlConnectionAsync).toHaveBeenCalledWith(identity, { operation: 'status' });
|
||||
expect(openControlConnectionAsync).not.toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
connected: false,
|
||||
id: 'session-one',
|
||||
connection: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('closing an absent connection succeeds without stopping a simulator session', async () => {
|
||||
await command(['close', '--id', 'session-one']).runAsync();
|
||||
expect(inspectControlConnectionAsync).toHaveBeenCalledWith(identity, { operation: 'close' });
|
||||
expect(Log.succeed).toHaveBeenCalledWith('No control connection is open.');
|
||||
expect(openControlConnectionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['status', 'close'])('rejects idle-timeout for %s', async operation => {
|
||||
await expect(command([operation, '--idle-timeout', '300']).runAsync()).rejects.toThrow(
|
||||
'only to connection open'
|
||||
);
|
||||
expect(createControlConnectionIdentityAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['', '0', '-1', 'invalid', '5m', '0.5', '86401'])(
|
||||
'rejects idle timeout %j before connection setup',
|
||||
async duration => {
|
||||
await expect(
|
||||
command(['open', '--id', 'session-one', '--idle-timeout', duration]).runAsync()
|
||||
).rejects.toThrow('--idle-timeout');
|
||||
expect(openControlConnectionAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects an empty id before loading account state', async () => {
|
||||
await expect(command(['open', '--id=']).runAsync()).rejects.toThrow('--id needs');
|
||||
expect(createControlConnectionIdentityAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts the session saved in the project environment', async () => {
|
||||
const previous = process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = 'saved-session';
|
||||
try {
|
||||
await command(['status']).runAsync();
|
||||
expect(createControlConnectionIdentityAsync).toHaveBeenCalledWith(
|
||||
'saved-session',
|
||||
'/project',
|
||||
expect.any(Object)
|
||||
);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
} else {
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import * as fetchModule from '../../../fetch';
|
||||
import {
|
||||
AppPlatform,
|
||||
DeviceRunSessionByIdQuery,
|
||||
@@ -13,17 +14,31 @@ import {
|
||||
} from '../../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log from '../../../log';
|
||||
import { loadSimulatorEnvAsync } from '../../../simulator/env';
|
||||
import { ora } from '../../../ora';
|
||||
import {
|
||||
BorrowedControlConnection,
|
||||
borrowControlConnectionAsync,
|
||||
createControlConnectionIdentityAsync,
|
||||
openControlConnectionAsync,
|
||||
} from '../../../simulator/controlConnection';
|
||||
import { EAS_SIMULATOR_SESSION_ID, loadSimulatorEnvAsync } from '../../../simulator/env';
|
||||
import {
|
||||
HostActionCatalog,
|
||||
HostActionResult,
|
||||
connectHostActionsAsync,
|
||||
fetchHostActionDeviceAsync,
|
||||
} from '../../../simulator/hostActions';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import SimulatorControl from '../control';
|
||||
|
||||
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../simulator/controlConnection', () => ({
|
||||
...jest.requireActual('../../../simulator/controlConnection'),
|
||||
borrowControlConnectionAsync: jest.fn(),
|
||||
createControlConnectionIdentityAsync: jest.fn(),
|
||||
openControlConnectionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../simulator/env', () => ({
|
||||
...jest.requireActual('../../../simulator/env'),
|
||||
loadSimulatorEnvAsync: jest.fn(),
|
||||
@@ -31,6 +46,7 @@ jest.mock('../../../simulator/env', () => ({
|
||||
jest.mock('../../../simulator/hostActions', () => ({
|
||||
...jest.requireActual('../../../simulator/hostActions'),
|
||||
connectHostActionsAsync: jest.fn(),
|
||||
fetchHostActionDeviceAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../ora', () => ({
|
||||
ora: jest.fn(() => {
|
||||
@@ -38,6 +54,7 @@ jest.mock('../../../ora', () => ({
|
||||
fail: jest.fn(),
|
||||
start: jest.fn(),
|
||||
succeed: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
};
|
||||
spinner.start.mockReturnValue(spinner);
|
||||
return spinner;
|
||||
@@ -107,6 +124,7 @@ describe(SimulatorControl, () => {
|
||||
let actionResult: HostActionResult;
|
||||
let served: HostActionCatalog;
|
||||
let tmpDir: string;
|
||||
let connectionController: AbortController;
|
||||
const requestAsync = jest.fn(
|
||||
async (action: string, params: Record<string, unknown> = {}): Promise<HostActionResult> => {
|
||||
switch (action) {
|
||||
@@ -127,9 +145,25 @@ describe(SimulatorControl, () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(borrowControlConnectionAsync).mockResolvedValue(null);
|
||||
jest.mocked(createControlConnectionIdentityAsync).mockResolvedValue({
|
||||
root: '/private/ipc',
|
||||
socketPath: '/private/ipc/socket',
|
||||
scope: 'fixture',
|
||||
credentialHash: '0'.repeat(64),
|
||||
});
|
||||
jest.mocked(loadSimulatorEnvAsync).mockResolvedValue();
|
||||
mockByIdAsync.mockResolvedValue(makeDeviceRunSession());
|
||||
mockConnectHostActionsAsync.mockResolvedValue({ requestAsync, close });
|
||||
jest
|
||||
.mocked(fetchHostActionDeviceAsync)
|
||||
.mockResolvedValue({ udid: 'simulator-udid', name: 'iPhone 17 Pro', runtime: 'iOS-26-4' });
|
||||
connectionController = new AbortController();
|
||||
mockConnectHostActionsAsync.mockResolvedValue({
|
||||
requestAsync,
|
||||
close,
|
||||
udid: 'simulator-udid',
|
||||
signal: connectionController.signal,
|
||||
});
|
||||
actionResult = ok('off\n');
|
||||
served = catalog;
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'simulator-control-'));
|
||||
@@ -143,7 +177,10 @@ describe(SimulatorControl, () => {
|
||||
const command = new SimulatorControl(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
loggedIn: { graphqlClient },
|
||||
loggedIn: {
|
||||
graphqlClient,
|
||||
authenticationInfo: { accessToken: 'credential-fixture', sessionSecret: null },
|
||||
},
|
||||
projectDir,
|
||||
});
|
||||
return command;
|
||||
@@ -153,6 +190,312 @@ describe(SimulatorControl, () => {
|
||||
return requestAsync.mock.calls.filter(([action]) => action !== 'actions.describe');
|
||||
}
|
||||
|
||||
function reuseConnection(): BorrowedControlConnection {
|
||||
const borrowed: BorrowedControlConnection = {
|
||||
connection: {
|
||||
requestAsync,
|
||||
close,
|
||||
udid: 'simulator-udid',
|
||||
signal: connectionController.signal,
|
||||
},
|
||||
catalog,
|
||||
info: {
|
||||
version: 3,
|
||||
pid: 123,
|
||||
idleTimeoutMs: 120_000,
|
||||
idleDeadline: null,
|
||||
busy: true,
|
||||
session: {
|
||||
id: 'session-123',
|
||||
name: 'Fixture session',
|
||||
project: '@testuser/testapp',
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
device: { udid: 'simulator-udid', name: 'iPhone 17 Pro', runtime: 'iOS-26-4' },
|
||||
},
|
||||
},
|
||||
};
|
||||
jest.mocked(borrowControlConnectionAsync).mockResolvedValue(borrowed);
|
||||
return borrowed;
|
||||
}
|
||||
|
||||
it('reuses the available connection without fetching the session or opening another remote socket', async () => {
|
||||
reuseConnection();
|
||||
await createCommand([
|
||||
'hardwareKeyboard.set',
|
||||
'off',
|
||||
'--id',
|
||||
'session-123',
|
||||
'--json',
|
||||
]).runAsync();
|
||||
expect(mockByIdAsync).not.toHaveBeenCalled();
|
||||
expect(mockConnectHostActionsAsync).not.toHaveBeenCalled();
|
||||
expect(fetchHostActionDeviceAsync).not.toHaveBeenCalled();
|
||||
expect(actionRequests()).toEqual([['hardwareKeyboard.set', { value: 'off' }]]);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'session-123', action: 'hardwareKeyboard.set', output: 'off' })
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps an action connection alive with a custom timeout', async () => {
|
||||
const borrowed = reuseConnection();
|
||||
jest.mocked(openControlConnectionAsync).mockImplementationOnce(async (_identity, load) => {
|
||||
await load();
|
||||
return borrowed.info;
|
||||
});
|
||||
await createCommand([
|
||||
'hardwareKeyboard.set',
|
||||
'off',
|
||||
'--id',
|
||||
'session-123',
|
||||
'--keep-alive',
|
||||
'--idle-timeout',
|
||||
'300',
|
||||
]).runAsync();
|
||||
expect(openControlConnectionAsync).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.any(Function),
|
||||
300_000
|
||||
);
|
||||
expect(actionRequests()).toEqual([['hardwareKeyboard.set', { value: 'off' }]]);
|
||||
});
|
||||
|
||||
it('can prepare keep-alive without running or listing actions', async () => {
|
||||
reuseConnection();
|
||||
await createCommand(['--keep-alive', '--id', 'session-123', '--json']).runAsync();
|
||||
expect(actionRequests()).toEqual([]);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connected: true, id: 'session-123' })
|
||||
);
|
||||
});
|
||||
|
||||
it('prints readiness when preparing a connection without JSON', async () => {
|
||||
reuseConnection();
|
||||
await createCommand(['--keep-alive', '--id', 'session-123']).runAsync();
|
||||
expect(Log.succeed).toHaveBeenCalledWith(expect.stringContaining('Control connection ready'));
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not fall back to a cold connection if a newly opened helper disappears', async () => {
|
||||
await expect(createCommand(['--keep-alive', '--id', 'session-123']).runAsync()).rejects.toThrow(
|
||||
'helper closed before it could be used'
|
||||
);
|
||||
expect(mockConnectHostActionsAsync).not.toHaveBeenCalled();
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects invalid timeout seconds before loading account or session state', async () => {
|
||||
const command = createCommand(['--keep-alive', '--idle-timeout', '0']);
|
||||
await expect(command.runAsync()).rejects.toThrow('--idle-timeout');
|
||||
expect(mockByIdAsync).not.toHaveBeenCalled();
|
||||
expect(openControlConnectionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
async function writeImportAsync(actions: unknown[]): Promise<string> {
|
||||
const filePath = path.join(tmpDir, 'control.json');
|
||||
await fs.writeJson(filePath, { version: 1, actions });
|
||||
return filePath;
|
||||
}
|
||||
|
||||
it('shows the local file being uploaded during an import', async () => {
|
||||
const localFile = path.join(tmpDir, 'MyApp.ipa');
|
||||
await fs.writeFile(localFile, 'app bytes');
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'app.install', params: {}, file: localFile },
|
||||
]);
|
||||
actionResult = ok('');
|
||||
await createCommand(['--import', filePath, '--id', 'session-123']).runAsync();
|
||||
expect(Log.gray).toHaveBeenCalledWith(` Read: ${localFile}`);
|
||||
expect(actionRequests().map(([action]) => action)).toEqual([
|
||||
'upload.append',
|
||||
'app.install',
|
||||
'upload.remove',
|
||||
]);
|
||||
});
|
||||
|
||||
it('imports an ordered config without prompts over the existing connection', async () => {
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'on' } },
|
||||
]);
|
||||
await createCommand([
|
||||
'--import',
|
||||
filePath,
|
||||
'--id',
|
||||
'session-123',
|
||||
'--non-interactive',
|
||||
]).runAsync();
|
||||
expect(actionRequests()).toEqual([
|
||||
['hardwareKeyboard.set', { value: 'off' }],
|
||||
['hardwareKeyboard.set', { value: 'on' }],
|
||||
]);
|
||||
expect(mockConnectHostActionsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('iPhone 17 Pro · iOS 26.4'));
|
||||
expect(Log.log).toHaveBeenCalledWith(' off');
|
||||
const spinner = jest.mocked(ora).mock.results[1].value;
|
||||
expect(spinner.start).toHaveBeenCalledWith('[1/2] hardwareKeyboard.set');
|
||||
expect(spinner.start).toHaveBeenCalledWith('[2/2] hardwareKeyboard.set');
|
||||
expect(Log.succeed).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Import complete · 2 actions')
|
||||
);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'prints one JSON result for a successful imported batch (reused: %s)',
|
||||
async reused => {
|
||||
if (reused) {
|
||||
reuseConnection();
|
||||
}
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
]);
|
||||
actionResult = ok('{"enabled":false}\n');
|
||||
await createCommand(['--import', filePath, '--id', 'session-123', '--json']).runAsync();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledTimes(1);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
id: 'session-123',
|
||||
session: expect.objectContaining({
|
||||
id: 'session-123',
|
||||
device: { udid: 'simulator-udid', name: 'iPhone 17 Pro', runtime: 'iOS-26-4' },
|
||||
}),
|
||||
config: filePath,
|
||||
results: [{ action: 'hardwareKeyboard.set', output: { enabled: false }, file: null }],
|
||||
});
|
||||
expect(ora).toHaveBeenCalledTimes(1);
|
||||
expect(Log.log).not.toHaveBeenCalled();
|
||||
expect(Log.succeed).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('retains completed results and failure metadata in JSON and stops the imported batch', async () => {
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'on' } },
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
]);
|
||||
requestAsync
|
||||
.mockImplementationOnce(async () => ok(JSON.stringify(served)))
|
||||
.mockResolvedValueOnce(ok('first completed'))
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: 'Permission denied', exitCode: 1 });
|
||||
const previous = process.exitCode;
|
||||
try {
|
||||
await createCommand(['--import', filePath, '--id', 'session-123', '--json']).runAsync();
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
id: 'session-123',
|
||||
session: expect.objectContaining({
|
||||
id: 'session-123',
|
||||
device: { udid: 'simulator-udid', name: 'iPhone 17 Pro', runtime: 'iOS-26-4' },
|
||||
}),
|
||||
config: filePath,
|
||||
results: [{ action: 'hardwareKeyboard.set', output: 'first completed', file: null }],
|
||||
error: {
|
||||
step: 2,
|
||||
action: 'hardwareKeyboard.set',
|
||||
message: expect.stringContaining('Permission denied'),
|
||||
},
|
||||
});
|
||||
expect(actionRequests()).toHaveLength(2);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
process.exitCode = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it('preflights every imported step before running the first action', async () => {
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'invalid' } },
|
||||
]);
|
||||
await expect(
|
||||
createCommand(['--import', filePath, '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('Config step 2');
|
||||
expect(actionRequests()).toEqual([]);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(jest.mocked(ora).mock.results[1].value.fail).toHaveBeenCalledWith(
|
||||
'Validation failed. No actions were run.'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ args: ['hardwareKeyboard.set', 'off'] },
|
||||
{ args: ['--list'] },
|
||||
{ args: ['--output', './out.png'] },
|
||||
])('rejects conflicting import options before connecting: %j', async ({ args }) => {
|
||||
await expect(
|
||||
createCommand(['--import', './control.json', '--id', 'session-123', ...args]).runAsync()
|
||||
).rejects.toThrow('--import cannot be combined');
|
||||
expect(mockByIdAsync).not.toHaveBeenCalled();
|
||||
expect(mockConnectHostActionsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses Android imports before connecting or reading the config file', async () => {
|
||||
mockByIdAsync.mockResolvedValue(makeDeviceRunSession({ platform: AppPlatform.Android }));
|
||||
await expect(
|
||||
createCommand(['--import', './missing.json', '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('not supported yet');
|
||||
expect(mockConnectHostActionsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints completed steps before a non-JSON import failure', async () => {
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'on' } },
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
]);
|
||||
requestAsync
|
||||
.mockImplementationOnce(async () => ok(JSON.stringify(served)))
|
||||
.mockResolvedValueOnce(ok('first done'))
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: 'Native action failed', exitCode: 1 });
|
||||
await expect(
|
||||
createCommand(['--import', filePath, '--id', 'session-123']).runAsync()
|
||||
).rejects.toThrow('Config step 2');
|
||||
expect(Log.log).toHaveBeenCalledWith(' first done');
|
||||
expect(Log.log).toHaveBeenCalledWith('1 completed · 1 failed · 1 skipped');
|
||||
expect(Log.gray).toHaveBeenCalledWith(' [3/3] hardwareKeyboard.set skipped');
|
||||
expect(actionRequests()).toHaveLength(2);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('downloads config-relative outputs and reports each saved file', async () => {
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'screenshot.capture', output: './shot.png' },
|
||||
]);
|
||||
actionResult = ok('/remote/screenshot.png');
|
||||
await createCommand(['--import', filePath, '--id', 'session-123']).runAsync();
|
||||
const savedPath = path.join(tmpDir, 'shot.png');
|
||||
expect(await fs.readFile(savedPath, 'utf8')).toBe('png bytes');
|
||||
expect(Log.log).toHaveBeenCalledWith(` Saved: ${savedPath}`);
|
||||
expect(Log.gray).toHaveBeenCalledWith(` Output: ${savedPath}`);
|
||||
expect(Log.gray).toHaveBeenCalledWith(
|
||||
'Explicit output files are overwritten; directory outputs use simulator filenames and fail if the file exists.'
|
||||
);
|
||||
expect(jest.mocked(Log.gray).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
requestAsync.mock.invocationCallOrder[1]
|
||||
);
|
||||
});
|
||||
|
||||
it('reports completion for actions with empty output', async () => {
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
]);
|
||||
actionResult = ok('');
|
||||
await createCommand(['--import', filePath, '--id', 'session-123']).runAsync();
|
||||
expect(jest.mocked(ora).mock.results[1].value.succeed).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^\[1\/1\] hardwareKeyboard\.set /)
|
||||
);
|
||||
});
|
||||
|
||||
it('formats structured results without opening a picker', async () => {
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
]);
|
||||
actionResult = ok('{"text":"Imported text"}');
|
||||
await createCommand(['--import', filePath, '--id', 'session-123']).runAsync();
|
||||
expect(Log.log).toHaveBeenCalledWith(' {\n "text": "Imported text"\n }');
|
||||
});
|
||||
|
||||
it.each([['--id='], ['--id', '']])('refuses an empty session ID in %j', async (...flags) => {
|
||||
const env = jest.replaceProperty(process, 'env', {
|
||||
...process.env,
|
||||
@@ -186,6 +529,7 @@ describe(SimulatorControl, () => {
|
||||
it('runs an action from the catalog with its values and prints the output', async () => {
|
||||
await createCommand(['hardwareKeyboard.set', 'off', '--id', 'session-123']).runAsync();
|
||||
|
||||
expect(fetchHostActionDeviceAsync).not.toHaveBeenCalled();
|
||||
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-123');
|
||||
expect(mockConnectHostActionsAsync).toHaveBeenCalledWith({
|
||||
apiUrl: 'https://sim.example.test',
|
||||
@@ -196,8 +540,8 @@ describe(SimulatorControl, () => {
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([false, true])('lists the actions of the session with --list=%s', async list => {
|
||||
await createCommand([...(list ? ['--list'] : []), '--id', 'session-123']).runAsync();
|
||||
it('lists the actions of the session', async () => {
|
||||
await createCommand(['--list', '--id', 'session-123']).runAsync();
|
||||
|
||||
const lines = jest.mocked(Log.log).mock.calls.map(([line]) => String(line));
|
||||
expect(lines.pop()).toBe('For action usage: eas simulator:control <action> --help');
|
||||
@@ -205,6 +549,7 @@ describe(SimulatorControl, () => {
|
||||
Object.keys(catalog).sort()
|
||||
);
|
||||
expect(actionRequests()).toEqual([]);
|
||||
expect(fetchHostActionDeviceAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['--help', '-h'])('shows action help with %s without running the action', async help => {
|
||||
@@ -278,31 +623,69 @@ describe(SimulatorControl, () => {
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
id: 'session-123',
|
||||
session: expect.objectContaining({
|
||||
id: 'session-123',
|
||||
device: expect.objectContaining({ udid: 'simulator-udid' }),
|
||||
}),
|
||||
action: 'permissions.list',
|
||||
output: { voiceover: 'off' },
|
||||
file: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads a local file for an action that takes one and removes it after', async () => {
|
||||
const localFile = path.join(tmpDir, 'MyApp.ipa');
|
||||
await fs.writeFile(localFile, 'app bytes');
|
||||
actionResult = ok('');
|
||||
|
||||
await createCommand(['app.install', localFile, '--id', 'session-123']).runAsync();
|
||||
|
||||
const requests = actionRequests();
|
||||
const uploadId = requests[0][1]?.uploadId;
|
||||
expect(requests).toEqual([
|
||||
[
|
||||
'upload.append',
|
||||
{ uploadId, data: Buffer.from('app bytes').toString('base64'), first: true },
|
||||
],
|
||||
['app.install', { path: `/tmp/serve-sim-uploads/${uploadId}` }],
|
||||
['upload.remove', { uploadId }],
|
||||
]);
|
||||
it('prints plain text as a JSON string and lists a catalog as JSON', async () => {
|
||||
await createCommand([
|
||||
'hardwareKeyboard.set',
|
||||
'off',
|
||||
'--id',
|
||||
'session-123',
|
||||
'--json',
|
||||
]).runAsync();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
id: 'session-123',
|
||||
session: expect.objectContaining({
|
||||
id: 'session-123',
|
||||
project: '@testuser/testapp',
|
||||
device: { udid: 'simulator-udid', name: 'iPhone 17 Pro', runtime: 'iOS-26-4' },
|
||||
}),
|
||||
action: 'hardwareKeyboard.set',
|
||||
output: 'off',
|
||||
file: null,
|
||||
});
|
||||
mockPrintJsonOnlyOutput.mockClear();
|
||||
jest.mocked(fetchHostActionDeviceAsync).mockClear();
|
||||
await createCommand(['--list', '--id', 'session-123', '--json']).runAsync();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ 'hardwareKeyboard.set': catalog['hardwareKeyboard.set'] })
|
||||
);
|
||||
expect(fetchHostActionDeviceAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'uploads a local file for an action that takes one and removes it after (reused: %s)',
|
||||
async reused => {
|
||||
if (reused) {
|
||||
reuseConnection();
|
||||
}
|
||||
const localFile = path.join(tmpDir, 'MyApp.ipa');
|
||||
await fs.writeFile(localFile, 'app bytes');
|
||||
actionResult = ok('');
|
||||
|
||||
await createCommand(['app.install', localFile, '--id', 'session-123']).runAsync();
|
||||
|
||||
const requests = actionRequests();
|
||||
const uploadId = requests[0][1]?.uploadId;
|
||||
expect(requests).toEqual([
|
||||
[
|
||||
'upload.append',
|
||||
{ uploadId, data: Buffer.from('app bytes').toString('base64'), first: true },
|
||||
],
|
||||
['app.install', { path: `/tmp/serve-sim-uploads/${uploadId}` }],
|
||||
['upload.remove', { uploadId }],
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
it('sends the upload ID to an action that asks for it', async () => {
|
||||
const localFile = path.join(tmpDir, 'session.json');
|
||||
await fs.writeFile(localFile, '{}');
|
||||
@@ -504,7 +887,10 @@ describe(SimulatorControl, () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('downloads the file an action returns', async () => {
|
||||
it.each([false, true])('downloads the file an action returns (reused: %s)', async reused => {
|
||||
if (reused) {
|
||||
reuseConnection();
|
||||
}
|
||||
const outputPath = path.join(tmpDir, 'shot.png');
|
||||
actionResult = ok('/tmp/serve-sim-screenshots/serve-sim-screenshot-1.png\n');
|
||||
|
||||
@@ -522,12 +908,42 @@ describe(SimulatorControl, () => {
|
||||
|
||||
it('refuses an action that the session does not list', async () => {
|
||||
await expect(createCommand(['server.kill', '--id', 'session-123']).runAsync()).rejects.toThrow(
|
||||
'Unknown simulator action "server.kill". Run `eas simulator:control` to see the actions.'
|
||||
'Unknown simulator action "server.kill". Run `eas simulator:control --list` to see the actions.'
|
||||
);
|
||||
expect(actionRequests()).toEqual([]);
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([{ flags: [] }, { flags: ['--non-interactive'] }, { flags: ['--json'] }])(
|
||||
'lists actions by default with flags %j',
|
||||
async ({ flags }) => {
|
||||
await createCommand(['--id', 'session-123', ...flags]).runAsync();
|
||||
expect(actionRequests()).toEqual([]);
|
||||
if (flags.includes('--json')) {
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
'app.install': expect.objectContaining({
|
||||
description: expect.stringContaining('host:/absolute/path'),
|
||||
}),
|
||||
'hardwareKeyboard.set': catalog['hardwareKeyboard.set'],
|
||||
})
|
||||
);
|
||||
} else {
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
'For action usage: eas simulator:control <action> --help'
|
||||
);
|
||||
}
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects --output without an action before connecting', async () => {
|
||||
await expect(
|
||||
createCommand(['--id', 'session-123', '--output', './out.png']).runAsync()
|
||||
).rejects.toThrow('--output requires an action');
|
||||
expect(mockConnectHostActionsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes an established connection when catalog setup fails', async () => {
|
||||
requestAsync.mockImplementationOnce(async () => ok('not a catalog'));
|
||||
await expect(createCommand(['--list', '--id', 'session-123']).runAsync()).rejects.toThrow(
|
||||
@@ -536,6 +952,28 @@ describe(SimulatorControl, () => {
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows session and device details without terminal escape sequences', async () => {
|
||||
mockByIdAsync.mockResolvedValueOnce(
|
||||
makeDeviceRunSession({ name: '\x1b[31mCheckout QA\x1b[0m\nSpoofed\u202E' })
|
||||
);
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
]);
|
||||
await createCommand(['--import', filePath, '--id', 'session-123']).runAsync();
|
||||
const text = jest
|
||||
.mocked(Log.log)
|
||||
.mock.calls.map(([value]) => String(value))
|
||||
.join('\n');
|
||||
expect(text).toContain('Checkout QA Spoofed');
|
||||
expect(text).toContain('@testuser/testapp');
|
||||
expect(text).toContain('iPhone 17 Pro · iOS 26.4');
|
||||
expect(text).toContain('session-123');
|
||||
expect(text).toContain('simulator-udid');
|
||||
expect(text).not.toContain('\x1b[31m');
|
||||
expect(text).not.toContain('\u202E');
|
||||
expect(text).not.toContain('preview-token');
|
||||
});
|
||||
|
||||
it('lists the actions as JSON when no action is provided', async () => {
|
||||
await createCommand(['--id', 'session-123', '--json']).runAsync();
|
||||
|
||||
@@ -550,6 +988,100 @@ describe(SimulatorControl, () => {
|
||||
expect(actionRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it('shows fallback names when optional details are unavailable or blank', async () => {
|
||||
mockByIdAsync.mockResolvedValueOnce(makeDeviceRunSession({ name: ' \n ' }));
|
||||
jest.mocked(fetchHostActionDeviceAsync).mockResolvedValueOnce({ udid: 'simulator-udid' });
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
]);
|
||||
await createCommand(['--import', filePath, '--id', 'session-123']).runAsync();
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Unnamed session'));
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Unavailable'));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ importing: true, jsonFlag: false },
|
||||
{ importing: true, jsonFlag: true },
|
||||
{ importing: false, jsonFlag: true },
|
||||
])(
|
||||
'runs actions without display metadata (importing: $importing, json: $jsonFlag)',
|
||||
async ({ importing, jsonFlag }) => {
|
||||
jest
|
||||
.mocked(fetchHostActionDeviceAsync)
|
||||
.mockImplementationOnce(
|
||||
jest.requireActual('../../../simulator/hostActions').fetchHostActionDeviceAsync
|
||||
);
|
||||
const fetch = jest
|
||||
.spyOn(fetchModule, 'default')
|
||||
.mockRejectedValueOnce(new Error('Metadata request failed'));
|
||||
try {
|
||||
const filePath = await writeImportAsync([
|
||||
{ action: 'hardwareKeyboard.set', params: { value: 'off' } },
|
||||
]);
|
||||
await createCommand([
|
||||
...(importing ? ['--import', filePath] : ['hardwareKeyboard.set', 'off']),
|
||||
...(jsonFlag ? ['--json'] : []),
|
||||
'--id',
|
||||
'session-123',
|
||||
]).runAsync();
|
||||
expect(actionRequests()).toEqual([['hardwareKeyboard.set', { value: 'off' }]]);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
if (jsonFlag) {
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
session: expect.objectContaining({ device: { udid: 'simulator-udid' } }),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Unavailable'));
|
||||
}
|
||||
} finally {
|
||||
fetch.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('requires a session ID before opening a connection', async () => {
|
||||
const previous = process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
try {
|
||||
await expect(createCommand([]).runAsync()).rejects.toThrow(
|
||||
'No simulator session ID provided.'
|
||||
);
|
||||
expect(mockByIdAsync).not.toHaveBeenCalled();
|
||||
expect(mockConnectHostActionsAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
} else {
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the session ID loaded into the simulator environment', async () => {
|
||||
const previous = process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = 'dotenv-session';
|
||||
try {
|
||||
await createCommand([]).runAsync();
|
||||
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'dotenv-session');
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
} else {
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses Android before listing actions', async () => {
|
||||
mockByIdAsync.mockResolvedValue(makeDeviceRunSession({ platform: AppPlatform.Android }));
|
||||
await expect(createCommand(['--id', 'session-123']).runAsync()).rejects.toThrow(
|
||||
'not supported yet'
|
||||
);
|
||||
expect(mockConnectHostActionsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a session that is not running', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({ status: DeviceRunSessionStatus.Stopped })
|
||||
|
||||
@@ -33,11 +33,14 @@ import {
|
||||
resetSimulatorEnvAsync,
|
||||
} from '../../../simulator/env';
|
||||
import { resolveExpoGoSdkVersionAsync } from '../../../simulator/expoGo';
|
||||
import { applyStartConfigAsync } from '../../../simulator/startSetup';
|
||||
import { ControlConfigExecutionError } from '../../../simulator/controlConfig';
|
||||
import * as promiseUtils from '../../../utils/promise';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import Simulator from '../index';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('../../../simulator/startSetup');
|
||||
jest.mock('../../../analytics/AnalyticsManager', () => ({
|
||||
...jest.requireActual('../../../analytics/AnalyticsManager'),
|
||||
isAnalyticsOptedOutAsync: jest.fn(),
|
||||
@@ -195,6 +198,7 @@ describe(Simulator, () => {
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(applyStartConfigAsync).mockReset().mockResolvedValue([]);
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
mockJobRunErrorsByIdAsync.mockResolvedValue(null);
|
||||
mockAvailabilityByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: true });
|
||||
@@ -203,7 +207,7 @@ describe(Simulator, () => {
|
||||
id: 'session-123',
|
||||
status: DeviceRunSessionStatus.Stopped,
|
||||
});
|
||||
mockByIdAsync.mockResolvedValue(makeDeviceRunSession());
|
||||
mockByIdAsync.mockReset().mockResolvedValue(makeDeviceRunSession());
|
||||
mockLoadSimulatorEnvAsync.mockResolvedValue();
|
||||
mockResetSimulatorEnvAsync.mockResolvedValue();
|
||||
mockResolveExpoGoSdkVersionAsync.mockResolvedValue('55.0.0');
|
||||
@@ -238,6 +242,804 @@ describe(Simulator, () => {
|
||||
return { command, getContextAsync };
|
||||
}
|
||||
|
||||
describe('imported session configuration', () => {
|
||||
const configPath = '/test/simulator.json';
|
||||
const localEgressSession = makeDeviceRunSession({
|
||||
remoteConfig: {
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
|
||||
previewUrl: 'https://expo.dev/preview',
|
||||
previewApiUrl: 'https://sim.example.test',
|
||||
previewToken: 'private-token',
|
||||
egressUrl: 'https://egress.example.com',
|
||||
egressToken: 'token',
|
||||
egressFingerprint: 'fp',
|
||||
egressPort: 8899,
|
||||
},
|
||||
});
|
||||
function config(start: object = { platform: 'ios' }, actions?: object[]): void {
|
||||
jest
|
||||
.mocked(fs.readJson)
|
||||
.mockResolvedValue({ version: 1, start, ...(actions ? { actions } : {}) } as never);
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({
|
||||
remoteConfig: {
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
|
||||
previewUrl: 'https://expo.dev/preview',
|
||||
previewApiUrl: 'https://sim.example.test',
|
||||
previewToken: 'private-token',
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
it('enables JSON output before import preflight can fail', async () => {
|
||||
config({ unknown: true });
|
||||
const { command, getContextAsync } = createCommand(['--import', configPath, '--json']);
|
||||
await expect(command.runAsync()).rejects.toThrow('Unknown or invocation-only');
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalledTimes(1);
|
||||
expect(mockEnableJsonOutput.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(fs.readJson).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(getContextAsync).not.toHaveBeenCalled();
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates from saved flags with explicit CLI overrides and unchanged output without actions', async () => {
|
||||
config({
|
||||
platform: 'ios',
|
||||
name: 'Saved name',
|
||||
type: 'web-preview-only',
|
||||
device: 'Saved phone',
|
||||
tag: ['saved'],
|
||||
});
|
||||
const { command } = createCommand(['--import', configPath, '--name', 'CLI name', '--json']);
|
||||
await command.runAsync();
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({
|
||||
name: 'CLI name',
|
||||
type: DeviceRunSessionType.WebPreviewOnly,
|
||||
ios: { deviceIdentifier: 'Saved phone' },
|
||||
tags: ['saved'],
|
||||
})
|
||||
);
|
||||
expect(applyStartConfigAsync).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(printJsonOnlyOutput)).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ setup: expect.anything() })
|
||||
);
|
||||
});
|
||||
|
||||
it.each(
|
||||
(['ios', 'android'] as const).flatMap(platform =>
|
||||
(['attached', 'detached', 'json'] as const).flatMap(mode =>
|
||||
(['env', 'dotenv'] as const).flatMap(output =>
|
||||
[false, true].map(egress => ({ platform, mode, output, egress }))
|
||||
)
|
||||
)
|
||||
)
|
||||
)(
|
||||
'preserves ordinary startup with saved settings: $platform/$mode/$output, egress=$egress',
|
||||
async ({ platform, mode, output, egress }) => {
|
||||
const listeners = process.listeners('SIGINT');
|
||||
const start = {
|
||||
platform,
|
||||
type: 'web-preview-only',
|
||||
name: 'Startup regression',
|
||||
device: platform === 'ios' ? 'iPhone 17 Pro' : 'pixel_7',
|
||||
'out-config-type': output,
|
||||
...(egress ? { egress: 'local' } : {}),
|
||||
};
|
||||
const ready = makeDeviceRunSession({
|
||||
platform: platform === 'ios' ? AppPlatform.Ios : AppPlatform.Android,
|
||||
remoteConfig: {
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
|
||||
previewUrl: 'https://expo.dev/preview',
|
||||
previewApiUrl: 'https://sim.example.test',
|
||||
previewToken: 'private-token',
|
||||
...(egress
|
||||
? {
|
||||
egressUrl: 'https://egress.example.com',
|
||||
egressToken: 'token',
|
||||
egressFingerprint: 'fp',
|
||||
egressPort: 8899,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
const runAsync = async (saved: boolean): Promise<object> => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(fs.readJson).mockResolvedValue({ version: 1, start } as never);
|
||||
mockByIdAsync
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(ready)
|
||||
.mockResolvedValue({ ...ready, status: DeviceRunSessionStatus.Stopped });
|
||||
jest.mocked(runLocalEgressAsync).mockImplementation(async ({ signal, onConnected }) => {
|
||||
onConnected?.();
|
||||
await new Promise<void>(resolve => {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
resolve();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
const argv = saved
|
||||
? ['--import', configPath]
|
||||
: Object.entries(start).flatMap(([name, value]) => [`--${name}`, value]);
|
||||
if (mode !== 'attached') {
|
||||
argv.push(mode === 'json' ? '--json' : '--non-interactive');
|
||||
}
|
||||
await createCommand(argv).command.runAsync();
|
||||
expect(applyStartConfigAsync).not.toHaveBeenCalled();
|
||||
expect(fs.readJson).toHaveBeenCalledTimes(saved ? 1 : 0);
|
||||
expect(runLocalEgressAsync).toHaveBeenCalledTimes(egress && mode === 'attached' ? 1 : 0);
|
||||
expect(fs.writeFile).toHaveBeenCalledTimes(
|
||||
output === 'dotenv' ? (mode === 'json' ? 1 : 2) : 0
|
||||
);
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
return {
|
||||
create: mockCreateDeviceRunSessionAsync.mock.calls,
|
||||
output: jest.mocked(Log.log).mock.calls,
|
||||
warnings: jest.mocked(Log.warn).mock.calls,
|
||||
newLines: jest.mocked(Log.newLine).mock.calls,
|
||||
json: mockPrintJsonOnlyOutput.mock.calls,
|
||||
writes: jest.mocked(fs.writeFile).mock.calls,
|
||||
cleanup: mockResetSimulatorEnvAsync.mock.calls,
|
||||
stop: mockEnsureDeviceRunSessionStoppedAsync.mock.calls,
|
||||
egress: jest.mocked(runLocalEgressAsync).mock.calls.map(([options]) => ({
|
||||
url: options.url,
|
||||
port: options.port,
|
||||
platform: options.platform,
|
||||
aborted: options.signal.aborted,
|
||||
})),
|
||||
};
|
||||
};
|
||||
const ordinary = await runAsync(false);
|
||||
expect(await runAsync(true)).toEqual(ordinary);
|
||||
}
|
||||
);
|
||||
|
||||
it('applies actions after readiness and includes parsed setup results in one JSON object', async () => {
|
||||
config(undefined, [{ action: 'appearance.set', params: { value: 'dark' } }]);
|
||||
jest.mocked(applyStartConfigAsync).mockImplementationOnce(async () => {
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
return [{ action: 'appearance.set', output: '{"value":"dark"}', file: null }];
|
||||
});
|
||||
const { command } = createCommand(['--import', configPath, '--json']);
|
||||
await command.runAsync();
|
||||
expect(jest.mocked(applyStartConfigAsync).mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
mockByIdAsync.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(applyStartConfigAsync).toHaveBeenCalledWith(
|
||||
{ apiUrl: 'https://sim.example.test', token: 'private-token' },
|
||||
expect.objectContaining({ path: configPath }),
|
||||
{ signal: expect.any(AbortSignal), onProgress: undefined }
|
||||
);
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledTimes(1);
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'session-123',
|
||||
setup: {
|
||||
config: configPath,
|
||||
results: [{ action: 'appearance.set', output: { value: 'dark' }, file: null }],
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
expect(fs.writeFile).toHaveBeenCalledTimes(1);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
simulatorDotenvPath,
|
||||
SIMULATOR_DOTENV_FILE_HEADER + `${EAS_SIMULATOR_SESSION_ID}='session-123'\n`
|
||||
);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'prints normal ready output before importing, detached=%s',
|
||||
async detached => {
|
||||
config({ platform: 'ios' }, [{ action: 'tap', params: { x: 0.5, y: 0.5 } }]);
|
||||
const ready = makeDeviceRunSession({
|
||||
remoteConfig: {
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
|
||||
previewUrl: 'https://expo.dev/preview',
|
||||
previewApiUrl: 'https://sim.example.test',
|
||||
previewToken: 'private-token',
|
||||
},
|
||||
});
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(ready)
|
||||
.mockResolvedValueOnce({ ...ready, status: DeviceRunSessionStatus.Stopped });
|
||||
jest
|
||||
.mocked(applyStartConfigAsync)
|
||||
.mockImplementationOnce(async (_access, _config, options) => {
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
const output = jest.mocked(Log.log).mock.calls.map(([line]) => String(line));
|
||||
const preview = output.findIndex(line => line.includes(deviceRunSessionPreviewUrl));
|
||||
const importing = output.findIndex(line => line.includes('Importing 1 action from'));
|
||||
expect(preview).toBeGreaterThanOrEqual(0);
|
||||
expect(importing).toBeGreaterThan(preview);
|
||||
options.onProgress?.({ step: 1, total: 1, action: 'tap', status: 'running' });
|
||||
const result = { action: 'tap', output: '', file: null };
|
||||
options.onProgress?.({ step: 1, total: 1, action: 'tap', status: 'completed', result });
|
||||
return [result];
|
||||
});
|
||||
await createCommand([
|
||||
'--import',
|
||||
configPath,
|
||||
...(detached ? ['--non-interactive'] : []),
|
||||
]).command.runAsync();
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
simulatorDotenvPath,
|
||||
expect.stringContaining(`${EAS_SIMULATOR_SESSION_ID}='session-123'`)
|
||||
);
|
||||
const spinners = jest.mocked(ora).mock.results.map(result => result.value);
|
||||
expect(
|
||||
spinners.some(spinner =>
|
||||
spinner.succeed.mock.calls.some(([text]: [string]) => text === '[1/1] tap completed')
|
||||
)
|
||||
).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it('identifies failed and skipped imported steps after printing the preview', async () => {
|
||||
config({ platform: 'ios' }, [
|
||||
{ action: 'appearance.get' },
|
||||
{ action: 'tap' },
|
||||
{ action: 'clipboard.read' },
|
||||
]);
|
||||
jest
|
||||
.mocked(applyStartConfigAsync)
|
||||
.mockRejectedValueOnce(
|
||||
new ControlConfigExecutionError(
|
||||
2,
|
||||
'tap',
|
||||
[{ action: 'appearance.get', output: 'dark', file: null }],
|
||||
new Error('refused')
|
||||
)
|
||||
);
|
||||
await expect(
|
||||
createCommand(['--import', configPath, '--non-interactive']).command.runAsync()
|
||||
).rejects.toThrow('refused');
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalledWith('1 completed · 1 failed · 1 skipped');
|
||||
expect(jest.mocked(Log.log)).toHaveBeenCalledWith(' [3/3] clipboard.read skipped');
|
||||
const spinners = jest.mocked(ora).mock.results.map(result => result.value);
|
||||
expect(
|
||||
spinners.some(spinner =>
|
||||
spinner.fail.mock.calls.some(([text]: [string]) => text === '[2/3] tap failed')
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('prints imported text and saved files after the ready preview URL', async () => {
|
||||
config(undefined, [{ action: 'appearance.get' }, { action: 'screenshot.capture' }]);
|
||||
jest
|
||||
.mocked(applyStartConfigAsync)
|
||||
.mockImplementationOnce(async (_access, _config, options) => {
|
||||
const results = [
|
||||
{ action: 'appearance.get', output: 'dark', file: null },
|
||||
{ action: 'screenshot.capture', output: '', file: '/test/screenshot.png' },
|
||||
];
|
||||
for (const [index, result] of results.entries()) {
|
||||
options.onProgress?.({
|
||||
step: index + 1,
|
||||
total: results.length,
|
||||
action: result.action,
|
||||
status: 'completed',
|
||||
result,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
});
|
||||
await createCommand(['--import', configPath, '--non-interactive']).command.runAsync();
|
||||
const output = jest.mocked(Log.log).mock.calls.map(([line]) => String(line));
|
||||
const preview = output.findIndex(line => line.includes(deviceRunSessionPreviewUrl));
|
||||
expect(preview).toBeGreaterThanOrEqual(0);
|
||||
expect(output.indexOf('dark')).toBeGreaterThan(preview);
|
||||
expect(output.indexOf('Saved /test/screenshot.png')).toBeGreaterThan(preview);
|
||||
});
|
||||
|
||||
it('preserves the setup error when the stopped session dotenv cannot be cleaned up', async () => {
|
||||
config(undefined, [{ action: 'appearance.get' }]);
|
||||
jest.mocked(applyStartConfigAsync).mockRejectedValueOnce(new Error('native action refused'));
|
||||
mockResetSimulatorEnvAsync.mockRejectedValueOnce(new Error('read-only file'));
|
||||
const listeners = process.listeners('SIGINT');
|
||||
await expect(
|
||||
createCommand(['--import', configPath, '--non-interactive']).command.runAsync()
|
||||
).rejects.toThrow('native action refused');
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('could not be cleaned up: read-only file')
|
||||
);
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
});
|
||||
|
||||
it('stops only the newly created session on setup failure and retains completed JSON results', async () => {
|
||||
config(undefined, [{ action: 'appearance.set' }, { action: 'appearance.get' }]);
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = 'previous-session';
|
||||
jest
|
||||
.mocked(applyStartConfigAsync)
|
||||
.mockRejectedValue(
|
||||
new ControlConfigExecutionError(
|
||||
2,
|
||||
'appearance.get',
|
||||
[{ action: 'appearance.set', output: '"dark"', file: null }],
|
||||
new Error('refused')
|
||||
)
|
||||
);
|
||||
const prior = process.exitCode;
|
||||
const listeners = process.listenerCount('SIGINT');
|
||||
try {
|
||||
await createCommand(['--import', configPath, '--json']).command.runAsync();
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledTimes(1);
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'session-123',
|
||||
stopped: true,
|
||||
setup: expect.objectContaining({
|
||||
results: [{ action: 'appearance.set', output: 'dark', file: null }],
|
||||
error: expect.objectContaining({ step: 2, action: 'appearance.get' }),
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(process.listenerCount('SIGINT')).toBe(listeners);
|
||||
} finally {
|
||||
process.exitCode = prior;
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ imported: false, jsonFlag: true },
|
||||
{ imported: true, jsonFlag: true },
|
||||
{ imported: true, jsonFlag: false },
|
||||
])(
|
||||
'stops startup interrupted during its final dotenv write, imported=$imported, json=$jsonFlag',
|
||||
async ({ imported, jsonFlag }) => {
|
||||
jest.spyOn(process, 'exit').mockImplementation(code => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
config(undefined, imported ? [{ action: 'appearance.get' }] : undefined);
|
||||
jest.mocked(fs.writeFile).mockImplementationOnce(async () => {
|
||||
process.emit('SIGINT');
|
||||
});
|
||||
const listeners = process.listeners('SIGINT');
|
||||
await expect(
|
||||
createCommand([
|
||||
'--import',
|
||||
configPath,
|
||||
...(jsonFlag ? ['--json'] : ['--non-interactive']),
|
||||
]).command.runAsync()
|
||||
).rejects.toThrow('process.exit(130)');
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(
|
||||
['interactive', 'detached', 'json'].flatMap(mode =>
|
||||
['failure', 'interruption'].map(outcome => ({ mode, outcome }))
|
||||
)
|
||||
)(
|
||||
'keeps previous session credentials after imported $outcome in $mode mode',
|
||||
async ({ mode, outcome }) => {
|
||||
config(undefined, [{ action: 'appearance.set' }]);
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = 'previous-session';
|
||||
const previous =
|
||||
SIMULATOR_DOTENV_FILE_HEADER +
|
||||
"EAS_SIMULATOR_SESSION_ID='previous-session'\nEAS_SIMULATOR_EGRESS_TOKEN='previous-token'\n";
|
||||
let saved = previous;
|
||||
jest.mocked(fs.readFile).mockImplementation(async () => saved as never);
|
||||
jest.mocked(fs.writeFile).mockImplementation(async (_path, content) => {
|
||||
saved = String(content);
|
||||
});
|
||||
mockResetSimulatorEnvAsync.mockImplementationOnce(
|
||||
jest.requireActual('../../../simulator/env').resetSimulatorEnvAsync
|
||||
);
|
||||
jest
|
||||
.mocked(applyStartConfigAsync)
|
||||
.mockImplementationOnce(async (_access, _config, { signal }) => {
|
||||
if (outcome === 'interruption') {
|
||||
process.emit('SIGINT');
|
||||
signal.throwIfAborted();
|
||||
}
|
||||
throw new Error('setup failed');
|
||||
});
|
||||
jest.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('interrupted');
|
||||
});
|
||||
const priorExitCode = process.exitCode;
|
||||
const listeners = process.listeners('SIGINT');
|
||||
try {
|
||||
const command = createCommand([
|
||||
'--import',
|
||||
configPath,
|
||||
...(mode === 'json' ? ['--json'] : mode === 'detached' ? ['--non-interactive'] : []),
|
||||
]).command;
|
||||
if (mode === 'json' && outcome === 'failure') {
|
||||
await command.runAsync();
|
||||
expect(process.exitCode).toBe(1);
|
||||
} else {
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
outcome === 'interruption' ? 'interrupted' : 'setup failed'
|
||||
);
|
||||
}
|
||||
expect(saved).toBe(previous);
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
} finally {
|
||||
process.exitCode = priorExitCode;
|
||||
jest.mocked(fs.readFile).mockReset();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('reports unconfirmed cleanup rather than implying the session stopped', async () => {
|
||||
config(undefined, [{ action: 'appearance.set' }]);
|
||||
jest.mocked(applyStartConfigAsync).mockRejectedValue(new Error('setup failed'));
|
||||
mockEnsureDeviceRunSessionStoppedAsync.mockRejectedValue(new Error('network failed'));
|
||||
const prior = process.exitCode;
|
||||
try {
|
||||
await createCommand(['--import', configPath, '--json']).command.runAsync();
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ stopped: false })
|
||||
);
|
||||
expect(mockResetSimulatorEnvAsync).not.toHaveBeenCalled();
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('simulator:stop --id session-123')
|
||||
);
|
||||
} finally {
|
||||
process.exitCode = prior;
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects Android actions and invalid files before creating a session', async () => {
|
||||
config({ platform: 'android' }, [{ action: 'appearance.set' }]);
|
||||
await expect(
|
||||
createCommand(['--import', configPath, '--non-interactive']).command.runAsync()
|
||||
).rejects.toThrow('not supported on Android');
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
config({ typo: 'value' });
|
||||
const { command, getContextAsync } = createCommand(['--import', configPath]);
|
||||
await expect(command.runAsync()).rejects.toThrow('Unknown or invocation-only');
|
||||
expect(getContextAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects explicit capture fields when the CLI disables saved capture', async () => {
|
||||
config({ platform: 'ios', 'network-capture': true });
|
||||
await expect(
|
||||
createCommand([
|
||||
'--import',
|
||||
configPath,
|
||||
'--no-network-capture',
|
||||
'--network-capture-field',
|
||||
'header',
|
||||
]).command.runAsync()
|
||||
).rejects.toThrow('--network-capture-field requires --network-capture');
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows Android start settings without actions', async () => {
|
||||
config({ platform: 'android' });
|
||||
await createCommand(['--import', configPath, '--json']).command.runAsync();
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ platform: AppPlatform.Android })
|
||||
);
|
||||
expect(applyStartConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the existing platform prompt and attached-session lifecycle in interactive mode', async () => {
|
||||
config({}, [{ action: 'appearance.set' }]);
|
||||
const ready = makeDeviceRunSession({
|
||||
remoteConfig: {
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
|
||||
previewUrl: 'https://expo.dev/preview',
|
||||
previewApiUrl: 'https://sim.example.test',
|
||||
previewToken: 'private-token',
|
||||
},
|
||||
});
|
||||
mockPromptAsync.mockResolvedValueOnce({ selectedPlatform: AppPlatform.Ios });
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(ready)
|
||||
.mockResolvedValueOnce({ ...ready, status: DeviceRunSessionStatus.Stopped });
|
||||
const listeners = process.listeners('SIGINT');
|
||||
await createCommand(['--import', configPath]).command.runAsync();
|
||||
expect(mockPromptAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'Select platform' })
|
||||
);
|
||||
expect(applyStartConfigAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockByIdAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([['--non-interactive'], ['--json']])(
|
||||
'refuses an incomplete platform without prompting: %j',
|
||||
async mode => {
|
||||
config({}, [{ action: 'appearance.set' }]);
|
||||
await expect(
|
||||
createCommand(['--import', configPath, mode]).command.runAsync()
|
||||
).rejects.toThrow('platform flag must be set');
|
||||
expect(mockPromptAsync).not.toHaveBeenCalled();
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['CI', 'pipe'])('inherits non-interactive defaults for %s', async mode => {
|
||||
config({}, [{ action: 'appearance.set' }]);
|
||||
const priorCi = process.env.CI;
|
||||
const priorTty = process.stdin.isTTY;
|
||||
try {
|
||||
if (mode === 'CI') {
|
||||
process.env.CI = 'true';
|
||||
} else {
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: false, writable: true });
|
||||
}
|
||||
await expect(createCommand(['--import', configPath]).command.runAsync()).rejects.toThrow(
|
||||
'platform flag must be set'
|
||||
);
|
||||
expect(mockPromptAsync).not.toHaveBeenCalled();
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (priorCi === undefined) {
|
||||
delete process.env.CI;
|
||||
} else {
|
||||
process.env.CI = priorCi;
|
||||
}
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: priorTty, writable: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('connects interactive local egress before applying imported actions and closes it on session end', async () => {
|
||||
config({ platform: 'ios', egress: 'local' }, [{ action: 'appearance.set' }]);
|
||||
const ready = localEgressSession;
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(ready)
|
||||
.mockResolvedValueOnce({ ...ready, status: DeviceRunSessionStatus.Stopped });
|
||||
let connected = false;
|
||||
let tunnelSignal: AbortSignal | undefined;
|
||||
jest.mocked(runLocalEgressAsync).mockImplementationOnce(async ({ signal, onConnected }) => {
|
||||
tunnelSignal = signal;
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
connected = true;
|
||||
onConnected?.();
|
||||
await new Promise<void>(resolve => {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
resolve();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
jest.mocked(applyStartConfigAsync).mockImplementationOnce(async () => {
|
||||
expect(connected).toBe(true);
|
||||
return [];
|
||||
});
|
||||
await createCommand(['--import', configPath]).command.runAsync();
|
||||
expect(tunnelSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it.each([['--non-interactive'], ['--json']])(
|
||||
'rejects actions with detached local egress before creating a session: %j',
|
||||
async mode => {
|
||||
config({ platform: 'ios', egress: 'local' }, [{ action: 'appearance.get' }]);
|
||||
await expect(
|
||||
createCommand(['--import', configPath, mode]).command.runAsync()
|
||||
).rejects.toThrow('Imported actions with local egress require interactive mode');
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
expect(applyStartConfigAsync).not.toHaveBeenCalled();
|
||||
expect(runLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(mockPromptAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('cleans up and preserves an egress connection failure before setup', async () => {
|
||||
config({ platform: 'ios', egress: 'local' }, [{ action: 'appearance.get' }]);
|
||||
mockByIdAsync.mockResolvedValue(localEgressSession);
|
||||
const failure = new Error('egress could not bind');
|
||||
jest.mocked(runLocalEgressAsync).mockRejectedValueOnce(failure);
|
||||
const listeners = process.listeners('SIGINT');
|
||||
await expect(createCommand(['--import', configPath]).command.runAsync()).rejects.toBe(
|
||||
failure
|
||||
);
|
||||
expect(applyStartConfigAsync).not.toHaveBeenCalled();
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
});
|
||||
|
||||
it('stops setup instead of running without requested local egress settings', async () => {
|
||||
config({ platform: 'ios', egress: 'local' }, [{ action: 'appearance.get' }]);
|
||||
await expect(createCommand(['--import', configPath]).command.runAsync()).rejects.toThrow(
|
||||
'did not provide local egress connection settings'
|
||||
);
|
||||
expect(applyStartConfigAsync).not.toHaveBeenCalled();
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['action', 'egress'])(
|
||||
'preserves a %s failure during connected setup and closes the tunnel',
|
||||
async source => {
|
||||
config({ platform: 'ios', egress: 'local' }, [{ action: 'appearance.get' }]);
|
||||
mockByIdAsync.mockResolvedValue(localEgressSession);
|
||||
const failure = new Error(`${source} failed during setup`);
|
||||
let rejectTunnel: (error: Error) => void = () => {};
|
||||
let tunnelClosed = false;
|
||||
jest.mocked(runLocalEgressAsync).mockImplementationOnce(async ({ signal, onConnected }) => {
|
||||
onConnected?.();
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
rejectTunnel = reject;
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
resolve();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
tunnelClosed = true;
|
||||
}
|
||||
});
|
||||
jest
|
||||
.mocked(applyStartConfigAsync)
|
||||
.mockImplementationOnce(async (_access, _config, { signal }) => {
|
||||
if (source === 'action') {
|
||||
throw failure;
|
||||
}
|
||||
rejectTunnel(failure);
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
reject(new Error('action aborted'));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
return [];
|
||||
});
|
||||
const listeners = process.listeners('SIGINT');
|
||||
await expect(createCommand(['--import', configPath]).command.runAsync()).rejects.toBe(
|
||||
failure
|
||||
);
|
||||
expect(tunnelClosed).toBe(true);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
}
|
||||
);
|
||||
|
||||
it('bounds the wait for local egress and closes the tunnel on timeout', async () => {
|
||||
config({ platform: 'ios', egress: 'local' }, [{ action: 'appearance.get' }]);
|
||||
mockByIdAsync.mockResolvedValue(localEgressSession);
|
||||
let tunnelSignal: AbortSignal | undefined;
|
||||
jest.mocked(runLocalEgressAsync).mockImplementationOnce(async ({ signal }) => {
|
||||
tunnelSignal = signal;
|
||||
await new Promise<void>(resolve => {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
resolve();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const result = expect(
|
||||
createCommand(['--import', configPath]).command.runAsync()
|
||||
).rejects.toThrow('did not connect within 120 seconds');
|
||||
await jest.advanceTimersByTimeAsync(120_000);
|
||||
await result;
|
||||
expect(tunnelSignal?.aborted).toBe(true);
|
||||
expect(applyStartConfigAsync).not.toHaveBeenCalled();
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('closes local egress and stops the session on Ctrl-C before the tunnel connects', async () => {
|
||||
config({ platform: 'ios', egress: 'local' }, [{ action: 'appearance.get' }]);
|
||||
mockByIdAsync.mockResolvedValue(localEgressSession);
|
||||
const interrupted = new Error('exit 130');
|
||||
jest.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw interrupted;
|
||||
});
|
||||
let tunnelClosed = false;
|
||||
jest.mocked(runLocalEgressAsync).mockImplementationOnce(async ({ signal }) => {
|
||||
await new Promise<void>(resolve => {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
resolve();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
setImmediate(() => {
|
||||
process.emit('SIGINT');
|
||||
});
|
||||
});
|
||||
tunnelClosed = true;
|
||||
});
|
||||
const listeners = process.listeners('SIGINT');
|
||||
await expect(createCommand(['--import', configPath]).command.runAsync()).rejects.toBe(
|
||||
interrupted
|
||||
);
|
||||
expect(tunnelClosed).toBe(true);
|
||||
expect(applyStartConfigAsync).not.toHaveBeenCalled();
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
});
|
||||
|
||||
it('stops on Ctrl-C during setup and removes the session handler', async () => {
|
||||
config(undefined, [{ action: 'appearance.set' }]);
|
||||
const interrupted = new Error('exit 130');
|
||||
const exit = jest.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw interrupted;
|
||||
});
|
||||
const listeners = process.listenerCount('SIGINT');
|
||||
jest
|
||||
.mocked(applyStartConfigAsync)
|
||||
.mockImplementation(async (_access, _config, { signal }) => {
|
||||
process.emit('SIGINT');
|
||||
signal.throwIfAborted();
|
||||
return [];
|
||||
});
|
||||
await expect(
|
||||
createCommand(['--import', configPath, '--non-interactive']).command.runAsync()
|
||||
).rejects.toBe(interrupted);
|
||||
expect(exit).toHaveBeenCalledWith(130);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(process.listenerCount('SIGINT')).toBe(listeners);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails with the waitlist link without creating a session when the account is gated', async () => {
|
||||
mockAvailabilityByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: false });
|
||||
|
||||
@@ -883,38 +1685,6 @@ describe(Simulator, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('accumulates repeated --android-log-source flags in order', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'android',
|
||||
'--non-interactive',
|
||||
'--android-log-source',
|
||||
'emulator-output',
|
||||
'--android-log-source',
|
||||
'device-logcat',
|
||||
]);
|
||||
await command.runAsync();
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ android: { logSources: ['emulator-output', 'device-logcat'] } })
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves Android log sources omitted unless explicitly requested', async () => {
|
||||
const { command } = createCommand(['--platform', 'android', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
expect(mockCreateDeviceRunSessionAsync.mock.calls[0][1].android?.logSources).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ args: ['--platform', 'ios', '--android-log-source', 'device-logcat'] },
|
||||
{ args: ['--platform', 'android', '--android-log-source', 'unknown'] },
|
||||
])('rejects invalid Android logging options $args', async ({ args }) => {
|
||||
const { command } = createCommand([...args, '--non-interactive']);
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes --network-capture to the createDeviceRunSession mutation', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
@@ -1863,7 +2633,14 @@ describe(Simulator, () => {
|
||||
'stops %s after an interactive egress failure and removes its interrupt handler',
|
||||
async (typeFlag, type, remoteConfig) => {
|
||||
const failure = new Error('listen EADDRINUSE');
|
||||
jest.mocked(runLocalEgressAsync).mockRejectedValueOnce(failure);
|
||||
jest.mocked(runLocalEgressAsync).mockImplementationOnce(async () => {
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
simulatorDotenvPath,
|
||||
expect.stringContaining("EAS_SIMULATOR_EGRESS_TOKEN='pw'")
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining(deviceRunSessionUrl));
|
||||
throw failure;
|
||||
});
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({
|
||||
type,
|
||||
|
||||
@@ -11,9 +11,14 @@ import {
|
||||
formatSimulatorUnavailableMessage,
|
||||
} from '../../simulator/utils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import {
|
||||
getSimulatorCommandAliases,
|
||||
getSimulatorCommandHiddenAliases,
|
||||
} from '../../simulator/commandAliases';
|
||||
|
||||
export default class SimulatorAvailability extends EasCommand {
|
||||
static override aliases = ['sim:availability'];
|
||||
static override aliases = getSimulatorCommandAliases('availability');
|
||||
static override hiddenAliases = getSimulatorCommandHiddenAliases('availability');
|
||||
static override description =
|
||||
'[EXPERIMENTAL] check whether EAS Simulator is enabled for the current project account';
|
||||
|
||||
|
||||
@@ -1,17 +1,37 @@
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import path from 'node:path';
|
||||
import { stripVTControlCharacters } from 'node:util';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { AppPlatform, DeviceRunSessionStatus } from '../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import {
|
||||
assertControlKeepAliveSupported,
|
||||
borrowControlConnectionAsync,
|
||||
createControlConnectionIdentityAsync,
|
||||
openControlConnectionAsync,
|
||||
} from '../../simulator/controlConnection';
|
||||
import { ControlSession } from '../../simulator/controlConnectionProtocol';
|
||||
import { loadControlSessionAsync } from '../../simulator/controlSession';
|
||||
import {
|
||||
ControlAction,
|
||||
executeControlActionAsync,
|
||||
parseControlActionOutput,
|
||||
prepareControlActionAsync,
|
||||
} from '../../simulator/controlActions';
|
||||
import {
|
||||
ControlConfigExecutionError,
|
||||
ControlConfigResult,
|
||||
PreparedControlConfig,
|
||||
executeControlConfigAsync,
|
||||
prepareControlConfigAsync,
|
||||
serializeControlConfigResults,
|
||||
} from '../../simulator/controlConfig';
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
@@ -21,21 +41,43 @@ import {
|
||||
HostActionCatalog,
|
||||
HostActionCatalogEntry,
|
||||
HostActionConnection,
|
||||
assertHostActionOutputAsync,
|
||||
buildHostActionParams,
|
||||
connectHostActionsAsync,
|
||||
fetchHostActionCatalogAsync,
|
||||
fetchHostActionDeviceAsync,
|
||||
formatHostActionUsage,
|
||||
saveHostActionOutputAsync,
|
||||
uploadFileAsync,
|
||||
} from '../../simulator/hostActions';
|
||||
import { PreviewApiAccess, getPreviewApiAccess } from '../../simulator/utils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import {
|
||||
getSimulatorCommandAliases,
|
||||
getSimulatorCommandHiddenAliases,
|
||||
} from '../../simulator/commandAliases';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import { sanitizeTerminalText, truncate } from '../../utils/terminalText';
|
||||
import { formatMilliseconds } from '../../utils/timer';
|
||||
|
||||
export default class SimulatorControl extends EasCommand {
|
||||
static override aliases = ['sim:control'];
|
||||
static override description =
|
||||
'[EXPERIMENTAL] run a serve-sim action on a remote iOS simulator session on EAS. Run without an action to list the actions the session supports. To save a screenshot locally, run `eas simulator:control screenshot.capture --output ./screenshot.png`';
|
||||
static override aliases = getSimulatorCommandAliases('control');
|
||||
static override hiddenAliases = getSimulatorCommandHiddenAliases('control');
|
||||
static override description = `[EXPERIMENTAL] control a remote iOS simulator session on EAS
|
||||
|
||||
Run without an action to list available actions. Use <action> --help for usage.
|
||||
|
||||
Use --keep-alive to reuse the connection across commands, or prepare it with simulator:control:connection open. Connections close after 120 seconds idle by default; use --idle-timeout to change the timeout in seconds.
|
||||
|
||||
To save a screenshot locally, run \`eas simulator:control screenshot.capture --output ./screenshot.png\`.
|
||||
|
||||
Use --import to run the actions from a JSON file. The start section is used only by simulator:start:
|
||||
|
||||
{
|
||||
"version": 1,
|
||||
"start": {"device": "iPhone 17 Pro", "type": "web-preview-only"},
|
||||
"actions": [{"action": "appearance.set", "params": {"value": "dark"}}]
|
||||
}
|
||||
|
||||
The file is checked before any actions run. Actions run in order and stop on the first failure. Local paths are relative to the JSON file; paths in params refer to the simulator host.
|
||||
|
||||
Existing output files are overwritten. When saving to a directory, an existing file with the generated name causes the action to fail.`;
|
||||
static override strict = false;
|
||||
|
||||
static override examples = [
|
||||
@@ -43,6 +85,7 @@ export default class SimulatorControl extends EasCommand {
|
||||
'$ eas simulator:control --id <session-id>',
|
||||
'$ eas simulator:control screenshot.capture --help',
|
||||
'$ eas simulator:control clipboard.write -- --some-text',
|
||||
'$ eas simulator:control --import ./simulator-control.json',
|
||||
'$ eas simulator:control hardwareKeyboard.set off',
|
||||
'$ eas simulator:control permissions.set grant camera com.example.app',
|
||||
'$ eas simulator:control app.install ./MyApp.ipa',
|
||||
@@ -53,24 +96,36 @@ export default class SimulatorControl extends EasCommand {
|
||||
static override args = {
|
||||
action: Args.string({
|
||||
description:
|
||||
'Action to run, followed by its values in order or as name=value. Omit to list the actions. Put -- before values that start with --.',
|
||||
'Action name, followed by values or name=value pairs. Omit to list actions. Use -- before values starting with --.',
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
help: Flags.boolean({
|
||||
char: 'h',
|
||||
description: 'Show command help, or usage for the selected action from the session.',
|
||||
description: 'Show help for this command or the selected action',
|
||||
}),
|
||||
id: Flags.string({
|
||||
description: `Simulator session ID. Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`,
|
||||
}),
|
||||
list: Flags.boolean({
|
||||
description: 'List the actions the session supports.',
|
||||
description: 'List available actions',
|
||||
}),
|
||||
output: Flags.string({
|
||||
description: 'Local file or existing directory for saved output',
|
||||
}),
|
||||
import: Flags.string({
|
||||
description: 'Run saved actions from a JSON file',
|
||||
}),
|
||||
'keep-alive': Flags.boolean({
|
||||
description:
|
||||
'Local path to save a returned file. For screenshots, --output ./screenshot.png sets the local filename; omit it to save with a generated filename in the current directory.',
|
||||
'Keep the control connection available for subsequent commands (macOS and Linux)',
|
||||
}),
|
||||
'idle-timeout': Flags.integer({
|
||||
min: 1,
|
||||
max: 86_400,
|
||||
description: 'Seconds of idle time before closing the kept-alive connection (default: 120)',
|
||||
dependsOn: ['keep-alive'],
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
@@ -90,11 +145,23 @@ export default class SimulatorControl extends EasCommand {
|
||||
if (flags.id === '') {
|
||||
throw new Error('--id needs a simulator session ID.');
|
||||
}
|
||||
const idleTimeoutMs =
|
||||
flags['idle-timeout'] !== undefined ? flags['idle-timeout'] * 1_000 : undefined;
|
||||
if (flags['keep-alive']) {
|
||||
assertControlKeepAliveSupported();
|
||||
}
|
||||
const action = flags.list && !flags.help ? undefined : args.action;
|
||||
const importing = flags.import !== undefined;
|
||||
if (importing && (args.action || flags.list || flags.output !== undefined)) {
|
||||
throw new Error('--import cannot be combined with an action, --list, or --output.');
|
||||
}
|
||||
if (!action && flags.output) {
|
||||
throw new Error('--output requires an action.');
|
||||
}
|
||||
|
||||
const {
|
||||
projectDir,
|
||||
loggedIn: { graphqlClient },
|
||||
loggedIn: { graphqlClient, authenticationInfo },
|
||||
} = await this.getContextAsync(SimulatorControl, {
|
||||
nonInteractive,
|
||||
});
|
||||
@@ -111,41 +178,202 @@ export default class SimulatorControl extends EasCommand {
|
||||
let connection: HostActionConnection | undefined;
|
||||
try {
|
||||
let catalog: HostActionCatalog;
|
||||
let controlSession: ControlSession;
|
||||
try {
|
||||
const session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, sessionId);
|
||||
connection = await connectHostActionsAsync(assertControllableSession(session));
|
||||
catalog = await fetchHostActionCatalogAsync(connection);
|
||||
spinner.succeed(`Connected to simulator session ${sessionId}`);
|
||||
const identity = await createControlConnectionIdentityAsync(
|
||||
sessionId,
|
||||
projectDir,
|
||||
authenticationInfo
|
||||
);
|
||||
if (flags['keep-alive']) {
|
||||
await openControlConnectionAsync(
|
||||
identity,
|
||||
() => loadControlSessionAsync(graphqlClient, sessionId),
|
||||
idleTimeoutMs
|
||||
);
|
||||
}
|
||||
const borrowed = await borrowControlConnectionAsync(identity);
|
||||
if (flags['keep-alive'] && !borrowed) {
|
||||
throw new Error(
|
||||
'The control helper closed before it could be used. Open the connection again.'
|
||||
);
|
||||
}
|
||||
if (borrowed) {
|
||||
connection = borrowed.connection;
|
||||
catalog = borrowed.catalog;
|
||||
controlSession = borrowed.info.session;
|
||||
spinner.stop();
|
||||
if (flags['keep-alive'] && !action && !importing && !flags.list && !flags.help) {
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({ connected: true, id: sessionId, connection: borrowed.info });
|
||||
} else {
|
||||
Log.succeed(
|
||||
`Control connection ready · idle timeout: ${formatMilliseconds(borrowed.info.idleTimeoutMs)}`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const { access, session } = await loadControlSessionAsync(graphqlClient, sessionId);
|
||||
connection = await connectHostActionsAsync(access);
|
||||
catalog = await fetchHostActionCatalogAsync(connection);
|
||||
controlSession = {
|
||||
...session,
|
||||
device:
|
||||
importing || (jsonFlag && Boolean(action) && !flags.list && !flags.help)
|
||||
? await fetchHostActionDeviceAsync(access, connection.udid)
|
||||
: { udid: connection.udid },
|
||||
};
|
||||
spinner.succeed(`Connected to simulator session ${sessionId}`);
|
||||
}
|
||||
} catch (err) {
|
||||
spinner.fail(`Failed to connect to simulator session ${sessionId}`);
|
||||
throw err;
|
||||
}
|
||||
if (!action) {
|
||||
if (!action && !importing) {
|
||||
this.printCatalog(catalog, jsonFlag);
|
||||
return;
|
||||
}
|
||||
const entry = Object.hasOwn(catalog, action) ? catalog[action] : undefined;
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Unknown simulator action "${action}". Run \`eas simulator:control\` to see the actions.`
|
||||
);
|
||||
}
|
||||
if (flags.help) {
|
||||
if (flags.help && action) {
|
||||
const entry = Object.hasOwn(catalog, action) ? catalog[action] : undefined;
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Unknown simulator action "${action}". Run \`eas simulator:control --list\` to see the actions.`
|
||||
);
|
||||
}
|
||||
this.printActionHelp(action, entry, jsonFlag);
|
||||
return;
|
||||
}
|
||||
await this.runActionAsync(connection, entry, {
|
||||
sessionId,
|
||||
action,
|
||||
values: argv.slice(1).map(String),
|
||||
outputPath: flags.output,
|
||||
jsonFlag,
|
||||
});
|
||||
if (importing) {
|
||||
await this.importConfigAsync(connection, catalog, flags.import!, {
|
||||
session: controlSession,
|
||||
jsonFlag,
|
||||
});
|
||||
} else {
|
||||
const entry = Object.hasOwn(catalog, action!) ? catalog[action!] : undefined;
|
||||
await this.runActionAsync(
|
||||
connection,
|
||||
catalog,
|
||||
{
|
||||
action: action!,
|
||||
params: entry ? buildHostActionParams(action!, entry, argv.slice(1).map(String)) : {},
|
||||
outputPath: flags.output,
|
||||
},
|
||||
{ session: controlSession, jsonFlag }
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
connection?.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async importConfigAsync(
|
||||
connection: HostActionConnection,
|
||||
catalog: HostActionCatalog,
|
||||
filePath: string,
|
||||
{ session, jsonFlag }: { session: ControlSession; jsonFlag: boolean }
|
||||
): Promise<void> {
|
||||
const spinner = jsonFlag ? undefined : ora(`Validating ${path.basename(filePath)}`).start();
|
||||
const startedAt = Date.now();
|
||||
let stepStartedAt = startedAt;
|
||||
let config: PreparedControlConfig | undefined;
|
||||
let results: ControlConfigResult[];
|
||||
try {
|
||||
config = await prepareControlConfigAsync(catalog, filePath, connection.udid);
|
||||
if (spinner) {
|
||||
spinner.succeed(`Validated ${formatActionCount(config.actions.length)}`);
|
||||
this.printSessionHeader(session);
|
||||
Log.log(chalk.bold(path.basename(config.path)));
|
||||
Log.gray(config.path);
|
||||
for (const { plan } of config.actions) {
|
||||
if (plan.localFile) {
|
||||
Log.gray(` Read: ${plan.localFile.path}`);
|
||||
}
|
||||
if (plan.outputPath) {
|
||||
Log.gray(` Output: ${plan.outputPath}`);
|
||||
}
|
||||
}
|
||||
if (config.actions.some(({ plan }) => plan.outputPath)) {
|
||||
Log.gray(
|
||||
'Explicit output files are overwritten; directory outputs use simulator filenames and fail if the file exists.'
|
||||
);
|
||||
}
|
||||
Log.newLine();
|
||||
}
|
||||
const prepared = config;
|
||||
results = await executeControlConfigAsync(connection, prepared, {
|
||||
onProgress: spinner
|
||||
? progress => {
|
||||
const label = `[${progress.step}/${progress.total}] ${progress.action}`;
|
||||
if (progress.status === 'running') {
|
||||
stepStartedAt = Date.now();
|
||||
spinner.start(label);
|
||||
} else {
|
||||
spinner.succeed(`${label} ${chalk.dim(formatElapsed(stepStartedAt))}`);
|
||||
this.printConfigResult(progress.result);
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ControlConfigExecutionError && config) {
|
||||
spinner?.fail(
|
||||
`[${error.step}/${config.actions.length}] ${error.action} ${chalk.dim(formatElapsed(stepStartedAt))}`
|
||||
);
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({
|
||||
id: session.id,
|
||||
session,
|
||||
config: config.path,
|
||||
results: serializeControlConfigResults(error.completed),
|
||||
error: { step: error.step, action: error.action, message: error.message },
|
||||
});
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const skipped = config.actions.length - error.step;
|
||||
Log.newLine();
|
||||
Log.log(`${error.completed.length} completed · 1 failed · ${skipped} skipped`);
|
||||
for (const [index, { plan }] of config.actions.entries()) {
|
||||
if (index >= error.step) {
|
||||
Log.gray(` [${index + 1}/${config.actions.length}] ${plan.action} skipped`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
spinner?.fail(config ? 'Import failed' : 'Validation failed. No actions were run.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({
|
||||
id: session.id,
|
||||
session,
|
||||
config: config.path,
|
||||
results: serializeControlConfigResults(results),
|
||||
});
|
||||
} else {
|
||||
Log.newLine();
|
||||
Log.succeed(
|
||||
`Import complete · ${formatActionCount(results.length)} · ${formatElapsed(startedAt)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private printConfigResult(result: ControlConfigResult): void {
|
||||
if (result.file) {
|
||||
Log.log(` Saved: ${result.file}`);
|
||||
} else if (result.output) {
|
||||
const output = parseControlActionOutput(result.output);
|
||||
const text = typeof output === 'string' ? output : JSON.stringify(output, null, 2);
|
||||
Log.log(
|
||||
text
|
||||
.split('\n')
|
||||
.map(line => ` ${line}`)
|
||||
.join('\n')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private printCatalog(catalog: HostActionCatalog, jsonFlag: boolean): void {
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(
|
||||
@@ -210,63 +438,59 @@ export default class SimulatorControl extends EasCommand {
|
||||
Log.newLine();
|
||||
}
|
||||
|
||||
private printSessionHeader(session: ControlSession): void {
|
||||
const clean = (value: string): string =>
|
||||
sanitizeTerminalText(
|
||||
stripVTControlCharacters(value).replace(/[\u202A-\u202E\u2066-\u2069]/g, '')
|
||||
);
|
||||
const runtime = clean(session.device.runtime ?? '')
|
||||
.replace(/^([A-Za-z]+)-/, '$1 ')
|
||||
.replaceAll('-', '.');
|
||||
Log.newLine();
|
||||
Log.log(chalk.bold('Simulator controls'));
|
||||
Log.log(
|
||||
formatFields([
|
||||
{ label: 'Session', value: truncate(clean(session.name ?? '') || 'Unnamed session', 80) },
|
||||
{ label: 'Project', value: clean(session.project) },
|
||||
{
|
||||
label: 'Device',
|
||||
value: [truncate(clean(session.device.name ?? '') || 'Unavailable', 80), runtime]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
},
|
||||
{ label: 'State', value: session.status.toLowerCase().replaceAll('_', ' ') },
|
||||
{ label: 'Session ID', value: clean(session.id) },
|
||||
{ label: 'Device ID', value: clean(session.device.udid) },
|
||||
])
|
||||
);
|
||||
Log.newLine();
|
||||
}
|
||||
|
||||
private async runActionAsync(
|
||||
connection: HostActionConnection,
|
||||
entry: HostActionCatalogEntry,
|
||||
{
|
||||
sessionId,
|
||||
action,
|
||||
values,
|
||||
outputPath,
|
||||
jsonFlag,
|
||||
}: {
|
||||
sessionId: string;
|
||||
action: string;
|
||||
values: string[];
|
||||
outputPath: string | undefined;
|
||||
jsonFlag: boolean;
|
||||
}
|
||||
catalog: HostActionCatalog,
|
||||
selection: ControlAction,
|
||||
{ session, jsonFlag }: { session: ControlSession; jsonFlag: boolean }
|
||||
): Promise<void> {
|
||||
const params = buildHostActionParams(action, entry, values);
|
||||
await assertHostActionOutputAsync(action, entry, outputPath);
|
||||
const localFile = await findLocalUploadAsync(entry, params);
|
||||
|
||||
const spinner = ora(`Running ${action} on simulator session ${sessionId}`).start();
|
||||
let uploadId: string | undefined;
|
||||
let succeeded = false;
|
||||
const { action } = selection;
|
||||
const plan = await prepareControlActionAsync(catalog, selection);
|
||||
const spinner = ora(`Running ${action} on simulator session ${session.id}`).start();
|
||||
let output: string;
|
||||
let savedFile: string | null;
|
||||
try {
|
||||
if (localFile) {
|
||||
spinner.text = `Uploading ${localFile.path}`;
|
||||
const upload = await uploadFileAsync(connection, localFile.path);
|
||||
uploadId = upload.uploadId;
|
||||
params[localFile.param] = entry.uploadAs === 'id' ? upload.uploadId : upload.path;
|
||||
spinner.text = `Running ${action} on simulator session ${sessionId}`;
|
||||
}
|
||||
const result = await connection.requestAsync(action, params);
|
||||
if (result.exitCode !== 0) {
|
||||
const detail = (result.stderr || result.stdout).trim() || `exit code ${result.exitCode}`;
|
||||
throw new Error(`The simulator could not run ${action}: ${detail}`);
|
||||
}
|
||||
succeeded = true;
|
||||
savedFile = await saveHostActionOutputAsync(connection, entry, result.stdout, outputPath);
|
||||
output = savedFile ? '' : result.stdout.trim();
|
||||
spinner.succeed(`Ran ${action} on simulator session ${sessionId}`);
|
||||
({ output, file: savedFile } = await executeControlActionAsync(connection, plan));
|
||||
spinner.succeed(`Ran ${action} on simulator session ${session.id}`);
|
||||
} catch (err) {
|
||||
spinner.fail(`Failed to run ${action} on simulator session ${sessionId}`);
|
||||
spinner.fail(`Failed to run ${action} on simulator session ${session.id}`);
|
||||
throw err;
|
||||
} finally {
|
||||
if (uploadId && (!entry.keepUpload || !succeeded)) {
|
||||
await connection.requestAsync('upload.remove', { uploadId }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({
|
||||
id: sessionId,
|
||||
id: session.id,
|
||||
session,
|
||||
action,
|
||||
output: parseJsonOutput(output),
|
||||
output: parseControlActionOutput(output),
|
||||
file: savedFile,
|
||||
});
|
||||
return;
|
||||
@@ -295,73 +519,10 @@ function withUploadPathHelp(entry: HostActionCatalogEntry): HostActionCatalogEnt
|
||||
: entry;
|
||||
}
|
||||
|
||||
async function findLocalUploadAsync(
|
||||
{ upload: param, uploadWhen = {}, uploadAs }: HostActionCatalogEntry,
|
||||
params: Record<string, unknown>
|
||||
): Promise<{ param: string; path: string } | null> {
|
||||
const value = param ? params[param] : undefined;
|
||||
const applies = Object.entries(uploadWhen).every(([name, expected]) => params[name] === expected);
|
||||
if (!param || typeof value !== 'string' || !applies) {
|
||||
return null;
|
||||
}
|
||||
if (value.startsWith('host:')) {
|
||||
if (uploadAs === 'id') {
|
||||
throw new Error(
|
||||
'This action requires a local file to upload; host: paths are not supported.'
|
||||
);
|
||||
}
|
||||
const hostPath = value.slice('host:'.length);
|
||||
if (!path.posix.isAbsolute(hostPath)) {
|
||||
throw new Error(
|
||||
`A host: path must be absolute, for example host:/tmp/app.tar.gz. For a local filename beginning with host:, use ./${value}.`
|
||||
);
|
||||
}
|
||||
params[param] = hostPath;
|
||||
return null;
|
||||
}
|
||||
const stat = await fs.stat(value).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (stat?.isDirectory()) {
|
||||
throw new Error(`${value} is a folder. Pass the path of a file.`);
|
||||
}
|
||||
if (!stat) {
|
||||
throw new Error(
|
||||
`${value} does not exist on this computer. Pass a local file, or prefix an absolute simulator-host path with host:.`
|
||||
);
|
||||
}
|
||||
return stat ? { param, path: value } : null;
|
||||
function formatElapsed(startedAt: number): string {
|
||||
return formatMilliseconds(Date.now() - startedAt) || '<1s';
|
||||
}
|
||||
|
||||
function assertControllableSession(
|
||||
session: Awaited<ReturnType<typeof DeviceRunSessionQuery.byIdAsync>>
|
||||
): PreviewApiAccess {
|
||||
if (session.status !== DeviceRunSessionStatus.InProgress) {
|
||||
throw new Error(
|
||||
`Simulator session ${session.id} is ${session.status.toLowerCase()}. Simulator actions need a running session. Start one with \`eas simulator:start\`.`
|
||||
);
|
||||
}
|
||||
if (session.platform !== AppPlatform.Ios) {
|
||||
throw new Error(
|
||||
`Simulator session ${session.id} runs on ${session.platform.toLowerCase()}. Simulator actions are available only for iOS sessions.`
|
||||
);
|
||||
}
|
||||
const access = session.remoteConfig ? getPreviewApiAccess(session.remoteConfig) : null;
|
||||
if (!access) {
|
||||
throw new Error(
|
||||
`Simulator session ${session.id} does not expose a preview server with a token. Simulator actions use that server. Start a new session with \`eas simulator:start\`.`
|
||||
);
|
||||
}
|
||||
return access;
|
||||
}
|
||||
|
||||
function parseJsonOutput(output: string): unknown {
|
||||
try {
|
||||
return JSON.parse(output);
|
||||
} catch {
|
||||
return output;
|
||||
}
|
||||
function formatActionCount(count: number): string {
|
||||
return `${count} action${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
Loaded 100 of 187 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user