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 | |
|---|---|---|---|
|
|
4f95ca50d9 | ||
|
|
0199cd9331 | ||
|
|
ffe6bda88d | ||
|
|
904859b824 | ||
|
|
3fd966bb9e | ||
|
|
aa9f5370d4 | ||
|
|
60a0b483eb | ||
|
|
070f15c633 | ||
|
|
c8aa5eb05d | ||
|
|
68596f8c0c | ||
|
|
1e38ada929 | ||
|
|
d52629e9f3 | ||
|
|
bed6354ef5 | ||
|
|
9aac1a0dff | ||
|
|
c1c2b748b4 | ||
|
|
7b39b92df8 | ||
|
|
929905ba7e | ||
|
|
ef7fceba86 | ||
|
|
c2dc766c38 | ||
|
|
785cfe2d53 | ||
|
|
18a008a39a | ||
|
|
f5be6b8d88 | ||
|
|
c4064ea9ba | ||
|
|
f1dda5df94 | ||
|
|
4ec2a5fe9e | ||
|
|
1068fab6ac | ||
|
|
3e00e86e5a | ||
|
|
69a4a06320 | ||
|
|
219df86f88 | ||
|
|
50e1f2aea5 | ||
|
|
4400b93ed3 | ||
|
|
52c1a4ce54 | ||
|
|
f97e6ecfa8 |
No files matched your search
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Speed up cold iOS Appium sessions by launching preinstalled WebDriverAgent.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Add experimentation client.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Add a Free plan build limit guidance experiment for coding agents to `eas build`.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/worker': patch
|
||||
---
|
||||
|
||||
Mask build secrets in every build log field, worker stdout and the build error log buffer.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Give agent-device and its simulator preview separate session logs.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Stream simulator preview output into its existing session step.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Add `eas simulator:simctl` to set the appearance of a remote iOS simulator session and restart its SpringBoard.
|
||||
@@ -22,4 +22,3 @@ jobs:
|
||||
uses: ./.github/actions/setup-mise
|
||||
- run: yarn install --immutable
|
||||
- run: yarn verify-graphql-code
|
||||
working-directory: packages/eas-cli
|
||||
@@ -2,6 +2,58 @@
|
||||
|
||||
This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
## [25.1.1](https://github.com/expo/eas-cli/releases/tag/v25.1.1) - 2026-10-16
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Call www only through the device run session worker API in device run session jobs, and remove their GraphQL fallbacks. ([#4638](https://github.com/expo/eas-cli/pull/4638) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [25.1.0](https://github.com/expo/eas-cli/releases/tag/v25.1.0) - 2026-10-16
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Show EAS Simulator minutes in `eas account:usage`. ([#4583](https://github.com/expo/eas-cli/pull/4583) by [@LipLoody](https://github.com/LipLoody))
|
||||
- [build-tools] Pass the `cors_origins` session input to serve-sim and expo-device-hub as `--cors-origin` origins, so a third party page can call the preview host API, for example with `@expo/hub-client`. ([#4657](https://github.com/expo/eas-cli/pull/4657) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [build-tools] Pass the `frame_ancestors` session input to serve-sim and expo-device-hub as `--frame-ancestor` origins, so a third party can embed the preview host. ([#4655](https://github.com/expo/eas-cli/pull/4655) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [eas-cli] Add `--cors-origin` to `eas simulator:start`, so a third party page can call the simulator preview API, for example with `@expo/hub-client`. ([#4658](https://github.com/expo/eas-cli/pull/4658) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [eas-cli] Add `--frame-ancestor` to `eas simulator:start`, so a third party site can embed the simulator preview, and print the embeddable preview URL. ([#4656](https://github.com/expo/eas-cli/pull/4656) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Allocate 4 GiB of guest RAM to Android emulators in EAS Simulator sessions. ([#4640](https://github.com/expo/eas-cli/pull/4640) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Retry transient Google Play API failures with exponential backoff while keeping uncertain commit results explicit. ([#4629](https://github.com/expo/eas-cli/pull/4629) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Retry Google Play edit commits when an uploaded Android App Bundle is still processing, with bounded retries and progress warnings. ([#4651](https://github.com/expo/eas-cli/pull/4651) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Report Google Play error responses to Sentry so unexplained submission failures can be classified. ([#4652](https://github.com/expo/eas-cli/pull/4652) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [25.0.0](https://github.com/expo/eas-cli/releases/tag/v25.0.0) - 2026-10-15
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Default new simulator sessions to authenticated preview links and omit preview credentials from private output; use `simulator:start --public-link` for public sharing, while `simulator:get` follows the saved preference. ([#4619](https://github.com/expo/eas-cli/pull/4619) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Start `expo-device-hub` for Android simulator sessions with `--require-token` and the website origins, as serve-sim starts for iOS, and report its session token. ([#4578](https://github.com/expo/eas-cli/pull/4578) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [build-tools] Respect saved sharing preferences for iOS simulator preview sessions. ([#4620](https://github.com/expo/eas-cli/pull/4620) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Give agent-device and its simulator preview separate session logs. ([#4585](https://github.com/expo/eas-cli/pull/4585) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Give Appium and its simulator preview separate session logs. ([#4586](https://github.com/expo/eas-cli/pull/4586) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Give Argent and its simulator preview separate session logs. ([#4587](https://github.com/expo/eas-cli/pull/4587) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Let serve-sim start the iOS Simulator for web-preview sessions. ([#4601](https://github.com/expo/eas-cli/pull/4601) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Stream simulator preview output into its existing session step. ([#4525](https://github.com/expo/eas-cli/pull/4525) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Use effectivePermissions for logic about capabilities and account:view display. ([#4637](https://github.com/expo/eas-cli/pull/4637) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Speed up cold iOS Appium sessions by launching preinstalled WebDriverAgent. ([#4618](https://github.com/expo/eas-cli/pull/4618) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools][worker] Call the device run session worker API with the session token when www provides one. ([#4615](https://github.com/expo/eas-cli/pull/4615) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools][eas-json] Clarify Google Play submission messages and reject zero Android submission rollouts. ([#4625](https://github.com/expo/eas-cli/pull/4625) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Update error message for scoped VC in EAS CLI now that scoped can be used outside build. ([#4636](https://github.com/expo/eas-cli/pull/4636) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [build-tools][worker] Mask the credentials `eas/run_agent` leases with the build log secrets filter instead of redacting them in the step. ([#4624](https://github.com/expo/eas-cli/pull/4624) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [build-tools] Add shared opt-in serve-sim Simulator startup support. ([#4605](https://github.com/expo/eas-cli/pull/4605) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [24.14.0](https://github.com/expo/eas-cli/releases/tag/v24.14.0) - 2026-10-14
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "24.14.0",
|
||||
"PRODUCTION": "24.14.0"
|
||||
"STAGING": "25.1.1",
|
||||
"PRODUCTION": "25.1.1"
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "24.14.0",
|
||||
"version": "25.1.1",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
"scripts": {
|
||||
"build": "lerna run build",
|
||||
"typecheck": "lerna run typecheck",
|
||||
"verify-graphql-code": "lerna run verify-graphql-code --concurrency 1",
|
||||
"start": "lerna run watch --stream --parallel",
|
||||
"start-allow-unused": "lerna run watch-allow-unused --stream --parallel",
|
||||
"watch": "yarn start",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# @expo/build-tools
|
||||
|
||||
## 25.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Call www only through the device run session worker API in device run session jobs, and remove their GraphQL fallbacks. ([#4638](https://github.com/expo/eas-cli/pull/4638) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## 25.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Pass the `cors_origins` session input to serve-sim and expo-device-hub as `--cors-origin` origins, so a third party page can call the preview host API, for example with `@expo/hub-client`. ([#4657](https://github.com/expo/eas-cli/pull/4657) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- Pass the `frame_ancestors` session input to serve-sim and expo-device-hub as `--frame-ancestor` origins, so a third party can embed the preview host. ([#4655](https://github.com/expo/eas-cli/pull/4655) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Allocate 4 GiB of guest RAM to Android emulators in EAS Simulator sessions. ([#4640](https://github.com/expo/eas-cli/pull/4640) by [@gwdp](https://github.com/gwdp))
|
||||
- Retry transient Google Play API failures with exponential backoff while keeping uncertain commit results explicit. ([#4629](https://github.com/expo/eas-cli/pull/4629) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Retry Google Play edit commits when an uploaded Android App Bundle is still processing, with bounded retries and progress warnings. ([#4651](https://github.com/expo/eas-cli/pull/4651) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Report Google Play error responses to Sentry so unexplained submission failures can be classified. ([#4652](https://github.com/expo/eas-cli/pull/4652) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## 25.0.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Start `expo-device-hub` for Android simulator sessions with `--require-token` and the website origins, as serve-sim starts for iOS, and report its session token. ([#4578](https://github.com/expo/eas-cli/pull/4578) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- Respect saved sharing preferences for iOS simulator preview sessions. ([#4620](https://github.com/expo/eas-cli/pull/4620) by [@gwdp](https://github.com/gwdp))
|
||||
- Give agent-device and its simulator preview separate session logs. ([#4585](https://github.com/expo/eas-cli/pull/4585) by [@gwdp](https://github.com/gwdp))
|
||||
- Give Appium and its simulator preview separate session logs. ([#4586](https://github.com/expo/eas-cli/pull/4586) by [@gwdp](https://github.com/gwdp))
|
||||
- Give Argent and its simulator preview separate session logs. ([#4587](https://github.com/expo/eas-cli/pull/4587) by [@gwdp](https://github.com/gwdp))
|
||||
- Let serve-sim start the iOS Simulator for web-preview sessions. ([#4601](https://github.com/expo/eas-cli/pull/4601) by [@gwdp](https://github.com/gwdp))
|
||||
- Stream simulator preview output into its existing session step. ([#4525](https://github.com/expo/eas-cli/pull/4525) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Speed up cold iOS Appium sessions by launching preinstalled WebDriverAgent. ([#4618](https://github.com/expo/eas-cli/pull/4618) by [@gwdp](https://github.com/gwdp))
|
||||
- Call the device run session worker API with the session token when www provides one. ([#4615](https://github.com/expo/eas-cli/pull/4615) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- Clarify Google Play submission messages and reject zero Android submission rollouts. ([#4625](https://github.com/expo/eas-cli/pull/4625) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "24.14.0",
|
||||
"version": "25.1.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -36,22 +36,23 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\"",
|
||||
"gql": "gql.tada generate-schema ${API_SERVER_URL:-https://api.expo.dev}/graphql --output ./schema.graphql && gql.tada generate-output",
|
||||
"gql:local": "API_SERVER_URL=http://api.expo.test yarn gql",
|
||||
"verify-graphql-code": "./scripts/verify-graphql.sh",
|
||||
"test": "yarn jest-unit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "24.13.0",
|
||||
"@expo/eas-build-job": "24.13.0",
|
||||
"@expo/downloader": "25.1.1",
|
||||
"@expo/eas-build-job": "25.1.1",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "24.13.0",
|
||||
"@expo/logger": "25.1.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": "24.13.0",
|
||||
"@expo/template-file": "24.13.0",
|
||||
"@expo/turtle-spawn": "24.13.0",
|
||||
"@expo/steps": "25.1.1",
|
||||
"@expo/template-file": "25.1.1",
|
||||
"@expo/turtle-spawn": "25.1.1",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
|
||||
@@ -71,6 +71,17 @@ by a coverage report from `lsof`, listing any process without the library. Both
|
||||
`packages/worker/package.sh` for the iOS worker tarball and by the
|
||||
`test-egress-guard` EAS workflow, not committed.
|
||||
|
||||
For serve-sim-owned iOS session startup, build-tools passes the guard through
|
||||
`SERVE_SIM_ADDITIONAL_DYLIBS` and its configuration and proxy variables through
|
||||
inherited `SIMCTL_CHILD_` variables. It shuts down an already booted selected
|
||||
device so every process inherits the guard. Serve-sim boots that device while
|
||||
build-tools prepares the app. After serve-sim's control endpoint is available,
|
||||
build-tools runs the packaged guard self-check before requesting installation
|
||||
and launch through the authenticated host API. Preview readiness and application
|
||||
startup both complete before reporting the remote session live. Build-tools keeps
|
||||
the event relay and egress cleanup without rewriting launchd's dylib list after
|
||||
serve-sim adds its capability loader.
|
||||
|
||||
Failure semantics: a missing library, a failed `launchctl setenv`, or a failed
|
||||
self-check fails the session, since a `--egress local` session without the
|
||||
guard would silently leak. Only an unwritable event log is a warning, because
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -eox pipefail
|
||||
|
||||
# Generated GraphQL files are untracked, so always regenerate and check compatibility.
|
||||
yarn gql
|
||||
yarn run -T lerna run build --scope @expo/build-tools --include-dependencies
|
||||
@@ -318,6 +318,49 @@ describe('Datadog singleton', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('POSTs distribution metrics to the device run session worker API with the session token', () => {
|
||||
turtleFetchMock.mockResolvedValueOnce({} as Response);
|
||||
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
deviceRunSessionToken: 'session-token',
|
||||
target: { kind: 'deviceRunSession' },
|
||||
});
|
||||
|
||||
Datadog.distribution('eas.workflow.hook', 1, { hook: 'on_start' });
|
||||
|
||||
expect(turtleFetchMock).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/device-run-session-worker/metrics',
|
||||
'POST',
|
||||
{
|
||||
json: {
|
||||
metrics: [
|
||||
{
|
||||
name: 'eas.workflow.hook',
|
||||
type: 'distribution',
|
||||
value: 1,
|
||||
tags: { hook: 'on_start', target: 'job_run' },
|
||||
},
|
||||
],
|
||||
},
|
||||
headers: { 'expo-device-run-session-token': 'session-token' },
|
||||
retries: 2,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('does not report logs for device run session targets', () => {
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
deviceRunSessionToken: 'session-token',
|
||||
target: { kind: 'deviceRunSession' },
|
||||
});
|
||||
|
||||
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(
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface BuildContextOptions {
|
||||
metadata?: Metadata;
|
||||
expoApiV2BaseUrl?: string;
|
||||
mcpServerUrl?: string;
|
||||
addBuildLogMask?: (secret: string) => void;
|
||||
}
|
||||
|
||||
export class SkipNativeBuildError extends Error {}
|
||||
@@ -86,6 +87,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
public readonly skipNativeBuild?: boolean;
|
||||
public readonly expoApiV2BaseUrl?: string;
|
||||
public readonly mcpServerUrl?: string;
|
||||
public readonly addBuildLogMask?: (secret: string) => void;
|
||||
public artifacts: Artifacts = {};
|
||||
|
||||
private readonly _isLocal: boolean;
|
||||
@@ -115,6 +117,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
this.skipNativeBuild = options.skipNativeBuild;
|
||||
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
|
||||
this.mcpServerUrl = options.mcpServerUrl;
|
||||
this.addBuildLogMask = options.addBuildLogMask;
|
||||
|
||||
const environmentSecrets = this.getEnvironmentSecrets(job);
|
||||
this._env = {
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface BuilderRuntimeApi {
|
||||
uploadArtifact: (spec: { artifact: ArtifactToUpload; logger: bunyan }) => Promise<{
|
||||
artifactId: string | null;
|
||||
}>;
|
||||
addBuildLogMask?: (secret: string) => void;
|
||||
}
|
||||
|
||||
export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuildContextProvider {
|
||||
@@ -84,6 +85,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
this.buildLogsDirectory = path.join(buildCtx.workingdir, 'logs');
|
||||
this.runtimeApi = {
|
||||
uploadArtifact: (...args) => buildCtx['uploadArtifact'](...args),
|
||||
addBuildLogMask: buildCtx.addBuildLogMask,
|
||||
};
|
||||
this.mcpServerUrl = buildCtx.mcpServerUrl;
|
||||
this.expoApiV2BaseUrl = buildCtx.expoApiV2BaseUrl;
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { Sentry } from './sentry';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from './steps/utils/deviceRunSessionWorkerApi';
|
||||
import { turtleFetch } from './utils/turtleFetch';
|
||||
|
||||
type MetricsTarget =
|
||||
| { kind: 'build'; turtleBuildId: string }
|
||||
| { kind: 'jobRun'; turtleJobRunId: 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' };
|
||||
|
||||
type DatadogSetupOptions = {
|
||||
expoApiV2BaseUrl: string;
|
||||
robotAccessToken: string;
|
||||
target: MetricsTarget;
|
||||
};
|
||||
type DatadogSetupOptions =
|
||||
| {
|
||||
expoApiV2BaseUrl: string;
|
||||
robotAccessToken: string;
|
||||
target: Exclude<MetricsTarget, { kind: 'deviceRunSession' }>;
|
||||
}
|
||||
| {
|
||||
expoApiV2BaseUrl: string;
|
||||
deviceRunSessionToken: string;
|
||||
target: Extract<MetricsTarget, { kind: 'deviceRunSession' }>;
|
||||
};
|
||||
|
||||
let setupOptions: DatadogSetupOptions | null = null;
|
||||
let pendingUploads: Promise<unknown>[] = [];
|
||||
@@ -18,9 +27,30 @@ function withTargetTag(
|
||||
tags: Record<string, string>,
|
||||
target: MetricsTarget
|
||||
): Record<string, string> {
|
||||
// A device run session job is a job run, so its metrics keep the job run tag.
|
||||
return { ...tags, target: target.kind === 'build' ? 'build' : 'job_run' };
|
||||
}
|
||||
|
||||
function getMetricsRequest(options: DatadogSetupOptions): {
|
||||
path: string;
|
||||
headers: Record<string, string>;
|
||||
} {
|
||||
if ('deviceRunSessionToken' in options) {
|
||||
return {
|
||||
path: 'device-run-session-worker/metrics',
|
||||
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: options.deviceRunSessionToken },
|
||||
};
|
||||
}
|
||||
const { target, robotAccessToken } = options;
|
||||
return {
|
||||
path:
|
||||
target.kind === 'build'
|
||||
? `turtle-builds/${target.turtleBuildId}/metrics`
|
||||
: `turtle-job-runs/${target.turtleJobRunId}/metrics`,
|
||||
headers: { Authorization: `Bearer ${robotAccessToken}` },
|
||||
};
|
||||
}
|
||||
|
||||
export const Datadog = {
|
||||
setup(opts: DatadogSetupOptions | null): void {
|
||||
setupOptions = opts;
|
||||
@@ -30,7 +60,7 @@ export const Datadog = {
|
||||
if (!setupOptions) {
|
||||
return;
|
||||
}
|
||||
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
|
||||
const { expoApiV2BaseUrl, target } = setupOptions;
|
||||
const metrics = [
|
||||
{
|
||||
name,
|
||||
@@ -40,16 +70,11 @@ export const Datadog = {
|
||||
},
|
||||
];
|
||||
|
||||
const metricsPath =
|
||||
target.kind === 'build'
|
||||
? `turtle-builds/${target.turtleBuildId}/metrics`
|
||||
: `turtle-job-runs/${target.turtleJobRunId}/metrics`;
|
||||
const { path: metricsPath, headers } = getMetricsRequest(setupOptions);
|
||||
|
||||
const uploadPromise = turtleFetch(new URL(metricsPath, expoApiV2BaseUrl).toString(), 'POST', {
|
||||
json: { metrics },
|
||||
headers: {
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
},
|
||||
headers,
|
||||
retries: 2,
|
||||
}).catch(err => {
|
||||
Sentry.capture(`Failed to report turtle ${target.kind} metric`, err, {
|
||||
@@ -61,7 +86,9 @@ export const Datadog = {
|
||||
},
|
||||
|
||||
log(message: string, tags: Record<string, string> = {}): void {
|
||||
if (!setupOptions) {
|
||||
// The device run session worker API has no logs route, and the session token is not a bearer
|
||||
// credential, so a device run session job does not report these logs.
|
||||
if (!setupOptions || !('robotAccessToken' in setupOptions)) {
|
||||
return;
|
||||
}
|
||||
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
|
||||
|
||||
@@ -33,5 +33,6 @@ export type { GradleProfileTask } from './android/gradleProfile';
|
||||
export * from './generic';
|
||||
|
||||
export { Datadog } from './datadog';
|
||||
export { getDeviceRunSessionTokenFromEnvironmentSecrets } from './steps/utils/deviceRunSessionWorkerApi';
|
||||
export { Sentry } from './sentry';
|
||||
export * from './runtimeSettings';
|
||||
+69
-5
@@ -108,7 +108,11 @@ describe.each([
|
||||
['Appium', createStartAppiumRemoteSessionBuildFunction],
|
||||
['Agent Device', createStartAgentDeviceSessionBuildFunction],
|
||||
] as const)('%s host ownership', (name, createFunction) => {
|
||||
async function runAsync() {
|
||||
async function runAsync(
|
||||
publicPreview?: boolean,
|
||||
frameAncestors?: string[],
|
||||
corsOrigins?: string[]
|
||||
) {
|
||||
const fn = createFunction({} as CustomBuildContext);
|
||||
await fn.fn!(
|
||||
{
|
||||
@@ -117,9 +121,16 @@ describe.each([
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
// Inputs a step does not get default to undefined, like in a real step call.
|
||||
inputs: new Proxy({} as Record<string, { value: unknown }>, {
|
||||
get: (target, id: string) => target[id] ?? { value: undefined },
|
||||
}),
|
||||
inputs: new Proxy(
|
||||
{
|
||||
public_preview: { value: publicPreview },
|
||||
frame_ancestors: { value: frameAncestors },
|
||||
cors_origins: { value: corsOrigins },
|
||||
} as Record<string, { value: unknown }>,
|
||||
{
|
||||
get: (target, id: string) => target[id] ?? { value: undefined },
|
||||
}
|
||||
),
|
||||
outputs: {},
|
||||
env: {
|
||||
DEVICE_RUN_SESSION_ID: 'session-id',
|
||||
@@ -132,7 +143,7 @@ describe.each([
|
||||
|
||||
it('holds the host until session completion', async () => {
|
||||
await runAsync();
|
||||
expect(openPreview).toHaveBeenCalledWith({ baseDomain: 'example.test' });
|
||||
expect(openPreview).toHaveBeenCalledWith({ baseDomain: 'example.test', authtoken: 'token' });
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({
|
||||
@@ -151,6 +162,59 @@ describe.each([
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('opens tunnels with the ngrok token but keeps it out of spawned processes', async () => {
|
||||
await runAsync();
|
||||
expect(startNgrokTunnelAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ authtoken: 'token' })
|
||||
);
|
||||
const spawnedEnvs = [
|
||||
...jest.mocked(spawn).mock.calls.map(([, , options]) => options?.env),
|
||||
...jest.mocked(spawnDetached).mock.calls.map(([options]) => options.env),
|
||||
...jest.mocked(startDeviceSessionHostAsync).mock.calls.map(([, options]) => options.env),
|
||||
];
|
||||
expect(spawnedEnvs.length).toBeGreaterThan(0);
|
||||
for (const env of spawnedEnvs) {
|
||||
expect(env).toEqual(expect.objectContaining({ DEVICE_RUN_SESSION_ID: 'session-id' }));
|
||||
expect(env).not.toHaveProperty('NGROK_AUTHTOKEN');
|
||||
}
|
||||
});
|
||||
|
||||
it.each([undefined, true, false])('maps public_preview=%s to the host', async publicPreview => {
|
||||
await runAsync(publicPreview);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ disableShare: publicPreview === false })
|
||||
);
|
||||
});
|
||||
|
||||
it('maps frame_ancestors to the host and defaults to none', async () => {
|
||||
await runAsync(undefined, ['https://embed.example.test']);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ frameAncestors: ['https://embed.example.test'] })
|
||||
);
|
||||
jest.mocked(startDeviceSessionHostAsync).mockClear();
|
||||
await runAsync();
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ frameAncestors: [] })
|
||||
);
|
||||
});
|
||||
|
||||
it('maps cors_origins to the host and defaults to none', async () => {
|
||||
await runAsync(undefined, undefined, ['https://app.example.test']);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
|
||||
);
|
||||
jest.mocked(startDeviceSessionHostAsync).mockClear();
|
||||
await runAsync();
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ corsOrigins: [] })
|
||||
);
|
||||
});
|
||||
|
||||
it('stops automation while recording finalization or upload is pending', async () => {
|
||||
let release!: () => void;
|
||||
let stopped!: () => void;
|
||||
|
||||
@@ -591,6 +591,100 @@ describe('createDownloadBuildFunction', () => {
|
||||
expect(graphqlClient.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('with a device run session token in the job env', () => {
|
||||
const { Response: ActualResponse } = jest.requireActual(
|
||||
'node-fetch'
|
||||
) as typeof import('node-fetch');
|
||||
const workerApiUrl = 'https://api.expo.test/v2/device-run-session-worker/application';
|
||||
|
||||
function createSessionCtx(graphqlClient: Client): any {
|
||||
return {
|
||||
graphqlClient,
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
};
|
||||
}
|
||||
|
||||
function createStep(ctx: any, callInputs: Record<string, unknown>) {
|
||||
return createDownloadBuildFunction(ctx).createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({
|
||||
logger: createMockLogger(),
|
||||
staticContextContent: { job: { secrets: { robotAccessToken: 'session-token' } } },
|
||||
}),
|
||||
{ callInputs }
|
||||
);
|
||||
}
|
||||
|
||||
it('looks up build_id through the worker API and downloads without an Authorization header', async () => {
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest.mocked(fetch).mockImplementation(async url => {
|
||||
if (url === workerApiUrl) {
|
||||
return new ActualResponse(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
build: {
|
||||
id: buildId,
|
||||
gitCommitHash: null,
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
text: () => Promise.resolve('Internal Server Error'),
|
||||
status: 500,
|
||||
} as unknown as Response;
|
||||
});
|
||||
|
||||
await expect(
|
||||
createStep(createSessionCtx(graphqlClient), {
|
||||
build_id: buildId,
|
||||
extensions: ['app'],
|
||||
}).executeAsync()
|
||||
).rejects.toThrow('Internal Server Error');
|
||||
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
workerApiUrl,
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: { 'expo-device-run-session-token': 'session-token' },
|
||||
})
|
||||
);
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
APPLICATION_ARCHIVE_URL,
|
||||
expect.objectContaining({ headers: undefined })
|
||||
);
|
||||
expect(graphqlClient.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call the worker API for an application_archive_url input', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
text: () => Promise.resolve('Internal Server Error'),
|
||||
status: 500,
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(
|
||||
createStep(createSessionCtx(graphqlClient), {
|
||||
application_archive_url: APPLICATION_ARCHIVE_URL,
|
||||
extensions: ['app'],
|
||||
}).executeAsync()
|
||||
).rejects.toThrow('Internal Server Error');
|
||||
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(1);
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
APPLICATION_ARCHIVE_URL,
|
||||
expect.objectContaining({ headers: undefined })
|
||||
);
|
||||
expect(graphqlClient.query).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should download a build', async () => {
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
|
||||
+26
-2
@@ -14,6 +14,12 @@ import {
|
||||
registerLocalEgressResources,
|
||||
stopLocalEgressResourcesAsync,
|
||||
} from '../../utils/localEgress';
|
||||
import {
|
||||
reportLocalEgressGuardCoverageAsync,
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync,
|
||||
startLocalEgressGuardRelayAsync,
|
||||
verifyLocalEgressGuardAsync,
|
||||
} from '../../utils/localEgressGuard';
|
||||
import {
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
@@ -29,8 +35,8 @@ jest.mock('../../../utils/processes');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/agentDeviceArtifacts');
|
||||
// The agent-device session step boots the Simulator itself; this test covers local egress.
|
||||
jest.mock('../startIosSimulator', () => ({
|
||||
...jest.requireActual('../startIosSimulator'),
|
||||
bootIosSimulatorAsync: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ deviceIdentifier: 'sim', udid: 'sim', displayName: 'iPhone' }),
|
||||
@@ -51,6 +57,13 @@ jest.mock('../../utils/localEgress', () => ({
|
||||
readLocalEgressHandoffAsync: jest.fn(),
|
||||
monitorLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/localEgressGuard', () => ({
|
||||
...jest.requireActual('../../utils/localEgressGuard'),
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn(),
|
||||
startLocalEgressGuardRelayAsync: jest.fn(),
|
||||
reportLocalEgressGuardCoverageAsync: jest.fn(),
|
||||
verifyLocalEgressGuardAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
getDeviceRunSessionIdOrThrow: () => 'session-id',
|
||||
@@ -126,9 +139,20 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
jest.mocked(isProcessDescendantOfAsync).mockResolvedValue(true);
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid: 'sim' }] as never);
|
||||
.mockResolvedValue([{ udid: 'sim', name: 'iPhone 17' }] as never);
|
||||
jest.mocked(IosSimulatorUtils.resolveUdidAsync).mockResolvedValue('sim' as never);
|
||||
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(handoff);
|
||||
jest
|
||||
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
|
||||
.mockImplementation(async () =>
|
||||
(await readLocalEgressHandoffAsync())
|
||||
? { SERVE_SIM_ADDITIONAL_DYLIBS: '/test/egress-guard.dylib' }
|
||||
: null
|
||||
);
|
||||
jest.mocked(startLocalEgressGuardRelayAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(reportLocalEgressGuardCoverageAsync).mockResolvedValue(null);
|
||||
jest.mocked(verifyLocalEgressGuardAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(monitorLocalEgressAsync).mockResolvedValue(undefined);
|
||||
openPreview.mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
|
||||
@@ -25,6 +25,7 @@ const leaseMock = jest.mocked(leaseAgentRunProviderCredentialsAsync);
|
||||
const ctx = {
|
||||
expoApiV2BaseUrl: 'https://staging-api.expo.test/v2/',
|
||||
mcpServerUrl: 'wss://staging-mcp.expo.test',
|
||||
runtimeApi: { addBuildLogMask: jest.fn() },
|
||||
} as any;
|
||||
const inputs = {
|
||||
agent_kind: { value: 'claude-code' },
|
||||
@@ -115,6 +116,19 @@ describe(createRunAgentBuildFunction, () => {
|
||||
expect(agentEnv.HOME).not.toBe('/home/expo');
|
||||
});
|
||||
|
||||
it('hands the build log secrets filter to the agent run', async () => {
|
||||
const runAgentSpy = jest.spyOn(agentRun, 'runAgentAsync').mockResolvedValue();
|
||||
try {
|
||||
await runAsync();
|
||||
|
||||
expect(runAgentSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ addBuildLogMask: ctx.runtimeApi.addBuildLogMask })
|
||||
);
|
||||
} finally {
|
||||
runAgentSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['wss://staging-mcp.expo.test', 'https://staging-mcp.expo.test/mcp'],
|
||||
['ws://localhost:8787', 'http://localhost:8787/mcp'],
|
||||
@@ -162,6 +176,7 @@ describe(createRunAgentBuildFunction, () => {
|
||||
['no PATH', { env: { ...env, PATH: '' } }],
|
||||
['no Expo API URL', { ctx: { ...ctx, expoApiV2BaseUrl: undefined } }],
|
||||
['no MCP server URL', { ctx: { ...ctx, mcpServerUrl: undefined } }],
|
||||
['no build log secrets filter', { ctx: { ...ctx, runtimeApi: {} } }],
|
||||
])('throws a system error for %s', async (_name, overrides) => {
|
||||
await expect(runAsync(overrides)).rejects.toBeInstanceOf(SystemError);
|
||||
|
||||
|
||||
+320
-24
@@ -70,11 +70,13 @@ const mockEventCollectionStopAsync = jest.fn();
|
||||
type CaptureInputs = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['capture'];
|
||||
const NO_CAPTURE: CaptureInputs = { networkCapture: false, networkCaptureFields: [] };
|
||||
|
||||
/** Runs the session for a device that is already booted, with the app (if any) launched. */
|
||||
/** Runs an Android session that is ready, or an iOS session with no application preparation. */
|
||||
async function runAsync(
|
||||
logger: { info: jest.Mock; warn: jest.Mock; child: jest.Mock },
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
capture: CaptureInputs = NO_CAPTURE
|
||||
capture: CaptureInputs = NO_CAPTURE,
|
||||
disableShare = false,
|
||||
privatePreviewUrl?: string
|
||||
): Promise<void> {
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env: {},
|
||||
@@ -89,8 +91,13 @@ async function runAsync(
|
||||
maxIdleTimeMinutes: undefined,
|
||||
maxDurationSeconds: undefined,
|
||||
capture,
|
||||
disableShare,
|
||||
privatePreviewUrl,
|
||||
tasks: createStartupTasks(logger as never),
|
||||
device: { booted: Promise.resolve(), ready: Promise.resolve() },
|
||||
device:
|
||||
runtimePlatform === BuildRuntimePlatform.DARWIN
|
||||
? { iosSimulatorUdid: 'selected-udid', application: Promise.resolve() }
|
||||
: { booted: Promise.resolve(), ready: Promise.resolve() },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -120,13 +127,23 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
subdomainId: 'agent-device-abc',
|
||||
stopAsync: mockTunnelStopAsync,
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
const host = {
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
};
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async (_ctx, options) => {
|
||||
try {
|
||||
await options.application;
|
||||
options.signal?.throwIfAborted();
|
||||
} catch (error) {
|
||||
await mockPreviewStopAsync();
|
||||
throw error;
|
||||
}
|
||||
return host;
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
@@ -146,7 +163,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, timeoutMs: 60_000 })
|
||||
);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -199,6 +216,24 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('hands a private preview choice to the session host', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
await runAsync(
|
||||
logger,
|
||||
BuildRuntimePlatform.DARWIN,
|
||||
NO_CAPTURE,
|
||||
true,
|
||||
'https://expo.dev/private-preview'
|
||||
);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
disableShare: true,
|
||||
privatePreviewUrl: 'https://expo.dev/private-preview',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('starts the session host without waiting for the agent-device daemon', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const sessionHost = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
@@ -267,14 +302,14 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
});
|
||||
|
||||
describe('runAgentDeviceRemoteSessionAsync with a device that is still starting', () => {
|
||||
function deferred(): {
|
||||
promise: Promise<void>;
|
||||
resolve: () => void;
|
||||
function deferred<T = void>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (e: Error) => void;
|
||||
} {
|
||||
let resolve!: () => void;
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (e: Error) => void;
|
||||
const promise = new Promise<void>((res, rej) => {
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
@@ -282,18 +317,26 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
type Device = { booted: Promise<unknown>; ready: Promise<unknown> };
|
||||
function startSession(device: Device | ((tasks: StartupTasks) => Device)) {
|
||||
type Device = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['device'];
|
||||
function startSession(
|
||||
device: Device | ((tasks: StartupTasks) => Device),
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
child: jest.fn().mockReturnThis(),
|
||||
} as never;
|
||||
const tasks = createStartupTasks(logger);
|
||||
const tasks = createStartupTasks(logger, signal);
|
||||
const sessionDevice = typeof device === 'function' ? device(tasks) : device;
|
||||
return runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env: {},
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
signal,
|
||||
runtimePlatform:
|
||||
'iosSimulatorUdid' in sessionDevice
|
||||
? BuildRuntimePlatform.DARWIN
|
||||
: BuildRuntimePlatform.LINUX,
|
||||
sessionEnv: {
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
ngrokTunnelDomain: 'tunnel.example.com',
|
||||
@@ -304,7 +347,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
maxDurationSeconds: undefined,
|
||||
capture: NO_CAPTURE,
|
||||
tasks,
|
||||
device: typeof device === 'function' ? device(tasks) : device,
|
||||
device: sessionDevice,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -314,7 +357,31 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
}
|
||||
}
|
||||
|
||||
it('starts the daemon during the boot and the session host after it', async () => {
|
||||
it('aborts parallel startup before host failure cleanup finishes', async () => {
|
||||
const failure = new Error('guard refused startup');
|
||||
const cleanup = deferred();
|
||||
const notified = deferred();
|
||||
let startupTasks!: StartupTasks;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async (_ctx, options) => {
|
||||
options.onStartupError?.(failure);
|
||||
notified.resolve();
|
||||
await cleanup.promise;
|
||||
throw failure;
|
||||
});
|
||||
const session = startSession(tasks => {
|
||||
startupTasks = tasks;
|
||||
return { iosSimulatorUdid: 'selected-udid' };
|
||||
});
|
||||
const rejected = expect(session).rejects.toBe(failure);
|
||||
await notified.promise;
|
||||
expect(startupTasks.signal.aborted).toBe(true);
|
||||
expect(startupTasks.signal.reason).toBe(failure);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
cleanup.resolve();
|
||||
await rejected;
|
||||
});
|
||||
|
||||
it('starts the daemon during the Android boot and the session host after it', async () => {
|
||||
const booted = deferred();
|
||||
const session = startSession({ booted: booted.promise, ready: booted.promise });
|
||||
await flushAsync();
|
||||
@@ -326,10 +393,237 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
booted.resolve();
|
||||
await session;
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
timeoutMs: 60_000,
|
||||
iosSimulatorUdid: undefined,
|
||||
})
|
||||
);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports the session as ready only after the app is launched', async () => {
|
||||
it('preserves a failed download when aborting daemon installation rejects first', async () => {
|
||||
const failure = new Error('download failed: build not found');
|
||||
const installStarted = deferred();
|
||||
jest.mocked(spawn).mockImplementation((async (_command, _args, options) => {
|
||||
return await new Promise((_resolve, reject) => {
|
||||
options!.signal!.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
const error = new Error('The operation was aborted');
|
||||
error.name = 'AbortError';
|
||||
reject(error);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
installStarted.resolve();
|
||||
});
|
||||
}) as typeof spawn);
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async (_ctx, { signal }) => {
|
||||
return await new Promise((_resolve, reject) => {
|
||||
signal!.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
setTimeout(() => reject(signal!.reason), 20);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
const applicationReady = Promise.resolve();
|
||||
const session = startSession(tasks => {
|
||||
void tasks.run('build download', async () => {
|
||||
await installStarted.promise;
|
||||
throw failure;
|
||||
});
|
||||
return { iosSimulatorUdid: 'selected-udid', application: applicationReady };
|
||||
});
|
||||
await expect(session).rejects.toBe(failure);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('boots the host during download and releases startup only after preparation', async () => {
|
||||
const hostReady = deferred();
|
||||
const application = deferred<{
|
||||
installAppPath: string;
|
||||
launchAppIdentifier: string;
|
||||
launchArgs: string[];
|
||||
openUrl: string;
|
||||
}>();
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
ctx,
|
||||
{} as never
|
||||
);
|
||||
const release = jest.fn();
|
||||
jest
|
||||
.mocked(startDeviceSessionHostAsync)
|
||||
.mockClear()
|
||||
.mockImplementation(async (_ctx, options) => {
|
||||
release(await options.application);
|
||||
await hostReady.promise;
|
||||
return host;
|
||||
});
|
||||
const applicationReady = application.promise;
|
||||
const session = startSession({
|
||||
iosSimulatorUdid: 'selected-udid',
|
||||
application: applicationReady,
|
||||
});
|
||||
await flushAsync();
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
expect(release).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
const startup = {
|
||||
installAppPath: '/tmp/App.app',
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
launchArgs: ['--flag'],
|
||||
openUrl: 'example://screen',
|
||||
};
|
||||
application.resolve(startup);
|
||||
await flushAsync();
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
application: applicationReady,
|
||||
iosSimulatorUdid: 'selected-udid',
|
||||
timeoutMs: 60_000,
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
);
|
||||
expect(release).toHaveBeenCalledWith(startup);
|
||||
expect(host.openPreviewAsync).not.toHaveBeenCalled();
|
||||
hostReady.resolve();
|
||||
await session;
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('passes the guard boot environment with the startup installation and launch', async () => {
|
||||
const startup = {
|
||||
installAppPath: '/tmp/App.app',
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
launchArgs: ['--flag'],
|
||||
openUrl: 'example://screen',
|
||||
};
|
||||
const bootEnv = {
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
SIMCTL_CHILD_EAS_EGRESS_GUARD_MODE: 'block',
|
||||
SIMCTL_CHILD_EAS_EGRESS_GUARD_LOG: '/tmp/egress-guard.log',
|
||||
SIMCTL_CHILD_HTTP_PROXY: 'http://127.0.0.1:8899',
|
||||
};
|
||||
await startSession({
|
||||
iosSimulatorUdid: 'guarded-udid',
|
||||
bootEnv,
|
||||
application: Promise.resolve(startup),
|
||||
});
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
application: expect.any(Promise),
|
||||
bootEnv,
|
||||
iosSimulatorUdid: 'guarded-udid',
|
||||
})
|
||||
);
|
||||
const hostOptions = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
|
||||
expect(hostOptions.bootEnv).toBe(bootEnv);
|
||||
expect(hostOptions.bootEnv).not.toHaveProperty('DYLD_INSERT_LIBRARIES');
|
||||
expect(hostOptions.bootEnv).not.toHaveProperty('SIMCTL_CHILD_DYLD_INSERT_LIBRARIES');
|
||||
});
|
||||
|
||||
it('cleans up a host returned after cancellation without configuring the device', async () => {
|
||||
const hostReady = deferred();
|
||||
const controller = new AbortController();
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
ctx,
|
||||
{} as never
|
||||
);
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async () => {
|
||||
await hostReady.promise;
|
||||
return host;
|
||||
});
|
||||
const applicationReady = Promise.resolve();
|
||||
const session = startSession(
|
||||
{ iosSimulatorUdid: 'selected-udid', application: applicationReady },
|
||||
controller.signal
|
||||
);
|
||||
const failure = expect(session).rejects.toThrow('cancelled');
|
||||
await flushAsync();
|
||||
controller.abort(new Error('cancelled'));
|
||||
hostReady.resolve();
|
||||
|
||||
await failure;
|
||||
expect(host.openPreviewAsync).not.toHaveBeenCalled();
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drains in-flight application preparation and stops the waiting host after cancellation', async () => {
|
||||
const controller = new AbortController();
|
||||
const appReady = deferred();
|
||||
const session = startSession(
|
||||
{ iosSimulatorUdid: 'selected-udid', application: appReady.promise },
|
||||
controller.signal
|
||||
);
|
||||
const failure = expect(session).rejects.toThrow('cancelled');
|
||||
await flushAsync();
|
||||
controller.abort(new Error('cancelled'));
|
||||
await flushAsync();
|
||||
expect(mockPreviewStopAsync).not.toHaveBeenCalled();
|
||||
|
||||
appReady.resolve();
|
||||
await failure;
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not report readiness when cancellation arrives during preview startup', async () => {
|
||||
const controller = new AbortController();
|
||||
const previewReady = deferred();
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
ctx,
|
||||
{} as never
|
||||
);
|
||||
const preview = await host.openPreviewAsync({
|
||||
baseDomain: 'tunnel.example.com',
|
||||
authtoken: 'ngrok-token',
|
||||
});
|
||||
jest.mocked(host.openPreviewAsync).mockImplementation(async () => {
|
||||
await previewReady.promise;
|
||||
return preview;
|
||||
});
|
||||
const session = startSession(
|
||||
{ iosSimulatorUdid: 'selected-udid', application: Promise.resolve() },
|
||||
controller.signal
|
||||
);
|
||||
const failure = expect(session).rejects.toThrow('cancelled');
|
||||
await flushAsync();
|
||||
controller.abort(new Error('cancelled'));
|
||||
previewReady.resolve();
|
||||
|
||||
await failure;
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not start resources for an already cancelled session', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error('cancelled'));
|
||||
const applicationReady = Promise.resolve();
|
||||
|
||||
await expect(
|
||||
startSession(
|
||||
{ iosSimulatorUdid: 'selected-udid', application: applicationReady },
|
||||
controller.signal
|
||||
)
|
||||
).rejects.toThrow('cancelled');
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts the Android host before application readiness and waits before reporting ready', async () => {
|
||||
const ready = deferred();
|
||||
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
|
||||
await flushAsync();
|
||||
@@ -339,10 +633,11 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
|
||||
ready.resolve();
|
||||
await session;
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops waiting for the boot when the daemon fails', async () => {
|
||||
it('stops waiting for the Android boot when the daemon fails', async () => {
|
||||
jest.mocked(waitForFileAsync).mockRejectedValue(new Error('no daemon credentials'));
|
||||
const neverBooted = new Promise<void>(() => {});
|
||||
|
||||
@@ -356,7 +651,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops the session host when the app fails while the daemon install hangs', async () => {
|
||||
it('cancels daemon installation and stops the Android host when the download fails', async () => {
|
||||
jest.mocked(spawn).mockImplementation(((
|
||||
command: string,
|
||||
args: string[],
|
||||
@@ -372,21 +667,22 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
await flushAsync();
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
|
||||
ready.reject(new Error('simctl install failed'));
|
||||
ready.reject(new Error('download failed'));
|
||||
|
||||
await expect(session).rejects.toThrow('simctl install failed');
|
||||
await expect(session).rejects.toThrow('download failed');
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops everything it started when the app install fails', async () => {
|
||||
it('stops the Android host, daemon and tunnel when the download fails', async () => {
|
||||
const ready = deferred();
|
||||
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
|
||||
await flushAsync();
|
||||
ready.reject(new Error('simctl install failed'));
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
ready.reject(new Error('download failed'));
|
||||
|
||||
await expect(session).rejects.toThrow('simctl install failed');
|
||||
await expect(session).rejects.toThrow('download failed');
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
|
||||
+395
-76
@@ -1,10 +1,20 @@
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { DeviceRunSessionWorkerApi } from '../../utils/deviceRunSessionWorkerApi';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { stopLocalEgressResourcesAsync } from '../../utils/localEgress';
|
||||
import {
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync,
|
||||
startLocalEgressGuardRelayAsync,
|
||||
} from '../../utils/localEgressGuard';
|
||||
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
|
||||
import { downloadBuildAsync } from '../downloadBuild';
|
||||
import { installBuildAsync } from '../installBuild';
|
||||
import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact';
|
||||
import { launchApplicationAsync } from '../launchApplication';
|
||||
import {
|
||||
getAgentDeviceRemoteSessionEnvOrThrow,
|
||||
@@ -14,17 +24,30 @@ import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceS
|
||||
import { startAndroidEmulatorAsync } from '../startAndroidEmulator';
|
||||
import { bootIosSimulatorAsync } from '../startIosSimulator';
|
||||
|
||||
jest.mock('../../utils/localEgressSession', () => ({
|
||||
withLocalEgressSession: (fn: unknown) => fn,
|
||||
jest.mock('../../utils/localEgress', () => ({
|
||||
stopLocalEgressResourcesAsync: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.mock('../../utils/localEgressGuard', () => ({
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn(),
|
||||
startLocalEgressGuardRelayAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/IosSimulatorUtils');
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../startIosSimulator', () => ({ bootIosSimulatorAsync: jest.fn() }));
|
||||
jest.mock('../startIosSimulator', () => ({
|
||||
...jest.requireActual('../startIosSimulator'),
|
||||
bootIosSimulatorAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../startAndroidEmulator', () => ({ startAndroidEmulatorAsync: jest.fn() }));
|
||||
jest.mock('../downloadBuild', () => ({ downloadBuildAsync: jest.fn() }));
|
||||
jest.mock('../installBuild', () => ({ installBuildAsync: jest.fn() }));
|
||||
jest.mock('../../utils/iosAppArtifact', () => ({
|
||||
...jest.requireActual('../../utils/iosAppArtifact'),
|
||||
readIosApplicationIdentifierAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../launchApplication', () => ({
|
||||
...jest.requireActual('../launchApplication'),
|
||||
launchApplicationAsync: jest.fn(),
|
||||
@@ -42,7 +65,17 @@ const sessionEnv = {
|
||||
ngrokAuthtoken: 'ngrok-token',
|
||||
};
|
||||
|
||||
type Device = { booted: Promise<unknown>; ready: Promise<unknown> };
|
||||
type Device = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['device'];
|
||||
let deviceLaunch: unknown;
|
||||
|
||||
function runDevicePreparationAsync(device: Device): Promise<unknown> {
|
||||
return (
|
||||
'iosSimulatorUdid' in device ? (device.application ?? Promise.resolve()) : device.ready
|
||||
).then(async launch => {
|
||||
deviceLaunch = launch;
|
||||
return launch;
|
||||
});
|
||||
}
|
||||
|
||||
function deferred<T = void>(): {
|
||||
promise: Promise<T>;
|
||||
@@ -67,9 +100,11 @@ async function flushAsync(): Promise<void> {
|
||||
|
||||
function runStep(
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
inputValues: Record<string, unknown> = {}
|
||||
inputValues: Record<string, unknown> = {},
|
||||
signal?: AbortSignal,
|
||||
stepCtx: CustomBuildContext = ctx
|
||||
): Promise<void> {
|
||||
const buildFunction = createStartAgentDeviceSessionBuildFunction(ctx);
|
||||
const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx);
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const inputs = Object.fromEntries(
|
||||
[
|
||||
@@ -84,6 +119,10 @@ function runStep(
|
||||
'open_url',
|
||||
'network_capture',
|
||||
'network_capture_fields',
|
||||
'public_preview',
|
||||
'private_preview_url',
|
||||
'frame_ancestors',
|
||||
'cors_origins',
|
||||
'package_version',
|
||||
'max_idle_time_minutes',
|
||||
'max_duration_seconds',
|
||||
@@ -97,22 +136,32 @@ function runStep(
|
||||
staticContext: { job: { secrets: { robotAccessToken: 'robot-token' } } },
|
||||
},
|
||||
} as unknown as BuildStepContext,
|
||||
{ inputs, outputs: {}, env: {}, signal: undefined } as never
|
||||
{ inputs, outputs: {}, env: {}, signal } as never
|
||||
) as Promise<void>;
|
||||
}
|
||||
|
||||
/** The `device` promises handed to the shared agent-device session code. */
|
||||
function sessionDevice(): Device {
|
||||
return jest.mocked(runAgentDeviceRemoteSessionAsync).mock.calls[0][1].device;
|
||||
}
|
||||
|
||||
describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
deviceLaunch = undefined;
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(null);
|
||||
jest.mocked(startLocalEgressGuardRelayAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Shutdown' } as never);
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '' } as never);
|
||||
jest.mocked(IosSimulatorUtils.resolveUdidAsync).mockResolvedValue('selected-udid' as never);
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ name: 'iPhone 17', udid: 'SELECTED-UDID' }] as never);
|
||||
jest.mocked(getAgentDeviceRemoteSessionEnvOrThrow).mockReturnValue(sessionEnv);
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async (_ctx, { device }) => {
|
||||
await device.ready;
|
||||
});
|
||||
jest
|
||||
.mocked(runAgentDeviceRemoteSessionAsync)
|
||||
.mockImplementation(async (_ctx, { device, tasks }) => {
|
||||
await tasks.untilAborted(runDevicePreparationAsync(device));
|
||||
});
|
||||
jest.mocked(bootIosSimulatorAsync).mockResolvedValue({
|
||||
deviceIdentifier: 'iPhone 17' as never,
|
||||
udid: 'udid' as never,
|
||||
@@ -128,22 +177,31 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
.mocked(installBuildAsync)
|
||||
.mockResolvedValue({ applicationIdentifier: 'dev.example.app', activityName: '.Main' });
|
||||
jest.mocked(launchApplicationAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(readIosApplicationIdentifierAsync).mockResolvedValue('dev.example.app');
|
||||
});
|
||||
|
||||
it('downloads the build and starts the session while the iOS Simulator boots', async () => {
|
||||
const boot = deferred<Awaited<ReturnType<typeof bootIosSimulatorAsync>>>();
|
||||
jest.mocked(bootIosSimulatorAsync).mockReturnValue(boot.promise);
|
||||
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, {
|
||||
it('hands guarded iOS boot to serve-sim and starts reporting before host startup', async () => {
|
||||
const bootEnv = {
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
SIMCTL_CHILD_EAS_EGRESS_GUARD_MODE: 'block',
|
||||
SIMCTL_CHILD_http_proxy: 'http://127.0.0.1:8899',
|
||||
};
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(bootEnv);
|
||||
await runStep(BuildRuntimePlatform.DARWIN, {
|
||||
build_id: 'build-id',
|
||||
launch_args: ['-flag'],
|
||||
open_url: 'exp://example.test',
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
expect(
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
|
||||
).toBeLessThan(jest.mocked(bootIosSimulatorAsync).mock.invocationCallOrder[0]);
|
||||
).toBeLessThan(
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(startLocalEgressGuardRelayAsync).toHaveBeenCalledTimes(1);
|
||||
expect(jest.mocked(startLocalEgressGuardRelayAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
buildId: 'build-id',
|
||||
@@ -153,32 +211,180 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
})
|
||||
);
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(1);
|
||||
// The install needs the booted Simulator.
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
|
||||
boot.resolve({
|
||||
deviceIdentifier: 'iPhone 17' as never,
|
||||
udid: 'udid' as never,
|
||||
displayName: '',
|
||||
const device = sessionDevice();
|
||||
expect(device).toEqual({
|
||||
iosSimulatorUdid: 'SELECTED-UDID',
|
||||
bootEnv,
|
||||
application: expect.any(Promise),
|
||||
});
|
||||
await step;
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
|
||||
artifactPath: '/tmp/App.app',
|
||||
env: {},
|
||||
});
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
expect(deviceLaunch).toEqual({
|
||||
installAppPath: '/tmp/App.app',
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
launchArgs: ['-flag'],
|
||||
openUrl: 'exp://example.test',
|
||||
});
|
||||
});
|
||||
|
||||
expect(installBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ artifactPath: '/tmp/App.app' })
|
||||
);
|
||||
expect(launchApplicationAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
applicationIdentifier: 'dev.example.app',
|
||||
launchArgs: ['-flag'],
|
||||
openUrl: 'exp://example.test',
|
||||
})
|
||||
);
|
||||
expect(jest.mocked(installBuildAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(launchApplicationAsync).mock.invocationCallOrder[0]
|
||||
it('shuts down only the selected guarded Booted device before host startup', async () => {
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
});
|
||||
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Booted' } as never);
|
||||
await runStep(BuildRuntimePlatform.DARWIN);
|
||||
expect(IosSimulatorUtils.getDeviceAsync).toHaveBeenCalledWith({
|
||||
udid: 'SELECTED-UDID',
|
||||
env: {},
|
||||
});
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(spawn).toHaveBeenCalledWith('xcrun', ['simctl', 'shutdown', 'SELECTED-UDID'], {
|
||||
env: {},
|
||||
logger: expect.any(Object),
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(jest.mocked(spawn).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('waits for the download when the Simulator boots first', async () => {
|
||||
it('downloads build_id through the device run session worker API when the job has a session token', async () => {
|
||||
const sessionCtx = {
|
||||
graphqlClient,
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
} as unknown as CustomBuildContext;
|
||||
|
||||
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' }, undefined, sessionCtx);
|
||||
|
||||
expect(downloadBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
buildId: 'build-id',
|
||||
deviceRunSessionWorkerApi: expect.any(DeviceRunSessionWorkerApi),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not use the worker API without a session token or for an archive URL', async () => {
|
||||
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' });
|
||||
expect(downloadBuildAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ buildId: 'build-id', deviceRunSessionWorkerApi: null })
|
||||
);
|
||||
|
||||
const sessionCtx = {
|
||||
graphqlClient,
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
} as unknown as CustomBuildContext;
|
||||
await runStep(
|
||||
BuildRuntimePlatform.LINUX,
|
||||
{ application_archive_url: 'https://example.test/app.tar.gz' },
|
||||
undefined,
|
||||
sessionCtx
|
||||
);
|
||||
expect(downloadBuildAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
applicationArchiveUrl: 'https://example.test/app.tar.gz',
|
||||
deviceRunSessionWorkerApi: null,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('fails before download or host startup when guarded device shutdown fails', async () => {
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
});
|
||||
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Booted' } as never);
|
||||
jest.mocked(spawn).mockRejectedValue(new Error('shutdown failed'));
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'shutdown failed'
|
||||
);
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('provides Simulator preparation and downloaded startup options to the remote session', async () => {
|
||||
const hostReady = deferred();
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
});
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async (_ctx, { device }) => {
|
||||
expect('iosSimulatorUdid' in device).toBe(true);
|
||||
if (!('iosSimulatorUdid' in device)) {
|
||||
return;
|
||||
}
|
||||
await hostReady.promise;
|
||||
deviceLaunch = await device.application;
|
||||
});
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, {
|
||||
device_identifier: 'iPhone 17',
|
||||
build_id: 'build-id',
|
||||
launch_args: ['-flag'],
|
||||
open_url: 'exp://example.test',
|
||||
});
|
||||
await flushAsync();
|
||||
expect(IosSimulatorUtils.resolveUdidAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'iPhone 17',
|
||||
env: {},
|
||||
});
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).toHaveBeenCalledTimes(1);
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
|
||||
artifactPath: '/tmp/App.app',
|
||||
env: {},
|
||||
});
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
expect(deviceLaunch).toBeUndefined();
|
||||
hostReady.resolve();
|
||||
await step;
|
||||
expect(deviceLaunch).toEqual({
|
||||
installAppPath: '/tmp/App.app',
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
launchArgs: ['-flag'],
|
||||
openUrl: 'exp://example.test',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails a guarded session before download or host startup if its boot files are missing', async () => {
|
||||
jest
|
||||
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
|
||||
.mockRejectedValue(new Error('missing bin/egress-guard-check'));
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'missing bin/egress-guard-check'
|
||||
);
|
||||
expect(startLocalEgressGuardRelayAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('releases guard reporting and egress resources when host startup fails', async () => {
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
});
|
||||
jest
|
||||
.mocked(runAgentDeviceRemoteSessionAsync)
|
||||
.mockRejectedValueOnce(new Error('guard check failed'));
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'guard check failed'
|
||||
);
|
||||
expect(startLocalEgressGuardRelayAsync).toHaveBeenCalledTimes(1);
|
||||
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits for the download before handing the app to the host', async () => {
|
||||
const download = deferred<{ artifactPath: string }>();
|
||||
jest.mocked(downloadBuildAsync).mockReturnValue(download.promise);
|
||||
|
||||
@@ -190,16 +396,21 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
|
||||
download.resolve({ artifactPath: '/tmp/App.app' });
|
||||
await step;
|
||||
expect(installBuildAsync).toHaveBeenCalledTimes(1);
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('makes the session ready when the device boots if there is no app', async () => {
|
||||
it('passes the selected Simulator to the host even when there is no app', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN);
|
||||
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
await expect(sessionDevice().ready).resolves.toBeUndefined();
|
||||
expect(sessionDevice()).toEqual({
|
||||
iosSimulatorUdid: 'SELECTED-UDID',
|
||||
bootEnv: undefined,
|
||||
application: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('boots the Android Emulator with the device inputs', async () => {
|
||||
@@ -231,23 +442,10 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
});
|
||||
|
||||
it('fails the session when the download fails, without an unhandled rejection', async () => {
|
||||
const boot = deferred<Awaited<ReturnType<typeof bootIosSimulatorAsync>>>();
|
||||
jest.mocked(bootIosSimulatorAsync).mockReturnValue(boot.promise);
|
||||
jest.mocked(downloadBuildAsync).mockRejectedValue(new Error('download failed'));
|
||||
|
||||
const stepFailure = expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })
|
||||
).rejects.toThrow('download failed');
|
||||
// The download fails while the boot still runs. Nothing inside the step may leave
|
||||
// that rejection unhandled; Jest fails the test if it does.
|
||||
await flushAsync();
|
||||
boot.resolve({
|
||||
deviceIdentifier: 'iPhone 17' as never,
|
||||
udid: 'udid' as never,
|
||||
displayName: '',
|
||||
});
|
||||
|
||||
await stepFailure;
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'download failed'
|
||||
);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -276,23 +474,24 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
jest
|
||||
.mocked(runAgentDeviceRemoteSessionAsync)
|
||||
.mockImplementation(async (_ctx, { tasks, device }) => {
|
||||
const ready = runDevicePreparationAsync(device);
|
||||
await beforeFailure?.();
|
||||
const daemon = tasks.run('agent-device daemon', async () => {
|
||||
throw new Error('daemon failed');
|
||||
});
|
||||
try {
|
||||
await Promise.all([daemon, device.ready]);
|
||||
await Promise.all([daemon, ready]);
|
||||
} finally {
|
||||
await Promise.allSettled([daemon, device.ready]);
|
||||
await Promise.allSettled([daemon, ready]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('stops the download and waits for it when the boot fails', async () => {
|
||||
it('stops the download and waits for it when Android boot fails', async () => {
|
||||
const download = mockStalledDownload();
|
||||
jest.mocked(bootIosSimulatorAsync).mockRejectedValue(new Error('boot failed'));
|
||||
jest.mocked(startAndroidEmulatorAsync).mockRejectedValue(new Error('boot failed'));
|
||||
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
await expect(runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'boot failed'
|
||||
);
|
||||
|
||||
@@ -316,25 +515,79 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not launch the app when startup fails during the install', async () => {
|
||||
const install = deferred<Awaited<ReturnType<typeof installBuildAsync>>>();
|
||||
const installStarted = deferred();
|
||||
jest.mocked(installBuildAsync).mockImplementation(() => {
|
||||
installStarted.resolve();
|
||||
return install.promise;
|
||||
it('drains the download when host startup fails before app preparation', async () => {
|
||||
const download = mockStalledDownload();
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mockRejectedValue(new Error('host failed'));
|
||||
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'host failed'
|
||||
);
|
||||
expect(download.aborted()).toBe(true);
|
||||
expect(download.settled()).toBe(true);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not start work for an already cancelled step', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error('cancelled'));
|
||||
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal)
|
||||
).rejects.toThrow('cancelled');
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels and drains a pending download through the external signal', async () => {
|
||||
const controller = new AbortController();
|
||||
const download = mockStalledDownload();
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal);
|
||||
const failure = expect(step).rejects.toThrow('cancelled');
|
||||
await flushAsync();
|
||||
controller.abort(new Error('cancelled'));
|
||||
|
||||
await failure;
|
||||
expect(download.aborted()).toBe(true);
|
||||
expect(download.settled()).toBe(true);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not start device or download work after cancellation during Xcode selection', async () => {
|
||||
const controller = new AbortController();
|
||||
const selected = deferred();
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockReturnValue(selected.promise);
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal);
|
||||
const failure = expect(step).rejects.toThrow('cancelled');
|
||||
await flushAsync();
|
||||
controller.abort(new Error('cancelled'));
|
||||
selected.resolve();
|
||||
|
||||
await failure;
|
||||
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drains app preparation when the daemon fails', async () => {
|
||||
const metadata = deferred<string>();
|
||||
const metadataStarted = deferred();
|
||||
jest.mocked(readIosApplicationIdentifierAsync).mockImplementation(() => {
|
||||
metadataStarted.resolve();
|
||||
return metadata.promise;
|
||||
});
|
||||
mockSessionWithFailingDaemon(async () => {
|
||||
await installStarted.promise;
|
||||
// The install finishes only after the daemon failed.
|
||||
setImmediate(() => install.resolve({ applicationIdentifier: 'dev.example.app' }));
|
||||
await metadataStarted.promise;
|
||||
setImmediate(() => metadata.resolve('dev.example.app'));
|
||||
});
|
||||
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'daemon failed'
|
||||
);
|
||||
|
||||
expect(installBuildAsync).toHaveBeenCalledTimes(1);
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
expect(deviceLaunch).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects conflicting application inputs before it boots anything', async () => {
|
||||
@@ -370,12 +623,78 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
'open_url',
|
||||
'network_capture',
|
||||
'network_capture_fields',
|
||||
'public_preview',
|
||||
'private_preview_url',
|
||||
'frame_ancestors',
|
||||
'cors_origins',
|
||||
'package_version',
|
||||
'max_idle_time_minutes',
|
||||
'max_duration_seconds',
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([undefined, true, false])(
|
||||
'passes public_preview=%s to the iOS session host',
|
||||
async publicPreview => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN, {
|
||||
public_preview: publicPreview,
|
||||
private_preview_url: 'https://expo.dev/private-preview',
|
||||
});
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
disableShare: publicPreview === false,
|
||||
privatePreviewUrl: 'https://expo.dev/private-preview',
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('passes frame_ancestors to the session host', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN, {
|
||||
frame_ancestors: ['https://embed.example.test'],
|
||||
});
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ frameAncestors: ['https://embed.example.test'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('passes cors_origins to the session host', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN, {
|
||||
cors_origins: ['https://app.example.test'],
|
||||
});
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['argument count', { launch_args: Array(257).fill('a') }],
|
||||
['argument length', { launch_args: ['a'.repeat(9000)] }],
|
||||
['NUL argument', { launch_args: ['a\u0000b'] }],
|
||||
['total UTF-8 bytes', { launch_args: Array(8).fill('😃'.repeat(4096)) }],
|
||||
['NUL URL', { open_url: 'example:\u0000screen' }],
|
||||
['URL length', { open_url: `example://${'a'.repeat(8192)}` }],
|
||||
])('rejects iOS launch %s before startup work', async (_name, inputs) => {
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id', ...inputs });
|
||||
await expect(step).rejects.toBeInstanceOf(UserError);
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps Android launch inputs on its existing direct path', async () => {
|
||||
const launchArgs = ['a'.repeat(9000)];
|
||||
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', launch_args: launchArgs });
|
||||
expect(launchApplicationAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, launchArgs })
|
||||
);
|
||||
});
|
||||
|
||||
it('passes network capture to the session', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN, {
|
||||
network_capture: true,
|
||||
|
||||
+149
-3
@@ -1,4 +1,4 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
|
||||
@@ -6,7 +6,15 @@ import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
|
||||
import { sleepAsync } from '../../../utils/retry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
|
||||
import {
|
||||
createStartAppiumRemoteSessionBuildFunction,
|
||||
@@ -24,8 +32,26 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
spawnDetached: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../utils/retry', () => ({
|
||||
...jest.requireActual('../../../utils/retry'),
|
||||
sleepAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../utils/turtleFetch', () => ({
|
||||
...jest.requireActual('../../../utils/turtleFetch'),
|
||||
turtleFetch: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/appiumEvents', () => ({ startAppiumEventCollectionAsync: jest.fn() }));
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/localEgressSession', () => ({
|
||||
...jest.requireActual('../../utils/localEgressSession'),
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as never;
|
||||
|
||||
@@ -168,6 +194,126 @@ describe('createStartAppiumRemoteSessionBuildFunction', () => {
|
||||
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
'launch_app_identifier',
|
||||
'launch_args',
|
||||
'open_url',
|
||||
'public_preview',
|
||||
'private_preview_url',
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () => {
|
||||
const stopError = new Error('Process output drain timed out after 5000ms.');
|
||||
const stopAppium = jest.fn();
|
||||
const stopEventCollection = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => ({
|
||||
stdout: args.includes('--json') ? '{}' : '',
|
||||
})) as never);
|
||||
jest
|
||||
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
|
||||
.mockResolvedValue([{ serialId: 'emulator-5554', state: 'device' } as never]);
|
||||
stopAppium.mockRejectedValue(stopError);
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 4242,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopAppium,
|
||||
});
|
||||
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
|
||||
stopEventCollection.mockResolvedValue(undefined);
|
||||
jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue({
|
||||
stopAsync: stopEventCollection,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
});
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
|
||||
url: 'https://appium-abc.tunnel.example.com',
|
||||
subdomainId: 'appium-abc',
|
||||
stopAsync: jest.fn(),
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
finishAsync: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function runSessionAsync(logger: { info: jest.Mock; warn: jest.Mock }): Promise<void> {
|
||||
await createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {
|
||||
DEVICE_RUN_SESSION_ID: 'device-run-session-id',
|
||||
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'tunnel.example.com',
|
||||
NGROK_AUTHTOKEN: 'ngrok-token',
|
||||
},
|
||||
} as never
|
||||
);
|
||||
}
|
||||
|
||||
function expectAppiumStoppedAndHomeRemoved(logger: { warn: jest.Mock }): void {
|
||||
expect(stopAppium).toHaveBeenCalledTimes(1);
|
||||
expect(fs.existsSync(jest.mocked(spawnDetached).mock.calls[0][0].env.APPIUM_HOME!)).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: stopError },
|
||||
'Could not stop the Appium server during remote session teardown.'
|
||||
);
|
||||
}
|
||||
|
||||
it('keeps the readiness error when Appium cannot be stopped', async () => {
|
||||
let now = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
jest.mocked(sleepAsync).mockImplementation(async ms => {
|
||||
now += ms;
|
||||
});
|
||||
jest.mocked(turtleFetch).mockRejectedValue(new Error('connect ECONNREFUSED'));
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
|
||||
await expect(runSessionAsync(logger)).rejects.toThrow(
|
||||
'Timed out waiting for Appium to become ready.'
|
||||
);
|
||||
expectAppiumStoppedAndHomeRemoved(logger);
|
||||
expect(startAppiumEventCollectionAsync).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the event collection error when Appium cannot be stopped', async () => {
|
||||
const collectionError = new Error('event collection failed');
|
||||
jest.mocked(startAppiumEventCollectionAsync).mockRejectedValueOnce(collectionError);
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
|
||||
await expect(runSessionAsync(logger)).rejects.toBe(collectionError);
|
||||
expectAppiumStoppedAndHomeRemoved(logger);
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails a finished session when Appium cannot be stopped', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
|
||||
await expect(runSessionAsync(logger)).rejects.toBe(stopError);
|
||||
expectAppiumStoppedAndHomeRemoved(logger);
|
||||
expect(stopEventCollection.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
stopAppium.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
});
|
||||
+248
-5
@@ -6,10 +6,12 @@ import path from 'node:path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
|
||||
import { startArgentEventCollectionAsync } from '../../utils/argentEvents';
|
||||
import { createProcessOutput } from '../../utils/processOutput';
|
||||
import {
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
@@ -119,18 +121,21 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
await fs.promises.rm(TEST_HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports an early exit with output before opening tunnels or publishing readiness', async () => {
|
||||
it('reports an early exit with output and stops Argent before opening tunnels', async () => {
|
||||
const stopError = new Error('drain timed out');
|
||||
const stopServer = jest.fn().mockRejectedValue(stopError);
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 4242,
|
||||
getOutput: () => 'could not bind server port',
|
||||
getExitError: () => new Error('process exited with code 1'),
|
||||
stopAsync: jest.fn(),
|
||||
stopAsync: stopServer,
|
||||
});
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
@@ -145,11 +150,66 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
).rejects.toThrow(
|
||||
'Argent exited before becoming ready: process exited with code 1\nArgent tool-server output:\ncould not bind server port'
|
||||
);
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: stopError },
|
||||
'Could not stop the Argent tool-server during remote session teardown.'
|
||||
);
|
||||
expect(pollArgentArtifactsForUploadAsync).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drains artifact polling and stops the tool-server when event collection cannot start', async () => {
|
||||
const collectionError = new Error('event collection failed');
|
||||
const stopError = new Error('drain timed out');
|
||||
let pollingFinished = false;
|
||||
jest
|
||||
.mocked(pollArgentArtifactsForUploadAsync)
|
||||
.mockImplementationOnce(async (_ctx, { signal }) => {
|
||||
await new Promise(resolve => signal.addEventListener('abort', resolve, { once: true }));
|
||||
pollingFinished = true;
|
||||
});
|
||||
jest.mocked(startArgentEventCollectionAsync).mockRejectedValueOnce(collectionError);
|
||||
const stopServer = jest.fn(async () => {
|
||||
expect(pollingFinished).toBe(true);
|
||||
throw stopError;
|
||||
});
|
||||
jest.mocked(spawnDetached).mockReturnValueOnce({
|
||||
pid: 4242,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopServer,
|
||||
});
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
).rejects.toBe(collectionError);
|
||||
expect(pollingFinished).toBe(true);
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: stopError },
|
||||
'Could not stop the Argent tool-server during remote session teardown.'
|
||||
);
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['preview', 'config', 'wait'])(
|
||||
'finishes the host and tools after %s fails',
|
||||
async phase => {
|
||||
@@ -188,6 +248,59 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
['succeeded', false],
|
||||
['failed', true],
|
||||
])(
|
||||
'fails after stopping the host and tools when the tools tunnel cannot close and the session %s',
|
||||
async (_description, sessionFails) => {
|
||||
const closeError = new Error('ngrok close failed');
|
||||
const sessionError = new Error('session failed');
|
||||
mockTunnelStopAsync.mockRejectedValueOnce(closeError);
|
||||
if (sessionFails) {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(sessionError);
|
||||
}
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
// The tunnel may still be serving, so its failure fails a session that otherwise succeeded.
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
).rejects.toBe(sessionFails ? sessionError : closeError);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(jest.mocked(spawnDetached).mock.results[0].value.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: closeError },
|
||||
'Could not stop the Argent tunnel during remote session teardown.'
|
||||
);
|
||||
expect(jest.mocked(Sentry.capture).mock.calls).toEqual(
|
||||
sessionFails
|
||||
? [
|
||||
[
|
||||
'Could not stop the Argent tunnel after the remote session failed',
|
||||
closeError,
|
||||
{ level: 'warning' },
|
||||
],
|
||||
]
|
||||
: []
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('enables the event log flag, disables the video watermark, shares one path, and starts/stops the collector', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
|
||||
@@ -268,7 +381,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, disableShare: false })
|
||||
);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -281,6 +394,82 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('passes public_preview=false to the session host', async () => {
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
public_preview: { value: false },
|
||||
private_preview_url: { value: 'https://expo.dev/private-preview' },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
disableShare: true,
|
||||
privatePreviewUrl: 'https://expo.dev/private-preview',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('passes frame_ancestors to the session host', async () => {
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
frame_ancestors: { value: ['https://embed.example.test'] },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ frameAncestors: ['https://embed.example.test'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('passes cors_origins to the session host', async () => {
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
cors_origins: { value: ['https://app.example.test'] },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('continues the session when disabling the video watermark fails', async () => {
|
||||
const error = new Error('bun x failed');
|
||||
jest
|
||||
@@ -318,6 +507,52 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('redacts startup credentials and registers the state token for later output', async () => {
|
||||
const token = 'tool/server "secret"';
|
||||
await fs.promises.writeFile(
|
||||
path.join(ARGENT_STATE_DIR, 'tool-server-orchestration.json'),
|
||||
JSON.stringify({ port: 5678, pid: 9999, token })
|
||||
);
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
jest.mocked(spawnDetached).mockImplementationOnce(options => {
|
||||
const output = createProcessOutput(options.logger, options.secrets);
|
||||
output.stdout.append(`argent link argent://${encodeURIComponent(token)}@127.0.0.1:5678\n`);
|
||||
return {
|
||||
pid: 4242,
|
||||
getOutput: output.getOutput,
|
||||
getExitError: () => undefined,
|
||||
stopAsync: async () => {
|
||||
output.stderr.append(`opaque ${token}\nencoded ${encodeURIComponent(token)}\n`);
|
||||
output.stderr.append(`escaped ${JSON.stringify(token).slice(1, -1)}\n`);
|
||||
output.finish();
|
||||
},
|
||||
};
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{ source: 'stdout' },
|
||||
'argent link argent://[REDACTED]@127.0.0.1:5678'
|
||||
);
|
||||
for (const label of ['opaque', 'encoded', 'escaped']) {
|
||||
expect(logger.info).toHaveBeenCalledWith({ source: 'stderr' }, `${label} [REDACTED]`);
|
||||
}
|
||||
expect(JSON.stringify(logger.info.mock.calls)).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
@@ -390,7 +625,15 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
'launch_app_identifier',
|
||||
'launch_args',
|
||||
'open_url',
|
||||
'public_preview',
|
||||
'private_preview_url',
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('stops automation without waiting for recording upload', async () => {
|
||||
|
||||
@@ -32,6 +32,7 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
getNgrokAuthtokenOrThrow: () => 'ngrok-token',
|
||||
getNgrokTunnelDomainOrThrow: () => 'example.com',
|
||||
omitNgrokAuthtoken: jest.requireActual('../../utils/remoteDeviceRunSession').omitNgrokAuthtoken,
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
|
||||
+161
-3
@@ -3,6 +3,7 @@ import { BuildRuntimePlatform, type BuildStepContext, type BuildStepEnv } from '
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
@@ -14,6 +15,22 @@ import {
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
|
||||
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
IosSimulatorUtils: {
|
||||
resolveUdidAsync: jest.fn().mockResolvedValue('selected-ios-udid'),
|
||||
getAvailableDevicesAsync: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ name: 'iPhone 17', udid: 'selected-ios-udid' }]),
|
||||
},
|
||||
}));
|
||||
jest.mock('../../utils/iosAppArtifact', () => ({
|
||||
...jest.requireActual('../../utils/iosAppArtifact'),
|
||||
readIosApplicationIdentifierAsync: jest.fn().mockResolvedValue('dev.example.app'),
|
||||
}));
|
||||
jest.mock('../../utils/localEgressGuard', () => ({
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
getDeviceRunSessionIdOrThrow: jest.fn(),
|
||||
@@ -24,7 +41,8 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
}));
|
||||
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const env = {} as BuildStepEnv;
|
||||
const env = { NGROK_AUTHTOKEN: 'ngrok-token', PATH: '/usr/bin' } as BuildStepEnv;
|
||||
const childEnv = { PATH: '/usr/bin' } as BuildStepEnv;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const stopAsync = jest.fn();
|
||||
|
||||
@@ -59,6 +77,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
sharePageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
@@ -73,6 +92,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
sharePageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
previewToken: 'tok-1',
|
||||
closeAsync: jest.fn(),
|
||||
@@ -95,6 +115,54 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('logs the authenticated link while publishing the public frame URL', async () => {
|
||||
const privatePreviewUrl = 'https://expo.dev/private-preview';
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
sharePageUrl: privatePreviewUrl,
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, {
|
||||
public_preview: { value: false },
|
||||
private_preview_url: { value: privatePreviewUrl },
|
||||
});
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
`Preview URL: ${privatePreviewUrl} (server: https://web-preview.example.test).`
|
||||
);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({
|
||||
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not log the public link when a private job lacks its authenticated URL', async () => {
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, { public_preview: { value: false } });
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith('Private preview URL unavailable for this session.');
|
||||
expect(jest.mocked(logger.info).mock.calls).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.arrayContaining([expect.stringContaining('/simulator-preview/preview-id')]),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['preview', 'config', 'wait'])('finishes the session after %s fails', async phase => {
|
||||
const error = new Error(`${phase} failed`);
|
||||
if (phase === 'preview') {
|
||||
@@ -115,21 +183,46 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
[BuildRuntimePlatform.DARWIN, true],
|
||||
[BuildRuntimePlatform.LINUX, false],
|
||||
])('starts the web preview for %s', async (runtimePlatform, selectsXcode) => {
|
||||
await runAsync(runtimePlatform);
|
||||
await runAsync(runtimePlatform, selectsXcode ? { boot_simulator: { value: true } } : {});
|
||||
|
||||
expect(
|
||||
jest
|
||||
.mocked(logger.info)
|
||||
.mock.calls.filter(
|
||||
([message]) =>
|
||||
message ===
|
||||
'Preview URL: https://expo.dev/simulator-preview/preview-id (server: https://web-preview.example.test).'
|
||||
)
|
||||
).toHaveLength(1);
|
||||
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(selectsXcode ? 1 : 0);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(ctx, {
|
||||
runtimePlatform,
|
||||
env,
|
||||
env: childEnv,
|
||||
logger,
|
||||
timeoutMs: 60_000,
|
||||
signal: undefined,
|
||||
...(selectsXcode
|
||||
? {
|
||||
iosSimulatorUdid: 'SELECTED-IOS-UDID',
|
||||
installAppPath: undefined,
|
||||
bootEnv: undefined,
|
||||
}
|
||||
: {}),
|
||||
packageVersion: '1.2.3',
|
||||
disableShare: false,
|
||||
frameAncestors: [],
|
||||
corsOrigins: [],
|
||||
launchAppIdentifier: undefined,
|
||||
launchArgs: [],
|
||||
openUrl: undefined,
|
||||
networkCapture: false,
|
||||
networkCaptureFields: [],
|
||||
});
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
|
||||
expect(host.openPreviewAsync).toHaveBeenCalledWith({
|
||||
baseDomain: 'tunnel.example.com',
|
||||
authtoken: 'ngrok-token',
|
||||
});
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
@@ -160,8 +253,15 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
'launch_app_identifier',
|
||||
'launch_args',
|
||||
'open_url',
|
||||
'boot_simulator',
|
||||
'device_identifier',
|
||||
'install_app_path',
|
||||
'network_capture',
|
||||
'network_capture_fields',
|
||||
'public_preview',
|
||||
'private_preview_url',
|
||||
'frame_ancestors',
|
||||
'cors_origins',
|
||||
'package_version',
|
||||
'max_duration_seconds',
|
||||
]);
|
||||
@@ -188,6 +288,64 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('hands the downloaded app and selected device to serve-sim with initial launch options', async () => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, {
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
install_app_path: { value: '/tmp/App.app' },
|
||||
launch_args: { value: ['--literal', 'value with spaces'] },
|
||||
open_url: { value: 'example://home' },
|
||||
});
|
||||
expect(
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
|
||||
).toBeLessThan(jest.mocked(IosSimulatorUtils.resolveUdidAsync).mock.invocationCallOrder[0]);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
iosSimulatorUdid: 'SELECTED-IOS-UDID',
|
||||
installAppPath: '/tmp/App.app',
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
launchArgs: ['--literal', 'value with spaces'],
|
||||
openUrl: 'example://home',
|
||||
})
|
||||
);
|
||||
});
|
||||
it.each([true, false])(
|
||||
'maps public_preview=%s to the native Share option',
|
||||
async publicPreview => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, {
|
||||
public_preview: { value: publicPreview },
|
||||
private_preview_url: { value: 'https://expo.dev/private-preview' },
|
||||
});
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
disableShare: !publicPreview,
|
||||
privatePreviewUrl: 'https://expo.dev/private-preview',
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('hands frame_ancestors to the session host', async () => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, {
|
||||
frame_ancestors: { value: ['https://embed.example.test'] },
|
||||
});
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ frameAncestors: ['https://embed.example.test'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('hands cors_origins to the session host', async () => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, {
|
||||
cors_origins: { value: ['https://app.example.test'] },
|
||||
});
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('hands network capture to the session host', async () => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, {
|
||||
network_capture: { value: true },
|
||||
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
import { createSubmitToGooglePlayBuildFunction } from '../submitToGooglePlay';
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
jest.mock('promise-retry', () => {
|
||||
const actual = jest.requireActual('promise-retry');
|
||||
return (fn: Parameters<typeof actual>[0], options: object) =>
|
||||
actual(fn, { ...options, minTimeout: 1, maxTimeout: 1, randomize: false });
|
||||
});
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
@@ -174,7 +179,7 @@ it('preserves the original failure when cleanup fails', async () => {
|
||||
api()
|
||||
.put(`${editPath}/tracks/internal`)
|
||||
.reply(400, { error: { message: 'unknown track' } });
|
||||
api().delete(editPath).reply(500);
|
||||
api().delete(editPath).times(6).reply(500);
|
||||
await expect(submission().run()).rejects.toMatchObject({
|
||||
cause: expect.objectContaining({ apiMessage: 'unknown track' }),
|
||||
});
|
||||
@@ -183,6 +188,7 @@ it('does not delete the edit or publish outputs when commit outcome is unknown',
|
||||
upload();
|
||||
api().put(`${editPath}/tracks/internal`).reply(200, { track: 'internal' });
|
||||
api().post(`${editPath}:commit`).query(true).reply(503);
|
||||
api().post(`${editPath}:commit`).query(true).reply(404);
|
||||
let deleted = false;
|
||||
api()
|
||||
.delete(editPath)
|
||||
@@ -220,3 +226,32 @@ it('preserves the cause when the service-account file cannot be read', async ()
|
||||
read.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('logs a formatted rollout and confirms the track update before committing', async () => {
|
||||
upload();
|
||||
const task = submission({ rollout: 0.29 });
|
||||
const info = jest.spyOn(task.step.ctx.logger, 'info');
|
||||
const updatedMessage = 'Updated Google Play track "internal" in the edit.';
|
||||
let loggedBeforeUpdate = false;
|
||||
let messagesBeforeCommit: unknown[] = [];
|
||||
api()
|
||||
.put(`${editPath}/tracks/internal`)
|
||||
.reply(() => {
|
||||
loggedBeforeUpdate = info.mock.calls.some(([message]) => message === updatedMessage);
|
||||
return [200, { track: 'internal' }];
|
||||
});
|
||||
api()
|
||||
.post(`${editPath}:commit`)
|
||||
.query({})
|
||||
.reply(() => {
|
||||
messagesBeforeCommit = info.mock.calls.map(([message]) => message);
|
||||
return [200, { id: 'edit' }];
|
||||
});
|
||||
await task.run();
|
||||
expect(info).toHaveBeenCalledWith('Release status: inProgress. Rollout: 29%.');
|
||||
expect(loggedBeforeUpdate).toBe(false);
|
||||
expect(messagesBeforeCommit.slice(-2)).toEqual([
|
||||
updatedMessage,
|
||||
'Committing the Google Play edit...',
|
||||
]);
|
||||
});
|
||||
@@ -28,6 +28,10 @@ import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
|
||||
import { pluralize } from '../../utils/strings';
|
||||
import {
|
||||
type DeviceRunSessionWorkerApi,
|
||||
getDeviceRunSessionWorkerApi,
|
||||
} from '../utils/deviceRunSessionWorkerApi';
|
||||
|
||||
const streamPipeline = promisify(stream.pipeline);
|
||||
|
||||
@@ -118,6 +122,7 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
logger,
|
||||
...source,
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
deviceRunSessionWorkerApi: buildId ? getDeviceRunSessionWorkerApi(ctx) : null,
|
||||
robotAccessToken: stepsCtx.global.staticContext.job.secrets?.robotAccessToken ?? null,
|
||||
extensions,
|
||||
signal,
|
||||
@@ -131,13 +136,22 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
async function fetchApplicationArchiveUrlAsync({
|
||||
buildId,
|
||||
graphqlClient,
|
||||
deviceRunSessionWorkerApi,
|
||||
signal,
|
||||
}: {
|
||||
buildId: string;
|
||||
graphqlClient: Client;
|
||||
deviceRunSessionWorkerApi?: DeviceRunSessionWorkerApi | null;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<string> {
|
||||
signal?.throwIfAborted();
|
||||
if (deviceRunSessionWorkerApi) {
|
||||
return await fetchDeviceRunSessionApplicationArchiveUrlAsync({
|
||||
buildId,
|
||||
deviceRunSessionWorkerApi,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
const result = await graphqlClient
|
||||
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
|
||||
.toPromise();
|
||||
@@ -163,17 +177,71 @@ async function fetchApplicationArchiveUrlAsync({
|
||||
return applicationArchiveUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* A device run session token can read only the build of its own session, so the build comes from
|
||||
* `GET /v2/device-run-session-worker/application` instead of the builds GraphQL query.
|
||||
*
|
||||
* Only device run session jobs, whose steps www generates from the session, reach this path, so
|
||||
* every failure here is a system error: neither the request nor `build_id` comes from the user.
|
||||
*/
|
||||
async function fetchDeviceRunSessionApplicationArchiveUrlAsync({
|
||||
buildId,
|
||||
deviceRunSessionWorkerApi,
|
||||
signal,
|
||||
}: {
|
||||
buildId: string;
|
||||
deviceRunSessionWorkerApi: DeviceRunSessionWorkerApi;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<string> {
|
||||
let application;
|
||||
try {
|
||||
application = await deviceRunSessionWorkerApi.getApplicationAsync({ signal });
|
||||
} catch (err) {
|
||||
signal?.throwIfAborted();
|
||||
throw new SystemError(
|
||||
`Could not fetch build ${buildId} for this device run session: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
|
||||
if (application.build?.id !== buildId) {
|
||||
throw new SystemError(
|
||||
`Could not fetch build ${buildId}: this device run session runs ${
|
||||
application.build ? `build ${application.build.id}` : 'no build'
|
||||
}, and its session token can read only that build. www generates this step from the ` +
|
||||
'session, so the two should always match. Start a new session, and contact support if ' +
|
||||
'it happens again.'
|
||||
);
|
||||
}
|
||||
if (!application.build.applicationArchiveUrl) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
|
||||
'Build does not have an application archive url'
|
||||
);
|
||||
}
|
||||
return application.build.applicationArchiveUrl;
|
||||
}
|
||||
|
||||
export async function downloadBuildAsync(
|
||||
params: DownloadBuildSource & {
|
||||
logger: bunyan;
|
||||
graphqlClient: Client;
|
||||
/**
|
||||
* Set in a device run session job that has a session token. The build lookup then uses the
|
||||
* device run session worker API instead of GraphQL.
|
||||
*/
|
||||
deviceRunSessionWorkerApi?: DeviceRunSessionWorkerApi | null;
|
||||
robotAccessToken: string | null;
|
||||
extensions: string[];
|
||||
/** Stops the build lookup, the request and the file write when aborted, and skips the rest. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<{ artifactPath: string }> {
|
||||
const { logger, graphqlClient, robotAccessToken, extensions, signal } = params;
|
||||
const { logger, graphqlClient, deviceRunSessionWorkerApi, robotAccessToken, extensions, signal } =
|
||||
params;
|
||||
|
||||
let downloadUrl: string;
|
||||
let headers: { Authorization: string } | undefined;
|
||||
@@ -188,8 +256,19 @@ export async function downloadBuildAsync(
|
||||
headers = undefined;
|
||||
} else if (params.buildId) {
|
||||
const buildId = z.string().uuid().parse(params.buildId);
|
||||
downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient, signal });
|
||||
headers = robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined;
|
||||
downloadUrl = await fetchApplicationArchiveUrlAsync({
|
||||
buildId,
|
||||
graphqlClient,
|
||||
deviceRunSessionWorkerApi,
|
||||
signal,
|
||||
});
|
||||
// The archive URL from the device run session worker API points to an artifact route that
|
||||
// needs no auth. After www stops minting EXPO_TOKEN for session jobs, robotAccessToken is the
|
||||
// session token, and www rejects it as a bearer credential on every route.
|
||||
headers =
|
||||
robotAccessToken && !deviceRunSessionWorkerApi
|
||||
? { Authorization: `Bearer ${robotAccessToken}` }
|
||||
: undefined;
|
||||
} else {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
|
||||
|
||||
@@ -96,6 +96,9 @@ export function createRunAgentBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
if (!ctx.mcpServerUrl) {
|
||||
throw new SystemError('MCP server URL is required to run an agent.');
|
||||
}
|
||||
if (!ctx.runtimeApi.addBuildLogMask) {
|
||||
throw new SystemError('A build log secrets filter is required to run an agent.');
|
||||
}
|
||||
// The worker config holds the MCP server's WebSocket URL; its HTTP endpoint is on the same host.
|
||||
const mcpUrl = new URL('/mcp', ctx.mcpServerUrl);
|
||||
mcpUrl.protocol = mcpUrl.protocol.replace(/^ws/, 'http');
|
||||
@@ -120,6 +123,7 @@ export function createRunAgentBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
pathEnv,
|
||||
npmCacheUrl,
|
||||
logger: stepCtx.logger,
|
||||
addBuildLogMask: ctx.runtimeApi.addBuildLogMask,
|
||||
signal: signal
|
||||
? AbortSignal.any([signal, timeoutController.signal])
|
||||
: timeoutController.signal,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
|
||||
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
|
||||
import { type parseNetworkCaptureInputs } from '../utils/networkCaptureFields';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
|
||||
import { type ServeSimApplicationOptions } from '../utils/remoteDeviceRunSession';
|
||||
import { type StartupTasks } from '../utils/startupTasks';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
@@ -76,13 +77,9 @@ export function getAgentDeviceRemoteSessionEnvOrThrow(
|
||||
* Starts the agent-device daemon, its tunnel, the session host and the web preview,
|
||||
* reports the session as ready, and keeps it alive until it stops.
|
||||
*
|
||||
* The daemon does not need the device, so it starts at once. The session host starts
|
||||
* when `device.booted` resolves. The session is reported as ready only when both are
|
||||
* up and `device.ready` (the app is installed and launched) resolved too.
|
||||
*
|
||||
* The first failure aborts `tasks.signal`: each part stops before its next stage, and
|
||||
* the teardown then stops whatever was started. `device.ready` must settle soon after
|
||||
* an abort, because the teardown waits for it.
|
||||
* Serve-sim boots while the iOS download runs. After device preparation verifies
|
||||
* any egress guard, its control API installs and launches the app.
|
||||
* Startup failures abort the other tasks; teardown drains them before stopping acquired resources.
|
||||
*/
|
||||
export async function runAgentDeviceRemoteSessionAsync(
|
||||
ctx: CustomBuildContext,
|
||||
@@ -96,6 +93,10 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
maxIdleTimeMinutes,
|
||||
maxDurationSeconds,
|
||||
capture,
|
||||
disableShare = false,
|
||||
privatePreviewUrl,
|
||||
frameAncestors,
|
||||
corsOrigins,
|
||||
tasks,
|
||||
device,
|
||||
}: {
|
||||
@@ -108,10 +109,26 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
maxIdleTimeMinutes: number | undefined;
|
||||
maxDurationSeconds: number | undefined;
|
||||
capture: ReturnType<typeof parseNetworkCaptureInputs>;
|
||||
disableShare?: boolean;
|
||||
privatePreviewUrl?: string;
|
||||
frameAncestors?: string[];
|
||||
corsOrigins?: string[];
|
||||
tasks: StartupTasks;
|
||||
device: { booted: Promise<unknown>; ready: Promise<unknown> };
|
||||
device:
|
||||
| {
|
||||
booted: Promise<unknown>;
|
||||
ready: Promise<unknown>;
|
||||
}
|
||||
| {
|
||||
iosSimulatorUdid: string;
|
||||
bootEnv?: Record<string, string>;
|
||||
application?: Promise<ServeSimApplicationOptions | void>;
|
||||
};
|
||||
}
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
tasks.signal.throwIfAborted();
|
||||
|
||||
logger.info(
|
||||
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
@@ -122,6 +139,8 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
let eventCollection: Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>> | undefined;
|
||||
let sessionFailed = false;
|
||||
const daemonSecrets = [ngrokAuthtoken];
|
||||
const hostBootedDevice = 'iosSimulatorUdid' in device ? device : undefined;
|
||||
const externallyBootedDevice = 'booted' in device ? device : undefined;
|
||||
|
||||
// Each task stores what it started, so the teardown below can stop it even when
|
||||
// another task failed first.
|
||||
@@ -150,12 +169,14 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
authtoken: ngrokAuthtoken,
|
||||
logger: taskLogger,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
taskLogger.info(`Tunnel is ready at ${agentDeviceTunnel.url}.`);
|
||||
return { ...daemonInfo, remoteSessionUrl: agentDeviceTunnel.url };
|
||||
});
|
||||
const sessionHostStartup = tasks.run('session host', async taskLogger => {
|
||||
// A boot cannot be cancelled, so stop waiting for it when startup is aborted.
|
||||
await tasks.untilAborted(device.booted);
|
||||
if (externallyBootedDevice) {
|
||||
await tasks.untilAborted(externallyBootedDevice.booted);
|
||||
}
|
||||
tasks.signal.throwIfAborted();
|
||||
sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
separateLogPhase: true,
|
||||
@@ -163,28 +184,45 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
env,
|
||||
logger: taskLogger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
disableShare,
|
||||
privatePreviewUrl,
|
||||
frameAncestors,
|
||||
corsOrigins,
|
||||
signal: tasks.signal,
|
||||
onStartupError: tasks.abort,
|
||||
networkCapture: capture.networkCapture,
|
||||
networkCaptureFields: capture.networkCaptureFields,
|
||||
iosSimulatorUdid: hostBootedDevice?.iosSimulatorUdid,
|
||||
bootEnv: hostBootedDevice?.bootEnv,
|
||||
application: hostBootedDevice?.application,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
const webPreview = await sessionHost.openPreviewAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
|
||||
taskLogger.info(
|
||||
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
|
||||
webPreview.sharePageUrl
|
||||
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
|
||||
: 'Private preview URL unavailable for this session.'
|
||||
);
|
||||
return webPreview;
|
||||
});
|
||||
const deviceReady = externallyBootedDevice?.ready;
|
||||
|
||||
try {
|
||||
const [
|
||||
{ port: daemonPort, token: daemonToken, remoteSessionUrl: agentDeviceRemoteSessionUrl },
|
||||
webPreview,
|
||||
] = await Promise.all([agentDeviceStartup, sessionHostStartup, device.ready]).catch(
|
||||
] = await Promise.all([agentDeviceStartup, sessionHostStartup, deviceReady]).catch(
|
||||
(err: unknown) => {
|
||||
// Also aborts for a `device.ready` that does not come from `tasks.run`.
|
||||
tasks.abort(err);
|
||||
throw err;
|
||||
throw tasks.signal.reason;
|
||||
}
|
||||
);
|
||||
tasks.signal.throwIfAborted();
|
||||
logger.info(tasks.summary());
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
@@ -201,6 +239,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
},
|
||||
logger,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
daemonUrl: `http://127.0.0.1:${daemonPort}`,
|
||||
@@ -236,7 +275,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
// Promise.all rejects on the first failure while other tasks can still be starting.
|
||||
// They stop at their next abort check. Wait for all of them, so the teardown sees
|
||||
// everything that was started.
|
||||
await Promise.allSettled([agentDeviceStartup, sessionHostStartup, device.ready]);
|
||||
await Promise.allSettled([agentDeviceStartup, sessionHostStartup, deviceReady]);
|
||||
const startedDaemon = daemonProcess;
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
@@ -280,6 +319,7 @@ export async function startAgentDeviceDaemonAsync({
|
||||
signal?: AbortSignal;
|
||||
secrets?: string[];
|
||||
}): Promise<DetachedProcessHandle> {
|
||||
signal?.throwIfAborted();
|
||||
const packageSpec = createAgentDevicePackageSpec(packageVersion);
|
||||
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
|
||||
const installDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-agent-device-'));
|
||||
@@ -289,6 +329,7 @@ export async function startAgentDeviceDaemonAsync({
|
||||
);
|
||||
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const add = resolvePackageAdd(packageManager, packageSpec);
|
||||
logger.info(`Installing ${packageSpec} with ${add.command}.`);
|
||||
await spawn(add.command, add.args, { cwd: installDir, env, logger, signal });
|
||||
|
||||
@@ -15,14 +15,27 @@ import {
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorName, IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi';
|
||||
import { withLocalEgressSession } from '../utils/localEgressSession';
|
||||
import { selectXcodeDeveloperDirectoryAsync } from '../utils/remoteDeviceRunSession';
|
||||
import {
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
createPublicPreviewInputProvider,
|
||||
omitNgrokAuthtoken,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
parseNetworkCaptureInputs,
|
||||
} from '../utils/networkCaptureFields';
|
||||
import { createStartupTasks } from '../utils/startupTasks';
|
||||
import {
|
||||
type ServeSimApplicationOptions,
|
||||
validateServeSimLaunchOptions,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
import { downloadBuildAsync } from './downloadBuild';
|
||||
import { installBuildAsync } from './installBuild';
|
||||
@@ -36,26 +49,15 @@ import {
|
||||
runAgentDeviceRemoteSessionAsync,
|
||||
} from './startAgentDeviceRemoteSession';
|
||||
import { startAndroidEmulatorAsync } from './startAndroidEmulator';
|
||||
import { bootIosSimulatorAsync } from './startIosSimulator';
|
||||
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
|
||||
import { readIosApplicationIdentifierAsync } from '../utils/iosAppArtifact';
|
||||
|
||||
const ANDROID_DEVICE_NAME = 'EasAndroidDevice01' as AndroidVirtualDeviceName;
|
||||
|
||||
/**
|
||||
* One step for a whole agent-device session: boot the device, download, install and
|
||||
* launch the app, and start the agent-device daemon and the web preview.
|
||||
*
|
||||
* It does the work of eas/start_ios_simulator or eas/start_android_emulator,
|
||||
* eas/download_build, eas/install_build and eas/launch_application, plus the agent-device
|
||||
* session, so the parts that do not depend on each other can run at the same time:
|
||||
*
|
||||
* boot ─────────────┬─► install ─► launch ─┐
|
||||
* download ─────────┘ │
|
||||
* boot ─► session host ─► web preview ─────┼─► ready
|
||||
* agent-device daemon ─► tunnel ───────────┘
|
||||
*
|
||||
* The first failure aborts the rest: the download stops, and nothing installs, launches
|
||||
* or starts after it. A boot cannot be cancelled, so it can still run when a failed
|
||||
* step returns.
|
||||
* Starts a device session, downloading the app alongside daemon startup.
|
||||
* On iOS, serve-sim boots the selected Simulator, then installs and launches
|
||||
* the downloaded app. Build-tools verifies any egress guard before install and launch.
|
||||
*/
|
||||
export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -114,6 +116,10 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
}),
|
||||
// Session.
|
||||
...createNetworkCaptureInputProviders(),
|
||||
createPublicPreviewInputProvider(),
|
||||
createPrivatePreviewUrlInputProvider(),
|
||||
createFrameAncestorsInputProvider(),
|
||||
createCorsOriginsInputProvider(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -130,13 +136,14 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: withLocalEgressSession(async (stepsCtx, { inputs, env, signal }) => {
|
||||
fn: withLocalEgressSession(async (stepsCtx, { inputs, env: stepEnv, signal }) => {
|
||||
const { logger, global } = stepsCtx;
|
||||
const { runtimePlatform } = global;
|
||||
const isIos = runtimePlatform === BuildRuntimePlatform.DARWIN;
|
||||
|
||||
// Validate everything before any expensive work starts.
|
||||
const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(env);
|
||||
const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(stepEnv);
|
||||
const env = omitNgrokAuthtoken(stepEnv);
|
||||
const buildId = inputs.build_id.value as string | undefined;
|
||||
const applicationArchiveUrl = inputs.application_archive_url.value as string | undefined;
|
||||
if (buildId && applicationArchiveUrl) {
|
||||
@@ -155,6 +162,9 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
'launch_args and open_url need an application: pass build_id or application_archive_url.'
|
||||
);
|
||||
}
|
||||
if (isIos) {
|
||||
validateServeSimLaunchOptions({ launchArgs, openUrl });
|
||||
}
|
||||
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
|
||||
const capture = parseNetworkCaptureInputs(
|
||||
{
|
||||
@@ -164,104 +174,137 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
{ runtimePlatform }
|
||||
);
|
||||
|
||||
if (isIos) {
|
||||
// Before the boot, so every Xcode tool below uses the same developer directory.
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
const tasks = createStartupTasks(logger, signal);
|
||||
let downloaded: Promise<string> | undefined;
|
||||
let applicationReady: Promise<ServeSimApplicationOptions | void> | undefined;
|
||||
|
||||
const tasks = createStartupTasks(logger);
|
||||
|
||||
const booted = tasks.run(
|
||||
isIos ? 'iOS Simulator boot' : 'Android Emulator boot',
|
||||
async taskLogger => {
|
||||
if (isIos) {
|
||||
await bootIosSimulatorAsync({
|
||||
deviceIdentifier: deviceIdentifier as IosSimulatorUuid | IosSimulatorName | undefined,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await startAndroidEmulatorAsync({
|
||||
deviceName: ANDROID_DEVICE_NAME,
|
||||
systemImagePackage: `${inputs.system_image_package.value}`,
|
||||
deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined,
|
||||
lcdWidth: inputs.lcd_width.value as number | undefined,
|
||||
lcdHeight: inputs.lcd_height.value as number | undefined,
|
||||
lcdDensity: inputs.lcd_density.value as number | undefined,
|
||||
logcatDirectory: await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
|
||||
),
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
try {
|
||||
if (isIos) {
|
||||
// Select Xcode before serve-sim boots the device or any Simulator tools run.
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
);
|
||||
|
||||
const downloaded = hasApplication
|
||||
? tasks.run('build download', async taskLogger => {
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: taskLogger,
|
||||
...(buildId ? { buildId } : { applicationArchiveUrl: applicationArchiveUrl! }),
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
|
||||
extensions: isIos ? ['app'] : ['apk'],
|
||||
tasks.signal.throwIfAborted();
|
||||
const { iosStartup } = isIos
|
||||
? await resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator: true,
|
||||
deviceIdentifier,
|
||||
env,
|
||||
logger,
|
||||
signal: tasks.signal,
|
||||
});
|
||||
return artifactPath;
|
||||
})
|
||||
: undefined;
|
||||
})
|
||||
: {};
|
||||
const iosSimulatorUdid = iosStartup?.iosSimulatorUdid;
|
||||
if (iosSimulatorUdid) {
|
||||
logger.info(`Selected iOS Simulator: ${iosSimulatorUdid}.`);
|
||||
}
|
||||
|
||||
// `ready` settles only after everything it started has settled, so the session
|
||||
// teardown, which waits for it, never leaves a download running. It must also settle
|
||||
// soon after an abort: it stops waiting for the boot then, and the download stops.
|
||||
const ready = downloaded
|
||||
? tasks.run('app install and launch', async taskLogger => {
|
||||
const [download, boot] = await Promise.allSettled([
|
||||
downloaded,
|
||||
tasks.untilAborted(booted),
|
||||
]);
|
||||
// After a failure elsewhere, report that failure, not the abort it caused here.
|
||||
tasks.signal.throwIfAborted();
|
||||
if (download.status === 'rejected') {
|
||||
throw download.reason;
|
||||
}
|
||||
if (boot.status === 'rejected') {
|
||||
throw boot.reason;
|
||||
}
|
||||
const { applicationIdentifier, activityName } = await installBuildAsync({
|
||||
artifactPath: download.value,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
const booted = iosSimulatorUdid
|
||||
? undefined
|
||||
: tasks.run('Android Emulator boot', async taskLogger => {
|
||||
const logcatDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
|
||||
);
|
||||
tasks.signal.throwIfAborted();
|
||||
await startAndroidEmulatorAsync({
|
||||
deviceName: ANDROID_DEVICE_NAME,
|
||||
systemImagePackage: `${inputs.system_image_package.value}`,
|
||||
deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined,
|
||||
lcdWidth: inputs.lcd_width.value as number | undefined,
|
||||
lcdHeight: inputs.lcd_height.value as number | undefined,
|
||||
lcdDensity: inputs.lcd_density.value as number | undefined,
|
||||
logcatDirectory,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier,
|
||||
activityName,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
})
|
||||
: tasks.untilAborted(booted);
|
||||
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
runtimePlatform,
|
||||
sessionEnv,
|
||||
packageVersion: inputs.package_version.value as string | undefined,
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined,
|
||||
maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined,
|
||||
capture,
|
||||
tasks,
|
||||
device: { booted, ready },
|
||||
});
|
||||
downloaded = hasApplication
|
||||
? tasks.run('build download', async taskLogger => {
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: taskLogger,
|
||||
...(buildId ? { buildId } : { applicationArchiveUrl: applicationArchiveUrl! }),
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
deviceRunSessionWorkerApi: buildId ? getDeviceRunSessionWorkerApi(ctx) : null,
|
||||
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
|
||||
extensions: isIos ? ['app'] : ['apk'],
|
||||
signal: tasks.signal,
|
||||
});
|
||||
return artifactPath;
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const download = downloaded;
|
||||
applicationReady = download
|
||||
? tasks.run(isIos ? 'app preparation' : 'app install and launch', async taskLogger => {
|
||||
if (booted) {
|
||||
await tasks.untilAborted(booted);
|
||||
}
|
||||
const artifactPath = await download;
|
||||
tasks.signal.throwIfAborted();
|
||||
if (isIos) {
|
||||
const applicationIdentifier = await readIosApplicationIdentifierAsync({
|
||||
artifactPath,
|
||||
env,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
return {
|
||||
installAppPath: artifactPath,
|
||||
launchAppIdentifier: applicationIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
};
|
||||
}
|
||||
const { applicationIdentifier, activityName } = await installBuildAsync({
|
||||
artifactPath,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier,
|
||||
activityName,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
})
|
||||
: undefined;
|
||||
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
runtimePlatform,
|
||||
sessionEnv,
|
||||
packageVersion: inputs.package_version.value as string | undefined,
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined,
|
||||
maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined,
|
||||
capture,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
tasks,
|
||||
device: iosSimulatorUdid
|
||||
? {
|
||||
...iosStartup,
|
||||
iosSimulatorUdid,
|
||||
application: applicationReady,
|
||||
}
|
||||
: {
|
||||
booted: booted!,
|
||||
ready: applicationReady ?? tasks.untilAborted(booted!),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
tasks.abort(new Error('Agent-device session ended.'));
|
||||
await Promise.allSettled([downloaded, applicationReady]);
|
||||
}
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -37,6 +37,10 @@ import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
|
||||
import { preparePreinstalledWdaAsync } from '../utils/preinstalledWda';
|
||||
import {
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
createPublicPreviewInputProvider,
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
@@ -44,6 +48,9 @@ import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
omitNgrokAuthtoken,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
@@ -72,6 +79,10 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
createPublicPreviewInputProvider(),
|
||||
createPrivatePreviewUrlInputProvider(),
|
||||
createFrameAncestorsInputProvider(),
|
||||
createCorsOriginsInputProvider(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -83,10 +94,11 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env: stepEnv, signal }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(stepEnv);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(stepEnv);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(stepEnv);
|
||||
const env = omitNgrokAuthtoken(stepEnv);
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
@@ -150,25 +162,20 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
JSON.stringify({ 'appium:eventTimings': true, ...wdaCapabilities }),
|
||||
],
|
||||
env: appiumEnv,
|
||||
});
|
||||
try {
|
||||
await waitForAppiumReadyAsync({ appiumProcess, logger });
|
||||
} catch (error) {
|
||||
await appiumProcess.stopAsync();
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
const eventCollection = await startAppiumEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
appiumUrl: `http://${APPIUM_HOST}:${APPIUM_PORT}/`,
|
||||
logger,
|
||||
});
|
||||
let eventCollection: Awaited<ReturnType<typeof startAppiumEventCollectionAsync>> | undefined;
|
||||
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let sessionFailed = false;
|
||||
try {
|
||||
await waitForAppiumReadyAsync({ appiumProcess, logger });
|
||||
eventCollection = await startAppiumEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
appiumUrl: `http://${APPIUM_HOST}:${APPIUM_PORT}/`,
|
||||
logger,
|
||||
});
|
||||
appiumTunnel = await startNgrokTunnelAsync({
|
||||
port: APPIUM_PORT,
|
||||
subdomainPrefix: 'appium',
|
||||
@@ -184,17 +191,25 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
separateLogPhase: true,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
});
|
||||
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
|
||||
const webPreview = await sessionHost.openPreviewAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
});
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
@@ -241,10 +256,13 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
'Appium server',
|
||||
(async () => {
|
||||
try {
|
||||
await eventCollection.stopAsync();
|
||||
await eventCollection?.stopAsync();
|
||||
} finally {
|
||||
await appiumProcess.stopAsync();
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
try {
|
||||
await appiumProcess.stopAsync();
|
||||
} finally {
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
})(),
|
||||
],
|
||||
|
||||
@@ -34,6 +34,10 @@ import { sleepAsync } from '../../utils/retry';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
|
||||
import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents';
|
||||
import {
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
createPublicPreviewInputProvider,
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
@@ -41,6 +45,9 @@ import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
omitNgrokAuthtoken,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
@@ -84,6 +91,10 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
createPublicPreviewInputProvider(),
|
||||
createPrivatePreviewUrlInputProvider(),
|
||||
createFrameAncestorsInputProvider(),
|
||||
createCorsOriginsInputProvider(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -100,14 +111,15 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env: stepEnv, signal }) => {
|
||||
// Fail fast before any expensive setup if the injected env
|
||||
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
|
||||
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
|
||||
// for our ngrok tunnels), and NGROK_AUTHTOKEN (to authenticate them).
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(stepEnv);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(stepEnv);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(stepEnv);
|
||||
const env = omitNgrokAuthtoken(stepEnv);
|
||||
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
@@ -197,10 +209,13 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
);
|
||||
// Keep Argent itself in foreground mode under the detached process. This preserves
|
||||
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
const secrets: string[] = [];
|
||||
const argentServer = spawnDetached({
|
||||
command: startServer.command,
|
||||
args: startServer.args,
|
||||
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH, ARGENT_EMULATOR_NO_WINDOW: '1' },
|
||||
logger,
|
||||
secrets,
|
||||
});
|
||||
if (argentServer.pid === undefined) {
|
||||
throw new SystemError(
|
||||
@@ -208,50 +223,55 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`);
|
||||
let toolServerPort: number;
|
||||
let toolServerToken: string | undefined;
|
||||
try {
|
||||
const toolServerState = await waitForArgentToolServerStateAsync({
|
||||
stateDir: ARGENT_STATE_DIR,
|
||||
ancestorPid: argentServer.pid,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
getExitError: argentServer.getExitError,
|
||||
});
|
||||
toolServerPort = toolServerState.port;
|
||||
toolServerToken = toolServerState.token;
|
||||
} catch (err) {
|
||||
const output = argentServer.getOutput();
|
||||
throw new SystemError(
|
||||
`${
|
||||
err instanceof Error ? err.message : `Timed out waiting for argent tool-server state.`
|
||||
}${output ? `\nArgent tool-server output:\n${output}` : ''}`
|
||||
);
|
||||
}
|
||||
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
|
||||
const artifactPollAbortController = new AbortController();
|
||||
const artifactPollSignal = signal
|
||||
? AbortSignal.any([signal, artifactPollAbortController.signal])
|
||||
: artifactPollAbortController.signal;
|
||||
const artifactPollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
toolsUrl: `http://127.0.0.1:${toolServerPort}`,
|
||||
toolsAuthToken: toolServerToken,
|
||||
logger,
|
||||
signal: artifactPollSignal,
|
||||
});
|
||||
|
||||
const eventCollection = await startArgentEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
eventLogPath: ARGENT_EVENT_LOG_PATH,
|
||||
logger,
|
||||
});
|
||||
|
||||
let artifactPollingPromise: Promise<void> | undefined;
|
||||
let eventCollection: Awaited<ReturnType<typeof startArgentEventCollectionAsync>> | undefined;
|
||||
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let sessionFailed = false;
|
||||
try {
|
||||
logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`);
|
||||
let toolServerPort: number;
|
||||
let toolServerToken: string | undefined;
|
||||
try {
|
||||
const toolServerState = await waitForArgentToolServerStateAsync({
|
||||
stateDir: ARGENT_STATE_DIR,
|
||||
ancestorPid: argentServer.pid,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
getExitError: argentServer.getExitError,
|
||||
});
|
||||
toolServerPort = toolServerState.port;
|
||||
toolServerToken = toolServerState.token;
|
||||
if (toolServerToken) {
|
||||
secrets.push(toolServerToken);
|
||||
}
|
||||
} catch (err) {
|
||||
const output = argentServer.getOutput();
|
||||
throw new SystemError(
|
||||
`${
|
||||
err instanceof Error ? err.message : `Timed out waiting for argent tool-server state.`
|
||||
}${output ? `\nArgent tool-server output:\n${output}` : ''}`
|
||||
);
|
||||
}
|
||||
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
|
||||
const artifactPollSignal = signal
|
||||
? AbortSignal.any([signal, artifactPollAbortController.signal])
|
||||
: artifactPollAbortController.signal;
|
||||
artifactPollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
toolsUrl: `http://127.0.0.1:${toolServerPort}`,
|
||||
toolsAuthToken: toolServerToken,
|
||||
logger,
|
||||
signal: artifactPollSignal,
|
||||
});
|
||||
|
||||
eventCollection = await startArgentEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
eventLogPath: ARGENT_EVENT_LOG_PATH,
|
||||
logger,
|
||||
});
|
||||
|
||||
toolsTunnel = await startNgrokTunnelAsync({
|
||||
port: toolServerPort,
|
||||
subdomainPrefix: 'argent',
|
||||
@@ -268,19 +288,29 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
separateLogPhase: true,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
});
|
||||
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
|
||||
const webPreview = await sessionHost.openPreviewAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
});
|
||||
logger.info(
|
||||
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
|
||||
webPreview.sharePageUrl
|
||||
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
|
||||
: 'Private preview URL unavailable for this session.'
|
||||
);
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
@@ -325,11 +355,13 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
'Argent tool-server',
|
||||
(async () => {
|
||||
try {
|
||||
await stopArgentEventCollectionSafelyAsync({
|
||||
eventCollection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
});
|
||||
if (eventCollection) {
|
||||
await stopArgentEventCollectionSafelyAsync({
|
||||
eventCollection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
artifactPollAbortController.abort();
|
||||
try {
|
||||
await artifactPollingPromise;
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
findAvailablePortAsync,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
omitNgrokAuthtoken,
|
||||
startNgrokTunnelAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
@@ -76,11 +77,11 @@ function awaitLocalEgressAcquisitionAsync<T>(
|
||||
|
||||
/**
|
||||
* Points the device host's system HTTP(S) proxy at the EAS CLI's egress client.
|
||||
* Must run before `eas/start_ios_simulator`: the simulator reads the system proxy
|
||||
* at boot. Once a simulator boots, `eas/start_ios_simulator` also sets proxy
|
||||
* environment variables inside it for clients that read them (gRPC, libcurl)
|
||||
* and installs the local egress guard, which refuses connections that ignore
|
||||
* both. On Linux it starts the Android emulator relay and fence instead; see
|
||||
* Must run before iOS simulator startup: the simulator reads the system proxy
|
||||
* at boot. Startup supplies proxy variables for gRPC/libcurl and the guard that
|
||||
* refuses connections bypassing the proxy. Serve-sim-owned startup passes this
|
||||
* boot configuration to serve-sim.
|
||||
* On Linux it starts the Android emulator relay and fence instead; see
|
||||
* androidLocalEgress.ts. The shared session cleanup releases the resources
|
||||
* started here when the session ends, with a job finalizer as a fallback.
|
||||
*/
|
||||
@@ -91,9 +92,10 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
|
||||
name: 'Start local egress',
|
||||
__metricsId: 'eas/start_local_egress',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX],
|
||||
fn: async ({ logger, global }, { env, signal }) => {
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
fn: async ({ logger, global }, { env: stepEnv, signal }) => {
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(stepEnv);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(stepEnv);
|
||||
const env = omitNgrokAuthtoken(stepEnv);
|
||||
const isAndroid = global?.runtimePlatform === BuildRuntimePlatform.LINUX;
|
||||
let workDir: string | undefined;
|
||||
let server: DetachedProcessHandle | undefined;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
parseNetworkCaptureInputs,
|
||||
@@ -16,11 +17,19 @@ import {
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import {
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createIosSessionStartupInputProviders,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
createPublicPreviewInputProvider,
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
omitNgrokAuthtoken,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
@@ -37,7 +46,12 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
__metricsId: 'eas/start_serve_sim_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createIosSessionStartupInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
createPublicPreviewInputProvider(),
|
||||
createPrivatePreviewUrlInputProvider(),
|
||||
createFrameAncestorsInputProvider(),
|
||||
createCorsOriginsInputProvider(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -49,20 +63,29 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env: stepEnv, signal }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(stepEnv);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(stepEnv);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(stepEnv);
|
||||
const env = omitNgrokAuthtoken(stepEnv);
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
const packageVersion = inputs.package_version?.value as string | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
const launch = parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value as string | undefined,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
|
||||
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
|
||||
installAppPath: inputs.install_app_path?.value as string | undefined,
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture?.value,
|
||||
@@ -77,16 +100,18 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
|
||||
const sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
...iosStartup,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
signal,
|
||||
packageVersion,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
@@ -95,8 +120,15 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
});
|
||||
|
||||
try {
|
||||
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
|
||||
logger.info(`Preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`);
|
||||
const webPreview = await sessionHost.openPreviewAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
});
|
||||
logger.info(
|
||||
webPreview.sharePageUrl
|
||||
? `Preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
|
||||
: 'Private preview URL unavailable for this session.'
|
||||
);
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
|
||||
@@ -131,7 +131,7 @@ export function createSubmitToGooglePlayBuildFunction(): BuildFunction {
|
||||
keyPath: path.resolve(ctx.workingDirectory, submission.service_account_key_path),
|
||||
});
|
||||
const artifactPath = path.resolve(ctx.workingDirectory, submission.artifact_path);
|
||||
const client = new GooglePlayClient(credentials);
|
||||
const client = new GooglePlayClient(credentials, ctx.logger);
|
||||
const versionCode = await submitToGooglePlayAsync({
|
||||
artifactType: submission.artifact_type,
|
||||
client,
|
||||
@@ -185,7 +185,19 @@ async function submitToGooglePlayAsync({
|
||||
`Submitting ${packageName} (${artifactType}) to track ${JSON.stringify(submission.track)}.`
|
||||
);
|
||||
logger.info(
|
||||
`Requested status: ${submission.release_status}; rollout: ${submission.rollout ?? 'none'}. Effective status: ${release.status}; rollout: ${release.userFraction ?? 'none'}. changes_not_sent_for_review: ${submission.changes_not_sent_for_review}.`
|
||||
`Release status: ${release.status}. Rollout: ${release.userFraction === undefined ? 'none' : new Intl.NumberFormat('en-US', { style: 'percent', maximumSignificantDigits: 15 }).format(release.userFraction)}.`
|
||||
);
|
||||
if (submission.release_status !== release.status) {
|
||||
logger.info(
|
||||
release.status === 'completed'
|
||||
? 'A 100% rollout is submitted as a completed release.'
|
||||
: 'A partial rollout is submitted as an in-progress release.'
|
||||
);
|
||||
}
|
||||
logger.info(
|
||||
submission.changes_not_sent_for_review
|
||||
? 'Save changes without sending them for review.'
|
||||
: 'Send changes for review.'
|
||||
);
|
||||
if (submission.changelog) {
|
||||
logger.info(`Changelog (en-US): ${JSON.stringify(submission.changelog)}`);
|
||||
@@ -216,7 +228,8 @@ async function submitToGooglePlayAsync({
|
||||
changelog: submission.changelog ? { locale: 'en-US', text: submission.changelog } : undefined,
|
||||
signal,
|
||||
});
|
||||
logger.info('Updated Google Play track ' + JSON.stringify(submission.track) + ' in the edit.');
|
||||
logger.info(`Updated Google Play track ${JSON.stringify(submission.track)} in the edit.`);
|
||||
logger.info('Committing the Google Play edit...');
|
||||
// Commit also validates the edit.
|
||||
await GooglePlayUtils.commitEditAsync(client, {
|
||||
packageName,
|
||||
@@ -232,7 +245,7 @@ async function submitToGooglePlayAsync({
|
||||
return versionCode;
|
||||
} catch (error) {
|
||||
commitOutcomeUnknown = error instanceof GooglePlayCommitOutcomeUnknownError;
|
||||
throw GooglePlayUtils.mapGooglePlayError(error);
|
||||
throw GooglePlayUtils.mapGooglePlayError(error, submission.package_name);
|
||||
} finally {
|
||||
if (editId && !committed && !commitOutcomeUnknown) {
|
||||
try {
|
||||
@@ -243,7 +256,7 @@ async function submitToGooglePlayAsync({
|
||||
});
|
||||
} catch {
|
||||
logger.warn(
|
||||
'Could not delete the Google Play edit after failure. The original submission error is preserved.'
|
||||
'Could not discard the pending Google Play changes. Check the release in Play Console before submitting again.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,12 @@ import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import HttpLogStream from '../../../logging/HttpLogStream';
|
||||
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';
|
||||
|
||||
const mockEventLogStream = {
|
||||
init: jest.fn(async () => undefined),
|
||||
@@ -28,10 +33,18 @@ jest.mock('../../../logging/RemoteLoggerStream', () => ({
|
||||
default: jest.fn(() => mockEventLogStream),
|
||||
}));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
const DEVICE_RUN_SESSION_TOKEN = 'device-run-session-token-value';
|
||||
const EVENT_LOG_UPLOAD_SESSION_URL =
|
||||
'https://api.expo.test/v2/device-run-session-worker/event-log-upload-session';
|
||||
|
||||
describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(turtleFetch).mockImplementation(async () => eventLogUploadSessionResponse());
|
||||
});
|
||||
|
||||
it('uploads normalized events to the session event artifact once', async () => {
|
||||
@@ -88,9 +101,13 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
await fs.promises.rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(ctx.graphqlClient.mutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
deviceRunSessionId: 'session-id',
|
||||
});
|
||||
expect(turtleFetch).toHaveBeenCalledWith(
|
||||
EVENT_LOG_UPLOAD_SESSION_URL,
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
|
||||
})
|
||||
);
|
||||
expect(RemoteLoggerStream).toHaveBeenCalledWith({
|
||||
logger,
|
||||
uploadMethod: {
|
||||
@@ -535,11 +552,13 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
});
|
||||
|
||||
it('does not fail the session when event log setup fails', async () => {
|
||||
const error = new Error('WWW unavailable');
|
||||
const ctx = createContext({ realtimeLogs: true });
|
||||
jest.mocked(ctx.graphqlClient.mutation).mockReturnValueOnce({
|
||||
toPromise: async () => ({ error }),
|
||||
} as unknown as ReturnType<CustomBuildContext['graphqlClient']['mutation']>);
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ errors: [{ message: 'WWW unavailable' }] }), {
|
||||
status: 503,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
);
|
||||
const logger = createLogger();
|
||||
|
||||
const collection = await startAgentDeviceEventCollectionAsync({
|
||||
@@ -554,8 +573,11 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{
|
||||
err: expect.objectContaining({
|
||||
message: 'Failed to create device run session event log upload session: WWW unavailable',
|
||||
cause: error,
|
||||
message:
|
||||
'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),
|
||||
}),
|
||||
},
|
||||
'Could not persist device run session events to the artifact.'
|
||||
@@ -563,8 +585,11 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not persist device run session events',
|
||||
expect.objectContaining({
|
||||
message: 'Failed to create device run session event log upload session: WWW unavailable',
|
||||
cause: error,
|
||||
message:
|
||||
'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),
|
||||
}),
|
||||
{
|
||||
level: 'warning',
|
||||
@@ -669,32 +694,35 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
function createContext({
|
||||
realtimeLogs = false,
|
||||
}: { realtimeLogs?: boolean } = {}): CustomBuildContext {
|
||||
const mutation = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createEventLogUploadSession: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/events',
|
||||
headers: {
|
||||
'content-disposition': 'attachment; filename="events.ndjson"',
|
||||
'x-goog-content-length-range': '0,104857600',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
return {
|
||||
graphqlClient: { mutation },
|
||||
env: realtimeLogs ? { EXPO_STAGING: '1', EAS_BUILD_ID: 'job-run-id' } : {},
|
||||
env: {
|
||||
EAS_DEVICE_RUN_SESSION_TOKEN: DEVICE_RUN_SESSION_TOKEN,
|
||||
...(realtimeLogs ? { EXPO_STAGING: '1', EAS_BUILD_ID: 'job-run-id' } : {}),
|
||||
},
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
job: {
|
||||
secrets: realtimeLogs ? { robotAccessToken: 'robot-access-token' } : {},
|
||||
},
|
||||
} as unknown as CustomBuildContext;
|
||||
}
|
||||
|
||||
function eventLogUploadSessionResponse(): InstanceType<typeof Response> {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/events',
|
||||
headers: {
|
||||
'content-disposition': 'attachment; filename="events.ndjson"',
|
||||
'x-goog-content-length-range': '0,104857600',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
function createLogger(): bunyan {
|
||||
return { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ function createOptions(
|
||||
pathEnv: '/usr/local/bin:/usr/bin',
|
||||
npmCacheUrl: 'https://npm-cache.expo.test',
|
||||
logger: createMockLogger(),
|
||||
addBuildLogMask: jest.fn(),
|
||||
signal: new AbortController().signal,
|
||||
...overrides,
|
||||
};
|
||||
@@ -220,23 +221,22 @@ describe(runAgentAsync, () => {
|
||||
expect(agent.stdin).toHaveBeenCalledWith('Fix the failing test.');
|
||||
});
|
||||
|
||||
it('writes the agent output as log lines with the leased credentials masked', async () => {
|
||||
it('writes the agent output as log lines', async () => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const logger = createMockLogger();
|
||||
mockSpawn({
|
||||
onAgent: agent => {
|
||||
// One JSON event split across two chunks, in the middle of the token.
|
||||
const toolResultEvent =
|
||||
'{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"3 tests passed"}]}}\n';
|
||||
agent.child.stdout.write(toolResultEvent.slice(0, 40));
|
||||
agent.child.stdout.write(toolResultEvent.slice(40));
|
||||
agent.child.stdout.write(
|
||||
'{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"token: anthropic-acc'
|
||||
);
|
||||
agent.child.stdout.write('ess-token"}]}}\n');
|
||||
agent.child.stdout.write(
|
||||
'{"type":"assistant","message":{"content":[{"type":"text","text":"Done.\\nThe token was anthropic-access-token.\\n"}]}}\n'
|
||||
'{"type":"assistant","message":{"content":[{"type":"text","text":"Done.\\nThe tests pass.\\n"}]}}\n'
|
||||
);
|
||||
agent.child.stdout.write(
|
||||
'{"type":"result","subtype":"error_during_execution","is_error":true,"errors":["boom"]}\n'
|
||||
);
|
||||
agent.child.stderr.write('warning: anthropic-access-token\n');
|
||||
agent.child.stderr.write('warning: deprecated flag\n');
|
||||
agent.exit(0);
|
||||
},
|
||||
});
|
||||
@@ -247,11 +247,54 @@ describe(runAgentAsync, () => {
|
||||
expect(child.mock.calls).toEqual([[{ source: 'stdout' }], [{ source: 'stderr' }]]);
|
||||
const [stdoutLogger, stderrLogger] = child.mock.results.map(result => result.value);
|
||||
expect(stdoutLogger.info.mock.calls).toEqual([
|
||||
['Tool result: token: [redacted]'],
|
||||
['Agent: Done.\nThe token was [redacted].'],
|
||||
['Tool result: 3 tests passed'],
|
||||
['Agent: Done.\nThe tests pass.'],
|
||||
]);
|
||||
expect(stdoutLogger.error.mock.calls).toEqual([['Agent failed: boom']]);
|
||||
expect(stderrLogger.info.mock.calls).toEqual([['warning: [redacted]']]);
|
||||
expect(stderrLogger.info.mock.calls).toEqual([['warning: deprecated flag']]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['claude-code', anthropicLease, ['anthropic-access-token']],
|
||||
['codex', openaiLease, ['openai-access-token', 'openai-id-token']],
|
||||
] as const)(
|
||||
'registers the credentials leased for %s with the build log secrets filter before starting the agent',
|
||||
async (agentKind, lease, secrets) => {
|
||||
leaseMock.mockResolvedValue(lease);
|
||||
const addBuildLogMask = jest.fn();
|
||||
const invocations = mockSpawn({
|
||||
onAgent: agent => {
|
||||
expect(addBuildLogMask.mock.calls).toEqual(secrets.map(secret => [secret]));
|
||||
agent.exit(0);
|
||||
},
|
||||
});
|
||||
|
||||
await runAgentAsync(createOptions({ agentKind, addBuildLogMask }));
|
||||
|
||||
expect(invocations).toHaveLength(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('registers renewed credentials before resuming the agent', async () => {
|
||||
leaseMock
|
||||
.mockResolvedValueOnce(anthropicLease)
|
||||
.mockResolvedValueOnce({ ...anthropicLease, accessToken: 'renewed-access-token' });
|
||||
const addBuildLogMask = jest.fn();
|
||||
const invocations = mockSpawn({
|
||||
onAgent: agent => {
|
||||
if (invocations.length === 2) {
|
||||
expect(addBuildLogMask.mock.calls).toEqual([
|
||||
['anthropic-access-token'],
|
||||
['renewed-access-token'],
|
||||
]);
|
||||
}
|
||||
agent.exit(invocations.length === 1 ? 1 : 0);
|
||||
},
|
||||
});
|
||||
|
||||
await runAgentAsync(createOptions({ addBuildLogMask }));
|
||||
|
||||
expect(invocations).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('reports a line the formatter cannot handle instead of ending the run', async () => {
|
||||
@@ -427,12 +470,7 @@ describe(runAgentAsync, () => {
|
||||
leaseMock.mockResolvedValueOnce(anthropicLease).mockResolvedValueOnce(renewedLease);
|
||||
const logger = createMockLogger();
|
||||
const invocations = mockSpawn({
|
||||
onAgent: agent => {
|
||||
if (invocations.length === 2) {
|
||||
agent.child.stderr.write('tokens: anthropic-access-token renewed-access-token\n');
|
||||
}
|
||||
agent.exit(invocations.length === 1 ? 1 : 0);
|
||||
},
|
||||
onAgent: agent => agent.exit(invocations.length === 1 ? 1 : 0),
|
||||
});
|
||||
|
||||
await runAgentAsync(createOptions({ logger }));
|
||||
@@ -452,8 +490,6 @@ describe(runAgentAsync, () => {
|
||||
[{ source: 'stdout' }],
|
||||
[{ source: 'stderr' }],
|
||||
]);
|
||||
const [, , , resumedStderrLogger] = child.mock.results.map(result => result.value);
|
||||
expect(resumedStderrLogger.info.mock.calls).toEqual([['tokens: [redacted] [redacted]']]);
|
||||
});
|
||||
|
||||
it('resumes Codex with new credentials when the lease changed during the run', async () => {
|
||||
|
||||
@@ -7,7 +7,9 @@ import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import HttpLogStream from '../../../logging/HttpLogStream';
|
||||
import RemoteLoggerStream from '../../../logging/RemoteLoggerStream';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startArgentEventCollectionAsync } from '../argentEvents';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
|
||||
const mockEventLogStream = {
|
||||
init: jest.fn(async () => undefined),
|
||||
@@ -28,12 +30,20 @@ jest.mock('../../../logging/RemoteLoggerStream', () => ({
|
||||
default: jest.fn(() => mockEventLogStream),
|
||||
}));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
const DEVICE_RUN_SESSION_TOKEN = 'device-run-session-token-value';
|
||||
const EVENT_LOG_UPLOAD_SESSION_URL =
|
||||
'https://api.expo.test/v2/device-run-session-worker/event-log-upload-session';
|
||||
|
||||
const EVENT_LOG_FILENAME = 'tool-server-events.jsonl';
|
||||
|
||||
describe(startArgentEventCollectionAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(turtleFetch).mockImplementation(async () => eventLogUploadSessionResponse());
|
||||
});
|
||||
|
||||
it('maps tool lifecycle records onto the shared operation vocabulary', async () => {
|
||||
@@ -84,9 +94,13 @@ describe(startArgentEventCollectionAsync, () => {
|
||||
await fs.promises.rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(ctx.graphqlClient.mutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
deviceRunSessionId: 'session-id',
|
||||
});
|
||||
expect(turtleFetch).toHaveBeenCalledWith(
|
||||
EVENT_LOG_UPLOAD_SESSION_URL,
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
|
||||
})
|
||||
);
|
||||
expect(RemoteLoggerStream).toHaveBeenCalledWith({
|
||||
logger,
|
||||
uploadMethod: {
|
||||
@@ -318,29 +332,32 @@ function bunyanRecord(fields: Record<string, unknown>): Record<string, unknown>
|
||||
function createContext({
|
||||
realtimeLogs = false,
|
||||
}: { realtimeLogs?: boolean } = {}): CustomBuildContext {
|
||||
const mutation = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createEventLogUploadSession: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/events',
|
||||
headers: { 'content-disposition': 'attachment; filename="events.ndjson"' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
return {
|
||||
graphqlClient: { mutation },
|
||||
env: realtimeLogs ? { EXPO_STAGING: '1', EAS_BUILD_ID: 'job-run-id' } : {},
|
||||
env: {
|
||||
EAS_DEVICE_RUN_SESSION_TOKEN: DEVICE_RUN_SESSION_TOKEN,
|
||||
...(realtimeLogs ? { EXPO_STAGING: '1', EAS_BUILD_ID: 'job-run-id' } : {}),
|
||||
},
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
job: {
|
||||
secrets: realtimeLogs ? { robotAccessToken: 'robot-access-token' } : {},
|
||||
},
|
||||
} as unknown as CustomBuildContext;
|
||||
}
|
||||
|
||||
function eventLogUploadSessionResponse(): InstanceType<typeof Response> {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/events',
|
||||
headers: { 'content-disposition': 'attachment; filename="events.ndjson"' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
function createLogger(): bunyan {
|
||||
return { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
}
|
||||
|
||||
@@ -2,46 +2,56 @@ import fetch from 'node-fetch';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
const TOKEN = 'device-run-session-token-value';
|
||||
|
||||
function jsonResponse(body: unknown): InstanceType<typeof Response> {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function createCtx(): CustomBuildContext {
|
||||
return {
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN },
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
} as unknown as CustomBuildContext;
|
||||
}
|
||||
|
||||
describe(uploadDeviceRunSessionArtifactAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(fetch).mockReset();
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
it('streams an artifact through a signed upload URL', async () => {
|
||||
const stream = Readable.from(Buffer.from('artifact-data'));
|
||||
const reportedSize = 1024;
|
||||
const mutation = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/artifact',
|
||||
headers: {
|
||||
'Content-Length': String(reportedSize),
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
},
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/artifact',
|
||||
headers: {
|
||||
'Content-Length': String(reportedSize),
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const ctx = {
|
||||
graphqlClient: {
|
||||
mutation,
|
||||
},
|
||||
} as unknown as CustomBuildContext;
|
||||
|
||||
})
|
||||
);
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
|
||||
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
await uploadDeviceRunSessionArtifactAsync(createCtx(), {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
artifactId: 'artifact-id',
|
||||
name: 'Artifact report.json (artifact-id)',
|
||||
@@ -52,19 +62,20 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
|
||||
openStream: () => stream,
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/device-run-session-worker/artifact-upload-sessions',
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
deviceRunSessionId: 'drs-id',
|
||||
input: {
|
||||
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: TOKEN },
|
||||
json: {
|
||||
name: 'Artifact report.json (artifact-id)',
|
||||
filename: 'report.json',
|
||||
kind: 'agent-device-test-report',
|
||||
metadata: { firstFrameRecordAt: 'test-time' },
|
||||
size: reportedSize,
|
||||
},
|
||||
}),
|
||||
{ fetch: expect.any(Function) }
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
);
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
'https://uploads.expo.test/artifact',
|
||||
@@ -77,26 +88,19 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
|
||||
|
||||
it('forwards cancellation through upload allocation and skips PUT when allocation aborts', async () => {
|
||||
const controller = new AbortController();
|
||||
const mutation = jest.fn().mockReturnValue({
|
||||
toPromise: async () => {
|
||||
controller.abort();
|
||||
return {
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/artifact',
|
||||
headers: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
jest.mocked(turtleFetch).mockImplementationOnce(async () => {
|
||||
controller.abort();
|
||||
return jsonResponse({
|
||||
data: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/artifact',
|
||||
headers: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
const ctx = { graphqlClient: { mutation } } as unknown as CustomBuildContext;
|
||||
await expect(
|
||||
uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
uploadDeviceRunSessionArtifactAsync(createCtx(), {
|
||||
deviceRunSessionId: 'session',
|
||||
artifactId: 'id',
|
||||
name: 'Logs',
|
||||
@@ -108,18 +112,8 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
const allocationFetch = mutation.mock.calls[0][2].fetch;
|
||||
const mockFetch = jest.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('aborted'));
|
||||
try {
|
||||
await expect(allocationFetch('https://api.expo.test/graphql', {})).rejects.toThrow('aborted');
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/graphql',
|
||||
expect.objectContaining({
|
||||
signal: expect.objectContaining({ aborted: true }),
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
mockFetch.mockRestore();
|
||||
}
|
||||
const allocationSignal = jest.mocked(turtleFetch).mock.calls[0][2].signal;
|
||||
expect(allocationSignal).toBeInstanceOf(AbortSignal);
|
||||
expect(allocationSignal?.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
import { getEventListeners } from 'node:events';
|
||||
import { readFile, readdir, writeFile } from 'node:fs/promises';
|
||||
import { Readable } from 'node:stream';
|
||||
@@ -10,20 +10,43 @@ import fetch from 'node-fetch';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import * as processes from '../../../utils/processes';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import {
|
||||
captureDeviceRunSessionPreviewAsync,
|
||||
ensureMacosPreviewEncoderInstalledAsync,
|
||||
startDeviceRunSessionPreview,
|
||||
} from '../deviceRunSessionPreview';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
const DEVICE_RUN_SESSION_TOKEN = 'device-run-session-token-value';
|
||||
const PREVIEW_UPLOAD_SESSION_URL =
|
||||
'https://api.expo.test/v2/device-run-session-worker/preview-upload-session';
|
||||
const uploadSession = {
|
||||
url: 'https://uploads.expo.test/preview',
|
||||
headers: { 'content-type': 'image/webp' },
|
||||
};
|
||||
|
||||
function createCtx(): CustomBuildContext {
|
||||
return {
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: DEVICE_RUN_SESSION_TOKEN },
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
} as unknown as CustomBuildContext;
|
||||
}
|
||||
|
||||
function uploadSessionResponse(): InstanceType<typeof Response> {
|
||||
return new Response(JSON.stringify({ data: { uploadSession } }), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
describe(startDeviceRunSessionPreview, () => {
|
||||
const image = Buffer.from('webp-image');
|
||||
let captureAsync: jest.Mock<Promise<Buffer>, [AbortSignal]>;
|
||||
let mutation: jest.Mock;
|
||||
let logger: bunyan;
|
||||
let ctx: CustomBuildContext;
|
||||
let preview: ReturnType<typeof startDeviceRunSessionPreview> | undefined;
|
||||
@@ -31,21 +54,11 @@ describe(startDeviceRunSessionPreview, () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
captureAsync = jest.fn().mockResolvedValue(image);
|
||||
mutation = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createPreviewUploadSession: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/preview',
|
||||
headers: { 'content-type': 'image/webp' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
ctx = { graphqlClient: { mutation } } as unknown as CustomBuildContext;
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockReset()
|
||||
.mockImplementation(async () => uploadSessionResponse());
|
||||
ctx = createCtx();
|
||||
logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
jest
|
||||
.mocked(fetch)
|
||||
@@ -82,7 +95,7 @@ describe(startDeviceRunSessionPreview, () => {
|
||||
expect(captureAsync).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(captureAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
await preview!.stopAsync();
|
||||
await jest.advanceTimersByTimeAsync(120_000);
|
||||
expect(captureAsync).toHaveBeenCalledTimes(2);
|
||||
@@ -92,7 +105,7 @@ describe(startDeviceRunSessionPreview, () => {
|
||||
captureAsync.mockRejectedValueOnce(new Error('device not ready'));
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
@@ -102,7 +115,7 @@ describe(startDeviceRunSessionPreview, () => {
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 403 }));
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -126,7 +139,7 @@ describe(startDeviceRunSessionPreview, () => {
|
||||
it('reuses the upload URL for the full session', async () => {
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(119 * 60_000);
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not overlap captures and aborts pending work when stopped', async () => {
|
||||
@@ -156,29 +169,20 @@ describe(startDeviceRunSessionPreview, () => {
|
||||
const stopping = handle.stopAsync();
|
||||
finish(image);
|
||||
await stopping;
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('session preview with the real GraphQL client', () => {
|
||||
const uploadSession = {
|
||||
url: 'https://uploads.expo.test/preview',
|
||||
headers: { 'content-type': 'image/webp' },
|
||||
};
|
||||
let graphqlFetch: jest.SpyInstance;
|
||||
describe('session preview upload session from the worker API', () => {
|
||||
let preview: ReturnType<typeof startDeviceRunSessionPreview> | undefined;
|
||||
let logger: bunyan;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
graphqlFetch = jest.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new globalThis.Response(
|
||||
JSON.stringify({
|
||||
data: { deviceRunSession: { createPreviewUploadSession: { uploadSession } } },
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json' } }
|
||||
)
|
||||
);
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockReset()
|
||||
.mockImplementation(async () => uploadSessionResponse());
|
||||
logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
jest
|
||||
.mocked(fetch)
|
||||
@@ -193,14 +197,9 @@ describe('session preview with the real GraphQL client', () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const start = () => {
|
||||
const graphqlClient = new Client({
|
||||
url: 'https://api.expo.test/graphql',
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: { headers: { Authorization: 'Bearer worker-token' } },
|
||||
});
|
||||
const start = (ctx: CustomBuildContext = createCtx()) => {
|
||||
preview = startDeviceRunSessionPreview({
|
||||
ctx: { graphqlClient } as CustomBuildContext,
|
||||
ctx,
|
||||
deviceRunSessionId: 'session-id',
|
||||
captureAsync: async () => Buffer.from('webp-image'),
|
||||
logger,
|
||||
@@ -208,25 +207,49 @@ describe('session preview with the real GraphQL client', () => {
|
||||
return preview;
|
||||
};
|
||||
|
||||
it('preserves the worker authentication when creating the upload session', async () => {
|
||||
it('sends the device run session token when creating the upload session', async () => {
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
const [, options] = graphqlFetch.mock.calls[0];
|
||||
expect(new Headers(options.headers).get('authorization')).toBe('Bearer worker-token');
|
||||
expect(turtleFetch).toHaveBeenCalledWith(
|
||||
PREVIEW_UPLOAD_SESSION_URL,
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(['shutdown', 'timeout'])('aborts a stalled GraphQL request on %s', async reason => {
|
||||
it('logs a failure and uploads nothing when the job has no device run session token', async () => {
|
||||
start({
|
||||
env: {},
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
} as unknown as CustomBuildContext);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(SystemError) },
|
||||
'Could not refresh the session preview; retrying in 60 seconds.'
|
||||
);
|
||||
const [{ err }] = jest.mocked(logger.warn).mock.calls[0] as unknown as [{ err: Error }];
|
||||
expect(err.message).toMatch(
|
||||
/^This device run session job has no EAS_DEVICE_RUN_SESSION_TOKEN secret\./
|
||||
);
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['shutdown', 'timeout'])('aborts a stalled worker API request on %s', async reason => {
|
||||
const timeout = new AbortController();
|
||||
jest.spyOn(AbortSignal, 'timeout').mockReturnValue(timeout.signal);
|
||||
let requestSignal: AbortSignal | undefined;
|
||||
let rejectRequest!: (error: Error) => void;
|
||||
graphqlFetch.mockImplementation(
|
||||
(_url, options) =>
|
||||
jest.mocked(turtleFetch).mockImplementation(
|
||||
(_url, _method, options) =>
|
||||
new Promise((_, reject) => {
|
||||
rejectRequest = reject;
|
||||
requestSignal = options.signal;
|
||||
requestSignal!.addEventListener('abort', () => reject(requestSignal!.reason), {
|
||||
requestSignal = options.signal as AbortSignal;
|
||||
requestSignal.addEventListener('abort', () => reject(requestSignal!.reason), {
|
||||
once: true,
|
||||
});
|
||||
})
|
||||
@@ -255,14 +278,7 @@ describe('session preview with the real GraphQL client', () => {
|
||||
await stopping;
|
||||
} else {
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
graphqlFetch.mockResolvedValue(
|
||||
new globalThis.Response(
|
||||
JSON.stringify({
|
||||
data: { deviceRunSession: { createPreviewUploadSession: { uploadSession } } },
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json' } }
|
||||
)
|
||||
);
|
||||
jest.mocked(turtleFetch).mockImplementation(async () => uploadSessionResponse());
|
||||
jest.spyOn(AbortSignal, 'timeout').mockReturnValue(new AbortController().signal);
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
+10
-18
@@ -1,6 +1,5 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import type { BuildStepEnv } from '@expo/steps';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { copyFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
|
||||
@@ -25,28 +24,23 @@ let ctx: CustomBuildContext;
|
||||
let url: string;
|
||||
let directory: string;
|
||||
let creationBodies: string[];
|
||||
let creationTokens: (string | undefined)[];
|
||||
|
||||
beforeEach(async () => {
|
||||
creationBodies = [];
|
||||
creationTokens = [];
|
||||
directory = await mkdtemp(path.join(tmpdir(), 'recording-recovery-'));
|
||||
server = createServer((request, response) => {
|
||||
if (request.url === '/graphql') {
|
||||
if (request.url === '/v2/device-run-session-worker/artifact-upload-sessions') {
|
||||
request.setEncoding('utf8');
|
||||
let body = '';
|
||||
request.on('data', chunk => (body += chunk));
|
||||
request.on('end', () => {
|
||||
creationBodies.push(body);
|
||||
creationTokens.push(request.headers['expo-device-run-session-token'] as string | undefined);
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: { url: `${url}/artifact`, headers: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
JSON.stringify({ data: { uploadSession: { url: `${url}/artifact`, headers: {} } } })
|
||||
);
|
||||
});
|
||||
} else {
|
||||
@@ -60,12 +54,9 @@ beforeEach(async () => {
|
||||
}
|
||||
url = `http://127.0.0.1:${address.port}`;
|
||||
ctx = {
|
||||
graphqlClient: new Client({
|
||||
url: `${url}/graphql`,
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
|
||||
}),
|
||||
} as CustomBuildContext;
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
|
||||
expoApiV2BaseUrl: `${url}/v2/`,
|
||||
} as unknown as CustomBuildContext;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -181,8 +172,9 @@ it('recovers unlisted recordings from a killed host and flags the unfinished one
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
expect(putBytes).toBe(3 * (await stat(path.join(cut, 'recording.mp4.partial'))).size);
|
||||
expect(creationTokens).toEqual(['session-token', 'session-token', 'session-token']);
|
||||
const inputs = creationBodies
|
||||
.map(body => JSON.parse(body).variables.input)
|
||||
.map(body => JSON.parse(body))
|
||||
.sort((a, b) => a.filename.localeCompare(b.filename));
|
||||
expect(inputs.map(input => input.filename)).toEqual(['cut.mp4', 'done.mp4', 'failed.mp4']);
|
||||
expect(inputs.map(input => input.name.includes(', partial)'))).toEqual([true, false, true]);
|
||||
|
||||
+35
@@ -45,3 +45,38 @@ it('accepts a full SHA-256 commit hash', () => {
|
||||
expect(result.filename).toBe(`my-app-ios-bbbbbbb-${timestamp}.png`);
|
||||
expect(result.metadata.gitCommitHash).toBe(gitCommitHash);
|
||||
});
|
||||
|
||||
describe('without an app slug (device run session worker API)', () => {
|
||||
const sessionWithoutSlug = {
|
||||
platform: 'IOS' as const,
|
||||
app: null,
|
||||
build: { id: 'installed-build', gitCommitHash, app: null },
|
||||
};
|
||||
|
||||
it('starts the filename with the platform and omits appSlug from the metadata', () => {
|
||||
expect(screenshotArtifactDetails(timestamp, sessionWithoutSlug)).toEqual({
|
||||
name,
|
||||
filename: `ios-a1b2c3d-${timestamp}.png`,
|
||||
metadata: {
|
||||
__eas_type: 'screenshot',
|
||||
platform: 'ios',
|
||||
buildId: 'installed-build',
|
||||
gitCommitHash,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the commit when the session has no build', () => {
|
||||
expect(
|
||||
screenshotArtifactDetails(timestamp, {
|
||||
...sessionWithoutSlug,
|
||||
platform: 'ANDROID',
|
||||
build: null,
|
||||
})
|
||||
).toEqual({
|
||||
name,
|
||||
filename: `android-${timestamp}.png`,
|
||||
metadata: { __eas_type: 'screenshot', platform: 'android' },
|
||||
});
|
||||
});
|
||||
});
|
||||
+33
-55
@@ -1,5 +1,5 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
screenshotArtifactDetails,
|
||||
} from '../deviceRunSessionScreenshotNames';
|
||||
import { uploadDeviceRunSessionScreenshotsAsync } from '../deviceRunSessionScreenshots';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
jest.unmock('node-fetch');
|
||||
jest.mock('../../../sentry');
|
||||
function deferred<T>() {
|
||||
@@ -26,7 +27,7 @@ let ctx: CustomBuildContext;
|
||||
let url: string;
|
||||
let directory: string;
|
||||
let creates: number;
|
||||
let authorization: string | undefined;
|
||||
let deviceRunSessionToken: string | string[] | undefined;
|
||||
let stallCreation: boolean;
|
||||
let creationStarted: ReturnType<typeof deferred<IncomingMessage>>;
|
||||
let creationBodies: string[];
|
||||
@@ -40,42 +41,32 @@ beforeEach(async () => {
|
||||
sessionLookups = 0;
|
||||
sessionLookupFails = false;
|
||||
uploadQuery = '';
|
||||
authorization = undefined;
|
||||
deviceRunSessionToken = undefined;
|
||||
stallCreation = false;
|
||||
creationBodies = [];
|
||||
creationStarted = deferred();
|
||||
directory = await mkdtemp(path.join(tmpdir(), 'recording-upload-http-'));
|
||||
server = createServer((request, response) => {
|
||||
if (request.url?.startsWith('/graphql')) {
|
||||
authorization = request.headers.authorization;
|
||||
if (request.url?.startsWith('/v2/device-run-session-worker/')) {
|
||||
deviceRunSessionToken = request.headers[DEVICE_RUN_SESSION_TOKEN_HEADER];
|
||||
request.setEncoding('utf8');
|
||||
let body = '';
|
||||
if (request.method === 'GET') {
|
||||
const requestUrl = new URL(request.url, 'http://localhost');
|
||||
body = JSON.stringify({ query: requestUrl.searchParams.get('query') });
|
||||
}
|
||||
request.on('data', chunk => (body += chunk));
|
||||
request.on('end', () => {
|
||||
const operation = JSON.parse(body);
|
||||
if (operation.query.includes('query ScreenshotSession')) {
|
||||
if (request.url === '/v2/device-run-session-worker/application') {
|
||||
sessionLookups++;
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
if (sessionLookupFails) {
|
||||
response.statusCode = 503;
|
||||
response.end(JSON.stringify({ errors: [{ message: 'Lookup unavailable' }] }));
|
||||
} else {
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
deviceRunSessions: {
|
||||
byId: {
|
||||
platform: 'IOS',
|
||||
app: { slug: 'session-project' },
|
||||
build: {
|
||||
id: 'installed-build',
|
||||
gitCommitHash: installedCommit,
|
||||
app: { slug: 'my-app' },
|
||||
},
|
||||
},
|
||||
build: {
|
||||
id: 'installed-build',
|
||||
gitCommitHash: installedCommit,
|
||||
applicationArchiveUrl: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -92,13 +83,7 @@ beforeEach(async () => {
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: { url: `${url}/artifact${uploadQuery}`, headers: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
data: { uploadSession: { url: `${url}/artifact${uploadQuery}`, headers: {} } },
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -113,12 +98,10 @@ beforeEach(async () => {
|
||||
}
|
||||
url = `http://127.0.0.1:${address.port}`;
|
||||
ctx = {
|
||||
graphqlClient: new Client({
|
||||
url: `${url}/graphql`,
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
|
||||
}),
|
||||
} as CustomBuildContext;
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'test-device-run-session-token' },
|
||||
expoApiV2BaseUrl: `${url}/v2/`,
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
} as unknown as CustomBuildContext;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -127,7 +110,7 @@ afterEach(async () => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('publishes a manual preview PNG through the artifact GraphQL and HTTP upload path', async () => {
|
||||
it('publishes a manual preview PNG through the worker API and HTTP upload path', async () => {
|
||||
const payload = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aF9sAAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
@@ -157,25 +140,20 @@ it('publishes a manual preview PNG through the artifact GraphQL and HTTP upload
|
||||
expect(bodies).toEqual([payload]);
|
||||
expect(creates).toBe(1);
|
||||
expect(sessionLookups).toBe(1);
|
||||
expect(authorization).toBe('Bearer test-auth');
|
||||
expect(JSON.parse(creationBodies[0]).variables).toEqual(
|
||||
expect.objectContaining({
|
||||
deviceRunSessionId: 'drs-id',
|
||||
input: expect.objectContaining({
|
||||
name: 'Screenshot 2026-09-24 08:45:59 UTC',
|
||||
filename: 'my-app-ios-a1b2c3d-2026-09-24T08-45-59-123Z.png',
|
||||
kind: 'screenshot',
|
||||
size: payload.length,
|
||||
metadata: {
|
||||
__eas_type: 'screenshot',
|
||||
appSlug: 'my-app',
|
||||
platform: 'ios',
|
||||
buildId: 'installed-build',
|
||||
gitCommitHash: installedCommit,
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(deviceRunSessionToken).toBe('test-device-run-session-token');
|
||||
// The worker API does not return the app slug, so the filename starts with the platform.
|
||||
expect(JSON.parse(creationBodies[0])).toEqual({
|
||||
name: 'Screenshot 2026-09-24 08:45:59 UTC',
|
||||
filename: 'ios-a1b2c3d-2026-09-24T08-45-59-123Z.png',
|
||||
kind: 'screenshot',
|
||||
size: payload.length,
|
||||
metadata: {
|
||||
__eas_type: 'screenshot',
|
||||
platform: 'ios',
|
||||
buildId: 'installed-build',
|
||||
gitCommitHash: installedCommit,
|
||||
},
|
||||
});
|
||||
await expect(stat(path.join(directory, filename))).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
@@ -206,7 +184,7 @@ it('rereads the PNG when the artifact PUT is retried', async () => {
|
||||
await expect(stat(path.join(directory, filename))).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
it('aborts a stalled GraphQL upload request and keeps the PNG', async () => {
|
||||
it('aborts a stalled worker API upload session request and keeps the PNG', async () => {
|
||||
stallCreation = true;
|
||||
const filename = 'screenshot-2026-09-24T08-45-59-123Z-a1b2c3d4e5f6.png';
|
||||
await writeFile(path.join(directory, filename), 'png');
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import { EnvironmentSecretType, Generic } from '@expo/eas-build-job';
|
||||
import { BuildStepGlobalContext } from '@expo/steps';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../../context';
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import {
|
||||
DEVICE_RUN_SESSION_TOKEN_ENV,
|
||||
DEVICE_RUN_SESSION_TOKEN_HEADER,
|
||||
getDeviceRunSessionTokenFromEnvironmentSecrets,
|
||||
getDeviceRunSessionWorkerApi,
|
||||
} from '../deviceRunSessionWorkerApi';
|
||||
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
const API_V2_BASE_URL = 'https://api.expo.test/v2/';
|
||||
|
||||
function createSessionJob(
|
||||
environmentSecrets: { name: string; type: EnvironmentSecretType; value: string }[]
|
||||
): Generic.Job {
|
||||
return {
|
||||
triggeredBy: 'GIT_BASED_INTEGRATION',
|
||||
steps: [],
|
||||
secrets: { robotAccessToken: 'robot-access-token', environmentSecrets },
|
||||
builderEnvironment: { env: { DEVICE_RUN_SESSION_ID: 'drs-id' } },
|
||||
} as unknown as Generic.Job;
|
||||
}
|
||||
|
||||
function createCustomBuildContext(job: Generic.Job): CustomBuildContext<Generic.Job> {
|
||||
const buildCtx = new BuildContext(job, {
|
||||
env: { __API_SERVER_URL: 'https://api.expo.test' },
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
uploadArtifact: jest.fn(),
|
||||
workingdir: '',
|
||||
expoApiV2BaseUrl: API_V2_BASE_URL,
|
||||
});
|
||||
return new CustomBuildContext(buildCtx);
|
||||
}
|
||||
|
||||
async function getSentTokenAsync(ctx: CustomBuildContext): Promise<string | undefined> {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ data: { id: 'drs-id', status: 'in-progress' } }), {
|
||||
status: 200,
|
||||
})
|
||||
);
|
||||
await getDeviceRunSessionWorkerApi(ctx)!.getSessionAsync();
|
||||
const [url, , options] = jest.mocked(turtleFetch).mock.calls[0];
|
||||
expect(url).toBe(`${API_V2_BASE_URL}device-run-session-worker/session`);
|
||||
return (options.headers as Record<string, string>)[DEVICE_RUN_SESSION_TOKEN_HEADER];
|
||||
}
|
||||
|
||||
describe('device run session token propagation', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
it('makes the environment secret reach the worker API client and the step env', async () => {
|
||||
const job = createSessionJob([
|
||||
{ name: 'NGROK_AUTHTOKEN', type: EnvironmentSecretType.STRING, value: 'ngrok' },
|
||||
{ name: DEVICE_RUN_SESSION_TOKEN_ENV, type: EnvironmentSecretType.STRING, value: 'token-a' },
|
||||
]);
|
||||
const ctx = createCustomBuildContext(job);
|
||||
|
||||
expect(new BuildStepGlobalContext(ctx, false).env[DEVICE_RUN_SESSION_TOKEN_ENV]).toBe(
|
||||
'token-a'
|
||||
);
|
||||
await expect(getSentTokenAsync(ctx)).resolves.toBe('token-a');
|
||||
});
|
||||
|
||||
it('resolves duplicate secrets to the same token in the build env and the worker', async () => {
|
||||
const environmentSecrets = [
|
||||
{ name: DEVICE_RUN_SESSION_TOKEN_ENV, type: EnvironmentSecretType.STRING, value: 'token-a' },
|
||||
{ name: DEVICE_RUN_SESSION_TOKEN_ENV, type: EnvironmentSecretType.STRING, value: 'token-b' },
|
||||
];
|
||||
const ctx = createCustomBuildContext(createSessionJob(environmentSecrets));
|
||||
|
||||
const workerToken = getDeviceRunSessionTokenFromEnvironmentSecrets(environmentSecrets);
|
||||
expect(workerToken).toBe('token-b');
|
||||
await expect(getSentTokenAsync(ctx)).resolves.toBe(workerToken);
|
||||
});
|
||||
|
||||
it('returns no client when the job has no session token', () => {
|
||||
const ctx = createCustomBuildContext(
|
||||
createSessionJob([
|
||||
{ name: 'NGROK_AUTHTOKEN', type: EnvironmentSecretType.STRING, value: 'ngrok' },
|
||||
])
|
||||
);
|
||||
|
||||
expect(getDeviceRunSessionWorkerApi(ctx)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(getDeviceRunSessionTokenFromEnvironmentSecrets, () => {
|
||||
it('returns null without secrets or without the token', () => {
|
||||
expect(getDeviceRunSessionTokenFromEnvironmentSecrets(undefined)).toBeNull();
|
||||
expect(
|
||||
getDeviceRunSessionTokenFromEnvironmentSecrets([
|
||||
{ name: 'OTHER', type: EnvironmentSecretType.STRING, value: 'x' },
|
||||
])
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the last secret with the name is a file secret', () => {
|
||||
expect(
|
||||
getDeviceRunSessionTokenFromEnvironmentSecrets([
|
||||
{ name: DEVICE_RUN_SESSION_TOKEN_ENV, type: EnvironmentSecretType.STRING, value: 'a' },
|
||||
{ name: DEVICE_RUN_SESSION_TOKEN_ENV, type: EnvironmentSecretType.FILE, value: 'Yg==' },
|
||||
])
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import type { BuildStepEnv } from '@expo/steps';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
|
||||
@@ -13,6 +12,7 @@ import { setTimeout as delay } from 'node:timers/promises';
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import { findUnlistedDeviceScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
jest.unmock('node:fs');
|
||||
@@ -40,20 +40,20 @@ let ctx: CustomBuildContext;
|
||||
let url: string;
|
||||
let directory: string;
|
||||
let creates: number;
|
||||
let authorization: string | undefined;
|
||||
let deviceRunSessionToken: string | string[] | undefined;
|
||||
let stallCreation: boolean;
|
||||
let creationStarted: ReturnType<typeof deferred<IncomingMessage>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
creates = 0;
|
||||
authorization = undefined;
|
||||
deviceRunSessionToken = undefined;
|
||||
stallCreation = false;
|
||||
creationStarted = deferred();
|
||||
directory = await mkdtemp(path.join(tmpdir(), 'recording-upload-http-'));
|
||||
server = createServer((request, response) => {
|
||||
if (request.url === '/graphql') {
|
||||
if (request.url === '/v2/device-run-session-worker/artifact-upload-sessions') {
|
||||
creates++;
|
||||
authorization = request.headers.authorization;
|
||||
deviceRunSessionToken = request.headers[DEVICE_RUN_SESSION_TOKEN_HEADER];
|
||||
creationStarted.resolve(request);
|
||||
request.resume();
|
||||
request.on('end', () => {
|
||||
@@ -63,13 +63,7 @@ beforeEach(async () => {
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: { url: `${url}/artifact`, headers: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
data: { uploadSession: { url: `${url}/artifact`, headers: {} } },
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -84,12 +78,9 @@ beforeEach(async () => {
|
||||
}
|
||||
url = `http://127.0.0.1:${address.port}`;
|
||||
ctx = {
|
||||
graphqlClient: new Client({
|
||||
url: `${url}/graphql`,
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
|
||||
}),
|
||||
} as CustomBuildContext;
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'test-device-run-session-token' },
|
||||
expoApiV2BaseUrl: `${url}/v2/`,
|
||||
} as unknown as CustomBuildContext;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -126,7 +117,7 @@ it('replays the full file after a transient PUT failure without recreating the u
|
||||
attempts: 3,
|
||||
});
|
||||
expect(creates).toBe(1);
|
||||
expect(authorization).toBe('Bearer test-auth');
|
||||
expect(deviceRunSessionToken).toBe('test-device-run-session-token');
|
||||
expect(bodies).toEqual([payload, payload]);
|
||||
expect(streams).toHaveLength(2);
|
||||
expect(streams.every(stream => stream.destroyed)).toBe(true);
|
||||
@@ -281,7 +272,7 @@ it.each([200, 400, 503])(
|
||||
}
|
||||
);
|
||||
|
||||
it('cancels urql session creation with auth intact and never starts a PUT', async () => {
|
||||
it('cancels worker API session creation with the token intact and never starts a PUT', async () => {
|
||||
stallCreation = true;
|
||||
const puts = jest.fn();
|
||||
handle = puts;
|
||||
@@ -298,7 +289,7 @@ it('cancels urql session creation with auth intact and never starts a PUT', asyn
|
||||
controller.abort(new Error('test cancellation'));
|
||||
await rejected;
|
||||
await closed;
|
||||
expect(authorization).toBe('Bearer test-auth');
|
||||
expect(deviceRunSessionToken).toBe('test-device-run-session-token');
|
||||
expect(creates).toBe(1);
|
||||
expect(puts).not.toHaveBeenCalled();
|
||||
expect(openStream).not.toHaveBeenCalled();
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import {
|
||||
DEVICE_RUN_SESSION_TOKEN_HEADER,
|
||||
DeviceRunSessionWorkerApi,
|
||||
DeviceRunSessionWorkerApiError,
|
||||
getDeviceRunSessionWorkerApi,
|
||||
requireDeviceRunSessionWorkerApi,
|
||||
} from '../deviceRunSessionWorkerApi';
|
||||
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
type Response = InstanceType<typeof Response>;
|
||||
|
||||
const BASE_URL = 'https://api.expo.test/v2/';
|
||||
const TOKEN = 'device-run-session-token-value';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function createApi(): DeviceRunSessionWorkerApi {
|
||||
return new DeviceRunSessionWorkerApi(BASE_URL, TOKEN);
|
||||
}
|
||||
|
||||
describe(getDeviceRunSessionWorkerApi, () => {
|
||||
it('returns null when the job has no device run session token', () => {
|
||||
expect(
|
||||
getDeviceRunSessionWorkerApi({
|
||||
env: { __API_SERVER_URL: 'https://api.expo.test' },
|
||||
expoApiV2BaseUrl: BASE_URL,
|
||||
} as unknown as CustomBuildContext)
|
||||
).toBeNull();
|
||||
expect(
|
||||
getDeviceRunSessionWorkerApi({
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: '' },
|
||||
expoApiV2BaseUrl: BASE_URL,
|
||||
} as unknown as CustomBuildContext)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the API v2 base URL of the context', 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://other.expo.test' },
|
||||
expoApiV2BaseUrl: BASE_URL,
|
||||
} 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('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({
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN },
|
||||
} as unknown as CustomBuildContext)
|
||||
).toThrow(SystemError);
|
||||
});
|
||||
});
|
||||
|
||||
describe(requireDeviceRunSessionWorkerApi, () => {
|
||||
it('returns the client when the job has a device run session token', () => {
|
||||
expect(
|
||||
requireDeviceRunSessionWorkerApi({
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN },
|
||||
expoApiV2BaseUrl: BASE_URL,
|
||||
} as unknown as CustomBuildContext)
|
||||
).toBeInstanceOf(DeviceRunSessionWorkerApi);
|
||||
});
|
||||
|
||||
it('throws a system error when the job has no device run session token', () => {
|
||||
const error = (() => {
|
||||
try {
|
||||
requireDeviceRunSessionWorkerApi({
|
||||
env: { __API_SERVER_URL: 'https://api.expo.test' },
|
||||
expoApiV2BaseUrl: BASE_URL,
|
||||
} as unknown as CustomBuildContext);
|
||||
} catch (err) {
|
||||
return err;
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
expect(error).toBeInstanceOf(SystemError);
|
||||
expect((error as Error).message).toMatch(
|
||||
/^This device run session job has no EAS_DEVICE_RUN_SESSION_TOKEN secret\./
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(DeviceRunSessionWorkerApi, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'getSessionAsync',
|
||||
call: (api: DeviceRunSessionWorkerApi) => api.getSessionAsync(),
|
||||
method: 'GET',
|
||||
route: 'session',
|
||||
json: undefined,
|
||||
data: { id: 'drs-id', status: 'in-progress' },
|
||||
expected: { id: 'drs-id', status: 'in-progress' },
|
||||
},
|
||||
{
|
||||
name: 'startAsync',
|
||||
call: (api: DeviceRunSessionWorkerApi) =>
|
||||
api.startAsync({ remoteConfig: { type: 'agent-device', url: 'https://x.test' } }),
|
||||
method: 'POST',
|
||||
route: 'start',
|
||||
json: { remoteConfig: { type: 'agent-device', url: 'https://x.test' } },
|
||||
data: { id: 'drs-id', status: 'in-progress' },
|
||||
expected: { id: 'drs-id', status: 'in-progress' },
|
||||
},
|
||||
{
|
||||
name: 'stopAsync',
|
||||
call: (api: DeviceRunSessionWorkerApi) => api.stopAsync(),
|
||||
method: 'POST',
|
||||
route: 'stop',
|
||||
json: undefined,
|
||||
data: { id: 'drs-id', status: 'stopped' },
|
||||
expected: { id: 'drs-id', status: 'stopped' },
|
||||
},
|
||||
{
|
||||
name: 'createArtifactUploadSessionAsync',
|
||||
call: (api: DeviceRunSessionWorkerApi) =>
|
||||
api.createArtifactUploadSessionAsync({
|
||||
name: 'Screenshot',
|
||||
filename: 'screenshot.png',
|
||||
kind: 'screenshot',
|
||||
size: 12,
|
||||
metadata: { __eas_type: 'screenshot' },
|
||||
}),
|
||||
method: 'POST',
|
||||
route: 'artifact-upload-sessions',
|
||||
json: {
|
||||
name: 'Screenshot',
|
||||
filename: 'screenshot.png',
|
||||
kind: 'screenshot',
|
||||
size: 12,
|
||||
metadata: { __eas_type: 'screenshot' },
|
||||
},
|
||||
data: { uploadSession: { url: 'https://uploads.test/a', headers: { 'X-A': '1' } } },
|
||||
expected: { url: 'https://uploads.test/a', headers: { 'X-A': '1' } },
|
||||
},
|
||||
{
|
||||
name: 'createEventLogUploadSessionAsync',
|
||||
call: (api: DeviceRunSessionWorkerApi) => api.createEventLogUploadSessionAsync(),
|
||||
method: 'POST',
|
||||
route: 'event-log-upload-session',
|
||||
json: undefined,
|
||||
data: { uploadSession: { url: 'https://uploads.test/events', headers: {} } },
|
||||
expected: { url: 'https://uploads.test/events', headers: {} },
|
||||
},
|
||||
{
|
||||
name: 'createPreviewUploadSessionAsync',
|
||||
call: (api: DeviceRunSessionWorkerApi) => api.createPreviewUploadSessionAsync(),
|
||||
method: 'POST',
|
||||
route: 'preview-upload-session',
|
||||
json: undefined,
|
||||
data: { uploadSession: { url: 'https://uploads.test/preview', headers: {} } },
|
||||
expected: { url: 'https://uploads.test/preview', headers: {} },
|
||||
},
|
||||
{
|
||||
name: 'fetchTurnIceServersAsync',
|
||||
call: (api: DeviceRunSessionWorkerApi) => api.fetchTurnIceServersAsync(),
|
||||
method: 'POST',
|
||||
route: 'turn-ice-servers',
|
||||
json: undefined,
|
||||
data: {
|
||||
iceServers: [
|
||||
{ urls: ['stun:stun.test:3478'] },
|
||||
{ urls: ['turn:turn.test:3478'], username: 'user', credential: 'secret' },
|
||||
],
|
||||
},
|
||||
expected: [
|
||||
{ urls: ['stun:stun.test:3478'] },
|
||||
{ urls: ['turn:turn.test:3478'], username: 'user', credential: 'secret' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'getApplicationAsync',
|
||||
call: (api: DeviceRunSessionWorkerApi) => api.getApplicationAsync(),
|
||||
method: 'GET',
|
||||
route: 'application',
|
||||
json: undefined,
|
||||
data: {
|
||||
build: {
|
||||
id: 'build-id',
|
||||
gitCommitHash: null,
|
||||
applicationArchiveUrl: 'https://expo.test/artifacts/eas/a.tar.gz',
|
||||
},
|
||||
},
|
||||
expected: {
|
||||
build: {
|
||||
id: 'build-id',
|
||||
gitCommitHash: null,
|
||||
applicationArchiveUrl: 'https://expo.test/artifacts/eas/a.tar.gz',
|
||||
},
|
||||
},
|
||||
},
|
||||
])(
|
||||
'$name calls $method /v2/device-run-session-worker/$route with the session token header',
|
||||
async ({ call, method, route, json, data, expected }) => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data }));
|
||||
|
||||
await expect(call(createApi())).resolves.toEqual(expected);
|
||||
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledTimes(1);
|
||||
const [url, calledMethod, options] = jest.mocked(turtleFetch).mock.calls[0];
|
||||
expect(url).toBe(`https://api.expo.test/v2/device-run-session-worker/${route}`);
|
||||
expect(calledMethod).toBe(method);
|
||||
expect(options.headers).toEqual({ [DEVICE_RUN_SESSION_TOKEN_HEADER]: TOKEN });
|
||||
expect(options.json).toEqual(json);
|
||||
expect(options).toEqual(expect.objectContaining({ retries: 0, shouldThrowOnNotOk: false }));
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['new', 'queued', 'starting', 'in-progress', 'stopped', 'errored'])(
|
||||
'returns the www status %s unchanged',
|
||||
async status => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status } }));
|
||||
|
||||
await expect(createApi().getSessionAsync()).resolves.toEqual({ id: 'drs-id', status });
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects an application response without the build field', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { platform: 'ios' } }));
|
||||
|
||||
await expect(createApi().getApplicationAsync()).rejects.toThrow(
|
||||
'GET /v2/device-run-session-worker/application returned an unexpected response'
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the signal, timeout, and retries to the request', 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: 1 })
|
||||
);
|
||||
});
|
||||
|
||||
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()
|
||||
.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 () => {
|
||||
jest.mocked(turtleFetch).mockRejectedValueOnce(new Error('socket hang up'));
|
||||
|
||||
const error = await createApi()
|
||||
.getSessionAsync()
|
||||
.catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
|
||||
expect(error.status).toBeUndefined();
|
||||
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()).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()).rejects.toThrow(
|
||||
'GET /v2/device-run-session-worker/session returned a response that is not JSON.'
|
||||
);
|
||||
});
|
||||
});
|
||||
+599
@@ -0,0 +1,599 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import fetch from 'node-fetch';
|
||||
import { Readable } from 'node:stream';
|
||||
import { setImmediate } from 'node:timers/promises';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import RemoteLoggerStream from '../../../logging/RemoteLoggerStream';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { downloadBuildAsync } from '../../functions/downloadBuild';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import { startDeviceRunSessionEventCollectionAsync } from '../deviceRunSessionEvents';
|
||||
import { startDeviceRunSessionPreview } from '../deviceRunSessionPreview';
|
||||
import { loadScreenshotSessionAsync } from '../deviceRunSessionScreenshotNames';
|
||||
import {
|
||||
DEVICE_RUN_SESSION_TOKEN_HEADER,
|
||||
DeviceRunSessionWorkerApi,
|
||||
} from '../deviceRunSessionWorkerApi';
|
||||
import {
|
||||
fetchWebPreviewTurnArgsAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../remoteDeviceRunSession';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
jest.mock('@ngrok/ngrok');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../logging/HttpLogStream', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({ write: jest.fn(), cleanUp: jest.fn(async () => undefined) })),
|
||||
}));
|
||||
jest.mock('../../../logging/RemoteLoggerStream', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({
|
||||
init: jest.fn(async () => undefined),
|
||||
write: jest.fn(),
|
||||
cleanUp: jest.fn(async () => undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
const TOKEN = 'device-run-session-token-value';
|
||||
const ROBOT_ACCESS_TOKEN = 'robot-access-token-value';
|
||||
const API_V2_BASE_URL = 'https://api.expo.test/v2/';
|
||||
const BUILD_ID = '0a8b5f3c-7c38-4a2f-9c0c-2a1f0e7d8b11';
|
||||
const APPLICATION_ARCHIVE_URL = 'https://expo.test/artifacts/eas/app.tar.gz';
|
||||
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
child: jest.fn(),
|
||||
} as unknown as bunyan;
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): InstanceType<typeof Response> {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function createCtx({ withToken }: { withToken: boolean }): CustomBuildContext {
|
||||
return {
|
||||
env: {
|
||||
__API_SERVER_URL: 'https://api.expo.test',
|
||||
EAS_BUILD_ID: 'job-run-id',
|
||||
...(withToken ? { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN } : {}),
|
||||
},
|
||||
expoApiV2BaseUrl: API_V2_BASE_URL,
|
||||
job: { secrets: { robotAccessToken: ROBOT_ACCESS_TOKEN } },
|
||||
graphqlClient: {
|
||||
query: jest.fn(),
|
||||
mutation: jest.fn(),
|
||||
},
|
||||
} as unknown as CustomBuildContext;
|
||||
}
|
||||
|
||||
function graphqlResult(data: unknown): { toPromise: () => Promise<{ data: unknown }> } {
|
||||
return { toPromise: async () => ({ data }) };
|
||||
}
|
||||
|
||||
function expectWorkerApiCall(
|
||||
route: string,
|
||||
method: 'GET' | 'POST',
|
||||
options: Record<string, unknown> = {}
|
||||
): void {
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
|
||||
`${API_V2_BASE_URL}device-run-session-worker/${route}`,
|
||||
method,
|
||||
expect.objectContaining({
|
||||
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: TOKEN },
|
||||
...options,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForCallAsync(mock: jest.Mock): Promise<void> {
|
||||
for (let i = 0; i < 100 && mock.mock.calls.length === 0; i++) {
|
||||
await setImmediate();
|
||||
}
|
||||
}
|
||||
|
||||
const eventSource = {
|
||||
producer: 'test-producer',
|
||||
findEventFilesAsync: async () => [],
|
||||
sourceKeyForFile: () => 'source',
|
||||
parseLine: () => ({}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
jest.mocked(fetch).mockReset();
|
||||
});
|
||||
|
||||
describe('with a device run session token', () => {
|
||||
it('polls the session status through the worker API', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'stopped' } }));
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({ ctx, deviceRunSessionId: 'drs-id', logger });
|
||||
|
||||
expectWorkerApiCall('session', 'GET', {
|
||||
retries: 0,
|
||||
timeout: 30_000,
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops an idle session through the worker API', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'stopped' } }));
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger,
|
||||
idleTimeout: { maxIdleTimeMinutes: 0, getLastEventObservedAt: () => undefined },
|
||||
});
|
||||
|
||||
expectWorkerApiCall('stop', 'POST', { timeout: 30_000 });
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts the session through the worker API', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
remoteConfig: { type: 'agent-device' },
|
||||
logger,
|
||||
});
|
||||
|
||||
expectWorkerApiCall('start', 'POST', { json: { remoteConfig: { type: 'agent-device' } } });
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails the start with a system error that does not contain the token', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'The session finished.' }] }, 403));
|
||||
|
||||
const error = await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
remoteConfig: {},
|
||||
logger,
|
||||
}).catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(SystemError);
|
||||
expect(error.message).toBe(
|
||||
'Failed to start device run session drs-id: POST /v2/device-run-session-worker/start failed with status 403: The session finished.'
|
||||
);
|
||||
expect(error.message).not.toContain(TOKEN);
|
||||
});
|
||||
|
||||
it('fetches TURN ICE servers through the worker API', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
data: {
|
||||
iceServers: [
|
||||
{ urls: ['stun:stun.test:3478'] },
|
||||
{ urls: ['turn:turn.test:3478'], username: 'user', credential: 'credential' },
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const args = await fetchWebPreviewTurnArgsAsync(ctx, { logger });
|
||||
|
||||
expect(args).toEqual([
|
||||
'--stun-url',
|
||||
'stun:stun.test:3478',
|
||||
'--turn-url',
|
||||
'turn:turn.test:3478',
|
||||
'--turn-username',
|
||||
'user',
|
||||
'--turn-credential',
|
||||
'credential',
|
||||
]);
|
||||
expectWorkerApiCall('turn-ice-servers', 'POST', { timeout: 5000, retries: 1 });
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('creates artifact upload sessions through the worker API', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
data: { uploadSession: { url: 'https://uploads.expo.test/artifact', headers: {} } },
|
||||
})
|
||||
);
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
|
||||
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
artifactId: 'artifact-id',
|
||||
name: 'Report',
|
||||
filename: 'report.json',
|
||||
kind: 'test-report',
|
||||
metadata: { a: 'b' },
|
||||
size: 4,
|
||||
openStream: () => Readable.from(Buffer.from('data')),
|
||||
});
|
||||
|
||||
expectWorkerApiCall('artifact-upload-sessions', 'POST', {
|
||||
json: {
|
||||
name: 'Report',
|
||||
filename: 'report.json',
|
||||
kind: 'test-report',
|
||||
metadata: { a: 'b' },
|
||||
size: 4,
|
||||
},
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
'https://uploads.expo.test/artifact',
|
||||
expect.objectContaining({ method: 'PUT' })
|
||||
);
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates the event log upload session through the worker API', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
data: {
|
||||
uploadSession: { url: 'https://uploads.expo.test/events', headers: { 'X-A': '1' } },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const collection = await startDeviceRunSessionEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
source: eventSource,
|
||||
logger,
|
||||
});
|
||||
await collection.stopAsync();
|
||||
|
||||
expectWorkerApiCall('event-log-upload-session', 'POST');
|
||||
expect(jest.mocked(RemoteLoggerStream)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
uploadMethod: {
|
||||
signedUrl: { url: 'https://uploads.expo.test/events', headers: { 'X-A': '1' } },
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates the preview upload session through the worker API', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
data: { uploadSession: { url: 'https://uploads.expo.test/preview', headers: {} } },
|
||||
})
|
||||
);
|
||||
jest.mocked(fetch).mockResolvedValue(new Response('', { status: 200 }));
|
||||
|
||||
const preview = startDeviceRunSessionPreview({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
captureAsync: async () => Buffer.from('webp'),
|
||||
logger,
|
||||
});
|
||||
await waitForCallAsync(jest.mocked(fetch));
|
||||
await preview.stopAsync();
|
||||
|
||||
expectWorkerApiCall('preview-upload-session', 'POST', { signal: expect.any(AbortSignal) });
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
'https://uploads.expo.test/preview',
|
||||
expect.objectContaining({ method: 'PUT' })
|
||||
);
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[BuildRuntimePlatform.DARWIN, 'IOS'],
|
||||
[BuildRuntimePlatform.LINUX, 'ANDROID'],
|
||||
] as const)(
|
||||
'loads the screenshot naming details through the worker API on a %s worker',
|
||||
async (runtimePlatform, platform) => {
|
||||
const ctx = { ...createCtx({ withToken: true }), runtimePlatform } as CustomBuildContext;
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
data: {
|
||||
build: {
|
||||
id: BUILD_ID,
|
||||
gitCommitHash: 'abc',
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await expect(loadScreenshotSessionAsync(ctx, 'drs-id', logger)).resolves.toEqual({
|
||||
platform,
|
||||
app: null,
|
||||
build: { id: BUILD_ID, gitCommitHash: 'abc', app: null },
|
||||
});
|
||||
expectWorkerApiCall('application', 'GET', { signal: expect.any(AbortSignal), retries: 0 });
|
||||
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('downloads the session build from the worker API archive URL without an Authorization header', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
data: {
|
||||
build: {
|
||||
id: BUILD_ID,
|
||||
gitCommitHash: null,
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('stop here', { status: 500 }));
|
||||
|
||||
await expect(
|
||||
downloadBuildAsync({
|
||||
logger,
|
||||
buildId: BUILD_ID,
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
deviceRunSessionWorkerApi: new DeviceRunSessionWorkerApi(API_V2_BASE_URL, TOKEN),
|
||||
robotAccessToken: ROBOT_ACCESS_TOKEN,
|
||||
extensions: ['app'],
|
||||
})
|
||||
).rejects.toThrow('Unexpected response from server (500): stop here');
|
||||
|
||||
expectWorkerApiCall('application', 'GET');
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
APPLICATION_ARCHIVE_URL,
|
||||
expect.objectContaining({ headers: undefined })
|
||||
);
|
||||
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses to download a build that is not the session build', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { build: null } }));
|
||||
|
||||
const error = await downloadBuildAsync({
|
||||
logger,
|
||||
buildId: BUILD_ID,
|
||||
graphqlClient: createCtx({ withToken: true }).graphqlClient,
|
||||
deviceRunSessionWorkerApi: new DeviceRunSessionWorkerApi(API_V2_BASE_URL, TOKEN),
|
||||
robotAccessToken: ROBOT_ACCESS_TOKEN,
|
||||
extensions: ['app'],
|
||||
}).catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(SystemError);
|
||||
expect(error.message).toContain(
|
||||
`Could not fetch build ${BUILD_ID}: this device run session runs no build`
|
||||
);
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a rejected build lookup as a system error, because the job sends no user input', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
code: 'UNAUTHORIZED_ERROR',
|
||||
message: 'This device run session no longer accepts worker requests.',
|
||||
},
|
||||
],
|
||||
},
|
||||
403
|
||||
)
|
||||
);
|
||||
|
||||
const error = await downloadBuildAsync({
|
||||
logger,
|
||||
buildId: BUILD_ID,
|
||||
graphqlClient: createCtx({ withToken: true }).graphqlClient,
|
||||
deviceRunSessionWorkerApi: new DeviceRunSessionWorkerApi(API_V2_BASE_URL, TOKEN),
|
||||
robotAccessToken: ROBOT_ACCESS_TOKEN,
|
||||
extensions: ['app'],
|
||||
}).catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(SystemError);
|
||||
expect(error.message).toContain(
|
||||
`Could not fetch build ${BUILD_ID} for this device run session: GET /v2/device-run-session-worker/application failed with status 403: This device run session no longer accepts worker requests.`
|
||||
);
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('without a device run session token', () => {
|
||||
const MISSING_TOKEN_MESSAGE =
|
||||
/^This device run session job has no EAS_DEVICE_RUN_SESSION_TOKEN secret\./;
|
||||
|
||||
function expectMissingTokenError(error: unknown): void {
|
||||
expect(error).toBeInstanceOf(SystemError);
|
||||
expect((error as Error).message).toMatch(MISSING_TOKEN_MESSAGE);
|
||||
}
|
||||
|
||||
function expectMissingTokenWarning(message: string): void {
|
||||
const warning = (jest.mocked(logger.warn).mock.calls as unknown[][]).find(
|
||||
([, warningMessage]) => warningMessage === message
|
||||
);
|
||||
expect(warning).toBeDefined();
|
||||
expectMissingTokenError((warning![0] as { err: unknown }).err);
|
||||
}
|
||||
|
||||
it('fails the status poll with a system error', async () => {
|
||||
const ctx = createCtx({ withToken: false });
|
||||
|
||||
const error = await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger,
|
||||
}).catch(err => err);
|
||||
|
||||
expectMissingTokenError(error);
|
||||
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
|
||||
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs the failed idle stop and ends the wait', async () => {
|
||||
const ctx = createCtx({ withToken: false });
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger,
|
||||
idleTimeout: { maxIdleTimeMinutes: 0, getLastEventObservedAt: () => undefined },
|
||||
});
|
||||
|
||||
expectMissingTokenWarning(
|
||||
'Could not mark device run session drs-id as stopped. The session job ends anyway.'
|
||||
);
|
||||
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails the start with a system error', async () => {
|
||||
const ctx = createCtx({ withToken: false });
|
||||
|
||||
const error = await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
remoteConfig: { type: 'agent-device' },
|
||||
logger,
|
||||
}).catch(err => err);
|
||||
|
||||
expectMissingTokenError(error);
|
||||
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to no TURN ICE servers', async () => {
|
||||
const ctx = createCtx({ withToken: false });
|
||||
|
||||
await expect(fetchWebPreviewTurnArgsAsync(ctx, { logger })).resolves.toEqual([]);
|
||||
|
||||
expectMissingTokenWarning(
|
||||
'Could not fetch Cloudflare TURN ICE servers; the web preview will fall back to P2P/STUN.'
|
||||
);
|
||||
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails the artifact upload with a system error before any PUT', async () => {
|
||||
const ctx = createCtx({ withToken: false });
|
||||
|
||||
const error = await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
artifactId: 'artifact-id',
|
||||
name: 'Report',
|
||||
filename: 'report.json',
|
||||
kind: undefined,
|
||||
size: 4,
|
||||
openStream: () => Readable.from(Buffer.from('data')),
|
||||
}).catch(err => err);
|
||||
|
||||
expectMissingTokenError(error);
|
||||
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs the session without persisting events', async () => {
|
||||
const ctx = createCtx({ withToken: false });
|
||||
|
||||
const collection = await startDeviceRunSessionEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
source: eventSource,
|
||||
logger,
|
||||
});
|
||||
await collection.stopAsync();
|
||||
|
||||
expectMissingTokenWarning('Could not persist device run session events to the artifact.');
|
||||
expect(jest.mocked(RemoteLoggerStream)).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs the failed preview upload session and uploads no preview', async () => {
|
||||
const ctx = createCtx({ withToken: false });
|
||||
|
||||
const preview = startDeviceRunSessionPreview({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
captureAsync: async () => Buffer.from('webp'),
|
||||
logger,
|
||||
});
|
||||
await waitForCallAsync(jest.mocked(logger.warn));
|
||||
await preview.stopAsync();
|
||||
|
||||
expectMissingTokenWarning('Could not refresh the session preview; retrying in 60 seconds.');
|
||||
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('names screenshots with capture timestamps', async () => {
|
||||
const ctx = createCtx({ withToken: false });
|
||||
|
||||
await expect(loadScreenshotSessionAsync(ctx, 'drs-id', logger)).resolves.toBeNull();
|
||||
|
||||
expectMissingTokenWarning(
|
||||
'Could not load screenshot session details; using capture timestamps.'
|
||||
);
|
||||
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
|
||||
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('looks up the build archive URL with GraphQL', async () => {
|
||||
const ctx = createCtx({ withToken: false });
|
||||
jest.mocked(ctx.graphqlClient.query).mockReturnValueOnce(
|
||||
graphqlResult({
|
||||
builds: {
|
||||
byId: {
|
||||
id: BUILD_ID,
|
||||
platform: 'IOS',
|
||||
artifacts: { applicationArchiveUrl: APPLICATION_ARCHIVE_URL },
|
||||
},
|
||||
},
|
||||
}) as never
|
||||
);
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('stop here', { status: 500 }));
|
||||
|
||||
await expect(
|
||||
downloadBuildAsync({
|
||||
logger,
|
||||
buildId: BUILD_ID,
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
deviceRunSessionWorkerApi: null,
|
||||
robotAccessToken: ROBOT_ACCESS_TOKEN,
|
||||
extensions: ['app'],
|
||||
})
|
||||
).rejects.toThrow('Unexpected response from server (500)');
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalled();
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
APPLICATION_ARCHIVE_URL,
|
||||
expect.objectContaining({ headers: { Authorization: `Bearer ${ROBOT_ACCESS_TOKEN}` } })
|
||||
);
|
||||
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,63 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readIosApplicationIdentifierAsync } from '../iosAppArtifact';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
describe(readIosApplicationIdentifierAsync, () => {
|
||||
let directory: string;
|
||||
let artifactPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.mocked(spawn).mockReset();
|
||||
directory = await mkdtemp(path.join(os.tmpdir(), 'ios-app-artifact-'));
|
||||
artifactPath = path.join(directory, 'Example.app');
|
||||
await mkdir(artifactPath);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reads app metadata without installing or launching', async () => {
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: 'com.example.app\n', stderr: '' } as never);
|
||||
await expect(readIosApplicationIdentifierAsync({ artifactPath, env: {} })).resolves.toBe(
|
||||
'com.example.app'
|
||||
);
|
||||
expect(jest.mocked(spawn).mock.calls).toEqual([
|
||||
[
|
||||
'plutil',
|
||||
['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', path.join(artifactPath, 'Info.plist')],
|
||||
{ stdio: 'pipe', env: {} },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['missing', 'file', 'wrong extension'])('rejects an invalid artifact: %s', async kind => {
|
||||
await rm(artifactPath, { recursive: true });
|
||||
if (kind === 'file') {
|
||||
await writeFile(artifactPath, 'not an app directory');
|
||||
} else if (kind === 'wrong extension') {
|
||||
artifactPath = path.join(directory, 'Example.ipa');
|
||||
await mkdir(artifactPath);
|
||||
}
|
||||
await expect(
|
||||
readIosApplicationIdentifierAsync({ artifactPath, env: {} })
|
||||
).rejects.toMatchObject({ errorCode: 'EAS_INSTALL_BUILD_INVALID_ARTIFACT' });
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a missing bundle identifier', async () => {
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: ' ', stderr: '' } as never);
|
||||
await expect(
|
||||
readIosApplicationIdentifierAsync({ artifactPath, env: {} })
|
||||
).rejects.toMatchObject({ errorCode: 'EAS_INSTALL_BUILD_MISSING_IDENTIFIER' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../../utils/IosSimulatorUtils';
|
||||
import { resolveIosSessionStartupAsync } from '../iosSimulatorSession';
|
||||
import { readIosApplicationIdentifierAsync } from '../iosAppArtifact';
|
||||
import {
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync,
|
||||
startLocalEgressGuardRelayAsync,
|
||||
} from '../localEgressGuard';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('../../../utils/IosSimulatorUtils');
|
||||
jest.mock('../iosAppArtifact', () => ({ readIosApplicationIdentifierAsync: jest.fn() }));
|
||||
jest.mock('../localEgressGuard', () => ({
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn(),
|
||||
startLocalEgressGuardRelayAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedUtils = jest.mocked(IosSimulatorUtils);
|
||||
|
||||
describe('serve-sim Simulator preparation', () => {
|
||||
const hostOptions = {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
};
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedUtils.getAvailableDevicesAsync.mockResolvedValue([
|
||||
{
|
||||
udid: 'selected-udid',
|
||||
name: 'iPhone 17',
|
||||
isAvailable: true,
|
||||
deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-17',
|
||||
},
|
||||
] as never);
|
||||
mockedUtils.resolveUdidAsync.mockResolvedValue('selected-udid' as IosSimulatorUuid);
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(null);
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as never);
|
||||
jest.mocked(readIosApplicationIdentifierAsync).mockResolvedValue('dev.example.app');
|
||||
});
|
||||
it.each([
|
||||
['argument count', Array(257).fill('a')],
|
||||
['argument length', ['a'.repeat(9000)]],
|
||||
['NUL argument', ['a\u0000b']],
|
||||
['total UTF-8 bytes', Array(8).fill('😃'.repeat(4096))],
|
||||
])('rejects invalid launch %s before Simulator preparation', async (_, launchArgs) => {
|
||||
await expect(
|
||||
resolveIosSessionStartupAsync({
|
||||
...hostOptions,
|
||||
bootSimulator: true,
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
launchArgs,
|
||||
})
|
||||
).rejects.toThrow('iOS launch_args');
|
||||
expect(mockedUtils.getAvailableDevicesAsync).not.toHaveBeenCalled();
|
||||
expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled();
|
||||
expect(resolveLocalEgressServeSimBootEnvironmentAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an oversized URL', `https://example.test/${'a'.repeat(8192)}`],
|
||||
['NUL in a URL', 'exp://example.test/a\0b'],
|
||||
])('rejects %s before Simulator preparation', async (_, openUrl) => {
|
||||
await expect(
|
||||
resolveIosSessionStartupAsync({
|
||||
...hostOptions,
|
||||
bootSimulator: true,
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
openUrl,
|
||||
})
|
||||
).rejects.toThrow('iOS open_url');
|
||||
expect(mockedUtils.getAvailableDevicesAsync).not.toHaveBeenCalled();
|
||||
expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled();
|
||||
expect(resolveLocalEgressServeSimBootEnvironmentAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps an explicit launch identifier independent of the installed app', async () => {
|
||||
const startup = await resolveIosSessionStartupAsync({
|
||||
...hostOptions,
|
||||
installAppPath: '/tmp/App.app',
|
||||
launchAppIdentifier: 'another.installed.app',
|
||||
});
|
||||
expect(startup.launch.launchAppIdentifier).toBe('another.installed.app');
|
||||
expect(readIosApplicationIdentifierAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('infers the launch identifier from the downloaded app', async () => {
|
||||
const startup = await resolveIosSessionStartupAsync({
|
||||
...hostOptions,
|
||||
installAppPath: '/tmp/App.app',
|
||||
});
|
||||
expect(startup.launch.launchAppIdentifier).toBe('dev.example.app');
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
|
||||
artifactPath: '/tmp/App.app',
|
||||
env: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('does no Simulator work on Android and rejects iOS installation inputs', async () => {
|
||||
const options = { ...hostOptions, runtimePlatform: BuildRuntimePlatform.LINUX };
|
||||
await expect(resolveIosSessionStartupAsync(options)).resolves.toEqual({
|
||||
launch: { launchArgs: [], launchAppIdentifier: undefined, openUrl: undefined },
|
||||
});
|
||||
await expect(
|
||||
resolveIosSessionStartupAsync({ ...options, installAppPath: '/tmp/App.app' })
|
||||
).rejects.toThrow('only supported for iOS');
|
||||
expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves older workflows that already prepared the Simulator and app', async () => {
|
||||
const startup = await resolveIosSessionStartupAsync({
|
||||
...hostOptions,
|
||||
launchAppIdentifier: 'previously.installed.app',
|
||||
});
|
||||
expect(startup.iosStartup).toBeUndefined();
|
||||
expect(startup.launch.launchAppIdentifier).toBe('previously.installed.app');
|
||||
expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled();
|
||||
expect(resolveLocalEgressServeSimBootEnvironmentAsync).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prepares a session without an app when the workflow requests owned startup', async () => {
|
||||
const startup = await resolveIosSessionStartupAsync({ ...hostOptions, bootSimulator: true });
|
||||
expect(startup.iosStartup?.iosSimulatorUdid).toBe('SELECTED-UDID');
|
||||
expect(startup.launch.launchAppIdentifier).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(['Booted', 'Booting', 'Shutdown'])(
|
||||
'prepares guarded startup on a %s device',
|
||||
async state => {
|
||||
const bootEnv = { SERVE_SIM_ADDITIONAL_DYLIBS: '/guard.dylib' };
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(bootEnv);
|
||||
mockedUtils.getDeviceAsync.mockResolvedValue({ state } as never);
|
||||
const startup = await resolveIosSessionStartupAsync({ ...hostOptions, bootSimulator: true });
|
||||
expect(startup.iosStartup).toEqual({
|
||||
iosSimulatorUdid: 'SELECTED-UDID',
|
||||
bootEnv,
|
||||
installAppPath: undefined,
|
||||
});
|
||||
if (state === 'Shutdown') {
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'shutdown', 'SELECTED-UDID'],
|
||||
expect.anything()
|
||||
);
|
||||
}
|
||||
expect(startLocalEgressGuardRelayAsync).toHaveBeenCalled();
|
||||
expect(mockedUtils.bootAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('resolves an explicit Simulator without booting or configuring it', async () => {
|
||||
const startup = await resolveIosSessionStartupAsync({
|
||||
...hostOptions,
|
||||
deviceIdentifier: 'iPhone 17',
|
||||
});
|
||||
expect(startup.iosStartup?.iosSimulatorUdid).toBe('SELECTED-UDID');
|
||||
expect(mockedUtils.resolveUdidAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'iPhone 17',
|
||||
env: {},
|
||||
});
|
||||
expect(mockedUtils.getAvailableDevicesAsync).not.toHaveBeenCalled();
|
||||
expect(mockedUtils.bootAsync).not.toHaveBeenCalled();
|
||||
expect(mockedUtils.startAsync).not.toHaveBeenCalled();
|
||||
expect(mockedUtils.enableAccessibilitySettingsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('selects the default iPhone without booting', async () => {
|
||||
mockedUtils.getAvailableDevicesAsync.mockResolvedValue([
|
||||
{ name: 'iPad', udid: 'ipad' },
|
||||
{ name: 'iPhone 17 Pro', udid: 'pro' },
|
||||
{ name: 'iPhone 17', udid: 'base' },
|
||||
] as never);
|
||||
await resolveIosSessionStartupAsync({ ...hostOptions, bootSimulator: true });
|
||||
expect(mockedUtils.resolveUdidAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'base',
|
||||
env: {},
|
||||
});
|
||||
expect(mockedUtils.bootAsync).not.toHaveBeenCalled();
|
||||
mockedUtils.getAvailableDevicesAsync.mockResolvedValue([]);
|
||||
await expect(
|
||||
resolveIosSessionStartupAsync({ ...hostOptions, bootSimulator: true })
|
||||
).rejects.toThrow('Could not find an iPhone');
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
rebindLocalEgressGuardRelays,
|
||||
resolveEgressGuardLibraryAsync,
|
||||
resolveLocalEgressBootEnvironmentAsync,
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync,
|
||||
startLocalEgressGuardRelayAsync,
|
||||
stopLocalEgressGuardRelaysAsync,
|
||||
verifyLocalEgressGuardAsync,
|
||||
} from '../localEgressGuard';
|
||||
@@ -401,6 +403,38 @@ describe(installLocalEgressGuardAsync, () => {
|
||||
expect(sessionLogger.lines.at(-1)?.msg).toContain('refused 1 connection attempt(s)');
|
||||
});
|
||||
|
||||
it('still installs the guard when its event log cannot be created', async () => {
|
||||
const handoffPath = path.join(dir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
const libraryPath = path.join(dir, 'egress-guard.dylib');
|
||||
const blockedDirectory = path.join(dir, 'not-a-directory');
|
||||
await fs.promises.writeFile(blockedDirectory, 'file');
|
||||
await expect(
|
||||
installLocalEgressGuardAsync({
|
||||
udid: 'u' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
libraryPath,
|
||||
logPath: path.join(blockedDirectory, 'guard.log'),
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
expect(mockedSetEnv).toHaveBeenCalledWith({
|
||||
udid: 'u',
|
||||
env: process.env,
|
||||
variables: buildGuardLaunchdEnvironment({
|
||||
libraryPath,
|
||||
logPath: path.join(blockedDirectory, 'guard.log'),
|
||||
mode: 'block',
|
||||
}),
|
||||
});
|
||||
expect(
|
||||
logger.lines.some(
|
||||
line => line.level === 'warn' && line.msg.includes('They are still refused.')
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('fails the session when launchctl fails', async () => {
|
||||
const handoffPath = path.join(dir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
@@ -469,6 +503,101 @@ describe(resolveLocalEgressBootEnvironmentAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveLocalEgressServeSimBootEnvironmentAsync, () => {
|
||||
let dir: string;
|
||||
let handoffPath: string;
|
||||
beforeEach(async () => {
|
||||
dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'guard-serve-sim-'));
|
||||
handoffPath = path.join(dir, 'handoff.json');
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('does not require the guard or check for an ordinary session', async () => {
|
||||
await expect(
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync({
|
||||
handoffPath,
|
||||
libraryPath: null,
|
||||
checkPath: null,
|
||||
})
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('requires both packaged files before handing startup to serve-sim', async () => {
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
await expect(
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync({ handoffPath, libraryPath: null })
|
||||
).rejects.toThrow('missing bin/egress-guard.dylib');
|
||||
await expect(
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync({
|
||||
handoffPath,
|
||||
libraryPath: '/w/bin/egress-guard.dylib',
|
||||
checkPath: null,
|
||||
})
|
||||
).rejects.toThrow('missing bin/egress-guard-check');
|
||||
});
|
||||
|
||||
it('passes the additional dylib and prefixed child variables without host DYLD injection', async () => {
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
const bootEnv = await resolveLocalEgressServeSimBootEnvironmentAsync({
|
||||
handoffPath,
|
||||
libraryPath: '/w/bin/egress-guard.dylib',
|
||||
checkPath: '/w/bin/egress-guard-check',
|
||||
logPath: '/tmp/guard.log',
|
||||
});
|
||||
expect(bootEnv).toEqual({
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
SIMCTL_CHILD_EAS_EGRESS_GUARD_LOG: '/tmp/guard.log',
|
||||
SIMCTL_CHILD_EAS_EGRESS_GUARD_MODE: 'block',
|
||||
SIMCTL_CHILD_http_proxy: 'http://127.0.0.1:8899',
|
||||
SIMCTL_CHILD_https_proxy: 'http://127.0.0.1:8899',
|
||||
SIMCTL_CHILD_HTTP_PROXY: 'http://127.0.0.1:8899',
|
||||
SIMCTL_CHILD_HTTPS_PROXY: 'http://127.0.0.1:8899',
|
||||
SIMCTL_CHILD_grpc_proxy: 'http://127.0.0.1:8899',
|
||||
SIMCTL_CHILD_no_proxy: 'localhost,127.0.0.1,::1',
|
||||
SIMCTL_CHILD_NO_PROXY: 'localhost,127.0.0.1,::1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(startLocalEgressGuardRelayAsync, () => {
|
||||
it('relays boot events once after repeated starts without changing Simulator state', async () => {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'guard-relay-'));
|
||||
const logPath = path.join(dir, 'guard.log');
|
||||
const logger = createLogger();
|
||||
mockedSetEnv.mockClear();
|
||||
await fs.promises.writeFile(
|
||||
logPath,
|
||||
'eas-egress-guard\tSpringBoard\t4001\tconnect\tblocked\t1.1.1.1:443\tNetwork\n'
|
||||
);
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
|
||||
try {
|
||||
await startLocalEgressGuardRelayAsync({ logger, logPath, tailIntervalMs: 5 });
|
||||
await startLocalEgressGuardRelayAsync({ logger, logPath, tailIntervalMs: 5 });
|
||||
await fs.promises.appendFile(
|
||||
logPath,
|
||||
'eas-egress-guard\tMyApp\t4002\tconnect\tblocked\t1.1.1.1:443\tNetwork\n'
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(20);
|
||||
await stopLocalEgressGuardRelaysAsync(logger);
|
||||
expect(mockedSetEnv).not.toHaveBeenCalled();
|
||||
expect(
|
||||
logger.lines.filter(line => /refused connect from SpringBoard/.test(line.msg))
|
||||
).toHaveLength(1);
|
||||
expect(logger.lines.filter(line => /refused connect from MyApp/.test(line.msg))).toHaveLength(
|
||||
1
|
||||
);
|
||||
expect(logger.lines.at(-1)?.msg).toContain('refused 2 connection attempt(s)');
|
||||
} finally {
|
||||
await stopLocalEgressGuardRelaysAsync(logger);
|
||||
jest.clearAllTimers();
|
||||
jest.useRealTimers();
|
||||
await fs.promises.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseGuardCoverage, () => {
|
||||
const ps = [
|
||||
' 1 0 /sbin/launchd',
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, BuildStepEnv, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -25,30 +26,46 @@ import * as remoteDeviceRunSession from '../remoteDeviceRunSession';
|
||||
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
|
||||
import { sleepAsync } from '../../../utils/retry';
|
||||
import { uploadDeviceRunSessionScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
import {
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createIosSessionStartupInputProviders,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
createPublicPreviewInputProvider,
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
fetchWebPreviewTurnArgsAsync,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
parseServeSimLaunchInputs,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
turnIceServersToWebPreviewArgs,
|
||||
validateServeSimLaunchOptions,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../remoteDeviceRunSession';
|
||||
|
||||
import {
|
||||
EXPO_DEVICE_HUB_STATE_DIR,
|
||||
corsOriginArgs,
|
||||
createExpoDeviceHubArgs,
|
||||
createServeSimArgs,
|
||||
frameAncestorArgs,
|
||||
simulatorPreviewPageUrl,
|
||||
startDeviceSessionHostAsync,
|
||||
waitForWebPreviewReadyAsync,
|
||||
websiteOrigin,
|
||||
websiteOriginServeSimArgs,
|
||||
websiteOriginArgs,
|
||||
} from '../deviceSessionHost';
|
||||
import { parseNetworkCaptureFieldsInput, parseNetworkCaptureInputs } from '../networkCaptureFields';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
jest.mock('../localEgress', () => ({
|
||||
...jest.requireActual('../localEgress'),
|
||||
readLocalEgressHandoffAsync: jest.fn().mockResolvedValue(null),
|
||||
}));
|
||||
jest.mock('node:timers');
|
||||
jest.mock('node:timers/promises');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
@@ -138,79 +155,121 @@ describe(spawnDetached, () => {
|
||||
);
|
||||
});
|
||||
|
||||
function createCtxMock(): CustomBuildContext {
|
||||
const DEVICE_RUN_SESSION_TOKEN = 'device-run-session-token';
|
||||
const DEVICE_RUN_SESSION_WORKER_URL = 'https://api.expo.test/v2/device-run-session-worker/';
|
||||
|
||||
function createCtxMock({ withToken = true }: { withToken?: boolean } = {}): CustomBuildContext {
|
||||
return {
|
||||
env: {
|
||||
__API_SERVER_URL: 'https://api.expo.test',
|
||||
...(withToken ? { EAS_DEVICE_RUN_SESSION_TOKEN: DEVICE_RUN_SESSION_TOKEN } : {}),
|
||||
},
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
job: {
|
||||
secrets: { robotAccessToken: 'robot-token' },
|
||||
},
|
||||
} as unknown as CustomBuildContext;
|
||||
}
|
||||
|
||||
/** A successful device run session worker API response with the `{ data }` body. */
|
||||
function workerApiResponse(data: unknown): Awaited<ReturnType<typeof turtleFetch>> {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data }),
|
||||
} as unknown as Awaited<ReturnType<typeof turtleFetch>>;
|
||||
}
|
||||
|
||||
/** The number of device run session worker API requests to the route. */
|
||||
function workerApiCallCount(route: string): number {
|
||||
return jest
|
||||
.mocked(turtleFetch)
|
||||
.mock.calls.filter(([url]) => url === `${DEVICE_RUN_SESSION_WORKER_URL}${route}`).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves session status polls from `results` in order, and idle stops, through the device run
|
||||
* session worker API.
|
||||
*/
|
||||
function createStatusCtxMock(
|
||||
results: (
|
||||
| { status: 'NEW' | 'IN_PROGRESS' | 'STOPPED' | 'ERRORED' }
|
||||
| { status: 'new' | 'in-progress' | 'stopped' | 'errored' }
|
||||
| { error: Error }
|
||||
| { data: unknown }
|
||||
)[],
|
||||
{ ensureStoppedError }: { ensureStoppedError?: Error } = {}
|
||||
{ stopError }: { stopError?: Error } = {}
|
||||
): CustomBuildContext {
|
||||
const query = jest.fn(() => {
|
||||
jest.mocked(turtleFetch).mockImplementation(async url => {
|
||||
if (url === `${DEVICE_RUN_SESSION_WORKER_URL}stop`) {
|
||||
if (stopError) {
|
||||
throw stopError;
|
||||
}
|
||||
return workerApiResponse({ id: 'drs-id', status: 'stopped' });
|
||||
}
|
||||
const result = results.shift();
|
||||
if (!result) {
|
||||
throw new Error('No mocked status result available');
|
||||
}
|
||||
return {
|
||||
toPromise: async () => {
|
||||
if ('error' in result) {
|
||||
throw result.error;
|
||||
}
|
||||
if ('data' in result) {
|
||||
return { data: result.data };
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
deviceRunSessions: {
|
||||
byId: {
|
||||
id: 'drs-id',
|
||||
status: result.status,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
if ('error' in result) {
|
||||
throw result.error;
|
||||
}
|
||||
if ('data' in result) {
|
||||
return workerApiResponse(result.data);
|
||||
}
|
||||
return workerApiResponse({ id: 'drs-id', status: result.status });
|
||||
});
|
||||
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => {
|
||||
if (ensureStoppedError) {
|
||||
return { error: ensureStoppedError };
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
ensureDeviceRunSessionStopped: { id: 'drs-id', status: 'STOPPED' },
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
graphqlClient: {
|
||||
query,
|
||||
mutation,
|
||||
},
|
||||
} as unknown as CustomBuildContext;
|
||||
return createCtxMock();
|
||||
}
|
||||
|
||||
function createEnvMock(): BuildStepEnv {
|
||||
return { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv;
|
||||
}
|
||||
|
||||
describe(createIosSessionStartupInputProviders, () => {
|
||||
it('declares optional inputs so existing workflows do not opt into owned startup', () => {
|
||||
const inputs = createIosSessionStartupInputProviders().map(provider =>
|
||||
provider(createGlobalContextMock(), 'Test step')
|
||||
);
|
||||
expect(
|
||||
inputs.map(({ id, required, allowedValueTypeName }) => ({
|
||||
id,
|
||||
required,
|
||||
allowedValueTypeName,
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
id: 'boot_simulator',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
},
|
||||
{
|
||||
id: 'device_identifier',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
},
|
||||
{
|
||||
id: 'install_app_path',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(validateServeSimLaunchOptions, () => {
|
||||
it('accepts empty options and arguments at the count, character and byte limits', () => {
|
||||
expect(() => validateServeSimLaunchOptions({})).not.toThrow();
|
||||
expect(() => validateServeSimLaunchOptions({ launchArgs: Array(256).fill('a') })).not.toThrow();
|
||||
expect(() => validateServeSimLaunchOptions({ launchArgs: ['a'.repeat(8192)] })).not.toThrow();
|
||||
expect(() =>
|
||||
validateServeSimLaunchOptions({ launchArgs: Array(16).fill('a'.repeat(8191)) })
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
validateServeSimLaunchOptions({ openUrl: `https://${'a'.repeat(8184)}` })
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe(createServeSimLaunchInputProviders, () => {
|
||||
it('declares the launch inputs as optional', () => {
|
||||
const globalCtx = createGlobalContextMock();
|
||||
@@ -244,6 +303,110 @@ describe(createServeSimLaunchInputProviders, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(createPublicPreviewInputProvider, () => {
|
||||
it('preserves the native Share button for legacy jobs without the input', () => {
|
||||
const input = createPublicPreviewInputProvider()(createGlobalContextMock(), 'Test step');
|
||||
expect(input).toMatchObject({
|
||||
id: 'public_preview',
|
||||
required: false,
|
||||
defaultValue: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(createPrivatePreviewUrlInputProvider, () => {
|
||||
it('accepts an optional authenticated preview URL from the session job', () => {
|
||||
const provider = createPrivatePreviewUrlInputProvider();
|
||||
const input = provider(createGlobalContextMock(), 'step-id');
|
||||
expect(input.id).toBe('private_preview_url');
|
||||
expect(input.required).toBe(false);
|
||||
expect(input.allowedValueTypeName).toBe(BuildStepInputValueTypeName.STRING);
|
||||
});
|
||||
});
|
||||
|
||||
describe(createFrameAncestorsInputProvider, () => {
|
||||
it('accepts an optional JSON list of origins from the session job', () => {
|
||||
const input = createFrameAncestorsInputProvider()(createGlobalContextMock(), 'step-id');
|
||||
expect(input).toMatchObject({
|
||||
id: 'frame_ancestors',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseFrameAncestorsInput, () => {
|
||||
it('defaults to no extra origins when the step declares nothing', () => {
|
||||
expect(parseFrameAncestorsInput(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns the origins as given', () => {
|
||||
expect(
|
||||
parseFrameAncestorsInput(['https://embed.example.test', 'https://*.example.test'])
|
||||
).toEqual(['https://embed.example.test', 'https://*.example.test']);
|
||||
});
|
||||
|
||||
it.each([['https://embed.example.test'], [['https://embed.example.test', '']], [[1]]])(
|
||||
'rejects %j',
|
||||
value => {
|
||||
expect(() => parseFrameAncestorsInput(value)).toThrow(
|
||||
'Input "frame_ancestors" must be an array of non-empty strings, one origin each.'
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe(createCorsOriginsInputProvider, () => {
|
||||
it('accepts an optional JSON list of origins from the session job', () => {
|
||||
const input = createCorsOriginsInputProvider()(createGlobalContextMock(), 'step-id');
|
||||
expect(input).toMatchObject({
|
||||
id: 'cors_origins',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseCorsOriginsInput, () => {
|
||||
it('defaults to no extra origins and returns the origins as given', () => {
|
||||
expect(parseCorsOriginsInput(undefined)).toEqual([]);
|
||||
expect(parseCorsOriginsInput(['https://app.example.test'])).toEqual([
|
||||
'https://app.example.test',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects anything but a list of non-empty strings', () => {
|
||||
expect(() => parseCorsOriginsInput(['https://app.example.test', ''])).toThrow(
|
||||
'Input "cors_origins" must be an array of non-empty strings, one origin each.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(corsOriginArgs, () => {
|
||||
it('repeats the flag once per origin', () => {
|
||||
expect(corsOriginArgs(['https://a.example.test', 'https://b.example.test'])).toEqual([
|
||||
'--cors-origin',
|
||||
'https://a.example.test',
|
||||
'--cors-origin',
|
||||
'https://b.example.test',
|
||||
]);
|
||||
expect(corsOriginArgs([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(frameAncestorArgs, () => {
|
||||
it('repeats the flag once per origin', () => {
|
||||
expect(frameAncestorArgs(['https://a.example.test', 'https://b.example.test'])).toEqual([
|
||||
'--frame-ancestor',
|
||||
'https://a.example.test',
|
||||
'--frame-ancestor',
|
||||
'https://b.example.test',
|
||||
]);
|
||||
expect(frameAncestorArgs([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseServeSimLaunchInputs, () => {
|
||||
const darwin = { runtimePlatform: BuildRuntimePlatform.DARWIN };
|
||||
|
||||
@@ -411,6 +574,26 @@ describe(createServeSimArgs, () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('appends the session frame ancestors after the website args', () => {
|
||||
const args = createServeSimArgs({
|
||||
port: 4321,
|
||||
websiteArgs: ['--frame-ancestor', 'https://expo.dev'],
|
||||
frameAncestorArgs: ['--frame-ancestor', 'https://embed.example.test'],
|
||||
corsOriginArgs: ['--cors-origin', 'https://app.example.test'],
|
||||
shareUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
});
|
||||
expect(args.slice(-8)).toEqual([
|
||||
'--frame-ancestor',
|
||||
'https://expo.dev',
|
||||
'--frame-ancestor',
|
||||
'https://embed.example.test',
|
||||
'--cors-origin',
|
||||
'https://app.example.test',
|
||||
'--share-url',
|
||||
'https://expo.dev/simulator-preview/preview-id',
|
||||
]);
|
||||
});
|
||||
|
||||
it('pins the requested package version', () => {
|
||||
expect(createServeSimArgs({ port: 4321, packageVersion: '0.1.38' })[0]).toBe(
|
||||
'@expo/[email protected]'
|
||||
@@ -437,6 +620,14 @@ describe(createServeSimArgs, () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('disables the native Share button only when requested', () => {
|
||||
expect(createServeSimArgs({ port: 4321, disableShare: true })).toContain('--disable-share');
|
||||
expect(createServeSimArgs({ port: 4321, disableShare: false })).not.toContain(
|
||||
'--disable-share'
|
||||
);
|
||||
expect(createServeSimArgs({ port: 4321 })).not.toContain('--disable-share');
|
||||
});
|
||||
|
||||
it('appends the launch flags after the streaming policy', () => {
|
||||
const args = createServeSimArgs({
|
||||
port: 4321,
|
||||
@@ -561,6 +752,7 @@ describe(createExpoDeviceHubArgs, () => {
|
||||
'4321',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--require-token',
|
||||
'--platform',
|
||||
'android',
|
||||
'--transport',
|
||||
@@ -582,6 +774,40 @@ describe(createExpoDeviceHubArgs, () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// The same website flags as serve-sim, after the TURN args.
|
||||
it('appends the website args after the TURN args when provided', () => {
|
||||
const args = createExpoDeviceHubArgs({
|
||||
port: 4321,
|
||||
turnArgs: ['--turn-url', 'turns:turn.example.test:443'],
|
||||
websiteArgs: ['--cors-origin', 'https://expo.dev', '--frame-ancestor', 'https://expo.dev'],
|
||||
});
|
||||
expect(args.slice(-6)).toEqual([
|
||||
'--turn-url',
|
||||
'turns:turn.example.test:443',
|
||||
'--cors-origin',
|
||||
'https://expo.dev',
|
||||
'--frame-ancestor',
|
||||
'https://expo.dev',
|
||||
]);
|
||||
});
|
||||
|
||||
it('appends the session frame ancestors after the website args', () => {
|
||||
const args = createExpoDeviceHubArgs({
|
||||
port: 4321,
|
||||
websiteArgs: ['--frame-ancestor', 'https://expo.dev'],
|
||||
frameAncestorArgs: ['--frame-ancestor', 'https://embed.example.test'],
|
||||
corsOriginArgs: ['--cors-origin', 'https://app.example.test'],
|
||||
});
|
||||
expect(args.slice(-6)).toEqual([
|
||||
'--frame-ancestor',
|
||||
'https://expo.dev',
|
||||
'--frame-ancestor',
|
||||
'https://embed.example.test',
|
||||
'--cors-origin',
|
||||
'https://app.example.test',
|
||||
]);
|
||||
});
|
||||
|
||||
it('pins the requested package version', () => {
|
||||
expect(createExpoDeviceHubArgs({ port: 4321, packageVersion: '0.7.0' })[0]).toBe(
|
||||
'[email protected]'
|
||||
@@ -589,9 +815,9 @@ describe(createExpoDeviceHubArgs, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(websiteOriginServeSimArgs, () => {
|
||||
describe(websiteOriginArgs, () => {
|
||||
it('names only the production website by default', () => {
|
||||
expect(websiteOriginServeSimArgs({} as BuildStepEnv)).toEqual([
|
||||
expect(websiteOriginArgs({} as BuildStepEnv)).toEqual([
|
||||
'--cors-origin',
|
||||
'https://expo.dev',
|
||||
'--frame-ancestor',
|
||||
@@ -600,7 +826,7 @@ describe(websiteOriginServeSimArgs, () => {
|
||||
});
|
||||
|
||||
it('names staging, its deploy previews and local website subdomains on staging', () => {
|
||||
const args = websiteOriginServeSimArgs({ EXPO_STAGING: '1' } as BuildStepEnv);
|
||||
const args = websiteOriginArgs({ EXPO_STAGING: '1' } as BuildStepEnv);
|
||||
expect(args).toEqual([
|
||||
'--cors-origin',
|
||||
'https://staging.expo.dev',
|
||||
@@ -623,14 +849,14 @@ describe(websiteOriginServeSimArgs, () => {
|
||||
|
||||
it('names only https origins', () => {
|
||||
for (const env of [{}, { EXPO_STAGING: '1' }, { EXPO_LOCAL: '1' }]) {
|
||||
const args = websiteOriginServeSimArgs(env as BuildStepEnv);
|
||||
const args = websiteOriginArgs(env as BuildStepEnv);
|
||||
expect(args.filter(value => value.startsWith('http://'))).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('names local website subdomains without the deploy-preview wildcard on local', () => {
|
||||
for (const env of [{ EXPO_LOCAL: '1' }, { EXPO_LOCAL: '1', EXPO_STAGING: '1' }]) {
|
||||
expect(websiteOriginServeSimArgs(env as BuildStepEnv)).toEqual([
|
||||
expect(websiteOriginArgs(env as BuildStepEnv)).toEqual([
|
||||
'--cors-origin',
|
||||
'https://expo.test',
|
||||
'--frame-ancestor',
|
||||
@@ -656,6 +882,8 @@ describe(waitForWebPreviewReadyAsync, () => {
|
||||
.mocked(turtleFetch)
|
||||
.mockRejectedValueOnce(new Error('not ready'))
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ status: 'ready', device: 'DEVICE-A' }),
|
||||
} as unknown as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
|
||||
@@ -827,10 +1055,11 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
'turn-credential',
|
||||
];
|
||||
const websiteArgs = ['--cors-origin', 'https://expo.dev', '--frame-ancestor', 'https://expo.dev'];
|
||||
const authtoken = 'ngrok-token';
|
||||
const env = {
|
||||
DEVICE_RUN_SESSION_ID: 'drs-id',
|
||||
NGROK_AUTHTOKEN: 'ngrok-token',
|
||||
} as unknown as BuildStepEnv;
|
||||
const hubStateFiles: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockReset();
|
||||
@@ -852,11 +1081,23 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
}),
|
||||
},
|
||||
});
|
||||
jest.mocked(spawn).mockReturnValue(spawnPromise as never);
|
||||
// expo-device-hub records its session token in `server-<port>.json` before it is ready.
|
||||
jest.mocked(spawn).mockImplementation(((_command: string, args: string[] = []) => {
|
||||
if (args.includes('--platform')) {
|
||||
const port = args[args.indexOf('--port') + 1];
|
||||
const file = path.join(EXPO_DEVICE_HUB_STATE_DIR, `server-${port}.json`);
|
||||
mkdirSync(EXPO_DEVICE_HUB_STATE_DIR, { recursive: true });
|
||||
writeFileSync(file, JSON.stringify({ pid: 1, port: Number(port), token: 'hub-token' }));
|
||||
hubStateFiles.push(file);
|
||||
}
|
||||
return spawnPromise;
|
||||
}) as never);
|
||||
|
||||
jest.mocked(turtleFetch).mockImplementation(async url => {
|
||||
if (url.endsWith('/turn-ice-servers')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
data: {
|
||||
iceServers: [
|
||||
@@ -871,13 +1112,16 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
} as unknown as Awaited<ReturnType<typeof turtleFetch>>;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ status: 'ready', device: 'device-id' }),
|
||||
} as unknown as Awaited<ReturnType<typeof turtleFetch>>;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
jest.restoreAllMocks();
|
||||
await Promise.all(hubStateFiles.splice(0).map(file => fs.rm(file, { force: true })));
|
||||
});
|
||||
|
||||
it('gives serve-sim its recording grace period on shutdown', async () => {
|
||||
@@ -896,6 +1140,84 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
await host.finishAsync();
|
||||
});
|
||||
|
||||
it.each([undefined, 'latest', '1.2.3'])(
|
||||
'passes private sharing flags directly to serve-sim version %s',
|
||||
async packageVersion => {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
packageVersion,
|
||||
disableShare: true,
|
||||
});
|
||||
|
||||
const serveSimCalls = jest
|
||||
.mocked(spawn)
|
||||
.mock.calls.filter(([, args]) =>
|
||||
args.includes(`@expo/serve-sim@${packageVersion ?? 'latest'}`)
|
||||
);
|
||||
expect(serveSimCalls).toHaveLength(1);
|
||||
expect(serveSimCalls[0][1]).toContain('--disable-share');
|
||||
expect(serveSimCalls[0][1]).not.toContain('--share-url');
|
||||
await host.finishAsync();
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps the authenticated link out of serve-sim while retaining the public frame URL', async () => {
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://preview.example.test',
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
const privatePreviewUrl =
|
||||
'https://expo.dev/accounts/owner/projects/app/simulator-sessions/session-id/preview';
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
disableShare: true,
|
||||
privatePreviewUrl,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
|
||||
|
||||
const serveSimArgs = jest
|
||||
.mocked(spawn)
|
||||
.mock.calls.find(([, args]) => args.includes('@expo/serve-sim@latest'))![1];
|
||||
expect(serveSimArgs).not.toContain('--share-url');
|
||||
expect(serveSimArgs).toContain('--disable-share');
|
||||
expect(preview.sharePageUrl).toBe(privatePreviewUrl);
|
||||
expect(preview.previewPageUrl).toMatch(
|
||||
/^https:\/\/expo\.dev\/simulator-preview\/[a-f0-9]{32}$/
|
||||
);
|
||||
await host.finishAsync();
|
||||
});
|
||||
|
||||
it('does not expose a public share URL when a private job omits its authenticated URL', async () => {
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://preview.example.test',
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
disableShare: true,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
|
||||
|
||||
const serveSimArgs = jest
|
||||
.mocked(spawn)
|
||||
.mock.calls.find(([, args]) => args.includes('@expo/serve-sim@latest'))![1];
|
||||
expect(serveSimArgs).not.toContain('--share-url');
|
||||
expect(preview.sharePageUrl).toBeUndefined();
|
||||
expect(preview.previewPageUrl).toMatch(
|
||||
/^https:\/\/expo\.dev\/simulator-preview\/[a-f0-9]{32}$/
|
||||
);
|
||||
await host.finishAsync();
|
||||
});
|
||||
|
||||
it('installs ffmpeg before starting expo-device-hub for Linux', async () => {
|
||||
const packageVersion = '1.2.3';
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
@@ -916,7 +1238,7 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
timeoutMs: 10_000,
|
||||
packageVersion,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
|
||||
|
||||
const spawnCalls = jest.mocked(spawn).mock.calls;
|
||||
expect(spawnCalls[0]).toEqual([
|
||||
@@ -947,7 +1269,13 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
const recordingDirectory = args[args.indexOf('--android-recording-directory') + 1];
|
||||
expect(args).toEqual([
|
||||
'--yes',
|
||||
...createExpoDeviceHubArgs({ port, turnArgs, packageVersion, recordingDirectory }),
|
||||
...createExpoDeviceHubArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
websiteArgs,
|
||||
packageVersion,
|
||||
recordingDirectory,
|
||||
}),
|
||||
]);
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(preview.apiUrl).toBe('https://android-preview.example.test');
|
||||
@@ -969,10 +1297,16 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
|
||||
|
||||
expect(preview.previewToken).toBe('tok-1');
|
||||
expect(preview.apiUrl).toBe('https://preview.example.test');
|
||||
expect(preview.sharePageUrl).toBe(preview.previewPageUrl);
|
||||
const serveSimArgs = jest
|
||||
.mocked(spawn)
|
||||
.mock.calls.find(([, args]) => args.includes('@expo/serve-sim@latest'))![1];
|
||||
expect(serveSimArgs[serveSimArgs.indexOf('--share-url') + 1]).toBe(preview.previewPageUrl);
|
||||
expect(serveSimArgs).not.toContain('--disable-share');
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -998,7 +1332,7 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
|
||||
|
||||
expect(preview.previewPageUrl).toMatch(
|
||||
/^https:\/\/staging\.expo\.dev\/simulator-preview\/[a-f0-9]{32}$/
|
||||
@@ -1026,8 +1360,8 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
).rejects.toThrow(/wrote no session token/);
|
||||
});
|
||||
|
||||
// expo-device-hub mints no token, so the Android preview must not require one.
|
||||
it('starts for Linux without a token, and does not gate expo-device-hub', async () => {
|
||||
// expo-device-hub records its token in a state file, as serve-sim does.
|
||||
it('carries the expo-device-hub session token for Linux', async () => {
|
||||
jest.mocked(readServeSimServersAsync).mockResolvedValue([]);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://android-preview.example.test',
|
||||
@@ -1040,11 +1374,12 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
|
||||
|
||||
expect(preview.previewToken).toBeUndefined();
|
||||
const [, args] = jest.mocked(spawn).mock.calls[0];
|
||||
expect(args).not.toContain('--require-token');
|
||||
expect(preview.previewToken).toBe('hub-token');
|
||||
const [, args] = jest.mocked(spawn).mock.calls.find(([command]) => command === 'npx')!;
|
||||
expect(args).toContain('--require-token');
|
||||
await host.finishAsync();
|
||||
});
|
||||
|
||||
it('starts serve-sim for Darwin with its metrics policy and cleans up the preview resources', async () => {
|
||||
@@ -1062,7 +1397,7 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
timeoutMs: 10_000,
|
||||
packageVersion,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
@@ -1101,7 +1436,7 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
timeoutMs: 10_000,
|
||||
packageVersion: '4.5.6',
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
@@ -1135,7 +1470,7 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
@@ -1260,24 +1595,21 @@ describe(fetchWebPreviewTurnArgsAsync, () => {
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
it('requests TURN ICE servers from the device run session endpoint and returns web preview args', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValue({
|
||||
json: async () => ({
|
||||
data: {
|
||||
iceServers: [
|
||||
{ urls: ['stun:stun.cloudflare.com:3478'] },
|
||||
{
|
||||
urls: ['turns:turn.cloudflare.com:443?transport=tcp'],
|
||||
username: 'u',
|
||||
credential: 'c',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
} as unknown as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
it('requests TURN ICE servers from the device run session worker API and returns web preview args', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValue(
|
||||
workerApiResponse({
|
||||
iceServers: [
|
||||
{ urls: ['stun:stun.cloudflare.com:3478'] },
|
||||
{
|
||||
urls: ['turns:turn.cloudflare.com:443?transport=tcp'],
|
||||
username: 'u',
|
||||
credential: 'c',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), {
|
||||
env: createEnvMock(),
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
|
||||
@@ -1292,10 +1624,10 @@ describe(fetchWebPreviewTurnArgsAsync, () => {
|
||||
'c',
|
||||
]);
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/device-run-sessions/drs-id/turn-ice-servers',
|
||||
`${DEVICE_RUN_SESSION_WORKER_URL}turn-ice-servers`,
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer robot-token' },
|
||||
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -1304,15 +1636,33 @@ describe(fetchWebPreviewTurnArgsAsync, () => {
|
||||
jest.mocked(turtleFetch).mockRejectedValue(new Error('boom'));
|
||||
const logger = createLoggerMock();
|
||||
|
||||
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), {
|
||||
env: createEnvMock(),
|
||||
logger,
|
||||
});
|
||||
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), { logger });
|
||||
|
||||
expect(args).toEqual([]);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(jest.mocked(Sentry).capture).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns [] and warns when the job has no device run session token', async () => {
|
||||
const logger = createLoggerMock();
|
||||
|
||||
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock({ withToken: false }), {
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(args).toEqual([]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{
|
||||
err: expect.objectContaining({
|
||||
message: expect.stringMatching(
|
||||
/^This device run session job has no EAS_DEVICE_RUN_SESSION_TOKEN secret\./
|
||||
),
|
||||
}),
|
||||
},
|
||||
'Could not fetch Cloudflare TURN ICE servers; the web preview will fall back to P2P/STUN.'
|
||||
);
|
||||
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
@@ -1328,7 +1678,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
});
|
||||
|
||||
it('continues polling until the device run session is stopped', async () => {
|
||||
const ctx = createStatusCtxMock([{ status: 'IN_PROGRESS' }, { status: 'STOPPED' }]);
|
||||
const ctx = createStatusCtxMock([{ status: 'in-progress' }, { status: 'stopped' }]);
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
@@ -1337,13 +1687,59 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(2);
|
||||
expect(workerApiCallCount('session')).toBe(2);
|
||||
expect(logger.info).toHaveBeenCalledWith('Device run session drs-id was stopped.');
|
||||
expect(setTimeoutCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('polls the session status through the worker API with the abort signal', async () => {
|
||||
const ctx = createStatusCtxMock([{ status: 'stopped' }]);
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
|
||||
`${DEVICE_RUN_SESSION_WORKER_URL}session`,
|
||||
'GET',
|
||||
expect.objectContaining({
|
||||
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
|
||||
signal: expect.any(AbortSignal),
|
||||
timeout: 30_000,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps polling while the session status is new', async () => {
|
||||
const ctx = createStatusCtxMock([{ status: 'new' }, { status: 'stopped' }]);
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
|
||||
expect(workerApiCallCount('session')).toBe(2);
|
||||
});
|
||||
|
||||
it('fails with a system error when the job has no device run session token', async () => {
|
||||
const error = await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx: createCtxMock({ withToken: false }),
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger: createLoggerMock(),
|
||||
}).catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(SystemError);
|
||||
expect(error.message).toMatch(
|
||||
/^This device run session job has no EAS_DEVICE_RUN_SESSION_TOKEN secret\./
|
||||
);
|
||||
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns normally when the maximum duration elapses', async () => {
|
||||
const ctx = createStatusCtxMock([{ status: 'IN_PROGRESS' }]);
|
||||
const ctx = createStatusCtxMock([{ status: 'in-progress' }]);
|
||||
const logger = createLoggerMock();
|
||||
const waitPromise = waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
@@ -1357,7 +1753,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
durationTimeoutCallback();
|
||||
await waitPromise;
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(1);
|
||||
expect(workerApiCallCount('session')).toBe(1);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Device run session drs-id reached its maximum duration.'
|
||||
);
|
||||
@@ -1366,7 +1762,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
|
||||
it('clears the duration timeout when the session stops first', async () => {
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx: createStatusCtxMock([{ status: 'STOPPED' }]),
|
||||
ctx: createStatusCtxMock([{ status: 'stopped' }]),
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger: createLoggerMock(),
|
||||
maxDurationSeconds: 30,
|
||||
@@ -1388,12 +1784,12 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
|
||||
expect(workerApiCallCount('session')).toBe(0);
|
||||
expect(setTimeoutCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the device run session errors', async () => {
|
||||
const ctx = createStatusCtxMock([{ status: 'ERRORED' }]);
|
||||
const ctx = createStatusCtxMock([{ status: 'errored' }]);
|
||||
|
||||
await expect(
|
||||
waitForDeviceRunSessionStoppedAsync({
|
||||
@@ -1407,7 +1803,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
});
|
||||
|
||||
it('clears the duration timeout when the build step is aborted', async () => {
|
||||
const ctx = createStatusCtxMock([{ status: 'IN_PROGRESS' }]);
|
||||
const ctx = createStatusCtxMock([{ status: 'in-progress' }]);
|
||||
const abortController = new AbortController();
|
||||
const waitPromise = waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
@@ -1420,12 +1816,12 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
abortController.abort();
|
||||
await waitPromise;
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(1);
|
||||
expect(workerApiCallCount('session')).toBe(1);
|
||||
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
|
||||
});
|
||||
|
||||
it('logs and retries transient polling errors', async () => {
|
||||
const ctx = createStatusCtxMock([{ error: new Error('network down') }, { status: 'STOPPED' }]);
|
||||
const ctx = createStatusCtxMock([{ error: new Error('network down') }, { status: 'stopped' }]);
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
@@ -1434,7 +1830,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(2);
|
||||
expect(workerApiCallCount('session')).toBe(2);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ failedStatusPollCount: 1 }),
|
||||
'Could not poll device run session status; will retry.'
|
||||
@@ -1446,11 +1842,8 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('logs and retries when the status response is missing', async () => {
|
||||
const ctx = createStatusCtxMock([
|
||||
{ data: { deviceRunSessions: { byId: null } } },
|
||||
{ status: 'STOPPED' },
|
||||
]);
|
||||
it('logs and retries when the status response does not match the route contract', async () => {
|
||||
const ctx = createStatusCtxMock([{ data: { id: 'drs-id' } }, { status: 'stopped' }]);
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
@@ -1459,7 +1852,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(2);
|
||||
expect(workerApiCallCount('session')).toBe(2);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ failedStatusPollCount: 1 }),
|
||||
'Could not poll device run session status; will retry.'
|
||||
@@ -1467,7 +1860,9 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
expect(jest.mocked(Sentry).capture).toHaveBeenCalledWith(
|
||||
'Could not poll device run session status',
|
||||
expect.objectContaining({
|
||||
message: 'Device run session drs-id status response was missing.',
|
||||
message: expect.stringContaining(
|
||||
'GET /v2/device-run-session-worker/session returned an unexpected response'
|
||||
),
|
||||
}),
|
||||
{ level: 'warning' }
|
||||
);
|
||||
@@ -1488,8 +1883,8 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
function manyInProgressStatuses(): { status: 'IN_PROGRESS' }[] {
|
||||
return Array.from({ length: 20 }, () => ({ status: 'IN_PROGRESS' as const }));
|
||||
function manyInProgressStatuses(): { status: 'in-progress' }[] {
|
||||
return Array.from({ length: 20 }, () => ({ status: 'in-progress' as const }));
|
||||
}
|
||||
|
||||
it('stops the session when no activity is observed within the max idle time', async () => {
|
||||
@@ -1507,15 +1902,15 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
});
|
||||
|
||||
// One minute at the 5-second poll interval is 12 status polls.
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(12);
|
||||
expect(ctx.graphqlClient.mutation).toHaveBeenCalledTimes(1);
|
||||
expect(workerApiCallCount('session')).toBe(12);
|
||||
expect(workerApiCallCount('stop')).toBe(1);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Device run session drs-id had no activity for 1 minute(s) (max idle time). Stopping the session.'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the session alive while events keep arriving', async () => {
|
||||
const ctx = createStatusCtxMock([...manyInProgressStatuses(), { status: 'STOPPED' }]);
|
||||
const ctx = createStatusCtxMock([...manyInProgressStatuses(), { status: 'stopped' }]);
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
@@ -1530,14 +1925,14 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(21);
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
expect(workerApiCallCount('session')).toBe(21);
|
||||
expect(workerApiCallCount('stop')).toBe(0);
|
||||
expect(logger.info).toHaveBeenCalledWith('Device run session drs-id was stopped.');
|
||||
});
|
||||
|
||||
it('still returns when the session cannot be marked stopped', async () => {
|
||||
const ctx = createStatusCtxMock(manyInProgressStatuses(), {
|
||||
ensureStoppedError: new Error('forbidden'),
|
||||
stopError: new Error('forbidden'),
|
||||
});
|
||||
const logger = createLoggerMock();
|
||||
|
||||
@@ -1551,7 +1946,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(ctx.graphqlClient.mutation).toHaveBeenCalledTimes(1);
|
||||
expect(workerApiCallCount('stop')).toBe(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
'Could not mark device run session drs-id as stopped. The session job ends anyway.'
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { once } from 'node:events';
|
||||
import {
|
||||
access,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
readlink,
|
||||
rm,
|
||||
stat,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
import { runServeSimActionAsync, stageServeSimAppAsync } from '../serveSimActions';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
let server: WebSocketServer;
|
||||
let port: number;
|
||||
const token = 'test-session-token';
|
||||
const request = () => ({
|
||||
port,
|
||||
token,
|
||||
action: 'app.install',
|
||||
params: { udid: 'device', path: '/tmp/App.app' },
|
||||
timeoutMs: 2_000,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
server = new WebSocketServer({ host: '127.0.0.1', port: 0 });
|
||||
await once(server, 'listening');
|
||||
port = (server.address() as { port: number }).port;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const socket of server.clients) {
|
||||
socket.terminate();
|
||||
}
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('authenticates the upgrade, waits for ready, and sends the action once', async () => {
|
||||
const actions: unknown[] = [];
|
||||
const upgrades: unknown[] = [];
|
||||
server.on('connection', (socket, upgrade) => {
|
||||
upgrades.push({
|
||||
url: upgrade.url,
|
||||
protocol: upgrade.headers['sec-websocket-protocol'],
|
||||
});
|
||||
socket.on('message', raw => {
|
||||
actions.push(JSON.parse(raw.toString()));
|
||||
socket.send(JSON.stringify({ id: 1, stdout: '', stderr: '', exitCode: 0 }));
|
||||
});
|
||||
socket.send(JSON.stringify({ ready: true }));
|
||||
socket.send(JSON.stringify({ ready: true }));
|
||||
});
|
||||
await runServeSimActionAsync(request());
|
||||
expect(upgrades).toEqual([{ url: '/exec-ws', protocol: `serve-sim.token.${token}` }]);
|
||||
expect(actions).toEqual([{ id: 1, action: 'app.install', params: request().params }]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ id: 1, exitCode: 1, stderr: 'install refused' }, 'install refused'],
|
||||
[{ id: 1, error: 'unknown action app.install' }, 'unknown action'],
|
||||
[{ id: 1, stdout: '' }, 'invalid response'],
|
||||
[{ id: 1, exitCode: 1, stderr: '' }, 'invalid response'],
|
||||
])('rejects unsuccessful action replies without retrying', async (reply, message) => {
|
||||
let attempts = 0;
|
||||
server.on('connection', socket => {
|
||||
socket.send(JSON.stringify({ ready: true }));
|
||||
socket.on('message', () => {
|
||||
attempts++;
|
||||
socket.send(JSON.stringify(reply));
|
||||
});
|
||||
});
|
||||
await expect(runServeSimActionAsync(request())).rejects.toThrow(message);
|
||||
expect(attempts).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects a connection that closes before its action finishes', async () => {
|
||||
server.on('connection', socket => socket.close());
|
||||
await expect(runServeSimActionAsync(request())).rejects.toThrow('closed before completion');
|
||||
});
|
||||
|
||||
it('rejects an upgrade refused by the server without executing an action', async () => {
|
||||
const connected = jest.fn();
|
||||
server.options.verifyClient = () => false;
|
||||
server.on('connection', connected);
|
||||
await expect(runServeSimActionAsync(request())).rejects.toThrow('connection failed');
|
||||
expect(connected).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects malformed replies without resending the action', async () => {
|
||||
const actions = jest.fn();
|
||||
server.on('connection', socket => {
|
||||
socket.send(JSON.stringify({ ready: true }));
|
||||
socket.on('message', () => {
|
||||
actions();
|
||||
socket.send('not JSON');
|
||||
});
|
||||
});
|
||||
await expect(runServeSimActionAsync(request())).rejects.toThrow('returned invalid JSON');
|
||||
expect(actions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('times out and closes a socket that never becomes ready', async () => {
|
||||
const disconnected = new Promise(resolve =>
|
||||
server.on('connection', socket => socket.on('close', resolve))
|
||||
);
|
||||
await expect(runServeSimActionAsync({ ...request(), timeoutMs: 30 })).rejects.toThrow(
|
||||
'timed out'
|
||||
);
|
||||
await disconnected;
|
||||
});
|
||||
|
||||
it('aborts an in-flight action without resending it', async () => {
|
||||
const controller = new AbortController();
|
||||
let attempts = 0;
|
||||
server.on('connection', socket => {
|
||||
socket.send(JSON.stringify({ ready: true }));
|
||||
socket.on('message', () => {
|
||||
attempts++;
|
||||
controller.abort(new Error('session stopped'));
|
||||
});
|
||||
});
|
||||
await expect(runServeSimActionAsync({ ...request(), signal: controller.signal })).rejects.toThrow(
|
||||
'session stopped'
|
||||
);
|
||||
expect(attempts).toBe(1);
|
||||
});
|
||||
|
||||
it('does not connect when already aborted', async () => {
|
||||
const connected = jest.fn();
|
||||
server.on('connection', connected);
|
||||
await expect(
|
||||
runServeSimActionAsync({
|
||||
...request(),
|
||||
signal: AbortSignal.abort(new Error('already stopped')),
|
||||
})
|
||||
).rejects.toThrow('already stopped');
|
||||
expect(connected).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stages a local app without consuming the caller's original", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'serve-sim-stage-test-'));
|
||||
try {
|
||||
const source = path.join(root, 'downloaded', 'App.app');
|
||||
await mkdir(source, { recursive: true });
|
||||
await writeFile(path.join(source, 'marker'), 'app');
|
||||
await symlink('marker', path.join(source, 'current'));
|
||||
const alias = path.join(root, 'Alias.app');
|
||||
await symlink(source, alias);
|
||||
const staged = await stageServeSimAppAsync(alias, root);
|
||||
expect(staged.path).toMatch(/serve-sim-uploads\/eas-app-[^/]+\/Alias.app$/);
|
||||
expect((await lstat(staged.path)).isDirectory()).toBe(true);
|
||||
await access(path.join(staged.path, 'marker'));
|
||||
expect(await readlink(path.join(staged.path, 'current'))).toBe('marker');
|
||||
expect((await stat(path.join(root, 'serve-sim-uploads'))).mode & 0o777).toBe(0o700);
|
||||
await rm(staged.directory, { recursive: true, force: true });
|
||||
expect(await readFile(path.join(source, 'marker'), 'utf8')).toBe('app');
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('removes its staging directory when the source app cannot be read', async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'serve-sim-stage-test-'));
|
||||
try {
|
||||
await expect(stageServeSimAppAsync(path.join(root, 'Missing.app'), root)).rejects.toThrow(
|
||||
'ENOENT'
|
||||
);
|
||||
expect(await readdir(path.join(root, 'serve-sim-uploads'))).toEqual([]);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -6,11 +6,13 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
import { uploadServeSimLogsFileAsync } from '../serveSimLogsArtifacts';
|
||||
import { streamServeSimLogsToFileAsync } from '../serveSimLogsRecorder';
|
||||
|
||||
// This test exercises actual sockets, UTF-8 decoding, files, and PUT uploads.
|
||||
// Only WWW's allocation response is substituted; it does not verify cloud storage.
|
||||
// WWW's device run session worker API is served by the local server; it does not verify cloud
|
||||
// storage.
|
||||
jest.mock('../../../sentry');
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
@@ -55,7 +57,29 @@ it('streams selected-device logs into NDJSON and uploads the exact bytes as a se
|
||||
let streamRequest: { url?: string; authorization?: string } | undefined;
|
||||
let uploaded: Buffer | undefined;
|
||||
let uploadedLength: string | undefined;
|
||||
let uploadSessionRequest: { token?: string | string[]; body: unknown } | undefined;
|
||||
server.on('request', (request, response) => {
|
||||
if (request.url === '/v2/device-run-session-worker/artifact-upload-sessions') {
|
||||
const chunks: Buffer[] = [];
|
||||
request.on('data', chunk => chunks.push(Buffer.from(chunk)));
|
||||
request.on('end', () => {
|
||||
uploadSessionRequest = {
|
||||
token: request.headers[DEVICE_RUN_SESSION_TOKEN_HEADER],
|
||||
body: JSON.parse(Buffer.concat(chunks).toString('utf8')),
|
||||
};
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' }).end(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
uploadSession: {
|
||||
url: `${baseUrl}/upload`,
|
||||
headers: { 'Content-Length': String(expected.length) },
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method === 'GET') {
|
||||
streamRequest = { url: request.url, authorization: request.headers.authorization };
|
||||
response.writeHead(200, {
|
||||
@@ -95,27 +119,16 @@ it('streams selected-device logs into NDJSON and uploads the exact bytes as a se
|
||||
});
|
||||
expect(await readFile(filePath)).toEqual(expected);
|
||||
|
||||
const mutation = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: {
|
||||
url: `${baseUrl}/upload`,
|
||||
headers: { 'Content-Length': String(expected.length) },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
await uploadServeSimLogsFileAsync(
|
||||
{ graphqlClient: { mutation } } as unknown as CustomBuildContext,
|
||||
{
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'device-run-session-token' },
|
||||
expoApiV2BaseUrl: `${baseUrl}/v2/`,
|
||||
} as unknown as CustomBuildContext,
|
||||
{ deviceRunSessionId: 'session-test', udid: 'device-B', filePath, logger }
|
||||
);
|
||||
expect(mutation.mock.calls[0][1]).toEqual({
|
||||
deviceRunSessionId: 'session-test',
|
||||
input: {
|
||||
expect(uploadSessionRequest).toEqual({
|
||||
token: 'device-run-session-token',
|
||||
body: {
|
||||
name: 'App logs (device-B)',
|
||||
filename: 'app-logs.ndjson',
|
||||
kind: 'simulator-log',
|
||||
|
||||
@@ -88,4 +88,23 @@ describe(createStartupTasks, () => {
|
||||
createStartupTasks(createLoggerMock()).untilAborted(Promise.resolve('ok'))
|
||||
).resolves.toBe('ok');
|
||||
});
|
||||
it.each([false, true])(
|
||||
'inherits parent cancellation, already aborted=%s',
|
||||
async alreadyAborted => {
|
||||
const parent = new AbortController();
|
||||
const reason = new Error('cancelled');
|
||||
if (alreadyAborted) {
|
||||
parent.abort(reason);
|
||||
}
|
||||
const tasks = createStartupTasks(createLoggerMock(), parent.signal);
|
||||
const waiting = tasks.untilAborted(new Promise<void>(() => {}));
|
||||
if (!alreadyAborted) {
|
||||
parent.abort(reason);
|
||||
}
|
||||
await expect(waiting).rejects.toBe(reason);
|
||||
expect(tasks.signal.reason).toBe(reason);
|
||||
tasks.abort(new Error('later failure'));
|
||||
expect(tasks.signal.reason).toBe(reason);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -35,6 +35,7 @@ export async function runAgentAsync({
|
||||
pathEnv,
|
||||
npmCacheUrl,
|
||||
logger,
|
||||
addBuildLogMask,
|
||||
signal,
|
||||
}: {
|
||||
agentKind: AgentKind;
|
||||
@@ -47,11 +48,11 @@ export async function runAgentAsync({
|
||||
pathEnv: string;
|
||||
npmCacheUrl?: string;
|
||||
logger: bunyan;
|
||||
addBuildLogMask: (secret: string) => void;
|
||||
signal: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const leaseOptions = { expoApiV2BaseUrl, expoToken, agentRunId, signal };
|
||||
let credentials = await leaseAgentRunProviderCredentialsAsync(leaseOptions);
|
||||
const secrets = new Set<string>();
|
||||
|
||||
const runDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-agent-run-'));
|
||||
try {
|
||||
@@ -126,14 +127,13 @@ export async function runAgentAsync({
|
||||
}
|
||||
|
||||
for (const secret of invocation.secrets) {
|
||||
secrets.add(secret);
|
||||
addBuildLogMask(secret);
|
||||
}
|
||||
const { exitCode, exitSignal } = await runAgentProcessAsync({
|
||||
command: path.join(cliDirectory, 'node_modules', '.bin', bin),
|
||||
...invocation,
|
||||
cwd: workingDirectory,
|
||||
formatOutputLine,
|
||||
secrets: [...secrets],
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
@@ -170,7 +170,6 @@ async function runAgentProcessAsync({
|
||||
cwd,
|
||||
stdin,
|
||||
formatOutputLine,
|
||||
secrets,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
@@ -180,7 +179,6 @@ async function runAgentProcessAsync({
|
||||
cwd: string;
|
||||
stdin: string;
|
||||
formatOutputLine: AgentCli['formatOutputLine'];
|
||||
secrets: string[];
|
||||
logger: bunyan;
|
||||
signal: AbortSignal;
|
||||
}): Promise<{ exitCode: number | null; exitSignal: NodeJS.Signals | null }> {
|
||||
@@ -188,8 +186,6 @@ async function runAgentProcessAsync({
|
||||
// Detached, so the agent leads a process group that can be signaled as a whole.
|
||||
const child = spawn(command, args, { cwd, env, detached: true });
|
||||
const outputClosed = new Promise<void>(resolve => child.once('close', () => resolve()));
|
||||
const redact = (text: string): string =>
|
||||
secrets.reduce((masked, secret) => masked.replaceAll(secret, '[redacted]'), text);
|
||||
const stdoutLogger = logger.child({ source: 'stdout' });
|
||||
readline.createInterface({ input: child.stdout }).on('line', line => {
|
||||
let messages: AgentLogMessage[];
|
||||
@@ -201,12 +197,12 @@ async function runAgentProcessAsync({
|
||||
return;
|
||||
}
|
||||
for (const { level, message } of messages) {
|
||||
stdoutLogger[level](redact(message).trimEnd());
|
||||
stdoutLogger[level](message.trimEnd());
|
||||
}
|
||||
});
|
||||
const stderrLogger = logger.child({ source: 'stderr' });
|
||||
readline.createInterface({ input: child.stderr }).on('line', line => {
|
||||
stderrLogger.info(redact(line));
|
||||
stderrLogger.info(line);
|
||||
});
|
||||
// The agent may exit before it reads the prompt; its exit status reports that.
|
||||
child.stdin.on('error', () => {});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ErrorMetadata } from '@expo/eas-build-job';
|
||||
import { Response } from 'node-fetch';
|
||||
import { z } from 'zod';
|
||||
|
||||
const MAX_RESPONSE_BODY_LENGTH = 8_000;
|
||||
|
||||
export class GooglePlayApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly apiMessage: string,
|
||||
readonly reasons: string[],
|
||||
// Kept for Sentry only, so build logs stay short.
|
||||
readonly diagnostics: { requestPath?: string; responseBody?: string } = {}
|
||||
) {
|
||||
super(`Google Play request failed (HTTP ${status})${apiMessage ? `: ${apiMessage}` : '.'}`);
|
||||
}
|
||||
|
||||
static isRetryableStatus(status: number): boolean {
|
||||
return status === 408 || status === 429 || (status >= 500 && status < 600);
|
||||
}
|
||||
|
||||
// Error metadata for the first Google Play API error in the cause chain. Retries and
|
||||
// uncertain commits wrap the API error, e.g. in an AggregateError.
|
||||
static getErrorMetadata(error: unknown): ErrorMetadata | undefined {
|
||||
let cause = error;
|
||||
for (let depth = 0; cause instanceof Error && depth < 5; depth++) {
|
||||
if (cause instanceof GooglePlayApiError) {
|
||||
return {
|
||||
googlePlayRequestPath: cause.diagnostics.requestPath,
|
||||
// A string, so Sentry does not cut off nested error details.
|
||||
googlePlayErrorResponse: cause.diagnostics.responseBody,
|
||||
};
|
||||
}
|
||||
cause = cause.cause;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
static async fromResponseAsync(
|
||||
response: Response,
|
||||
signal?: AbortSignal
|
||||
): Promise<GooglePlayApiError> {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
signal?.throwIfAborted();
|
||||
// Raw text or HTML can contain the request URL, including an upload secret.
|
||||
}
|
||||
const parsed = z
|
||||
.object({
|
||||
error: z.object({
|
||||
message: z.string().optional(),
|
||||
errors: z.array(z.object({ reason: z.string().optional() })).optional(),
|
||||
}),
|
||||
})
|
||||
.safeParse(data);
|
||||
return new GooglePlayApiError(
|
||||
response.status,
|
||||
parsed.data?.error.message ?? '',
|
||||
parsed.data?.error.errors?.flatMap(error => (error.reason ? [error.reason] : [])) ?? [],
|
||||
{
|
||||
// Path only: the upload session query contains a secret.
|
||||
requestPath: response.url ? new URL(response.url).pathname : undefined,
|
||||
responseBody:
|
||||
data === undefined ? undefined : JSON.stringify(data).slice(0, MAX_RESPONSE_BODY_LENGTH),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { z } from 'zod';
|
||||
|
||||
import { promiseRetryWithCondition } from '../../../utils/promiseRetryWithCondition';
|
||||
|
||||
import { GooglePlayApiError } from './GooglePlayApiError';
|
||||
const PostApi = {
|
||||
'/token': {
|
||||
request: z.object({
|
||||
@@ -61,7 +62,7 @@ export class GooglePlayAuthClient {
|
||||
error =>
|
||||
!signal?.aborted &&
|
||||
error instanceof GooglePlayAuthRequestError &&
|
||||
(error.status === 429 || error.status >= 500),
|
||||
GooglePlayApiError.isRetryableStatus(error.status),
|
||||
{ retries: 3, factor: 2, minTimeout: 1000, maxTimeout: 4000 }
|
||||
)();
|
||||
}
|
||||
|
||||
@@ -1,45 +1,13 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import fetch, { RequestInit, Response } from 'node-fetch';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { promiseRetryWithCondition } from '../../../utils/promiseRetryWithCondition';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isConnectFailure, isConnectionInterruptedError } from '../../../utils/networkErrors';
|
||||
import { GooglePlayApiError } from './GooglePlayApiError';
|
||||
import { GooglePlayAuthUtils, GoogleServiceAccount } from './GooglePlayAuthUtils';
|
||||
|
||||
export class GooglePlayApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly apiMessage: string,
|
||||
readonly reasons: string[]
|
||||
) {
|
||||
super(`Google Play request failed (HTTP ${status})${apiMessage ? `: ${apiMessage}` : '.'}`);
|
||||
}
|
||||
|
||||
static async fromResponseAsync(
|
||||
response: Response,
|
||||
signal?: AbortSignal
|
||||
): Promise<GooglePlayApiError> {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
signal?.throwIfAborted();
|
||||
// Raw text or HTML can contain the request URL, including an upload secret.
|
||||
}
|
||||
const parsed = z
|
||||
.object({
|
||||
error: z.object({
|
||||
message: z.string().optional(),
|
||||
errors: z.array(z.object({ reason: z.string().optional() })).optional(),
|
||||
}),
|
||||
})
|
||||
.safeParse(data);
|
||||
return new GooglePlayApiError(
|
||||
response.status,
|
||||
parsed.data?.error.message ?? '',
|
||||
parsed.data?.error.errors?.flatMap(error => (error.reason ? [error.reason] : [])) ?? []
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const ReleaseStatusZ = z.enum(['draft', 'inProgress', 'halted', 'completed']);
|
||||
export type ReleaseStatus = z.output<typeof ReleaseStatusZ>;
|
||||
|
||||
@@ -88,7 +56,9 @@ const PostApi = {
|
||||
'/androidpublisher/v3/applications/:packageName/edits/:editId:commit': {
|
||||
path: z.object({ packageName: z.string().min(1), editId: z.string().min(1) }),
|
||||
request: z.object({}),
|
||||
query: z.object({ changesNotSentForReview: z.boolean().optional() }),
|
||||
query: z.object({
|
||||
changesNotSentForReview: z.boolean().optional(),
|
||||
}),
|
||||
response: z.object({ id: z.string().min(1), expiryTimeSeconds: z.string().optional() }),
|
||||
},
|
||||
'/androidpublisher/v3/applications/:packageName/edits/:editId:validate': {
|
||||
@@ -128,7 +98,10 @@ export class GooglePlayClient {
|
||||
private readonly baseUrl = 'https://androidpublisher.googleapis.com';
|
||||
private cachedToken?: Promise<{ value: string; expiresAt: number }>;
|
||||
|
||||
constructor(private readonly serviceAccount: GoogleServiceAccount) {}
|
||||
constructor(
|
||||
private readonly serviceAccount: GoogleServiceAccount,
|
||||
private readonly logger?: bunyan
|
||||
) {}
|
||||
|
||||
private async getTokenAsync(signal?: AbortSignal): Promise<string> {
|
||||
signal?.throwIfAborted();
|
||||
@@ -204,7 +177,21 @@ export class GooglePlayClient {
|
||||
for (const [key, value] of Object.entries(DeleteApi[path].path.parse(params))) {
|
||||
url = url.replace(`:${key}`, encodeURIComponent(value));
|
||||
}
|
||||
await this.requestAsync('DELETE', url, undefined, signal);
|
||||
try {
|
||||
await this.requestWithRetriesAsync(
|
||||
() => this.requestAsync('DELETE', url, undefined, signal),
|
||||
signal
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof AggregateError &&
|
||||
error.cause instanceof GooglePlayApiError &&
|
||||
error.cause.status === 404
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async sendJsonRequestAsync(
|
||||
@@ -236,12 +223,16 @@ export class GooglePlayClient {
|
||||
}
|
||||
}
|
||||
|
||||
const response = await this.requestAsync(
|
||||
method,
|
||||
search.size ? `${path}?${search}` : path,
|
||||
method === 'GET' ? undefined : JSON.stringify(parsedBody),
|
||||
signal,
|
||||
{ 'Content-Type': 'application/json' }
|
||||
const response = await this.requestWithRetriesAsync(
|
||||
() =>
|
||||
this.requestAsync(
|
||||
method,
|
||||
search.size ? `${path}?${search}` : path,
|
||||
method === 'GET' ? undefined : JSON.stringify(parsedBody),
|
||||
signal,
|
||||
{ 'Content-Type': 'application/json' }
|
||||
),
|
||||
signal
|
||||
);
|
||||
|
||||
let data: unknown;
|
||||
@@ -264,6 +255,47 @@ export class GooglePlayClient {
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
private async requestWithRetriesAsync<T>(
|
||||
request: () => Promise<T>,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
const errors: unknown[] = [];
|
||||
try {
|
||||
return await promiseRetryWithCondition(
|
||||
async () => {
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
return await request();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
error =>
|
||||
!signal?.aborted &&
|
||||
(error instanceof GooglePlayApiError
|
||||
? GooglePlayApiError.isRetryableStatus(error.status)
|
||||
: isConnectFailure(error) || isConnectionInterruptedError(error)),
|
||||
{ retries: 5, factor: 2, minTimeout: 1000, maxTimeout: 32_000, randomize: true },
|
||||
({ attemptNumber, maxAttemptsCount, error }) => {
|
||||
const reason =
|
||||
error instanceof GooglePlayApiError ? `HTTP ${error.status}` : 'network error';
|
||||
this.logger?.warn(
|
||||
`Retrying Google Play request (${reason}, attempt ${attemptNumber}/${maxAttemptsCount})...`
|
||||
);
|
||||
}
|
||||
)();
|
||||
} catch (error) {
|
||||
// Keep earlier failures: a later rejection does not prove that every attempt failed.
|
||||
if (errors.length > 1) {
|
||||
throw new AggregateError(errors, 'Google Play request failed after retries.', {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async requestAsync(
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||
path: string,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import fetch, { FetchError, Response } from 'node-fetch';
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
import promiseRetry from 'promise-retry';
|
||||
import { FileHandle } from 'node:fs/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { GooglePlayApiError, GooglePlayClient } from './GooglePlayClient';
|
||||
import { isConnectFailure, isConnectionInterruptedError } from '../../../utils/networkErrors';
|
||||
import { GooglePlayApiError } from './GooglePlayApiError';
|
||||
import { GooglePlayClient } from './GooglePlayClient';
|
||||
import { AndroidArtifactType } from './appArtifact';
|
||||
|
||||
const UploadResponseZ = z.object({
|
||||
@@ -53,8 +55,10 @@ export namespace GooglePlayResumableUpload {
|
||||
signal?.throwIfAborted();
|
||||
if (
|
||||
timeout.aborted ||
|
||||
isNetworkRetryable(error) ||
|
||||
(error instanceof GooglePlayApiError && (error.status === 429 || error.status >= 500))
|
||||
isConnectFailure(error) ||
|
||||
isConnectionInterruptedError(error) ||
|
||||
(error instanceof GooglePlayApiError &&
|
||||
GooglePlayApiError.isRetryableStatus(error.status))
|
||||
) {
|
||||
retry(
|
||||
timeout.aborted
|
||||
@@ -187,7 +191,7 @@ export namespace GooglePlayResumableUpload {
|
||||
const failure = new SystemError(
|
||||
'Google Play upload failed before a response was received.'
|
||||
);
|
||||
if (timeout.aborted || isNetworkRetryable(error)) {
|
||||
if (timeout.aborted || isConnectFailure(error) || isConnectionInterruptedError(error)) {
|
||||
checkServerOffset = true;
|
||||
retry(failure);
|
||||
}
|
||||
@@ -200,7 +204,7 @@ export namespace GooglePlayResumableUpload {
|
||||
checkServerOffset = true;
|
||||
return retry(new SystemError('Google Play upload response timed out.'));
|
||||
}
|
||||
if (response.status === 429 || response.status >= 500) {
|
||||
if (GooglePlayApiError.isRetryableStatus(response.status)) {
|
||||
checkServerOffset = true;
|
||||
retry(error);
|
||||
}
|
||||
@@ -276,15 +280,3 @@ function getContentTypeFromArtifactType(artifactType: AndroidArtifactType): stri
|
||||
? 'application/vnd.android.package-archive'
|
||||
: 'application/octet-stream';
|
||||
}
|
||||
|
||||
function isNetworkRetryable(error: unknown): boolean {
|
||||
const visited = new Set<Error>();
|
||||
while (error instanceof Error && !visited.has(error)) {
|
||||
if (error instanceof FetchError && error.type === 'system') {
|
||||
return true;
|
||||
}
|
||||
visited.add(error);
|
||||
error = error.cause;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -3,8 +3,11 @@ import { bunyan } from '@expo/logger';
|
||||
import fs from 'node:fs/promises';
|
||||
import promiseRetry from 'promise-retry';
|
||||
|
||||
import { promiseRetryWithCondition } from '../../../utils/promiseRetryWithCondition';
|
||||
|
||||
import { GooglePlayApiError } from './GooglePlayApiError';
|
||||
import { GooglePlayAuthRequestError } from './GooglePlayAuthClient';
|
||||
import { GooglePlayApiError, GooglePlayClient, ReleaseStatus } from './GooglePlayClient';
|
||||
import { GooglePlayClient, ReleaseStatus } from './GooglePlayClient';
|
||||
import { GooglePlayResumableUpload } from './GooglePlayResumableUpload';
|
||||
import { AndroidArtifactType } from './appArtifact';
|
||||
|
||||
@@ -141,7 +144,7 @@ export namespace GooglePlayUtils {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<void> {
|
||||
const commitOnceAsync = async ({
|
||||
const sendCommitAsync = async ({
|
||||
changesNotSentForReview,
|
||||
}: {
|
||||
changesNotSentForReview?: boolean;
|
||||
@@ -163,13 +166,48 @@ export namespace GooglePlayUtils {
|
||||
}
|
||||
if (
|
||||
!(error instanceof GooglePlayApiError) ||
|
||||
error.status >= 500 ||
|
||||
error.status === 408 ||
|
||||
error.status === 429
|
||||
GooglePlayApiError.isRetryableStatus(error.status)
|
||||
) {
|
||||
throw new GooglePlayCommitOutcomeUnknownError(
|
||||
'Google Play commit outcome is unknown. Check the release in Play Console before you submit again. The commit was not retried.',
|
||||
{ cause: error }
|
||||
`Could not confirm whether Google Play saved the release for ${packageName}. Check its release status at https://play.google.com/console/ before submitting again.`,
|
||||
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
logger.info(
|
||||
changesNotSentForReview
|
||||
? 'Google Play saved the changes without sending them for review. Send them for review at https://play.google.com/console/.'
|
||||
: 'Google Play saved the release using its normal review process. Check its review and publishing status at https://play.google.com/console/.'
|
||||
);
|
||||
};
|
||||
// Google can accept the bundle upload before the edit is ready to commit.
|
||||
// Retry only this explicit rejection, not other HTTP 400 preconditions.
|
||||
const isUploadStillProcessing = (error: unknown): boolean =>
|
||||
error instanceof GooglePlayApiError &&
|
||||
error.status === 400 &&
|
||||
error.apiMessage === 'Some of the Android App Bundle uploads are not completed yet.';
|
||||
|
||||
const commitAsync = async (options: { changesNotSentForReview?: boolean }): Promise<void> => {
|
||||
try {
|
||||
await promiseRetryWithCondition(
|
||||
sendCommitAsync,
|
||||
error => {
|
||||
signal?.throwIfAborted();
|
||||
return isUploadStillProcessing(error);
|
||||
},
|
||||
{ retries: 5, factor: 2, minTimeout: 2000, maxTimeout: 32_000 },
|
||||
({ attemptNumber, maxAttemptsCount }) => {
|
||||
logger.warn(
|
||||
`Google Play is still processing the uploaded bundle. Retrying the same edit commit (attempt ${attemptNumber}/${maxAttemptsCount}).`
|
||||
);
|
||||
}
|
||||
)(options);
|
||||
} catch (error) {
|
||||
if (isUploadStillProcessing(error)) {
|
||||
throw new SystemError(
|
||||
`Google Play did not finish processing the uploaded bundle for ${packageName} after six commit attempts. Wait a few minutes, then check the release at https://play.google.com/console/ before submitting again.`,
|
||||
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
@@ -178,7 +216,7 @@ export namespace GooglePlayUtils {
|
||||
try {
|
||||
// Omit the optional parameter for normal review submission. Some app states
|
||||
// reject its presence, even when false. True saves changes for review in Console.
|
||||
await commitOnceAsync({ changesNotSentForReview: changesNotSentForReview || undefined });
|
||||
await commitAsync({ changesNotSentForReview: changesNotSentForReview || undefined });
|
||||
} catch (error) {
|
||||
if (!(error instanceof GooglePlayApiError) || error.status !== 400) {
|
||||
throw error;
|
||||
@@ -191,7 +229,7 @@ export namespace GooglePlayUtils {
|
||||
logger.warn(
|
||||
'Google does not allow changesNotSentForReview for this app right now. Retrying commit once without it. The changes will be sent for review.'
|
||||
);
|
||||
await commitOnceAsync({});
|
||||
await commitAsync({});
|
||||
} else if (
|
||||
!changesNotSentForReview &&
|
||||
error.apiMessage.includes('Please set the query parameter changesNotSentForReview to true')
|
||||
@@ -199,29 +237,31 @@ export namespace GooglePlayUtils {
|
||||
logger.warn(
|
||||
'Google requires changesNotSentForReview=true. Retrying commit once. Send the saved changes for review from Play Console.'
|
||||
);
|
||||
await commitOnceAsync({ changesNotSentForReview: true });
|
||||
await commitAsync({ changesNotSentForReview: true });
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function mapGooglePlayError(error: unknown): unknown {
|
||||
export function mapGooglePlayError(error: unknown, packageName: string): unknown {
|
||||
if (error instanceof AggregateError) {
|
||||
return mapGooglePlayError(error.cause, packageName);
|
||||
}
|
||||
if (
|
||||
(error instanceof GooglePlayApiError || error instanceof GooglePlayAuthRequestError) &&
|
||||
(error.status === 408 || error.status === 429 || error.status >= 500)
|
||||
GooglePlayApiError.isRetryableStatus(error.status)
|
||||
) {
|
||||
return new SystemError(
|
||||
`Google Play is temporarily unavailable or rate limited (HTTP ${error.status}). Try again later.`,
|
||||
{ cause: error }
|
||||
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
|
||||
);
|
||||
}
|
||||
if (
|
||||
error instanceof GooglePlayAuthRequestError &&
|
||||
error.status >= 400 &&
|
||||
error.status < 500 &&
|
||||
error.status !== 408 &&
|
||||
error.status !== 429
|
||||
!GooglePlayApiError.isRetryableStatus(error.status)
|
||||
) {
|
||||
return new UserError(
|
||||
'EAS_GOOGLE_PLAY_INVALID_CREDENTIALS',
|
||||
@@ -235,7 +275,7 @@ export namespace GooglePlayUtils {
|
||||
const message = error.apiMessage;
|
||||
const reasons = new Set(error.reasons);
|
||||
let code = 'EAS_GOOGLE_PLAY_REQUEST_FAILED';
|
||||
let detail = `Google Play rejected the request (HTTP ${error.status}; reasons: ${error.reasons.join(', ') || 'unspecified'}). Check the app and release settings in Play Console. Concurrent edits or Console changes can invalidate an edit.`;
|
||||
let detail = `Google Play rejected the request (HTTP ${error.status})${message ? `: ${message}` : '.'} Check the app settings before submitting again.`;
|
||||
if (message.includes('Package not found')) {
|
||||
code = 'EAS_GOOGLE_PLAY_FIRST_UPLOAD';
|
||||
detail =
|
||||
@@ -247,7 +287,7 @@ export namespace GooglePlayUtils {
|
||||
) {
|
||||
code = 'EAS_GOOGLE_PLAY_VERSION_CODE_USED';
|
||||
detail =
|
||||
'This version code was already uploaded. Increase the Android version code and build a new binary.';
|
||||
'This version code was already uploaded. Check whether the intended release already exists. To upload a new binary, increase the Android version code and build again.';
|
||||
} else if (
|
||||
/privacy policy/i.test(message) ||
|
||||
reasons.has('apkNotificationMessageKeyPermissionsRequirePrivacyPolicy') ||
|
||||
@@ -255,10 +295,13 @@ export namespace GooglePlayUtils {
|
||||
) {
|
||||
code = 'EAS_GOOGLE_PLAY_PRIVACY_POLICY_REQUIRED';
|
||||
detail = 'This app requires a privacy policy. Add it in Play Console before you submit.';
|
||||
} else if (message.includes('Only releases with status draft may be created on draft app')) {
|
||||
} else if (
|
||||
message.includes('Only releases with status draft may be created on draft app') ||
|
||||
message.includes('Staged rollout is not permitted on draft app')
|
||||
) {
|
||||
code = 'EAS_GOOGLE_PLAY_DRAFT_APP';
|
||||
detail =
|
||||
'This app is a draft in Play Console. Complete its required metadata or submit with release_status: draft.';
|
||||
'This app is still a draft in Play Console. Submit a draft release without a rollout, or complete the app setup in Play Console before submitting a release to testers. Use releaseStatus: "draft" in eas.json, or release_status: "draft" in a workflow.';
|
||||
} else if (error.status === 409 || /edit.*(?:expired|invalid|not found)/i.test(message)) {
|
||||
code = 'EAS_GOOGLE_PLAY_EDIT_INVALID';
|
||||
detail =
|
||||
@@ -269,11 +312,12 @@ export namespace GooglePlayUtils {
|
||||
'The Google service account cannot submit this app. Check its key and app permissions in Play Console.';
|
||||
} else if (error.status === 400) {
|
||||
code = 'EAS_GOOGLE_PLAY_INVALID_RELEASE';
|
||||
detail =
|
||||
'Google Play rejected the release or review settings. Check the track, release status, rollout, and review settings in Play Console.';
|
||||
detail = `Google Play rejected the release or review settings${message ? `: ${message}` : '.'} Check the track, release status, rollout, and review settings in Play Console.`;
|
||||
}
|
||||
return new UserError(code, message ? `${detail} Google Play said: ${message}` : detail, {
|
||||
cause: error,
|
||||
});
|
||||
return new UserError(
|
||||
code,
|
||||
`${detail}\n\nApp: ${packageName}\nGoogle Play Console: https://play.google.com/console/`,
|
||||
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import nock from 'nock';
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
import { GooglePlayClient } from '../GooglePlayClient';
|
||||
import { createMockLogger } from '../../../../__tests__/utils/logger';
|
||||
|
||||
const packageName = 'dev.expo.submitfixture';
|
||||
const editPath = `/androidpublisher/v3/applications/${packageName}/edits/edit`;
|
||||
@@ -13,6 +13,11 @@ const api = (): nock.Scope =>
|
||||
});
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
jest.mock('promise-retry', () => {
|
||||
const actual = jest.requireActual('promise-retry');
|
||||
return (fn: Parameters<typeof actual>[0], options: object) =>
|
||||
actual(fn, { ...options, minTimeout: 1, maxTimeout: 1, randomize: false });
|
||||
});
|
||||
|
||||
const serviceAccount = {
|
||||
client_email: '[email protected]',
|
||||
@@ -75,11 +80,12 @@ it('rejects invalid rollout requests before sending them', async () => {
|
||||
).rejects.toBeInstanceOf(ZodError);
|
||||
});
|
||||
|
||||
it('does not retry an ambiguous edit commit', async () => {
|
||||
it('keeps the outcome unknown when a lost commit response is followed by an invalid edit', async () => {
|
||||
api()
|
||||
.post(`${editPath}:commit`, {})
|
||||
.query({ changesNotSentForReview: false })
|
||||
.replyWithError('Lost response');
|
||||
.replyWithError(Object.assign(new Error('Lost response'), { code: 'ECONNRESET' }));
|
||||
api().post(`${editPath}:commit`, {}).query({ changesNotSentForReview: false }).reply(404);
|
||||
await expect(
|
||||
client.postAsync(
|
||||
'/androidpublisher/v3/applications/:packageName/edits/:editId:commit',
|
||||
@@ -87,7 +93,62 @@ it('does not retry an ambiguous edit commit', async () => {
|
||||
{ packageName, editId: 'edit' },
|
||||
{ query: { changesNotSentForReview: false } }
|
||||
)
|
||||
).rejects.toBeInstanceOf(SystemError);
|
||||
).rejects.toMatchObject({
|
||||
errors: [expect.any(Error), expect.objectContaining({ status: 404 })],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([408, 429, 500, 503])('stops after five retries for HTTP %s', async status => {
|
||||
api().get(editPath).times(6).reply(status);
|
||||
await expect(
|
||||
client.getAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
|
||||
packageName,
|
||||
editId: 'edit',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
cause: expect.objectContaining({ status }),
|
||||
errors: expect.arrayContaining([expect.objectContaining({ status })]),
|
||||
});
|
||||
});
|
||||
|
||||
it('retries the same track update body after a temporary error', async () => {
|
||||
const body = {
|
||||
track: 'internal',
|
||||
releases: [{ versionCodes: ['123'], status: 'draft' as const }],
|
||||
};
|
||||
api().put(`${editPath}/tracks/internal`, body).reply(503);
|
||||
api().put(`${editPath}/tracks/internal`, body).reply(200, { track: 'internal' });
|
||||
await expect(
|
||||
client.putAsync(
|
||||
'/androidpublisher/v3/applications/:packageName/edits/:editId/tracks/:track',
|
||||
body,
|
||||
{ packageName, editId: 'edit', track: 'internal' }
|
||||
)
|
||||
).resolves.toEqual({ track: 'internal' });
|
||||
});
|
||||
|
||||
it('does not add retries to upload-session requests', async () => {
|
||||
api().post('/upload/session').reply(503);
|
||||
await expect(client.requestAsync('POST', '/upload/session')).rejects.toMatchObject({
|
||||
status: 503,
|
||||
});
|
||||
});
|
||||
|
||||
it('stops retrying when cancelled after a transient response', async () => {
|
||||
const controller = new AbortController();
|
||||
api()
|
||||
.get(editPath)
|
||||
.reply(() => {
|
||||
controller.abort();
|
||||
return [503];
|
||||
});
|
||||
await expect(
|
||||
client.getAsync(
|
||||
'/androidpublisher/v3/applications/:packageName/edits/:editId',
|
||||
{ packageName, editId: 'edit' },
|
||||
controller.signal
|
||||
)
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it('deletes an edit with an empty response', async () => {
|
||||
@@ -100,6 +161,44 @@ it('deletes an edit with an empty response', async () => {
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('reports a retry even when the next request succeeds', async () => {
|
||||
const logger = createMockLogger();
|
||||
client = new GooglePlayClient(serviceAccount, logger);
|
||||
api().get(editPath).reply(503);
|
||||
api().get(editPath).reply(200, { id: 'edit' });
|
||||
await client.getAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
|
||||
packageName,
|
||||
editId: 'edit',
|
||||
});
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Retrying Google Play request (HTTP 503, attempt 2/6)...'
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a missing edit after a lost delete response', async () => {
|
||||
api()
|
||||
.delete(editPath)
|
||||
.replyWithError(Object.assign(new Error('Lost response'), { code: 'ECONNRESET' }));
|
||||
api().delete(editPath).reply(404);
|
||||
await expect(
|
||||
client.deleteAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
|
||||
packageName,
|
||||
editId: 'edit',
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('retains an initial delete rejection', async () => {
|
||||
api().delete(editPath).reply(404);
|
||||
await expect(
|
||||
client.deleteAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
|
||||
packageName,
|
||||
editId: 'edit',
|
||||
})
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it('rejects malformed success responses without exposing their contents', async () => {
|
||||
api()
|
||||
.get(editPath)
|
||||
@@ -124,6 +223,42 @@ it('retains structured API errors', async () => {
|
||||
).rejects.toMatchObject({ status: 403, apiMessage: 'Denied', reasons: ['forbidden'] });
|
||||
});
|
||||
|
||||
it.each(['CERT_HAS_EXPIRED', 'EPROTO', 'EACCES', undefined])(
|
||||
'does not retry a system error with code %s',
|
||||
async code => {
|
||||
const logger = createMockLogger();
|
||||
client = new GooglePlayClient(serviceAccount, logger);
|
||||
api()
|
||||
.get(editPath)
|
||||
.replyWithError(Object.assign(new Error('Request failed'), { code }));
|
||||
await expect(
|
||||
client.getAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
|
||||
packageName,
|
||||
editId: 'edit',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
cause: expect.objectContaining({ type: 'system' }),
|
||||
});
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['EAI_AGAIN', 'ECONNREFUSED', 'ECONNRESET', 'UND_ERR_SOCKET'])(
|
||||
'retries a recognised network error %s',
|
||||
async code => {
|
||||
api()
|
||||
.get(editPath)
|
||||
.replyWithError(Object.assign(new Error('Request failed'), { code }));
|
||||
api().get(editPath).reply(200, { id: 'edit' });
|
||||
await expect(
|
||||
client.getAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
|
||||
packageName,
|
||||
editId: 'edit',
|
||||
})
|
||||
).resolves.toEqual({ id: 'edit' });
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['body', 'query'])('accepts extra fields in an empty %s object', async field => {
|
||||
api()
|
||||
.post(`/androidpublisher/v3/applications/${packageName}/edits`, {})
|
||||
|
||||
+160
-11
@@ -1,13 +1,20 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import nock from 'nock';
|
||||
import { Response } from 'node-fetch';
|
||||
|
||||
import { api, editPath, mockToken, packageName, serviceAccount } from './googlePlayTestUtils';
|
||||
import { createMockLogger } from '../../../../__tests__/utils/logger';
|
||||
import { GooglePlayAuthRequestError } from '../GooglePlayAuthClient';
|
||||
import { GooglePlayApiError, GooglePlayClient } from '../GooglePlayClient';
|
||||
import { GooglePlayUtils } from '../GooglePlayUtils';
|
||||
import { GooglePlayApiError } from '../GooglePlayApiError';
|
||||
import { GooglePlayClient } from '../GooglePlayClient';
|
||||
import { GooglePlayCommitOutcomeUnknownError, GooglePlayUtils } from '../GooglePlayUtils';
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
jest.mock('promise-retry', () => {
|
||||
const actual = jest.requireActual('promise-retry');
|
||||
return (fn: Parameters<typeof actual>[0], options: object) =>
|
||||
actual(fn, { ...options, minTimeout: 1, maxTimeout: 1, randomize: false });
|
||||
});
|
||||
beforeEach(() => nock.disableNetConnect());
|
||||
afterEach(() => {
|
||||
const pending = nock.pendingMocks();
|
||||
@@ -18,15 +25,17 @@ afterEach(() => {
|
||||
function commit({
|
||||
signal,
|
||||
changesNotSentForReview = false,
|
||||
logger = createMockLogger(),
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
changesNotSentForReview?: boolean;
|
||||
logger?: ReturnType<typeof createMockLogger>;
|
||||
} = {}) {
|
||||
return GooglePlayUtils.commitEditAsync(new GooglePlayClient(serviceAccount), {
|
||||
packageName,
|
||||
editId: 'edit',
|
||||
changesNotSentForReview,
|
||||
logger: createMockLogger(),
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
@@ -57,16 +66,23 @@ it.each([
|
||||
] as const)(
|
||||
'retries the rejected review setting (requested=%s)',
|
||||
async (changesNotSentForReview, initialQuery, message, retryQuery) => {
|
||||
const logger = createMockLogger();
|
||||
mockToken();
|
||||
api().post(`${editPath}:commit`).query(initialQuery).reply(400, { error: { message } });
|
||||
api().post(`${editPath}:commit`).query(retryQuery).reply(200, { id: 'edit' });
|
||||
await expect(commit({ changesNotSentForReview })).resolves.toBeUndefined();
|
||||
await expect(commit({ changesNotSentForReview, logger })).resolves.toBeUndefined();
|
||||
expect(logger.info).toHaveBeenLastCalledWith(
|
||||
changesNotSentForReview
|
||||
? 'Google Play saved the release using its normal review process. Check its review and publishing status at https://play.google.com/console/.'
|
||||
: 'Google Play saved the changes without sending them for review. Send them for review at https://play.google.com/console/.'
|
||||
);
|
||||
}
|
||||
);
|
||||
it.each([408, 429, 503])('does not retry an uncertain HTTP %s commit', async status => {
|
||||
it.each([408, 429, 503])('retries a transient HTTP %s commit on the same edit', async status => {
|
||||
mockToken();
|
||||
api().post(`${editPath}:commit`).query(true).reply(status);
|
||||
await expect(commit()).rejects.toMatchObject({ cause: expect.objectContaining({ status }) });
|
||||
api().post(`${editPath}:commit`).query({}).reply(200, { id: 'edit' });
|
||||
await expect(commit()).resolves.toBeUndefined();
|
||||
});
|
||||
it('preserves cancellation before commit is sent', async () => {
|
||||
const controller = new AbortController();
|
||||
@@ -74,6 +90,74 @@ it('preserves cancellation before commit is sent', async () => {
|
||||
controller.abort(reason);
|
||||
await expect(commit({ signal: controller.signal })).rejects.toBe(reason);
|
||||
});
|
||||
const pendingUploadMessage = 'Some of the Android App Bundle uploads are not completed yet.';
|
||||
|
||||
it.each([false, true])(
|
||||
'waits for bundle processing without changing review settings (%s)',
|
||||
async changesNotSentForReview => {
|
||||
const logger = createMockLogger();
|
||||
const query = changesNotSentForReview ? { changesNotSentForReview: 'true' } : {};
|
||||
mockToken();
|
||||
api()
|
||||
.post(`${editPath}:commit`, {})
|
||||
.query(query)
|
||||
.reply(400, { error: { message: pendingUploadMessage } });
|
||||
api().post(`${editPath}:commit`, {}).query(query).reply(200, { id: 'edit' });
|
||||
await expect(commit({ changesNotSentForReview, logger })).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Google Play is still processing')
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('stops waiting for bundle processing after six attempts', async () => {
|
||||
mockToken();
|
||||
api()
|
||||
.post(`${editPath}:commit`)
|
||||
.query({})
|
||||
.times(6)
|
||||
.reply(400, { error: { message: pendingUploadMessage } });
|
||||
const error = await commit().catch(error => error);
|
||||
expect(GooglePlayUtils.mapGooglePlayError(error, packageName)).toMatchObject({
|
||||
message: expect.stringContaining('Google Play did not finish processing'),
|
||||
metadata: {
|
||||
googlePlayRequestPath: `${editPath}:commit`,
|
||||
googlePlayErrorResponse: JSON.stringify({ error: { message: pendingUploadMessage } }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not retry an unrelated commit precondition', async () => {
|
||||
mockToken();
|
||||
api()
|
||||
.post(`${editPath}:commit`)
|
||||
.query({})
|
||||
.reply(400, { error: { message: 'Precondition check failed.' } });
|
||||
await expect(commit()).rejects.toBeInstanceOf(GooglePlayApiError);
|
||||
});
|
||||
|
||||
it('checks cancellation before sending a processing retry', async () => {
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('cancelled while waiting');
|
||||
const logger = createMockLogger();
|
||||
jest.spyOn(logger, 'warn').mockImplementation(() => controller.abort(reason));
|
||||
mockToken();
|
||||
api()
|
||||
.post(`${editPath}:commit`)
|
||||
.query({})
|
||||
.reply(400, { error: { message: pendingUploadMessage } });
|
||||
await expect(commit({ signal: controller.signal, logger })).rejects.toBe(reason);
|
||||
});
|
||||
|
||||
it('does not report success if a processing retry has no valid commit receipt', async () => {
|
||||
mockToken();
|
||||
api()
|
||||
.post(`${editPath}:commit`)
|
||||
.query({})
|
||||
.reply(400, { error: { message: pendingUploadMessage } });
|
||||
api().post(`${editPath}:commit`).query({}).reply(200, { id: 'different-edit' });
|
||||
await expect(commit()).rejects.toBeInstanceOf(GooglePlayCommitOutcomeUnknownError);
|
||||
});
|
||||
it('preserves OAuth rejection before commit is sent', async () => {
|
||||
nock('https://oauth2.googleapis.com').post('/token').reply(400, { error: 'invalid_grant' });
|
||||
await expect(commit()).rejects.toBeInstanceOf(GooglePlayAuthRequestError);
|
||||
@@ -86,21 +170,33 @@ it.each([
|
||||
[403, 'wrong signing key', 'EAS_GOOGLE_PLAY_REQUEST_FAILED'],
|
||||
[400, 'privacy policy required', 'EAS_GOOGLE_PLAY_PRIVACY_POLICY_REQUIRED'],
|
||||
[400, 'Only releases with status draft may be created on draft app', 'EAS_GOOGLE_PLAY_DRAFT_APP'],
|
||||
[400, 'Staged rollout is not permitted on draft app', 'EAS_GOOGLE_PLAY_DRAFT_APP'],
|
||||
[409, 'edit invalid', 'EAS_GOOGLE_PLAY_EDIT_INVALID'],
|
||||
[400, 'unknown track', 'EAS_GOOGLE_PLAY_INVALID_RELEASE'],
|
||||
] as const)('keeps the Google error behind %s %s', (status, message, code) => {
|
||||
const original = new GooglePlayApiError(status, message, []);
|
||||
const mapped = GooglePlayUtils.mapGooglePlayError(original);
|
||||
const mapped = GooglePlayUtils.mapGooglePlayError(original, packageName);
|
||||
expect(mapped).toBeInstanceOf(UserError);
|
||||
expect(mapped).toMatchObject({
|
||||
errorCode: code,
|
||||
message: expect.stringContaining(`Google Play said: ${message}`),
|
||||
cause: original,
|
||||
});
|
||||
if (code === 'EAS_GOOGLE_PLAY_REQUEST_FAILED' || code === 'EAS_GOOGLE_PLAY_INVALID_RELEASE') {
|
||||
expect((mapped as Error).message).toContain(message);
|
||||
} else {
|
||||
expect((mapped as Error).message).not.toContain(message);
|
||||
}
|
||||
expect((mapped as Error).message).toContain(`App: ${packageName}`);
|
||||
expect((mapped as Error).message).toContain('https://play.google.com/console/');
|
||||
expect((mapped as Error).message).not.toContain('Google Play error:');
|
||||
if (code === 'EAS_GOOGLE_PLAY_DRAFT_APP') {
|
||||
expect((mapped as Error).message).toContain('without a rollout');
|
||||
expect((mapped as Error).message).toContain('releaseStatus: "draft" in eas.json');
|
||||
}
|
||||
});
|
||||
it('maps a rejected OAuth key to a credential error', () => {
|
||||
const original = new GooglePlayAuthRequestError(400, 'invalid_grant');
|
||||
expect(GooglePlayUtils.mapGooglePlayError(original)).toMatchObject({
|
||||
expect(GooglePlayUtils.mapGooglePlayError(original, packageName)).toMatchObject({
|
||||
errorCode: 'EAS_GOOGLE_PLAY_INVALID_CREDENTIALS',
|
||||
cause: original,
|
||||
});
|
||||
@@ -110,7 +206,7 @@ it.each([408, 429, 500, 503])('classifies temporary HTTP %s errors as system err
|
||||
new GooglePlayApiError(status, 'temporary failure', []),
|
||||
new GooglePlayAuthRequestError(status, 'temporarily_unavailable'),
|
||||
]) {
|
||||
const mapped = GooglePlayUtils.mapGooglePlayError(original);
|
||||
const mapped = GooglePlayUtils.mapGooglePlayError(original, packageName);
|
||||
expect(mapped).toBeInstanceOf(SystemError);
|
||||
expect(mapped).toMatchObject({ cause: original });
|
||||
expect((mapped as Error).message).not.toContain('settings');
|
||||
@@ -118,5 +214,58 @@ it.each([408, 429, 500, 503])('classifies temporary HTTP %s errors as system err
|
||||
});
|
||||
it('keeps non-Google errors unchanged', () => {
|
||||
const error = new SystemError('file failure');
|
||||
expect(GooglePlayUtils.mapGooglePlayError(error)).toBe(error);
|
||||
expect(GooglePlayUtils.mapGooglePlayError(error, packageName)).toBe(error);
|
||||
});
|
||||
it('keeps the Google Play error response in metadata, not in the message', async () => {
|
||||
const response = {
|
||||
error: {
|
||||
code: 400,
|
||||
message: 'Precondition check failed.',
|
||||
status: 'FAILED_PRECONDITION',
|
||||
details: [
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.PreconditionFailure',
|
||||
violations: [{ type: 'APP_SETUP', description: 'Complete the app setup.' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
mockToken();
|
||||
api().post(`${editPath}:commit`).query({}).reply(400, response);
|
||||
const error = await commit().catch(error => error);
|
||||
const mapped = GooglePlayUtils.mapGooglePlayError(error, packageName);
|
||||
expect(mapped).toMatchObject({
|
||||
errorCode: 'EAS_GOOGLE_PLAY_INVALID_RELEASE',
|
||||
metadata: {
|
||||
googlePlayRequestPath: `${editPath}:commit`,
|
||||
googlePlayErrorResponse: JSON.stringify(response),
|
||||
},
|
||||
});
|
||||
expect((mapped as Error).message).not.toContain('APP_SETUP');
|
||||
});
|
||||
it('keeps the last Google Play error response when the commit outcome is unknown', async () => {
|
||||
mockToken();
|
||||
api()
|
||||
.post(`${editPath}:commit`)
|
||||
.query({})
|
||||
.times(6)
|
||||
.reply(503, { error: { message: 'Backend error' } });
|
||||
await expect(commit()).rejects.toMatchObject({
|
||||
metadata: {
|
||||
googlePlayRequestPath: `${editPath}:commit`,
|
||||
googlePlayErrorResponse: JSON.stringify({ error: { message: 'Backend error' } }),
|
||||
},
|
||||
});
|
||||
});
|
||||
it('does not keep the upload session query or a non-JSON response', async () => {
|
||||
const error = await GooglePlayApiError.fromResponseAsync(
|
||||
new Response('<html>upload_id=SECRET</html>', {
|
||||
status: 400,
|
||||
url: 'https://androidpublisher.googleapis.com/upload/session?upload_id=SECRET',
|
||||
})
|
||||
);
|
||||
expect(GooglePlayApiError.getErrorMetadata(error)).toEqual({
|
||||
googlePlayRequestPath: '/upload/session',
|
||||
googlePlayErrorResponse: undefined,
|
||||
});
|
||||
});
|
||||
@@ -89,14 +89,16 @@ it('uploads aligned chunks and reports server-confirmed progress', async () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['network', 429, 503])(
|
||||
it.each(['network', 408, 429, 503])(
|
||||
'recovers the accepted offset after a %s interruption',
|
||||
async failure => {
|
||||
mockToken();
|
||||
mockStart();
|
||||
const interrupted = uploadApi().put(session).matchHeader('content-range', 'bytes 0-11/12');
|
||||
if (failure === 'network') {
|
||||
interrupted.replyWithError('connection reset');
|
||||
interrupted.replyWithError(
|
||||
Object.assign(new Error('connection reset'), { code: 'ECONNRESET' })
|
||||
);
|
||||
} else {
|
||||
interrupted.reply(failure as number, { error: { message: 'retry' } });
|
||||
}
|
||||
@@ -121,7 +123,7 @@ it('recovers a completed upload when the final upload response is lost', async (
|
||||
uploadApi()
|
||||
.put(session)
|
||||
.matchHeader('content-length', '12')
|
||||
.replyWithError('lost final response');
|
||||
.replyWithError(Object.assign(new Error('lost final response'), { code: 'ECONNRESET' }));
|
||||
status().reply(200, { versionCode: 42 });
|
||||
await expect(upload()).resolves.toBe(42);
|
||||
});
|
||||
@@ -286,7 +288,7 @@ it('retries a network failure during session creation', async () => {
|
||||
api()
|
||||
.post(`/upload${editPath}/apks`)
|
||||
.query({ uploadType: 'resumable' })
|
||||
.replyWithError('connection reset');
|
||||
.replyWithError(Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }));
|
||||
mockStart();
|
||||
uploadApi().put(session).reply(200, { versionCode: 42 });
|
||||
await expect(upload()).resolves.toBe(42);
|
||||
@@ -347,7 +349,9 @@ it.each([
|
||||
mockStart();
|
||||
const finalChunk = uploadApi().put(session).matchHeader('content-length', '12');
|
||||
if (finalResponse === 'lost') {
|
||||
finalChunk.replyWithError('lost final response');
|
||||
finalChunk.replyWithError(
|
||||
Object.assign(new Error('lost final response'), { code: 'ECONNRESET' })
|
||||
);
|
||||
} else {
|
||||
finalChunk.reply(308, '', { Range: 'bytes=0-11' });
|
||||
}
|
||||
|
||||
@@ -1,31 +1,15 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { graphql } from 'gql.tada';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import {
|
||||
ArtifactPutError,
|
||||
ArtifactPutNetworkError,
|
||||
type ArtifactSource,
|
||||
type ArtifactUploadSession,
|
||||
putArtifactWithRetriesAsync,
|
||||
} from './artifactPut';
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
|
||||
const CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
|
||||
mutation CreateDeviceRunSessionArtifactUploadSession(
|
||||
$deviceRunSessionId: ID!
|
||||
$input: CreateDeviceRunSessionArtifactUploadSessionInput!
|
||||
) {
|
||||
deviceRunSession {
|
||||
createArtifactUploadSession(deviceRunSessionId: $deviceRunSessionId, input: $input) {
|
||||
uploadSession {
|
||||
url
|
||||
headers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
|
||||
|
||||
type ArtifactMetadata = {
|
||||
deviceRunSessionId: string;
|
||||
@@ -69,29 +53,29 @@ async function createDeviceRunSessionArtifactUploadSessionAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{ deviceRunSessionId, artifactId, name, filename, kind, metadata, size }: ArtifactMetadata,
|
||||
signal: AbortSignal
|
||||
) {
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(
|
||||
CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION,
|
||||
): Promise<ArtifactUploadSession> {
|
||||
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
|
||||
let uploadSession: ArtifactUploadSession;
|
||||
try {
|
||||
uploadSession = await workerApi.createArtifactUploadSessionAsync(
|
||||
{
|
||||
deviceRunSessionId,
|
||||
input: {
|
||||
name,
|
||||
filename,
|
||||
...(kind !== undefined ? { kind } : {}),
|
||||
...(metadata !== undefined ? { metadata } : {}),
|
||||
size,
|
||||
},
|
||||
name,
|
||||
filename,
|
||||
...(kind !== undefined ? { kind } : {}),
|
||||
...(metadata !== undefined ? { metadata } : {}),
|
||||
size,
|
||||
},
|
||||
graphqlAbortContext(signal)
|
||||
)
|
||||
.toPromise();
|
||||
signal.throwIfAborted();
|
||||
if (result.error) {
|
||||
{ signal }
|
||||
);
|
||||
} catch (err) {
|
||||
signal.throwIfAborted();
|
||||
throw new SystemError(
|
||||
`Failed to create upload session for device run session artifact ${artifactId}: ${result.error.message}`,
|
||||
{ cause: result.error }
|
||||
`Failed to create upload session for device run session artifact ${artifactId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
return result.data!.deviceRunSession.createArtifactUploadSession.uploadSession;
|
||||
signal.throwIfAborted();
|
||||
return uploadSession;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { graphql } from 'gql.tada';
|
||||
import fs from 'node:fs';
|
||||
import { StringDecoder } from 'node:string_decoder';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
@@ -10,25 +9,13 @@ import HttpLogStream from '../../logging/HttpLogStream';
|
||||
import RemoteLoggerStream from '../../logging/RemoteLoggerStream';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { type SignedUrl } from '../../storage/uploadWithSignedUrl';
|
||||
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
|
||||
|
||||
const POLL_INTERVAL_MS = 1_000;
|
||||
const UPLOAD_INTERVAL_MS = 5_000;
|
||||
const EAS_LOGS_THREAD = 'session-events';
|
||||
const EAS_LOGS_BUFFER_RETENTION_MS = 30_000;
|
||||
|
||||
const CREATE_DEVICE_RUN_SESSION_EVENT_LOG_UPLOAD_SESSION_MUTATION = graphql(`
|
||||
mutation CreateDeviceRunSessionEventLogUploadSession($deviceRunSessionId: ID!) {
|
||||
deviceRunSession {
|
||||
createEventLogUploadSession(deviceRunSessionId: $deviceRunSessionId) {
|
||||
uploadSession {
|
||||
url
|
||||
headers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
/**
|
||||
* The normalized, producer-agnostic event shape uploaded to the API server.
|
||||
* Every remote-session producer (agent-device, argent, ...) tails its own
|
||||
@@ -349,22 +336,17 @@ async function createEventLogUploadSessionAsync(
|
||||
ctx: CustomBuildContext,
|
||||
deviceRunSessionId: string
|
||||
): Promise<SignedUrl> {
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(CREATE_DEVICE_RUN_SESSION_EVENT_LOG_UPLOAD_SESSION_MUTATION, {
|
||||
deviceRunSessionId,
|
||||
})
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
|
||||
try {
|
||||
return await workerApi.createEventLogUploadSessionAsync();
|
||||
} catch (err) {
|
||||
throw new SystemError(
|
||||
`Failed to create device run session event log upload session: ${result.error.message}`,
|
||||
{ cause: result.error }
|
||||
`Failed to create device run session event log upload session: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
const uploadSession = result.data!.deviceRunSession.createEventLogUploadSession.uploadSession;
|
||||
return {
|
||||
url: uploadSession.url,
|
||||
headers: uploadSession.headers as Record<string, string>,
|
||||
};
|
||||
}
|
||||
|
||||
async function collectEventFileAsync({
|
||||
|
||||
@@ -2,7 +2,6 @@ import { type bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
|
||||
import spawn, { type SpawnOptions } from '@expo/turtle-spawn';
|
||||
import { graphql } from 'gql.tada';
|
||||
import fetch from 'node-fetch';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
@@ -12,24 +11,12 @@ import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { killProcessGroup } from '../../utils/processes';
|
||||
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
|
||||
|
||||
const PREVIEW_INTERVAL_MS = 60_000;
|
||||
const PREVIEW_MAX_DIMENSION = 320;
|
||||
const PREVIEW_QUALITY = 70;
|
||||
const MAX_PREVIEW_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
const CREATE_PREVIEW_UPLOAD_SESSION_MUTATION = graphql(`
|
||||
mutation CreateDeviceRunSessionPreviewUploadSession($deviceRunSessionId: ID!) {
|
||||
deviceRunSession {
|
||||
createPreviewUploadSession(deviceRunSessionId: $deviceRunSessionId) {
|
||||
uploadSession {
|
||||
url
|
||||
headers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
/**
|
||||
* node-fetch includes the request URL in its error messages, and this URL is a signed upload URL
|
||||
* that is valid for the whole session. Replace such errors with one that names only the failure.
|
||||
@@ -83,31 +70,7 @@ export function startDeviceRunSessionPreview({
|
||||
throw new Error('Session preview is empty or exceeds the 5 MiB upload limit.');
|
||||
}
|
||||
if (!uploadSession) {
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(
|
||||
CREATE_PREVIEW_UPLOAD_SESSION_MUTATION,
|
||||
{ deviceRunSessionId },
|
||||
{
|
||||
// Keep the client's auth headers and combine cancellation after URQL sets its signal.
|
||||
fetch: (url, options) =>
|
||||
globalThis.fetch(url, {
|
||||
...options,
|
||||
signal: AbortSignal.any([signal, ...(options?.signal ? [options.signal] : [])]),
|
||||
}),
|
||||
}
|
||||
)
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
const session = result.data?.deviceRunSession.createPreviewUploadSession.uploadSession;
|
||||
if (!session) {
|
||||
throw new Error('No session preview upload URL was returned.');
|
||||
}
|
||||
uploadSession = {
|
||||
url: session.url,
|
||||
headers: session.headers as Record<string, string>,
|
||||
};
|
||||
uploadSession = await createPreviewUploadSessionAsync(ctx, deviceRunSessionId, signal);
|
||||
}
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
@@ -140,6 +103,14 @@ export function startDeviceRunSessionPreview({
|
||||
};
|
||||
}
|
||||
|
||||
async function createPreviewUploadSessionAsync(
|
||||
ctx: CustomBuildContext,
|
||||
deviceRunSessionId: string,
|
||||
signal: AbortSignal
|
||||
): Promise<{ url: string; headers: Record<string, string> }> {
|
||||
return await requireDeviceRunSessionWorkerApi(ctx).createPreviewUploadSessionAsync({ signal });
|
||||
}
|
||||
|
||||
/**
|
||||
* Homebrew's FFmpeg is built without libwebp, so macOS encodes previews with `cwebp` from
|
||||
* Homebrew's webp package instead. Throws when it cannot be installed.
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { graphql } from 'gql.tada';
|
||||
import { z } from 'zod';
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import type { CustomBuildContext } from '../../customBuildContext';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
|
||||
|
||||
const SHORT_COMMIT_HASH_LENGTH = 7;
|
||||
const MAX_APP_SLUG_LENGTH = 80;
|
||||
@@ -15,36 +14,15 @@ const LEADING_OR_TRAILING_DOTS_AND_HYPHENS = /^[.-]+|[.-]+$/g;
|
||||
// A full git commit hash: 40 hex characters for SHA-1 or 64 for SHA-256.
|
||||
const FULL_GIT_COMMIT_HASH = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i;
|
||||
|
||||
const SCREENSHOT_SESSION_QUERY = graphql(`
|
||||
query ScreenshotSession($deviceRunSessionId: ID!) {
|
||||
deviceRunSessions {
|
||||
byId(deviceRunSessionId: $deviceRunSessionId) {
|
||||
platform
|
||||
app { slug }
|
||||
build {
|
||||
id
|
||||
gitCommitHash
|
||||
app { slug }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const AppSchema = z.object({ slug: z.string() });
|
||||
const ScreenshotSessionSchema = z.object({
|
||||
platform: z.enum(['IOS', 'ANDROID']),
|
||||
app: AppSchema,
|
||||
build: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
gitCommitHash: z.string().nullable(),
|
||||
app: AppSchema,
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export type ScreenshotSession = z.infer<typeof ScreenshotSessionSchema>;
|
||||
/**
|
||||
* Session details used to name screenshots. `app` is always null: the device run session worker
|
||||
* API does not return the app slug.
|
||||
*/
|
||||
export type ScreenshotSession = {
|
||||
platform: 'IOS' | 'ANDROID';
|
||||
app: { slug: string } | null;
|
||||
build: { id: string; gitCommitHash: string | null; app: { slug: string } | null } | null;
|
||||
};
|
||||
|
||||
export async function loadScreenshotSessionAsync(
|
||||
ctx: CustomBuildContext,
|
||||
@@ -52,17 +30,23 @@ export async function loadScreenshotSessionAsync(
|
||||
logger: bunyan
|
||||
): Promise<ScreenshotSession | null> {
|
||||
try {
|
||||
const result = await ctx.graphqlClient
|
||||
.query(
|
||||
SCREENSHOT_SESSION_QUERY,
|
||||
{ deviceRunSessionId },
|
||||
graphqlAbortContext(AbortSignal.timeout(SESSION_LOOKUP_TIMEOUT_MS))
|
||||
)
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
return ScreenshotSessionSchema.parse(result.data?.deviceRunSessions.byId);
|
||||
const signal = AbortSignal.timeout(SESSION_LOOKUP_TIMEOUT_MS);
|
||||
const application = await requireDeviceRunSessionWorkerApi(ctx).getApplicationAsync({
|
||||
signal,
|
||||
});
|
||||
return {
|
||||
// www runs iOS sessions only on macOS workers and Android sessions only on Linux workers,
|
||||
// so the worker API does not return the platform.
|
||||
platform: ctx.runtimePlatform === BuildRuntimePlatform.DARWIN ? 'IOS' : 'ANDROID',
|
||||
app: null,
|
||||
build: application.build
|
||||
? {
|
||||
id: application.build.id,
|
||||
gitCommitHash: application.build.gitCommitHash,
|
||||
app: null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, deviceRunSessionId },
|
||||
@@ -86,18 +70,20 @@ export function screenshotArtifactDetails(
|
||||
}
|
||||
|
||||
const app = session.build?.app ?? session.app;
|
||||
const appSlug =
|
||||
app.slug
|
||||
.replace(FILENAME_UNSAFE_RUN, '-')
|
||||
.replace(LEADING_OR_TRAILING_DOTS_AND_HYPHENS, '')
|
||||
.slice(0, MAX_APP_SLUG_LENGTH) || 'app';
|
||||
const platform = session.platform.toLowerCase();
|
||||
const filenameParts = [appSlug, platform];
|
||||
const metadata: Record<string, string> = {
|
||||
__eas_type: 'screenshot',
|
||||
appSlug: app.slug,
|
||||
platform,
|
||||
};
|
||||
const filenameParts = [platform];
|
||||
const metadata: Record<string, string> = { __eas_type: 'screenshot' };
|
||||
// Without an app slug, the filename starts with the platform.
|
||||
if (app) {
|
||||
const appSlug =
|
||||
app.slug
|
||||
.replace(FILENAME_UNSAFE_RUN, '-')
|
||||
.replace(LEADING_OR_TRAILING_DOTS_AND_HYPHENS, '')
|
||||
.slice(0, MAX_APP_SLUG_LENGTH) || 'app';
|
||||
filenameParts.unshift(appSlug);
|
||||
metadata.appSlug = app.slug;
|
||||
}
|
||||
metadata.platform = platform;
|
||||
|
||||
if (session.build) {
|
||||
metadata.buildId = session.build.id;
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import { type EnvironmentSecret, EnvironmentSecretType, SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
|
||||
/**
|
||||
* The environment secret that holds the device run session token. www gives it only to the job of
|
||||
* one device run session, and the token names that session.
|
||||
*/
|
||||
export const DEVICE_RUN_SESSION_TOKEN_ENV = 'EAS_DEVICE_RUN_SESSION_TOKEN';
|
||||
|
||||
/**
|
||||
* www reads the device run session token only from this header, and only on the
|
||||
* `/v2/device-run-session-worker/*` routes. It is not an `Authorization` credential.
|
||||
*/
|
||||
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.
|
||||
status: z.enum(['new', 'queued', 'starting', 'in-progress', 'stopped', 'errored']),
|
||||
});
|
||||
|
||||
const UploadSessionSchema = z.object({
|
||||
uploadSession: z.object({
|
||||
url: z.string(),
|
||||
headers: z.record(z.string(), z.string()),
|
||||
}),
|
||||
});
|
||||
|
||||
const TurnIceServersSchema = z.object({
|
||||
iceServers: z.array(
|
||||
z.object({
|
||||
urls: z.array(z.string()),
|
||||
username: z.string().optional(),
|
||||
credential: z.string().optional(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
const DeviceRunSessionApplicationSchema = z.object({
|
||||
build: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
gitCommitHash: z.string().nullable(),
|
||||
applicationArchiveUrl: z.string().nullable(),
|
||||
})
|
||||
.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'];
|
||||
export type DeviceRunSessionApplication = z.infer<typeof DeviceRunSessionApplicationSchema>;
|
||||
|
||||
export type CreateDeviceRunSessionArtifactUploadSessionInput = {
|
||||
name: string;
|
||||
filename: string;
|
||||
kind?: string | null;
|
||||
size: number;
|
||||
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 after a failed one. Defaults to none, so each call sends one request. */
|
||||
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
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'DeviceRunSessionWorkerApiError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export class DeviceRunSessionWorkerApi {
|
||||
constructor(
|
||||
private readonly expoApiV2BaseUrl: string,
|
||||
private readonly deviceRunSessionToken: string
|
||||
) {}
|
||||
|
||||
public async getSessionAsync(options: RequestOptions = {}): Promise<DeviceRunSessionStatus> {
|
||||
return await this.requestAsync('GET', 'session', DeviceRunSessionStatusSchema, options);
|
||||
}
|
||||
|
||||
public async startAsync(
|
||||
{ remoteConfig }: { remoteConfig: Record<string, unknown> },
|
||||
options: RequestOptions = {}
|
||||
): Promise<DeviceRunSessionStatus> {
|
||||
return await this.requestAsync('POST', 'start', DeviceRunSessionStatusSchema, {
|
||||
...options,
|
||||
json: { remoteConfig },
|
||||
});
|
||||
}
|
||||
|
||||
public async stopAsync(options: RequestOptions = {}): Promise<DeviceRunSessionStatus> {
|
||||
return await this.requestAsync('POST', 'stop', DeviceRunSessionStatusSchema, options);
|
||||
}
|
||||
|
||||
public async createArtifactUploadSessionAsync(
|
||||
input: CreateDeviceRunSessionArtifactUploadSessionInput,
|
||||
options: RequestOptions = {}
|
||||
): Promise<DeviceRunSessionUploadSession> {
|
||||
const { uploadSession } = await this.requestAsync(
|
||||
'POST',
|
||||
'artifact-upload-sessions',
|
||||
UploadSessionSchema,
|
||||
{ ...options, json: input }
|
||||
);
|
||||
return uploadSession;
|
||||
}
|
||||
|
||||
public async createEventLogUploadSessionAsync(
|
||||
options: RequestOptions = {}
|
||||
): Promise<DeviceRunSessionUploadSession> {
|
||||
const { uploadSession } = await this.requestAsync(
|
||||
'POST',
|
||||
'event-log-upload-session',
|
||||
UploadSessionSchema,
|
||||
options
|
||||
);
|
||||
return uploadSession;
|
||||
}
|
||||
|
||||
public async createPreviewUploadSessionAsync(
|
||||
options: RequestOptions = {}
|
||||
): Promise<DeviceRunSessionUploadSession> {
|
||||
const { uploadSession } = await this.requestAsync(
|
||||
'POST',
|
||||
'preview-upload-session',
|
||||
UploadSessionSchema,
|
||||
options
|
||||
);
|
||||
return uploadSession;
|
||||
}
|
||||
|
||||
public async fetchTurnIceServersAsync(
|
||||
options: RequestOptions = {}
|
||||
): Promise<DeviceRunSessionTurnIceServers> {
|
||||
const { iceServers } = await this.requestAsync(
|
||||
'POST',
|
||||
'turn-ice-servers',
|
||||
TurnIceServersSchema,
|
||||
options
|
||||
);
|
||||
return iceServers;
|
||||
}
|
||||
|
||||
public async getApplicationAsync(
|
||||
options: RequestOptions = {}
|
||||
): Promise<DeviceRunSessionApplication> {
|
||||
return await this.requestAsync(
|
||||
'GET',
|
||||
'application',
|
||||
DeviceRunSessionApplicationSchema,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
private async requestAsync<T>(
|
||||
method: 'GET' | 'POST',
|
||||
route: string,
|
||||
dataSchema: z.ZodType<T>,
|
||||
{
|
||||
signal,
|
||||
timeoutMs,
|
||||
retries = 0,
|
||||
logger,
|
||||
json,
|
||||
}: RequestOptions & { 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,
|
||||
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
|
||||
);
|
||||
}
|
||||
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
|
||||
);
|
||||
}
|
||||
if (!bodyResult.ok) {
|
||||
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.
|
||||
*/
|
||||
export function getDeviceRunSessionWorkerApi(
|
||||
ctx: Pick<CustomBuildContext, 'env' | 'expoApiV2BaseUrl'>
|
||||
): DeviceRunSessionWorkerApi | null {
|
||||
const token = getDeviceRunSessionToken(ctx.env);
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
return new DeviceRunSessionWorkerApi(getExpoApiV2BaseUrl(ctx), token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the client for the device run session worker API, which every device run session job
|
||||
* uses to call www about its session.
|
||||
*/
|
||||
export function requireDeviceRunSessionWorkerApi(
|
||||
ctx: Pick<CustomBuildContext, 'env' | 'expoApiV2BaseUrl'>
|
||||
): DeviceRunSessionWorkerApi {
|
||||
const workerApi = getDeviceRunSessionWorkerApi(ctx);
|
||||
if (!workerApi) {
|
||||
throw new SystemError(
|
||||
`This device run session job has no ${DEVICE_RUN_SESSION_TOKEN_ENV} secret. ` +
|
||||
'www gives one to every device run session job, and the worker needs it to call the ' +
|
||||
'device run session API. Start a new session, and contact support if it happens again.'
|
||||
);
|
||||
}
|
||||
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.'
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BuildPhaseResult, SystemError, UserError } from '@expo/eas-build-job';
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
@@ -29,12 +30,12 @@ import {
|
||||
import { startDeviceRunSessionScreenshotsAsync } from './deviceRunSessionScreenshots';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
type ServeSimApplicationOptions,
|
||||
type ServeSimLaunchOptions,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
fetchWebPreviewTurnArgsAsync,
|
||||
findAvailablePortAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
isProcessRunning,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
@@ -46,6 +47,10 @@ import {
|
||||
} from './IosSimulatorRecordingUtils';
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
import { startLogPhase } from '../../utils/logPhase';
|
||||
import { runServeSimActionAsync, stageServeSimAppAsync } from './serveSimActions';
|
||||
import { verifyLocalEgressGuardAsync } from './localEgressGuard';
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { createStartupTasks } from './startupTasks';
|
||||
|
||||
const WEB_PREVIEW_HOST = '127.0.0.1';
|
||||
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
|
||||
@@ -57,6 +62,8 @@ const EXPO_DEVICE_HUB_PACKAGE_NAME = 'expo-device-hub';
|
||||
const EXPO_DEVICE_HUB_MAX_DIMENSION = '960';
|
||||
const EXPO_DEVICE_HUB_VIDEO_BITRATE = '6000000';
|
||||
const EXPO_DEVICE_HUB_VIDEO_FPS = '60';
|
||||
// expo-device-hub's record of each running Hub, like serve-sim's: `server-<port>.json`.
|
||||
export const EXPO_DEVICE_HUB_STATE_DIR = path.join(os.tmpdir(), 'expo-device-hub');
|
||||
// On SIGTERM the Hub finalizes the recording itself, with this deadline before it force-exits,
|
||||
// so a stop request that failed here still gets one more chance to write the MP4.
|
||||
const EXPO_DEVICE_HUB_SIGTERM_FINALIZE_DEADLINE_MS = 60_000;
|
||||
@@ -81,7 +88,8 @@ export function simulatorPreviewPageUrl(env: BuildStepEnv, subdomainId: string):
|
||||
// Local website hosts are shared by local and staging simulator sessions.
|
||||
const WEBSITE_DEV_ORIGINS = ['https://expo.test', 'https://*.expo.test'];
|
||||
|
||||
export function websiteOriginServeSimArgs(env: BuildStepEnv): string[] {
|
||||
// serve-sim and expo-device-hub take the same website origin flags and values.
|
||||
export function websiteOriginArgs(env: BuildStepEnv): string[] {
|
||||
const origins = new Set([websiteOrigin(env)]);
|
||||
if (!env.EXPO_LOCAL && env.EXPO_STAGING) {
|
||||
origins.add('https://*.expo.dev');
|
||||
@@ -94,6 +102,16 @@ export function websiteOriginServeSimArgs(env: BuildStepEnv): string[] {
|
||||
return [...origins].flatMap(origin => ['--cors-origin', origin, '--frame-ancestor', origin]);
|
||||
}
|
||||
|
||||
// Both hosts take the same flag; it only applies under --require-token, which both run with.
|
||||
export function frameAncestorArgs(frameAncestors: readonly string[]): string[] {
|
||||
return frameAncestors.flatMap(origin => ['--frame-ancestor', origin]);
|
||||
}
|
||||
|
||||
// Both hosts take the same flag and match the same origin shapes.
|
||||
export function corsOriginArgs(corsOrigins: readonly string[]): string[] {
|
||||
return corsOrigins.flatMap(origin => ['--cors-origin', origin]);
|
||||
}
|
||||
|
||||
function createServeSimPackageSpec(packageVersion: string | undefined): string {
|
||||
return `${SERVE_SIM_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
|
||||
}
|
||||
@@ -106,8 +124,13 @@ export function createServeSimArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
websiteArgs = [],
|
||||
frameAncestorArgs: frameArgs = [],
|
||||
corsOriginArgs: corsArgs = [],
|
||||
shareUrl,
|
||||
disableShare = false,
|
||||
packageVersion,
|
||||
iosSimulatorUdid,
|
||||
installAppPath,
|
||||
launchAppIdentifier,
|
||||
launchArgs = [],
|
||||
openUrl,
|
||||
@@ -117,13 +140,19 @@ export function createServeSimArgs({
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
websiteArgs?: string[];
|
||||
frameAncestorArgs?: string[];
|
||||
corsOriginArgs?: string[];
|
||||
shareUrl?: string;
|
||||
disableShare?: boolean;
|
||||
packageVersion?: string;
|
||||
iosSimulatorUdid?: string;
|
||||
installAppPath?: string;
|
||||
networkCapture?: boolean;
|
||||
networkCaptureFields?: string[];
|
||||
} & ServeSimLaunchOptions): string[] {
|
||||
return [
|
||||
createServeSimPackageSpec(packageVersion),
|
||||
...(iosSimulatorUdid ? [iosSimulatorUdid] : []),
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
@@ -143,8 +172,12 @@ export function createServeSimArgs({
|
||||
SERVE_SIM_VIDEO_FPS,
|
||||
...turnArgs,
|
||||
...websiteArgs,
|
||||
...frameArgs,
|
||||
...corsArgs,
|
||||
...(shareUrl ? ['--share-url', shareUrl] : []),
|
||||
...(disableShare ? ['--disable-share'] : []),
|
||||
...(launchAppIdentifier ? ['--launch-app-identifier', launchAppIdentifier] : []),
|
||||
...(installAppPath ? ['--install-app-path', installAppPath] : []),
|
||||
...launchArgs.flatMap(argument => ['--launch-arg', argument]),
|
||||
...(openUrl ? ['--open-url', openUrl] : []),
|
||||
// `--network-capture` also covers an already booted simulator. Fields are repeated, not
|
||||
@@ -161,11 +194,17 @@ export function createServeSimArgs({
|
||||
export function createExpoDeviceHubArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
websiteArgs = [],
|
||||
frameAncestorArgs: frameArgs = [],
|
||||
corsOriginArgs: corsArgs = [],
|
||||
packageVersion,
|
||||
recordingDirectory,
|
||||
}: {
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
websiteArgs?: string[];
|
||||
frameAncestorArgs?: string[];
|
||||
corsOriginArgs?: string[];
|
||||
packageVersion?: string;
|
||||
recordingDirectory?: string;
|
||||
}): string[] {
|
||||
@@ -175,6 +214,7 @@ export function createExpoDeviceHubArgs({
|
||||
String(port),
|
||||
'--host',
|
||||
WEB_PREVIEW_HOST,
|
||||
'--require-token',
|
||||
'--platform',
|
||||
'android',
|
||||
'--transport',
|
||||
@@ -193,6 +233,9 @@ export function createExpoDeviceHubArgs({
|
||||
'--hide-boot-device',
|
||||
...(recordingDirectory ? ['--android-recording-directory', recordingDirectory] : []),
|
||||
...turnArgs,
|
||||
...websiteArgs,
|
||||
...frameArgs,
|
||||
...corsArgs,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -201,25 +244,39 @@ const WebPreviewReadyResponseSchema = z.object({
|
||||
device: z.string(),
|
||||
});
|
||||
|
||||
// Startup output includes bearer URLs (or a token field with --quiet).
|
||||
function redactPreviewTokens(output: string): string {
|
||||
return output
|
||||
.replace(/([?&#]token=)[^\s&"'<>]+/g, '$1[redacted]')
|
||||
.replace(/("token"\s*:\s*")[^"]*/g, '$1[redacted]');
|
||||
}
|
||||
|
||||
export async function waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
startupTimeoutMs,
|
||||
signal,
|
||||
}: {
|
||||
previewServer: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
|
||||
serverName: string;
|
||||
port: number;
|
||||
timeoutMs: number;
|
||||
startupTimeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<string> {
|
||||
const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let deadline = Date.now() + (startupTimeoutMs ?? timeoutMs);
|
||||
// Preserve the boot budget until the host starts answering.
|
||||
let waitingForHost = startupTimeoutMs !== undefined;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
signal?.throwIfAborted();
|
||||
if (previewServer.pid !== undefined && !isProcessRunning(previewServer.pid)) {
|
||||
throw new SystemError(
|
||||
`${serverName} exited before becoming ready. Last output:\n${
|
||||
previewServer.getOutput() || '<empty>'
|
||||
redactPreviewTokens(previewServer.getOutput()) || '<empty>'
|
||||
}`
|
||||
);
|
||||
}
|
||||
@@ -227,21 +284,52 @@ export async function waitForWebPreviewReadyAsync({
|
||||
const response = await turtleFetch(readyUrl, 'GET', {
|
||||
retries: 0,
|
||||
timeout: 2_000,
|
||||
shouldThrowOnNotOk: false,
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new SystemError(`${serverName} readiness returned HTTP ${response.status}`);
|
||||
}
|
||||
const ready = WebPreviewReadyResponseSchema.parse(await response.json());
|
||||
signal?.throwIfAborted();
|
||||
return ready.device;
|
||||
} catch (error) {
|
||||
const connectionRefused =
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
error.code === 'ECONNREFUSED';
|
||||
if (waitingForHost && !connectionRefused) {
|
||||
waitingForHost = false;
|
||||
deadline = Math.min(deadline, Date.now() + timeoutMs);
|
||||
}
|
||||
lastError = error;
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
await sleepAsync(WEB_PREVIEW_READY_POLL_INTERVAL_MS);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
throw new SystemError(
|
||||
`Timed out waiting for ${serverName} readiness at ${readyUrl}${
|
||||
lastError instanceof Error ? `: ${lastError.message}` : ''
|
||||
}. Last output:\n${previewServer.getOutput() || '<empty>'}`
|
||||
}. Last output:\n${redactPreviewTokens(previewServer.getOutput()) || '<empty>'}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function readExpoDeviceHubPreviewTokenAsync(
|
||||
port: number,
|
||||
stateDir: string = EXPO_DEVICE_HUB_STATE_DIR
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const state = JSON.parse(
|
||||
await fs.promises.readFile(path.join(stateDir, `server-${port}.json`), 'utf-8')
|
||||
) as { token?: unknown };
|
||||
return typeof state.token === 'string' ? state.token : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readServeSimPreviewTokenAsync(
|
||||
udid: string,
|
||||
stateDir: string = SERVE_SIM_STATE_DIR
|
||||
@@ -252,6 +340,8 @@ export async function readServeSimPreviewTokenAsync(
|
||||
|
||||
export type DeviceWebPreview = {
|
||||
previewPageUrl: string;
|
||||
/** Human-facing link, absent when a private job has no authenticated URL. */
|
||||
sharePageUrl?: string;
|
||||
apiUrl: string;
|
||||
previewToken?: string;
|
||||
/** Closes this tunnel only. The session host and recording remain running. */
|
||||
@@ -259,7 +349,7 @@ export type DeviceWebPreview = {
|
||||
};
|
||||
|
||||
export type DeviceSessionHost = {
|
||||
openPreviewAsync(options: { baseDomain: string }): Promise<DeviceWebPreview>;
|
||||
openPreviewAsync(options: { baseDomain: string; authtoken: string }): Promise<DeviceWebPreview>;
|
||||
/**
|
||||
* Terminal and idempotent. Never rejects: a failed finalization, host stop or upload is logged
|
||||
* and reported, so callers need no error handling around it.
|
||||
@@ -280,7 +370,20 @@ type DeviceSessionHostOptions = {
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
separateLogPhase?: boolean;
|
||||
startupTimeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
packageVersion?: string;
|
||||
disableShare?: boolean;
|
||||
privatePreviewUrl?: string;
|
||||
/** Origins allowed to embed the preview host, in addition to the website origins. */
|
||||
frameAncestors?: string[];
|
||||
/** Origins whose pages may call the preview host's API, in addition to the website origins. */
|
||||
corsOrigins?: string[];
|
||||
iosSimulatorUdid?: string;
|
||||
installAppPath?: string;
|
||||
bootEnv?: Record<string, string>;
|
||||
application?: Promise<ServeSimApplicationOptions | void>;
|
||||
onStartupError?: (error: unknown) => void;
|
||||
networkCapture?: boolean;
|
||||
networkCaptureFields?: string[];
|
||||
} & ServeSimLaunchOptions;
|
||||
@@ -317,17 +420,38 @@ async function startDeviceSessionHostInternalAsync(
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
startupTimeoutMs,
|
||||
signal,
|
||||
packageVersion,
|
||||
iosSimulatorUdid,
|
||||
installAppPath,
|
||||
application,
|
||||
onStartupError,
|
||||
disableShare = false,
|
||||
privatePreviewUrl,
|
||||
frameAncestors = [],
|
||||
corsOrigins = [],
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
networkCapture = false,
|
||||
networkCaptureFields = [],
|
||||
bootEnv = {},
|
||||
}: DeviceSessionHostOptions,
|
||||
onFinished: (successful: boolean) => void,
|
||||
artifactLogger: bunyan
|
||||
): Promise<DeviceSessionHost> {
|
||||
signal?.throwIfAborted();
|
||||
const isAndroid = runtimePlatform === BuildRuntimePlatform.LINUX;
|
||||
if (
|
||||
Object.keys(bootEnv).length > 0 &&
|
||||
(runtimePlatform !== BuildRuntimePlatform.DARWIN || !iosSimulatorUdid)
|
||||
) {
|
||||
throw new UserError(
|
||||
'EAS_IOS_SIMULATOR_BOOT_INVALID_INPUT',
|
||||
'Simulator boot options require an explicit iOS Simulator.'
|
||||
);
|
||||
}
|
||||
// Unreachable from the step functions, which reject a non-Darwin launch while parsing.
|
||||
// Kept because this function is exported and expo-device-hub cannot launch.
|
||||
if (isAndroid && networkCapture) {
|
||||
@@ -342,6 +466,9 @@ async function startDeviceSessionHostInternalAsync(
|
||||
`Cannot launch ${launchAppIdentifier}: an application launch runs through serve-sim on an iOS simulator, and this session runs expo-device-hub on ${runtimePlatform}.`
|
||||
);
|
||||
}
|
||||
if (application && (isAndroid || !iosSimulatorUdid)) {
|
||||
throw new SystemError('Deferred startup requires an explicit iOS Simulator.');
|
||||
}
|
||||
if (isAndroid) {
|
||||
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
|
||||
}
|
||||
@@ -353,14 +480,19 @@ async function startDeviceSessionHostInternalAsync(
|
||||
env,
|
||||
}
|
||||
: null;
|
||||
startupTimeoutMs ??= iosSimulatorUdid ? 30 * 60_000 : undefined;
|
||||
const startup = createStartupTasks(logger, signal);
|
||||
const serveSimTmpdir = env.TMPDIR ?? os.tmpdir();
|
||||
const serveSimStateDir = env.SERVE_SIM_STATE_DIR ?? SERVE_SIM_STATE_DIR;
|
||||
const subdomainId = randomBytes(16).toString('hex');
|
||||
const previewPageUrl = simulatorPreviewPageUrl(env, subdomainId);
|
||||
const sharePageUrl = disableShare ? privatePreviewUrl : previewPageUrl;
|
||||
const port = await findAvailablePortAsync();
|
||||
const serverName = isAndroid ? 'expo-device-hub' : 'serve-sim';
|
||||
const packageSpec = isAndroid
|
||||
? createExpoDeviceHubPackageSpec(packageVersion)
|
||||
: createServeSimPackageSpec(packageVersion);
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { logger });
|
||||
const previewExec = resolvePackageExec(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
isAndroid
|
||||
@@ -369,16 +501,25 @@ async function startDeviceSessionHostInternalAsync(
|
||||
turnArgs,
|
||||
packageVersion,
|
||||
recordingDirectory: recording?.directory,
|
||||
websiteArgs: websiteOriginArgs(env),
|
||||
frameAncestorArgs: frameAncestorArgs(frameAncestors),
|
||||
corsOriginArgs: corsOriginArgs(corsOrigins),
|
||||
})
|
||||
: createServeSimArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
packageVersion,
|
||||
websiteArgs: websiteOriginServeSimArgs(env),
|
||||
shareUrl: previewPageUrl,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
iosSimulatorUdid,
|
||||
websiteArgs: websiteOriginArgs(env),
|
||||
frameAncestorArgs: frameAncestorArgs(frameAncestors),
|
||||
corsOriginArgs: corsOriginArgs(corsOrigins),
|
||||
// serve-sim adds its bearer token to --share-url; private links are for worker logs only.
|
||||
shareUrl: disableShare ? undefined : previewPageUrl,
|
||||
disableShare,
|
||||
installAppPath: iosSimulatorUdid ? undefined : installAppPath,
|
||||
launchAppIdentifier: iosSimulatorUdid ? undefined : launchAppIdentifier,
|
||||
launchArgs: iosSimulatorUdid ? [] : launchArgs,
|
||||
openUrl: iosSimulatorUdid ? undefined : openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
})
|
||||
@@ -397,13 +538,19 @@ async function startDeviceSessionHostInternalAsync(
|
||||
});
|
||||
let previewServer: DetachedProcessHandle;
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
previewServer = spawnDetached({
|
||||
command: previewExec.command,
|
||||
args: previewExec.args,
|
||||
env: {
|
||||
...env,
|
||||
...bootEnv,
|
||||
...(isAndroid ? {} : { TMPDIR: serveSimTmpdir, SERVE_SIM_STATE_DIR: serveSimStateDir }),
|
||||
EXPO_DEVICE_HUB_SCREENSHOT_DIRECTORY: screenshots.directory,
|
||||
...(recording ? { EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN: recording.controlToken } : {}),
|
||||
// The Hub would resolve its state directory from the step's env, which may set its own
|
||||
// TMPDIR or state directory, so it writes its token where the host reads it below.
|
||||
...(isAndroid ? { EXPO_DEVICE_HUB_STATE_DIR } : {}),
|
||||
},
|
||||
// Only an Android host records through expo-device-hub; serve-sim records on iOS.
|
||||
stopGracePeriodMs: recording
|
||||
@@ -418,6 +565,10 @@ async function startDeviceSessionHostInternalAsync(
|
||||
throw error;
|
||||
}
|
||||
|
||||
let stagedAppDirectory: string | undefined;
|
||||
let beforeLaunch: ReturnType<typeof asyncResult<void>> | undefined;
|
||||
const readinessController = new AbortController();
|
||||
const readinessSignal = AbortSignal.any([startup.signal, readinessController.signal]);
|
||||
let previewToken: string | undefined;
|
||||
let previewTask: Promise<DeviceWebPreview> | null = null;
|
||||
let finishTask: Promise<void> | null = null;
|
||||
@@ -428,7 +579,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
let previewFailed = false;
|
||||
|
||||
const host: DeviceSessionHost = {
|
||||
openPreviewAsync({ baseDomain }) {
|
||||
openPreviewAsync({ baseDomain, authtoken }) {
|
||||
if (finishTask) {
|
||||
return Promise.reject(
|
||||
new SystemError('Cannot open a preview after session host finalization.')
|
||||
@@ -443,7 +594,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
subdomainPrefix: 'web-preview',
|
||||
subdomainId,
|
||||
baseDomain,
|
||||
authtoken: getNgrokAuthtokenOrThrow(env),
|
||||
authtoken,
|
||||
logger,
|
||||
});
|
||||
if (finishTask) {
|
||||
@@ -457,6 +608,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
previewFailed = false;
|
||||
return {
|
||||
previewPageUrl,
|
||||
sharePageUrl,
|
||||
apiUrl: tunnel.url,
|
||||
previewToken,
|
||||
closeAsync() {
|
||||
@@ -483,6 +635,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
return opening;
|
||||
},
|
||||
finishAsync() {
|
||||
readinessController.abort(new Error('Simulator preview ended.'));
|
||||
return (finishTask ??= finishDeviceSessionHostAsync(ctx, {
|
||||
previewTask,
|
||||
stopSessionPreviewAsync: async () => {
|
||||
@@ -499,32 +652,122 @@ async function startDeviceSessionHostInternalAsync(
|
||||
logger,
|
||||
artifactLogger,
|
||||
onStopped: successful => onFinished(successful && !previewFailed),
|
||||
}).catch(err => {
|
||||
artifactLogger.warn({ err }, 'Could not finish the simulator preview.');
|
||||
onFinished(false);
|
||||
}));
|
||||
})
|
||||
.finally(async () => {
|
||||
await beforeLaunch;
|
||||
if (stagedAppDirectory) {
|
||||
await fs.promises.rm(stagedAppDirectory, { recursive: true, force: true });
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
artifactLogger.warn({ err }, 'Could not finish the simulator preview.');
|
||||
onFinished(false);
|
||||
}));
|
||||
},
|
||||
};
|
||||
try {
|
||||
logger.info(`Waiting for ${serverName} to become ready.`);
|
||||
const device = await waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
});
|
||||
hostReady = true;
|
||||
if (!isAndroid) {
|
||||
previewToken = await readServeSimPreviewTokenAsync(device);
|
||||
if (!previewToken) {
|
||||
throw new SystemError(
|
||||
`serve-sim became ready but wrote no session token for device ${device}. The preview is ` +
|
||||
'on a public tunnel and would be reachable without one, so the session cannot continue. ' +
|
||||
'This usually means the state file was not written as expected; retry the session, and ' +
|
||||
'report it if it repeats.'
|
||||
const readiness = asyncResult(
|
||||
waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
startupTimeoutMs,
|
||||
signal: readinessSignal,
|
||||
}).catch(error => {
|
||||
startup.abort(error);
|
||||
throw error;
|
||||
})
|
||||
);
|
||||
if (iosSimulatorUdid) {
|
||||
previewToken = await waitForServeSimControlAsync({
|
||||
device: iosSimulatorUdid,
|
||||
port,
|
||||
previewServer,
|
||||
stateDir: serveSimStateDir,
|
||||
timeoutMs: startupTimeoutMs ?? timeoutMs,
|
||||
signal: readinessSignal,
|
||||
});
|
||||
secrets.push(previewToken);
|
||||
if (Object.keys(bootEnv).length > 0) {
|
||||
await startup.untilAborted(
|
||||
verifyLocalEgressGuardAsync({ udid: iosSimulatorUdid as IosSimulatorUuid, env, logger })
|
||||
);
|
||||
}
|
||||
secrets.push(previewToken);
|
||||
signal?.throwIfAborted();
|
||||
beforeLaunch = asyncResult(
|
||||
startup.untilAborted(
|
||||
startup.run('Simulator setup', async taskLogger => {
|
||||
try {
|
||||
await IosSimulatorUtils.disableApsdAsync({
|
||||
udid: iosSimulatorUdid as IosSimulatorUuid,
|
||||
env,
|
||||
});
|
||||
} catch (err) {
|
||||
startup.signal.throwIfAborted();
|
||||
taskLogger.warn({ err }, 'Failed to disable apsd in the Simulator.');
|
||||
}
|
||||
startup.signal.throwIfAborted();
|
||||
})
|
||||
)
|
||||
);
|
||||
const app = (application ? await startup.untilAborted(application) : undefined) ?? {
|
||||
installAppPath,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
};
|
||||
signal?.throwIfAborted();
|
||||
const action = (name: string, params: Record<string, unknown>) =>
|
||||
runServeSimActionAsync({
|
||||
port,
|
||||
token: previewToken!,
|
||||
action: name,
|
||||
params,
|
||||
timeoutMs: startupTimeoutMs ?? timeoutMs,
|
||||
signal: startup.signal,
|
||||
});
|
||||
if (app.installAppPath) {
|
||||
const staged = await stageServeSimAppAsync(app.installAppPath, serveSimTmpdir);
|
||||
stagedAppDirectory = staged.directory;
|
||||
signal?.throwIfAborted();
|
||||
await action('app.install', { udid: iosSimulatorUdid, path: staged.path });
|
||||
}
|
||||
(await beforeLaunch)?.enforceValue();
|
||||
if (app.launchAppIdentifier) {
|
||||
signal?.throwIfAborted();
|
||||
await action('app.launch', {
|
||||
udid: iosSimulatorUdid,
|
||||
bundleId: app.launchAppIdentifier,
|
||||
launchArgs: app.launchArgs ?? [],
|
||||
openUrl: app.openUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
logger.info(`Waiting for ${serverName} to become ready.`);
|
||||
const device = (await readiness).enforceValue();
|
||||
if (iosSimulatorUdid && device.toLowerCase() !== iosSimulatorUdid.toLowerCase()) {
|
||||
throw new SystemError(
|
||||
`serve-sim became ready on ${device}, but this session requested ${iosSimulatorUdid}.`
|
||||
);
|
||||
}
|
||||
hostReady = true;
|
||||
// Both servers record their session token in a state file once they are ready.
|
||||
if (isAndroid) {
|
||||
previewToken = await readExpoDeviceHubPreviewTokenAsync(port, EXPO_DEVICE_HUB_STATE_DIR);
|
||||
} else {
|
||||
previewToken ??= await readServeSimPreviewTokenAsync(device, serveSimStateDir);
|
||||
}
|
||||
if (!previewToken) {
|
||||
throw new SystemError(
|
||||
`${serverName} became ready but wrote no session token for device ${device}. The ` +
|
||||
'preview is on a public tunnel and would be reachable without one, so the session ' +
|
||||
'cannot continue. This usually means the state file was not written as expected; ' +
|
||||
'retry the session, and report it if it repeats.'
|
||||
);
|
||||
}
|
||||
secrets.push(previewToken);
|
||||
if (!isAndroid) {
|
||||
IosSimulatorRecordingUtils.useServeSimPackage(packageSpec);
|
||||
}
|
||||
// Android installed FFmpeg before launching the host. The optional thumbnail must not delay
|
||||
@@ -556,13 +799,56 @@ async function startDeviceSessionHostInternalAsync(
|
||||
logger.warn({ err }, 'Could not start refreshing the session preview.');
|
||||
}
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
return host;
|
||||
} catch (error) {
|
||||
onStartupError?.(error);
|
||||
await host.finishAsync();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForServeSimControlAsync({
|
||||
device,
|
||||
port,
|
||||
stateDir,
|
||||
previewServer,
|
||||
timeoutMs,
|
||||
signal,
|
||||
}: {
|
||||
device: string;
|
||||
port: number;
|
||||
stateDir: string;
|
||||
previewServer: DetachedProcessHandle;
|
||||
timeoutMs: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<string> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
signal?.throwIfAborted();
|
||||
if (
|
||||
previewServer.getExitError() !== undefined ||
|
||||
(previewServer.pid !== undefined && !isProcessRunning(previewServer.pid))
|
||||
) {
|
||||
throw new SystemError(
|
||||
`serve-sim exited before its control API became ready. ${previewServer.getOutput()}`
|
||||
);
|
||||
}
|
||||
const servers = await readServeSimServersAsync(stateDir);
|
||||
const server = servers.find(
|
||||
server =>
|
||||
server.udid.toLowerCase() === device.toLowerCase() &&
|
||||
server.url === `http://${WEB_PREVIEW_HOST}:${port}`
|
||||
);
|
||||
if (server?.token) {
|
||||
return server.token;
|
||||
}
|
||||
await sleepAsync(WEB_PREVIEW_READY_POLL_INTERVAL_MS);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
throw new SystemError('Timed out waiting for serve-sim control readiness.');
|
||||
}
|
||||
|
||||
async function finishDeviceSessionHostAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
@@ -642,7 +928,11 @@ async function finishDeviceSessionHostAsync(
|
||||
if (recording && captured && (finalization === 'failed' || !uploaded)) {
|
||||
// The Hub reports capture failures on stderr; the stop route answers with only a summary.
|
||||
artifactLogger.warn(
|
||||
{ hostOutput: previewServer.getOutput().slice(-HOST_OUTPUT_TAIL_CHARS) || '<empty>' },
|
||||
{
|
||||
hostOutput:
|
||||
redactPreviewTokens(previewServer.getOutput()).slice(-HOST_OUTPUT_TAIL_CHARS) ||
|
||||
'<empty>',
|
||||
},
|
||||
'Session host output around the recording failure.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { type BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export async function readIosApplicationIdentifierAsync({
|
||||
artifactPath,
|
||||
env,
|
||||
}: {
|
||||
artifactPath: string;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<string> {
|
||||
const artifactStat = await fs.promises.stat(artifactPath).catch(err => {
|
||||
throw new UserError(
|
||||
'EAS_INSTALL_BUILD_INVALID_ARTIFACT',
|
||||
`Build artifact does not exist at ${artifactPath}.`,
|
||||
{ cause: err }
|
||||
);
|
||||
});
|
||||
if (path.extname(artifactPath) !== '.app' || !artifactStat.isDirectory()) {
|
||||
throw new UserError(
|
||||
'EAS_INSTALL_BUILD_INVALID_ARTIFACT',
|
||||
'iOS Simulator sessions require a .app build artifact.'
|
||||
);
|
||||
}
|
||||
const infoPlistPath = path.join(artifactPath, 'Info.plist');
|
||||
const { stdout } = await spawn(
|
||||
'plutil',
|
||||
['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', infoPlistPath],
|
||||
{ stdio: 'pipe', env }
|
||||
);
|
||||
const applicationIdentifier = stdout.trim();
|
||||
if (!applicationIdentifier) {
|
||||
throw new UserError(
|
||||
'EAS_INSTALL_BUILD_MISSING_IDENTIFIER',
|
||||
`Could not read CFBundleIdentifier from ${infoPlistPath}.`
|
||||
);
|
||||
}
|
||||
return applicationIdentifier;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { minBy } from 'lodash';
|
||||
|
||||
import {
|
||||
type IosSimulatorName,
|
||||
IosSimulatorUtils,
|
||||
type IosSimulatorUuid,
|
||||
} from '../../utils/IosSimulatorUtils';
|
||||
import {
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync,
|
||||
startLocalEgressGuardRelayAsync,
|
||||
} from './localEgressGuard';
|
||||
import { readIosApplicationIdentifierAsync } from './iosAppArtifact';
|
||||
import {
|
||||
type ServeSimLaunchOptions,
|
||||
parseServeSimLaunchInputs,
|
||||
validateServeSimLaunchOptions,
|
||||
} from './remoteDeviceRunSession';
|
||||
|
||||
export type IosSessionStartup = {
|
||||
iosSimulatorUdid: string;
|
||||
installAppPath?: string;
|
||||
bootEnv?: Record<string, string>;
|
||||
};
|
||||
|
||||
export async function resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator,
|
||||
deviceIdentifier,
|
||||
installAppPath,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
bootSimulator?: boolean;
|
||||
deviceIdentifier?: string;
|
||||
installAppPath?: string;
|
||||
launchAppIdentifier?: unknown;
|
||||
launchArgs?: unknown;
|
||||
openUrl?: unknown;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ launch: ServeSimLaunchOptions; iosStartup?: IosSessionStartup }> {
|
||||
signal?.throwIfAborted();
|
||||
if ((bootSimulator || installAppPath) && runtimePlatform !== BuildRuntimePlatform.DARWIN) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
'install_app_path is only supported for iOS Simulator sessions.'
|
||||
);
|
||||
}
|
||||
const applicationIdentifier =
|
||||
launchAppIdentifier ??
|
||||
(installAppPath
|
||||
? await readIosApplicationIdentifierAsync({ artifactPath: installAppPath, env })
|
||||
: undefined);
|
||||
const launch = parseServeSimLaunchInputs(
|
||||
{ launchAppIdentifier: applicationIdentifier, launchArgs, openUrl },
|
||||
{ runtimePlatform }
|
||||
);
|
||||
if (
|
||||
runtimePlatform !== BuildRuntimePlatform.DARWIN ||
|
||||
(!bootSimulator && !deviceIdentifier && !installAppPath)
|
||||
) {
|
||||
return { launch };
|
||||
}
|
||||
validateServeSimLaunchOptions(launch);
|
||||
signal?.throwIfAborted();
|
||||
let selectedIdentifier = (deviceIdentifier ?? undefined) as
|
||||
| IosSimulatorUuid
|
||||
| IosSimulatorName
|
||||
| undefined;
|
||||
if (selectedIdentifier === undefined) {
|
||||
const devices = await IosSimulatorUtils.getAvailableDevicesAsync({ env, filter: 'available' });
|
||||
selectedIdentifier = minBy(
|
||||
devices.filter(device => device.name.startsWith('iPhone')),
|
||||
device => device.name.length
|
||||
)?.udid;
|
||||
}
|
||||
if (!selectedIdentifier) {
|
||||
throw new Error('Could not find an iPhone among available simulator devices.');
|
||||
}
|
||||
const iosSimulatorUdid = (
|
||||
await IosSimulatorUtils.resolveUdidAsync({ deviceIdentifier: selectedIdentifier, env })
|
||||
).toUpperCase() as IosSimulatorUuid;
|
||||
const bootEnv = await resolveLocalEgressServeSimBootEnvironmentAsync();
|
||||
signal?.throwIfAborted();
|
||||
if (bootEnv) {
|
||||
const device = await IosSimulatorUtils.getDeviceAsync({ udid: iosSimulatorUdid, env });
|
||||
signal?.throwIfAborted();
|
||||
if (device && device.state !== 'Shutdown') {
|
||||
logger.info(`Shutting down ${iosSimulatorUdid} to apply the local egress guard at boot.`);
|
||||
await spawn('xcrun', ['simctl', 'shutdown', iosSimulatorUdid], { env, logger, signal });
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
await startLocalEgressGuardRelayAsync({ logger });
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
return {
|
||||
launch,
|
||||
iosStartup: { iosSimulatorUdid, installAppPath, bootEnv: bootEnv ?? undefined },
|
||||
};
|
||||
}
|
||||
@@ -110,6 +110,18 @@ export async function resolveEgressGuardCheckAsync(
|
||||
return await resolvePackagedFileAsync(binDir, EGRESS_GUARD_CHECK_FILE);
|
||||
}
|
||||
|
||||
async function requireEgressGuardCheckAsync(checkPath?: string | null): Promise<string> {
|
||||
const resolvedCheck = checkPath === undefined ? await resolveEgressGuardCheckAsync() : checkPath;
|
||||
if (!resolvedCheck) {
|
||||
throw new SystemError(
|
||||
'The local egress guard self-check is not available on this device host, so this session cannot ' +
|
||||
'verify that the guard is in effect. The device host image is missing bin/egress-guard-check; ' +
|
||||
'this is a service problem, please contact support.'
|
||||
);
|
||||
}
|
||||
return resolvedCheck;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns guard events into session log lines: one line the first time a
|
||||
* process reaches a destination through a given call, counts after that, and
|
||||
@@ -278,6 +290,14 @@ export class GuardLogTailer {
|
||||
}
|
||||
}
|
||||
|
||||
type LocalEgressBootEnvironmentOptions = {
|
||||
handoffPath?: string;
|
||||
/** Explicit library path, `null` for "not available"; resolved from the package when omitted. */
|
||||
libraryPath?: string | null;
|
||||
logPath?: string;
|
||||
mode?: EgressGuardMode;
|
||||
};
|
||||
|
||||
/**
|
||||
* The environment the simulator's launchd must have from its first process:
|
||||
* the guard and the proxy variables. Pass it to `IosSimulatorUtils.bootAsync`,
|
||||
@@ -291,13 +311,7 @@ export async function resolveLocalEgressBootEnvironmentAsync({
|
||||
libraryPath,
|
||||
logPath = LOCAL_EGRESS_GUARD_LOG_PATH,
|
||||
mode = 'block',
|
||||
}: {
|
||||
handoffPath?: string;
|
||||
/** Explicit library path, `null` for "not available"; resolved from the package when omitted. */
|
||||
libraryPath?: string | null;
|
||||
logPath?: string;
|
||||
mode?: EgressGuardMode;
|
||||
} = {}): Promise<Record<string, string> | null> {
|
||||
}: LocalEgressBootEnvironmentOptions = {}): Promise<Record<string, string> | null> {
|
||||
const handoff = await readLocalEgressHandoffAsync(handoffPath);
|
||||
if (!handoff) {
|
||||
return null;
|
||||
@@ -317,6 +331,27 @@ export async function resolveLocalEgressBootEnvironmentAsync({
|
||||
};
|
||||
}
|
||||
|
||||
/** Supply the guard at boot and require its self-check before starting serve-sim. */
|
||||
export async function resolveLocalEgressServeSimBootEnvironmentAsync({
|
||||
checkPath,
|
||||
...options
|
||||
}: LocalEgressBootEnvironmentOptions & {
|
||||
checkPath?: string | null;
|
||||
} = {}): Promise<Record<string, string> | null> {
|
||||
const environment = await resolveLocalEgressBootEnvironmentAsync(options);
|
||||
if (!environment) {
|
||||
return null;
|
||||
}
|
||||
await requireEgressGuardCheckAsync(checkPath);
|
||||
const { DYLD_INSERT_LIBRARIES, ...childEnvironment } = environment;
|
||||
return {
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: DYLD_INSERT_LIBRARIES,
|
||||
...Object.fromEntries(
|
||||
Object.entries(childEnvironment).map(([name, value]) => [`SIMCTL_CHILD_${name}`, value])
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
type ActiveRelay = {
|
||||
source: { stopAsync: () => Promise<void> };
|
||||
relay: GuardEventRelay;
|
||||
@@ -325,6 +360,44 @@ type ActiveRelay = {
|
||||
};
|
||||
const activeRelays = new Map<string, ActiveRelay>();
|
||||
|
||||
/** Start reporting guard events without changing the Simulator's launchd environment. */
|
||||
export async function startLocalEgressGuardRelayAsync({
|
||||
logger,
|
||||
logPath = LOCAL_EGRESS_GUARD_LOG_PATH,
|
||||
tailIntervalMs,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
logPath?: string;
|
||||
tailIntervalMs?: number;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await fs.promises.mkdir(path.dirname(logPath), { recursive: true });
|
||||
await fs.promises.appendFile(logPath, '');
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
`Local egress guard: could not create ${logPath}, so refused connections will not be reported in this log. They are still refused.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (activeRelays.has(logPath)) {
|
||||
return;
|
||||
}
|
||||
const relay = new GuardEventRelay(logger);
|
||||
const tailer = new GuardLogTailer({
|
||||
path: logPath,
|
||||
intervalMs: tailIntervalMs,
|
||||
onLine: line => {
|
||||
const event = parseGuardLogLine(line);
|
||||
if (event) {
|
||||
relay.handle(event);
|
||||
}
|
||||
},
|
||||
});
|
||||
tailer.start();
|
||||
activeRelays.set(logPath, { source: tailer, relay });
|
||||
}
|
||||
|
||||
/**
|
||||
* Report events from another source, such as the Android emulator's packet
|
||||
* fence, through the same session log lines, step rebinding and summary as the
|
||||
@@ -396,17 +469,7 @@ export async function installLocalEgressGuardAsync({
|
||||
|
||||
await logAlreadyRunningProcessesAsync({ env, logger });
|
||||
|
||||
let logWritable = true;
|
||||
try {
|
||||
await fs.promises.mkdir(path.dirname(logPath), { recursive: true });
|
||||
await fs.promises.appendFile(logPath, '');
|
||||
} catch (err) {
|
||||
logWritable = false;
|
||||
logger.warn(
|
||||
{ err },
|
||||
`Local egress guard: could not create ${logPath}, so refused connections will not be reported in this log. They are still refused.`
|
||||
);
|
||||
}
|
||||
await startLocalEgressGuardRelayAsync({ logger, logPath, tailIntervalMs });
|
||||
|
||||
try {
|
||||
await IosSimulatorUtils.setLaunchdEnvironmentAsync({
|
||||
@@ -423,22 +486,6 @@ export async function installLocalEgressGuardAsync({
|
||||
);
|
||||
}
|
||||
|
||||
if (logWritable && !activeRelays.has(logPath)) {
|
||||
const relay = new GuardEventRelay(logger);
|
||||
const tailer = new GuardLogTailer({
|
||||
path: logPath,
|
||||
intervalMs: tailIntervalMs,
|
||||
onLine: line => {
|
||||
const event = parseGuardLogLine(line);
|
||||
if (event) {
|
||||
relay.handle(event);
|
||||
}
|
||||
},
|
||||
});
|
||||
tailer.start();
|
||||
activeRelays.set(logPath, { source: tailer, relay });
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Local egress guard installed in the Simulator (mode ${mode}): connections that bypass the system proxy are ${
|
||||
mode === 'block' ? 'refused' : 'observed'
|
||||
@@ -658,14 +705,7 @@ export async function verifyLocalEgressGuardAsync({
|
||||
/** Explicit check binary path, `null` for "not available"; resolved from the package when omitted. */
|
||||
checkPath?: string | null;
|
||||
}): Promise<void> {
|
||||
const resolvedCheck = checkPath === undefined ? await resolveEgressGuardCheckAsync() : checkPath;
|
||||
if (!resolvedCheck) {
|
||||
throw new SystemError(
|
||||
'The local egress guard self-check is not available on this device host, so this session cannot ' +
|
||||
'verify that the guard is in effect. The device host image is missing bin/egress-guard-check; ' +
|
||||
'this is a service problem, please contact support.'
|
||||
);
|
||||
}
|
||||
const resolvedCheck = await requireEgressGuardCheckAsync(checkPath);
|
||||
let output: string;
|
||||
try {
|
||||
const result = await spawn('xcrun', ['simctl', 'spawn', udid, resolvedCheck, '--mode', mode], {
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import { graphql } from 'gql.tada';
|
||||
import nullthrows from 'nullthrows';
|
||||
import { z } from 'zod';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
@@ -29,49 +27,18 @@ import {
|
||||
import { Sentry } from '../../sentry';
|
||||
import { isProcessGroupRunning } from '../../utils/processes';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { createProcessOutput } from './processOutput';
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
import {
|
||||
type DeviceRunSessionStatus,
|
||||
requireDeviceRunSessionWorkerApi,
|
||||
} from './deviceRunSessionWorkerApi';
|
||||
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
|
||||
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
|
||||
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
|
||||
deviceRunSession {
|
||||
startDeviceRunSession(
|
||||
deviceRunSessionId: $deviceRunSessionId
|
||||
remoteConfig: $remoteConfig
|
||||
) {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const DEVICE_RUN_SESSION_STATUS_QUERY = graphql(`
|
||||
query DeviceRunSessionStatus($deviceRunSessionId: ID!) {
|
||||
deviceRunSessions {
|
||||
byId(deviceRunSessionId: $deviceRunSessionId) {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const ENSURE_DEVICE_RUN_SESSION_STOPPED_MUTATION = graphql(`
|
||||
mutation EnsureDeviceRunSessionStopped($deviceRunSessionId: ID!) {
|
||||
deviceRunSession {
|
||||
ensureDeviceRunSessionStopped(deviceRunSessionId: $deviceRunSessionId) {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const DEVICE_RUN_SESSION_STATUS_POLL_INTERVAL_MS = 5_000;
|
||||
// A request to the device run session worker API that hangs counts as a failed poll or stop.
|
||||
const DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
export function getDeviceRunSessionIdOrThrow(env: BuildStepEnv): string {
|
||||
const deviceRunSessionId = env.DEVICE_RUN_SESSION_ID;
|
||||
@@ -109,6 +76,17 @@ export function getNgrokAuthtokenOrThrow(env: BuildStepEnv): string {
|
||||
return authtoken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the step env without NGROK_AUTHTOKEN, for everything the step spawns.
|
||||
* Only the step process opens ngrok tunnels. The processes it starts (daemons,
|
||||
* preview servers, package installs) are reachable by whoever controls the
|
||||
* session, and the token can run tunnels on Expo's ngrok account.
|
||||
*/
|
||||
export function omitNgrokAuthtoken(env: BuildStepEnv): BuildStepEnv {
|
||||
const { NGROK_AUTHTOKEN: _ngrokAuthtoken, ...childEnv } = env;
|
||||
return childEnv;
|
||||
}
|
||||
|
||||
const TurnIceServersSchema = z.array(
|
||||
z.object({
|
||||
urls: z.array(z.string()),
|
||||
@@ -215,23 +193,13 @@ export async function waitForDeviceRunSessionStoppedAsync({
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await ctx.graphqlClient
|
||||
.query(DEVICE_RUN_SESSION_STATUS_QUERY, { deviceRunSessionId })
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
const status = result.data?.deviceRunSessions?.byId?.status;
|
||||
if (!status) {
|
||||
throw new Error(`Device run session ${deviceRunSessionId} status response was missing.`);
|
||||
}
|
||||
const status = await fetchDeviceRunSessionStatusAsync(ctx, deviceRunSessionId, signal);
|
||||
pollErrorCount = 0;
|
||||
if (status === 'STOPPED') {
|
||||
if (status === 'stopped') {
|
||||
logger.info(`Device run session ${deviceRunSessionId} was stopped.`);
|
||||
return;
|
||||
}
|
||||
if (status === 'ERRORED') {
|
||||
if (status === 'errored') {
|
||||
throw new SystemError(`Device run session ${deviceRunSessionId} errored.`);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -258,6 +226,20 @@ export async function waitForDeviceRunSessionStoppedAsync({
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDeviceRunSessionStatusAsync(
|
||||
ctx: CustomBuildContext,
|
||||
deviceRunSessionId: string,
|
||||
signal: AbortSignal
|
||||
): Promise<DeviceRunSessionStatus['status']> {
|
||||
// One request per poll; the polling loop retries on its own schedule.
|
||||
return (
|
||||
await requireDeviceRunSessionWorkerApi(ctx).getSessionAsync({
|
||||
signal,
|
||||
timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS,
|
||||
})
|
||||
).status;
|
||||
}
|
||||
|
||||
// Best effort: when this fails, the caller still tears the session down and the
|
||||
// job run finishes, which clients also treat as the session ending.
|
||||
async function ensureDeviceRunSessionStoppedSafelyAsync({
|
||||
@@ -270,12 +252,9 @@ async function ensureDeviceRunSessionStoppedSafelyAsync({
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(ENSURE_DEVICE_RUN_SESSION_STOPPED_MUTATION, { deviceRunSessionId })
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
await requireDeviceRunSessionWorkerApi(ctx).stopAsync({
|
||||
timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not mark idle device run session as stopped', error, {
|
||||
@@ -440,12 +419,6 @@ export async function ensureFfmpegInstalledOnceAsync({
|
||||
}
|
||||
}
|
||||
|
||||
const TurnIceServersResponseSchema = z.object({
|
||||
data: z.object({
|
||||
iceServers: TurnIceServersSchema,
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Translate Cloudflare ICE servers into web preview CLI flags: `--stun-url` (the
|
||||
* credential-less entries) and `--turn-url`/`--turn-username`/`--turn-credential`
|
||||
@@ -487,34 +460,11 @@ export function turnIceServersToWebPreviewArgs(iceServers: TurnIceServers): stri
|
||||
*/
|
||||
export async function fetchWebPreviewTurnArgsAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
|
||||
{ logger }: { logger: bunyan }
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const expoApiServerUrl = nullthrows(ctx.env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
const robotAccessToken = nullthrows(
|
||||
ctx.job.secrets?.robotAccessToken,
|
||||
'robot access token is not set'
|
||||
);
|
||||
|
||||
const response = await turtleFetch(
|
||||
new URL(
|
||||
`/v2/device-run-sessions/${deviceRunSessionId}/turn-ice-servers`,
|
||||
expoApiServerUrl
|
||||
).toString(),
|
||||
'POST',
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
},
|
||||
timeout: 5000,
|
||||
retries: 1,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
const { data } = TurnIceServersResponseSchema.parse(await response.json());
|
||||
const args = turnIceServersToWebPreviewArgs(data.iceServers);
|
||||
const iceServers = await fetchTurnIceServersAsync(ctx, { logger });
|
||||
const args = turnIceServersToWebPreviewArgs(iceServers);
|
||||
if (args.length > 0) {
|
||||
logger.info('Configured the web preview with Cloudflare TURN ICE servers.');
|
||||
}
|
||||
@@ -530,6 +480,17 @@ export async function fetchWebPreviewTurnArgsAsync(
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTurnIceServersAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{ logger }: { logger: bunyan }
|
||||
): Promise<TurnIceServers> {
|
||||
return await requireDeviceRunSessionWorkerApi(ctx).fetchTurnIceServersAsync({
|
||||
timeoutMs: 5000,
|
||||
retries: 1,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
@@ -544,12 +505,15 @@ export async function uploadRemoteSessionConfigAsync({
|
||||
logger.info(
|
||||
`Reporting remote config to the API server (device run session: ${deviceRunSessionId}).`
|
||||
);
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(START_DEVICE_RUN_SESSION_MUTATION, { deviceRunSessionId, remoteConfig })
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
|
||||
try {
|
||||
await workerApi.startAsync({ remoteConfig });
|
||||
} catch (err) {
|
||||
throw new SystemError(
|
||||
`Failed to start device run session ${deviceRunSessionId}: ${result.error.message}`
|
||||
`Failed to start device run session ${deviceRunSessionId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -717,6 +681,53 @@ export function spawnDetached({
|
||||
};
|
||||
}
|
||||
|
||||
export function createIosSessionStartupInputProviders(): ReturnType<
|
||||
typeof BuildStepInput.createProvider
|
||||
>[] {
|
||||
return [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'boot_simulator',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
}),
|
||||
...['device_identifier', 'install_app_path'].map(id =>
|
||||
BuildStepInput.createProvider({
|
||||
id,
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
})
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export type ServeSimApplicationOptions = ServeSimLaunchOptions & {
|
||||
installAppPath: string;
|
||||
launchAppIdentifier: string;
|
||||
};
|
||||
|
||||
export function validateServeSimLaunchOptions({
|
||||
launchArgs = [],
|
||||
openUrl,
|
||||
}: Pick<ServeSimLaunchOptions, 'launchArgs' | 'openUrl'>): void {
|
||||
// Reject oversized iOS command arguments before starting the host.
|
||||
if (
|
||||
launchArgs.length > 256 ||
|
||||
launchArgs.some(arg => arg.length > 8192 || arg.includes('\0')) ||
|
||||
launchArgs.reduce((bytes, arg) => bytes + Buffer.byteLength(arg) + 1, 0) > 128 * 1024
|
||||
) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
'iOS launch_args supports up to 256 arguments of 8192 characters each, without NUL, and at most 128 KiB total UTF-8 bytes including terminators.'
|
||||
);
|
||||
}
|
||||
if (openUrl && (openUrl.length > 8192 || openUrl.includes('\0'))) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
'iOS open_url supports at most 8192 characters, without NUL.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ServeSimLaunchOptions {
|
||||
launchAppIdentifier?: string;
|
||||
launchArgs?: string[];
|
||||
@@ -748,6 +759,80 @@ export function createServeSimLaunchInputProviders(): ReturnType<
|
||||
];
|
||||
}
|
||||
|
||||
/** Older session jobs omit this input and keep their existing Share button. */
|
||||
export function createPublicPreviewInputProvider(): ReturnType<
|
||||
typeof BuildStepInput.createProvider
|
||||
> {
|
||||
return BuildStepInput.createProvider({
|
||||
id: 'public_preview',
|
||||
required: false,
|
||||
defaultValue: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
});
|
||||
}
|
||||
|
||||
export function createPrivatePreviewUrlInputProvider(): ReturnType<
|
||||
typeof BuildStepInput.createProvider
|
||||
> {
|
||||
return BuildStepInput.createProvider({
|
||||
id: 'private_preview_url',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Origins that may embed the preview host in a frame, next to the website origins. The host
|
||||
* drops values that are not an origin or a subdomain wildcard, so this parser only guards
|
||||
* the input shape.
|
||||
*/
|
||||
export function createFrameAncestorsInputProvider(): ReturnType<
|
||||
typeof BuildStepInput.createProvider
|
||||
> {
|
||||
return BuildStepInput.createProvider({
|
||||
id: 'frame_ancestors',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseFrameAncestorsInput(value: unknown): string[] {
|
||||
return parseOriginListInput(value, 'frame_ancestors', 'EAS_FRAME_ANCESTORS_INVALID_INPUT');
|
||||
}
|
||||
|
||||
/**
|
||||
* Origins whose pages may call the preview host's API cross-origin, such as a page that renders
|
||||
* the device with @expo/hub-client. Framing alone does not need it. The host refuses values that
|
||||
* are not an origin or a subdomain wildcard, so this parser only guards the input shape.
|
||||
*/
|
||||
export function createCorsOriginsInputProvider(): ReturnType<typeof BuildStepInput.createProvider> {
|
||||
return BuildStepInput.createProvider({
|
||||
id: 'cors_origins',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseCorsOriginsInput(value: unknown): string[] {
|
||||
return parseOriginListInput(value, 'cors_origins', 'EAS_CORS_ORIGINS_INVALID_INPUT');
|
||||
}
|
||||
|
||||
function parseOriginListInput(value: unknown, inputId: string, errorCode: string): string[] {
|
||||
if (value === undefined) {
|
||||
return [];
|
||||
}
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
!value.every(origin => typeof origin === 'string' && origin.trim().length > 0)
|
||||
) {
|
||||
throw new UserError(
|
||||
errorCode,
|
||||
`Input "${inputId}" must be an array of non-empty strings, one origin each.`
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: rawLaunchAppIdentifier,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { constants } from 'node:fs';
|
||||
import { cp, mkdir, mkdtemp, realpath, rm } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
export async function stageServeSimAppAsync(
|
||||
appPath: string,
|
||||
tmpdir = os.tmpdir()
|
||||
): Promise<{
|
||||
directory: string;
|
||||
path: string;
|
||||
}> {
|
||||
// Clone into app.install's approved root while preserving the caller's app.
|
||||
const root = path.join(tmpdir, 'serve-sim-uploads');
|
||||
await mkdir(root, { recursive: true, mode: 0o700 });
|
||||
const directory = await mkdtemp(path.join(root, 'eas-app-'));
|
||||
const stagedPath = path.join(directory, path.basename(appPath));
|
||||
try {
|
||||
await cp(await realpath(appPath), stagedPath, {
|
||||
recursive: true,
|
||||
mode: constants.COPYFILE_FICLONE,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
return { directory, path: stagedPath };
|
||||
} catch (error) {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runServeSimActionAsync({
|
||||
port,
|
||||
token,
|
||||
action,
|
||||
params,
|
||||
timeoutMs,
|
||||
signal,
|
||||
}: {
|
||||
port: number;
|
||||
token: string;
|
||||
action: string;
|
||||
params: Record<string, unknown>;
|
||||
timeoutMs: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${port}/exec-ws`, [`serve-sim.token.${token}`]);
|
||||
let settled = false;
|
||||
let sent = false;
|
||||
const finish = (error?: unknown) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', aborted);
|
||||
socket.terminate();
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
const aborted = () => finish(signal?.reason ?? new Error('Serve-sim action aborted.'));
|
||||
const timer = setTimeout(
|
||||
() => finish(new SystemError(`serve-sim ${action} timed out.`)),
|
||||
timeoutMs
|
||||
);
|
||||
signal?.addEventListener('abort', aborted, { once: true });
|
||||
socket.on('error', () => finish(new SystemError(`serve-sim ${action} connection failed.`)));
|
||||
socket.on('close', () =>
|
||||
finish(new SystemError(`serve-sim ${action} connection closed before completion.`))
|
||||
);
|
||||
socket.on('message', data => {
|
||||
let reply;
|
||||
try {
|
||||
reply = JSON.parse(data.toString());
|
||||
} catch {
|
||||
return finish(new SystemError(`serve-sim ${action} returned invalid JSON.`));
|
||||
}
|
||||
if (reply?.ready === true && !sent) {
|
||||
sent = true;
|
||||
socket.send(JSON.stringify({ id: 1, action, params }));
|
||||
} else if (reply?.id === 1 && sent) {
|
||||
finish(
|
||||
reply.exitCode === 0
|
||||
? undefined
|
||||
: new SystemError(
|
||||
`serve-sim ${action} failed: ${(typeof reply.error === 'string' && reply.error) || (typeof reply.stderr === 'string' && reply.stderr) || 'invalid response'}`
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
if (signal?.aborted) {
|
||||
aborted();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -30,17 +30,20 @@ export type StartupTasks = {
|
||||
summary(): string;
|
||||
};
|
||||
|
||||
export function createStartupTasks(logger: bunyan): StartupTasks {
|
||||
export function createStartupTasks(logger: bunyan, parentSignal?: AbortSignal): StartupTasks {
|
||||
const startedAt = Date.now();
|
||||
const finished: { name: string; ms: number }[] = [];
|
||||
const controller = new AbortController();
|
||||
const signal = parentSignal
|
||||
? AbortSignal.any([parentSignal, controller.signal])
|
||||
: controller.signal;
|
||||
const abort = (reason: unknown): void => {
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort(reason);
|
||||
}
|
||||
};
|
||||
return {
|
||||
signal: controller.signal,
|
||||
signal,
|
||||
run<T>(name: string, fn: (taskLogger: bunyan) => Promise<T>): Promise<T> {
|
||||
const taskStartedAt = Date.now();
|
||||
logger.info(`Starting ${name}.`);
|
||||
@@ -61,7 +64,6 @@ export function createStartupTasks(logger: bunyan): StartupTasks {
|
||||
return promise;
|
||||
},
|
||||
untilAborted<T>(promise: Promise<T>): Promise<T> {
|
||||
const { signal } = controller;
|
||||
const result = new Promise<T>((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(signal.reason);
|
||||
|
||||
@@ -154,8 +154,10 @@ export namespace AndroidEmulatorUtils {
|
||||
try {
|
||||
let configIniFileContent = await fs.promises.readFile(configIniFile, 'utf-8');
|
||||
|
||||
logger.info('Setting hw.ramSize to 2048.');
|
||||
configIniFileContent = `${configIniFileContent}\nhw.ramSize=2048\n`;
|
||||
// Give interactive sessions more headroom for guest VSOCK packet allocations.
|
||||
const ramSize = env.DEVICE_RUN_SESSION_ID ? 4096 : 2048;
|
||||
logger.info(`Setting hw.ramSize to ${ramSize}.`);
|
||||
configIniFileContent = `${configIniFileContent}\nhw.ramSize=${ramSize}\n`;
|
||||
|
||||
const shouldResizeScreen =
|
||||
env.ANDROID_EMULATOR_ADJUST_SCREEN === 'true' || env.ANDROID_EMULATOR_ADJUST_SCREEN === '1';
|
||||
|
||||
@@ -136,7 +136,10 @@ describe('AndroidEmulatorUtils', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('does not apply an LCD configuration when it is omitted', async () => {
|
||||
it.each([
|
||||
[undefined, 2048],
|
||||
['device-run-session-id', 4096],
|
||||
])('configures memory for session %s without an LCD override', async (sessionId, ramSize) => {
|
||||
const deviceName = 'generic-emulator' as AndroidVirtualDeviceName;
|
||||
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
|
||||
const initialConfig = ['skin.path=pixel_9', 'showDeviceFrame=yes'].join('\n');
|
||||
@@ -156,12 +159,14 @@ describe('AndroidEmulatorUtils', () => {
|
||||
env: {
|
||||
HOME: '/home/expo',
|
||||
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
|
||||
DEVICE_RUN_SESSION_ID: sessionId,
|
||||
},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
const config = await fs.promises.readFile(`${avdDirectory}/config.ini`, 'utf8');
|
||||
expect(config).toContain(initialConfig);
|
||||
expect(config).toContain(`hw.ramSize=${ramSize}\n`);
|
||||
expect(config).not.toContain('hw.lcd.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,13 @@ import promiseRetry from 'promise-retry';
|
||||
export function promiseRetryWithCondition<TFn extends (...args: any[]) => Promise<any>>(
|
||||
fn: TFn,
|
||||
retryConditionFn: (error: any) => boolean,
|
||||
options: { retries: number; factor?: number; minTimeout?: number; maxTimeout?: number } = {
|
||||
options: {
|
||||
retries: number;
|
||||
factor?: number;
|
||||
minTimeout?: number;
|
||||
maxTimeout?: number;
|
||||
randomize?: boolean;
|
||||
} = {
|
||||
retries: 3,
|
||||
factor: 2,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# create-eas-build-function
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.0.0
|
||||
|
||||
No changes in this release.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "24.13.0",
|
||||
"version": "25.1.1",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "24.13.0"
|
||||
"@expo/steps": "25.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# @expo/downloader
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.0.0
|
||||
|
||||
No changes in this release.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "24.13.0",
|
||||
"version": "25.1.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# eas-build-cache-provider
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.0.0
|
||||
|
||||
No changes in this release.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "24.13.0",
|
||||
"version": "25.1.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",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# @expo/eas-build-job
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.0.0
|
||||
|
||||
No changes in this release.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "24.13.0",
|
||||
"version": "25.1.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,9 +25,9 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "24.13.0",
|
||||
"@expo/logger": "25.1.1",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "24.13.0",
|
||||
"@expo/turtle-spawn": "25.1.1",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# eas-cli
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Show EAS Simulator minutes in `eas account:usage`. ([#4583](https://github.com/expo/eas-cli/pull/4583) by [@LipLoody](https://github.com/LipLoody))
|
||||
- Add `--cors-origin` to `eas simulator:start`, so a third party page can call the simulator preview API, for example with `@expo/hub-client`. ([#4658](https://github.com/expo/eas-cli/pull/4658) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- Add `--frame-ancestor` to `eas simulator:start`, so a third party site can embed the simulator preview, and print the embeddable preview URL. ([#4656](https://github.com/expo/eas-cli/pull/4656) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
|
||||
## 25.0.0
|
||||
|
||||
### Major Changes
|
||||
|
||||
- Default new simulator sessions to authenticated preview links and omit preview credentials from private output; use `simulator:start --public-link` for public sharing, while `simulator:get` follows the saved preference. ([#4619](https://github.com/expo/eas-cli/pull/4619) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Use effectivePermissions for logic about capabilities and account:view display. ([#4637](https://github.com/expo/eas-cli/pull/4637) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Update error message for scoped VC in EAS CLI now that scoped can be used outside build. ([#4636](https://github.com/expo/eas-cli/pull/4636) by [@wschurman](https://github.com/wschurman))
|
||||
+208
-158
File diff suppressed because it is too large.
Load diff
Generated
+202
-6235
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "24.14.0",
|
||||
"version": "25.1.1",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,11 +48,12 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "24.13.0",
|
||||
"@expo/eas-json": "24.13.0",
|
||||
"@expo/eas-build-job": "25.1.1",
|
||||
"@expo/eas-json": "25.1.1",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/experimentation": "0.0.5",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "24.13.0",
|
||||
"@expo/logger": "25.1.1",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -64,7 +65,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "24.13.0",
|
||||
"@expo/steps": "25.1.1",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { mockCommandContext } from './utils';
|
||||
|
||||
jest.mock('fs');
|
||||
|
||||
class TestCommand extends EasCommand {
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.Experimentation,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {}
|
||||
}
|
||||
|
||||
describe(mockCommandContext, () => {
|
||||
it('provides a disabled experimentation client that returns defaults', async () => {
|
||||
const ctx = mockCommandContext(TestCommand, {});
|
||||
const client = await ctx.experimentation.getClientAsync();
|
||||
expect(client.getUserNamespace('any').getParam('variant', 'control')).toBe('control');
|
||||
expect(client.getAccountNamespace('any', { accountId: 'a' }).getParam('enabled', true)).toBe(
|
||||
true
|
||||
);
|
||||
expect(client.getDeviceNamespace('any').getParam('count', 3)).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,9 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { ContextInput, ContextOutput } from '../../commandUtils/EasCommand';
|
||||
import { DynamicConfigContextFn } from '../../commandUtils/context/DynamicProjectConfigContextField';
|
||||
import { ExperimentOverrides } from '../../experimentation/ExperimentOverrides';
|
||||
import { ExperimentationClient } from '../../experimentation/ExperimentationClient';
|
||||
import { LazyExperimentationClient } from '../../experimentation/LazyExperimentationClient';
|
||||
import {
|
||||
AppFragment,
|
||||
Role,
|
||||
@@ -17,6 +20,7 @@ import {
|
||||
WorkflowRunStatus,
|
||||
WorkflowRunTriggerEventType,
|
||||
} from '../../graphql/generated';
|
||||
import { getMockViewerUserPermission } from '../viewerUserPermission';
|
||||
|
||||
export function getMockEasJson(): EasJson {
|
||||
return {
|
||||
@@ -38,9 +42,7 @@ export function getMockAppFragment(): AppFragment {
|
||||
ownerAccount: {
|
||||
id: 'test-account-id',
|
||||
name: 'testuser',
|
||||
viewerUserPermission: {
|
||||
role: Role.Owner,
|
||||
},
|
||||
viewerUserPermission2: getMockViewerUserPermission(Role.Owner),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -97,6 +99,11 @@ export function mockCommandContext<
|
||||
if (contextKey === 'sessionManager') {
|
||||
result.sessionManager = {};
|
||||
}
|
||||
if (contextKey === 'experimentation') {
|
||||
result.experimentation = LazyExperimentationClient.fromClient(
|
||||
ExperimentationClient.createDisabled(ExperimentOverrides.empty())
|
||||
);
|
||||
}
|
||||
if (contextKey === 'projectConfig') {
|
||||
result.projectConfig = {
|
||||
exp: overrides.exp ?? getMockExpoConfig(),
|
||||
|
||||
Loaded 100 of 226 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user