Compare commits

..
Author SHA1 Message Date
Stanisław ChmielaandClaude Opus 5.5 fe9ade0c4e [worker] Mask secrets in all build log fields without stalling on stdout
Write to stdout without waiting for it to flush, so uploads and the error
log buffer are not held back. Mask every string field except logger
metadata, and serialize records safely so circular or BigInt values
cannot throw into the logging call site.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:04:22 +02:00
Stanisław Chmiela b31fa31acb [worker] Mask secrets before writing build logs to stdout 2026-10-15 09:53:13 +02:00
226 changed files with 9446 additions and 14449 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Speed up cold iOS Appium sessions by launching preinstalled WebDriverAgent.
-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': minor
---
Add experimentation client.
@@ -1,5 +0,0 @@
---
'eas-cli': minor
---
Add a Free plan build limit guidance experiment for coding agents to `eas build`.
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/worker': patch
---
Mask build secrets in every build log field, worker stdout and the build error log buffer.
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': minor
---
Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard.
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/build-tools': minor
---
Give agent-device and its simulator preview separate session logs.
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/build-tools': minor
---
Stream simulator preview output into its existing session step.
-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': minor
---
Add `eas simulator:simctl` to set the appearance of a remote iOS simulator session and restart its SpringBoard.
+1
View File
@@ -22,3 +22,4 @@ jobs:
uses: ./.github/actions/setup-mise
- run: yarn install --immutable
- run: yarn verify-graphql-code
working-directory: packages/eas-cli
-52
View File
@@ -2,58 +2,6 @@
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
View File
@@ -1,4 +1,4 @@
{
"STAGING": "25.1.1",
"PRODUCTION": "25.1.1"
"STAGING": "24.14.0",
"PRODUCTION": "24.14.0"
}
+2 -2
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "25.1.1",
"version": "24.14.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
}
-1
View File
@@ -7,7 +7,6 @@
"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",
-39
View File
@@ -1,39 +0,0 @@
# @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))
+7 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "25.1.1",
"version": "24.14.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -36,23 +36,22 @@
"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": "25.1.1",
"@expo/eas-build-job": "25.1.1",
"@expo/downloader": "24.13.0",
"@expo/eas-build-job": "24.13.0",
"@expo/env": "^0.4.0",
"@expo/logger": "25.1.1",
"@expo/logger": "24.13.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "25.1.1",
"@expo/template-file": "25.1.1",
"@expo/turtle-spawn": "25.1.1",
"@expo/steps": "24.13.0",
"@expo/template-file": "24.13.0",
"@expo/turtle-spawn": "24.13.0",
"@expo/xcpretty": "^4.3.1",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
@@ -71,17 +71,6 @@ 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
@@ -1,7 +0,0 @@
#!/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,49 +318,6 @@ 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(
-3
View File
@@ -69,7 +69,6 @@ export interface BuildContextOptions {
metadata?: Metadata;
expoApiV2BaseUrl?: string;
mcpServerUrl?: string;
addBuildLogMask?: (secret: string) => void;
}
export class SkipNativeBuildError extends Error {}
@@ -87,7 +86,6 @@ 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;
@@ -117,7 +115,6 @@ 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,7 +32,6 @@ 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 {
@@ -85,7 +84,6 @@ 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;
+15 -42
View File
@@ -1,24 +1,15 @@
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 }
// A device run session job with a session token. The token names the session and its job run.
| { kind: 'deviceRunSession' };
| { kind: 'jobRun'; turtleJobRunId: string };
type DatadogSetupOptions =
| {
expoApiV2BaseUrl: string;
robotAccessToken: string;
target: Exclude<MetricsTarget, { kind: 'deviceRunSession' }>;
}
| {
expoApiV2BaseUrl: string;
deviceRunSessionToken: string;
target: Extract<MetricsTarget, { kind: 'deviceRunSession' }>;
};
type DatadogSetupOptions = {
expoApiV2BaseUrl: string;
robotAccessToken: string;
target: MetricsTarget;
};
let setupOptions: DatadogSetupOptions | null = null;
let pendingUploads: Promise<unknown>[] = [];
@@ -27,30 +18,9 @@ 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;
@@ -60,7 +30,7 @@ export const Datadog = {
if (!setupOptions) {
return;
}
const { expoApiV2BaseUrl, target } = setupOptions;
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
const metrics = [
{
name,
@@ -70,11 +40,16 @@ export const Datadog = {
},
];
const { path: metricsPath, headers } = getMetricsRequest(setupOptions);
const metricsPath =
target.kind === 'build'
? `turtle-builds/${target.turtleBuildId}/metrics`
: `turtle-job-runs/${target.turtleJobRunId}/metrics`;
const uploadPromise = turtleFetch(new URL(metricsPath, expoApiV2BaseUrl).toString(), 'POST', {
json: { metrics },
headers,
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
retries: 2,
}).catch(err => {
Sentry.capture(`Failed to report turtle ${target.kind} metric`, err, {
@@ -86,9 +61,7 @@ export const Datadog = {
},
log(message: string, tags: Record<string, string> = {}): void {
// 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)) {
if (!setupOptions) {
return;
}
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
-1
View File
@@ -33,6 +33,5 @@ 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';
@@ -108,11 +108,7 @@ describe.each([
['Appium', createStartAppiumRemoteSessionBuildFunction],
['Agent Device', createStartAgentDeviceSessionBuildFunction],
] as const)('%s host ownership', (name, createFunction) => {
async function runAsync(
publicPreview?: boolean,
frameAncestors?: string[],
corsOrigins?: string[]
) {
async function runAsync() {
const fn = createFunction({} as CustomBuildContext);
await fn.fn!(
{
@@ -121,16 +117,9 @@ describe.each([
} as unknown as BuildStepContext,
{
// Inputs a step does not get default to undefined, like in a real step call.
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 },
}
),
inputs: new Proxy({} as Record<string, { value: unknown }>, {
get: (target, id: string) => target[id] ?? { value: undefined },
}),
outputs: {},
env: {
DEVICE_RUN_SESSION_ID: 'session-id',
@@ -143,7 +132,7 @@ describe.each([
it('holds the host until session completion', async () => {
await runAsync();
expect(openPreview).toHaveBeenCalledWith({ baseDomain: 'example.test', authtoken: 'token' });
expect(openPreview).toHaveBeenCalledWith({ baseDomain: 'example.test' });
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
@@ -162,59 +151,6 @@ 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,100 +591,6 @@ 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({
@@ -14,12 +14,6 @@ import {
registerLocalEgressResources,
stopLocalEgressResourcesAsync,
} from '../../utils/localEgress';
import {
reportLocalEgressGuardCoverageAsync,
resolveLocalEgressServeSimBootEnvironmentAsync,
startLocalEgressGuardRelayAsync,
verifyLocalEgressGuardAsync,
} from '../../utils/localEgressGuard';
import {
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
@@ -35,8 +29,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' }),
@@ -57,13 +51,6 @@ 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',
@@ -139,20 +126,9 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
jest.mocked(isProcessDescendantOfAsync).mockResolvedValue(true);
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ udid: 'sim', name: 'iPhone 17' }] as never);
jest.mocked(IosSimulatorUtils.resolveUdidAsync).mockResolvedValue('sim' as never);
.mockResolvedValue([{ udid: '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,7 +25,6 @@ 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' },
@@ -116,19 +115,6 @@ 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'],
@@ -176,7 +162,6 @@ 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);
@@ -70,13 +70,11 @@ const mockEventCollectionStopAsync = jest.fn();
type CaptureInputs = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['capture'];
const NO_CAPTURE: CaptureInputs = { networkCapture: false, networkCaptureFields: [] };
/** Runs an Android session that is ready, or an iOS session with no application preparation. */
/** Runs the session for a device that is already booted, with the app (if any) launched. */
async function runAsync(
logger: { info: jest.Mock; warn: jest.Mock; child: jest.Mock },
runtimePlatform: BuildRuntimePlatform,
capture: CaptureInputs = NO_CAPTURE,
disableShare = false,
privatePreviewUrl?: string
capture: CaptureInputs = NO_CAPTURE
): Promise<void> {
await runAgentDeviceRemoteSessionAsync(ctx, {
env: {},
@@ -91,13 +89,8 @@ async function runAsync(
maxIdleTimeMinutes: undefined,
maxDurationSeconds: undefined,
capture,
disableShare,
privatePreviewUrl,
tasks: createStartupTasks(logger as never),
device:
runtimePlatform === BuildRuntimePlatform.DARWIN
? { iosSimulatorUdid: 'selected-udid', application: Promise.resolve() }
: { booted: Promise.resolve(), ready: Promise.resolve() },
device: { booted: Promise.resolve(), ready: Promise.resolve() },
});
}
@@ -127,23 +120,13 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
subdomainId: 'agent-device-abc',
stopAsync: mockTunnelStopAsync,
});
const host = {
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: 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);
@@ -163,7 +146,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, timeoutMs: 60_000 })
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
@@ -216,24 +199,6 @@ 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()!(
@@ -302,14 +267,14 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
});
describe('runAgentDeviceRemoteSessionAsync with a device that is still starting', () => {
function deferred<T = void>(): {
promise: Promise<T>;
resolve: (value: T) => void;
function deferred(): {
promise: Promise<void>;
resolve: () => void;
reject: (e: Error) => void;
} {
let resolve!: (value: T) => void;
let resolve!: () => void;
let reject!: (e: Error) => void;
const promise = new Promise<T>((res, rej) => {
const promise = new Promise<void>((res, rej) => {
resolve = res;
reject = rej;
});
@@ -317,26 +282,18 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
return { promise, resolve, reject };
}
type Device = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['device'];
function startSession(
device: Device | ((tasks: StartupTasks) => Device),
signal?: AbortSignal
) {
type Device = { booted: Promise<unknown>; ready: Promise<unknown> };
function startSession(device: Device | ((tasks: StartupTasks) => Device)) {
const logger = {
info: jest.fn(),
warn: jest.fn(),
child: jest.fn().mockReturnThis(),
} as never;
const tasks = createStartupTasks(logger, signal);
const sessionDevice = typeof device === 'function' ? device(tasks) : device;
const tasks = createStartupTasks(logger);
return runAgentDeviceRemoteSessionAsync(ctx, {
env: {},
logger,
signal,
runtimePlatform:
'iosSimulatorUdid' in sessionDevice
? BuildRuntimePlatform.DARWIN
: BuildRuntimePlatform.LINUX,
runtimePlatform: BuildRuntimePlatform.DARWIN,
sessionEnv: {
deviceRunSessionId: 'device-run-session-id',
ngrokTunnelDomain: 'tunnel.example.com',
@@ -347,7 +304,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
maxDurationSeconds: undefined,
capture: NO_CAPTURE,
tasks,
device: sessionDevice,
device: typeof device === 'function' ? device(tasks) : device,
});
}
@@ -357,31 +314,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
}
}
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 () => {
it('starts the daemon during the boot and the session host after it', async () => {
const booted = deferred();
const session = startSession({ booted: booted.promise, ready: booted.promise });
await flushAsync();
@@ -393,237 +326,10 @@ 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('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 () => {
it('reports the session as ready only after the app is launched', async () => {
const ready = deferred();
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
await flushAsync();
@@ -633,11 +339,10 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
ready.resolve();
await session;
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
});
it('stops waiting for the Android boot when the daemon fails', async () => {
it('stops waiting for the boot when the daemon fails', async () => {
jest.mocked(waitForFileAsync).mockRejectedValue(new Error('no daemon credentials'));
const neverBooted = new Promise<void>(() => {});
@@ -651,7 +356,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('cancels daemon installation and stops the Android host when the download fails', async () => {
it('stops the session host when the app fails while the daemon install hangs', async () => {
jest.mocked(spawn).mockImplementation(((
command: string,
args: string[],
@@ -667,22 +372,21 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
await flushAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
ready.reject(new Error('download failed'));
ready.reject(new Error('simctl install failed'));
await expect(session).rejects.toThrow('download failed');
await expect(session).rejects.toThrow('simctl install failed');
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(spawnDetached).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('stops the Android host, daemon and tunnel when the download fails', async () => {
it('stops everything it started when the app install fails', async () => {
const ready = deferred();
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
await flushAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
ready.reject(new Error('download failed'));
ready.reject(new Error('simctl install failed'));
await expect(session).rejects.toThrow('download failed');
await expect(session).rejects.toThrow('simctl install failed');
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
@@ -1,20 +1,10 @@
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,
@@ -24,30 +14,17 @@ import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceS
import { startAndroidEmulatorAsync } from '../startAndroidEmulator';
import { bootIosSimulatorAsync } from '../startIosSimulator';
jest.mock('../../utils/localEgress', () => ({
stopLocalEgressResourcesAsync: jest.fn().mockResolvedValue(undefined),
jest.mock('../../utils/localEgressSession', () => ({
withLocalEgressSession: (fn: unknown) => fn,
}));
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', () => ({
...jest.requireActual('../startIosSimulator'),
bootIosSimulatorAsync: jest.fn(),
}));
jest.mock('../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(),
@@ -65,17 +42,7 @@ const sessionEnv = {
ngrokAuthtoken: 'ngrok-token',
};
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;
});
}
type Device = { booted: Promise<unknown>; ready: Promise<unknown> };
function deferred<T = void>(): {
promise: Promise<T>;
@@ -100,11 +67,9 @@ async function flushAsync(): Promise<void> {
function runStep(
runtimePlatform: BuildRuntimePlatform,
inputValues: Record<string, unknown> = {},
signal?: AbortSignal,
stepCtx: CustomBuildContext = ctx
inputValues: Record<string, unknown> = {}
): Promise<void> {
const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx);
const buildFunction = createStartAgentDeviceSessionBuildFunction(ctx);
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const inputs = Object.fromEntries(
[
@@ -119,10 +84,6 @@ 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',
@@ -136,32 +97,22 @@ function runStep(
staticContext: { job: { secrets: { robotAccessToken: 'robot-token' } } },
},
} as unknown as BuildStepContext,
{ inputs, outputs: {}, env: {}, signal } as never
{ inputs, outputs: {}, env: {}, signal: undefined } 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, tasks }) => {
await tasks.untilAborted(runDevicePreparationAsync(device));
});
jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async (_ctx, { device }) => {
await device.ready;
});
jest.mocked(bootIosSimulatorAsync).mockResolvedValue({
deviceIdentifier: 'iPhone 17' as never,
udid: 'udid' as never,
@@ -177,31 +128,22 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
.mocked(installBuildAsync)
.mockResolvedValue({ applicationIdentifier: 'dev.example.app', activityName: '.Main' });
jest.mocked(launchApplicationAsync).mockResolvedValue(undefined);
jest.mocked(readIosApplicationIdentifierAsync).mockResolvedValue('dev.example.app');
});
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, {
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, {
build_id: 'build-id',
launch_args: ['-flag'],
open_url: 'exp://example.test',
});
await flushAsync();
expect(
jest.mocked(selectXcodeDeveloperDirectoryAsync).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();
).toBeLessThan(jest.mocked(bootIosSimulatorAsync).mock.invocationCallOrder[0]);
expect(downloadBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({
buildId: 'build-id',
@@ -211,180 +153,32 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
})
);
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(1);
const device = sessionDevice();
expect(device).toEqual({
iosSimulatorUdid: 'SELECTED-UDID',
bootEnv,
application: expect.any(Promise),
});
expect(spawn).not.toHaveBeenCalled();
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
artifactPath: '/tmp/App.app',
env: {},
});
// The install needs the booted Simulator.
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',
});
});
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',
boot.resolve({
deviceIdentifier: 'iPhone 17' as never,
udid: 'udid' as never,
displayName: '',
});
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('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(installBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({ artifactPath: '/tmp/App.app' })
);
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(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]
);
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 () => {
it('waits for the download when the Simulator boots first', async () => {
const download = deferred<{ artifactPath: string }>();
jest.mocked(downloadBuildAsync).mockReturnValue(download.promise);
@@ -396,21 +190,16 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
download.resolve({ artifactPath: '/tmp/App.app' });
await step;
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
expect(installBuildAsync).not.toHaveBeenCalled();
expect(installBuildAsync).toHaveBeenCalledTimes(1);
});
it('passes the selected Simulator to the host even when there is no app', async () => {
it('makes the session ready when the device boots if there is no app', async () => {
await runStep(BuildRuntimePlatform.DARWIN);
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
expect(sessionDevice()).toEqual({
iosSimulatorUdid: 'SELECTED-UDID',
bootEnv: undefined,
application: undefined,
});
await expect(sessionDevice().ready).resolves.toBeUndefined();
});
it('boots the Android Emulator with the device inputs', async () => {
@@ -442,10 +231,23 @@ 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'));
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'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;
expect(installBuildAsync).not.toHaveBeenCalled();
});
@@ -474,24 +276,23 @@ 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, ready]);
await Promise.all([daemon, device.ready]);
} finally {
await Promise.allSettled([daemon, ready]);
await Promise.allSettled([daemon, device.ready]);
}
});
}
it('stops the download and waits for it when Android boot fails', async () => {
it('stops the download and waits for it when the boot fails', async () => {
const download = mockStalledDownload();
jest.mocked(startAndroidEmulatorAsync).mockRejectedValue(new Error('boot failed'));
jest.mocked(bootIosSimulatorAsync).mockRejectedValue(new Error('boot failed'));
await expect(runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' })).rejects.toThrow(
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'boot failed'
);
@@ -515,79 +316,25 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
expect(launchApplicationAsync).not.toHaveBeenCalled();
});
it('drains the download when host startup fails before app preparation', async () => {
const download = mockStalledDownload();
jest.mocked(runAgentDeviceRemoteSessionAsync).mockRejectedValue(new Error('host failed'));
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'host failed'
);
expect(download.aborted()).toBe(true);
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
});
it('does not start work for an already cancelled step', async () => {
const controller = new AbortController();
controller.abort(new Error('cancelled'));
await expect(
runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal)
).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;
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;
});
mockSessionWithFailingDaemon(async () => {
await metadataStarted.promise;
setImmediate(() => metadata.resolve('dev.example.app'));
await installStarted.promise;
// The install finishes only after the daemon failed.
setImmediate(() => install.resolve({ applicationIdentifier: 'dev.example.app' }));
});
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'daemon failed'
);
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
expect(installBuildAsync).not.toHaveBeenCalled();
expect(installBuildAsync).toHaveBeenCalledTimes(1);
expect(launchApplicationAsync).not.toHaveBeenCalled();
expect(deviceLaunch).toBeUndefined();
});
it('rejects conflicting application inputs before it boots anything', async () => {
@@ -623,78 +370,12 @@ 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,
@@ -1,4 +1,4 @@
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import { BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
@@ -6,15 +6,7 @@ import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
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 { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
import {
createStartAppiumRemoteSessionBuildFunction,
@@ -32,26 +24,8 @@ 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;
@@ -194,126 +168,6 @@ describe('createStartAppiumRemoteSessionBuildFunction', () => {
expect(
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).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]
);
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
});
});
@@ -6,12 +6,10 @@ 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,
@@ -121,21 +119,18 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
await fs.promises.rm(TEST_HOME, { recursive: true, force: true });
});
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);
it('reports an early exit with output before opening tunnels or publishing readiness', async () => {
jest.mocked(spawnDetached).mockReturnValue({
pid: 4242,
getOutput: () => 'could not bind server port',
getExitError: () => new Error('process exited with code 1'),
stopAsync: stopServer,
stopAsync: jest.fn(),
});
const logger = { info: jest.fn(), warn: jest.fn() };
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
await expect(
buildFunction.fn!(
{
logger,
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
@@ -150,66 +145,11 @@ 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 => {
@@ -248,59 +188,6 @@ 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);
@@ -381,7 +268,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, disableShare: false })
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
@@ -394,82 +281,6 @@ 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
@@ -507,52 +318,6 @@ 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() };
@@ -625,15 +390,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
expect(
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual(
expect.arrayContaining([
'launch_app_identifier',
'launch_args',
'open_url',
'public_preview',
'private_preview_url',
])
);
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
});
it('stops automation without waiting for recording upload', async () => {
@@ -32,7 +32,6 @@ 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;
@@ -3,7 +3,6 @@ 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,
@@ -15,22 +14,6 @@ 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(),
@@ -41,8 +24,7 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
}));
const ctx = {} as CustomBuildContext;
const env = { NGROK_AUTHTOKEN: 'ngrok-token', PATH: '/usr/bin' } as BuildStepEnv;
const childEnv = { PATH: '/usr/bin' } as BuildStepEnv;
const env = {} as BuildStepEnv;
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const stopAsync = jest.fn();
@@ -77,7 +59,6 @@ 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(),
}),
@@ -92,7 +73,6 @@ 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(),
@@ -115,54 +95,6 @@ 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') {
@@ -183,46 +115,21 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
[BuildRuntimePlatform.DARWIN, true],
[BuildRuntimePlatform.LINUX, false],
])('starts the web preview for %s', async (runtimePlatform, selectsXcode) => {
await runAsync(runtimePlatform, selectsXcode ? { boot_simulator: { value: true } } : {});
await runAsync(runtimePlatform);
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: childEnv,
env,
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',
@@ -253,15 +160,8 @@ 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',
]);
@@ -288,64 +188,6 @@ 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,11 +15,6 @@ 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');
@@ -179,7 +174,7 @@ it('preserves the original failure when cleanup fails', async () => {
api()
.put(`${editPath}/tracks/internal`)
.reply(400, { error: { message: 'unknown track' } });
api().delete(editPath).times(6).reply(500);
api().delete(editPath).reply(500);
await expect(submission().run()).rejects.toMatchObject({
cause: expect.objectContaining({ apiMessage: 'unknown track' }),
});
@@ -188,7 +183,6 @@ 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)
@@ -226,32 +220,3 @@ 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,10 +28,6 @@ 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);
@@ -122,7 +118,6 @@ 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,
@@ -136,22 +131,13 @@ 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();
@@ -177,71 +163,17 @@ 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, deviceRunSessionWorkerApi, robotAccessToken, extensions, signal } =
params;
const { logger, graphqlClient, robotAccessToken, extensions, signal } = params;
let downloadUrl: string;
let headers: { Authorization: string } | undefined;
@@ -256,19 +188,8 @@ export async function downloadBuildAsync(
headers = undefined;
} else if (params.buildId) {
const buildId = z.string().uuid().parse(params.buildId);
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;
downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient, signal });
headers = robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined;
} else {
throw new UserError(
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
@@ -96,9 +96,6 @@ 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');
@@ -123,7 +120,6 @@ 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 { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import { type BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
@@ -19,7 +19,6 @@ 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,
@@ -77,9 +76,13 @@ 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.
*
* 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.
* 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.
*/
export async function runAgentDeviceRemoteSessionAsync(
ctx: CustomBuildContext,
@@ -93,10 +96,6 @@ export async function runAgentDeviceRemoteSessionAsync(
maxIdleTimeMinutes,
maxDurationSeconds,
capture,
disableShare = false,
privatePreviewUrl,
frameAncestors,
corsOrigins,
tasks,
device,
}: {
@@ -109,26 +108,10 @@ 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>;
}
| {
iosSimulatorUdid: string;
bootEnv?: Record<string, string>;
application?: Promise<ServeSimApplicationOptions | void>;
};
device: { booted: Promise<unknown>; ready: Promise<unknown> };
}
): Promise<void> {
signal?.throwIfAborted();
tasks.signal.throwIfAborted();
logger.info(
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
);
@@ -139,8 +122,6 @@ 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.
@@ -169,14 +150,12 @@ 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 => {
if (externallyBootedDevice) {
await tasks.untilAborted(externallyBootedDevice.booted);
}
// A boot cannot be cancelled, so stop waiting for it when startup is aborted.
await tasks.untilAborted(device.booted);
tasks.signal.throwIfAborted();
sessionHost = await startDeviceSessionHostAsync(ctx, {
separateLogPhase: true,
@@ -184,45 +163,28 @@ 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(
webPreview.sharePageUrl
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
);
return webPreview;
});
const deviceReady = externallyBootedDevice?.ready;
try {
const [
{ port: daemonPort, token: daemonToken, remoteSessionUrl: agentDeviceRemoteSessionUrl },
webPreview,
] = await Promise.all([agentDeviceStartup, sessionHostStartup, deviceReady]).catch(
] = await Promise.all([agentDeviceStartup, sessionHostStartup, device.ready]).catch(
(err: unknown) => {
// Also aborts for a `device.ready` that does not come from `tasks.run`.
tasks.abort(err);
throw tasks.signal.reason;
throw err;
}
);
tasks.signal.throwIfAborted();
logger.info(tasks.summary());
await uploadRemoteSessionConfigWithLocalEgressAsync({
@@ -239,7 +201,6 @@ export async function runAgentDeviceRemoteSessionAsync(
},
logger,
});
tasks.signal.throwIfAborted();
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
daemonUrl: `http://127.0.0.1:${daemonPort}`,
@@ -275,7 +236,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, deviceReady]);
await Promise.allSettled([agentDeviceStartup, sessionHostStartup, device.ready]);
const startedDaemon = daemonProcess;
await finishRemoteSessionAsync({
logger,
@@ -319,7 +280,6 @@ 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-'));
@@ -329,7 +289,6 @@ 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,27 +15,14 @@ import {
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../../utils/AndroidEmulatorUtils';
import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi';
import { IosSimulatorName, IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
import { withLocalEgressSession } from '../utils/localEgressSession';
import {
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createPrivatePreviewUrlInputProvider,
createPublicPreviewInputProvider,
omitNgrokAuthtoken,
parseCorsOriginsInput,
parseFrameAncestorsInput,
selectXcodeDeveloperDirectoryAsync,
} from '../utils/remoteDeviceRunSession';
import { 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';
@@ -49,15 +36,26 @@ import {
runAgentDeviceRemoteSessionAsync,
} from './startAgentDeviceRemoteSession';
import { startAndroidEmulatorAsync } from './startAndroidEmulator';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
import { readIosApplicationIdentifierAsync } from '../utils/iosAppArtifact';
import { bootIosSimulatorAsync } from './startIosSimulator';
const ANDROID_DEVICE_NAME = 'EasAndroidDevice01' as AndroidVirtualDeviceName;
/**
* 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.
* 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.
*/
export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
@@ -116,10 +114,6 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
}),
// Session.
...createNetworkCaptureInputProviders(),
createPublicPreviewInputProvider(),
createPrivatePreviewUrlInputProvider(),
createFrameAncestorsInputProvider(),
createCorsOriginsInputProvider(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -136,14 +130,13 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: withLocalEgressSession(async (stepsCtx, { inputs, env: stepEnv, signal }) => {
fn: withLocalEgressSession(async (stepsCtx, { inputs, env, signal }) => {
const { logger, global } = stepsCtx;
const { runtimePlatform } = global;
const isIos = runtimePlatform === BuildRuntimePlatform.DARWIN;
// Validate everything before any expensive work starts.
const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(stepEnv);
const env = omitNgrokAuthtoken(stepEnv);
const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(env);
const buildId = inputs.build_id.value as string | undefined;
const applicationArchiveUrl = inputs.application_archive_url.value as string | undefined;
if (buildId && applicationArchiveUrl) {
@@ -162,9 +155,6 @@ 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(
{
@@ -174,137 +164,104 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
{ runtimePlatform }
);
const tasks = createStartupTasks(logger, signal);
let downloaded: Promise<string> | undefined;
let applicationReady: Promise<ServeSimApplicationOptions | void> | undefined;
try {
if (isIos) {
// Select Xcode before serve-sim boots the device or any Simulator tools run.
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
tasks.signal.throwIfAborted();
const { iosStartup } = isIos
? await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: true,
deviceIdentifier,
env,
logger,
signal: tasks.signal,
})
: {};
const iosSimulatorUdid = iosStartup?.iosSimulatorUdid;
if (iosSimulatorUdid) {
logger.info(`Selected iOS Simulator: ${iosSimulatorUdid}.`);
}
const booted = iosSimulatorUdid
? undefined
: tasks.run('Android Emulator boot', async taskLogger => {
const logcatDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
);
tasks.signal.throwIfAborted();
await startAndroidEmulatorAsync({
deviceName: ANDROID_DEVICE_NAME,
systemImagePackage: `${inputs.system_image_package.value}`,
deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined,
lcdWidth: inputs.lcd_width.value as number | undefined,
lcdHeight: inputs.lcd_height.value as number | undefined,
lcdDensity: inputs.lcd_density.value as number | undefined,
logcatDirectory,
env,
logger: taskLogger,
});
});
downloaded = hasApplication
? tasks.run('build download', async taskLogger => {
const { artifactPath } = await downloadBuildAsync({
logger: taskLogger,
...(buildId ? { buildId } : { applicationArchiveUrl: applicationArchiveUrl! }),
graphqlClient: ctx.graphqlClient,
deviceRunSessionWorkerApi: buildId ? getDeviceRunSessionWorkerApi(ctx) : null,
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
extensions: isIos ? ['app'] : ['apk'],
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]);
if (isIos) {
// Before the boot, so every Xcode tool below uses the same developer directory.
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
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,
});
}
);
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'],
signal: tasks.signal,
});
return artifactPath;
})
: undefined;
// `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,
});
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 },
});
}),
});
}
@@ -37,10 +37,6 @@ import { turtleFetch } from '../../utils/turtleFetch';
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
import { preparePreinstalledWdaAsync } from '../utils/preinstalledWda';
import {
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createPrivatePreviewUrlInputProvider,
createPublicPreviewInputProvider,
createServeSimLaunchInputProviders,
describeServeSimLaunch,
ensureFfmpegInstalledOnceAsync,
@@ -48,9 +44,6 @@ import {
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
omitNgrokAuthtoken,
parseCorsOriginsInput,
parseFrameAncestorsInput,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
@@ -79,10 +72,6 @@ export function createStartAppiumRemoteSessionBuildFunction(
inputProviders: [
...createServeSimLaunchInputProviders(),
...createNetworkCaptureInputProviders(),
createPublicPreviewInputProvider(),
createPrivatePreviewUrlInputProvider(),
createFrameAncestorsInputProvider(),
createCorsOriginsInputProvider(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -94,11 +83,10 @@ export function createStartAppiumRemoteSessionBuildFunction(
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
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);
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
const packageVersion = inputs.package_version.value as string | undefined;
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
const { runtimePlatform } = global;
@@ -162,20 +150,25 @@ 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',
@@ -191,25 +184,17 @@ 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,
authtoken: ngrokAuthtoken,
});
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
@@ -256,13 +241,10 @@ export function createStartAppiumRemoteSessionBuildFunction(
'Appium server',
(async () => {
try {
await eventCollection?.stopAsync();
await eventCollection.stopAsync();
} finally {
try {
await appiumProcess.stopAsync();
} finally {
await fs.promises.rm(appiumHome, { recursive: true, force: true });
}
await appiumProcess.stopAsync();
await fs.promises.rm(appiumHome, { recursive: true, force: true });
}
})(),
],
@@ -34,10 +34,6 @@ 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,
@@ -45,9 +41,6 @@ import {
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
omitNgrokAuthtoken,
parseCorsOriginsInput,
parseFrameAncestorsInput,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
@@ -91,10 +84,6 @@ export function createStartArgentRemoteSessionBuildFunction(
inputProviders: [
...createServeSimLaunchInputProviders(),
...createNetworkCaptureInputProviders(),
createPublicPreviewInputProvider(),
createPrivatePreviewUrlInputProvider(),
createFrameAncestorsInputProvider(),
createCorsOriginsInputProvider(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -111,15 +100,14 @@ export function createStartArgentRemoteSessionBuildFunction(
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env: stepEnv, signal }) => {
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, 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(stepEnv);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(stepEnv);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(stepEnv);
const env = omitNgrokAuthtoken(stepEnv);
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
const packageVersion = inputs.package_version.value as string | undefined;
// A missing or non-positive value disables the idle timeout (opt-in feature).
@@ -209,13 +197,10 @@ 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(
@@ -223,55 +208,50 @@ 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();
let artifactPollingPromise: Promise<void> | undefined;
let eventCollection: Awaited<ReturnType<typeof startArgentEventCollectionAsync>> | undefined;
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 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',
@@ -288,29 +268,19 @@ 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,
authtoken: ngrokAuthtoken,
});
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
logger.info(
webPreview.sharePageUrl
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
);
await uploadRemoteSessionConfigWithLocalEgressAsync({
@@ -355,13 +325,11 @@ export function createStartArgentRemoteSessionBuildFunction(
'Argent tool-server',
(async () => {
try {
if (eventCollection) {
await stopArgentEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
}
await stopArgentEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
artifactPollAbortController.abort();
try {
await artifactPollingPromise;
@@ -25,7 +25,6 @@ import {
findAvailablePortAsync,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
omitNgrokAuthtoken,
startNgrokTunnelAsync,
} from '../utils/remoteDeviceRunSession';
@@ -77,11 +76,11 @@ function awaitLocalEgressAcquisitionAsync<T>(
/**
* Points the device host's system HTTP(S) proxy at the EAS CLI's egress client.
* 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
* 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
* androidLocalEgress.ts. The shared session cleanup releases the resources
* started here when the session ends, with a job finalizer as a fallback.
*/
@@ -92,10 +91,9 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
name: 'Start local egress',
__metricsId: 'eas/start_local_egress',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX],
fn: async ({ logger, global }, { env: stepEnv, signal }) => {
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(stepEnv);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(stepEnv);
const env = omitNgrokAuthtoken(stepEnv);
fn: async ({ logger, global }, { env, signal }) => {
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
const isAndroid = global?.runtimePlatform === BuildRuntimePlatform.LINUX;
let workDir: string | undefined;
let server: DetachedProcessHandle | undefined;
@@ -7,7 +7,6 @@ import {
import { CustomBuildContext } from '../../customBuildContext';
import { startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
import {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
@@ -17,19 +16,11 @@ import {
withLocalEgressSession,
} from '../utils/localEgressSession';
import {
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createIosSessionStartupInputProviders,
createPrivatePreviewUrlInputProvider,
createPublicPreviewInputProvider,
createServeSimLaunchInputProviders,
describeServeSimLaunch,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
omitNgrokAuthtoken,
parseCorsOriginsInput,
parseFrameAncestorsInput,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
@@ -46,12 +37,7 @@ 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,
@@ -63,29 +49,20 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
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);
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
const packageVersion = inputs.package_version?.value as string | undefined;
const { runtimePlatform } = global;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
installAppPath: inputs.install_app_path?.value as string | undefined,
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
env,
logger,
signal,
});
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 }
);
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
@@ -100,18 +77,16 @@ 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,
@@ -120,15 +95,8 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
});
try {
const webPreview = await sessionHost.openPreviewAsync({
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
});
logger.info(
webPreview.sharePageUrl
? `Preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
logger.info(`Preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`);
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, ctx.logger);
const client = new GooglePlayClient(credentials);
const versionCode = await submitToGooglePlayAsync({
artifactType: submission.artifact_type,
client,
@@ -185,19 +185,7 @@ async function submitToGooglePlayAsync({
`Submitting ${packageName} (${artifactType}) to track ${JSON.stringify(submission.track)}.`
);
logger.info(
`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.'
`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}.`
);
if (submission.changelog) {
logger.info(`Changelog (en-US): ${JSON.stringify(submission.changelog)}`);
@@ -228,8 +216,7 @@ 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('Committing the Google Play edit...');
logger.info('Updated Google Play track ' + JSON.stringify(submission.track) + ' in the edit.');
// Commit also validates the edit.
await GooglePlayUtils.commitEditAsync(client, {
packageName,
@@ -245,7 +232,7 @@ async function submitToGooglePlayAsync({
return versionCode;
} catch (error) {
commitOutcomeUnknown = error instanceof GooglePlayCommitOutcomeUnknownError;
throw GooglePlayUtils.mapGooglePlayError(error, submission.package_name);
throw GooglePlayUtils.mapGooglePlayError(error);
} finally {
if (editId && !committed && !commitOutcomeUnknown) {
try {
@@ -256,7 +243,7 @@ async function submitToGooglePlayAsync({
});
} catch {
logger.warn(
'Could not discard the pending Google Play changes. Check the release in Play Console before submitting again.'
'Could not delete the Google Play edit after failure. The original submission error is preserved.'
);
}
}
@@ -7,12 +7,7 @@ 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),
@@ -33,18 +28,10 @@ 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 () => {
@@ -101,13 +88,9 @@ describe(startAgentDeviceEventCollectionAsync, () => {
await fs.promises.rm(stateDir, { recursive: true, force: true });
}
expect(turtleFetch).toHaveBeenCalledWith(
EVENT_LOG_UPLOAD_SESSION_URL,
'POST',
expect.objectContaining({
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
})
);
expect(ctx.graphqlClient.mutation).toHaveBeenCalledWith(expect.anything(), {
deviceRunSessionId: 'session-id',
});
expect(RemoteLoggerStream).toHaveBeenCalledWith({
logger,
uploadMethod: {
@@ -552,13 +535,11 @@ 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(turtleFetch).mockResolvedValueOnce(
new Response(JSON.stringify({ errors: [{ message: 'WWW unavailable' }] }), {
status: 503,
headers: { 'content-type': 'application/json' },
})
);
jest.mocked(ctx.graphqlClient.mutation).mockReturnValueOnce({
toPromise: async () => ({ error }),
} as unknown as ReturnType<CustomBuildContext['graphqlClient']['mutation']>);
const logger = createLogger();
const collection = await startAgentDeviceEventCollectionAsync({
@@ -573,11 +554,8 @@ describe(startAgentDeviceEventCollectionAsync, () => {
expect(logger.warn).toHaveBeenCalledWith(
{
err: expect.objectContaining({
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),
message: 'Failed to create device run session event log upload session: WWW unavailable',
cause: error,
}),
},
'Could not persist device run session events to the artifact.'
@@ -585,11 +563,8 @@ 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: ' +
'POST /v2/device-run-session-worker/event-log-upload-session failed with status 503: ' +
'WWW unavailable',
cause: expect.any(DeviceRunSessionWorkerApiError),
message: 'Failed to create device run session event log upload session: WWW unavailable',
cause: error,
}),
{
level: 'warning',
@@ -694,33 +669,30 @@ describe(startAgentDeviceEventCollectionAsync, () => {
function createContext({
realtimeLogs = false,
}: { realtimeLogs?: boolean } = {}): CustomBuildContext {
return {
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({
const mutation = jest.fn().mockReturnValue({
toPromise: async () => ({
data: {
uploadSession: {
url: 'https://uploads.expo.test/events',
headers: {
'content-disposition': 'attachment; filename="events.ndjson"',
'x-goog-content-length-range': '0,104857600',
deviceRunSession: {
createEventLogUploadSession: {
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' } }
);
});
return {
graphqlClient: { mutation },
env: realtimeLogs ? { EXPO_STAGING: '1', EAS_BUILD_ID: 'job-run-id' } : {},
job: {
secrets: realtimeLogs ? { robotAccessToken: 'robot-access-token' } : {},
},
} as unknown as CustomBuildContext;
}
function createLogger(): bunyan {
@@ -140,7 +140,6 @@ function createOptions(
pathEnv: '/usr/local/bin:/usr/bin',
npmCacheUrl: 'https://npm-cache.expo.test',
logger: createMockLogger(),
addBuildLogMask: jest.fn(),
signal: new AbortController().signal,
...overrides,
};
@@ -221,22 +220,23 @@ describe(runAgentAsync, () => {
expect(agent.stdin).toHaveBeenCalledWith('Fix the failing test.');
});
it('writes the agent output as log lines', async () => {
it('writes the agent output as log lines with the leased credentials masked', async () => {
leaseMock.mockResolvedValue(anthropicLease);
const logger = createMockLogger();
mockSpawn({
onAgent: agent => {
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));
// One JSON event split across two chunks, in the middle of the token.
agent.child.stdout.write(
'{"type":"assistant","message":{"content":[{"type":"text","text":"Done.\\nThe tests pass.\\n"}]}}\n'
'{"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'
);
agent.child.stdout.write(
'{"type":"result","subtype":"error_during_execution","is_error":true,"errors":["boom"]}\n'
);
agent.child.stderr.write('warning: deprecated flag\n');
agent.child.stderr.write('warning: anthropic-access-token\n');
agent.exit(0);
},
});
@@ -247,54 +247,11 @@ 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: 3 tests passed'],
['Agent: Done.\nThe tests pass.'],
['Tool result: token: [redacted]'],
['Agent: Done.\nThe token was [redacted].'],
]);
expect(stdoutLogger.error.mock.calls).toEqual([['Agent failed: boom']]);
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);
expect(stderrLogger.info.mock.calls).toEqual([['warning: [redacted]']]);
});
it('reports a line the formatter cannot handle instead of ending the run', async () => {
@@ -470,7 +427,12 @@ describe(runAgentAsync, () => {
leaseMock.mockResolvedValueOnce(anthropicLease).mockResolvedValueOnce(renewedLease);
const logger = createMockLogger();
const invocations = mockSpawn({
onAgent: agent => agent.exit(invocations.length === 1 ? 1 : 0),
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);
},
});
await runAgentAsync(createOptions({ logger }));
@@ -490,6 +452,8 @@ 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,9 +7,7 @@ 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),
@@ -30,20 +28,12 @@ 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 () => {
@@ -94,13 +84,9 @@ describe(startArgentEventCollectionAsync, () => {
await fs.promises.rm(stateDir, { recursive: true, force: true });
}
expect(turtleFetch).toHaveBeenCalledWith(
EVENT_LOG_UPLOAD_SESSION_URL,
'POST',
expect.objectContaining({
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
})
);
expect(ctx.graphqlClient.mutation).toHaveBeenCalledWith(expect.anything(), {
deviceRunSessionId: 'session-id',
});
expect(RemoteLoggerStream).toHaveBeenCalledWith({
logger,
uploadMethod: {
@@ -332,32 +318,29 @@ 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 {
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/',
graphqlClient: { mutation },
env: realtimeLogs ? { EXPO_STAGING: '1', EAS_BUILD_ID: 'job-run-id' } : {},
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,56 +2,46 @@ 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;
jest.mocked(turtleFetch).mockResolvedValueOnce(
jsonResponse({
const mutation = jest.fn().mockReturnValue({
toPromise: async () => ({
data: {
uploadSession: {
url: 'https://uploads.expo.test/artifact',
headers: {
'Content-Length': String(reportedSize),
'Content-Type': 'application/octet-stream',
deviceRunSession: {
createArtifactUploadSession: {
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(createCtx(), {
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId: 'drs-id',
artifactId: 'artifact-id',
name: 'Artifact report.json (artifact-id)',
@@ -62,20 +52,19 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
openStream: () => stream,
});
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
'https://api.expo.test/v2/device-run-session-worker/artifact-upload-sessions',
'POST',
expect(mutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: TOKEN },
json: {
deviceRunSessionId: 'drs-id',
input: {
name: 'Artifact report.json (artifact-id)',
filename: 'report.json',
kind: 'agent-device-test-report',
metadata: { firstFrameRecordAt: 'test-time' },
size: reportedSize,
},
signal: expect.any(AbortSignal),
})
}),
{ fetch: expect.any(Function) }
);
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
'https://uploads.expo.test/artifact',
@@ -88,19 +77,26 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
it('forwards cancellation through upload allocation and skips PUT when allocation aborts', async () => {
const controller = new AbortController();
jest.mocked(turtleFetch).mockImplementationOnce(async () => {
controller.abort();
return jsonResponse({
data: {
uploadSession: {
url: 'https://uploads.expo.test/artifact',
headers: {},
const mutation = jest.fn().mockReturnValue({
toPromise: async () => {
controller.abort();
return {
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: {
url: 'https://uploads.expo.test/artifact',
headers: {},
},
},
},
},
},
});
};
},
});
const ctx = { graphqlClient: { mutation } } as unknown as CustomBuildContext;
await expect(
uploadDeviceRunSessionArtifactAsync(createCtx(), {
uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId: 'session',
artifactId: 'id',
name: 'Logs',
@@ -112,8 +108,18 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
})
).rejects.toThrow();
expect(fetch).not.toHaveBeenCalled();
const allocationSignal = jest.mocked(turtleFetch).mock.calls[0][2].signal;
expect(allocationSignal).toBeInstanceOf(AbortSignal);
expect(allocationSignal?.aborted).toBe(true);
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();
}
});
});
@@ -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,43 +10,20 @@ 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;
@@ -54,11 +31,21 @@ describe(startDeviceRunSessionPreview, () => {
beforeEach(() => {
jest.useFakeTimers();
captureAsync = jest.fn().mockResolvedValue(image);
jest
.mocked(turtleFetch)
.mockReset()
.mockImplementation(async () => uploadSessionResponse());
ctx = createCtx();
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;
logger = { warn: jest.fn() } as unknown as bunyan;
jest
.mocked(fetch)
@@ -95,7 +82,7 @@ describe(startDeviceRunSessionPreview, () => {
expect(captureAsync).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1);
expect(captureAsync).toHaveBeenCalledTimes(2);
expect(turtleFetch).toHaveBeenCalledTimes(1);
expect(mutation).toHaveBeenCalledTimes(1);
await preview!.stopAsync();
await jest.advanceTimersByTimeAsync(120_000);
expect(captureAsync).toHaveBeenCalledTimes(2);
@@ -105,7 +92,7 @@ describe(startDeviceRunSessionPreview, () => {
captureAsync.mockRejectedValueOnce(new Error('device not ready'));
start();
await jest.advanceTimersByTimeAsync(0);
expect(turtleFetch).not.toHaveBeenCalled();
expect(mutation).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(60_000);
expect(fetch).toHaveBeenCalledTimes(1);
@@ -115,7 +102,7 @@ describe(startDeviceRunSessionPreview, () => {
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 403 }));
start();
await jest.advanceTimersByTimeAsync(60_000);
expect(turtleFetch).toHaveBeenCalledTimes(1);
expect(mutation).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledTimes(2);
});
@@ -139,7 +126,7 @@ describe(startDeviceRunSessionPreview, () => {
it('reuses the upload URL for the full session', async () => {
start();
await jest.advanceTimersByTimeAsync(119 * 60_000);
expect(turtleFetch).toHaveBeenCalledTimes(1);
expect(mutation).toHaveBeenCalledTimes(1);
});
it('does not overlap captures and aborts pending work when stopped', async () => {
@@ -169,20 +156,29 @@ describe(startDeviceRunSessionPreview, () => {
const stopping = handle.stopAsync();
finish(image);
await stopping;
expect(turtleFetch).not.toHaveBeenCalled();
expect(mutation).not.toHaveBeenCalled();
});
});
describe('session preview upload session from the worker API', () => {
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;
let preview: ReturnType<typeof startDeviceRunSessionPreview> | undefined;
let logger: bunyan;
beforeEach(() => {
jest.useFakeTimers();
jest
.mocked(turtleFetch)
.mockReset()
.mockImplementation(async () => uploadSessionResponse());
graphqlFetch = jest.spyOn(globalThis, 'fetch').mockResolvedValue(
new globalThis.Response(
JSON.stringify({
data: { deviceRunSession: { createPreviewUploadSession: { uploadSession } } },
}),
{ headers: { 'content-type': 'application/json' } }
)
);
logger = { warn: jest.fn() } as unknown as bunyan;
jest
.mocked(fetch)
@@ -197,9 +193,14 @@ describe('session preview upload session from the worker API', () => {
jest.useRealTimers();
});
const start = (ctx: CustomBuildContext = createCtx()) => {
const start = () => {
const graphqlClient = new Client({
url: 'https://api.expo.test/graphql',
exchanges: [fetchExchange],
fetchOptions: { headers: { Authorization: 'Bearer worker-token' } },
});
preview = startDeviceRunSessionPreview({
ctx,
ctx: { graphqlClient } as CustomBuildContext,
deviceRunSessionId: 'session-id',
captureAsync: async () => Buffer.from('webp-image'),
logger,
@@ -207,49 +208,25 @@ describe('session preview upload session from the worker API', () => {
return preview;
};
it('sends the device run session token when creating the upload session', async () => {
it('preserves the worker authentication when creating the upload session', async () => {
start();
await jest.advanceTimersByTimeAsync(0);
expect(turtleFetch).toHaveBeenCalledWith(
PREVIEW_UPLOAD_SESSION_URL,
'POST',
expect.objectContaining({
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
signal: expect.any(AbortSignal),
})
);
const [, options] = graphqlFetch.mock.calls[0];
expect(new Headers(options.headers).get('authorization')).toBe('Bearer worker-token');
expect(fetch).toHaveBeenCalledTimes(1);
});
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 => {
it.each(['shutdown', 'timeout'])('aborts a stalled GraphQL 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;
jest.mocked(turtleFetch).mockImplementation(
(_url, _method, options) =>
graphqlFetch.mockImplementation(
(_url, options) =>
new Promise((_, reject) => {
rejectRequest = reject;
requestSignal = options.signal as AbortSignal;
requestSignal.addEventListener('abort', () => reject(requestSignal!.reason), {
requestSignal = options.signal;
requestSignal!.addEventListener('abort', () => reject(requestSignal!.reason), {
once: true,
});
})
@@ -278,7 +255,14 @@ describe('session preview upload session from the worker API', () => {
await stopping;
} else {
expect(logger.warn).toHaveBeenCalled();
jest.mocked(turtleFetch).mockImplementation(async () => uploadSessionResponse());
graphqlFetch.mockResolvedValue(
new globalThis.Response(
JSON.stringify({
data: { deviceRunSession: { createPreviewUploadSession: { uploadSession } } },
}),
{ headers: { 'content-type': 'application/json' } }
)
);
jest.spyOn(AbortSignal, 'timeout').mockReturnValue(new AbortController().signal);
await jest.advanceTimersByTimeAsync(60_000);
expect(fetch).toHaveBeenCalledTimes(1);
@@ -1,5 +1,6 @@
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';
@@ -24,23 +25,28 @@ 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 === '/v2/device-run-session-worker/artifact-upload-sessions') {
if (request.url === '/graphql') {
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: { uploadSession: { url: `${url}/artifact`, headers: {} } } })
JSON.stringify({
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: { url: `${url}/artifact`, headers: {} },
},
},
},
})
);
});
} else {
@@ -54,9 +60,12 @@ beforeEach(async () => {
}
url = `http://127.0.0.1:${address.port}`;
ctx = {
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
expoApiV2BaseUrl: `${url}/v2/`,
} as unknown as CustomBuildContext;
graphqlClient: new Client({
url: `${url}/graphql`,
exchanges: [fetchExchange],
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
}),
} as CustomBuildContext;
});
afterEach(async () => {
@@ -172,9 +181,8 @@ 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))
.map(body => JSON.parse(body).variables.input)
.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]);
@@ -45,38 +45,3 @@ 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' },
});
});
});
@@ -1,5 +1,5 @@
import type { bunyan } from '@expo/logger';
import { BuildRuntimePlatform } from '@expo/steps';
import { Client, fetchExchange } from '@urql/core';
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
import { tmpdir } from 'node:os';
@@ -10,7 +10,6 @@ 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>() {
@@ -27,7 +26,7 @@ let ctx: CustomBuildContext;
let url: string;
let directory: string;
let creates: number;
let deviceRunSessionToken: string | string[] | undefined;
let authorization: string | undefined;
let stallCreation: boolean;
let creationStarted: ReturnType<typeof deferred<IncomingMessage>>;
let creationBodies: string[];
@@ -41,32 +40,42 @@ beforeEach(async () => {
sessionLookups = 0;
sessionLookupFails = false;
uploadQuery = '';
deviceRunSessionToken = undefined;
authorization = undefined;
stallCreation = false;
creationBodies = [];
creationStarted = deferred();
directory = await mkdtemp(path.join(tmpdir(), 'recording-upload-http-'));
server = createServer((request, response) => {
if (request.url?.startsWith('/v2/device-run-session-worker/')) {
deviceRunSessionToken = request.headers[DEVICE_RUN_SESSION_TOKEN_HEADER];
if (request.url?.startsWith('/graphql')) {
authorization = request.headers.authorization;
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', () => {
if (request.url === '/v2/device-run-session-worker/application') {
const operation = JSON.parse(body);
if (operation.query.includes('query ScreenshotSession')) {
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: {
build: {
id: 'installed-build',
gitCommitHash: installedCommit,
applicationArchiveUrl: null,
deviceRunSessions: {
byId: {
platform: 'IOS',
app: { slug: 'session-project' },
build: {
id: 'installed-build',
gitCommitHash: installedCommit,
app: { slug: 'my-app' },
},
},
},
},
})
@@ -83,7 +92,13 @@ beforeEach(async () => {
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify({
data: { uploadSession: { url: `${url}/artifact${uploadQuery}`, headers: {} } },
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: { url: `${url}/artifact${uploadQuery}`, headers: {} },
},
},
},
})
);
});
@@ -98,10 +113,12 @@ beforeEach(async () => {
}
url = `http://127.0.0.1:${address.port}`;
ctx = {
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'test-device-run-session-token' },
expoApiV2BaseUrl: `${url}/v2/`,
runtimePlatform: BuildRuntimePlatform.DARWIN,
} as unknown as CustomBuildContext;
graphqlClient: new Client({
url: `${url}/graphql`,
exchanges: [fetchExchange],
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
}),
} as CustomBuildContext;
});
afterEach(async () => {
@@ -110,7 +127,7 @@ afterEach(async () => {
await rm(directory, { recursive: true, force: true });
});
it('publishes a manual preview PNG through the worker API and HTTP upload path', async () => {
it('publishes a manual preview PNG through the artifact GraphQL and HTTP upload path', async () => {
const payload = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aF9sAAAAASUVORK5CYII=',
'base64'
@@ -140,20 +157,25 @@ it('publishes a manual preview PNG through the worker API and HTTP upload path',
expect(bodies).toEqual([payload]);
expect(creates).toBe(1);
expect(sessionLookups).toBe(1);
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,
},
});
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,
},
}),
})
);
await expect(stat(path.join(directory, filename))).rejects.toMatchObject({ code: 'ENOENT' });
});
@@ -184,7 +206,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 worker API upload session request and keeps the PNG', async () => {
it('aborts a stalled GraphQL upload 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');
@@ -1,115 +0,0 @@
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,5 +1,6 @@
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';
@@ -12,7 +13,6 @@ 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 deviceRunSessionToken: string | string[] | undefined;
let authorization: string | undefined;
let stallCreation: boolean;
let creationStarted: ReturnType<typeof deferred<IncomingMessage>>;
beforeEach(async () => {
creates = 0;
deviceRunSessionToken = undefined;
authorization = undefined;
stallCreation = false;
creationStarted = deferred();
directory = await mkdtemp(path.join(tmpdir(), 'recording-upload-http-'));
server = createServer((request, response) => {
if (request.url === '/v2/device-run-session-worker/artifact-upload-sessions') {
if (request.url === '/graphql') {
creates++;
deviceRunSessionToken = request.headers[DEVICE_RUN_SESSION_TOKEN_HEADER];
authorization = request.headers.authorization;
creationStarted.resolve(request);
request.resume();
request.on('end', () => {
@@ -63,7 +63,13 @@ beforeEach(async () => {
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify({
data: { uploadSession: { url: `${url}/artifact`, headers: {} } },
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: { url: `${url}/artifact`, headers: {} },
},
},
},
})
);
});
@@ -78,9 +84,12 @@ beforeEach(async () => {
}
url = `http://127.0.0.1:${address.port}`;
ctx = {
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'test-device-run-session-token' },
expoApiV2BaseUrl: `${url}/v2/`,
} as unknown as CustomBuildContext;
graphqlClient: new Client({
url: `${url}/graphql`,
exchanges: [fetchExchange],
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
}),
} as CustomBuildContext;
});
afterEach(async () => {
@@ -117,7 +126,7 @@ it('replays the full file after a transient PUT failure without recreating the u
attempts: 3,
});
expect(creates).toBe(1);
expect(deviceRunSessionToken).toBe('test-device-run-session-token');
expect(authorization).toBe('Bearer test-auth');
expect(bodies).toEqual([payload, payload]);
expect(streams).toHaveLength(2);
expect(streams.every(stream => stream.destroyed)).toBe(true);
@@ -272,7 +281,7 @@ it.each([200, 400, 503])(
}
);
it('cancels worker API session creation with the token intact and never starts a PUT', async () => {
it('cancels urql session creation with auth intact and never starts a PUT', async () => {
stallCreation = true;
const puts = jest.fn();
handle = puts;
@@ -289,7 +298,7 @@ it('cancels worker API session creation with the token intact and never starts a
controller.abort(new Error('test cancellation'));
await rejected;
await closed;
expect(deviceRunSessionToken).toBe('test-device-run-session-token');
expect(authorization).toBe('Bearer test-auth');
expect(creates).toBe(1);
expect(puts).not.toHaveBeenCalled();
expect(openStream).not.toHaveBeenCalled();
@@ -1,361 +0,0 @@
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.'
);
});
});
@@ -1,599 +0,0 @@
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
@@ -1,63 +0,0 @@
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' });
});
});
@@ -1,191 +0,0 @@
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,8 +21,6 @@ import {
rebindLocalEgressGuardRelays,
resolveEgressGuardLibraryAsync,
resolveLocalEgressBootEnvironmentAsync,
resolveLocalEgressServeSimBootEnvironmentAsync,
startLocalEgressGuardRelayAsync,
stopLocalEgressGuardRelaysAsync,
verifyLocalEgressGuardAsync,
} from '../localEgressGuard';
@@ -403,38 +401,6 @@ 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);
@@ -503,101 +469,6 @@ 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,10 +1,9 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { 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';
@@ -26,46 +25,30 @@ 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,
websiteOriginArgs,
websiteOriginServeSimArgs,
} 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');
@@ -155,121 +138,79 @@ describe(spawnDetached, () => {
);
});
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 {
function createCtxMock(): 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 }
)[],
{ stopError }: { stopError?: Error } = {}
{ ensureStoppedError }: { ensureStoppedError?: Error } = {}
): CustomBuildContext {
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 query = jest.fn(() => {
const result = results.shift();
if (!result) {
throw new Error('No mocked status result available');
}
if ('error' in result) {
throw result.error;
}
if ('data' in result) {
return workerApiResponse(result.data);
}
return workerApiResponse({ id: 'drs-id', status: result.status });
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,
},
},
},
};
},
};
});
return createCtxMock();
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;
}
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();
@@ -303,110 +244,6 @@ 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 };
@@ -574,26 +411,6 @@ 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]'
@@ -620,14 +437,6 @@ 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,
@@ -752,7 +561,6 @@ describe(createExpoDeviceHubArgs, () => {
'4321',
'--host',
'127.0.0.1',
'--require-token',
'--platform',
'android',
'--transport',
@@ -774,40 +582,6 @@ 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]'
@@ -815,9 +589,9 @@ describe(createExpoDeviceHubArgs, () => {
});
});
describe(websiteOriginArgs, () => {
describe(websiteOriginServeSimArgs, () => {
it('names only the production website by default', () => {
expect(websiteOriginArgs({} as BuildStepEnv)).toEqual([
expect(websiteOriginServeSimArgs({} as BuildStepEnv)).toEqual([
'--cors-origin',
'https://expo.dev',
'--frame-ancestor',
@@ -826,7 +600,7 @@ describe(websiteOriginArgs, () => {
});
it('names staging, its deploy previews and local website subdomains on staging', () => {
const args = websiteOriginArgs({ EXPO_STAGING: '1' } as BuildStepEnv);
const args = websiteOriginServeSimArgs({ EXPO_STAGING: '1' } as BuildStepEnv);
expect(args).toEqual([
'--cors-origin',
'https://staging.expo.dev',
@@ -849,14 +623,14 @@ describe(websiteOriginArgs, () => {
it('names only https origins', () => {
for (const env of [{}, { EXPO_STAGING: '1' }, { EXPO_LOCAL: '1' }]) {
const args = websiteOriginArgs(env as BuildStepEnv);
const args = websiteOriginServeSimArgs(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(websiteOriginArgs(env as BuildStepEnv)).toEqual([
expect(websiteOriginServeSimArgs(env as BuildStepEnv)).toEqual([
'--cors-origin',
'https://expo.test',
'--frame-ancestor',
@@ -882,8 +656,6 @@ 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>>);
@@ -1055,11 +827,10 @@ 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();
@@ -1081,23 +852,11 @@ describe(startDeviceSessionHostAsync, () => {
}),
},
});
// 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(spawn).mockReturnValue(spawnPromise as never);
jest.mocked(turtleFetch).mockImplementation(async url => {
if (url.endsWith('/turn-ice-servers')) {
return {
ok: true,
status: 200,
json: async () => ({
data: {
iceServers: [
@@ -1112,16 +871,13 @@ 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(async () => {
afterEach(() => {
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 () => {
@@ -1140,84 +896,6 @@ 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);
@@ -1238,7 +916,7 @@ describe(startDeviceSessionHostAsync, () => {
timeoutMs: 10_000,
packageVersion,
});
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
const preview = await host.openPreviewAsync({ baseDomain });
const spawnCalls = jest.mocked(spawn).mock.calls;
expect(spawnCalls[0]).toEqual([
@@ -1269,13 +947,7 @@ describe(startDeviceSessionHostAsync, () => {
const recordingDirectory = args[args.indexOf('--android-recording-directory') + 1];
expect(args).toEqual([
'--yes',
...createExpoDeviceHubArgs({
port,
turnArgs,
websiteArgs,
packageVersion,
recordingDirectory,
}),
...createExpoDeviceHubArgs({ port, turnArgs, packageVersion, recordingDirectory }),
]);
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(preview.apiUrl).toBe('https://android-preview.example.test');
@@ -1297,16 +969,10 @@ describe(startDeviceSessionHostAsync, () => {
logger: createLoggerMock(),
timeoutMs: 10_000,
});
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
const preview = await host.openPreviewAsync({ baseDomain });
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([
@@ -1332,7 +998,7 @@ describe(startDeviceSessionHostAsync, () => {
logger: createLoggerMock(),
timeoutMs: 10_000,
});
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
const preview = await host.openPreviewAsync({ baseDomain });
expect(preview.previewPageUrl).toMatch(
/^https:\/\/staging\.expo\.dev\/simulator-preview\/[a-f0-9]{32}$/
@@ -1360,8 +1026,8 @@ describe(startDeviceSessionHostAsync, () => {
).rejects.toThrow(/wrote no session token/);
});
// 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 () => {
// 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 () => {
jest.mocked(readServeSimServersAsync).mockResolvedValue([]);
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://android-preview.example.test',
@@ -1374,12 +1040,11 @@ describe(startDeviceSessionHostAsync, () => {
logger: createLoggerMock(),
timeoutMs: 10_000,
});
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
const preview = await host.openPreviewAsync({ baseDomain });
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();
expect(preview.previewToken).toBeUndefined();
const [, args] = jest.mocked(spawn).mock.calls[0];
expect(args).not.toContain('--require-token');
});
it('starts serve-sim for Darwin with its metrics policy and cleans up the preview resources', async () => {
@@ -1397,7 +1062,7 @@ describe(startDeviceSessionHostAsync, () => {
timeoutMs: 10_000,
packageVersion,
});
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
const preview = await host.openPreviewAsync({ baseDomain });
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
@@ -1436,7 +1101,7 @@ describe(startDeviceSessionHostAsync, () => {
timeoutMs: 10_000,
packageVersion: '4.5.6',
});
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
const preview = await host.openPreviewAsync({ baseDomain });
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
@@ -1470,7 +1135,7 @@ describe(startDeviceSessionHostAsync, () => {
logger: createLoggerMock(),
timeoutMs: 10_000,
});
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
const preview = await host.openPreviewAsync({ baseDomain });
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
@@ -1595,21 +1260,24 @@ describe(fetchWebPreviewTurnArgsAsync, () => {
jest.mocked(turtleFetch).mockReset();
});
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',
},
],
})
);
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>>);
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), {
env: createEnvMock(),
logger: createLoggerMock(),
});
@@ -1624,10 +1292,10 @@ describe(fetchWebPreviewTurnArgsAsync, () => {
'c',
]);
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
`${DEVICE_RUN_SESSION_WORKER_URL}turn-ice-servers`,
'https://api.expo.test/v2/device-run-sessions/drs-id/turn-ice-servers',
'POST',
expect.objectContaining({
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
headers: { Authorization: 'Bearer robot-token' },
})
);
});
@@ -1636,32 +1304,14 @@ describe(fetchWebPreviewTurnArgsAsync, () => {
jest.mocked(turtleFetch).mockRejectedValue(new Error('boom'));
const logger = createLoggerMock();
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 }), {
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), {
env: createEnvMock(),
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();
expect(logger.warn).toHaveBeenCalled();
expect(jest.mocked(Sentry).capture).toHaveBeenCalled();
});
});
@@ -1678,7 +1328,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({
@@ -1687,59 +1337,13 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
logger,
});
expect(workerApiCallCount('session')).toBe(2);
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(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,
@@ -1753,7 +1357,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
durationTimeoutCallback();
await waitPromise;
expect(workerApiCallCount('session')).toBe(1);
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith(
'Device run session drs-id reached its maximum duration.'
);
@@ -1762,7 +1366,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,
@@ -1784,12 +1388,12 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
signal: abortController.signal,
});
expect(workerApiCallCount('session')).toBe(0);
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
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({
@@ -1803,7 +1407,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,
@@ -1816,12 +1420,12 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
abortController.abort();
await waitPromise;
expect(workerApiCallCount('session')).toBe(1);
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(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({
@@ -1830,7 +1434,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
logger,
});
expect(workerApiCallCount('session')).toBe(2);
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(2);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ failedStatusPollCount: 1 }),
'Could not poll device run session status; will retry.'
@@ -1842,8 +1446,11 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
);
});
it('logs and retries when the status response does not match the route contract', async () => {
const ctx = createStatusCtxMock([{ data: { id: 'drs-id' } }, { status: 'stopped' }]);
it('logs and retries when the status response is missing', async () => {
const ctx = createStatusCtxMock([
{ data: { deviceRunSessions: { byId: null } } },
{ status: 'STOPPED' },
]);
const logger = createLoggerMock();
await waitForDeviceRunSessionStoppedAsync({
@@ -1852,7 +1459,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
logger,
});
expect(workerApiCallCount('session')).toBe(2);
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(2);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ failedStatusPollCount: 1 }),
'Could not poll device run session status; will retry.'
@@ -1860,9 +1467,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
expect(jest.mocked(Sentry).capture).toHaveBeenCalledWith(
'Could not poll device run session status',
expect.objectContaining({
message: expect.stringContaining(
'GET /v2/device-run-session-worker/session returned an unexpected response'
),
message: 'Device run session drs-id status response was missing.',
}),
{ level: 'warning' }
);
@@ -1883,8 +1488,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 () => {
@@ -1902,15 +1507,15 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
});
// One minute at the 5-second poll interval is 12 status polls.
expect(workerApiCallCount('session')).toBe(12);
expect(workerApiCallCount('stop')).toBe(1);
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(12);
expect(ctx.graphqlClient.mutation).toHaveBeenCalledTimes(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({
@@ -1925,14 +1530,14 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
},
});
expect(workerApiCallCount('session')).toBe(21);
expect(workerApiCallCount('stop')).toBe(0);
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(21);
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
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(), {
stopError: new Error('forbidden'),
ensureStoppedError: new Error('forbidden'),
});
const logger = createLoggerMock();
@@ -1946,7 +1551,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
},
});
expect(workerApiCallCount('stop')).toBe(1);
expect(ctx.graphqlClient.mutation).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'Could not mark device run session drs-id as stopped. The session job ends anyway.'
@@ -1,184 +0,0 @@
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,13 +6,11 @@ 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.
// WWW's device run session worker API is served by the local server; it does not verify cloud
// storage.
// Only WWW's allocation response is substituted; it does not verify cloud storage.
jest.mock('../../../sentry');
jest.unmock('fs');
jest.unmock('node:fs');
@@ -57,29 +55,7 @@ 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, {
@@ -119,16 +95,27 @@ 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(
{
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'device-run-session-token' },
expoApiV2BaseUrl: `${baseUrl}/v2/`,
} as unknown as CustomBuildContext,
{ graphqlClient: { mutation } } as unknown as CustomBuildContext,
{ deviceRunSessionId: 'session-test', udid: 'device-B', filePath, logger }
);
expect(uploadSessionRequest).toEqual({
token: 'device-run-session-token',
body: {
expect(mutation.mock.calls[0][1]).toEqual({
deviceRunSessionId: 'session-test',
input: {
name: 'App logs (device-B)',
filename: 'app-logs.ndjson',
kind: 'simulator-log',
@@ -88,23 +88,4 @@ 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,7 +35,6 @@ export async function runAgentAsync({
pathEnv,
npmCacheUrl,
logger,
addBuildLogMask,
signal,
}: {
agentKind: AgentKind;
@@ -48,11 +47,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 {
@@ -127,13 +126,14 @@ export async function runAgentAsync({
}
for (const secret of invocation.secrets) {
addBuildLogMask(secret);
secrets.add(secret);
}
const { exitCode, exitSignal } = await runAgentProcessAsync({
command: path.join(cliDirectory, 'node_modules', '.bin', bin),
...invocation,
cwd: workingDirectory,
formatOutputLine,
secrets: [...secrets],
logger,
signal,
});
@@ -170,6 +170,7 @@ async function runAgentProcessAsync({
cwd,
stdin,
formatOutputLine,
secrets,
logger,
signal,
}: {
@@ -179,6 +180,7 @@ async function runAgentProcessAsync({
cwd: string;
stdin: string;
formatOutputLine: AgentCli['formatOutputLine'];
secrets: string[];
logger: bunyan;
signal: AbortSignal;
}): Promise<{ exitCode: number | null; exitSignal: NodeJS.Signals | null }> {
@@ -186,6 +188,8 @@ 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[];
@@ -197,12 +201,12 @@ async function runAgentProcessAsync({
return;
}
for (const { level, message } of messages) {
stdoutLogger[level](message.trimEnd());
stdoutLogger[level](redact(message).trimEnd());
}
});
const stderrLogger = logger.child({ source: 'stderr' });
readline.createInterface({ input: child.stderr }).on('line', line => {
stderrLogger.info(line);
stderrLogger.info(redact(line));
});
// The agent may exit before it reads the prompt; its exit status reports that.
child.stdin.on('error', () => {});
@@ -1,70 +0,0 @@
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,7 +5,6 @@ import { z } from 'zod';
import { promiseRetryWithCondition } from '../../../utils/promiseRetryWithCondition';
import { GooglePlayApiError } from './GooglePlayApiError';
const PostApi = {
'/token': {
request: z.object({
@@ -62,7 +61,7 @@ export class GooglePlayAuthClient {
error =>
!signal?.aborted &&
error instanceof GooglePlayAuthRequestError &&
GooglePlayApiError.isRetryableStatus(error.status),
(error.status === 429 || error.status >= 500),
{ retries: 3, factor: 2, minTimeout: 1000, maxTimeout: 4000 }
)();
}
@@ -1,13 +1,45 @@
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>;
@@ -56,9 +88,7 @@ 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': {
@@ -98,10 +128,7 @@ export class GooglePlayClient {
private readonly baseUrl = 'https://androidpublisher.googleapis.com';
private cachedToken?: Promise<{ value: string; expiresAt: number }>;
constructor(
private readonly serviceAccount: GoogleServiceAccount,
private readonly logger?: bunyan
) {}
constructor(private readonly serviceAccount: GoogleServiceAccount) {}
private async getTokenAsync(signal?: AbortSignal): Promise<string> {
signal?.throwIfAborted();
@@ -177,21 +204,7 @@ export class GooglePlayClient {
for (const [key, value] of Object.entries(DeleteApi[path].path.parse(params))) {
url = url.replace(`:${key}`, encodeURIComponent(value));
}
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;
}
await this.requestAsync('DELETE', url, undefined, signal);
}
private async sendJsonRequestAsync(
@@ -223,16 +236,12 @@ export class GooglePlayClient {
}
}
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
const response = await this.requestAsync(
method,
search.size ? `${path}?${search}` : path,
method === 'GET' ? undefined : JSON.stringify(parsedBody),
signal,
{ 'Content-Type': 'application/json' }
);
let data: unknown;
@@ -255,47 +264,6 @@ 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,12 +1,10 @@
import { SystemError } from '@expo/eas-build-job';
import fetch, { Response } from 'node-fetch';
import fetch, { FetchError, Response } from 'node-fetch';
import promiseRetry from 'promise-retry';
import { FileHandle } from 'node:fs/promises';
import { z } from 'zod';
import { isConnectFailure, isConnectionInterruptedError } from '../../../utils/networkErrors';
import { GooglePlayApiError } from './GooglePlayApiError';
import { GooglePlayClient } from './GooglePlayClient';
import { GooglePlayApiError, GooglePlayClient } from './GooglePlayClient';
import { AndroidArtifactType } from './appArtifact';
const UploadResponseZ = z.object({
@@ -55,10 +53,8 @@ export namespace GooglePlayResumableUpload {
signal?.throwIfAborted();
if (
timeout.aborted ||
isConnectFailure(error) ||
isConnectionInterruptedError(error) ||
(error instanceof GooglePlayApiError &&
GooglePlayApiError.isRetryableStatus(error.status))
isNetworkRetryable(error) ||
(error instanceof GooglePlayApiError && (error.status === 429 || error.status >= 500))
) {
retry(
timeout.aborted
@@ -191,7 +187,7 @@ export namespace GooglePlayResumableUpload {
const failure = new SystemError(
'Google Play upload failed before a response was received.'
);
if (timeout.aborted || isConnectFailure(error) || isConnectionInterruptedError(error)) {
if (timeout.aborted || isNetworkRetryable(error)) {
checkServerOffset = true;
retry(failure);
}
@@ -204,7 +200,7 @@ export namespace GooglePlayResumableUpload {
checkServerOffset = true;
return retry(new SystemError('Google Play upload response timed out.'));
}
if (GooglePlayApiError.isRetryableStatus(response.status)) {
if (response.status === 429 || response.status >= 500) {
checkServerOffset = true;
retry(error);
}
@@ -280,3 +276,15 @@ 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,11 +3,8 @@ 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 { GooglePlayClient, ReleaseStatus } from './GooglePlayClient';
import { GooglePlayApiError, GooglePlayClient, ReleaseStatus } from './GooglePlayClient';
import { GooglePlayResumableUpload } from './GooglePlayResumableUpload';
import { AndroidArtifactType } from './appArtifact';
@@ -144,7 +141,7 @@ export namespace GooglePlayUtils {
signal?: AbortSignal;
}
): Promise<void> {
const sendCommitAsync = async ({
const commitOnceAsync = async ({
changesNotSentForReview,
}: {
changesNotSentForReview?: boolean;
@@ -166,48 +163,13 @@ export namespace GooglePlayUtils {
}
if (
!(error instanceof GooglePlayApiError) ||
GooglePlayApiError.isRetryableStatus(error.status)
error.status >= 500 ||
error.status === 408 ||
error.status === 429
) {
throw new GooglePlayCommitOutcomeUnknownError(
`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) }
'Google Play commit outcome is unknown. Check the release in Play Console before you submit again. The commit was not retried.',
{ cause: error }
);
}
throw error;
@@ -216,7 +178,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 commitAsync({ changesNotSentForReview: changesNotSentForReview || undefined });
await commitOnceAsync({ changesNotSentForReview: changesNotSentForReview || undefined });
} catch (error) {
if (!(error instanceof GooglePlayApiError) || error.status !== 400) {
throw error;
@@ -229,7 +191,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 commitAsync({});
await commitOnceAsync({});
} else if (
!changesNotSentForReview &&
error.apiMessage.includes('Please set the query parameter changesNotSentForReview to true')
@@ -237,31 +199,29 @@ export namespace GooglePlayUtils {
logger.warn(
'Google requires changesNotSentForReview=true. Retrying commit once. Send the saved changes for review from Play Console.'
);
await commitAsync({ changesNotSentForReview: true });
await commitOnceAsync({ changesNotSentForReview: true });
} else {
throw error;
}
}
}
export function mapGooglePlayError(error: unknown, packageName: string): unknown {
if (error instanceof AggregateError) {
return mapGooglePlayError(error.cause, packageName);
}
export function mapGooglePlayError(error: unknown): unknown {
if (
(error instanceof GooglePlayApiError || error instanceof GooglePlayAuthRequestError) &&
GooglePlayApiError.isRetryableStatus(error.status)
(error.status === 408 || error.status === 429 || error.status >= 500)
) {
return new SystemError(
`Google Play is temporarily unavailable or rate limited (HTTP ${error.status}). Try again later.`,
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
{ cause: error }
);
}
if (
error instanceof GooglePlayAuthRequestError &&
error.status >= 400 &&
error.status < 500 &&
!GooglePlayApiError.isRetryableStatus(error.status)
error.status !== 408 &&
error.status !== 429
) {
return new UserError(
'EAS_GOOGLE_PLAY_INVALID_CREDENTIALS',
@@ -275,7 +235,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})${message ? `: ${message}` : '.'} Check the app settings before submitting again.`;
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.`;
if (message.includes('Package not found')) {
code = 'EAS_GOOGLE_PLAY_FIRST_UPLOAD';
detail =
@@ -287,7 +247,7 @@ export namespace GooglePlayUtils {
) {
code = 'EAS_GOOGLE_PLAY_VERSION_CODE_USED';
detail =
'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.';
'This version code was already uploaded. Increase the Android version code and build a new binary.';
} else if (
/privacy policy/i.test(message) ||
reasons.has('apkNotificationMessageKeyPermissionsRequirePrivacyPolicy') ||
@@ -295,13 +255,10 @@ 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') ||
message.includes('Staged rollout is not permitted on draft app')
) {
} else if (message.includes('Only releases with status draft may be created on draft app')) {
code = 'EAS_GOOGLE_PLAY_DRAFT_APP';
detail =
'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.';
'This app is a draft in Play Console. Complete its required metadata or submit with release_status: draft.';
} else if (error.status === 409 || /edit.*(?:expired|invalid|not found)/i.test(message)) {
code = 'EAS_GOOGLE_PLAY_EDIT_INVALID';
detail =
@@ -312,12 +269,11 @@ 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${message ? `: ${message}` : '.'} Check the track, release status, rollout, and review settings in Play Console.`;
detail =
'Google Play rejected the release or review settings. Check the track, release status, rollout, and review settings in Play Console.';
}
return new UserError(
code,
`${detail}\n\nApp: ${packageName}\nGoogle Play Console: https://play.google.com/console/`,
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
);
return new UserError(code, message ? `${detail} Google Play said: ${message}` : detail, {
cause: 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,11 +13,6 @@ 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]',
@@ -80,12 +75,11 @@ it('rejects invalid rollout requests before sending them', async () => {
).rejects.toBeInstanceOf(ZodError);
});
it('keeps the outcome unknown when a lost commit response is followed by an invalid edit', async () => {
it('does not retry an ambiguous edit commit', async () => {
api()
.post(`${editPath}:commit`, {})
.query({ changesNotSentForReview: false })
.replyWithError(Object.assign(new Error('Lost response'), { code: 'ECONNRESET' }));
api().post(`${editPath}:commit`, {}).query({ changesNotSentForReview: false }).reply(404);
.replyWithError('Lost response');
await expect(
client.postAsync(
'/androidpublisher/v3/applications/:packageName/edits/:editId:commit',
@@ -93,62 +87,7 @@ it('keeps the outcome unknown when a lost commit response is followed by an inva
{ packageName, editId: 'edit' },
{ query: { changesNotSentForReview: false } }
)
).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();
).rejects.toBeInstanceOf(SystemError);
});
it('deletes an edit with an empty response', async () => {
@@ -161,44 +100,6 @@ 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)
@@ -223,42 +124,6 @@ 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`, {})
@@ -1,20 +1,13 @@
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 } from '../GooglePlayApiError';
import { GooglePlayClient } from '../GooglePlayClient';
import { GooglePlayCommitOutcomeUnknownError, GooglePlayUtils } from '../GooglePlayUtils';
import { GooglePlayApiError, GooglePlayClient } from '../GooglePlayClient';
import { 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();
@@ -25,17 +18,15 @@ 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,
logger: createMockLogger(),
signal,
});
}
@@ -66,23 +57,16 @@ 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, 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/.'
);
await expect(commit({ changesNotSentForReview })).resolves.toBeUndefined();
}
);
it.each([408, 429, 503])('retries a transient HTTP %s commit on the same edit', async status => {
it.each([408, 429, 503])('does not retry an uncertain HTTP %s commit', async status => {
mockToken();
api().post(`${editPath}:commit`).query(true).reply(status);
api().post(`${editPath}:commit`).query({}).reply(200, { id: 'edit' });
await expect(commit()).resolves.toBeUndefined();
await expect(commit()).rejects.toMatchObject({ cause: expect.objectContaining({ status }) });
});
it('preserves cancellation before commit is sent', async () => {
const controller = new AbortController();
@@ -90,74 +74,6 @@ 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);
@@ -170,33 +86,21 @@ 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, packageName);
const mapped = GooglePlayUtils.mapGooglePlayError(original);
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, packageName)).toMatchObject({
expect(GooglePlayUtils.mapGooglePlayError(original)).toMatchObject({
errorCode: 'EAS_GOOGLE_PLAY_INVALID_CREDENTIALS',
cause: original,
});
@@ -206,7 +110,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, packageName);
const mapped = GooglePlayUtils.mapGooglePlayError(original);
expect(mapped).toBeInstanceOf(SystemError);
expect(mapped).toMatchObject({ cause: original });
expect((mapped as Error).message).not.toContain('settings');
@@ -214,58 +118,5 @@ 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, 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,
});
expect(GooglePlayUtils.mapGooglePlayError(error)).toBe(error);
});
@@ -89,16 +89,14 @@ it('uploads aligned chunks and reports server-confirmed progress', async () => {
]);
});
it.each(['network', 408, 429, 503])(
it.each(['network', 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(
Object.assign(new Error('connection reset'), { code: 'ECONNRESET' })
);
interrupted.replyWithError('connection reset');
} else {
interrupted.reply(failure as number, { error: { message: 'retry' } });
}
@@ -123,7 +121,7 @@ it('recovers a completed upload when the final upload response is lost', async (
uploadApi()
.put(session)
.matchHeader('content-length', '12')
.replyWithError(Object.assign(new Error('lost final response'), { code: 'ECONNRESET' }));
.replyWithError('lost final response');
status().reply(200, { versionCode: 42 });
await expect(upload()).resolves.toBe(42);
});
@@ -288,7 +286,7 @@ it('retries a network failure during session creation', async () => {
api()
.post(`/upload${editPath}/apks`)
.query({ uploadType: 'resumable' })
.replyWithError(Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }));
.replyWithError('connection reset');
mockStart();
uploadApi().put(session).reply(200, { versionCode: 42 });
await expect(upload()).resolves.toBe(42);
@@ -349,9 +347,7 @@ it.each([
mockStart();
const finalChunk = uploadApi().put(session).matchHeader('content-length', '12');
if (finalResponse === 'lost') {
finalChunk.replyWithError(
Object.assign(new Error('lost final response'), { code: 'ECONNRESET' })
);
finalChunk.replyWithError('lost final response');
} else {
finalChunk.reply(308, '', { Range: 'bytes=0-11' });
}
@@ -1,15 +1,31 @@
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';
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
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
}
}
}
}
`);
type ArtifactMetadata = {
deviceRunSessionId: string;
@@ -53,29 +69,29 @@ async function createDeviceRunSessionArtifactUploadSessionAsync(
ctx: CustomBuildContext,
{ deviceRunSessionId, artifactId, name, filename, kind, metadata, size }: ArtifactMetadata,
signal: AbortSignal
): Promise<ArtifactUploadSession> {
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
let uploadSession: ArtifactUploadSession;
try {
uploadSession = await workerApi.createArtifactUploadSessionAsync(
) {
const result = await ctx.graphqlClient
.mutation(
CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION,
{
name,
filename,
...(kind !== undefined ? { kind } : {}),
...(metadata !== undefined ? { metadata } : {}),
size,
deviceRunSessionId,
input: {
name,
filename,
...(kind !== undefined ? { kind } : {}),
...(metadata !== undefined ? { metadata } : {}),
size,
},
},
{ signal }
);
} catch (err) {
signal.throwIfAborted();
graphqlAbortContext(signal)
)
.toPromise();
signal.throwIfAborted();
if (result.error) {
throw new SystemError(
`Failed to create upload session for device run session artifact ${artifactId}: ${
err instanceof Error ? err.message : String(err)
}`,
{ cause: err }
`Failed to create upload session for device run session artifact ${artifactId}: ${result.error.message}`,
{ cause: result.error }
);
}
signal.throwIfAborted();
return uploadSession;
return result.data!.deviceRunSession.createArtifactUploadSession.uploadSession;
}
@@ -1,5 +1,6 @@
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';
@@ -9,13 +10,25 @@ 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
@@ -336,17 +349,22 @@ async function createEventLogUploadSessionAsync(
ctx: CustomBuildContext,
deviceRunSessionId: string
): Promise<SignedUrl> {
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
try {
return await workerApi.createEventLogUploadSessionAsync();
} catch (err) {
const result = await ctx.graphqlClient
.mutation(CREATE_DEVICE_RUN_SESSION_EVENT_LOG_UPLOAD_SESSION_MUTATION, {
deviceRunSessionId,
})
.toPromise();
if (result.error) {
throw new SystemError(
`Failed to create device run session event log upload session: ${
err instanceof Error ? err.message : String(err)
}`,
{ cause: err }
`Failed to create device run session event log upload session: ${result.error.message}`,
{ cause: result.error }
);
}
const uploadSession = result.data!.deviceRunSession.createEventLogUploadSession.uploadSession;
return {
url: uploadSession.url,
headers: uploadSession.headers as Record<string, string>,
};
}
async function collectEventFileAsync({
@@ -2,6 +2,7 @@ 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';
@@ -11,12 +12,24 @@ 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.
@@ -70,7 +83,31 @@ export function startDeviceRunSessionPreview({
throw new Error('Session preview is empty or exceeds the 5 MiB upload limit.');
}
if (!uploadSession) {
uploadSession = await createPreviewUploadSessionAsync(ctx, deviceRunSessionId, signal);
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>,
};
}
if (controller.signal.aborted) {
return;
@@ -103,14 +140,6 @@ 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,8 +1,9 @@
import type { bunyan } from '@expo/logger';
import { BuildRuntimePlatform } from '@expo/steps';
import { graphql } from 'gql.tada';
import { z } from 'zod';
import type { CustomBuildContext } from '../../customBuildContext';
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
const SHORT_COMMIT_HASH_LENGTH = 7;
const MAX_APP_SLUG_LENGTH = 80;
@@ -14,15 +15,36 @@ 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;
/**
* 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;
};
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>;
export async function loadScreenshotSessionAsync(
ctx: CustomBuildContext,
@@ -30,23 +52,17 @@ export async function loadScreenshotSessionAsync(
logger: bunyan
): Promise<ScreenshotSession | null> {
try {
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,
};
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);
} catch (err) {
logger.warn(
{ err, deviceRunSessionId },
@@ -70,20 +86,18 @@ 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 = [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;
const filenameParts = [appSlug, platform];
const metadata: Record<string, string> = {
__eas_type: 'screenshot',
appSlug: app.slug,
platform,
};
if (session.build) {
metadata.buildId = session.build.id;
@@ -1,326 +0,0 @@
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,6 +1,5 @@
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';
@@ -30,12 +29,12 @@ import {
import { startDeviceRunSessionScreenshotsAsync } from './deviceRunSessionScreenshots';
import {
type DetachedProcessHandle,
type ServeSimApplicationOptions,
type ServeSimLaunchOptions,
ensureFfmpegInstalledOnceAsync,
fetchWebPreviewTurnArgsAsync,
findAvailablePortAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
isProcessRunning,
spawnDetached,
startNgrokTunnelAsync,
@@ -47,10 +46,6 @@ 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';
@@ -62,8 +57,6 @@ 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;
@@ -88,8 +81,7 @@ 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'];
// serve-sim and expo-device-hub take the same website origin flags and values.
export function websiteOriginArgs(env: BuildStepEnv): string[] {
export function websiteOriginServeSimArgs(env: BuildStepEnv): string[] {
const origins = new Set([websiteOrigin(env)]);
if (!env.EXPO_LOCAL && env.EXPO_STAGING) {
origins.add('https://*.expo.dev');
@@ -102,16 +94,6 @@ export function websiteOriginArgs(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'}`;
}
@@ -124,13 +106,8 @@ export function createServeSimArgs({
port,
turnArgs = [],
websiteArgs = [],
frameAncestorArgs: frameArgs = [],
corsOriginArgs: corsArgs = [],
shareUrl,
disableShare = false,
packageVersion,
iosSimulatorUdid,
installAppPath,
launchAppIdentifier,
launchArgs = [],
openUrl,
@@ -140,19 +117,13 @@ 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',
@@ -172,12 +143,8 @@ 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
@@ -194,17 +161,11 @@ 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[] {
@@ -214,7 +175,6 @@ export function createExpoDeviceHubArgs({
String(port),
'--host',
WEB_PREVIEW_HOST,
'--require-token',
'--platform',
'android',
'--transport',
@@ -233,9 +193,6 @@ export function createExpoDeviceHubArgs({
'--hide-boot-device',
...(recordingDirectory ? ['--android-recording-directory', recordingDirectory] : []),
...turnArgs,
...websiteArgs,
...frameArgs,
...corsArgs,
];
}
@@ -244,39 +201,25 @@ 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`;
let deadline = Date.now() + (startupTimeoutMs ?? timeoutMs);
// Preserve the boot budget until the host starts answering.
let waitingForHost = startupTimeoutMs !== undefined;
const deadline = Date.now() + timeoutMs;
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${
redactPreviewTokens(previewServer.getOutput()) || '<empty>'
previewServer.getOutput() || '<empty>'
}`
);
}
@@ -284,52 +227,21 @@ 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${redactPreviewTokens(previewServer.getOutput()) || '<empty>'}`
}. Last output:\n${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
@@ -340,8 +252,6 @@ 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. */
@@ -349,7 +259,7 @@ export type DeviceWebPreview = {
};
export type DeviceSessionHost = {
openPreviewAsync(options: { baseDomain: string; authtoken: string }): Promise<DeviceWebPreview>;
openPreviewAsync(options: { baseDomain: 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.
@@ -370,20 +280,7 @@ 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;
@@ -420,38 +317,17 @@ 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) {
@@ -466,9 +342,6 @@ 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 });
}
@@ -480,19 +353,14 @@ 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, { logger });
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
const previewExec = resolvePackageExec(
resolveConfiguredPackageManager(env, PackageManager.NPM),
isAndroid
@@ -501,25 +369,16 @@ async function startDeviceSessionHostInternalAsync(
turnArgs,
packageVersion,
recordingDirectory: recording?.directory,
websiteArgs: websiteOriginArgs(env),
frameAncestorArgs: frameAncestorArgs(frameAncestors),
corsOriginArgs: corsOriginArgs(corsOrigins),
})
: createServeSimArgs({
port,
turnArgs,
packageVersion,
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,
websiteArgs: websiteOriginServeSimArgs(env),
shareUrl: previewPageUrl,
launchAppIdentifier,
launchArgs,
openUrl,
networkCapture,
networkCaptureFields,
})
@@ -538,19 +397,13 @@ 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
@@ -565,10 +418,6 @@ 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;
@@ -579,7 +428,7 @@ async function startDeviceSessionHostInternalAsync(
let previewFailed = false;
const host: DeviceSessionHost = {
openPreviewAsync({ baseDomain, authtoken }) {
openPreviewAsync({ baseDomain }) {
if (finishTask) {
return Promise.reject(
new SystemError('Cannot open a preview after session host finalization.')
@@ -594,7 +443,7 @@ async function startDeviceSessionHostInternalAsync(
subdomainPrefix: 'web-preview',
subdomainId,
baseDomain,
authtoken,
authtoken: getNgrokAuthtokenOrThrow(env),
logger,
});
if (finishTask) {
@@ -608,7 +457,6 @@ async function startDeviceSessionHostInternalAsync(
previewFailed = false;
return {
previewPageUrl,
sharePageUrl,
apiUrl: tunnel.url,
previewToken,
closeAsync() {
@@ -635,7 +483,6 @@ async function startDeviceSessionHostInternalAsync(
return opening;
},
finishAsync() {
readinessController.abort(new Error('Simulator preview ended.'));
return (finishTask ??= finishDeviceSessionHostAsync(ctx, {
previewTask,
stopSessionPreviewAsync: async () => {
@@ -652,122 +499,32 @@ async function startDeviceSessionHostInternalAsync(
logger,
artifactLogger,
onStopped: successful => onFinished(successful && !previewFailed),
})
.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);
}));
}).catch(err => {
artifactLogger.warn({ err }, 'Could not finish the simulator preview.');
onFinished(false);
}));
},
};
try {
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 })
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.'
);
}
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) {
secrets.push(previewToken);
IosSimulatorRecordingUtils.useServeSimPackage(packageSpec);
}
// Android installed FFmpeg before launching the host. The optional thumbnail must not delay
@@ -799,56 +556,13 @@ 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,
{
@@ -928,11 +642,7 @@ 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:
redactPreviewTokens(previewServer.getOutput()).slice(-HOST_OUTPUT_TAIL_CHARS) ||
'<empty>',
},
{ hostOutput: previewServer.getOutput().slice(-HOST_OUTPUT_TAIL_CHARS) || '<empty>' },
'Session host output around the recording failure.'
);
}
@@ -1,41 +0,0 @@
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;
}
@@ -1,110 +0,0 @@
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,18 +110,6 @@ 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
@@ -290,14 +278,6 @@ 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`,
@@ -311,7 +291,13 @@ export async function resolveLocalEgressBootEnvironmentAsync({
libraryPath,
logPath = LOCAL_EGRESS_GUARD_LOG_PATH,
mode = 'block',
}: LocalEgressBootEnvironmentOptions = {}): Promise<Record<string, string> | null> {
}: {
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> {
const handoff = await readLocalEgressHandoffAsync(handoffPath);
if (!handoff) {
return null;
@@ -331,27 +317,6 @@ 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;
@@ -360,44 +325,6 @@ 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
@@ -469,7 +396,17 @@ export async function installLocalEgressGuardAsync({
await logAlreadyRunningProcessesAsync({ env, logger });
await startLocalEgressGuardRelayAsync({ logger, logPath, tailIntervalMs });
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.`
);
}
try {
await IosSimulatorUtils.setLaunchdEnvironmentAsync({
@@ -486,6 +423,22 @@ 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'
@@ -705,7 +658,14 @@ 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 = await requireEgressGuardCheckAsync(checkPath);
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.'
);
}
let output: string;
try {
const result = await spawn('xcrun', ['simctl', 'spawn', udid, resolvedCheck, '--mode', mode], {
@@ -10,6 +10,8 @@ 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';
@@ -27,18 +29,49 @@ 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;
@@ -76,17 +109,6 @@ 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()),
@@ -193,13 +215,23 @@ export async function waitForDeviceRunSessionStoppedAsync({
}
}
try {
const status = await fetchDeviceRunSessionStatusAsync(ctx, deviceRunSessionId, signal);
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.`);
}
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) {
@@ -226,20 +258,6 @@ 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({
@@ -252,9 +270,12 @@ async function ensureDeviceRunSessionStoppedSafelyAsync({
logger: bunyan;
}): Promise<void> {
try {
await requireDeviceRunSessionWorkerApi(ctx).stopAsync({
timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS,
});
const result = await ctx.graphqlClient
.mutation(ENSURE_DEVICE_RUN_SESSION_STOPPED_MUTATION, { deviceRunSessionId })
.toPromise();
if (result.error) {
throw result.error;
}
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not mark idle device run session as stopped', error, {
@@ -419,6 +440,12 @@ 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`
@@ -460,11 +487,34 @@ export function turnIceServersToWebPreviewArgs(iceServers: TurnIceServers): stri
*/
export async function fetchWebPreviewTurnArgsAsync(
ctx: CustomBuildContext,
{ logger }: { logger: bunyan }
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
): Promise<string[]> {
try {
const iceServers = await fetchTurnIceServersAsync(ctx, { logger });
const args = turnIceServersToWebPreviewArgs(iceServers);
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);
if (args.length > 0) {
logger.info('Configured the web preview with Cloudflare TURN ICE servers.');
}
@@ -480,17 +530,6 @@ 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,
@@ -505,15 +544,12 @@ export async function uploadRemoteSessionConfigAsync({
logger.info(
`Reporting remote config to the API server (device run session: ${deviceRunSessionId}).`
);
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
try {
await workerApi.startAsync({ remoteConfig });
} catch (err) {
const result = await ctx.graphqlClient
.mutation(START_DEVICE_RUN_SESSION_MUTATION, { deviceRunSessionId, remoteConfig })
.toPromise();
if (result.error) {
throw new SystemError(
`Failed to start device run session ${deviceRunSessionId}: ${
err instanceof Error ? err.message : String(err)
}`,
{ cause: err }
`Failed to start device run session ${deviceRunSessionId}: ${result.error.message}`
);
}
}
@@ -681,53 +717,6 @@ 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[];
@@ -759,80 +748,6 @@ 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,
@@ -1,101 +0,0 @@
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,20 +30,17 @@ export type StartupTasks = {
summary(): string;
};
export function createStartupTasks(logger: bunyan, parentSignal?: AbortSignal): StartupTasks {
export function createStartupTasks(logger: bunyan): 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,
signal: controller.signal,
run<T>(name: string, fn: (taskLogger: bunyan) => Promise<T>): Promise<T> {
const taskStartedAt = Date.now();
logger.info(`Starting ${name}.`);
@@ -64,6 +61,7 @@ export function createStartupTasks(logger: bunyan, parentSignal?: AbortSignal):
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,10 +154,8 @@ export namespace AndroidEmulatorUtils {
try {
let configIniFileContent = await fs.promises.readFile(configIniFile, 'utf-8');
// 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`;
logger.info('Setting hw.ramSize to 2048.');
configIniFileContent = `${configIniFileContent}\nhw.ramSize=2048\n`;
const shouldResizeScreen =
env.ANDROID_EMULATOR_ADJUST_SCREEN === 'true' || env.ANDROID_EMULATOR_ADJUST_SCREEN === '1';
@@ -136,10 +136,7 @@ describe('AndroidEmulatorUtils', () => {
);
});
it.each([
[undefined, 2048],
['device-run-session-id', 4096],
])('configures memory for session %s without an LCD override', async (sessionId, ramSize) => {
it('does not apply an LCD configuration when it is omitted', async () => {
const deviceName = 'generic-emulator' as AndroidVirtualDeviceName;
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
const initialConfig = ['skin.path=pixel_9', 'showDeviceFrame=yes'].join('\n');
@@ -159,14 +156,12 @@ 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,13 +3,7 @@ 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;
randomize?: boolean;
} = {
options: { retries: number; factor?: number; minTimeout?: number; maxTimeout?: number } = {
retries: 3,
factor: 2,
},
@@ -1,13 +0,0 @@
# 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": "25.1.1",
"version": "24.13.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "25.1.1"
"@expo/steps": "24.13.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
-13
View File
@@ -1,13 +0,0 @@
# @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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "25.1.1",
"version": "24.13.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -1,13 +0,0 @@
# 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": "25.1.1",
"version": "24.13.0",
"description": "A build cache provider plugin for the Expo CLI",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
-13
View File
@@ -1,13 +0,0 @@
# @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.
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "25.1.1",
"version": "24.13.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -25,9 +25,9 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "25.1.1",
"@expo/logger": "24.13.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "25.1.1",
"@expo/turtle-spawn": "24.13.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
-27
View File
@@ -1,27 +0,0 @@
# 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))
+158 -208
View File
File diff suppressed because it is too large. Load diff
+6235 -202
View File
File diff suppressed because it is too large. Load diff
+5 -6
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "25.1.1",
"version": "24.14.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -48,12 +48,11 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "25.1.1",
"@expo/eas-json": "25.1.1",
"@expo/eas-build-job": "24.13.0",
"@expo/eas-json": "24.13.0",
"@expo/env": "^1.0.0",
"@expo/experimentation": "0.0.5",
"@expo/json-file": "8.3.3",
"@expo/logger": "25.1.1",
"@expo/logger": "24.13.0",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -65,7 +64,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "25.1.1",
"@expo/steps": "24.13.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -1,25 +0,0 @@
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,9 +7,6 @@ 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,
@@ -20,7 +17,6 @@ import {
WorkflowRunStatus,
WorkflowRunTriggerEventType,
} from '../../graphql/generated';
import { getMockViewerUserPermission } from '../viewerUserPermission';
export function getMockEasJson(): EasJson {
return {
@@ -42,7 +38,9 @@ export function getMockAppFragment(): AppFragment {
ownerAccount: {
id: 'test-account-id',
name: 'testuser',
viewerUserPermission2: getMockViewerUserPermission(Role.Owner),
viewerUserPermission: {
role: Role.Owner,
},
},
};
}
@@ -99,11 +97,6 @@ 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