mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e507735166 | ||
|
|
9b54c203ee | ||
|
|
a0fe3ff2c3 | ||
|
|
fb383d818b | ||
|
|
c07c5edba9 | ||
|
|
56646b7436 | ||
|
|
f775b10549 | ||
|
|
c756c5d0b5 | ||
|
|
9f674b1d19 | ||
|
|
6f33a4d2a4 | ||
|
|
44b63e5a7d | ||
|
|
42e4fa8a7c | ||
|
|
e4bb181802 | ||
|
|
53ac936b11 | ||
|
|
c0d3f4f935 | ||
|
|
5ec3d84fcb | ||
|
|
d0cb5a2d67 | ||
|
|
aae7d13ac0 | ||
|
|
d9b1f866d4 | ||
|
|
a03dca45eb | ||
|
|
f949f3430e | ||
|
|
e61a563b70 | ||
|
|
150cd21174 | ||
|
|
4b9e792e91 | ||
|
|
0199cd9331 | ||
|
|
ffe6bda88d | ||
|
|
904859b824 | ||
|
|
3fd966bb9e | ||
|
|
aa9f5370d4 | ||
|
|
60a0b483eb | ||
|
|
070f15c633 | ||
|
|
c8aa5eb05d | ||
|
|
68596f8c0c | ||
|
|
1e38ada929 | ||
|
|
d52629e9f3 | ||
|
|
bed6354ef5 | ||
|
|
9aac1a0dff | ||
|
|
c1c2b748b4 | ||
|
|
7b39b92df8 | ||
|
|
929905ba7e | ||
|
|
ef7fceba86 | ||
|
|
c2dc766c38 |
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@expo/eas-build-job': minor
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': none
|
||||
---
|
||||
|
||||
Make command descriptions in `eas --help` consistently lowercase without trailing periods.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'eas-cli': none
|
||||
'@expo/build-tools': none
|
||||
---
|
||||
|
||||
Bump Node.js to 22.23.4 to fix the CVEs in the [October 2026 Node.js security releases](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases).
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Stop sandboxes after an optional maximum duration measured from readiness.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/worker': patch
|
||||
---
|
||||
|
||||
Mask secrets in every field of uploaded job log records, such as logged errors, not only in log messages.
|
||||
@@ -22,4 +22,3 @@ jobs:
|
||||
uses: ./.github/actions/setup-mise
|
||||
- run: yarn install --immutable
|
||||
- run: yarn verify-graphql-code
|
||||
working-directory: packages/eas-cli
|
||||
@@ -2,6 +2,59 @@
|
||||
|
||||
This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
## [25.3.0](https://github.com/expo/eas-cli/releases/tag/v25.3.0) - 2026-10-20
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add a Free plan build limit checkout experiment to `eas build` that offers to open the Starter plan checkout. ([#4633](https://github.com/expo/eas-cli/pull/4633) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [build-tools] Let serve-sim boot, install and launch Argent iOS sessions. ([#4602](https://github.com/expo/eas-cli/pull/4602) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Let serve-sim boot, install and launch Appium iOS sessions. ([#4603](https://github.com/expo/eas-cli/pull/4603) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Upload the screen recording session manifest as a JSON artifact alongside each video. ([#4689](https://github.com/expo/eas-cli/pull/4689) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add a `--system-image` flag to `eas simulator:start` to select the Android SDK system image for the emulator. ([#4500](https://github.com/expo/eas-cli/pull/4500) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Quote Android launch arguments and URLs for the device shell in simulator sessions, so values with spaces or shell characters reach `am start` as one argument; pass raw values and remove any shell quoting added as a workaround. ([#4695](https://github.com/expo/eas-cli/pull/4695) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Wait up to five minutes for Google Play to finish processing an uploaded bundle before giving up on the edit commit. ([#4700](https://github.com/expo/eas-cli/pull/4700) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [build-tools] Reset the device run session idle timer on iOS Simulator preview input. ([#4691](https://github.com/expo/eas-cli/pull/4691) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Forward captured Simulator traffic through the local egress proxy. ([#4598](https://github.com/expo/eas-cli/pull/4598) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [25.2.0](https://github.com/expo/eas-cli/releases/tag/v25.2.0) - 2026-10-17
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add experimentation client. ([#4558](https://github.com/expo/eas-cli/pull/4558) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [eas-cli] Add a Free plan build limit guidance experiment for coding agents to `eas build`. ([#4631](https://github.com/expo/eas-cli/pull/4631) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [build-tools] Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard. ([#4544](https://github.com/expo/eas-cli/pull/4544) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `eas simulator:control` to run serve-sim actions, with file upload and download, on a remote iOS simulator session. ([#4641](https://github.com/expo/eas-cli/pull/4641) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Prepare authenticated simulator action transport and file transfers. ([#4642](https://github.com/expo/eas-cli/pull/4642) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [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
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "24.14.0",
|
||||
"PRODUCTION": "24.14.0"
|
||||
"STAGING": "25.3.0",
|
||||
"PRODUCTION": "25.3.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "25.0.0",
|
||||
"version": "25.3.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -6,4 +6,4 @@ MISE_NODE_COREPACK = "true"
|
||||
actionlint = "1.7.12"
|
||||
gcloud = "570.0.0"
|
||||
jq = "1.8.1"
|
||||
node = "22.23.3"
|
||||
node = "22.23.4"
|
||||
@@ -7,6 +7,7 @@
|
||||
"scripts": {
|
||||
"build": "lerna run build",
|
||||
"typecheck": "lerna run typecheck",
|
||||
"verify-graphql-code": "lerna run verify-graphql-code --concurrency 1",
|
||||
"start": "lerna run watch --stream --parallel",
|
||||
"start-allow-unused": "lerna run watch-allow-unused --stream --parallel",
|
||||
"watch": "yarn start",
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# @expo/build-tools
|
||||
|
||||
## 25.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Let serve-sim boot, install and launch Argent iOS sessions. ([#4602](https://github.com/expo/eas-cli/pull/4602) by [@gwdp](https://github.com/gwdp))
|
||||
- Let serve-sim boot, install and launch Appium iOS sessions. ([#4603](https://github.com/expo/eas-cli/pull/4603) by [@gwdp](https://github.com/gwdp))
|
||||
- Upload the screen recording session manifest as a JSON artifact alongside each video. ([#4689](https://github.com/expo/eas-cli/pull/4689) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Quote Android launch arguments and URLs for the device shell in simulator sessions, so values with spaces or shell characters reach `am start` as one argument; pass raw values and remove any shell quoting added as a workaround. ([#4695](https://github.com/expo/eas-cli/pull/4695) by [@brentvatne](https://github.com/brentvatne))
|
||||
- Wait up to five minutes for Google Play to finish processing an uploaded bundle before giving up on the edit commit. ([#4700](https://github.com/expo/eas-cli/pull/4700) by [@hSATAC](https://github.com/hSATAC))
|
||||
- Reset the device run session idle timer on iOS Simulator preview input. ([#4691](https://github.com/expo/eas-cli/pull/4691) by [@gwdp](https://github.com/gwdp))
|
||||
- Forward captured Simulator traffic through the local egress proxy. ([#4598](https://github.com/expo/eas-cli/pull/4598) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 25.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard. ([#4544](https://github.com/expo/eas-cli/pull/4544) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "25.0.0",
|
||||
"version": "25.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -26,7 +26,7 @@
|
||||
"build:egress-guard": "resources/egress-guard/build.sh",
|
||||
"test:egress-guard": "resources/egress-guard/tests/run-policy-tests.sh && resources/egress-guard/tests/run-guard-tests.sh",
|
||||
"typecheck": "tsc",
|
||||
"generate-appium-commands": "mise exec [email protected]2.0 -- node scripts/generate-appium-commands.js",
|
||||
"generate-appium-commands": "mise exec [email protected]3.4 -- node scripts/generate-appium-commands.js",
|
||||
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
|
||||
"jest-unit": "jest --config jest/unit-config.ts",
|
||||
"jest-integration": "jest --config jest/integration-config.ts",
|
||||
@@ -36,22 +36,23 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\"",
|
||||
"gql": "gql.tada generate-schema ${API_SERVER_URL:-https://api.expo.dev}/graphql --output ./schema.graphql && gql.tada generate-output",
|
||||
"gql:local": "API_SERVER_URL=http://api.expo.test yarn gql",
|
||||
"verify-graphql-code": "./scripts/verify-graphql.sh",
|
||||
"test": "yarn jest-unit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "25.0.0",
|
||||
"@expo/eas-build-job": "25.0.0",
|
||||
"@expo/downloader": "25.3.0",
|
||||
"@expo/eas-build-job": "25.3.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "25.0.0",
|
||||
"@expo/logger": "25.3.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "25.0.0",
|
||||
"@expo/template-file": "25.0.0",
|
||||
"@expo/turtle-spawn": "25.0.0",
|
||||
"@expo/steps": "25.3.0",
|
||||
"@expo/template-file": "25.3.0",
|
||||
"@expo/turtle-spawn": "25.3.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -eox pipefail
|
||||
|
||||
# Generated GraphQL files are untracked, so always regenerate and check compatibility.
|
||||
yarn gql
|
||||
yarn run -T lerna run build --scope @expo/build-tools --include-dependencies
|
||||
+43
-5
@@ -79,9 +79,11 @@ beforeEach(() => {
|
||||
apiUrl: 'https://preview.example.test',
|
||||
closeAsync: closePreview,
|
||||
});
|
||||
jest
|
||||
.mocked(startDeviceSessionHostAsync)
|
||||
.mockResolvedValue({ openPreviewAsync: openPreview, finishAsync: finishHost });
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: openPreview,
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: finishHost,
|
||||
});
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: undefined,
|
||||
getOutput: () => '',
|
||||
@@ -108,7 +110,11 @@ describe.each([
|
||||
['Appium', createStartAppiumRemoteSessionBuildFunction],
|
||||
['Agent Device', createStartAgentDeviceSessionBuildFunction],
|
||||
] as const)('%s host ownership', (name, createFunction) => {
|
||||
async function runAsync(publicPreview?: boolean) {
|
||||
async function runAsync(
|
||||
publicPreview?: boolean,
|
||||
frameAncestors?: string[],
|
||||
corsOrigins?: string[]
|
||||
) {
|
||||
const fn = createFunction({} as CustomBuildContext);
|
||||
await fn.fn!(
|
||||
{
|
||||
@@ -118,7 +124,11 @@ describe.each([
|
||||
{
|
||||
// Inputs a step does not get default to undefined, like in a real step call.
|
||||
inputs: new Proxy(
|
||||
{ public_preview: { value: publicPreview } } as Record<string, { value: unknown }>,
|
||||
{
|
||||
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 },
|
||||
}
|
||||
@@ -179,6 +189,34 @@ describe.each([
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -11,6 +11,7 @@ import * as tar from 'tar';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { Datadog } from '../../../datadog';
|
||||
import * as files from '../../../utils/files';
|
||||
import { createDownloadBuildFunction, downloadBuildAsync } from '../downloadBuild';
|
||||
|
||||
@@ -30,11 +31,17 @@ const APPLICATION_ARCHIVE_URL = `https://expo.dev/artifacts/eas/${randomUUID()}.
|
||||
function createMockGraphqlClient({
|
||||
applicationArchiveUrl,
|
||||
error,
|
||||
transientError,
|
||||
}: {
|
||||
applicationArchiveUrl?: string | null;
|
||||
error?: Error;
|
||||
transientError?: Error;
|
||||
}): Client {
|
||||
const toPromise = jest.fn().mockResolvedValue(
|
||||
const toPromise = jest.fn();
|
||||
if (transientError) {
|
||||
toPromise.mockResolvedValueOnce({ error: transientError, data: undefined });
|
||||
}
|
||||
toPromise.mockResolvedValue(
|
||||
error
|
||||
? { error, data: undefined }
|
||||
: {
|
||||
@@ -62,7 +69,7 @@ function createSuccessfulResponse({
|
||||
url,
|
||||
headers,
|
||||
}: {
|
||||
body: Buffer;
|
||||
body: Buffer | Readable;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}): Response {
|
||||
@@ -72,7 +79,7 @@ function createSuccessfulResponse({
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
body: Readable.from(body),
|
||||
body: body instanceof Readable ? body : Readable.from(body),
|
||||
url,
|
||||
headers: {
|
||||
get: (name: string): string | null => normalizedHeaders[name.toLowerCase()] ?? null,
|
||||
@@ -80,6 +87,47 @@ function createSuccessfulResponse({
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function createUndiciNetworkError(code: string): CombinedError {
|
||||
return new CombinedError({
|
||||
networkError: Object.assign(new TypeError('fetch failed'), {
|
||||
cause: Object.assign(new Error(`read ${code}`), { code }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const { FetchError: ActualFetchError } = jest.requireActual(
|
||||
'node-fetch'
|
||||
) as typeof import('node-fetch');
|
||||
|
||||
function createSocketFetchError(url: string, code: string): Error {
|
||||
return new ActualFetchError(`request to ${url} failed, reason: connect ${code}`, 'system', {
|
||||
code,
|
||||
} as any);
|
||||
}
|
||||
|
||||
function useFakeBackoffTimers(): void {
|
||||
// Streams and fs callbacks need the real nextTick and setImmediate; only the backoff timers are faked.
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
|
||||
}
|
||||
|
||||
const MAX_IO_TURNS_BEFORE_BACKOFF = 1000;
|
||||
|
||||
async function waitForBackoffTimerAsync(): Promise<void> {
|
||||
for (let turns = 0; jest.getTimerCount() === 0; turns++) {
|
||||
if (turns === MAX_IO_TURNS_BEFORE_BACKOFF) {
|
||||
throw new Error('No backoff timer was scheduled.');
|
||||
}
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
async function fireBackoffTimersAsync(count: number): Promise<void> {
|
||||
for (let i = 0; i < count; i++) {
|
||||
await waitForBackoffTimerAsync();
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
}
|
||||
}
|
||||
|
||||
async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
|
||||
const sourceDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'flat-app-source-'));
|
||||
const archivePath = path.join(os.tmpdir(), `${randomUUID()}.tar.gz`);
|
||||
@@ -108,6 +156,11 @@ async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('downloadBuild', () => {
|
||||
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
|
||||
const buildId = randomUUID();
|
||||
@@ -465,13 +518,13 @@ describe('downloadBuild', () => {
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws SystemError when GraphQL request fails with a network error', async () => {
|
||||
it('throws SystemError with the cause code after retrying a failing build lookup', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
error: new CombinedError({
|
||||
networkError: new Error('Network request failed'),
|
||||
}),
|
||||
error: createUndiciNetworkError('ECONNRESET'),
|
||||
});
|
||||
const datadogLog = jest.spyOn(Datadog, 'log');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
@@ -480,19 +533,81 @@ describe('downloadBuild', () => {
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await fireBackoffTimersAsync(5);
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await rejection;
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: `Could not fetch build ${buildId}: [Network] Network request failed`,
|
||||
message: `Could not fetch build ${buildId}: [Network] fetch failed (ECONNRESET)`,
|
||||
});
|
||||
expect(graphqlClient.query).toHaveBeenCalledTimes(6);
|
||||
expect(datadogLog).toHaveBeenCalledWith('download_build.build_lookup failed on attempt 6', {
|
||||
event: 'network_retry',
|
||||
operation: 'download_build.build_lookup',
|
||||
outcome: 'failed',
|
||||
attempts: '6',
|
||||
code: 'ECONNRESET',
|
||||
});
|
||||
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws SystemError when GraphQL request returns 5xx', async () => {
|
||||
it.each([
|
||||
['a network error', createUndiciNetworkError('ECONNRESET'), 'ECONNRESET'],
|
||||
[
|
||||
'a transient GraphQL error',
|
||||
new CombinedError({
|
||||
graphQLErrors: [
|
||||
{
|
||||
message: 'Too many requests',
|
||||
extensions: { isTransient: true, errorCode: 'TOO_MANY_REQUESTS' },
|
||||
},
|
||||
],
|
||||
response: { status: 200 },
|
||||
}),
|
||||
'TOO_MANY_REQUESTS',
|
||||
],
|
||||
])('downloads after a build lookup that failed once with %s', async (_, transientError, code) => {
|
||||
useFakeBackoffTimers();
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
error: Object.assign(new Error('Internal Server Error'), {
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
transientError,
|
||||
});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
|
||||
);
|
||||
|
||||
const datadogLog = jest.spyOn(Datadog, 'log');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
await fireBackoffTimersAsync(1);
|
||||
|
||||
const { artifactPath } = await promise;
|
||||
expect(path.basename(artifactPath)).toBe('app.apk');
|
||||
expect(graphqlClient.query).toHaveBeenCalledTimes(2);
|
||||
expect(datadogLog).toHaveBeenCalledWith('download_build.build_lookup recovered on attempt 2', {
|
||||
event: 'network_retry',
|
||||
operation: 'download_build.build_lookup',
|
||||
outcome: 'recovered',
|
||||
attempts: '2',
|
||||
code,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws SystemError when GraphQL request returns 5xx', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
error: new CombinedError({
|
||||
graphQLErrors: [{ message: 'Internal Server Error' }],
|
||||
response: { status: 500 },
|
||||
}),
|
||||
});
|
||||
@@ -504,12 +619,14 @@ describe('downloadBuild', () => {
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await fireBackoffTimersAsync(5);
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await rejection;
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: `Could not fetch build ${buildId}: Internal Server Error`,
|
||||
message: `Could not fetch build ${buildId}: [GraphQL] Internal Server Error`,
|
||||
});
|
||||
|
||||
expect(graphqlClient.query).toHaveBeenCalledTimes(6);
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -534,10 +651,193 @@ describe('downloadBuild', () => {
|
||||
errorCode: 'EAS_DOWNLOAD_BUILD_FETCH_FAILED',
|
||||
message: `Could not fetch build ${buildId}: [GraphQL] Build not found`,
|
||||
});
|
||||
|
||||
expect(graphqlClient.query).toHaveBeenCalledTimes(1);
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('downloads after an archive request that failed once with a socket error', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValueOnce(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ECONNRESET'))
|
||||
.mockResolvedValueOnce(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
|
||||
);
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
await fireBackoffTimersAsync(1);
|
||||
|
||||
const { artifactPath } = await promise;
|
||||
expect(path.basename(artifactPath)).toBe('app.apk');
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws SystemError with the cause code after retrying a failing build archive request', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValue(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ETIMEDOUT'));
|
||||
const datadogLog = jest.spyOn(Datadog, 'log');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await fireBackoffTimersAsync(5);
|
||||
|
||||
await rejection;
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: `Could not download the application archive: request to ${APPLICATION_ARCHIVE_URL} failed, reason: connect ETIMEDOUT`,
|
||||
});
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
|
||||
expect(datadogLog).toHaveBeenCalledWith('download_build.archive_download failed on attempt 6', {
|
||||
event: 'network_retry',
|
||||
operation: 'download_build.archive_download',
|
||||
outcome: 'failed',
|
||||
attempts: '6',
|
||||
code: 'ETIMEDOUT',
|
||||
});
|
||||
});
|
||||
|
||||
function createBrokenBody(code: string): Readable {
|
||||
return Readable.from(
|
||||
(async function* () {
|
||||
yield Buffer.from('he');
|
||||
throw Object.assign(new Error(`body failed with ${code}`), { code });
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
it.each(['ERR_STREAM_PREMATURE_CLOSE', 'ECONNRESET'])(
|
||||
'downloads again when the archive body breaks off with %s after the headers arrived',
|
||||
async code => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
const brokenBody = createBrokenBody(code);
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(
|
||||
createSuccessfulResponse({ body: brokenBody, url: 'https://cdn.test/app.apk' })
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
|
||||
);
|
||||
const datadogLog = jest.spyOn(Datadog, 'log');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
await fireBackoffTimersAsync(1);
|
||||
|
||||
const { artifactPath } = await promise;
|
||||
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(2);
|
||||
expect(datadogLog).toHaveBeenCalledWith(
|
||||
'download_build.archive_download recovered on attempt 2',
|
||||
expect.objectContaining({ outcome: 'recovered', code })
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('throws SystemError when the build archive body keeps breaking off', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
jest.mocked(fetch).mockImplementation(async () =>
|
||||
createSuccessfulResponse({
|
||||
body: createBrokenBody('ECONNRESET'),
|
||||
url: 'https://cdn.test/app.apk',
|
||||
})
|
||||
);
|
||||
const datadogLog = jest.spyOn(Datadog, 'log');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await fireBackoffTimersAsync(5);
|
||||
|
||||
await rejection;
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: 'Could not download the application archive: body failed with ECONNRESET',
|
||||
});
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
|
||||
expect(datadogLog).toHaveBeenCalledWith(
|
||||
'download_build.archive_download failed on attempt 6',
|
||||
expect.objectContaining({ outcome: 'failed', code: 'ECONNRESET' })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not start another archive attempt after an abort during the backoff', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValueOnce(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ECONNRESET'));
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('step timed out');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
signal: controller.signal,
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBe(reason);
|
||||
await waitForBackoffTimerAsync();
|
||||
controller.abort(reason);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
await rejection;
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rethrows the raw error of a failing user-supplied archive URL after the retries', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValue(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ENOTFOUND'));
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBeInstanceOf(ActualFetchError);
|
||||
await fireBackoffTimersAsync(5);
|
||||
|
||||
await rejection;
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it('should throw an error if no matching files are found', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
@@ -605,6 +905,21 @@ describe('createDownloadBuildFunction', () => {
|
||||
};
|
||||
}
|
||||
|
||||
function createApplicationResponse(buildId: string): Response {
|
||||
return new ActualResponse(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
build: {
|
||||
id: buildId,
|
||||
gitCommitHash: null,
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
function createStep(ctx: any, callInputs: Record<string, unknown>) {
|
||||
return createDownloadBuildFunction(ctx).createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({
|
||||
@@ -620,18 +935,7 @@ describe('createDownloadBuildFunction', () => {
|
||||
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 createApplicationResponse(buildId);
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
@@ -661,6 +965,33 @@ describe('createDownloadBuildFunction', () => {
|
||||
expect(graphqlClient.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries the worker API lookup after a 5xx response', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(new ActualResponse('Service Unavailable', { status: 503 }))
|
||||
.mockResolvedValueOnce(createApplicationResponse(buildId))
|
||||
.mockResolvedValueOnce(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
|
||||
);
|
||||
|
||||
const execution = createStep(createSessionCtx(graphqlClient), {
|
||||
build_id: buildId,
|
||||
extensions: ['apk'],
|
||||
}).executeAsync();
|
||||
await fireBackoffTimersAsync(1);
|
||||
|
||||
await execution;
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(3);
|
||||
expect(jest.mocked(fetch).mock.calls.map(([url]) => url)).toEqual([
|
||||
workerApiUrl,
|
||||
workerApiUrl,
|
||||
APPLICATION_ARCHIVE_URL,
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not call the worker API for an application_archive_url input', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
@@ -201,6 +202,263 @@ describe(launchApplicationAsync, () => {
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('quotes Android launch arguments and URLs for the device shell', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'dev.example.app',
|
||||
activityName: 'dev.example.app.MainActivity',
|
||||
launchArgs: ['-d', 'euxy://tempo', '--es', 'probe', 'hello world', '--es', 'quote', "it's"],
|
||||
openUrl: 'exp://example.test/path?a=1&b=2',
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'-d',
|
||||
'euxy://tempo',
|
||||
'--es',
|
||||
'probe',
|
||||
"'hello world'",
|
||||
'--es',
|
||||
'quote',
|
||||
"'it'\\''s'",
|
||||
'-n',
|
||||
'dev.example.app/dev.example.app.MainActivity',
|
||||
],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'-a',
|
||||
'android.intent.action.VIEW',
|
||||
'-d',
|
||||
"'exp://example.test/path?a=1&b=2'",
|
||||
],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(logger.info).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
`Launching dev.example.app with arguments ${JSON.stringify(['-d', 'euxy://tempo', '--es', 'probe', 'hello world', '--es', 'quote', "it's"])}.`
|
||||
);
|
||||
});
|
||||
|
||||
it('passes iOS launch arguments with spaces unquoted', async () => {
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
launchArgs: ['-name', 'hello world'],
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'launch', 'booted', 'com.example.app', '-name', 'hello world'],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('round-trips quoted Android arguments through a POSIX shell', async () => {
|
||||
const component = 'dev.example.app/dev.example.app.MainActivity$Inner';
|
||||
const launchArgs = [
|
||||
'--es',
|
||||
'text',
|
||||
'hello world',
|
||||
'--es',
|
||||
'json',
|
||||
'{"a":1,"b":2}',
|
||||
'--es',
|
||||
'shell',
|
||||
'$(printf injected) `printf injected` $HOME ; & | < >',
|
||||
'--es',
|
||||
'glob',
|
||||
'*',
|
||||
'--es',
|
||||
'home',
|
||||
'~',
|
||||
'--esa',
|
||||
'list',
|
||||
'a\\,b,c',
|
||||
'--es',
|
||||
'whitespace',
|
||||
'one\ntwo\tthree',
|
||||
'--es',
|
||||
'unicode',
|
||||
'héllo 🌍',
|
||||
'--es',
|
||||
'empty',
|
||||
'',
|
||||
];
|
||||
const openUrl = 'exp://example.test/path?a=1&b=2#fragment';
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'dev.example.app',
|
||||
activityName: 'dev.example.app.MainActivity$Inner',
|
||||
launchArgs,
|
||||
openUrl,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
// adb joins the words after `shell` with spaces and the device shell parses the result.
|
||||
const parsedCommands = mockedSpawn.mock.calls.map(([, args]) => {
|
||||
const { stdout } = spawnSync(
|
||||
'/bin/sh',
|
||||
['-c', `am() { printf '%s\\0' "$@"; }; ${(args as string[]).slice(1).join(' ')}`],
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
return stdout.split('\0').slice(0, -1);
|
||||
});
|
||||
expect(parsedCommands).toEqual([
|
||||
['start', ...launchArgs, '-n', component],
|
||||
['start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([[['--es', 'key', '']], [['-e', 'key', '']], [['-d', '']]])(
|
||||
'passes empty Android option values as empty arguments (%j)',
|
||||
async launchArgs => {
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
...launchArgs.map(argument => (argument === '' ? "''" : argument)),
|
||||
'-n',
|
||||
'com.example.app/com.example.app.MainActivity',
|
||||
],
|
||||
expect.any(Object)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('explains an Android launch failure when a launch argument is empty', async () => {
|
||||
const launchError = new Error('adb shell am start exited with non-zero code: 1');
|
||||
mockedSpawn.mockRejectedValueOnce(launchError);
|
||||
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs: ['--ez', 'isTest', 'true', ''],
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
errorCode: 'EAS_LAUNCH_APPLICATION_FAILED',
|
||||
message: expect.stringContaining('empty argument at position 4'),
|
||||
cause: launchError,
|
||||
});
|
||||
});
|
||||
|
||||
it('rethrows an Android launch failure unchanged when no launch argument is empty', async () => {
|
||||
const launchError = new Error('adb shell am start exited with non-zero code: 1');
|
||||
mockedSpawn.mockRejectedValueOnce(launchError);
|
||||
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs: ['--ez', 'isTest', 'true'],
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toBe(launchError);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ launchArgs: ['--es', 'key', 'a\0b'] },
|
||||
{ launchArgs: [], openUrl: 'exp://example.test/a\0b' },
|
||||
])('rejects NUL characters on Android (%j)', async ({ launchArgs, openUrl }) => {
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs,
|
||||
openUrl,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith(
|
||||
'adb',
|
||||
expect.arrayContaining([expect.stringContaining('\0')]),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts an Android launch command of exactly 65,000 bytes', async () => {
|
||||
const fixedLength = 'am start --es key -n com.example.app/com.example.app.MainActivity'.length;
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs: ['--es', 'key', 'a'.repeat(65_000 - fixedLength)],
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects an Android URL command of 65,001 bytes', async () => {
|
||||
const fixedLength = 'am start -a android.intent.action.VIEW -d exp://example.test/'.length;
|
||||
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
openUrl: `exp://example.test/${'a'.repeat(65_001 - fixedLength)}`,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects an Android launch command that is too long after quoting', async () => {
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs: ['--es', 'quotes', "'".repeat(20_000)],
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('launches using application metadata passed to the build step', async () => {
|
||||
const launchApplication = createLaunchApplicationFunction();
|
||||
const buildStep = launchApplication.createBuildStepFromFunctionCall(
|
||||
@@ -237,6 +495,36 @@ describe(launchApplicationAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('quotes Android launch arguments passed to the build step', async () => {
|
||||
const launchApplication = createLaunchApplicationFunction();
|
||||
const buildStep = launchApplication.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
|
||||
{
|
||||
callInputs: {
|
||||
application_identifier: 'com.example.app',
|
||||
activity_name: 'com.example.app.MainActivity',
|
||||
launch_args: ['--es', 'probe', 'hello world'],
|
||||
},
|
||||
}
|
||||
);
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'--es',
|
||||
'probe',
|
||||
"'hello world'",
|
||||
'-n',
|
||||
'com.example.app/com.example.app.MainActivity',
|
||||
],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it.each([{}, ['valid', 42]])('rejects invalid launch arguments (%j)', async launchArgs => {
|
||||
const launchApplication = createLaunchApplicationFunction();
|
||||
const buildStep = launchApplication.createBuildStepFromFunctionCall(
|
||||
|
||||
+26
-1
@@ -14,6 +14,12 @@ import {
|
||||
registerLocalEgressResources,
|
||||
stopLocalEgressResourcesAsync,
|
||||
} from '../../utils/localEgress';
|
||||
import {
|
||||
reportLocalEgressGuardCoverageAsync,
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync,
|
||||
startLocalEgressGuardRelayAsync,
|
||||
verifyLocalEgressGuardAsync,
|
||||
} from '../../utils/localEgressGuard';
|
||||
import {
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
@@ -51,6 +57,13 @@ jest.mock('../../utils/localEgress', () => ({
|
||||
readLocalEgressHandoffAsync: jest.fn(),
|
||||
monitorLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/localEgressGuard', () => ({
|
||||
...jest.requireActual('../../utils/localEgressGuard'),
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn(),
|
||||
startLocalEgressGuardRelayAsync: jest.fn(),
|
||||
reportLocalEgressGuardCoverageAsync: jest.fn(),
|
||||
verifyLocalEgressGuardAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
getDeviceRunSessionIdOrThrow: () => 'session-id',
|
||||
@@ -126,9 +139,20 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
jest.mocked(isProcessDescendantOfAsync).mockResolvedValue(true);
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid: 'sim' }] as never);
|
||||
.mockResolvedValue([{ udid: 'sim', name: 'iPhone 17' }] as never);
|
||||
jest.mocked(IosSimulatorUtils.resolveUdidAsync).mockResolvedValue('sim' as never);
|
||||
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(handoff);
|
||||
jest
|
||||
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
|
||||
.mockImplementation(async () =>
|
||||
(await readLocalEgressHandoffAsync())
|
||||
? { SERVE_SIM_ADDITIONAL_DYLIBS: '/test/egress-guard.dylib' }
|
||||
: null
|
||||
);
|
||||
jest.mocked(startLocalEgressGuardRelayAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(reportLocalEgressGuardCoverageAsync).mockResolvedValue(null);
|
||||
jest.mocked(verifyLocalEgressGuardAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(monitorLocalEgressAsync).mockResolvedValue(undefined);
|
||||
openPreview.mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
@@ -138,6 +162,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: openPreview,
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopPreview,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
|
||||
+316
-25
@@ -70,13 +70,14 @@ const mockEventCollectionStopAsync = jest.fn();
|
||||
type CaptureInputs = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['capture'];
|
||||
const NO_CAPTURE: CaptureInputs = { networkCapture: false, networkCaptureFields: [] };
|
||||
|
||||
/** Runs the session for a device that is already booted, with the app (if any) launched. */
|
||||
/** Runs an Android session that is ready, or an iOS session with no application preparation. */
|
||||
async function runAsync(
|
||||
logger: { info: jest.Mock; warn: jest.Mock; child: jest.Mock },
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
capture: CaptureInputs = NO_CAPTURE,
|
||||
disableShare = false,
|
||||
privatePreviewUrl?: string
|
||||
privatePreviewUrl?: string,
|
||||
maxIdleTimeMinutes?: number
|
||||
): Promise<void> {
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env: {},
|
||||
@@ -88,13 +89,16 @@ async function runAsync(
|
||||
ngrokAuthtoken: 'ngrok-token',
|
||||
},
|
||||
packageVersion: undefined,
|
||||
maxIdleTimeMinutes: undefined,
|
||||
maxIdleTimeMinutes,
|
||||
maxDurationSeconds: undefined,
|
||||
capture,
|
||||
disableShare,
|
||||
privatePreviewUrl,
|
||||
tasks: createStartupTasks(logger as never),
|
||||
device: { booted: Promise.resolve(), ready: Promise.resolve() },
|
||||
device:
|
||||
runtimePlatform === BuildRuntimePlatform.DARWIN
|
||||
? { iosSimulatorUdid: 'selected-udid', application: Promise.resolve() }
|
||||
: { booted: Promise.resolve(), ready: Promise.resolve() },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,13 +128,24 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
subdomainId: 'agent-device-abc',
|
||||
stopAsync: mockTunnelStopAsync,
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
const host = {
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: 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);
|
||||
@@ -150,7 +165,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, timeoutMs: 60_000 })
|
||||
);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -189,6 +204,21 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(encodeURIComponent(token));
|
||||
});
|
||||
|
||||
it('resets the idle timer on preview input from the session host', async () => {
|
||||
const previewInputAt = new Date('2026-10-19T12:00:00Z');
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(ctx, {
|
||||
application: Promise.resolve(),
|
||||
} as never);
|
||||
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(previewInputAt);
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.LINUX, NO_CAPTURE, false, undefined, 5);
|
||||
|
||||
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
|
||||
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(previewInputAt);
|
||||
});
|
||||
|
||||
it('hands network capture to serve-sim', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
@@ -289,14 +319,14 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
});
|
||||
|
||||
describe('runAgentDeviceRemoteSessionAsync with a device that is still starting', () => {
|
||||
function deferred(): {
|
||||
promise: Promise<void>;
|
||||
resolve: () => void;
|
||||
function deferred<T = void>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (e: Error) => void;
|
||||
} {
|
||||
let resolve!: () => void;
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (e: Error) => void;
|
||||
const promise = new Promise<void>((res, rej) => {
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
@@ -304,18 +334,26 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
type Device = { booted: Promise<unknown>; ready: Promise<unknown> };
|
||||
function startSession(device: Device | ((tasks: StartupTasks) => Device)) {
|
||||
type Device = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['device'];
|
||||
function startSession(
|
||||
device: Device | ((tasks: StartupTasks) => Device),
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
child: jest.fn().mockReturnThis(),
|
||||
} as never;
|
||||
const tasks = createStartupTasks(logger);
|
||||
const tasks = createStartupTasks(logger, signal);
|
||||
const sessionDevice = typeof device === 'function' ? device(tasks) : device;
|
||||
return runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env: {},
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
signal,
|
||||
runtimePlatform:
|
||||
'iosSimulatorUdid' in sessionDevice
|
||||
? BuildRuntimePlatform.DARWIN
|
||||
: BuildRuntimePlatform.LINUX,
|
||||
sessionEnv: {
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
ngrokTunnelDomain: 'tunnel.example.com',
|
||||
@@ -326,7 +364,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
maxDurationSeconds: undefined,
|
||||
capture: NO_CAPTURE,
|
||||
tasks,
|
||||
device: typeof device === 'function' ? device(tasks) : device,
|
||||
device: sessionDevice,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -336,7 +374,31 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
}
|
||||
}
|
||||
|
||||
it('starts the daemon during the boot and the session host after it', async () => {
|
||||
it('aborts parallel startup before host failure cleanup finishes', async () => {
|
||||
const failure = new Error('guard refused startup');
|
||||
const cleanup = deferred();
|
||||
const notified = deferred();
|
||||
let startupTasks!: StartupTasks;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async (_ctx, options) => {
|
||||
options.onStartupError?.(failure);
|
||||
notified.resolve();
|
||||
await cleanup.promise;
|
||||
throw failure;
|
||||
});
|
||||
const session = startSession(tasks => {
|
||||
startupTasks = tasks;
|
||||
return { iosSimulatorUdid: 'selected-udid' };
|
||||
});
|
||||
const rejected = expect(session).rejects.toBe(failure);
|
||||
await notified.promise;
|
||||
expect(startupTasks.signal.aborted).toBe(true);
|
||||
expect(startupTasks.signal.reason).toBe(failure);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
cleanup.resolve();
|
||||
await rejected;
|
||||
});
|
||||
|
||||
it('starts the daemon during the Android boot and the session host after it', async () => {
|
||||
const booted = deferred();
|
||||
const session = startSession({ booted: booted.promise, ready: booted.promise });
|
||||
await flushAsync();
|
||||
@@ -348,10 +410,237 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
booted.resolve();
|
||||
await session;
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
timeoutMs: 60_000,
|
||||
iosSimulatorUdid: undefined,
|
||||
})
|
||||
);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports the session as ready only after the app is launched', async () => {
|
||||
it('preserves a failed download when aborting daemon installation rejects first', async () => {
|
||||
const failure = new Error('download failed: build not found');
|
||||
const installStarted = deferred();
|
||||
jest.mocked(spawn).mockImplementation((async (_command, _args, options) => {
|
||||
return await new Promise((_resolve, reject) => {
|
||||
options!.signal!.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
const error = new Error('The operation was aborted');
|
||||
error.name = 'AbortError';
|
||||
reject(error);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
installStarted.resolve();
|
||||
});
|
||||
}) as typeof spawn);
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async (_ctx, { signal }) => {
|
||||
return await new Promise((_resolve, reject) => {
|
||||
signal!.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
setTimeout(() => reject(signal!.reason), 20);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
const applicationReady = Promise.resolve();
|
||||
const session = startSession(tasks => {
|
||||
void tasks.run('build download', async () => {
|
||||
await installStarted.promise;
|
||||
throw failure;
|
||||
});
|
||||
return { iosSimulatorUdid: 'selected-udid', application: applicationReady };
|
||||
});
|
||||
await expect(session).rejects.toBe(failure);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('boots the host during download and releases startup only after preparation', async () => {
|
||||
const hostReady = deferred();
|
||||
const application = deferred<{
|
||||
installAppPath: string;
|
||||
launchAppIdentifier: string;
|
||||
launchArgs: string[];
|
||||
openUrl: string;
|
||||
}>();
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
ctx,
|
||||
{} as never
|
||||
);
|
||||
const release = jest.fn();
|
||||
jest
|
||||
.mocked(startDeviceSessionHostAsync)
|
||||
.mockClear()
|
||||
.mockImplementation(async (_ctx, options) => {
|
||||
release(await options.application);
|
||||
await hostReady.promise;
|
||||
return host;
|
||||
});
|
||||
const applicationReady = application.promise;
|
||||
const session = startSession({
|
||||
iosSimulatorUdid: 'selected-udid',
|
||||
application: applicationReady,
|
||||
});
|
||||
await flushAsync();
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
expect(release).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
const startup = {
|
||||
installAppPath: '/tmp/App.app',
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
launchArgs: ['--flag'],
|
||||
openUrl: 'example://screen',
|
||||
};
|
||||
application.resolve(startup);
|
||||
await flushAsync();
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
application: applicationReady,
|
||||
iosSimulatorUdid: 'selected-udid',
|
||||
timeoutMs: 60_000,
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
);
|
||||
expect(release).toHaveBeenCalledWith(startup);
|
||||
expect(host.openPreviewAsync).not.toHaveBeenCalled();
|
||||
hostReady.resolve();
|
||||
await session;
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('passes the guard boot environment with the startup installation and launch', async () => {
|
||||
const startup = {
|
||||
installAppPath: '/tmp/App.app',
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
launchArgs: ['--flag'],
|
||||
openUrl: 'example://screen',
|
||||
};
|
||||
const bootEnv = {
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
SIMCTL_CHILD_EAS_EGRESS_GUARD_MODE: 'block',
|
||||
SIMCTL_CHILD_EAS_EGRESS_GUARD_LOG: '/tmp/egress-guard.log',
|
||||
SIMCTL_CHILD_HTTP_PROXY: 'http://127.0.0.1:8899',
|
||||
};
|
||||
await startSession({
|
||||
iosSimulatorUdid: 'guarded-udid',
|
||||
bootEnv,
|
||||
application: Promise.resolve(startup),
|
||||
});
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
application: expect.any(Promise),
|
||||
bootEnv,
|
||||
iosSimulatorUdid: 'guarded-udid',
|
||||
})
|
||||
);
|
||||
const hostOptions = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
|
||||
expect(hostOptions.bootEnv).toBe(bootEnv);
|
||||
expect(hostOptions.bootEnv).not.toHaveProperty('DYLD_INSERT_LIBRARIES');
|
||||
expect(hostOptions.bootEnv).not.toHaveProperty('SIMCTL_CHILD_DYLD_INSERT_LIBRARIES');
|
||||
});
|
||||
|
||||
it('cleans up a host returned after cancellation without configuring the device', async () => {
|
||||
const hostReady = deferred();
|
||||
const controller = new AbortController();
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
ctx,
|
||||
{} as never
|
||||
);
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async () => {
|
||||
await hostReady.promise;
|
||||
return host;
|
||||
});
|
||||
const applicationReady = Promise.resolve();
|
||||
const session = startSession(
|
||||
{ iosSimulatorUdid: 'selected-udid', application: applicationReady },
|
||||
controller.signal
|
||||
);
|
||||
const failure = expect(session).rejects.toThrow('cancelled');
|
||||
await flushAsync();
|
||||
controller.abort(new Error('cancelled'));
|
||||
hostReady.resolve();
|
||||
|
||||
await failure;
|
||||
expect(host.openPreviewAsync).not.toHaveBeenCalled();
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drains in-flight application preparation and stops the waiting host after cancellation', async () => {
|
||||
const controller = new AbortController();
|
||||
const appReady = deferred();
|
||||
const session = startSession(
|
||||
{ iosSimulatorUdid: 'selected-udid', application: appReady.promise },
|
||||
controller.signal
|
||||
);
|
||||
const failure = expect(session).rejects.toThrow('cancelled');
|
||||
await flushAsync();
|
||||
controller.abort(new Error('cancelled'));
|
||||
await flushAsync();
|
||||
expect(mockPreviewStopAsync).not.toHaveBeenCalled();
|
||||
|
||||
appReady.resolve();
|
||||
await failure;
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not report readiness when cancellation arrives during preview startup', async () => {
|
||||
const controller = new AbortController();
|
||||
const previewReady = deferred();
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
ctx,
|
||||
{} as never
|
||||
);
|
||||
const preview = await host.openPreviewAsync({
|
||||
baseDomain: 'tunnel.example.com',
|
||||
authtoken: 'ngrok-token',
|
||||
});
|
||||
jest.mocked(host.openPreviewAsync).mockImplementation(async () => {
|
||||
await previewReady.promise;
|
||||
return preview;
|
||||
});
|
||||
const session = startSession(
|
||||
{ iosSimulatorUdid: 'selected-udid', application: Promise.resolve() },
|
||||
controller.signal
|
||||
);
|
||||
const failure = expect(session).rejects.toThrow('cancelled');
|
||||
await flushAsync();
|
||||
controller.abort(new Error('cancelled'));
|
||||
previewReady.resolve();
|
||||
|
||||
await failure;
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not start resources for an already cancelled session', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error('cancelled'));
|
||||
const applicationReady = Promise.resolve();
|
||||
|
||||
await expect(
|
||||
startSession(
|
||||
{ iosSimulatorUdid: 'selected-udid', application: applicationReady },
|
||||
controller.signal
|
||||
)
|
||||
).rejects.toThrow('cancelled');
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts the Android host before application readiness and waits before reporting ready', async () => {
|
||||
const ready = deferred();
|
||||
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
|
||||
await flushAsync();
|
||||
@@ -361,10 +650,11 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
|
||||
ready.resolve();
|
||||
await session;
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops waiting for the boot when the daemon fails', async () => {
|
||||
it('stops waiting for the Android boot when the daemon fails', async () => {
|
||||
jest.mocked(waitForFileAsync).mockRejectedValue(new Error('no daemon credentials'));
|
||||
const neverBooted = new Promise<void>(() => {});
|
||||
|
||||
@@ -378,7 +668,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops the session host when the app fails while the daemon install hangs', async () => {
|
||||
it('cancels daemon installation and stops the Android host when the download fails', async () => {
|
||||
jest.mocked(spawn).mockImplementation(((
|
||||
command: string,
|
||||
args: string[],
|
||||
@@ -394,21 +684,22 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
await flushAsync();
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
|
||||
ready.reject(new Error('simctl install failed'));
|
||||
ready.reject(new Error('download failed'));
|
||||
|
||||
await expect(session).rejects.toThrow('simctl install failed');
|
||||
await expect(session).rejects.toThrow('download failed');
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops everything it started when the app install fails', async () => {
|
||||
it('stops the Android host, daemon and tunnel when the download fails', async () => {
|
||||
const ready = deferred();
|
||||
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
|
||||
await flushAsync();
|
||||
ready.reject(new Error('simctl install failed'));
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
ready.reject(new Error('download failed'));
|
||||
|
||||
await expect(session).rejects.toThrow('simctl install failed');
|
||||
await expect(session).rejects.toThrow('download failed');
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
|
||||
+342
-75
@@ -1,11 +1,20 @@
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { DeviceRunSessionWorkerApi } from '../../utils/deviceRunSessionWorkerApi';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { stopLocalEgressResourcesAsync } from '../../utils/localEgress';
|
||||
import {
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync,
|
||||
startLocalEgressGuardRelayAsync,
|
||||
} from '../../utils/localEgressGuard';
|
||||
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
|
||||
import { downloadBuildAsync } from '../downloadBuild';
|
||||
import { installBuildAsync } from '../installBuild';
|
||||
import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact';
|
||||
import { launchApplicationAsync } from '../launchApplication';
|
||||
import {
|
||||
getAgentDeviceRemoteSessionEnvOrThrow,
|
||||
@@ -15,17 +24,30 @@ import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceS
|
||||
import { startAndroidEmulatorAsync } from '../startAndroidEmulator';
|
||||
import { bootIosSimulatorAsync } from '../startIosSimulator';
|
||||
|
||||
jest.mock('../../utils/localEgressSession', () => ({
|
||||
withLocalEgressSession: (fn: unknown) => fn,
|
||||
jest.mock('../../utils/localEgress', () => ({
|
||||
stopLocalEgressResourcesAsync: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.mock('../../utils/localEgressGuard', () => ({
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn(),
|
||||
startLocalEgressGuardRelayAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/IosSimulatorUtils');
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../startIosSimulator', () => ({ bootIosSimulatorAsync: jest.fn() }));
|
||||
jest.mock('../startIosSimulator', () => ({
|
||||
...jest.requireActual('../startIosSimulator'),
|
||||
bootIosSimulatorAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../startAndroidEmulator', () => ({ startAndroidEmulatorAsync: jest.fn() }));
|
||||
jest.mock('../downloadBuild', () => ({ downloadBuildAsync: jest.fn() }));
|
||||
jest.mock('../installBuild', () => ({ installBuildAsync: jest.fn() }));
|
||||
jest.mock('../../utils/iosAppArtifact', () => ({
|
||||
...jest.requireActual('../../utils/iosAppArtifact'),
|
||||
readIosApplicationIdentifierAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../launchApplication', () => ({
|
||||
...jest.requireActual('../launchApplication'),
|
||||
launchApplicationAsync: jest.fn(),
|
||||
@@ -43,7 +65,17 @@ const sessionEnv = {
|
||||
ngrokAuthtoken: 'ngrok-token',
|
||||
};
|
||||
|
||||
type Device = { booted: Promise<unknown>; ready: Promise<unknown> };
|
||||
type Device = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['device'];
|
||||
let deviceLaunch: unknown;
|
||||
|
||||
function runDevicePreparationAsync(device: Device): Promise<unknown> {
|
||||
return (
|
||||
'iosSimulatorUdid' in device ? (device.application ?? Promise.resolve()) : device.ready
|
||||
).then(async launch => {
|
||||
deviceLaunch = launch;
|
||||
return launch;
|
||||
});
|
||||
}
|
||||
|
||||
function deferred<T = void>(): {
|
||||
promise: Promise<T>;
|
||||
@@ -69,6 +101,7 @@ async function flushAsync(): Promise<void> {
|
||||
function runStep(
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
inputValues: Record<string, unknown> = {},
|
||||
signal?: AbortSignal,
|
||||
stepCtx: CustomBuildContext = ctx
|
||||
): Promise<void> {
|
||||
const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx);
|
||||
@@ -88,6 +121,8 @@ function runStep(
|
||||
'network_capture_fields',
|
||||
'public_preview',
|
||||
'private_preview_url',
|
||||
'frame_ancestors',
|
||||
'cors_origins',
|
||||
'package_version',
|
||||
'max_idle_time_minutes',
|
||||
'max_duration_seconds',
|
||||
@@ -101,22 +136,32 @@ function runStep(
|
||||
staticContext: { job: { secrets: { robotAccessToken: 'robot-token' } } },
|
||||
},
|
||||
} as unknown as BuildStepContext,
|
||||
{ inputs, outputs: {}, env: {}, signal: undefined } as never
|
||||
{ inputs, outputs: {}, env: {}, signal } as never
|
||||
) as Promise<void>;
|
||||
}
|
||||
|
||||
/** The `device` promises handed to the shared agent-device session code. */
|
||||
function sessionDevice(): Device {
|
||||
return jest.mocked(runAgentDeviceRemoteSessionAsync).mock.calls[0][1].device;
|
||||
}
|
||||
|
||||
describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
deviceLaunch = undefined;
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(null);
|
||||
jest.mocked(startLocalEgressGuardRelayAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Shutdown' } as never);
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '' } as never);
|
||||
jest.mocked(IosSimulatorUtils.resolveUdidAsync).mockResolvedValue('selected-udid' as never);
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ name: 'iPhone 17', udid: 'SELECTED-UDID' }] as never);
|
||||
jest.mocked(getAgentDeviceRemoteSessionEnvOrThrow).mockReturnValue(sessionEnv);
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async (_ctx, { device }) => {
|
||||
await device.ready;
|
||||
});
|
||||
jest
|
||||
.mocked(runAgentDeviceRemoteSessionAsync)
|
||||
.mockImplementation(async (_ctx, { device, tasks }) => {
|
||||
await tasks.untilAborted(runDevicePreparationAsync(device));
|
||||
});
|
||||
jest.mocked(bootIosSimulatorAsync).mockResolvedValue({
|
||||
deviceIdentifier: 'iPhone 17' as never,
|
||||
udid: 'udid' as never,
|
||||
@@ -132,22 +177,31 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
.mocked(installBuildAsync)
|
||||
.mockResolvedValue({ applicationIdentifier: 'dev.example.app', activityName: '.Main' });
|
||||
jest.mocked(launchApplicationAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(readIosApplicationIdentifierAsync).mockResolvedValue('dev.example.app');
|
||||
});
|
||||
|
||||
it('downloads the build and starts the session while the iOS Simulator boots', async () => {
|
||||
const boot = deferred<Awaited<ReturnType<typeof bootIosSimulatorAsync>>>();
|
||||
jest.mocked(bootIosSimulatorAsync).mockReturnValue(boot.promise);
|
||||
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, {
|
||||
it('hands guarded iOS boot to serve-sim and starts reporting before host startup', async () => {
|
||||
const bootEnv = {
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
SIMCTL_CHILD_EAS_EGRESS_GUARD_MODE: 'block',
|
||||
SIMCTL_CHILD_http_proxy: 'http://127.0.0.1:8899',
|
||||
};
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(bootEnv);
|
||||
await runStep(BuildRuntimePlatform.DARWIN, {
|
||||
build_id: 'build-id',
|
||||
launch_args: ['-flag'],
|
||||
open_url: 'exp://example.test',
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
expect(
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
|
||||
).toBeLessThan(jest.mocked(bootIosSimulatorAsync).mock.invocationCallOrder[0]);
|
||||
).toBeLessThan(
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(startLocalEgressGuardRelayAsync).toHaveBeenCalledTimes(1);
|
||||
expect(jest.mocked(startLocalEgressGuardRelayAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
buildId: 'build-id',
|
||||
@@ -157,28 +211,45 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
})
|
||||
);
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(1);
|
||||
// The install needs the booted Simulator.
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
|
||||
boot.resolve({
|
||||
deviceIdentifier: 'iPhone 17' as never,
|
||||
udid: 'udid' as never,
|
||||
displayName: '',
|
||||
const device = sessionDevice();
|
||||
expect(device).toEqual({
|
||||
iosSimulatorUdid: 'SELECTED-UDID',
|
||||
bootEnv,
|
||||
application: expect.any(Promise),
|
||||
});
|
||||
await step;
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
|
||||
artifactPath: '/tmp/App.app',
|
||||
env: {},
|
||||
});
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
expect(deviceLaunch).toEqual({
|
||||
installAppPath: '/tmp/App.app',
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
launchArgs: ['-flag'],
|
||||
openUrl: 'exp://example.test',
|
||||
});
|
||||
});
|
||||
|
||||
expect(installBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ artifactPath: '/tmp/App.app' })
|
||||
);
|
||||
expect(launchApplicationAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
applicationIdentifier: 'dev.example.app',
|
||||
launchArgs: ['-flag'],
|
||||
openUrl: 'exp://example.test',
|
||||
})
|
||||
);
|
||||
expect(jest.mocked(installBuildAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(launchApplicationAsync).mock.invocationCallOrder[0]
|
||||
it('shuts down only the selected guarded Booted device before host startup', async () => {
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
});
|
||||
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Booted' } as never);
|
||||
await runStep(BuildRuntimePlatform.DARWIN);
|
||||
expect(IosSimulatorUtils.getDeviceAsync).toHaveBeenCalledWith({
|
||||
udid: 'SELECTED-UDID',
|
||||
env: {},
|
||||
});
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(spawn).toHaveBeenCalledWith('xcrun', ['simctl', 'shutdown', 'SELECTED-UDID'], {
|
||||
env: {},
|
||||
logger: expect.any(Object),
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(jest.mocked(spawn).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
@@ -189,7 +260,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
} as unknown as CustomBuildContext;
|
||||
|
||||
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' }, sessionCtx);
|
||||
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' }, undefined, sessionCtx);
|
||||
|
||||
expect(downloadBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -213,6 +284,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
await runStep(
|
||||
BuildRuntimePlatform.LINUX,
|
||||
{ application_archive_url: 'https://example.test/app.tar.gz' },
|
||||
undefined,
|
||||
sessionCtx
|
||||
);
|
||||
expect(downloadBuildAsync).toHaveBeenLastCalledWith(
|
||||
@@ -223,7 +295,96 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('waits for the download when the Simulator boots first', async () => {
|
||||
it('fails before download or host startup when guarded device shutdown fails', async () => {
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
});
|
||||
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Booted' } as never);
|
||||
jest.mocked(spawn).mockRejectedValue(new Error('shutdown failed'));
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'shutdown failed'
|
||||
);
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('provides Simulator preparation and downloaded startup options to the remote session', async () => {
|
||||
const hostReady = deferred();
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
});
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async (_ctx, { device }) => {
|
||||
expect('iosSimulatorUdid' in device).toBe(true);
|
||||
if (!('iosSimulatorUdid' in device)) {
|
||||
return;
|
||||
}
|
||||
await hostReady.promise;
|
||||
deviceLaunch = await device.application;
|
||||
});
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, {
|
||||
device_identifier: 'iPhone 17',
|
||||
build_id: 'build-id',
|
||||
launch_args: ['-flag'],
|
||||
open_url: 'exp://example.test',
|
||||
});
|
||||
await flushAsync();
|
||||
expect(IosSimulatorUtils.resolveUdidAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'iPhone 17',
|
||||
env: {},
|
||||
});
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).toHaveBeenCalledTimes(1);
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
|
||||
artifactPath: '/tmp/App.app',
|
||||
env: {},
|
||||
});
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
expect(deviceLaunch).toBeUndefined();
|
||||
hostReady.resolve();
|
||||
await step;
|
||||
expect(deviceLaunch).toEqual({
|
||||
installAppPath: '/tmp/App.app',
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
launchArgs: ['-flag'],
|
||||
openUrl: 'exp://example.test',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails a guarded session before download or host startup if its boot files are missing', async () => {
|
||||
jest
|
||||
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
|
||||
.mockRejectedValue(new Error('missing bin/egress-guard-check'));
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'missing bin/egress-guard-check'
|
||||
);
|
||||
expect(startLocalEgressGuardRelayAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('releases guard reporting and egress resources when host startup fails', async () => {
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
});
|
||||
jest
|
||||
.mocked(runAgentDeviceRemoteSessionAsync)
|
||||
.mockRejectedValueOnce(new Error('guard check failed'));
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'guard check failed'
|
||||
);
|
||||
expect(startLocalEgressGuardRelayAsync).toHaveBeenCalledTimes(1);
|
||||
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits for the download before handing the app to the host', async () => {
|
||||
const download = deferred<{ artifactPath: string }>();
|
||||
jest.mocked(downloadBuildAsync).mockReturnValue(download.promise);
|
||||
|
||||
@@ -235,16 +396,21 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
|
||||
download.resolve({ artifactPath: '/tmp/App.app' });
|
||||
await step;
|
||||
expect(installBuildAsync).toHaveBeenCalledTimes(1);
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('makes the session ready when the device boots if there is no app', async () => {
|
||||
it('passes the selected Simulator to the host even when there is no app', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN);
|
||||
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
await expect(sessionDevice().ready).resolves.toBeUndefined();
|
||||
expect(sessionDevice()).toEqual({
|
||||
iosSimulatorUdid: 'SELECTED-UDID',
|
||||
bootEnv: undefined,
|
||||
application: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('boots the Android Emulator with the device inputs', async () => {
|
||||
@@ -276,23 +442,10 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
});
|
||||
|
||||
it('fails the session when the download fails, without an unhandled rejection', async () => {
|
||||
const boot = deferred<Awaited<ReturnType<typeof bootIosSimulatorAsync>>>();
|
||||
jest.mocked(bootIosSimulatorAsync).mockReturnValue(boot.promise);
|
||||
jest.mocked(downloadBuildAsync).mockRejectedValue(new Error('download failed'));
|
||||
|
||||
const stepFailure = expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })
|
||||
).rejects.toThrow('download failed');
|
||||
// The download fails while the boot still runs. Nothing inside the step may leave
|
||||
// that rejection unhandled; Jest fails the test if it does.
|
||||
await flushAsync();
|
||||
boot.resolve({
|
||||
deviceIdentifier: 'iPhone 17' as never,
|
||||
udid: 'udid' as never,
|
||||
displayName: '',
|
||||
});
|
||||
|
||||
await stepFailure;
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'download failed'
|
||||
);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -321,23 +474,24 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
jest
|
||||
.mocked(runAgentDeviceRemoteSessionAsync)
|
||||
.mockImplementation(async (_ctx, { tasks, device }) => {
|
||||
const ready = runDevicePreparationAsync(device);
|
||||
await beforeFailure?.();
|
||||
const daemon = tasks.run('agent-device daemon', async () => {
|
||||
throw new Error('daemon failed');
|
||||
});
|
||||
try {
|
||||
await Promise.all([daemon, device.ready]);
|
||||
await Promise.all([daemon, ready]);
|
||||
} finally {
|
||||
await Promise.allSettled([daemon, device.ready]);
|
||||
await Promise.allSettled([daemon, ready]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('stops the download and waits for it when the boot fails', async () => {
|
||||
it('stops the download and waits for it when Android boot fails', async () => {
|
||||
const download = mockStalledDownload();
|
||||
jest.mocked(bootIosSimulatorAsync).mockRejectedValue(new Error('boot failed'));
|
||||
jest.mocked(startAndroidEmulatorAsync).mockRejectedValue(new Error('boot failed'));
|
||||
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
await expect(runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'boot failed'
|
||||
);
|
||||
|
||||
@@ -361,25 +515,79 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not launch the app when startup fails during the install', async () => {
|
||||
const install = deferred<Awaited<ReturnType<typeof installBuildAsync>>>();
|
||||
const installStarted = deferred();
|
||||
jest.mocked(installBuildAsync).mockImplementation(() => {
|
||||
installStarted.resolve();
|
||||
return install.promise;
|
||||
it('drains the download when host startup fails before app preparation', async () => {
|
||||
const download = mockStalledDownload();
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mockRejectedValue(new Error('host failed'));
|
||||
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'host failed'
|
||||
);
|
||||
expect(download.aborted()).toBe(true);
|
||||
expect(download.settled()).toBe(true);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not start work for an already cancelled step', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error('cancelled'));
|
||||
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal)
|
||||
).rejects.toThrow('cancelled');
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels and drains a pending download through the external signal', async () => {
|
||||
const controller = new AbortController();
|
||||
const download = mockStalledDownload();
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal);
|
||||
const failure = expect(step).rejects.toThrow('cancelled');
|
||||
await flushAsync();
|
||||
controller.abort(new Error('cancelled'));
|
||||
|
||||
await failure;
|
||||
expect(download.aborted()).toBe(true);
|
||||
expect(download.settled()).toBe(true);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not start device or download work after cancellation during Xcode selection', async () => {
|
||||
const controller = new AbortController();
|
||||
const selected = deferred();
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockReturnValue(selected.promise);
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal);
|
||||
const failure = expect(step).rejects.toThrow('cancelled');
|
||||
await flushAsync();
|
||||
controller.abort(new Error('cancelled'));
|
||||
selected.resolve();
|
||||
|
||||
await failure;
|
||||
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drains app preparation when the daemon fails', async () => {
|
||||
const metadata = deferred<string>();
|
||||
const metadataStarted = deferred();
|
||||
jest.mocked(readIosApplicationIdentifierAsync).mockImplementation(() => {
|
||||
metadataStarted.resolve();
|
||||
return metadata.promise;
|
||||
});
|
||||
mockSessionWithFailingDaemon(async () => {
|
||||
await installStarted.promise;
|
||||
// The install finishes only after the daemon failed.
|
||||
setImmediate(() => install.resolve({ applicationIdentifier: 'dev.example.app' }));
|
||||
await metadataStarted.promise;
|
||||
setImmediate(() => metadata.resolve('dev.example.app'));
|
||||
});
|
||||
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'daemon failed'
|
||||
);
|
||||
|
||||
expect(installBuildAsync).toHaveBeenCalledTimes(1);
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
expect(deviceLaunch).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects conflicting application inputs before it boots anything', async () => {
|
||||
@@ -417,6 +625,8 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
'network_capture_fields',
|
||||
'public_preview',
|
||||
'private_preview_url',
|
||||
'frame_ancestors',
|
||||
'cors_origins',
|
||||
'package_version',
|
||||
'max_idle_time_minutes',
|
||||
'max_duration_seconds',
|
||||
@@ -440,6 +650,63 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
}
|
||||
);
|
||||
|
||||
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.each([
|
||||
['NUL argument', { launch_args: ['a\u0000b'] }],
|
||||
['command length', { launch_args: ['--es', 'quotes', "'".repeat(20_000)] }],
|
||||
['NUL URL', { open_url: 'example:\u0000screen' }],
|
||||
])('rejects Android launch %s before startup work', async (_name, inputs) => {
|
||||
const step = runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', ...inputs });
|
||||
await expect(step).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
|
||||
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps Android launch inputs on its existing direct path', async () => {
|
||||
const launchArgs = ['a'.repeat(9000)];
|
||||
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', launch_args: launchArgs });
|
||||
expect(launchApplicationAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, launchArgs })
|
||||
);
|
||||
});
|
||||
|
||||
it('passes network capture to the session', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN, {
|
||||
network_capture: true,
|
||||
|
||||
+397
-7
@@ -6,14 +6,18 @@ import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import * as processes from '../../../utils/processes';
|
||||
import { sleepAsync } from '../../../utils/retry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import { preparePreinstalledWdaAsync } from '../../utils/preinstalledWda';
|
||||
import {
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
|
||||
import {
|
||||
@@ -27,11 +31,16 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
AndroidEmulatorUtils: { getAttachedDevicesAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
IosSimulatorUtils: { getAvailableDevicesAsync: jest.fn() },
|
||||
IosSimulatorUtils: {
|
||||
getAvailableDevicesAsync: jest.fn(),
|
||||
getDeviceAsync: jest.fn(),
|
||||
resolveUdidAsync: jest.fn().mockResolvedValue('chosen-ios-udid'),
|
||||
},
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
spawnDetached: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
@@ -48,6 +57,15 @@ jest.mock('../../../utils/turtleFetch', () => ({
|
||||
}));
|
||||
jest.mock('../../utils/appiumEvents', () => ({ startAppiumEventCollectionAsync: jest.fn() }));
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/preinstalledWda', () => ({ preparePreinstalledWdaAsync: jest.fn() }));
|
||||
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/localEgressSession', () => ({
|
||||
...jest.requireActual('../../utils/localEgressSession'),
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync: jest.fn(),
|
||||
@@ -88,7 +106,6 @@ describe(resolveAppiumDeviceAsync, () => {
|
||||
resolveAppiumDeviceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
platformName: 'iOS',
|
||||
@@ -97,7 +114,6 @@ describe(resolveAppiumDeviceAsync, () => {
|
||||
udid: 'ios-simulator-id',
|
||||
runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
|
||||
});
|
||||
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledWith({ env: {}, logger });
|
||||
expect(IosSimulatorUtils.getAvailableDevicesAsync).toHaveBeenCalledWith({
|
||||
env: {},
|
||||
filter: 'booted',
|
||||
@@ -113,7 +129,6 @@ describe(resolveAppiumDeviceAsync, () => {
|
||||
resolveAppiumDeviceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
platformName: 'Android',
|
||||
@@ -212,6 +227,13 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
const stopEventCollection = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(ensureFfmpegInstalledOnceAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(preparePreinstalledWdaAsync).mockResolvedValue({});
|
||||
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({
|
||||
udid: 'CHOSEN-IOS-UDID',
|
||||
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
|
||||
state: 'Shutdown',
|
||||
} as never);
|
||||
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => ({
|
||||
stdout: args.includes('--json') ? '{}' : '',
|
||||
})) as never);
|
||||
@@ -242,6 +264,7 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: jest.fn(),
|
||||
});
|
||||
});
|
||||
@@ -250,16 +273,22 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function runSessionAsync(logger: { info: jest.Mock; warn: jest.Mock }): Promise<void> {
|
||||
async function runSessionAsync(
|
||||
logger: { info: jest.Mock; warn: jest.Mock },
|
||||
maxIdleTimeMinutes?: number,
|
||||
runtimePlatform = BuildRuntimePlatform.LINUX,
|
||||
startupInputs: Record<string, { value: unknown }> = {}
|
||||
): Promise<void> {
|
||||
await createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
global: { runtimePlatform },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
max_idle_time_minutes: { value: maxIdleTimeMinutes },
|
||||
...startupInputs,
|
||||
},
|
||||
outputs: {},
|
||||
env: {
|
||||
@@ -280,6 +309,345 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
);
|
||||
}
|
||||
|
||||
it.each([false, true])(
|
||||
'preserves preview settings with Simulator startup %s',
|
||||
async bootSimulator => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid: 'chosen-ios-udid' } as never]);
|
||||
await runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
|
||||
...(bootSimulator
|
||||
? { boot_simulator: { value: true }, device_identifier: { value: 'chosen-device' } }
|
||||
: {}),
|
||||
public_preview: { value: false },
|
||||
private_preview_url: { value: 'https://expo.dev/private-preview' },
|
||||
frame_ancestors: { value: ['https://preview.example.com'] },
|
||||
cors_origins: { value: ['https://api.example.com'] },
|
||||
});
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
disableShare: true,
|
||||
privatePreviewUrl: 'https://expo.dev/private-preview',
|
||||
frameAncestors: ['https://preview.example.com'],
|
||||
corsOrigins: ['https://api.example.com'],
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('selects Xcode only once for an already-prepared iOS session', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockClear();
|
||||
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockResolvedValue([
|
||||
{
|
||||
udid: 'chosen-ios-udid',
|
||||
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-4',
|
||||
} as never,
|
||||
]);
|
||||
jest.mocked(preparePreinstalledWdaAsync).mockResolvedValue({
|
||||
'appium:usePreinstalledWDA': true,
|
||||
'appium:prebuiltWDAPath': '/tmp/legacy-wda.app',
|
||||
});
|
||||
await runSessionAsync(
|
||||
{ info: jest.fn(), warn: jest.fn() },
|
||||
undefined,
|
||||
BuildRuntimePlatform.DARWIN
|
||||
);
|
||||
expect(preparePreinstalledWdaAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-4' })
|
||||
);
|
||||
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
|
||||
'appium:eventTimings': true,
|
||||
'appium:usePreinstalledWDA': true,
|
||||
'appium:prebuiltWDAPath': '/tmp/legacy-wda.app',
|
||||
});
|
||||
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
|
||||
).toBeLessThan(
|
||||
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('starts serve-sim on the selected iOS device while Appium installation is pending', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockClear();
|
||||
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockClear();
|
||||
jest.mocked(preparePreinstalledWdaAsync).mockResolvedValue({
|
||||
'appium:usePreinstalledWDA': true,
|
||||
'appium:prebuiltWDAPath': '/tmp/selected-wda.app',
|
||||
});
|
||||
let releaseInstall!: (value: unknown) => void;
|
||||
let enteredInstall!: () => void;
|
||||
const installEntered = new Promise<void>(resolve => {
|
||||
enteredInstall = resolve;
|
||||
});
|
||||
jest.mocked(spawn).mockImplementationOnce(() => {
|
||||
enteredInstall();
|
||||
return new Promise(resolve => {
|
||||
releaseInstall = resolve;
|
||||
}) as never;
|
||||
});
|
||||
const running = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
install_app_path: { value: '/tmp/App.app' },
|
||||
launch_args: { value: ['--literal'] },
|
||||
open_url: { value: 'example://home' },
|
||||
},
|
||||
outputs: {},
|
||||
env: {
|
||||
DEVICE_RUN_SESSION_ID: 'device-run-session-id',
|
||||
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'tunnel.example.com',
|
||||
NGROK_AUTHTOKEN: 'ngrok-token',
|
||||
},
|
||||
} as never
|
||||
);
|
||||
await installEntered;
|
||||
expect(
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
|
||||
).toBeLessThan(jest.mocked(IosSimulatorUtils.resolveUdidAsync).mock.invocationCallOrder[0]);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
iosSimulatorUdid: 'CHOSEN-IOS-UDID',
|
||||
installAppPath: '/tmp/App.app',
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
launchArgs: ['--literal'],
|
||||
openUrl: 'example://home',
|
||||
})
|
||||
);
|
||||
expect(IosSimulatorUtils.getAvailableDevicesAsync).not.toHaveBeenCalled();
|
||||
expect(preparePreinstalledWdaAsync).not.toHaveBeenCalled();
|
||||
const readyHost = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
|
||||
expect(readyHost.finishAsync).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1].signal?.aborted).toBe(false);
|
||||
releaseInstall({ stdout: '' });
|
||||
await running;
|
||||
expect(readyHost.finishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(IosSimulatorUtils.getDeviceAsync).toHaveBeenCalledWith({
|
||||
udid: 'CHOSEN-IOS-UDID',
|
||||
env: expect.objectContaining({ DEVICE_RUN_SESSION_ID: 'device-run-session-id' }),
|
||||
});
|
||||
expect(preparePreinstalledWdaAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
|
||||
appiumHome: jest.mocked(spawnDetached).mock.calls[0][0].env.APPIUM_HOME,
|
||||
})
|
||||
);
|
||||
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
|
||||
'appium:eventTimings': true,
|
||||
'appium:usePreinstalledWDA': true,
|
||||
'appium:prebuiltWDAPath': '/tmp/selected-wda.app',
|
||||
});
|
||||
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(1);
|
||||
expect(stopAppium).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('drains a failed Simulator startup installer before removing its Appium home', async () => {
|
||||
const hostError = new Error('Simulator boot failed');
|
||||
let failHost!: (error: Error) => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failHost = reject;
|
||||
})
|
||||
);
|
||||
let enteredInstall!: () => void;
|
||||
const installEntered = new Promise<void>(resolve => {
|
||||
enteredInstall = resolve;
|
||||
});
|
||||
let closeInstaller!: (error: Error) => void;
|
||||
const child = { pid: 12345, kill: jest.fn() };
|
||||
jest.mocked(spawn).mockImplementationOnce(() => {
|
||||
enteredInstall();
|
||||
return Object.assign(
|
||||
new Promise((_resolve, reject) => {
|
||||
closeInstaller = reject;
|
||||
}),
|
||||
{ child }
|
||||
) as never;
|
||||
});
|
||||
const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
|
||||
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
|
||||
boot_simulator: { value: true },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
});
|
||||
const settled = jest.fn();
|
||||
const observed = running.then(settled, settled);
|
||||
try {
|
||||
await installEntered;
|
||||
const home = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
|
||||
const hostOptions = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
|
||||
hostOptions.onStartupError?.(hostError);
|
||||
failHost(hostError);
|
||||
await Promise.resolve();
|
||||
expect(killGroup).toHaveBeenCalledWith(child, 'SIGKILL');
|
||||
expect(settled).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(home)).toBe(true);
|
||||
closeInstaller(new Error('installer interrupted'));
|
||||
await expect(running).rejects.toBe(hostError);
|
||||
expect(fs.existsSync(home)).toBe(false);
|
||||
expect(preparePreinstalledWdaAsync).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
closeInstaller?.(new Error('test cleanup'));
|
||||
await observed;
|
||||
}
|
||||
});
|
||||
|
||||
it('removes a completed Appium install when Simulator failure interrupts FFmpeg setup', async () => {
|
||||
const hostError = new Error('Simulator startup failed');
|
||||
let failHost!: (error: Error) => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failHost = reject;
|
||||
})
|
||||
);
|
||||
jest
|
||||
.mocked(ensureFfmpegInstalledOnceAsync)
|
||||
.mockImplementationOnce(
|
||||
({ signal }) =>
|
||||
new Promise(resolve => signal?.addEventListener('abort', () => resolve(), { once: true }))
|
||||
);
|
||||
let installed!: () => void;
|
||||
const installationFinished = new Promise<void>(resolve => {
|
||||
installed = resolve;
|
||||
});
|
||||
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (args[0] === 'driver' && args[1] === 'install') {
|
||||
installed();
|
||||
}
|
||||
return { stdout: args.includes('--json') ? '{}' : '' };
|
||||
}) as never);
|
||||
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
|
||||
boot_simulator: { value: true },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
});
|
||||
await installationFinished;
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1].onStartupError?.(hostError);
|
||||
failHost(hostError);
|
||||
await expect(running).rejects.toBe(hostError);
|
||||
const home = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
|
||||
expect(fs.existsSync(home)).toBe(false);
|
||||
expect(preparePreinstalledWdaAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves an installer failure before a later host failure during home cleanup', async () => {
|
||||
const installError = new Error('npm install failed');
|
||||
const hostError = new Error('later Simulator failure');
|
||||
let failHost!: (error: Error) => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failHost = reject;
|
||||
})
|
||||
);
|
||||
jest.mocked(spawn).mockRejectedValueOnce(installError);
|
||||
const remove = fs.promises.rm.bind(fs.promises);
|
||||
let enteredCleanup!: () => void;
|
||||
const cleanupEntered = new Promise<void>(resolve => {
|
||||
enteredCleanup = resolve;
|
||||
});
|
||||
let releaseCleanup!: () => void;
|
||||
const cleanupGate = new Promise<void>(resolve => {
|
||||
releaseCleanup = resolve;
|
||||
});
|
||||
jest.spyOn(fs.promises, 'rm').mockImplementationOnce(async (directory, options) => {
|
||||
enteredCleanup();
|
||||
await cleanupGate;
|
||||
await remove(directory, options);
|
||||
});
|
||||
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
|
||||
boot_simulator: { value: true },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
});
|
||||
const observed = running.catch(() => {});
|
||||
try {
|
||||
await cleanupEntered;
|
||||
const options = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
|
||||
expect(options.signal?.reason).toBe(installError);
|
||||
options.onStartupError?.(hostError);
|
||||
failHost(hostError);
|
||||
releaseCleanup();
|
||||
await expect(running).rejects.toBe(installError);
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
failHost(hostError);
|
||||
releaseCleanup();
|
||||
await observed;
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['WDA download', 'Appium readiness'])(
|
||||
'keeps the Simulator error when it interrupts %s',
|
||||
async stage => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
const hostError = new Error('Simulator launch failed');
|
||||
const laterError = new Error(`${stage} interrupted`);
|
||||
let failHost!: (error: Error) => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failHost = reject;
|
||||
})
|
||||
);
|
||||
let enteredStage!: () => void;
|
||||
const stageEntered = new Promise<void>(resolve => {
|
||||
enteredStage = resolve;
|
||||
});
|
||||
let rejectStage!: (error: Error) => void;
|
||||
let setupSignal: AbortSignal | undefined;
|
||||
const waitForStage = (signal?: AbortSignal): Promise<never> => {
|
||||
setupSignal = signal;
|
||||
enteredStage();
|
||||
return new Promise((_resolve, reject) => {
|
||||
rejectStage = reject;
|
||||
signal?.addEventListener('abort', () => reject(laterError), { once: true });
|
||||
});
|
||||
};
|
||||
if (stage === 'WDA download') {
|
||||
jest
|
||||
.mocked(preparePreinstalledWdaAsync)
|
||||
.mockImplementationOnce(({ signal }) => waitForStage(signal));
|
||||
} else {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockImplementationOnce((_url, _method, options) =>
|
||||
waitForStage(options?.signal ?? undefined)
|
||||
);
|
||||
}
|
||||
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
|
||||
boot_simulator: { value: true },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
});
|
||||
const observed = running.catch(() => {});
|
||||
try {
|
||||
await stageEntered;
|
||||
jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1].onStartupError?.(hostError);
|
||||
failHost(hostError);
|
||||
await Promise.resolve();
|
||||
expect(setupSignal?.aborted).toBe(true);
|
||||
await expect(running).rejects.toBe(hostError);
|
||||
const home = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
|
||||
expect(fs.existsSync(home)).toBe(false);
|
||||
expect(startAppiumEventCollectionAsync).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rejectStage?.(laterError);
|
||||
await observed;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps the readiness error when Appium cannot be stopped', async () => {
|
||||
let now = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
@@ -307,10 +675,32 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resets the idle timer on preview input from the session host', async () => {
|
||||
const previewInputAt = new Date('2026-10-19T12:00:00Z');
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
{} as never,
|
||||
{} as never
|
||||
);
|
||||
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(previewInputAt);
|
||||
|
||||
await expect(runSessionAsync({ info: jest.fn(), warn: jest.fn() }, 5)).rejects.toBe(stopError);
|
||||
|
||||
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
|
||||
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(previewInputAt);
|
||||
});
|
||||
|
||||
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);
|
||||
expect(preparePreinstalledWdaAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ runtimeIdentifier: undefined })
|
||||
);
|
||||
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
|
||||
'appium:eventTimings': true,
|
||||
});
|
||||
expectAppiumStoppedAndHomeRemoved(logger);
|
||||
expect(stopEventCollection.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
stopAppium.mock.invocationCallOrder[0]
|
||||
|
||||
+376
-2
@@ -7,8 +7,9 @@ import path from 'node:path';
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { isProcessDescendantOfAsync, killProcessGroup } from '../../../utils/processes';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
|
||||
import { startArgentEventCollectionAsync } from '../../utils/argentEvents';
|
||||
import { createProcessOutput } from '../../utils/processOutput';
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
|
||||
import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact';
|
||||
|
||||
// Redirect ~/.argent (where the tool-server writes its state file and event log) to a temp
|
||||
// home so waitForArgentToolServerStateAsync — which lives in the module under test and cannot
|
||||
@@ -38,13 +40,32 @@ jest.mock('node:os', () => {
|
||||
});
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../utils/processes', () => ({ isProcessDescendantOfAsync: jest.fn() }));
|
||||
jest.mock('../../../utils/processes', () => ({
|
||||
isProcessDescendantOfAsync: jest.fn(),
|
||||
killProcessGroup: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/argentArtifacts', () => ({ pollArgentArtifactsForUploadAsync: jest.fn() }));
|
||||
jest.mock('../../utils/argentEvents', () => ({
|
||||
...jest.requireActual('../../utils/argentEvents'),
|
||||
startArgentEventCollectionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/iosAppArtifact', () => ({
|
||||
...jest.requireActual('../../utils/iosAppArtifact'),
|
||||
readIosApplicationIdentifierAsync: jest.fn().mockResolvedValue('dev.example.app'),
|
||||
}));
|
||||
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/localEgressGuard', () => ({
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
@@ -104,6 +125,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
@@ -217,6 +239,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
if (phase === 'preview') {
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
|
||||
openPreviewAsync: jest.fn().mockRejectedValue(error),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
});
|
||||
} else if (phase === 'config') {
|
||||
@@ -422,6 +445,54 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
);
|
||||
});
|
||||
|
||||
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
|
||||
@@ -459,6 +530,35 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resets the idle timer on preview input from the session host', async () => {
|
||||
const previewInputAt = new Date('2026-10-19T12:00:00Z');
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
{} as never,
|
||||
{} as never
|
||||
);
|
||||
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(previewInputAt);
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
|
||||
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: 5 },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
|
||||
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
|
||||
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(previewInputAt);
|
||||
});
|
||||
|
||||
it('redacts startup credentials and registers the state token for later output', async () => {
|
||||
const token = 'tool/server "secret"';
|
||||
await fs.promises.writeFile(
|
||||
@@ -544,6 +644,280 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('boots and prepares the downloaded app while Argent installation is pending, and closes the host if installation fails', async () => {
|
||||
let rejectInstall!: (error: Error) => void;
|
||||
let enteredInstall!: () => void;
|
||||
const installEntered = new Promise<void>(resolve => {
|
||||
enteredInstall = resolve;
|
||||
});
|
||||
jest.mocked(spawn).mockImplementationOnce(() => {
|
||||
enteredInstall();
|
||||
return new Promise((_resolve, reject) => {
|
||||
rejectInstall = reject;
|
||||
}) as never;
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
const running = buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
install_app_path: { value: '/tmp/App.app' },
|
||||
launch_args: { value: ['--literal'] },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
await installEntered;
|
||||
expect(
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
|
||||
).toBeLessThan(jest.mocked(IosSimulatorUtils.resolveUdidAsync).mock.invocationCallOrder[0]);
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
|
||||
artifactPath: '/tmp/App.app',
|
||||
env: {},
|
||||
});
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
iosSimulatorUdid: 'SELECTED-IOS-UDID',
|
||||
installAppPath: '/tmp/App.app',
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
launchArgs: ['--literal'],
|
||||
})
|
||||
);
|
||||
const error = new Error('Argent install failed');
|
||||
rejectInstall(error);
|
||||
await expect(running).rejects.toBe(error);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['artifacts-list-endpoint', 'tool-server-event-log', 'video-watermark'])(
|
||||
'stops and drains the pending %s command when the concurrent host fails',
|
||||
async flag => {
|
||||
const hostError = new Error('serve-sim startup failed');
|
||||
const commandError = new Error('Argent command exited after cancellation');
|
||||
let failHost!: (error: Error) => void;
|
||||
let finishHostCleanup!: () => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(
|
||||
async (_ctx, options) =>
|
||||
await new Promise((_resolve, reject) => {
|
||||
failHost = error => {
|
||||
options.onStartupError?.(error);
|
||||
finishHostCleanup = () => reject(error);
|
||||
};
|
||||
})
|
||||
);
|
||||
let failCommand!: (error: Error) => void;
|
||||
let commandStarted!: () => void;
|
||||
const commandEntered = new Promise<void>(resolve => {
|
||||
commandStarted = resolve;
|
||||
});
|
||||
const child = { pid: 4242, kill: jest.fn() };
|
||||
jest.mocked(spawn).mockImplementation((_command, args) => {
|
||||
if (!args.includes(flag)) {
|
||||
return Promise.resolve(undefined) as never;
|
||||
}
|
||||
const pending = new Promise((_resolve, reject) => {
|
||||
failCommand = reject;
|
||||
});
|
||||
commandStarted();
|
||||
return Object.assign(pending, { child }) as never;
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
const running = Promise.resolve(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
boot_simulator: { value: true },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
);
|
||||
let settled = false;
|
||||
void running
|
||||
.finally(() => {
|
||||
settled = true;
|
||||
})
|
||||
.catch(() => {});
|
||||
try {
|
||||
await commandEntered;
|
||||
failHost(hostError);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(killProcessGroup).toHaveBeenCalledWith(child, 'SIGKILL');
|
||||
expect(settled).toBe(false);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
failCommand(commandError);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(settled).toBe(false);
|
||||
finishHostCleanup();
|
||||
await expect(running).rejects.toBe(hostError);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
failCommand?.(commandError);
|
||||
finishHostCleanup?.();
|
||||
await running.catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('interrupts tool-server state polling and retains the original host error', async () => {
|
||||
await fs.promises.rm(path.join(ARGENT_STATE_DIR, 'tool-server-orchestration.json'));
|
||||
const hostError = new Error('serve-sim startup failed');
|
||||
let failHost!: (error: Error) => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise((_resolve, reject) => {
|
||||
failHost = reject;
|
||||
})
|
||||
);
|
||||
let pollingStarted!: () => void;
|
||||
const pollingEntered = new Promise<void>(resolve => {
|
||||
pollingStarted = resolve;
|
||||
});
|
||||
const getExitError = jest.fn<Error | undefined, []>(() => {
|
||||
pollingStarted();
|
||||
return undefined;
|
||||
});
|
||||
const stopServer = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(spawnDetached).mockReturnValueOnce({
|
||||
pid: 4242,
|
||||
getOutput: () => 'later controller diagnostic',
|
||||
getExitError,
|
||||
stopAsync: stopServer,
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
const running = Promise.resolve(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
boot_simulator: { value: true },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
);
|
||||
void running.catch(() => {});
|
||||
try {
|
||||
await pollingEntered;
|
||||
failHost(hostError);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const options = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
|
||||
expect(options.signal?.aborted).toBe(true);
|
||||
getExitError.mockReturnValue(new Error('later Argent exit'));
|
||||
await expect(running).rejects.toBe(hostError);
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
getExitError.mockReturnValue(new Error('stop pending state poll'));
|
||||
await running.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps Argent setup concurrent but waits for the iOS host before opening tools', async () => {
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
{} as CustomBuildContext,
|
||||
{} as never
|
||||
);
|
||||
let releaseHost!: () => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
releaseHost = () => resolve(host);
|
||||
})
|
||||
);
|
||||
let stateReady!: () => void;
|
||||
const stateRead = new Promise<void>(resolve => {
|
||||
stateReady = resolve;
|
||||
});
|
||||
jest.mocked(isProcessDescendantOfAsync).mockImplementationOnce(async () => {
|
||||
stateReady();
|
||||
return true;
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
const running = Promise.resolve(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
boot_simulator: { value: true },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
);
|
||||
try {
|
||||
await stateRead;
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(1);
|
||||
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
releaseHost();
|
||||
await running;
|
||||
}
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
|
||||
'keeps %s prepared setup commands outside the new cancellation path',
|
||||
async runtimePlatform => {
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
expect(spawn).toHaveBeenCalledTimes(3);
|
||||
for (const [, , options] of jest.mocked(spawn).mock.calls) {
|
||||
expect(options).not.toHaveProperty('detached');
|
||||
expect(options).not.toHaveProperty('signal');
|
||||
}
|
||||
expect(killProcessGroup).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('fails before starting anything when a launch is asked for on Android', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
|
||||
import * as sandboxDaemon from '../../utils/sandboxDaemon';
|
||||
@@ -6,6 +6,90 @@ import * as sandboxDaemon from '../../utils/sandboxDaemon';
|
||||
import { createStartSandboxBuildFunction, markSandboxReadyAsync } from '../startSandbox';
|
||||
|
||||
describe('sandbox build functions', () => {
|
||||
it.each([
|
||||
{ completion: 'duration', duration: 0.02 },
|
||||
{ completion: 'cancellation', duration: 60 },
|
||||
{ completion: 'duration', duration: 0.0001 },
|
||||
])(
|
||||
'stops after $completion with $duration seconds only after readiness',
|
||||
async ({ completion, duration }) => {
|
||||
const controller = new AbortController();
|
||||
let ready!: () => void;
|
||||
const stopAsync = jest.fn(async () => {});
|
||||
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockResolvedValue({
|
||||
ready: new Promise<void>(resolve => {
|
||||
ready = resolve;
|
||||
}),
|
||||
stopAsync,
|
||||
});
|
||||
const mutation = jest.fn(() => ({ toPromise: async () => ({ data: {} }) }));
|
||||
const logger = { info: jest.fn(), child: jest.fn() };
|
||||
logger.child.mockReturnValue(logger);
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
graphqlClient: { mutation },
|
||||
} as any);
|
||||
try {
|
||||
const pending = fn.fn!({ logger } as any, {
|
||||
inputs: {
|
||||
sandbox_id: { value: 'sandbox-id' },
|
||||
max_duration_seconds: { value: duration },
|
||||
},
|
||||
outputs: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 40));
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
expect(stopAsync).not.toHaveBeenCalled();
|
||||
ready();
|
||||
if (completion === 'cancellation') {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(stopAsync).not.toHaveBeenCalled();
|
||||
controller.abort();
|
||||
}
|
||||
await pending;
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
const timeoutMessage = `Sandbox reached its maximum runtime of ${duration} seconds. Stopping.`;
|
||||
if (completion === 'duration') {
|
||||
expect(logger.info).toHaveBeenCalledWith(timeoutMessage);
|
||||
} else {
|
||||
expect(logger.info).not.toHaveBeenCalledWith(timeoutMessage);
|
||||
}
|
||||
} finally {
|
||||
start.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it.each([0, -1, Infinity, NaN])(
|
||||
'rejects invalid maximum duration %s before starting the daemon',
|
||||
async duration => {
|
||||
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync');
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
} as any);
|
||||
try {
|
||||
const result = fn.fn!({} as any, {
|
||||
inputs: {
|
||||
sandbox_id: { value: 'sandbox-id' },
|
||||
max_duration_seconds: { value: duration },
|
||||
},
|
||||
outputs: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
|
||||
});
|
||||
await expect(result).rejects.toBeInstanceOf(UserError);
|
||||
await expect(result).rejects.toThrow(
|
||||
`Invalid sandbox maximum duration: ${duration}. Provide a finite number greater than zero, or omit max_duration_seconds.`
|
||||
);
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
start.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('aborts an active ready request and keeps its authentication headers', async () => {
|
||||
const controller = new AbortController();
|
||||
let requestStarted!: () => void;
|
||||
|
||||
+29
@@ -81,6 +81,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
@@ -97,6 +98,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
previewToken: 'tok-1',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
|
||||
@@ -124,6 +126,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
|
||||
@@ -151,6 +154,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, { public_preview: { value: false } });
|
||||
@@ -168,6 +172,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
if (phase === 'preview') {
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
|
||||
openPreviewAsync: jest.fn().mockRejectedValue(error),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
} else if (phase === 'config') {
|
||||
@@ -210,6 +215,8 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
: {}),
|
||||
packageVersion: '1.2.3',
|
||||
disableShare: false,
|
||||
frameAncestors: [],
|
||||
corsOrigins: [],
|
||||
launchAppIdentifier: undefined,
|
||||
launchArgs: [],
|
||||
openUrl: undefined,
|
||||
@@ -258,6 +265,8 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
'network_capture_fields',
|
||||
'public_preview',
|
||||
'private_preview_url',
|
||||
'frame_ancestors',
|
||||
'cors_origins',
|
||||
'package_version',
|
||||
'max_duration_seconds',
|
||||
]);
|
||||
@@ -322,6 +331,26 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
}
|
||||
);
|
||||
|
||||
it('hands frame_ancestors to the session host', async () => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, {
|
||||
frame_ancestors: { value: ['https://embed.example.test'] },
|
||||
});
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ frameAncestors: ['https://embed.example.test'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('hands cors_origins to the session host', async () => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, {
|
||||
cors_origins: { value: ['https://app.example.test'] },
|
||||
});
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('hands network capture to the session host', async () => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, {
|
||||
network_capture: { value: true },
|
||||
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
import { createSubmitToGooglePlayBuildFunction } from '../submitToGooglePlay';
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
jest.mock('promise-retry', () => {
|
||||
const actual = jest.requireActual('promise-retry');
|
||||
return (fn: Parameters<typeof actual>[0], options: object) =>
|
||||
actual(fn, { ...options, minTimeout: 1, maxTimeout: 1, randomize: false });
|
||||
});
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
@@ -174,7 +179,7 @@ it('preserves the original failure when cleanup fails', async () => {
|
||||
api()
|
||||
.put(`${editPath}/tracks/internal`)
|
||||
.reply(400, { error: { message: 'unknown track' } });
|
||||
api().delete(editPath).reply(500);
|
||||
api().delete(editPath).times(6).reply(500);
|
||||
await expect(submission().run()).rejects.toMatchObject({
|
||||
cause: expect.objectContaining({ apiMessage: 'unknown track' }),
|
||||
});
|
||||
@@ -183,6 +188,7 @@ it('does not delete the edit or publish outputs when commit outcome is unknown',
|
||||
upload();
|
||||
api().put(`${editPath}/tracks/internal`).reply(200, { track: 'internal' });
|
||||
api().post(`${editPath}:commit`).query(true).reply(503);
|
||||
api().post(`${editPath}:commit`).query(true).reply(404);
|
||||
let deleted = false;
|
||||
api()
|
||||
.delete(editPath)
|
||||
|
||||
+11
-2
@@ -140,9 +140,12 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
const names = jest
|
||||
const uploads = jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mock.calls.map(([, options]) => options.name);
|
||||
.mock.calls.map(([, options]) => options);
|
||||
const names = uploads
|
||||
.filter(options => options.kind === 'screen-recording')
|
||||
.map(options => options.name);
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining([
|
||||
'iPhone 16 screen recording (01234567-…, started at Jul 10, 2026, 10:00:00.000 UTC)',
|
||||
@@ -151,6 +154,12 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
])
|
||||
);
|
||||
expect(new Set(names).size).toBe(3);
|
||||
const metadata = uploads.filter(options => options.kind === 'screen-recording-metadata');
|
||||
expect(metadata.map(options => options.name)).toEqual(
|
||||
expect.arrayContaining(names.map(name => `${name} metadata`))
|
||||
);
|
||||
expect(metadata).toHaveLength(3);
|
||||
expect(uploads).toHaveLength(6);
|
||||
} finally {
|
||||
await Promise.all(
|
||||
recordingDirectories.map(directory => fs.rm(directory, { recursive: true, force: true }))
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import { Client } from '@urql/core';
|
||||
import { Client, CombinedError } from '@urql/core';
|
||||
import contentDisposition from 'content-disposition';
|
||||
import { glob } from 'fast-glob';
|
||||
import { graphql } from 'gql.tada';
|
||||
@@ -26,10 +26,16 @@ import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
|
||||
import {
|
||||
describeError,
|
||||
isTransferFailure,
|
||||
isTransientGraphqlError,
|
||||
} from '../../utils/networkErrors';
|
||||
import { retryOnNetworkFailureAsync } from '../../utils/retryOnNetworkFailure';
|
||||
import { pluralize } from '../../utils/strings';
|
||||
import {
|
||||
type DeviceRunSessionWorkerApi,
|
||||
DeviceRunSessionWorkerApiError,
|
||||
getDeviceRunSessionWorkerApi,
|
||||
} from '../utils/deviceRunSessionWorkerApi';
|
||||
|
||||
@@ -152,21 +158,34 @@ async function fetchApplicationArchiveUrlAsync({
|
||||
signal,
|
||||
});
|
||||
}
|
||||
const result = await graphqlClient
|
||||
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
|
||||
.toPromise();
|
||||
let data;
|
||||
try {
|
||||
data = await retryOnNetworkFailureAsync(
|
||||
async () => {
|
||||
const queryResult = await graphqlClient
|
||||
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
|
||||
.toPromise();
|
||||
if (queryResult.error) {
|
||||
throw queryResult.error;
|
||||
}
|
||||
return queryResult.data;
|
||||
},
|
||||
{
|
||||
operation: 'download_build.build_lookup',
|
||||
signal,
|
||||
isRetryable: error => error instanceof CombinedError && isTransientGraphqlError(error),
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
const message = `Could not fetch build ${buildId}: ${describeError(error)}`;
|
||||
throw error instanceof CombinedError && !isTransientGraphqlError(error)
|
||||
? new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error })
|
||||
: new SystemError(message, { cause: error });
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
|
||||
if (result.error) {
|
||||
const { error } = result;
|
||||
const message = `Could not fetch build ${buildId}: ${error.message}`;
|
||||
|
||||
throw error.networkError || result.error.response?.status >= 500
|
||||
? new SystemError(message, { cause: error })
|
||||
: new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error });
|
||||
}
|
||||
|
||||
const applicationArchiveUrl = result.data?.builds.byId?.artifacts?.applicationArchiveUrl;
|
||||
const applicationArchiveUrl = data?.builds.byId?.artifacts?.applicationArchiveUrl;
|
||||
if (!applicationArchiveUrl) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
|
||||
@@ -195,13 +214,18 @@ async function fetchDeviceRunSessionApplicationArchiveUrlAsync({
|
||||
}): Promise<string> {
|
||||
let application;
|
||||
try {
|
||||
application = await deviceRunSessionWorkerApi.getApplicationAsync({ signal });
|
||||
application = await retryOnNetworkFailureAsync(
|
||||
() => deviceRunSessionWorkerApi.getApplicationAsync({ signal }),
|
||||
{
|
||||
operation: 'download_build.session_build_lookup',
|
||||
signal,
|
||||
isRetryable: error => error instanceof DeviceRunSessionWorkerApiError && error.isTransient,
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
signal?.throwIfAborted();
|
||||
throw new SystemError(
|
||||
`Could not fetch build ${buildId} for this device run session: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
`Could not fetch build ${buildId} for this device run session: ${describeError(err)}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
@@ -281,18 +305,34 @@ export async function downloadBuildAsync(
|
||||
);
|
||||
|
||||
signal?.throwIfAborted();
|
||||
const response = await retryOnDNSFailure(fetch)(downloadUrl, { headers, signal });
|
||||
|
||||
if (!response.ok) {
|
||||
const textResult = await asyncResult(response.text());
|
||||
throw new Error(`Unexpected response from server (${response.status}): ${textResult.value}`);
|
||||
let archivePath: string;
|
||||
try {
|
||||
archivePath = await retryOnNetworkFailureAsync(
|
||||
async () => {
|
||||
const response = await fetch(downloadUrl, { headers, signal });
|
||||
if (!response.ok) {
|
||||
const textResult = await asyncResult(response.text());
|
||||
throw new Error(
|
||||
`Unexpected response from server (${response.status}): ${textResult.value}`
|
||||
);
|
||||
}
|
||||
const archiveFilename = resolveArchiveFilename({ response, extensions });
|
||||
const attemptArchivePath = path.join(downloadDestinationDirectory, archiveFilename);
|
||||
// On abort, node-fetch emits an error on the body stream, which fails the pipeline.
|
||||
await streamPipeline(response.body, fs.createWriteStream(attemptArchivePath));
|
||||
return attemptArchivePath;
|
||||
},
|
||||
{ operation: 'download_build.archive_download', signal, isRetryable: isTransferFailure }
|
||||
);
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
if (params.buildId && isTransferFailure(error)) {
|
||||
throw new SystemError(`Could not download the application archive: ${describeError(error)}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const archiveFilename = resolveArchiveFilename({ response, extensions });
|
||||
const archivePath = path.join(downloadDestinationDirectory, archiveFilename);
|
||||
|
||||
// On abort, node-fetch emits an error on the body stream, which fails the pipeline.
|
||||
await streamPipeline(response.body, fs.createWriteStream(archivePath));
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const { size } = await fs.promises.stat(archivePath);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { UserError, quoteAndroidShellCommand } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
@@ -106,24 +106,40 @@ export async function launchApplicationAsync({
|
||||
logApplicationLaunch(logger, applicationIdentifier, launchArgs);
|
||||
// Android does not support process arguments like iOS. Pass raw `am start` Intent
|
||||
// arguments instead, such as `--es key value` or `--ez key true`.
|
||||
await spawn(
|
||||
'adb',
|
||||
['shell', 'am', 'start', ...launchArgs, '-n', `${applicationIdentifier}/${activityName}`],
|
||||
{
|
||||
env,
|
||||
logger,
|
||||
try {
|
||||
await spawnAndroidShellCommandAsync(
|
||||
['am', 'start', ...launchArgs, '-n', `${applicationIdentifier}/${activityName}`],
|
||||
{ env, logger }
|
||||
);
|
||||
} catch (err) {
|
||||
// An empty argument that is not an option value reaches `am start` as an empty package, and
|
||||
// `am start` then fails to resolve the Intent without saying why.
|
||||
const emptyArgumentIndex = launchArgs.indexOf('');
|
||||
if (emptyArgumentIndex === -1 || err instanceof UserError) {
|
||||
throw err;
|
||||
}
|
||||
);
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_FAILED',
|
||||
`Launching ${applicationIdentifier} failed. Input "launch_args" has an empty argument at position ${emptyArgumentIndex + 1}, which Android passes to "am start" as an empty argument. Remove it unless it is an option value, such as "--es", "key", "".`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
if (openUrl) {
|
||||
logger.info(`Opening ${openUrl} in ${applicationIdentifier}.`);
|
||||
await spawn(
|
||||
'adb',
|
||||
['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
|
||||
await spawnAndroidShellCommandAsync(
|
||||
['am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
|
||||
{ env, logger }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function spawnAndroidShellCommandAsync(
|
||||
command: string[],
|
||||
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
|
||||
): Promise<void> {
|
||||
await spawn('adb', ['shell', ...quoteAndroidShellCommand(command)], { env, logger });
|
||||
}
|
||||
|
||||
async function preapproveIosUrlSchemeAsync({
|
||||
applicationIdentifier,
|
||||
openUrl,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
|
||||
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
|
||||
import { type parseNetworkCaptureInputs } from '../utils/networkCaptureFields';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
|
||||
import { type ServeSimApplicationOptions } from '../utils/remoteDeviceRunSession';
|
||||
import { type StartupTasks } from '../utils/startupTasks';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
@@ -76,13 +77,9 @@ export function getAgentDeviceRemoteSessionEnvOrThrow(
|
||||
* Starts the agent-device daemon, its tunnel, the session host and the web preview,
|
||||
* reports the session as ready, and keeps it alive until it stops.
|
||||
*
|
||||
* The daemon does not need the device, so it starts at once. The session host starts
|
||||
* when `device.booted` resolves. The session is reported as ready only when both are
|
||||
* up and `device.ready` (the app is installed and launched) resolved too.
|
||||
*
|
||||
* The first failure aborts `tasks.signal`: each part stops before its next stage, and
|
||||
* the teardown then stops whatever was started. `device.ready` must settle soon after
|
||||
* an abort, because the teardown waits for it.
|
||||
* Serve-sim boots while the iOS download runs. After device preparation verifies
|
||||
* any egress guard, its control API installs and launches the app.
|
||||
* Startup failures abort the other tasks; teardown drains them before stopping acquired resources.
|
||||
*/
|
||||
export async function runAgentDeviceRemoteSessionAsync(
|
||||
ctx: CustomBuildContext,
|
||||
@@ -98,6 +95,8 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
capture,
|
||||
disableShare = false,
|
||||
privatePreviewUrl,
|
||||
frameAncestors,
|
||||
corsOrigins,
|
||||
tasks,
|
||||
device,
|
||||
}: {
|
||||
@@ -112,10 +111,24 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
capture: ReturnType<typeof parseNetworkCaptureInputs>;
|
||||
disableShare?: boolean;
|
||||
privatePreviewUrl?: string;
|
||||
frameAncestors?: string[];
|
||||
corsOrigins?: string[];
|
||||
tasks: StartupTasks;
|
||||
device: { booted: Promise<unknown>; ready: Promise<unknown> };
|
||||
device:
|
||||
| {
|
||||
booted: Promise<unknown>;
|
||||
ready: Promise<unknown>;
|
||||
}
|
||||
| {
|
||||
iosSimulatorUdid: string;
|
||||
bootEnv?: Record<string, string>;
|
||||
application?: Promise<ServeSimApplicationOptions | void>;
|
||||
};
|
||||
}
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
tasks.signal.throwIfAborted();
|
||||
|
||||
logger.info(
|
||||
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
@@ -126,6 +139,8 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
let eventCollection: Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>> | undefined;
|
||||
let sessionFailed = false;
|
||||
const daemonSecrets = [ngrokAuthtoken];
|
||||
const hostBootedDevice = 'iosSimulatorUdid' in device ? device : undefined;
|
||||
const externallyBootedDevice = 'booted' in device ? device : undefined;
|
||||
|
||||
// Each task stores what it started, so the teardown below can stop it even when
|
||||
// another task failed first.
|
||||
@@ -154,12 +169,14 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
authtoken: ngrokAuthtoken,
|
||||
logger: taskLogger,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
taskLogger.info(`Tunnel is ready at ${agentDeviceTunnel.url}.`);
|
||||
return { ...daemonInfo, remoteSessionUrl: agentDeviceTunnel.url };
|
||||
});
|
||||
const sessionHostStartup = tasks.run('session host', async taskLogger => {
|
||||
// A boot cannot be cancelled, so stop waiting for it when startup is aborted.
|
||||
await tasks.untilAborted(device.booted);
|
||||
if (externallyBootedDevice) {
|
||||
await tasks.untilAborted(externallyBootedDevice.booted);
|
||||
}
|
||||
tasks.signal.throwIfAborted();
|
||||
sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
separateLogPhase: true,
|
||||
@@ -169,14 +186,22 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
disableShare,
|
||||
privatePreviewUrl,
|
||||
frameAncestors,
|
||||
corsOrigins,
|
||||
signal: tasks.signal,
|
||||
onStartupError: tasks.abort,
|
||||
networkCapture: capture.networkCapture,
|
||||
networkCaptureFields: capture.networkCaptureFields,
|
||||
iosSimulatorUdid: hostBootedDevice?.iosSimulatorUdid,
|
||||
bootEnv: hostBootedDevice?.bootEnv,
|
||||
application: hostBootedDevice?.application,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
const webPreview = await sessionHost.openPreviewAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
taskLogger.info(
|
||||
webPreview.sharePageUrl
|
||||
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
|
||||
@@ -184,18 +209,20 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
);
|
||||
return webPreview;
|
||||
});
|
||||
const deviceReady = externallyBootedDevice?.ready;
|
||||
|
||||
try {
|
||||
const [
|
||||
{ port: daemonPort, token: daemonToken, remoteSessionUrl: agentDeviceRemoteSessionUrl },
|
||||
webPreview,
|
||||
] = await Promise.all([agentDeviceStartup, sessionHostStartup, device.ready]).catch(
|
||||
] = await Promise.all([agentDeviceStartup, sessionHostStartup, deviceReady]).catch(
|
||||
(err: unknown) => {
|
||||
// Also aborts for a `device.ready` that does not come from `tasks.run`.
|
||||
tasks.abort(err);
|
||||
throw err;
|
||||
throw tasks.signal.reason;
|
||||
}
|
||||
);
|
||||
tasks.signal.throwIfAborted();
|
||||
logger.info(tasks.summary());
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
@@ -212,6 +239,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
},
|
||||
logger,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
daemonUrl: `http://127.0.0.1:${daemonPort}`,
|
||||
@@ -237,6 +265,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
@@ -247,7 +276,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
// Promise.all rejects on the first failure while other tasks can still be starting.
|
||||
// They stop at their next abort check. Wait for all of them, so the teardown sees
|
||||
// everything that was started.
|
||||
await Promise.allSettled([agentDeviceStartup, sessionHostStartup, device.ready]);
|
||||
await Promise.allSettled([agentDeviceStartup, sessionHostStartup, deviceReady]);
|
||||
const startedDaemon = daemonProcess;
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
@@ -291,6 +320,7 @@ export async function startAgentDeviceDaemonAsync({
|
||||
signal?: AbortSignal;
|
||||
secrets?: string[];
|
||||
}): Promise<DetachedProcessHandle> {
|
||||
signal?.throwIfAborted();
|
||||
const packageSpec = createAgentDevicePackageSpec(packageVersion);
|
||||
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
|
||||
const installDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-agent-device-'));
|
||||
@@ -300,6 +330,7 @@ export async function startAgentDeviceDaemonAsync({
|
||||
);
|
||||
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const add = resolvePackageAdd(packageManager, packageSpec);
|
||||
logger.info(`Installing ${packageSpec} with ${add.command}.`);
|
||||
await spawn(add.command, add.args, { cwd: installDir, env, logger, signal });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { UserError, validateAndroidLaunchOptions } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
@@ -15,13 +15,16 @@ import {
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorName, IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi';
|
||||
import { withLocalEgressSession } from '../utils/localEgressSession';
|
||||
import {
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
createPublicPreviewInputProvider,
|
||||
omitNgrokAuthtoken,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
import {
|
||||
@@ -29,6 +32,10 @@ import {
|
||||
parseNetworkCaptureInputs,
|
||||
} from '../utils/networkCaptureFields';
|
||||
import { createStartupTasks } from '../utils/startupTasks';
|
||||
import {
|
||||
type ServeSimApplicationOptions,
|
||||
validateServeSimLaunchOptions,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
import { downloadBuildAsync } from './downloadBuild';
|
||||
import { installBuildAsync } from './installBuild';
|
||||
@@ -42,26 +49,15 @@ import {
|
||||
runAgentDeviceRemoteSessionAsync,
|
||||
} from './startAgentDeviceRemoteSession';
|
||||
import { startAndroidEmulatorAsync } from './startAndroidEmulator';
|
||||
import { bootIosSimulatorAsync } from './startIosSimulator';
|
||||
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
|
||||
import { readIosApplicationIdentifierAsync } from '../utils/iosAppArtifact';
|
||||
|
||||
const ANDROID_DEVICE_NAME = 'EasAndroidDevice01' as AndroidVirtualDeviceName;
|
||||
|
||||
/**
|
||||
* One step for a whole agent-device session: boot the device, download, install and
|
||||
* launch the app, and start the agent-device daemon and the web preview.
|
||||
*
|
||||
* It does the work of eas/start_ios_simulator or eas/start_android_emulator,
|
||||
* eas/download_build, eas/install_build and eas/launch_application, plus the agent-device
|
||||
* session, so the parts that do not depend on each other can run at the same time:
|
||||
*
|
||||
* boot ─────────────┬─► install ─► launch ─┐
|
||||
* download ─────────┘ │
|
||||
* boot ─► session host ─► web preview ─────┼─► ready
|
||||
* agent-device daemon ─► tunnel ───────────┘
|
||||
*
|
||||
* The first failure aborts the rest: the download stops, and nothing installs, launches
|
||||
* or starts after it. A boot cannot be cancelled, so it can still run when a failed
|
||||
* step returns.
|
||||
* Starts a device session, downloading the app alongside daemon startup.
|
||||
* On iOS, serve-sim boots the selected Simulator, then installs and launches
|
||||
* the downloaded app. Build-tools verifies any egress guard before install and launch.
|
||||
*/
|
||||
export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -122,6 +118,8 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
...createNetworkCaptureInputProviders(),
|
||||
createPublicPreviewInputProvider(),
|
||||
createPrivatePreviewUrlInputProvider(),
|
||||
createFrameAncestorsInputProvider(),
|
||||
createCorsOriginsInputProvider(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -164,6 +162,11 @@ 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 });
|
||||
} else {
|
||||
validateAndroidLaunchOptions({ launchArgs, openUrl });
|
||||
}
|
||||
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
|
||||
const capture = parseNetworkCaptureInputs(
|
||||
{
|
||||
@@ -173,107 +176,137 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
{ runtimePlatform }
|
||||
);
|
||||
|
||||
if (isIos) {
|
||||
// Before the boot, so every Xcode tool below uses the same developer directory.
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
const tasks = createStartupTasks(logger, signal);
|
||||
let downloaded: Promise<string> | undefined;
|
||||
let applicationReady: Promise<ServeSimApplicationOptions | void> | undefined;
|
||||
|
||||
const tasks = createStartupTasks(logger);
|
||||
|
||||
const booted = tasks.run(
|
||||
isIos ? 'iOS Simulator boot' : 'Android Emulator boot',
|
||||
async taskLogger => {
|
||||
if (isIos) {
|
||||
await bootIosSimulatorAsync({
|
||||
deviceIdentifier: deviceIdentifier as IosSimulatorUuid | IosSimulatorName | undefined,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await startAndroidEmulatorAsync({
|
||||
deviceName: ANDROID_DEVICE_NAME,
|
||||
systemImagePackage: `${inputs.system_image_package.value}`,
|
||||
deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined,
|
||||
lcdWidth: inputs.lcd_width.value as number | undefined,
|
||||
lcdHeight: inputs.lcd_height.value as number | undefined,
|
||||
lcdDensity: inputs.lcd_density.value as number | undefined,
|
||||
logcatDirectory: await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
|
||||
),
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
try {
|
||||
if (isIos) {
|
||||
// Select Xcode before serve-sim boots the device or any Simulator tools run.
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
);
|
||||
|
||||
const downloaded = hasApplication
|
||||
? tasks.run('build download', async taskLogger => {
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: taskLogger,
|
||||
...(buildId ? { buildId } : { applicationArchiveUrl: applicationArchiveUrl! }),
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
deviceRunSessionWorkerApi: buildId ? getDeviceRunSessionWorkerApi(ctx) : null,
|
||||
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
|
||||
extensions: isIos ? ['app'] : ['apk'],
|
||||
tasks.signal.throwIfAborted();
|
||||
const { iosStartup } = isIos
|
||||
? await resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator: true,
|
||||
deviceIdentifier,
|
||||
env,
|
||||
logger,
|
||||
signal: tasks.signal,
|
||||
});
|
||||
return artifactPath;
|
||||
})
|
||||
: undefined;
|
||||
})
|
||||
: {};
|
||||
const iosSimulatorUdid = iosStartup?.iosSimulatorUdid;
|
||||
if (iosSimulatorUdid) {
|
||||
logger.info(`Selected iOS Simulator: ${iosSimulatorUdid}.`);
|
||||
}
|
||||
|
||||
// `ready` settles only after everything it started has settled, so the session
|
||||
// teardown, which waits for it, never leaves a download running. It must also settle
|
||||
// soon after an abort: it stops waiting for the boot then, and the download stops.
|
||||
const ready = downloaded
|
||||
? tasks.run('app install and launch', async taskLogger => {
|
||||
const [download, boot] = await Promise.allSettled([
|
||||
downloaded,
|
||||
tasks.untilAborted(booted),
|
||||
]);
|
||||
// After a failure elsewhere, report that failure, not the abort it caused here.
|
||||
tasks.signal.throwIfAborted();
|
||||
if (download.status === 'rejected') {
|
||||
throw download.reason;
|
||||
}
|
||||
if (boot.status === 'rejected') {
|
||||
throw boot.reason;
|
||||
}
|
||||
const { applicationIdentifier, activityName } = await installBuildAsync({
|
||||
artifactPath: download.value,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
const booted = iosSimulatorUdid
|
||||
? undefined
|
||||
: tasks.run('Android Emulator boot', async taskLogger => {
|
||||
const logcatDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
|
||||
);
|
||||
tasks.signal.throwIfAborted();
|
||||
await startAndroidEmulatorAsync({
|
||||
deviceName: ANDROID_DEVICE_NAME,
|
||||
systemImagePackage: `${inputs.system_image_package.value}`,
|
||||
deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined,
|
||||
lcdWidth: inputs.lcd_width.value as number | undefined,
|
||||
lcdHeight: inputs.lcd_height.value as number | undefined,
|
||||
lcdDensity: inputs.lcd_density.value as number | undefined,
|
||||
logcatDirectory,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier,
|
||||
activityName,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
})
|
||||
: tasks.untilAborted(booted);
|
||||
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
runtimePlatform,
|
||||
sessionEnv,
|
||||
packageVersion: inputs.package_version.value as string | undefined,
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined,
|
||||
maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined,
|
||||
capture,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
tasks,
|
||||
device: { booted, ready },
|
||||
});
|
||||
downloaded = hasApplication
|
||||
? tasks.run('build download', async taskLogger => {
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: taskLogger,
|
||||
...(buildId ? { buildId } : { applicationArchiveUrl: applicationArchiveUrl! }),
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
deviceRunSessionWorkerApi: buildId ? getDeviceRunSessionWorkerApi(ctx) : null,
|
||||
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
|
||||
extensions: isIos ? ['app'] : ['apk'],
|
||||
signal: tasks.signal,
|
||||
});
|
||||
return artifactPath;
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const download = downloaded;
|
||||
applicationReady = download
|
||||
? tasks.run(isIos ? 'app preparation' : 'app install and launch', async taskLogger => {
|
||||
if (booted) {
|
||||
await tasks.untilAborted(booted);
|
||||
}
|
||||
const artifactPath = await download;
|
||||
tasks.signal.throwIfAborted();
|
||||
if (isIos) {
|
||||
const applicationIdentifier = await readIosApplicationIdentifierAsync({
|
||||
artifactPath,
|
||||
env,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
return {
|
||||
installAppPath: artifactPath,
|
||||
launchAppIdentifier: applicationIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
};
|
||||
}
|
||||
const { applicationIdentifier, activityName } = await installBuildAsync({
|
||||
artifactPath,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier,
|
||||
activityName,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
})
|
||||
: undefined;
|
||||
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
runtimePlatform,
|
||||
sessionEnv,
|
||||
packageVersion: inputs.package_version.value as string | undefined,
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined,
|
||||
maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined,
|
||||
capture,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
tasks,
|
||||
device: iosSimulatorUdid
|
||||
? {
|
||||
...iosStartup,
|
||||
iosSimulatorUdid,
|
||||
application: applicationReady,
|
||||
}
|
||||
: {
|
||||
booted: booted!,
|
||||
ready: applicationReady ?? tasks.untilAborted(booted!),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
tasks.abort(new Error('Agent-device session ended.'));
|
||||
await Promise.allSettled([downloaded, applicationReady]);
|
||||
}
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { type Result, asyncResult } from '@expo/results';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import spawn, { type SpawnOptions, type SpawnResult } from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -21,22 +22,27 @@ import {
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
parseNetworkCaptureInputs,
|
||||
} from '../utils/networkCaptureFields';
|
||||
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageAdd,
|
||||
} from '../../utils/packageManager';
|
||||
import { killProcessGroup } from '../../utils/processes';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
|
||||
import { preparePreinstalledWdaAsync } from '../utils/preinstalledWda';
|
||||
import {
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createIosSessionStartupInputProviders,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
createPublicPreviewInputProvider,
|
||||
createServeSimLaunchInputProviders,
|
||||
@@ -47,7 +53,8 @@ import {
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
omitNgrokAuthtoken,
|
||||
parseServeSimLaunchInputs,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
@@ -74,9 +81,12 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
__metricsId: 'eas/start_appium_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createIosSessionStartupInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
createPublicPreviewInputProvider(),
|
||||
createPrivatePreviewUrlInputProvider(),
|
||||
createFrameAncestorsInputProvider(),
|
||||
createCorsOriginsInputProvider(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -96,14 +106,21 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
const launch = parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
|
||||
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
|
||||
installAppPath: inputs.install_app_path?.value as string | undefined,
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture?.value,
|
||||
@@ -116,54 +133,125 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
logger.info(
|
||||
`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
const device = await resolveAppiumDeviceAsync({ runtimePlatform, env, logger });
|
||||
// Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the
|
||||
// simulator stream with it, and UiAutomator2 merges long recordings with it. The
|
||||
// macOS session image does not ship ffmpeg, so install it concurrently with Appium.
|
||||
// Both installs settle before the step continues or fails, so no install outlives it.
|
||||
const [appiumInstall] = await Promise.all([
|
||||
asyncResult(
|
||||
installAppiumAsync({ versionSpec, driverName: device.driverName, env, logger })
|
||||
),
|
||||
ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger }),
|
||||
]);
|
||||
const { appiumHome, appiumBinPath, appiumEnv } = appiumInstall.enforceValue();
|
||||
const wdaCapabilities = await preparePreinstalledWdaAsync({
|
||||
runtimeIdentifier: device.runtimeIdentifier,
|
||||
appiumHome,
|
||||
appiumBinPath,
|
||||
appiumEnv,
|
||||
logger,
|
||||
});
|
||||
|
||||
const appiumProcess = spawnDetached({
|
||||
command: appiumBinPath,
|
||||
args: [
|
||||
'--address',
|
||||
APPIUM_HOST,
|
||||
'--port',
|
||||
String(APPIUM_PORT),
|
||||
'--base-path',
|
||||
'/',
|
||||
'--log-level',
|
||||
'error',
|
||||
// Appium 3 gates session listing (GET /appium/sessions) behind the
|
||||
// session_discovery insecure feature. We rely on it to poll for
|
||||
// Appium Event Timings, so enable it for all drivers.
|
||||
'--allow-insecure',
|
||||
'*:session_discovery',
|
||||
'--default-capabilities',
|
||||
JSON.stringify({ 'appium:eventTimings': true, ...wdaCapabilities }),
|
||||
],
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
});
|
||||
const device: AppiumDevice = iosStartup
|
||||
? {
|
||||
platformName: 'iOS',
|
||||
automationName: 'XCUITest',
|
||||
driverName: 'xcuitest',
|
||||
udid: iosStartup.iosSimulatorUdid,
|
||||
}
|
||||
: await resolveAppiumDeviceAsync({ runtimePlatform, env });
|
||||
const startupAbortController = new AbortController();
|
||||
const startupSignal = signal
|
||||
? AbortSignal.any([signal, startupAbortController.signal])
|
||||
: startupAbortController.signal;
|
||||
const setupSignal = iosStartup ? startupSignal : undefined;
|
||||
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let appiumProcess: ReturnType<typeof spawnDetached> | undefined;
|
||||
let appiumHome: string | undefined;
|
||||
let eventCollection: Awaited<ReturnType<typeof startAppiumEventCollectionAsync>> | undefined;
|
||||
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let sessionFailed = false;
|
||||
const startHost = () =>
|
||||
startDeviceSessionHostAsync(ctx, {
|
||||
...iosStartup,
|
||||
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),
|
||||
signal: startupSignal,
|
||||
onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined,
|
||||
...launch,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
}).catch(error => {
|
||||
if (iosStartup) {
|
||||
startupAbortController.abort(error);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
await waitForAppiumReadyAsync({ appiumProcess, logger });
|
||||
if (iosStartup) {
|
||||
hostStartup = asyncResult(startHost());
|
||||
}
|
||||
// Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the
|
||||
// simulator stream with it, and UiAutomator2 merges long recordings with it. The
|
||||
// macOS session image does not ship ffmpeg, so install it concurrently with Appium.
|
||||
// Both owned installs settle before proceeding; a shared FFmpeg install keeps its owner.
|
||||
const [appiumInstall] = await Promise.all([
|
||||
asyncResult(
|
||||
installAppiumAsync({
|
||||
versionSpec,
|
||||
driverName: device.driverName,
|
||||
env,
|
||||
logger,
|
||||
signal: setupSignal,
|
||||
onError: iosStartup ? error => startupAbortController.abort(error) : undefined,
|
||||
}).catch(error => {
|
||||
if (iosStartup) {
|
||||
startupAbortController.abort(error);
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
),
|
||||
asyncResult(
|
||||
ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger, signal: setupSignal })
|
||||
),
|
||||
]);
|
||||
const installation = appiumInstall.enforceValue();
|
||||
appiumHome = installation.appiumHome;
|
||||
setupSignal?.throwIfAborted();
|
||||
const { appiumBinPath, appiumEnv } = installation;
|
||||
const wdaCapabilities = await preparePreinstalledWdaAsync({
|
||||
runtimeIdentifier: iosStartup
|
||||
? (
|
||||
await IosSimulatorUtils.getDeviceAsync({
|
||||
udid: iosStartup.iosSimulatorUdid as IosSimulatorUuid,
|
||||
env,
|
||||
})
|
||||
)?.runtime
|
||||
: device.runtimeIdentifier,
|
||||
appiumHome,
|
||||
appiumBinPath,
|
||||
appiumEnv,
|
||||
logger,
|
||||
signal: setupSignal,
|
||||
});
|
||||
|
||||
setupSignal?.throwIfAborted();
|
||||
appiumProcess = spawnDetached({
|
||||
command: appiumBinPath,
|
||||
args: [
|
||||
'--address',
|
||||
APPIUM_HOST,
|
||||
'--port',
|
||||
String(APPIUM_PORT),
|
||||
'--base-path',
|
||||
'/',
|
||||
'--log-level',
|
||||
'error',
|
||||
// Appium 3 gates session listing (GET /appium/sessions) behind the
|
||||
// session_discovery insecure feature. We rely on it to poll for
|
||||
// Appium Event Timings, so enable it for all drivers.
|
||||
'--allow-insecure',
|
||||
'*:session_discovery',
|
||||
'--default-capabilities',
|
||||
JSON.stringify({ 'appium:eventTimings': true, ...wdaCapabilities }),
|
||||
],
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
});
|
||||
await waitForAppiumReadyAsync({ appiumProcess, logger, signal: setupSignal });
|
||||
if (hostStartup) {
|
||||
sessionHost = (await hostStartup).enforceValue();
|
||||
}
|
||||
setupSignal?.throwIfAborted();
|
||||
eventCollection = await startAppiumEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
@@ -184,20 +272,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
if (launchDescription) {
|
||||
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,
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
});
|
||||
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
|
||||
const webPreview = await sessionHost.openPreviewAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
@@ -232,13 +307,15 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
sessionFailed = true;
|
||||
throw error;
|
||||
throw setupSignal?.aborted ? setupSignal.reason : error;
|
||||
} finally {
|
||||
startupAbortController.abort(new Error('Appium session ended.'));
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
@@ -251,14 +328,16 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
await eventCollection?.stopAsync();
|
||||
} finally {
|
||||
try {
|
||||
await appiumProcess.stopAsync();
|
||||
await appiumProcess?.stopAsync();
|
||||
} finally {
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
if (appiumHome) {
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
})(),
|
||||
],
|
||||
['session host', sessionHost?.finishAsync()],
|
||||
['session host', hostStartup?.then(result => result.value?.finishAsync())],
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -288,15 +367,12 @@ export type AppiumDevice = {
|
||||
export async function resolveAppiumDeviceAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<AppiumDevice> {
|
||||
switch (runtimePlatform) {
|
||||
case BuildRuntimePlatform.DARWIN: {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
const [bootedDevice] = await IosSimulatorUtils.getAvailableDevicesAsync({
|
||||
env,
|
||||
filter: 'booted',
|
||||
@@ -333,65 +409,135 @@ export async function installAppiumAsync({
|
||||
driverName,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
onError,
|
||||
}: {
|
||||
versionSpec: string;
|
||||
driverName: AppiumDevice['driverName'];
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
onError?: (error: unknown) => void;
|
||||
}): Promise<{ appiumHome: string; appiumBinPath: string; appiumEnv: BuildStepEnv }> {
|
||||
signal?.throwIfAborted();
|
||||
const appiumHome = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-appium-home-'));
|
||||
await fs.promises.writeFile(
|
||||
path.join(appiumHome, 'package.json'),
|
||||
`${JSON.stringify({ name: 'eas-appium-home', private: true })}\n`
|
||||
);
|
||||
const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome };
|
||||
const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium');
|
||||
const add = resolvePackageAdd(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
`appium@${versionSpec}`
|
||||
);
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
await fs.promises.writeFile(
|
||||
path.join(appiumHome, 'package.json'),
|
||||
`${JSON.stringify({ name: 'eas-appium-home', private: true })}\n`
|
||||
);
|
||||
const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome };
|
||||
const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium');
|
||||
const add = resolvePackageAdd(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
`appium@${versionSpec}`
|
||||
);
|
||||
|
||||
logger.info(`Installing appium@${versionSpec} with ${add.command}.`);
|
||||
await spawn(add.command, add.args, {
|
||||
cwd: appiumHome,
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
});
|
||||
const { stdout } = await spawn(appiumBinPath, ['driver', 'list', '--installed', '--json'], {
|
||||
env: appiumEnv,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const installedDrivers = AppiumInstalledDriversSchema.parse(JSON.parse(stdout));
|
||||
if (installedDrivers[driverName]?.installed) {
|
||||
logger.info(`Updating the installed Appium ${driverName} driver.`);
|
||||
await spawn(appiumBinPath, ['driver', 'update', driverName], { env: appiumEnv, logger });
|
||||
} else {
|
||||
logger.info(`Installing the Appium ${driverName} driver.`);
|
||||
await spawn(appiumBinPath, ['driver', 'install', driverName], { env: appiumEnv, logger });
|
||||
logger.info(`Installing appium@${versionSpec} with ${add.command}.`);
|
||||
await runAppiumSetupCommandAsync(
|
||||
add.command,
|
||||
add.args,
|
||||
{
|
||||
cwd: appiumHome,
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
},
|
||||
signal
|
||||
);
|
||||
const { stdout } = await runAppiumSetupCommandAsync(
|
||||
appiumBinPath,
|
||||
['driver', 'list', '--installed', '--json'],
|
||||
{
|
||||
env: appiumEnv,
|
||||
stdio: 'pipe',
|
||||
},
|
||||
signal
|
||||
);
|
||||
const installedDrivers = AppiumInstalledDriversSchema.parse(JSON.parse(stdout));
|
||||
if (installedDrivers[driverName]?.installed) {
|
||||
logger.info(`Updating the installed Appium ${driverName} driver.`);
|
||||
await runAppiumSetupCommandAsync(
|
||||
appiumBinPath,
|
||||
['driver', 'update', driverName],
|
||||
{ env: appiumEnv, logger },
|
||||
signal
|
||||
);
|
||||
} else {
|
||||
logger.info(`Installing the Appium ${driverName} driver.`);
|
||||
await runAppiumSetupCommandAsync(
|
||||
appiumBinPath,
|
||||
['driver', 'install', driverName],
|
||||
{ env: appiumEnv, logger },
|
||||
signal
|
||||
);
|
||||
}
|
||||
return { appiumHome, appiumBinPath, appiumEnv };
|
||||
} catch (error) {
|
||||
onError?.(error);
|
||||
if (signal) {
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
signal.throwIfAborted();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function runAppiumSetupCommandAsync(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: SpawnOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<SpawnResult> {
|
||||
if (!signal) {
|
||||
return await spawn(command, args, options);
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
const task = spawn(command, args, { ...options, detached: true });
|
||||
const abort = (): void => killProcessGroup(task.child, 'SIGKILL');
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
if (signal.aborted) {
|
||||
abort();
|
||||
}
|
||||
try {
|
||||
const result = await task;
|
||||
signal.throwIfAborted();
|
||||
return result;
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abort);
|
||||
}
|
||||
return { appiumHome, appiumBinPath, appiumEnv };
|
||||
}
|
||||
|
||||
async function waitForAppiumReadyAsync({
|
||||
appiumProcess,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
appiumProcess: { getOutput: () => string };
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const deadline = Date.now() + APPIUM_STARTUP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
const response = await turtleFetch(`http://${APPIUM_HOST}:${APPIUM_PORT}/status`, 'GET', {
|
||||
timeout: 2_000,
|
||||
retries: 0,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
await sleepAsync(1_000);
|
||||
signal?.throwIfAborted();
|
||||
if (signal) {
|
||||
await setTimeoutAsync(1_000, undefined, { signal });
|
||||
} else {
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
}
|
||||
const output = appiumProcess.getOutput();
|
||||
throw new SystemError(
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type Result, asyncResult } from '@expo/results';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import spawn, { type SpawnOptions } from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
parseNetworkCaptureInputs,
|
||||
@@ -29,11 +32,14 @@ import {
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageExec,
|
||||
} from '../../utils/packageManager';
|
||||
import { isProcessDescendantOfAsync } from '../../utils/processes';
|
||||
import { isProcessDescendantOfAsync, killProcessGroup } from '../../utils/processes';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
|
||||
import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents';
|
||||
import {
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createIosSessionStartupInputProviders,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
createPublicPreviewInputProvider,
|
||||
createServeSimLaunchInputProviders,
|
||||
@@ -44,7 +50,8 @@ import {
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
omitNgrokAuthtoken,
|
||||
parseServeSimLaunchInputs,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
@@ -86,9 +93,12 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
__metricsId: 'eas/start_argent_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createIosSessionStartupInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
createPublicPreviewInputProvider(),
|
||||
createPrivatePreviewUrlInputProvider(),
|
||||
createFrameAncestorsInputProvider(),
|
||||
createCorsOriginsInputProvider(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -122,14 +132,21 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
|
||||
const versionSpec = packageVersion ?? 'latest';
|
||||
const { runtimePlatform } = global;
|
||||
const launch = parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value as string | undefined,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
|
||||
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
|
||||
installAppPath: inputs.install_app_path?.value as string | undefined,
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture?.value,
|
||||
@@ -141,10 +158,6 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
|
||||
// Start the potentially slow installation while Argent is being prepared.
|
||||
// On Linux expo-device-hub calls this again and awaits the same in-flight
|
||||
// setup before launching. On macOS this remains non-blocking, so only a
|
||||
@@ -156,74 +169,124 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
const argentExec = (args: string[]): { command: string; args: string[] } =>
|
||||
resolvePackageExec(packageManager, args);
|
||||
|
||||
logger.info('Enabling the Argent artifacts list endpoint flag.');
|
||||
const enableArtifacts = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
|
||||
]);
|
||||
await spawn(enableArtifacts.command, enableArtifacts.args, { env, logger });
|
||||
|
||||
logger.info('Enabling the Argent tool-server event log flag.');
|
||||
const enableEventLog = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_EVENT_LOG_FLAG,
|
||||
]);
|
||||
await spawn(enableEventLog.command, enableEventLog.args, { env, logger });
|
||||
|
||||
logger.info('Disabling the Argent video watermark flag.');
|
||||
const disableVideoWatermark = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'disable',
|
||||
ARGENT_VIDEO_WATERMARK_FLAG,
|
||||
]);
|
||||
// The watermark is cosmetic, so a failure here must not fail the session.
|
||||
try {
|
||||
await spawn(disableVideoWatermark.command, disableVideoWatermark.args, { env, logger });
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.'
|
||||
);
|
||||
}
|
||||
|
||||
const startServer = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
'start',
|
||||
'--port',
|
||||
'0',
|
||||
'--idle-timeout',
|
||||
'0',
|
||||
'--force',
|
||||
]);
|
||||
logger.info(
|
||||
`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.`
|
||||
);
|
||||
// Keep Argent itself in foreground mode under the detached process. This preserves
|
||||
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
const secrets: string[] = [];
|
||||
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(
|
||||
'Failed to start Argent: could not determine the PID of the launched process.'
|
||||
);
|
||||
}
|
||||
|
||||
const startupAbortController = new AbortController();
|
||||
const startupSignal = signal
|
||||
? AbortSignal.any([signal, startupAbortController.signal])
|
||||
: startupAbortController.signal;
|
||||
const setupSignal = iosStartup ? startupSignal : undefined;
|
||||
const artifactPollAbortController = new AbortController();
|
||||
let artifactPollingPromise: Promise<void> | undefined;
|
||||
let eventCollection: Awaited<ReturnType<typeof startArgentEventCollectionAsync>> | undefined;
|
||||
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let argentServer: ReturnType<typeof spawnDetached> | undefined;
|
||||
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let sessionFailed = false;
|
||||
const startHost = () =>
|
||||
startDeviceSessionHostAsync(ctx, {
|
||||
...iosStartup,
|
||||
separateLogPhase: true,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
signal: startupSignal,
|
||||
onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined,
|
||||
...launch,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
}).catch(error => {
|
||||
if (iosStartup) {
|
||||
startupAbortController.abort(error);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
if (iosStartup) {
|
||||
hostStartup = asyncResult(startHost());
|
||||
}
|
||||
logger.info('Enabling the Argent artifacts list endpoint flag.');
|
||||
const enableArtifacts = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
|
||||
]);
|
||||
await runArgentSetupCommandAsync(
|
||||
enableArtifacts.command,
|
||||
enableArtifacts.args,
|
||||
{ env, logger },
|
||||
setupSignal
|
||||
);
|
||||
|
||||
logger.info('Enabling the Argent tool-server event log flag.');
|
||||
const enableEventLog = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_EVENT_LOG_FLAG,
|
||||
]);
|
||||
await runArgentSetupCommandAsync(
|
||||
enableEventLog.command,
|
||||
enableEventLog.args,
|
||||
{ env, logger },
|
||||
setupSignal
|
||||
);
|
||||
|
||||
logger.info('Disabling the Argent video watermark flag.');
|
||||
const disableVideoWatermark = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'disable',
|
||||
ARGENT_VIDEO_WATERMARK_FLAG,
|
||||
]);
|
||||
// The watermark is cosmetic, so a failure here must not fail the session.
|
||||
try {
|
||||
await runArgentSetupCommandAsync(
|
||||
disableVideoWatermark.command,
|
||||
disableVideoWatermark.args,
|
||||
{ env, logger },
|
||||
setupSignal
|
||||
);
|
||||
} catch (err) {
|
||||
setupSignal?.throwIfAborted();
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.'
|
||||
);
|
||||
}
|
||||
|
||||
setupSignal?.throwIfAborted();
|
||||
const startServer = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
'start',
|
||||
'--port',
|
||||
'0',
|
||||
'--idle-timeout',
|
||||
'0',
|
||||
'--force',
|
||||
]);
|
||||
logger.info(
|
||||
`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.`
|
||||
);
|
||||
// Keep Argent itself in foreground mode under the detached process. This preserves
|
||||
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
const secrets: string[] = [];
|
||||
argentServer = spawnDetached({
|
||||
command: startServer.command,
|
||||
args: startServer.args,
|
||||
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH, ARGENT_EMULATOR_NO_WINDOW: '1' },
|
||||
logger,
|
||||
secrets,
|
||||
});
|
||||
if (argentServer.pid === undefined) {
|
||||
throw new SystemError(
|
||||
'Failed to start Argent: could not determine the PID of the launched process.'
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`);
|
||||
let toolServerPort: number;
|
||||
let toolServerToken: string | undefined;
|
||||
@@ -233,6 +296,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
ancestorPid: argentServer.pid,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
getExitError: argentServer.getExitError,
|
||||
signal: setupSignal,
|
||||
});
|
||||
toolServerPort = toolServerState.port;
|
||||
toolServerToken = toolServerState.token;
|
||||
@@ -247,6 +311,10 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
}${output ? `\nArgent tool-server output:\n${output}` : ''}`
|
||||
);
|
||||
}
|
||||
if (hostStartup) {
|
||||
sessionHost = (await hostStartup).enforceValue();
|
||||
}
|
||||
setupSignal?.throwIfAborted();
|
||||
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
|
||||
const artifactPollSignal = signal
|
||||
? AbortSignal.any([signal, artifactPollAbortController.signal])
|
||||
@@ -281,20 +349,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
if (launchDescription) {
|
||||
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,
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
});
|
||||
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
|
||||
const webPreview = await sessionHost.openPreviewAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
@@ -331,13 +386,15 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
sessionFailed = true;
|
||||
throw error;
|
||||
throw setupSignal?.aborted ? setupSignal.reason : error;
|
||||
} finally {
|
||||
startupAbortController.abort(new Error('Argent session ended.'));
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
@@ -369,11 +426,11 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await argentServer.stopAsync();
|
||||
await argentServer?.stopAsync();
|
||||
}
|
||||
})(),
|
||||
],
|
||||
['session host', sessionHost?.finishAsync()],
|
||||
['session host', hostStartup?.then(result => result.value?.finishAsync())],
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -433,6 +490,31 @@ export function warnIfArgentPackageVersionCannotBeVerified({
|
||||
}
|
||||
}
|
||||
|
||||
async function runArgentSetupCommandAsync(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: SpawnOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
if (!signal) {
|
||||
await spawn(command, args, options);
|
||||
return;
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
const task = spawn(command, args, { ...options, detached: true });
|
||||
const abort = (): void => killProcessGroup(task.child, 'SIGKILL');
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
if (signal.aborted) {
|
||||
abort();
|
||||
}
|
||||
try {
|
||||
await task;
|
||||
signal.throwIfAborted();
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abort);
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgentToolServerState(raw: string): ArgentToolServerState {
|
||||
let json: unknown;
|
||||
try {
|
||||
@@ -455,13 +537,16 @@ export async function waitForArgentToolServerStateAsync({
|
||||
timeoutMs,
|
||||
pollIntervalMs = 1_000,
|
||||
getExitError,
|
||||
signal,
|
||||
}: {
|
||||
stateDir: string;
|
||||
ancestorPid: number;
|
||||
timeoutMs: number;
|
||||
pollIntervalMs?: number;
|
||||
getExitError?: () => Error | undefined;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ArgentToolServerState> {
|
||||
signal?.throwIfAborted();
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
const throwIfExited = (): void => {
|
||||
@@ -472,6 +557,7 @@ export async function waitForArgentToolServerStateAsync({
|
||||
};
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
signal?.throwIfAborted();
|
||||
throwIfExited();
|
||||
let matchingState: ArgentToolServerState | undefined;
|
||||
try {
|
||||
@@ -497,13 +583,19 @@ export async function waitForArgentToolServerStateAsync({
|
||||
lastError = err;
|
||||
}
|
||||
// Keep process failures outside the retryable state-file read errors.
|
||||
signal?.throwIfAborted();
|
||||
throwIfExited();
|
||||
if (matchingState) {
|
||||
return matchingState;
|
||||
}
|
||||
await sleepAsync(pollIntervalMs);
|
||||
if (signal) {
|
||||
await setTimeoutAsync(pollIntervalMs, undefined, { signal });
|
||||
} else {
|
||||
await sleepAsync(pollIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
signal?.throwIfAborted();
|
||||
throw new SystemError(
|
||||
`Timed out waiting for an argent tool-server state file belonging to process ${ancestorPid} in ${stateDir}${
|
||||
lastError instanceof Error ? `: ${lastError.message}` : ''
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import { graphql } from 'gql.tada';
|
||||
|
||||
@@ -32,6 +32,11 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_duration_seconds',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, signal, env }) => {
|
||||
// The daemon needs this credential, but shell commands do not.
|
||||
@@ -44,6 +49,18 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
throw new SystemError('MCP server URL is required to start the sandbox daemon.');
|
||||
}
|
||||
const sandboxId = String(inputs.sandbox_id.value);
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value;
|
||||
if (
|
||||
maxDurationSeconds !== undefined &&
|
||||
(typeof maxDurationSeconds !== 'number' ||
|
||||
!Number.isFinite(maxDurationSeconds) ||
|
||||
maxDurationSeconds <= 0)
|
||||
) {
|
||||
throw new UserError(
|
||||
'EAS_SANDBOX_INVALID_DURATION',
|
||||
`Invalid sandbox maximum duration: ${String(maxDurationSeconds)}. Provide a finite number greater than zero, or omit max_duration_seconds.`
|
||||
);
|
||||
}
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential: sandboxToken,
|
||||
serverUrl: mcpServerUrl,
|
||||
@@ -63,7 +80,21 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
stepCtx.logger.info('Sandbox daemon started.');
|
||||
|
||||
await withLogPhaseAsync(stepCtx.logger, AWAIT_SANDBOX_STEP_NAME, async () => {
|
||||
await waitForCancellationAsync(signal);
|
||||
const timeoutSignal =
|
||||
maxDurationSeconds === undefined
|
||||
? undefined
|
||||
: AbortSignal.timeout(Math.ceil(maxDurationSeconds * 1000));
|
||||
await waitForCompletionAsync(
|
||||
AbortSignal.any([
|
||||
...(signal ? [signal] : []),
|
||||
...(timeoutSignal ? [timeoutSignal] : []),
|
||||
])
|
||||
);
|
||||
if (timeoutSignal?.aborted) {
|
||||
stepCtx.logger.info(
|
||||
`Sandbox reached its maximum runtime of ${maxDurationSeconds} seconds. Stopping.`
|
||||
);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await daemon.stopAsync();
|
||||
@@ -89,11 +120,11 @@ export async function markSandboxReadyAsync(
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForCancellationAsync(signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) {
|
||||
async function waitForCompletionAsync(signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>(resolve => {
|
||||
signal?.addEventListener('abort', () => resolve(), { once: true });
|
||||
signal.addEventListener('abort', () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import {
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createIosSessionStartupInputProviders,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
createPublicPreviewInputProvider,
|
||||
@@ -26,6 +28,8 @@ import {
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
omitNgrokAuthtoken,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
@@ -46,6 +50,8 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
...createNetworkCaptureInputProviders(),
|
||||
createPublicPreviewInputProvider(),
|
||||
createPrivatePreviewUrlInputProvider(),
|
||||
createFrameAncestorsInputProvider(),
|
||||
createCorsOriginsInputProvider(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -104,6 +110,8 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
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,
|
||||
|
||||
@@ -131,7 +131,7 @@ export function createSubmitToGooglePlayBuildFunction(): BuildFunction {
|
||||
keyPath: path.resolve(ctx.workingDirectory, submission.service_account_key_path),
|
||||
});
|
||||
const artifactPath = path.resolve(ctx.workingDirectory, submission.artifact_path);
|
||||
const client = new GooglePlayClient(credentials);
|
||||
const client = new GooglePlayClient(credentials, ctx.logger);
|
||||
const versionCode = await submitToGooglePlayAsync({
|
||||
artifactType: submission.artifact_type,
|
||||
client,
|
||||
|
||||
@@ -7,7 +7,12 @@ import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import HttpLogStream from '../../../logging/HttpLogStream';
|
||||
import RemoteLoggerStream from '../../../logging/RemoteLoggerStream';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../agentDeviceEvents';
|
||||
import {
|
||||
DEVICE_RUN_SESSION_TOKEN_HEADER,
|
||||
DeviceRunSessionWorkerApiError,
|
||||
} from '../deviceRunSessionWorkerApi';
|
||||
|
||||
const mockEventLogStream = {
|
||||
init: jest.fn(async () => undefined),
|
||||
@@ -28,10 +33,18 @@ jest.mock('../../../logging/RemoteLoggerStream', () => ({
|
||||
default: jest.fn(() => mockEventLogStream),
|
||||
}));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
const DEVICE_RUN_SESSION_TOKEN = 'device-run-session-token-value';
|
||||
const EVENT_LOG_UPLOAD_SESSION_URL =
|
||||
'https://api.expo.test/v2/device-run-session-worker/event-log-upload-session';
|
||||
|
||||
describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(turtleFetch).mockImplementation(async () => eventLogUploadSessionResponse());
|
||||
});
|
||||
|
||||
it('uploads normalized events to the session event artifact once', async () => {
|
||||
@@ -88,9 +101,13 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
await fs.promises.rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(ctx.graphqlClient.mutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
deviceRunSessionId: 'session-id',
|
||||
});
|
||||
expect(turtleFetch).toHaveBeenCalledWith(
|
||||
EVENT_LOG_UPLOAD_SESSION_URL,
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
|
||||
})
|
||||
);
|
||||
expect(RemoteLoggerStream).toHaveBeenCalledWith({
|
||||
logger,
|
||||
uploadMethod: {
|
||||
@@ -535,11 +552,13 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
});
|
||||
|
||||
it('does not fail the session when event log setup fails', async () => {
|
||||
const error = new Error('WWW unavailable');
|
||||
const ctx = createContext({ realtimeLogs: true });
|
||||
jest.mocked(ctx.graphqlClient.mutation).mockReturnValueOnce({
|
||||
toPromise: async () => ({ error }),
|
||||
} as unknown as ReturnType<CustomBuildContext['graphqlClient']['mutation']>);
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ errors: [{ message: 'WWW unavailable' }] }), {
|
||||
status: 503,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
);
|
||||
const logger = createLogger();
|
||||
|
||||
const collection = await startAgentDeviceEventCollectionAsync({
|
||||
@@ -554,8 +573,11 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{
|
||||
err: expect.objectContaining({
|
||||
message: 'Failed to create device run session event log upload session: WWW unavailable',
|
||||
cause: error,
|
||||
message:
|
||||
'Failed to create device run session event log upload session: ' +
|
||||
'POST /v2/device-run-session-worker/event-log-upload-session failed with status 503: ' +
|
||||
'WWW unavailable',
|
||||
cause: expect.any(DeviceRunSessionWorkerApiError),
|
||||
}),
|
||||
},
|
||||
'Could not persist device run session events to the artifact.'
|
||||
@@ -563,8 +585,11 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not persist device run session events',
|
||||
expect.objectContaining({
|
||||
message: 'Failed to create device run session event log upload session: WWW unavailable',
|
||||
cause: error,
|
||||
message:
|
||||
'Failed to create device run session event log upload session: ' +
|
||||
'POST /v2/device-run-session-worker/event-log-upload-session failed with status 503: ' +
|
||||
'WWW unavailable',
|
||||
cause: expect.any(DeviceRunSessionWorkerApiError),
|
||||
}),
|
||||
{
|
||||
level: 'warning',
|
||||
@@ -669,32 +694,35 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
function createContext({
|
||||
realtimeLogs = false,
|
||||
}: { realtimeLogs?: boolean } = {}): CustomBuildContext {
|
||||
const mutation = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createEventLogUploadSession: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/events',
|
||||
headers: {
|
||||
'content-disposition': 'attachment; filename="events.ndjson"',
|
||||
'x-goog-content-length-range': '0,104857600',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
return {
|
||||
graphqlClient: { mutation },
|
||||
env: realtimeLogs ? { EXPO_STAGING: '1', EAS_BUILD_ID: 'job-run-id' } : {},
|
||||
env: {
|
||||
EAS_DEVICE_RUN_SESSION_TOKEN: DEVICE_RUN_SESSION_TOKEN,
|
||||
...(realtimeLogs ? { EXPO_STAGING: '1', EAS_BUILD_ID: 'job-run-id' } : {}),
|
||||
},
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
job: {
|
||||
secrets: realtimeLogs ? { robotAccessToken: 'robot-access-token' } : {},
|
||||
},
|
||||
} as unknown as CustomBuildContext;
|
||||
}
|
||||
|
||||
function eventLogUploadSessionResponse(): InstanceType<typeof Response> {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/events',
|
||||
headers: {
|
||||
'content-disposition': 'attachment; filename="events.ndjson"',
|
||||
'x-goog-content-length-range': '0,104857600',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
function createLogger(): bunyan {
|
||||
return { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import HttpLogStream from '../../../logging/HttpLogStream';
|
||||
import RemoteLoggerStream from '../../../logging/RemoteLoggerStream';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startArgentEventCollectionAsync } from '../argentEvents';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
|
||||
const mockEventLogStream = {
|
||||
init: jest.fn(async () => undefined),
|
||||
@@ -28,12 +30,20 @@ jest.mock('../../../logging/RemoteLoggerStream', () => ({
|
||||
default: jest.fn(() => mockEventLogStream),
|
||||
}));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
const DEVICE_RUN_SESSION_TOKEN = 'device-run-session-token-value';
|
||||
const EVENT_LOG_UPLOAD_SESSION_URL =
|
||||
'https://api.expo.test/v2/device-run-session-worker/event-log-upload-session';
|
||||
|
||||
const EVENT_LOG_FILENAME = 'tool-server-events.jsonl';
|
||||
|
||||
describe(startArgentEventCollectionAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(turtleFetch).mockImplementation(async () => eventLogUploadSessionResponse());
|
||||
});
|
||||
|
||||
it('maps tool lifecycle records onto the shared operation vocabulary', async () => {
|
||||
@@ -84,9 +94,13 @@ describe(startArgentEventCollectionAsync, () => {
|
||||
await fs.promises.rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(ctx.graphqlClient.mutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
deviceRunSessionId: 'session-id',
|
||||
});
|
||||
expect(turtleFetch).toHaveBeenCalledWith(
|
||||
EVENT_LOG_UPLOAD_SESSION_URL,
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
|
||||
})
|
||||
);
|
||||
expect(RemoteLoggerStream).toHaveBeenCalledWith({
|
||||
logger,
|
||||
uploadMethod: {
|
||||
@@ -318,29 +332,32 @@ function bunyanRecord(fields: Record<string, unknown>): Record<string, unknown>
|
||||
function createContext({
|
||||
realtimeLogs = false,
|
||||
}: { realtimeLogs?: boolean } = {}): CustomBuildContext {
|
||||
const mutation = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createEventLogUploadSession: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/events',
|
||||
headers: { 'content-disposition': 'attachment; filename="events.ndjson"' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
return {
|
||||
graphqlClient: { mutation },
|
||||
env: realtimeLogs ? { EXPO_STAGING: '1', EAS_BUILD_ID: 'job-run-id' } : {},
|
||||
env: {
|
||||
EAS_DEVICE_RUN_SESSION_TOKEN: DEVICE_RUN_SESSION_TOKEN,
|
||||
...(realtimeLogs ? { EXPO_STAGING: '1', EAS_BUILD_ID: 'job-run-id' } : {}),
|
||||
},
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
job: {
|
||||
secrets: realtimeLogs ? { robotAccessToken: 'robot-access-token' } : {},
|
||||
},
|
||||
} as unknown as CustomBuildContext;
|
||||
}
|
||||
|
||||
function eventLogUploadSessionResponse(): InstanceType<typeof Response> {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/events',
|
||||
headers: { 'content-disposition': 'attachment; filename="events.ndjson"' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
function createLogger(): bunyan {
|
||||
return { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
}
|
||||
|
||||
@@ -2,46 +2,56 @@ import fetch from 'node-fetch';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
const TOKEN = 'device-run-session-token-value';
|
||||
|
||||
function jsonResponse(body: unknown): InstanceType<typeof Response> {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function createCtx(): CustomBuildContext {
|
||||
return {
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN },
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
} as unknown as CustomBuildContext;
|
||||
}
|
||||
|
||||
describe(uploadDeviceRunSessionArtifactAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(fetch).mockReset();
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
it('streams an artifact through a signed upload URL', async () => {
|
||||
const stream = Readable.from(Buffer.from('artifact-data'));
|
||||
const reportedSize = 1024;
|
||||
const mutation = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/artifact',
|
||||
headers: {
|
||||
'Content-Length': String(reportedSize),
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
},
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/artifact',
|
||||
headers: {
|
||||
'Content-Length': String(reportedSize),
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const ctx = {
|
||||
graphqlClient: {
|
||||
mutation,
|
||||
},
|
||||
} as unknown as CustomBuildContext;
|
||||
|
||||
})
|
||||
);
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
|
||||
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
await uploadDeviceRunSessionArtifactAsync(createCtx(), {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
artifactId: 'artifact-id',
|
||||
name: 'Artifact report.json (artifact-id)',
|
||||
@@ -52,19 +62,20 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
|
||||
openStream: () => stream,
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/device-run-session-worker/artifact-upload-sessions',
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
deviceRunSessionId: 'drs-id',
|
||||
input: {
|
||||
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: TOKEN },
|
||||
json: {
|
||||
name: 'Artifact report.json (artifact-id)',
|
||||
filename: 'report.json',
|
||||
kind: 'agent-device-test-report',
|
||||
metadata: { firstFrameRecordAt: 'test-time' },
|
||||
size: reportedSize,
|
||||
},
|
||||
}),
|
||||
{ fetch: expect.any(Function) }
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
);
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
'https://uploads.expo.test/artifact',
|
||||
@@ -77,26 +88,19 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
|
||||
|
||||
it('forwards cancellation through upload allocation and skips PUT when allocation aborts', async () => {
|
||||
const controller = new AbortController();
|
||||
const mutation = jest.fn().mockReturnValue({
|
||||
toPromise: async () => {
|
||||
controller.abort();
|
||||
return {
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/artifact',
|
||||
headers: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
jest.mocked(turtleFetch).mockImplementationOnce(async () => {
|
||||
controller.abort();
|
||||
return jsonResponse({
|
||||
data: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/artifact',
|
||||
headers: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
const ctx = { graphqlClient: { mutation } } as unknown as CustomBuildContext;
|
||||
await expect(
|
||||
uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
uploadDeviceRunSessionArtifactAsync(createCtx(), {
|
||||
deviceRunSessionId: 'session',
|
||||
artifactId: 'id',
|
||||
name: 'Logs',
|
||||
@@ -108,18 +112,8 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
const allocationFetch = mutation.mock.calls[0][2].fetch;
|
||||
const mockFetch = jest.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('aborted'));
|
||||
try {
|
||||
await expect(allocationFetch('https://api.expo.test/graphql', {})).rejects.toThrow('aborted');
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/graphql',
|
||||
expect.objectContaining({
|
||||
signal: expect.objectContaining({ aborted: true }),
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
mockFetch.mockRestore();
|
||||
}
|
||||
const allocationSignal = jest.mocked(turtleFetch).mock.calls[0][2].signal;
|
||||
expect(allocationSignal).toBeInstanceOf(AbortSignal);
|
||||
expect(allocationSignal?.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
import { getEventListeners } from 'node:events';
|
||||
import { readFile, readdir, writeFile } from 'node:fs/promises';
|
||||
import { Readable } from 'node:stream';
|
||||
@@ -10,20 +10,43 @@ import fetch from 'node-fetch';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import * as processes from '../../../utils/processes';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import {
|
||||
captureDeviceRunSessionPreviewAsync,
|
||||
ensureMacosPreviewEncoderInstalledAsync,
|
||||
startDeviceRunSessionPreview,
|
||||
} from '../deviceRunSessionPreview';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
const DEVICE_RUN_SESSION_TOKEN = 'device-run-session-token-value';
|
||||
const PREVIEW_UPLOAD_SESSION_URL =
|
||||
'https://api.expo.test/v2/device-run-session-worker/preview-upload-session';
|
||||
const uploadSession = {
|
||||
url: 'https://uploads.expo.test/preview',
|
||||
headers: { 'content-type': 'image/webp' },
|
||||
};
|
||||
|
||||
function createCtx(): CustomBuildContext {
|
||||
return {
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: DEVICE_RUN_SESSION_TOKEN },
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
} as unknown as CustomBuildContext;
|
||||
}
|
||||
|
||||
function uploadSessionResponse(): InstanceType<typeof Response> {
|
||||
return new Response(JSON.stringify({ data: { uploadSession } }), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
describe(startDeviceRunSessionPreview, () => {
|
||||
const image = Buffer.from('webp-image');
|
||||
let captureAsync: jest.Mock<Promise<Buffer>, [AbortSignal]>;
|
||||
let mutation: jest.Mock;
|
||||
let logger: bunyan;
|
||||
let ctx: CustomBuildContext;
|
||||
let preview: ReturnType<typeof startDeviceRunSessionPreview> | undefined;
|
||||
@@ -31,21 +54,11 @@ describe(startDeviceRunSessionPreview, () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
captureAsync = jest.fn().mockResolvedValue(image);
|
||||
mutation = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createPreviewUploadSession: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/preview',
|
||||
headers: { 'content-type': 'image/webp' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
ctx = { graphqlClient: { mutation } } as unknown as CustomBuildContext;
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockReset()
|
||||
.mockImplementation(async () => uploadSessionResponse());
|
||||
ctx = createCtx();
|
||||
logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
jest
|
||||
.mocked(fetch)
|
||||
@@ -82,7 +95,7 @@ describe(startDeviceRunSessionPreview, () => {
|
||||
expect(captureAsync).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(captureAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
await preview!.stopAsync();
|
||||
await jest.advanceTimersByTimeAsync(120_000);
|
||||
expect(captureAsync).toHaveBeenCalledTimes(2);
|
||||
@@ -92,7 +105,7 @@ describe(startDeviceRunSessionPreview, () => {
|
||||
captureAsync.mockRejectedValueOnce(new Error('device not ready'));
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
@@ -102,7 +115,7 @@ describe(startDeviceRunSessionPreview, () => {
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 403 }));
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -126,7 +139,7 @@ describe(startDeviceRunSessionPreview, () => {
|
||||
it('reuses the upload URL for the full session', async () => {
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(119 * 60_000);
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not overlap captures and aborts pending work when stopped', async () => {
|
||||
@@ -156,29 +169,20 @@ describe(startDeviceRunSessionPreview, () => {
|
||||
const stopping = handle.stopAsync();
|
||||
finish(image);
|
||||
await stopping;
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('session preview with the real GraphQL client', () => {
|
||||
const uploadSession = {
|
||||
url: 'https://uploads.expo.test/preview',
|
||||
headers: { 'content-type': 'image/webp' },
|
||||
};
|
||||
let graphqlFetch: jest.SpyInstance;
|
||||
describe('session preview upload session from the worker API', () => {
|
||||
let preview: ReturnType<typeof startDeviceRunSessionPreview> | undefined;
|
||||
let logger: bunyan;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
graphqlFetch = jest.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new globalThis.Response(
|
||||
JSON.stringify({
|
||||
data: { deviceRunSession: { createPreviewUploadSession: { uploadSession } } },
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json' } }
|
||||
)
|
||||
);
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockReset()
|
||||
.mockImplementation(async () => uploadSessionResponse());
|
||||
logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
jest
|
||||
.mocked(fetch)
|
||||
@@ -193,14 +197,9 @@ describe('session preview with the real GraphQL client', () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const start = () => {
|
||||
const graphqlClient = new Client({
|
||||
url: 'https://api.expo.test/graphql',
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: { headers: { Authorization: 'Bearer worker-token' } },
|
||||
});
|
||||
const start = (ctx: CustomBuildContext = createCtx()) => {
|
||||
preview = startDeviceRunSessionPreview({
|
||||
ctx: { graphqlClient } as CustomBuildContext,
|
||||
ctx,
|
||||
deviceRunSessionId: 'session-id',
|
||||
captureAsync: async () => Buffer.from('webp-image'),
|
||||
logger,
|
||||
@@ -208,25 +207,49 @@ describe('session preview with the real GraphQL client', () => {
|
||||
return preview;
|
||||
};
|
||||
|
||||
it('preserves the worker authentication when creating the upload session', async () => {
|
||||
it('sends the device run session token when creating the upload session', async () => {
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
const [, options] = graphqlFetch.mock.calls[0];
|
||||
expect(new Headers(options.headers).get('authorization')).toBe('Bearer worker-token');
|
||||
expect(turtleFetch).toHaveBeenCalledWith(
|
||||
PREVIEW_UPLOAD_SESSION_URL,
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(['shutdown', 'timeout'])('aborts a stalled GraphQL request on %s', async reason => {
|
||||
it('logs a failure and uploads nothing when the job has no device run session token', async () => {
|
||||
start({
|
||||
env: {},
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
} as unknown as CustomBuildContext);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(SystemError) },
|
||||
'Could not refresh the session preview; retrying in 60 seconds.'
|
||||
);
|
||||
const [{ err }] = jest.mocked(logger.warn).mock.calls[0] as unknown as [{ err: Error }];
|
||||
expect(err.message).toMatch(
|
||||
/^This device run session job has no EAS_DEVICE_RUN_SESSION_TOKEN secret\./
|
||||
);
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['shutdown', 'timeout'])('aborts a stalled worker API request on %s', async reason => {
|
||||
const timeout = new AbortController();
|
||||
jest.spyOn(AbortSignal, 'timeout').mockReturnValue(timeout.signal);
|
||||
let requestSignal: AbortSignal | undefined;
|
||||
let rejectRequest!: (error: Error) => void;
|
||||
graphqlFetch.mockImplementation(
|
||||
(_url, options) =>
|
||||
jest.mocked(turtleFetch).mockImplementation(
|
||||
(_url, _method, options) =>
|
||||
new Promise((_, reject) => {
|
||||
rejectRequest = reject;
|
||||
requestSignal = options.signal;
|
||||
requestSignal!.addEventListener('abort', () => reject(requestSignal!.reason), {
|
||||
requestSignal = options.signal as AbortSignal;
|
||||
requestSignal.addEventListener('abort', () => reject(requestSignal!.reason), {
|
||||
once: true,
|
||||
});
|
||||
})
|
||||
@@ -255,14 +278,7 @@ describe('session preview with the real GraphQL client', () => {
|
||||
await stopping;
|
||||
} else {
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
graphqlFetch.mockResolvedValue(
|
||||
new globalThis.Response(
|
||||
JSON.stringify({
|
||||
data: { deviceRunSession: { createPreviewUploadSession: { uploadSession } } },
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json' } }
|
||||
)
|
||||
);
|
||||
jest.mocked(turtleFetch).mockImplementation(async () => uploadSessionResponse());
|
||||
jest.spyOn(AbortSignal, 'timeout').mockReturnValue(new AbortController().signal);
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
+38
-17
@@ -1,8 +1,7 @@
|
||||
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 { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -25,25 +24,27 @@ let ctx: CustomBuildContext;
|
||||
let url: string;
|
||||
let directory: string;
|
||||
let creationBodies: string[];
|
||||
let creationTokens: (string | undefined)[];
|
||||
|
||||
beforeEach(async () => {
|
||||
creationBodies = [];
|
||||
creationTokens = [];
|
||||
directory = await mkdtemp(path.join(tmpdir(), 'recording-recovery-'));
|
||||
server = createServer((request, response) => {
|
||||
if (request.url === '/graphql') {
|
||||
if (request.url === '/v2/device-run-session-worker/artifact-upload-sessions') {
|
||||
request.setEncoding('utf8');
|
||||
let body = '';
|
||||
request.on('data', chunk => (body += chunk));
|
||||
request.on('end', () => {
|
||||
creationBodies.push(body);
|
||||
creationTokens.push(request.headers['expo-device-run-session-token'] as string | undefined);
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: { url: `${url}/artifact`, headers: {} },
|
||||
},
|
||||
uploadSession: {
|
||||
url: `${url}/artifact/${encodeURIComponent(JSON.parse(body).filename)}`,
|
||||
headers: {},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -60,12 +61,9 @@ beforeEach(async () => {
|
||||
}
|
||||
url = `http://127.0.0.1:${address.port}`;
|
||||
ctx = {
|
||||
graphqlClient: new Client({
|
||||
url: `${url}/graphql`,
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
|
||||
}),
|
||||
} as CustomBuildContext;
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
|
||||
expoApiV2BaseUrl: `${url}/v2/`,
|
||||
} as unknown as CustomBuildContext;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -165,10 +163,14 @@ it('recovers unlisted recordings from a killed host and flags the unfinished one
|
||||
]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('empty/recording.mp4.partial'));
|
||||
|
||||
let putBytes = 0;
|
||||
const uploaded = new Map<string, Buffer>();
|
||||
handle = (request, response) => {
|
||||
request.on('data', chunk => (putBytes += chunk.length));
|
||||
const chunks: Buffer[] = [];
|
||||
request.on('data', chunk => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
request.on('end', () => {
|
||||
uploaded.set(request.url!, Buffer.concat(chunks));
|
||||
response.statusCode = 200;
|
||||
response.end();
|
||||
});
|
||||
@@ -180,9 +182,18 @@ it('recovers unlisted recordings from a killed host and flags the unfinished one
|
||||
recordings,
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
expect(putBytes).toBe(3 * (await stat(path.join(cut, 'recording.mp4.partial'))).size);
|
||||
for (const recording of recordings) {
|
||||
const manifest = await readFile(path.join(recording.directory, 'session.json'));
|
||||
const recordingId = path.basename(recording.directory);
|
||||
expect(uploaded.get(`/artifact/${recordingId}.json`)).toEqual(manifest);
|
||||
expect(uploaded.get(`/artifact/${recordingId}.mp4`)).toEqual(
|
||||
await readFile(path.join(recording.directory, JSON.parse(manifest.toString()).recording))
|
||||
);
|
||||
}
|
||||
expect(creationTokens).toEqual(Array(6).fill('session-token'));
|
||||
const inputs = creationBodies
|
||||
.map(body => JSON.parse(body).variables.input)
|
||||
.map(body => JSON.parse(body))
|
||||
.filter(input => input.kind === 'screen-recording')
|
||||
.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]);
|
||||
@@ -193,6 +204,16 @@ it('recovers unlisted recordings from a killed host and flags the unfinished one
|
||||
width: 128,
|
||||
height: 96,
|
||||
});
|
||||
const manifests = creationBodies
|
||||
.map(body => JSON.parse(body))
|
||||
.filter(input => input.kind === 'screen-recording-metadata')
|
||||
.sort((a, b) => a.filename.localeCompare(b.filename));
|
||||
expect(manifests.map(input => input.filename)).toEqual(['cut.json', 'done.json', 'failed.json']);
|
||||
expect(manifests.map(input => input.metadata.recordingFilename)).toEqual([
|
||||
'cut.mp4',
|
||||
'done.mp4',
|
||||
'failed.mp4',
|
||||
]);
|
||||
expect(inputs[2].metadata).toMatchObject({
|
||||
partial: true,
|
||||
partialReason: 'Recording finalization exceeded 30000 ms.',
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { vol } from 'memfs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import { uploadDeviceRunSessionScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
|
||||
|
||||
jest.mock('../deviceRunSessionArtifacts');
|
||||
jest.mock('../../../sentry');
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const recording = {
|
||||
udid: 'simulator-id',
|
||||
deviceName: 'iPhone Duo',
|
||||
runtimeDisplayName: 'iOS 27.1',
|
||||
directory: '/recordings/recording-id',
|
||||
};
|
||||
const manifest = {
|
||||
firstFrameWallClock: { unixMs: 1_791_072_000_000, iso8601: '2026-10-04T00:00:00.000Z' },
|
||||
width: 1440,
|
||||
height: 1200,
|
||||
recording: 'recording.mp4',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{},
|
||||
{
|
||||
deviceStates: [
|
||||
{
|
||||
timeMs: 12.5,
|
||||
state: {
|
||||
width: 1200,
|
||||
height: 1440,
|
||||
orientation: 'landscape-left',
|
||||
screen: 'inner',
|
||||
hingeAngle: 90,
|
||||
physicalOrientation: 'portrait',
|
||||
tableMode: false,
|
||||
contentRect: { x: 220, y: 0, width: 1000, height: 1200 },
|
||||
},
|
||||
},
|
||||
],
|
||||
interruptions: [{ reason: 'simulatorReboot', pausedAtSeconds: 1, resumedAtSeconds: 2 }],
|
||||
futureField: { label: '서울 🌲' },
|
||||
},
|
||||
])('uploads the complete manifest unchanged beside its video (%j)', async extra => {
|
||||
const contents = Buffer.from(JSON.stringify({ ...manifest, ...extra }, null, 2) + '\n');
|
||||
vol.fromJSON({
|
||||
[recording.directory + '/session.json']: contents.toString(),
|
||||
[recording.directory + '/recording.mp4']: 'video',
|
||||
});
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_, input) => {
|
||||
if (input.kind === 'screen-recording') {
|
||||
await writeFile(recording.directory + '/session.json', '{}');
|
||||
}
|
||||
});
|
||||
|
||||
await expect(
|
||||
uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: 'session-id',
|
||||
recordings: [recording],
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
|
||||
const inputs = jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mock.calls.map(([, input]) => input);
|
||||
expect(inputs.map(input => input.filename)).toEqual(['recording-id.mp4', 'recording-id.json']);
|
||||
expect(inputs[1]).toMatchObject({
|
||||
deviceRunSessionId: 'session-id',
|
||||
artifactId: 'recording-id-metadata',
|
||||
kind: 'screen-recording-metadata',
|
||||
metadata: {
|
||||
__eas_type: 'screen-recording-metadata',
|
||||
recordingFilename: 'recording-id.mp4',
|
||||
udid: recording.udid,
|
||||
},
|
||||
size: contents.length,
|
||||
});
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of inputs[1].openStream()) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
expect(Buffer.concat(chunks)).toEqual(contents);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a metadata upload failure while keeping the video and uploading other recordings', async () => {
|
||||
const other = { ...recording, directory: '/recordings/other-recording', udid: 'other-device' };
|
||||
for (const item of [recording, other]) {
|
||||
vol.fromJSON({
|
||||
[item.directory + '/session.json']: JSON.stringify(manifest),
|
||||
[item.directory + '/recording.mp4']: 'video',
|
||||
});
|
||||
}
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_, input) => {
|
||||
if (input.filename === 'recording-id.json') {
|
||||
throw new Error('Storage unavailable');
|
||||
}
|
||||
});
|
||||
|
||||
await expect(
|
||||
uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: 'session-id',
|
||||
recordings: [recording, other],
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls.map(([, input]) => input.filename)
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
'recording-id.mp4',
|
||||
'recording-id.json',
|
||||
'other-recording.mp4',
|
||||
'other-recording.json',
|
||||
])
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not upload device screen recording metadata',
|
||||
expect.any(Error)
|
||||
);
|
||||
expect(Sentry.capture).not.toHaveBeenCalledWith(
|
||||
'Could not upload device screen recording',
|
||||
expect.any(Error)
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
`Could not upload screen recording metadata for ${recording.deviceName}.`
|
||||
);
|
||||
});
|
||||
+33
-55
@@ -1,5 +1,5 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
screenshotArtifactDetails,
|
||||
} from '../deviceRunSessionScreenshotNames';
|
||||
import { uploadDeviceRunSessionScreenshotsAsync } from '../deviceRunSessionScreenshots';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
jest.unmock('node-fetch');
|
||||
jest.mock('../../../sentry');
|
||||
function deferred<T>() {
|
||||
@@ -26,7 +27,7 @@ let ctx: CustomBuildContext;
|
||||
let url: string;
|
||||
let directory: string;
|
||||
let creates: number;
|
||||
let authorization: string | undefined;
|
||||
let deviceRunSessionToken: string | string[] | undefined;
|
||||
let stallCreation: boolean;
|
||||
let creationStarted: ReturnType<typeof deferred<IncomingMessage>>;
|
||||
let creationBodies: string[];
|
||||
@@ -40,42 +41,32 @@ beforeEach(async () => {
|
||||
sessionLookups = 0;
|
||||
sessionLookupFails = false;
|
||||
uploadQuery = '';
|
||||
authorization = undefined;
|
||||
deviceRunSessionToken = undefined;
|
||||
stallCreation = false;
|
||||
creationBodies = [];
|
||||
creationStarted = deferred();
|
||||
directory = await mkdtemp(path.join(tmpdir(), 'recording-upload-http-'));
|
||||
server = createServer((request, response) => {
|
||||
if (request.url?.startsWith('/graphql')) {
|
||||
authorization = request.headers.authorization;
|
||||
if (request.url?.startsWith('/v2/device-run-session-worker/')) {
|
||||
deviceRunSessionToken = request.headers[DEVICE_RUN_SESSION_TOKEN_HEADER];
|
||||
request.setEncoding('utf8');
|
||||
let body = '';
|
||||
if (request.method === 'GET') {
|
||||
const requestUrl = new URL(request.url, 'http://localhost');
|
||||
body = JSON.stringify({ query: requestUrl.searchParams.get('query') });
|
||||
}
|
||||
request.on('data', chunk => (body += chunk));
|
||||
request.on('end', () => {
|
||||
const operation = JSON.parse(body);
|
||||
if (operation.query.includes('query ScreenshotSession')) {
|
||||
if (request.url === '/v2/device-run-session-worker/application') {
|
||||
sessionLookups++;
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
if (sessionLookupFails) {
|
||||
response.statusCode = 503;
|
||||
response.end(JSON.stringify({ errors: [{ message: 'Lookup unavailable' }] }));
|
||||
} else {
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
deviceRunSessions: {
|
||||
byId: {
|
||||
platform: 'IOS',
|
||||
app: { slug: 'session-project' },
|
||||
build: {
|
||||
id: 'installed-build',
|
||||
gitCommitHash: installedCommit,
|
||||
app: { slug: 'my-app' },
|
||||
},
|
||||
},
|
||||
build: {
|
||||
id: 'installed-build',
|
||||
gitCommitHash: installedCommit,
|
||||
applicationArchiveUrl: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -92,13 +83,7 @@ beforeEach(async () => {
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: { url: `${url}/artifact${uploadQuery}`, headers: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
data: { uploadSession: { url: `${url}/artifact${uploadQuery}`, headers: {} } },
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -113,12 +98,10 @@ beforeEach(async () => {
|
||||
}
|
||||
url = `http://127.0.0.1:${address.port}`;
|
||||
ctx = {
|
||||
graphqlClient: new Client({
|
||||
url: `${url}/graphql`,
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
|
||||
}),
|
||||
} as CustomBuildContext;
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'test-device-run-session-token' },
|
||||
expoApiV2BaseUrl: `${url}/v2/`,
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
} as unknown as CustomBuildContext;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -127,7 +110,7 @@ afterEach(async () => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('publishes a manual preview PNG through the artifact GraphQL and HTTP upload path', async () => {
|
||||
it('publishes a manual preview PNG through the worker API and HTTP upload path', async () => {
|
||||
const payload = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aF9sAAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
@@ -157,25 +140,20 @@ it('publishes a manual preview PNG through the artifact GraphQL and HTTP upload
|
||||
expect(bodies).toEqual([payload]);
|
||||
expect(creates).toBe(1);
|
||||
expect(sessionLookups).toBe(1);
|
||||
expect(authorization).toBe('Bearer test-auth');
|
||||
expect(JSON.parse(creationBodies[0]).variables).toEqual(
|
||||
expect.objectContaining({
|
||||
deviceRunSessionId: 'drs-id',
|
||||
input: expect.objectContaining({
|
||||
name: 'Screenshot 2026-09-24 08:45:59 UTC',
|
||||
filename: 'my-app-ios-a1b2c3d-2026-09-24T08-45-59-123Z.png',
|
||||
kind: 'screenshot',
|
||||
size: payload.length,
|
||||
metadata: {
|
||||
__eas_type: 'screenshot',
|
||||
appSlug: 'my-app',
|
||||
platform: 'ios',
|
||||
buildId: 'installed-build',
|
||||
gitCommitHash: installedCommit,
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(deviceRunSessionToken).toBe('test-device-run-session-token');
|
||||
// The worker API does not return the app slug, so the filename starts with the platform.
|
||||
expect(JSON.parse(creationBodies[0])).toEqual({
|
||||
name: 'Screenshot 2026-09-24 08:45:59 UTC',
|
||||
filename: 'ios-a1b2c3d-2026-09-24T08-45-59-123Z.png',
|
||||
kind: 'screenshot',
|
||||
size: payload.length,
|
||||
metadata: {
|
||||
__eas_type: 'screenshot',
|
||||
platform: 'ios',
|
||||
buildId: 'installed-build',
|
||||
gitCommitHash: installedCommit,
|
||||
},
|
||||
});
|
||||
await expect(stat(path.join(directory, filename))).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
@@ -206,7 +184,7 @@ it('rereads the PNG when the artifact PUT is retried', async () => {
|
||||
await expect(stat(path.join(directory, filename))).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
it('aborts a stalled GraphQL upload request and keeps the PNG', async () => {
|
||||
it('aborts a stalled worker API upload session request and keeps the PNG', async () => {
|
||||
stallCreation = true;
|
||||
const filename = 'screenshot-2026-09-24T08-45-59-123Z-a1b2c3d4e5f6.png';
|
||||
await writeFile(path.join(directory, filename), 'png');
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import type { BuildStepEnv } from '@expo/steps';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
|
||||
@@ -13,6 +12,7 @@ import { setTimeout as delay } from 'node:timers/promises';
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import { findUnlistedDeviceScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
jest.unmock('node:fs');
|
||||
@@ -40,20 +40,20 @@ let ctx: CustomBuildContext;
|
||||
let url: string;
|
||||
let directory: string;
|
||||
let creates: number;
|
||||
let authorization: string | undefined;
|
||||
let deviceRunSessionToken: string | string[] | undefined;
|
||||
let stallCreation: boolean;
|
||||
let creationStarted: ReturnType<typeof deferred<IncomingMessage>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
creates = 0;
|
||||
authorization = undefined;
|
||||
deviceRunSessionToken = undefined;
|
||||
stallCreation = false;
|
||||
creationStarted = deferred();
|
||||
directory = await mkdtemp(path.join(tmpdir(), 'recording-upload-http-'));
|
||||
server = createServer((request, response) => {
|
||||
if (request.url === '/graphql') {
|
||||
if (request.url === '/v2/device-run-session-worker/artifact-upload-sessions') {
|
||||
creates++;
|
||||
authorization = request.headers.authorization;
|
||||
deviceRunSessionToken = request.headers[DEVICE_RUN_SESSION_TOKEN_HEADER];
|
||||
creationStarted.resolve(request);
|
||||
request.resume();
|
||||
request.on('end', () => {
|
||||
@@ -63,13 +63,7 @@ beforeEach(async () => {
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: { url: `${url}/artifact`, headers: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
data: { uploadSession: { url: `${url}/artifact`, headers: {} } },
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -84,12 +78,9 @@ beforeEach(async () => {
|
||||
}
|
||||
url = `http://127.0.0.1:${address.port}`;
|
||||
ctx = {
|
||||
graphqlClient: new Client({
|
||||
url: `${url}/graphql`,
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
|
||||
}),
|
||||
} as CustomBuildContext;
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'test-device-run-session-token' },
|
||||
expoApiV2BaseUrl: `${url}/v2/`,
|
||||
} as unknown as CustomBuildContext;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -126,7 +117,7 @@ it('replays the full file after a transient PUT failure without recreating the u
|
||||
attempts: 3,
|
||||
});
|
||||
expect(creates).toBe(1);
|
||||
expect(authorization).toBe('Bearer test-auth');
|
||||
expect(deviceRunSessionToken).toBe('test-device-run-session-token');
|
||||
expect(bodies).toEqual([payload, payload]);
|
||||
expect(streams).toHaveLength(2);
|
||||
expect(streams.every(stream => stream.destroyed)).toBe(true);
|
||||
@@ -281,7 +272,7 @@ it.each([200, 400, 503])(
|
||||
}
|
||||
);
|
||||
|
||||
it('cancels urql session creation with auth intact and never starts a PUT', async () => {
|
||||
it('cancels worker API session creation with the token intact and never starts a PUT', async () => {
|
||||
stallCreation = true;
|
||||
const puts = jest.fn();
|
||||
handle = puts;
|
||||
@@ -298,7 +289,7 @@ it('cancels urql session creation with auth intact and never starts a PUT', asyn
|
||||
controller.abort(new Error('test cancellation'));
|
||||
await rejected;
|
||||
await closed;
|
||||
expect(authorization).toBe('Bearer test-auth');
|
||||
expect(deviceRunSessionToken).toBe('test-device-run-session-token');
|
||||
expect(creates).toBe(1);
|
||||
expect(puts).not.toHaveBeenCalled();
|
||||
expect(openStream).not.toHaveBeenCalled();
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
DeviceRunSessionWorkerApi,
|
||||
DeviceRunSessionWorkerApiError,
|
||||
getDeviceRunSessionWorkerApi,
|
||||
requireDeviceRunSessionWorkerApi,
|
||||
} from '../deviceRunSessionWorkerApi';
|
||||
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
@@ -88,6 +89,36 @@ describe(getDeviceRunSessionWorkerApi, () => {
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -101,7 +132,7 @@ describe(DeviceRunSessionWorkerApi, () => {
|
||||
route: 'session',
|
||||
json: undefined,
|
||||
data: { id: 'drs-id', status: 'in-progress' },
|
||||
expected: { id: 'drs-id', status: 'IN_PROGRESS' },
|
||||
expected: { id: 'drs-id', status: 'in-progress' },
|
||||
},
|
||||
{
|
||||
name: 'startAsync',
|
||||
@@ -111,7 +142,7 @@ describe(DeviceRunSessionWorkerApi, () => {
|
||||
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' },
|
||||
expected: { id: 'drs-id', status: 'in-progress' },
|
||||
},
|
||||
{
|
||||
name: 'stopAsync',
|
||||
@@ -120,7 +151,7 @@ describe(DeviceRunSessionWorkerApi, () => {
|
||||
route: 'stop',
|
||||
json: undefined,
|
||||
data: { id: 'drs-id', status: 'stopped' },
|
||||
expected: { id: 'drs-id', status: 'STOPPED' },
|
||||
expected: { id: 'drs-id', status: 'stopped' },
|
||||
},
|
||||
{
|
||||
name: 'createArtifactUploadSessionAsync',
|
||||
@@ -217,20 +248,16 @@ describe(DeviceRunSessionWorkerApi, () => {
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
['new', 'NEW'],
|
||||
['queued', 'QUEUED'],
|
||||
['starting', 'STARTING'],
|
||||
['in-progress', 'IN_PROGRESS'],
|
||||
['stopped', 'STOPPED'],
|
||||
['errored', 'ERRORED'],
|
||||
])('maps the www status %s to the GraphQL status %s', async (wwwStatus, status) => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: wwwStatus } }));
|
||||
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 });
|
||||
});
|
||||
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' } }));
|
||||
@@ -303,7 +330,8 @@ describe(DeviceRunSessionWorkerApi, () => {
|
||||
});
|
||||
|
||||
it('reports a request that failed before www responded', async () => {
|
||||
jest.mocked(turtleFetch).mockRejectedValueOnce(new Error('socket hang up'));
|
||||
const reason = new Error('socket hang up');
|
||||
jest.mocked(turtleFetch).mockRejectedValueOnce(reason);
|
||||
|
||||
const error = await createApi()
|
||||
.getSessionAsync()
|
||||
@@ -311,6 +339,7 @@ describe(DeviceRunSessionWorkerApi, () => {
|
||||
|
||||
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
|
||||
expect(error.status).toBeUndefined();
|
||||
expect(error.cause).toBe(reason);
|
||||
expect(error.message).toContain('failed before www responded: socket hang up');
|
||||
});
|
||||
|
||||
|
||||
+69
-105
@@ -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 { BuildRuntimePlatform } from '@expo/steps';
|
||||
import fetch from 'node-fetch';
|
||||
import { Readable } from 'node:stream';
|
||||
import { setImmediate } from 'node:timers/promises';
|
||||
@@ -105,8 +105,6 @@ async function waitForCallAsync(mock: jest.Mock): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const env = { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv;
|
||||
|
||||
const eventSource = {
|
||||
producer: 'test-producer',
|
||||
findEventFilesAsync: async () => [],
|
||||
@@ -203,7 +201,7 @@ describe('with a device run session token', () => {
|
||||
})
|
||||
);
|
||||
|
||||
const args = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
|
||||
const args = await fetchWebPreviewTurnArgsAsync(ctx, { logger });
|
||||
|
||||
expect(args).toEqual([
|
||||
'--stun-url',
|
||||
@@ -426,29 +424,38 @@ describe('with a device run session token', () => {
|
||||
});
|
||||
|
||||
describe('without a device run session token', () => {
|
||||
it('polls the session status with GraphQL', async () => {
|
||||
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 });
|
||||
jest
|
||||
.mocked(ctx.graphqlClient.query)
|
||||
.mockReturnValueOnce(
|
||||
graphqlResult({ deviceRunSessions: { byId: { id: 'drs-id', status: 'STOPPED' } } }) as never
|
||||
);
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({ ctx, deviceRunSessionId: 'drs-id', logger });
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledWith(expect.anything(), {
|
||||
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('stops an idle session with GraphQL', async () => {
|
||||
it('logs the failed idle stop and ends the wait', async () => {
|
||||
const ctx = createCtx({ withToken: false });
|
||||
jest.mocked(ctx.graphqlClient.mutation).mockReturnValueOnce(
|
||||
graphqlResult({
|
||||
deviceRunSession: { ensureDeviceRunSessionStopped: { id: 'drs-id', status: 'STOPPED' } },
|
||||
}) as never
|
||||
);
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
@@ -457,65 +464,43 @@ describe('without a device run session token', () => {
|
||||
idleTimeout: { maxIdleTimeMinutes: 0, getLastEventObservedAt: () => undefined },
|
||||
});
|
||||
|
||||
expect(ctx.graphqlClient.mutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
});
|
||||
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('starts the session with GraphQL', async () => {
|
||||
it('fails the start with a system error', async () => {
|
||||
const ctx = createCtx({ withToken: false });
|
||||
jest.mocked(ctx.graphqlClient.mutation).mockReturnValueOnce(
|
||||
graphqlResult({
|
||||
deviceRunSession: { startDeviceRunSession: { id: 'drs-id', status: 'IN_PROGRESS' } },
|
||||
}) as never
|
||||
);
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
const error = await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
remoteConfig: { type: 'agent-device' },
|
||||
logger,
|
||||
});
|
||||
}).catch(err => err);
|
||||
|
||||
expect(ctx.graphqlClient.mutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
remoteConfig: { type: 'agent-device' },
|
||||
});
|
||||
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('fetches TURN ICE servers from the device run sessions route with the robot token', async () => {
|
||||
it('fails the artifact upload with a system error before any PUT', async () => {
|
||||
const ctx = createCtx({ withToken: false });
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { iceServers: [] } }));
|
||||
|
||||
await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
|
||||
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/device-run-sessions/drs-id/turn-ice-servers',
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: `Bearer ${ROBOT_ACCESS_TOKEN}` },
|
||||
timeout: 5000,
|
||||
retries: 1,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('creates artifact upload sessions with GraphQL', async () => {
|
||||
const ctx = createCtx({ withToken: false });
|
||||
jest.mocked(ctx.graphqlClient.mutation).mockReturnValueOnce(
|
||||
graphqlResult({
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: { url: 'https://uploads.expo.test/artifact', headers: {} },
|
||||
},
|
||||
},
|
||||
}) as never
|
||||
);
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
|
||||
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
const error = await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
artifactId: 'artifact-id',
|
||||
name: 'Report',
|
||||
@@ -523,23 +508,16 @@ describe('without a device run session token', () => {
|
||||
kind: undefined,
|
||||
size: 4,
|
||||
openStream: () => Readable.from(Buffer.from('data')),
|
||||
});
|
||||
}).catch(err => err);
|
||||
|
||||
expect(ctx.graphqlClient.mutation).toHaveBeenCalled();
|
||||
expectMissingTokenError(error);
|
||||
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates the event log upload session with GraphQL', async () => {
|
||||
it('runs the session without persisting events', async () => {
|
||||
const ctx = createCtx({ withToken: false });
|
||||
jest.mocked(ctx.graphqlClient.mutation).mockReturnValueOnce(
|
||||
graphqlResult({
|
||||
deviceRunSession: {
|
||||
createEventLogUploadSession: {
|
||||
uploadSession: { url: 'https://uploads.expo.test/events', headers: {} },
|
||||
},
|
||||
},
|
||||
}) as never
|
||||
);
|
||||
|
||||
const collection = await startDeviceRunSessionEventCollectionAsync({
|
||||
ctx,
|
||||
@@ -549,24 +527,14 @@ describe('without a device run session token', () => {
|
||||
});
|
||||
await collection.stopAsync();
|
||||
|
||||
expect(ctx.graphqlClient.mutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
});
|
||||
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('creates the preview upload session with GraphQL', async () => {
|
||||
it('logs the failed preview upload session and uploads no preview', async () => {
|
||||
const ctx = createCtx({ withToken: false });
|
||||
jest.mocked(ctx.graphqlClient.mutation).mockReturnValueOnce(
|
||||
graphqlResult({
|
||||
deviceRunSession: {
|
||||
createPreviewUploadSession: {
|
||||
uploadSession: { url: 'https://uploads.expo.test/preview', headers: {} },
|
||||
},
|
||||
},
|
||||
}) as never
|
||||
);
|
||||
jest.mocked(fetch).mockResolvedValue(new Response('', { status: 200 }));
|
||||
|
||||
const preview = startDeviceRunSessionPreview({
|
||||
ctx,
|
||||
@@ -574,29 +542,25 @@ describe('without a device run session token', () => {
|
||||
captureAsync: async () => Buffer.from('webp'),
|
||||
logger,
|
||||
});
|
||||
await waitForCallAsync(jest.mocked(fetch));
|
||||
await waitForCallAsync(jest.mocked(logger.warn));
|
||||
await preview.stopAsync();
|
||||
|
||||
expect(ctx.graphqlClient.mutation).toHaveBeenCalled();
|
||||
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('loads the screenshot naming details with GraphQL', async () => {
|
||||
it('names screenshots with capture timestamps', async () => {
|
||||
const ctx = createCtx({ withToken: false });
|
||||
jest.mocked(ctx.graphqlClient.query).mockReturnValueOnce(
|
||||
graphqlResult({
|
||||
deviceRunSessions: {
|
||||
byId: { platform: 'ANDROID', app: { slug: 'app' }, build: null },
|
||||
},
|
||||
}) as never
|
||||
);
|
||||
|
||||
await expect(loadScreenshotSessionAsync(ctx, 'drs-id', logger)).resolves.toEqual({
|
||||
platform: 'ANDROID',
|
||||
app: { slug: 'app' },
|
||||
build: null,
|
||||
});
|
||||
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 () => {
|
||||
|
||||
@@ -12,7 +12,9 @@ import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { verifyLocalEgressGuardAsync } from '../localEgressGuard';
|
||||
import { type ServeSimApplicationOptions } from '../remoteDeviceRunSession';
|
||||
|
||||
import { readLocalEgressHandoffAsync } from '../localEgress';
|
||||
import { runServeSimActionAsync, stageServeSimAppAsync } from '../serveSimActions';
|
||||
import { startServeSimActivityPolling } from '../serveSimActivity';
|
||||
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
@@ -44,6 +46,11 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
}));
|
||||
jest.mock('../localEgressGuard', () => ({ verifyLocalEgressGuardAsync: jest.fn() }));
|
||||
jest.mock('../serveSimActions');
|
||||
jest.mock('../serveSimActivity');
|
||||
jest.mock('../localEgress', () => ({
|
||||
...jest.requireActual('../localEgress'),
|
||||
readLocalEgressHandoffAsync: jest.fn().mockResolvedValue(null),
|
||||
}));
|
||||
jest.mock('../deviceRunSessionArtifacts');
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../serveSimMetricsRecorder', () => ({
|
||||
@@ -129,6 +136,7 @@ beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(IosSimulatorUtils.disableApsdAsync).mockReset().mockResolvedValue(undefined);
|
||||
jest.mocked(verifyLocalEgressGuardAsync).mockReset().mockResolvedValue(undefined);
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockReset().mockResolvedValue(null);
|
||||
stopSessionPreview.mockResolvedValue(undefined);
|
||||
jest.mocked(startDeviceRunSessionPreview).mockReturnValue({ stopAsync: stopSessionPreview });
|
||||
jest.mocked(ensureFfmpegInstalledOnceAsync).mockResolvedValue(undefined);
|
||||
@@ -452,6 +460,62 @@ it('starts the readiness budget after refusals that exceed the normal startup de
|
||||
await rejected;
|
||||
});
|
||||
|
||||
it('reports preview activity from serve-sim on iOS and stops polling when finished', async () => {
|
||||
const observedAt = new Date('2026-10-19T12:00:00Z');
|
||||
const stopActivity = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(startServeSimActivityPolling).mockReturnValue({
|
||||
getLastActivityObservedAt: () => observedAt,
|
||||
stopAsync: stopActivity,
|
||||
});
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const port = Number(
|
||||
jest.mocked(spawnDetached).mock.calls[0][0].args[
|
||||
jest.mocked(spawnDetached).mock.calls[0][0].args.indexOf('--port') + 1
|
||||
]
|
||||
);
|
||||
|
||||
expect(startServeSimActivityPolling).toHaveBeenCalledWith({
|
||||
port,
|
||||
token: 'preview-token',
|
||||
logger: expect.anything(),
|
||||
});
|
||||
expect(host.getLastPreviewActivityAt()).toBe(observedAt);
|
||||
|
||||
await host.finishAsync();
|
||||
expect(stopActivity).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not start polling preview activity when startup is cancelled', async () => {
|
||||
const controller = new AbortController();
|
||||
jest.mocked(IosSimulatorUtils.disableApsdAsync).mockImplementation(async () => {
|
||||
controller.abort(new Error('cancelled'));
|
||||
});
|
||||
await expect(
|
||||
startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
iosSimulatorUdid: 'emulator-5554',
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(startServeSimActivityPolling).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports no preview activity on Android, where expo-device-hub keeps no event log', async () => {
|
||||
const host = await startHostAsync();
|
||||
|
||||
expect(startServeSimActivityPolling).not.toHaveBeenCalled();
|
||||
expect(host.getLastPreviewActivityAt()).toBeUndefined();
|
||||
await host.finishAsync();
|
||||
});
|
||||
|
||||
it('stops a host that became ready on a different Simulator', async () => {
|
||||
await expect(
|
||||
startDeviceSessionHostAsync(ctx, {
|
||||
@@ -874,6 +938,23 @@ it.each([
|
||||
await host.finishAsync();
|
||||
});
|
||||
|
||||
it('lets the session add frame ancestors and CORS origins after the website origins', async () => {
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env,
|
||||
logger,
|
||||
separateLogPhase: true,
|
||||
timeoutMs: 10_000,
|
||||
frameAncestors: ['https://embed.example.test', 'https://*.example.test'],
|
||||
corsOrigins: ['https://app.example.test'],
|
||||
});
|
||||
const { args } = jest.mocked(spawnDetached).mock.calls[0][0];
|
||||
expect(args.join(' ')).toContain(
|
||||
'--frame-ancestor https://expo.dev --frame-ancestor https://embed.example.test --frame-ancestor https://*.example.test --cors-origin https://app.example.test'
|
||||
);
|
||||
await host.finishAsync();
|
||||
});
|
||||
|
||||
it('stops when expo-device-hub records no session token', async () => {
|
||||
jest.mocked(spawnDetached).mockImplementationOnce(options => {
|
||||
const flag = options.args.indexOf('--android-recording-directory');
|
||||
@@ -1302,6 +1383,35 @@ it('uses the existing step logger for startup and shutdown output', async () =>
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('passes the active local-egress address to serve-sim without tunnel credentials', async () => {
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue({
|
||||
port: 8899,
|
||||
url: 'https://tunnel.example',
|
||||
token: 'tunnel-secret',
|
||||
fingerprint: 'fingerprint',
|
||||
});
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
networkCapture: true,
|
||||
});
|
||||
const spawned = jest.mocked(spawnDetached).mock.calls[0][0];
|
||||
expect(spawned.args).toEqual(
|
||||
expect.arrayContaining(['--network-capture-proxy', 'http://127.0.0.1:8899'])
|
||||
);
|
||||
expect(spawned.args).toContain('--network-capture');
|
||||
expect(JSON.stringify(spawned)).not.toContain('tunnel-secret');
|
||||
await host.finishAsync();
|
||||
});
|
||||
|
||||
it('does not read the iOS egress handoff for an Android host', async () => {
|
||||
const host = await startHostAsync();
|
||||
expect(readLocalEgressHandoffAsync).not.toHaveBeenCalled();
|
||||
await host.finishAsync();
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
'waits for setup before launch or boot-only readiness (app: %s)',
|
||||
async hasApp => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import * as processes from '../../../utils/processes';
|
||||
import { preparePreinstalledWdaAsync } from '../preinstalledWda';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
@@ -78,3 +79,37 @@ it('does not enable preinstalled startup when the download produces no runner',
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '' } as never);
|
||||
await expect(preparePreinstalledWdaAsync(options)).resolves.toEqual({});
|
||||
});
|
||||
|
||||
it('drains an aborted WDA downloader before removing its partial artifact', async () => {
|
||||
await fs.promises.mkdir(outdir, { recursive: true });
|
||||
const controller = new AbortController();
|
||||
const error = new Error('Simulator startup failed');
|
||||
const child = { pid: 12345, kill: jest.fn() };
|
||||
let closeDownloader!: (error: Error) => void;
|
||||
jest.mocked(spawn).mockReturnValueOnce(
|
||||
Object.assign(
|
||||
new Promise((_resolve, reject) => {
|
||||
closeDownloader = reject;
|
||||
}),
|
||||
{ child }
|
||||
) as never
|
||||
);
|
||||
const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
|
||||
const running = preparePreinstalledWdaAsync({ ...options, signal: controller.signal });
|
||||
const settled = jest.fn();
|
||||
const observed = running.then(settled, settled);
|
||||
try {
|
||||
controller.abort(error);
|
||||
expect(killGroup).toHaveBeenCalledWith(child, 'SIGKILL');
|
||||
expect(settled).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(outdir)).toBe(true);
|
||||
closeDownloader(new Error('download interrupted'));
|
||||
await expect(running).rejects.toBe(error);
|
||||
expect(fs.existsSync(outdir)).toBe(false);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
closeDownloader(error);
|
||||
await observed;
|
||||
killGroup.mockRestore();
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, BuildStepEnv, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
@@ -18,6 +18,7 @@ import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import * as processes from '../../../utils/processes';
|
||||
import {
|
||||
IosSimulatorRecordingUtils,
|
||||
SERVE_SIM_STOP_GRACE_PERIOD_MS,
|
||||
@@ -26,7 +27,10 @@ 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,
|
||||
@@ -34,6 +38,8 @@ import {
|
||||
describeServeSimLaunch,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
fetchWebPreviewTurnArgsAsync,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
parseServeSimLaunchInputs,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
@@ -44,8 +50,10 @@ import {
|
||||
|
||||
import {
|
||||
EXPO_DEVICE_HUB_STATE_DIR,
|
||||
corsOriginArgs,
|
||||
createExpoDeviceHubArgs,
|
||||
createServeSimArgs,
|
||||
frameAncestorArgs,
|
||||
simulatorPreviewPageUrl,
|
||||
startDeviceSessionHostAsync,
|
||||
waitForWebPreviewReadyAsync,
|
||||
@@ -148,73 +156,70 @@ describe(spawnDetached, () => {
|
||||
);
|
||||
});
|
||||
|
||||
function createCtxMock(): CustomBuildContext {
|
||||
const DEVICE_RUN_SESSION_TOKEN = 'device-run-session-token';
|
||||
const DEVICE_RUN_SESSION_WORKER_URL = 'https://api.expo.test/v2/device-run-session-worker/';
|
||||
|
||||
function createCtxMock({ withToken = true }: { withToken?: boolean } = {}): CustomBuildContext {
|
||||
return {
|
||||
env: {
|
||||
__API_SERVER_URL: 'https://api.expo.test',
|
||||
...(withToken ? { EAS_DEVICE_RUN_SESSION_TOKEN: DEVICE_RUN_SESSION_TOKEN } : {}),
|
||||
},
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
job: {
|
||||
secrets: { robotAccessToken: 'robot-token' },
|
||||
},
|
||||
} as unknown as CustomBuildContext;
|
||||
}
|
||||
|
||||
/** A successful device run session worker API response with the `{ data }` body. */
|
||||
function workerApiResponse(data: unknown): Awaited<ReturnType<typeof turtleFetch>> {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data }),
|
||||
} as unknown as Awaited<ReturnType<typeof turtleFetch>>;
|
||||
}
|
||||
|
||||
/** The number of device run session worker API requests to the route. */
|
||||
function workerApiCallCount(route: string): number {
|
||||
return jest
|
||||
.mocked(turtleFetch)
|
||||
.mock.calls.filter(([url]) => url === `${DEVICE_RUN_SESSION_WORKER_URL}${route}`).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves session status polls from `results` in order, and idle stops, through the device run
|
||||
* session worker API.
|
||||
*/
|
||||
function createStatusCtxMock(
|
||||
results: (
|
||||
| { status: 'NEW' | 'IN_PROGRESS' | 'STOPPED' | 'ERRORED' }
|
||||
| { status: 'new' | 'in-progress' | 'stopped' | 'errored' }
|
||||
| { error: Error }
|
||||
| { data: unknown }
|
||||
)[],
|
||||
{ ensureStoppedError }: { ensureStoppedError?: Error } = {}
|
||||
{ stopError }: { stopError?: Error } = {}
|
||||
): CustomBuildContext {
|
||||
const query = jest.fn(() => {
|
||||
jest.mocked(turtleFetch).mockImplementation(async url => {
|
||||
if (url === `${DEVICE_RUN_SESSION_WORKER_URL}stop`) {
|
||||
if (stopError) {
|
||||
throw stopError;
|
||||
}
|
||||
return workerApiResponse({ id: 'drs-id', status: 'stopped' });
|
||||
}
|
||||
const result = results.shift();
|
||||
if (!result) {
|
||||
throw new Error('No mocked status result available');
|
||||
}
|
||||
return {
|
||||
toPromise: async () => {
|
||||
if ('error' in result) {
|
||||
throw result.error;
|
||||
}
|
||||
if ('data' in result) {
|
||||
return { data: result.data };
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
deviceRunSessions: {
|
||||
byId: {
|
||||
id: 'drs-id',
|
||||
status: result.status,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
if ('error' in result) {
|
||||
throw result.error;
|
||||
}
|
||||
if ('data' in result) {
|
||||
return workerApiResponse(result.data);
|
||||
}
|
||||
return workerApiResponse({ id: 'drs-id', status: result.status });
|
||||
});
|
||||
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => {
|
||||
if (ensureStoppedError) {
|
||||
return { error: ensureStoppedError };
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
ensureDeviceRunSessionStopped: { id: 'drs-id', status: 'STOPPED' },
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
graphqlClient: {
|
||||
query,
|
||||
mutation,
|
||||
},
|
||||
} as unknown as CustomBuildContext;
|
||||
return createCtxMock();
|
||||
}
|
||||
|
||||
function createEnvMock(): BuildStepEnv {
|
||||
@@ -321,6 +326,88 @@ describe(createPrivatePreviewUrlInputProvider, () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 };
|
||||
|
||||
@@ -488,6 +575,26 @@ describe(createServeSimArgs, () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('appends the session frame ancestors after the website args', () => {
|
||||
const args = createServeSimArgs({
|
||||
port: 4321,
|
||||
websiteArgs: ['--frame-ancestor', 'https://expo.dev'],
|
||||
frameAncestorArgs: ['--frame-ancestor', 'https://embed.example.test'],
|
||||
corsOriginArgs: ['--cors-origin', 'https://app.example.test'],
|
||||
shareUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
});
|
||||
expect(args.slice(-8)).toEqual([
|
||||
'--frame-ancestor',
|
||||
'https://expo.dev',
|
||||
'--frame-ancestor',
|
||||
'https://embed.example.test',
|
||||
'--cors-origin',
|
||||
'https://app.example.test',
|
||||
'--share-url',
|
||||
'https://expo.dev/simulator-preview/preview-id',
|
||||
]);
|
||||
});
|
||||
|
||||
it('pins the requested package version', () => {
|
||||
expect(createServeSimArgs({ port: 4321, packageVersion: '0.1.38' })[0]).toBe(
|
||||
'@expo/[email protected]'
|
||||
@@ -554,6 +661,14 @@ describe(createServeSimArgs, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('passes a capture proxy as one argument, including when capture will be enabled later', () => {
|
||||
const proxy = 'http://user:p%40ss%[email protected]:8899';
|
||||
const args = createServeSimArgs({ port: 4321, networkCaptureProxy: proxy });
|
||||
expect(args.slice(-2)).toEqual(['--network-capture-proxy', proxy]);
|
||||
expect(args).not.toContain('--network-capture');
|
||||
expect(createServeSimArgs({ port: 4321 })).not.toContain('--network-capture-proxy');
|
||||
});
|
||||
|
||||
it('appends --network-capture when enabled, which also covers an already booted simulator', () => {
|
||||
const args = createServeSimArgs({ port: 4321, networkCapture: true });
|
||||
expect(args).toContain('--network-capture');
|
||||
@@ -685,6 +800,23 @@ describe(createExpoDeviceHubArgs, () => {
|
||||
]);
|
||||
});
|
||||
|
||||
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]'
|
||||
@@ -973,6 +1105,8 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
jest.mocked(turtleFetch).mockImplementation(async url => {
|
||||
if (url.endsWith('/turn-ice-servers')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
data: {
|
||||
iceServers: [
|
||||
@@ -1470,24 +1604,21 @@ describe(fetchWebPreviewTurnArgsAsync, () => {
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
it('requests TURN ICE servers from the device run session endpoint and returns web preview args', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValue({
|
||||
json: async () => ({
|
||||
data: {
|
||||
iceServers: [
|
||||
{ urls: ['stun:stun.cloudflare.com:3478'] },
|
||||
{
|
||||
urls: ['turns:turn.cloudflare.com:443?transport=tcp'],
|
||||
username: 'u',
|
||||
credential: 'c',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
} as unknown as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
it('requests TURN ICE servers from the device run session worker API and returns web preview args', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValue(
|
||||
workerApiResponse({
|
||||
iceServers: [
|
||||
{ urls: ['stun:stun.cloudflare.com:3478'] },
|
||||
{
|
||||
urls: ['turns:turn.cloudflare.com:443?transport=tcp'],
|
||||
username: 'u',
|
||||
credential: 'c',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), {
|
||||
env: createEnvMock(),
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
|
||||
@@ -1502,10 +1633,10 @@ describe(fetchWebPreviewTurnArgsAsync, () => {
|
||||
'c',
|
||||
]);
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/device-run-sessions/drs-id/turn-ice-servers',
|
||||
`${DEVICE_RUN_SESSION_WORKER_URL}turn-ice-servers`,
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer robot-token' },
|
||||
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -1514,15 +1645,33 @@ describe(fetchWebPreviewTurnArgsAsync, () => {
|
||||
jest.mocked(turtleFetch).mockRejectedValue(new Error('boom'));
|
||||
const logger = createLoggerMock();
|
||||
|
||||
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), {
|
||||
env: createEnvMock(),
|
||||
logger,
|
||||
});
|
||||
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), { logger });
|
||||
|
||||
expect(args).toEqual([]);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(jest.mocked(Sentry).capture).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns [] and warns when the job has no device run session token', async () => {
|
||||
const logger = createLoggerMock();
|
||||
|
||||
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock({ withToken: false }), {
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(args).toEqual([]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{
|
||||
err: expect.objectContaining({
|
||||
message: expect.stringMatching(
|
||||
/^This device run session job has no EAS_DEVICE_RUN_SESSION_TOKEN secret\./
|
||||
),
|
||||
}),
|
||||
},
|
||||
'Could not fetch Cloudflare TURN ICE servers; the web preview will fall back to P2P/STUN.'
|
||||
);
|
||||
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
@@ -1538,7 +1687,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({
|
||||
@@ -1547,13 +1696,59 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(2);
|
||||
expect(workerApiCallCount('session')).toBe(2);
|
||||
expect(logger.info).toHaveBeenCalledWith('Device run session drs-id was stopped.');
|
||||
expect(setTimeoutCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('polls the session status through the worker API with the abort signal', async () => {
|
||||
const ctx = createStatusCtxMock([{ status: 'stopped' }]);
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
|
||||
`${DEVICE_RUN_SESSION_WORKER_URL}session`,
|
||||
'GET',
|
||||
expect.objectContaining({
|
||||
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
|
||||
signal: expect.any(AbortSignal),
|
||||
timeout: 30_000,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps polling while the session status is new', async () => {
|
||||
const ctx = createStatusCtxMock([{ status: 'new' }, { status: 'stopped' }]);
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
|
||||
expect(workerApiCallCount('session')).toBe(2);
|
||||
});
|
||||
|
||||
it('fails with a system error when the job has no device run session token', async () => {
|
||||
const error = await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx: createCtxMock({ withToken: false }),
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger: createLoggerMock(),
|
||||
}).catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(SystemError);
|
||||
expect(error.message).toMatch(
|
||||
/^This device run session job has no EAS_DEVICE_RUN_SESSION_TOKEN secret\./
|
||||
);
|
||||
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns normally when the maximum duration elapses', async () => {
|
||||
const ctx = createStatusCtxMock([{ status: 'IN_PROGRESS' }]);
|
||||
const ctx = createStatusCtxMock([{ status: 'in-progress' }]);
|
||||
const logger = createLoggerMock();
|
||||
const waitPromise = waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
@@ -1567,7 +1762,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
durationTimeoutCallback();
|
||||
await waitPromise;
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(1);
|
||||
expect(workerApiCallCount('session')).toBe(1);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Device run session drs-id reached its maximum duration.'
|
||||
);
|
||||
@@ -1576,7 +1771,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,
|
||||
@@ -1598,12 +1793,12 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
|
||||
expect(workerApiCallCount('session')).toBe(0);
|
||||
expect(setTimeoutCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the device run session errors', async () => {
|
||||
const ctx = createStatusCtxMock([{ status: 'ERRORED' }]);
|
||||
const ctx = createStatusCtxMock([{ status: 'errored' }]);
|
||||
|
||||
await expect(
|
||||
waitForDeviceRunSessionStoppedAsync({
|
||||
@@ -1617,7 +1812,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,
|
||||
@@ -1630,12 +1825,12 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
abortController.abort();
|
||||
await waitPromise;
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(1);
|
||||
expect(workerApiCallCount('session')).toBe(1);
|
||||
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
|
||||
});
|
||||
|
||||
it('logs and retries transient polling errors', async () => {
|
||||
const ctx = createStatusCtxMock([{ error: new Error('network down') }, { status: 'STOPPED' }]);
|
||||
const ctx = createStatusCtxMock([{ error: new Error('network down') }, { status: 'stopped' }]);
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
@@ -1644,7 +1839,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(2);
|
||||
expect(workerApiCallCount('session')).toBe(2);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ failedStatusPollCount: 1 }),
|
||||
'Could not poll device run session status; will retry.'
|
||||
@@ -1656,11 +1851,8 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('logs and retries when the status response is missing', async () => {
|
||||
const ctx = createStatusCtxMock([
|
||||
{ data: { deviceRunSessions: { byId: null } } },
|
||||
{ status: 'STOPPED' },
|
||||
]);
|
||||
it('logs and retries when the status response does not match the route contract', async () => {
|
||||
const ctx = createStatusCtxMock([{ data: { id: 'drs-id' } }, { status: 'stopped' }]);
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
@@ -1669,7 +1861,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(2);
|
||||
expect(workerApiCallCount('session')).toBe(2);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ failedStatusPollCount: 1 }),
|
||||
'Could not poll device run session status; will retry.'
|
||||
@@ -1677,7 +1869,9 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
expect(jest.mocked(Sentry).capture).toHaveBeenCalledWith(
|
||||
'Could not poll device run session status',
|
||||
expect.objectContaining({
|
||||
message: 'Device run session drs-id status response was missing.',
|
||||
message: expect.stringContaining(
|
||||
'GET /v2/device-run-session-worker/session returned an unexpected response'
|
||||
),
|
||||
}),
|
||||
{ level: 'warning' }
|
||||
);
|
||||
@@ -1698,8 +1892,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 () => {
|
||||
@@ -1717,15 +1911,15 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
});
|
||||
|
||||
// One minute at the 5-second poll interval is 12 status polls.
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(12);
|
||||
expect(ctx.graphqlClient.mutation).toHaveBeenCalledTimes(1);
|
||||
expect(workerApiCallCount('session')).toBe(12);
|
||||
expect(workerApiCallCount('stop')).toBe(1);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Device run session drs-id had no activity for 1 minute(s) (max idle time). Stopping the session.'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the session alive while events keep arriving', async () => {
|
||||
const ctx = createStatusCtxMock([...manyInProgressStatuses(), { status: 'STOPPED' }]);
|
||||
const ctx = createStatusCtxMock([...manyInProgressStatuses(), { status: 'stopped' }]);
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
@@ -1740,14 +1934,55 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(21);
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
expect(workerApiCallCount('session')).toBe(21);
|
||||
expect(workerApiCallCount('stop')).toBe(0);
|
||||
expect(logger.info).toHaveBeenCalledWith('Device run session drs-id was stopped.');
|
||||
});
|
||||
|
||||
it('keeps the session alive while preview input keeps arriving', async () => {
|
||||
const ctx = createStatusCtxMock([...manyInProgressStatuses(), { status: 'stopped' }]);
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger,
|
||||
idleTimeout: {
|
||||
maxIdleTimeMinutes: 1,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
getLastPreviewActivityAt: () => new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(workerApiCallCount('session')).toBe(21);
|
||||
expect(workerApiCallCount('stop')).toBe(0);
|
||||
});
|
||||
|
||||
it('counts idle time from the later of automation and preview activity', async () => {
|
||||
const ctx = createStatusCtxMock(manyInProgressStatuses());
|
||||
const logger = createLoggerMock();
|
||||
const previewInputAt = new Date(Date.now() + 30_000);
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger,
|
||||
idleTimeout: {
|
||||
maxIdleTimeMinutes: 1,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
getLastPreviewActivityAt: () =>
|
||||
Date.now() >= previewInputAt.getTime() ? previewInputAt : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Idle from 30 s, so the stop comes at 90 s: 18 polls.
|
||||
expect(workerApiCallCount('session')).toBe(18);
|
||||
expect(workerApiCallCount('stop')).toBe(1);
|
||||
});
|
||||
|
||||
it('still returns when the session cannot be marked stopped', async () => {
|
||||
const ctx = createStatusCtxMock(manyInProgressStatuses(), {
|
||||
ensureStoppedError: new Error('forbidden'),
|
||||
stopError: new Error('forbidden'),
|
||||
});
|
||||
const logger = createLoggerMock();
|
||||
|
||||
@@ -1761,7 +1996,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(ctx.graphqlClient.mutation).toHaveBeenCalledTimes(1);
|
||||
expect(workerApiCallCount('stop')).toBe(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
'Could not mark device run session drs-id as stopped. The session job ends anyway.'
|
||||
@@ -1950,6 +2185,105 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
expect(spawnMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('retries one aborted FFmpeg install for two surviving shared callers after it drains', async () => {
|
||||
const controller = new AbortController();
|
||||
const error = new Error('Simulator startup failed');
|
||||
const child = { pid: 12345, kill: jest.fn() };
|
||||
let closeInstaller!: (error: Error) => void;
|
||||
spawnMock.mockReturnValueOnce(
|
||||
Object.assign(
|
||||
new Promise((_resolve, reject) => {
|
||||
closeInstaller = reject;
|
||||
}),
|
||||
{ child }
|
||||
) as never
|
||||
);
|
||||
let finishReplacement!: () => void;
|
||||
spawnMock.mockReturnValueOnce(
|
||||
new Promise<void>(resolve => {
|
||||
finishReplacement = resolve;
|
||||
}) as never
|
||||
);
|
||||
spawnMock.mockReturnValue(spawnResolved());
|
||||
const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
|
||||
const options = {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createPathEnv(),
|
||||
logger: createLoggerMock(),
|
||||
};
|
||||
const owner = ensureFfmpegInstalledOnceAsync({ ...options, signal: controller.signal });
|
||||
const surviving = [
|
||||
ensureFfmpegInstalledOnceAsync(options),
|
||||
ensureFfmpegInstalledOnceAsync({ ...options, signal: new AbortController().signal }),
|
||||
];
|
||||
const settled = jest.fn();
|
||||
const observed = owner.then(settled, settled);
|
||||
const borrowersSettled = jest.fn();
|
||||
const observedBorrowers = Promise.all(surviving).then(borrowersSettled, borrowersSettled);
|
||||
try {
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
controller.abort(error);
|
||||
expect(killGroup).toHaveBeenCalledWith(child, 'SIGKILL');
|
||||
expect(settled).not.toHaveBeenCalled();
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
closeInstaller(new Error('brew interrupted'));
|
||||
await expect(owner).rejects.toBe(error);
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2);
|
||||
expect(borrowersSettled).not.toHaveBeenCalled();
|
||||
finishReplacement();
|
||||
await expect(Promise.all(surviving)).resolves.toEqual([undefined, undefined]);
|
||||
expect(spawnMock).toHaveBeenCalledTimes(3);
|
||||
expect(spawnMock).toHaveBeenLastCalledWith(...warmUpCall);
|
||||
expect(options.logger.warn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
closeInstaller?.(error);
|
||||
finishReplacement();
|
||||
await Promise.all([observed, observedBorrowers]);
|
||||
killGroup.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('stops an aborted shared waiter without cancelling the installer owner', async () => {
|
||||
let finishInstall!: () => void;
|
||||
spawnMock.mockReturnValueOnce(
|
||||
new Promise<void>(resolve => {
|
||||
finishInstall = resolve;
|
||||
}) as never
|
||||
);
|
||||
spawnMock.mockReturnValue(spawnResolved());
|
||||
const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
|
||||
const controller = new AbortController();
|
||||
const error = new Error('Appium startup failed');
|
||||
const options = {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createPathEnv(),
|
||||
logger: createLoggerMock(),
|
||||
};
|
||||
const owner = ensureFfmpegInstalledOnceAsync(options);
|
||||
let ownerFinished = false;
|
||||
const observedOwner = owner.then(() => {
|
||||
ownerFinished = true;
|
||||
});
|
||||
const waiter = ensureFfmpegInstalledOnceAsync({ ...options, signal: controller.signal });
|
||||
const observedWaiter = waiter.catch(() => {});
|
||||
try {
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
controller.abort(error);
|
||||
await expect(waiter).rejects.toBe(error);
|
||||
expect(ownerFinished).toBe(false);
|
||||
expect(killGroup).not.toHaveBeenCalled();
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
finishInstall();
|
||||
await expect(owner).resolves.toBeUndefined();
|
||||
expect(spawnMock).toHaveBeenLastCalledWith(...warmUpCall);
|
||||
} finally {
|
||||
finishInstall();
|
||||
await Promise.all([observedOwner, observedWaiter]);
|
||||
killGroup.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('still installs on linux when the apt index refresh fails', async () => {
|
||||
spawnMock
|
||||
.mockReturnValueOnce(spawnRejected()) // apt-get update
|
||||
@@ -1987,6 +2321,30 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
expect(jest.mocked(Sentry).capture).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not retry an ordinary shared FFmpeg install failure', async () => {
|
||||
let failInstall!: (error: Error) => void;
|
||||
spawnMock.mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failInstall = reject;
|
||||
}) as never
|
||||
);
|
||||
const options = {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createPathEnv(),
|
||||
logger: createLoggerMock(),
|
||||
};
|
||||
const callers = [
|
||||
ensureFfmpegInstalledOnceAsync(options),
|
||||
ensureFfmpegInstalledOnceAsync(options),
|
||||
ensureFfmpegInstalledOnceAsync({ ...options, signal: new AbortController().signal }),
|
||||
];
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
failInstall(new Error('Homebrew unavailable'));
|
||||
await expect(Promise.all(callers)).resolves.toEqual([undefined, undefined, undefined]);
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
expect(options.logger.warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// The caller runs this with `void` and the worker installs no unhandledRejection
|
||||
// handler, so a rejection here would crash the process. `spawn` is not async and
|
||||
// can throw synchronously, which `asyncResult` cannot catch.
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { once } from 'node:events';
|
||||
import http from 'node:http';
|
||||
|
||||
import { fetchLatestServeSimEventIdAsync, startServeSimActivityPolling } from '../serveSimActivity';
|
||||
|
||||
const token = 'test-session-token';
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
let events: { id: number }[];
|
||||
let status: number;
|
||||
let requests: { url?: string; authorization?: string }[];
|
||||
let rawBody: string | undefined;
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
events = [];
|
||||
status = 200;
|
||||
requests = [];
|
||||
rawBody = undefined;
|
||||
server = http.createServer((req, res) => {
|
||||
requests.push({ url: req.url, authorization: req.headers.authorization });
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(rawBody ?? JSON.stringify({ events: events.slice(-1) }));
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
port = (server.address() as { port: number }).port;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
describe(fetchLatestServeSimEventIdAsync, () => {
|
||||
it('reads the newest event ID with the session token', async () => {
|
||||
events = [{ id: 3 }, { id: 7 }];
|
||||
await expect(fetchLatestServeSimEventIdAsync({ port, token })).resolves.toBe(7);
|
||||
expect(requests).toEqual([{ url: '/api/event-log?limit=1', authorization: `Bearer ${token}` }]);
|
||||
});
|
||||
|
||||
it('returns undefined when serve-sim has logged no event', async () => {
|
||||
await expect(fetchLatestServeSimEventIdAsync({ port, token })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unparseable JSON', 'not json'],
|
||||
['a body without an events array', '{}'],
|
||||
['an entry without a numeric ID', '{"events":[{"id":"7"}]}'],
|
||||
])('rejects %s', async (_, body) => {
|
||||
rawBody = body;
|
||||
await expect(fetchLatestServeSimEventIdAsync({ port, token })).rejects.toThrow(
|
||||
'serve-sim event log'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a refused request', async () => {
|
||||
status = 401;
|
||||
await expect(fetchLatestServeSimEventIdAsync({ port, token })).rejects.toThrow('401');
|
||||
});
|
||||
});
|
||||
|
||||
describe(startServeSimActivityPolling, () => {
|
||||
async function waitForRequestsAsync(count: number): Promise<void> {
|
||||
while (requests.length < count) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
}
|
||||
|
||||
it('reports activity only when a new event appears', async () => {
|
||||
const polling = startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
expect(polling.getLastActivityObservedAt()).toBeUndefined();
|
||||
|
||||
events = [{ id: 1 }];
|
||||
const before = Date.now();
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
const firstSeen = polling.getLastActivityObservedAt();
|
||||
expect(firstSeen?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBe(firstSeen);
|
||||
|
||||
events = [{ id: 1 }, { id: 2 }];
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).not.toBe(firstSeen);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports current activity while the event log fails, and warns once', async () => {
|
||||
status = 500;
|
||||
const logger = createLoggerMock();
|
||||
const polling = startServeSimActivityPolling({ port, token, logger, pollIntervalMs: 10 });
|
||||
try {
|
||||
await waitForRequestsAsync(3);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
const before = Date.now();
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
|
||||
status = 200;
|
||||
events = [{ id: 4 }];
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
const observedAt = polling.getLastActivityObservedAt();
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBe(observedAt);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the last observation while serve-sim refuses connections', async () => {
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
server = http.createServer();
|
||||
const polling = startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
expect(polling.getLastActivityObservedAt()).toBeUndefined();
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports current activity when serve-sim drops the connection mid-response', async () => {
|
||||
server.removeAllListeners('request');
|
||||
server.on('request', (req, res) => {
|
||||
requests.push({ url: req.url });
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': '100' });
|
||||
res.write('{"events":');
|
||||
setImmediate(() => res.socket?.destroy());
|
||||
});
|
||||
const polling = startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(3);
|
||||
const before = Date.now();
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports current activity while serve-sim answers without an event log', async () => {
|
||||
status = 404;
|
||||
const polling = startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
const before = Date.now();
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
|
||||
status = 200;
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBeUndefined();
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('stops polling after stopAsync', async () => {
|
||||
const polling = startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
await waitForRequestsAsync(1);
|
||||
await polling.stopAsync();
|
||||
const count = requests.length;
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
expect(requests.length).toBe(count);
|
||||
});
|
||||
});
|
||||
@@ -6,11 +6,13 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
import { uploadServeSimLogsFileAsync } from '../serveSimLogsArtifacts';
|
||||
import { streamServeSimLogsToFileAsync } from '../serveSimLogsRecorder';
|
||||
|
||||
// This test exercises actual sockets, UTF-8 decoding, files, and PUT uploads.
|
||||
// Only WWW's allocation response is substituted; it does not verify cloud storage.
|
||||
// WWW's device run session worker API is served by the local server; it does not verify cloud
|
||||
// storage.
|
||||
jest.mock('../../../sentry');
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
@@ -55,7 +57,29 @@ it('streams selected-device logs into NDJSON and uploads the exact bytes as a se
|
||||
let streamRequest: { url?: string; authorization?: string } | undefined;
|
||||
let uploaded: Buffer | undefined;
|
||||
let uploadedLength: string | undefined;
|
||||
let uploadSessionRequest: { token?: string | string[]; body: unknown } | undefined;
|
||||
server.on('request', (request, response) => {
|
||||
if (request.url === '/v2/device-run-session-worker/artifact-upload-sessions') {
|
||||
const chunks: Buffer[] = [];
|
||||
request.on('data', chunk => chunks.push(Buffer.from(chunk)));
|
||||
request.on('end', () => {
|
||||
uploadSessionRequest = {
|
||||
token: request.headers[DEVICE_RUN_SESSION_TOKEN_HEADER],
|
||||
body: JSON.parse(Buffer.concat(chunks).toString('utf8')),
|
||||
};
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' }).end(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
uploadSession: {
|
||||
url: `${baseUrl}/upload`,
|
||||
headers: { 'Content-Length': String(expected.length) },
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method === 'GET') {
|
||||
streamRequest = { url: request.url, authorization: request.headers.authorization };
|
||||
response.writeHead(200, {
|
||||
@@ -95,27 +119,16 @@ it('streams selected-device logs into NDJSON and uploads the exact bytes as a se
|
||||
});
|
||||
expect(await readFile(filePath)).toEqual(expected);
|
||||
|
||||
const mutation = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: {
|
||||
url: `${baseUrl}/upload`,
|
||||
headers: { 'Content-Length': String(expected.length) },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
await uploadServeSimLogsFileAsync(
|
||||
{ graphqlClient: { mutation } } as unknown as CustomBuildContext,
|
||||
{
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'device-run-session-token' },
|
||||
expoApiV2BaseUrl: `${baseUrl}/v2/`,
|
||||
} as unknown as CustomBuildContext,
|
||||
{ deviceRunSessionId: 'session-test', udid: 'device-B', filePath, logger }
|
||||
);
|
||||
expect(mutation.mock.calls[0][1]).toEqual({
|
||||
deviceRunSessionId: 'session-test',
|
||||
input: {
|
||||
expect(uploadSessionRequest).toEqual({
|
||||
token: 'device-run-session-token',
|
||||
body: {
|
||||
name: 'App logs (device-B)',
|
||||
filename: 'app-logs.ndjson',
|
||||
kind: 'simulator-log',
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ErrorMetadata } from '@expo/eas-build-job';
|
||||
import { Response } from 'node-fetch';
|
||||
import { z } from 'zod';
|
||||
|
||||
const MAX_RESPONSE_BODY_LENGTH = 8_000;
|
||||
|
||||
export class GooglePlayApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly apiMessage: string,
|
||||
readonly reasons: string[],
|
||||
// Kept for Sentry only, so build logs stay short.
|
||||
readonly diagnostics: { requestPath?: string; responseBody?: string } = {}
|
||||
) {
|
||||
super(`Google Play request failed (HTTP ${status})${apiMessage ? `: ${apiMessage}` : '.'}`);
|
||||
}
|
||||
|
||||
static isRetryableStatus(status: number): boolean {
|
||||
return status === 408 || status === 429 || (status >= 500 && status < 600);
|
||||
}
|
||||
|
||||
// Error metadata for the first Google Play API error in the cause chain. Retries and
|
||||
// uncertain commits wrap the API error, e.g. in an AggregateError.
|
||||
static getErrorMetadata(error: unknown): ErrorMetadata | undefined {
|
||||
let cause = error;
|
||||
for (let depth = 0; cause instanceof Error && depth < 5; depth++) {
|
||||
if (cause instanceof GooglePlayApiError) {
|
||||
return {
|
||||
googlePlayRequestPath: cause.diagnostics.requestPath,
|
||||
// A string, so Sentry does not cut off nested error details.
|
||||
googlePlayErrorResponse: cause.diagnostics.responseBody,
|
||||
};
|
||||
}
|
||||
cause = cause.cause;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
static async fromResponseAsync(
|
||||
response: Response,
|
||||
signal?: AbortSignal
|
||||
): Promise<GooglePlayApiError> {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
signal?.throwIfAborted();
|
||||
// Raw text or HTML can contain the request URL, including an upload secret.
|
||||
}
|
||||
const parsed = z
|
||||
.object({
|
||||
error: z.object({
|
||||
message: z.string().optional(),
|
||||
errors: z.array(z.object({ reason: z.string().optional() })).optional(),
|
||||
}),
|
||||
})
|
||||
.safeParse(data);
|
||||
return new GooglePlayApiError(
|
||||
response.status,
|
||||
parsed.data?.error.message ?? '',
|
||||
parsed.data?.error.errors?.flatMap(error => (error.reason ? [error.reason] : [])) ?? [],
|
||||
{
|
||||
// Path only: the upload session query contains a secret.
|
||||
requestPath: response.url ? new URL(response.url).pathname : undefined,
|
||||
responseBody:
|
||||
data === undefined ? undefined : JSON.stringify(data).slice(0, MAX_RESPONSE_BODY_LENGTH),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { z } from 'zod';
|
||||
|
||||
import { promiseRetryWithCondition } from '../../../utils/promiseRetryWithCondition';
|
||||
|
||||
import { GooglePlayApiError } from './GooglePlayApiError';
|
||||
const PostApi = {
|
||||
'/token': {
|
||||
request: z.object({
|
||||
@@ -61,7 +62,7 @@ export class GooglePlayAuthClient {
|
||||
error =>
|
||||
!signal?.aborted &&
|
||||
error instanceof GooglePlayAuthRequestError &&
|
||||
(error.status === 429 || error.status >= 500),
|
||||
GooglePlayApiError.isRetryableStatus(error.status),
|
||||
{ retries: 3, factor: 2, minTimeout: 1000, maxTimeout: 4000 }
|
||||
)();
|
||||
}
|
||||
|
||||
@@ -1,45 +1,13 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import fetch, { RequestInit, Response } from 'node-fetch';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { promiseRetryWithCondition } from '../../../utils/promiseRetryWithCondition';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isConnectFailure, isConnectionInterruptedError } from '../../../utils/networkErrors';
|
||||
import { GooglePlayApiError } from './GooglePlayApiError';
|
||||
import { GooglePlayAuthUtils, GoogleServiceAccount } from './GooglePlayAuthUtils';
|
||||
|
||||
export class GooglePlayApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly apiMessage: string,
|
||||
readonly reasons: string[]
|
||||
) {
|
||||
super(`Google Play request failed (HTTP ${status})${apiMessage ? `: ${apiMessage}` : '.'}`);
|
||||
}
|
||||
|
||||
static async fromResponseAsync(
|
||||
response: Response,
|
||||
signal?: AbortSignal
|
||||
): Promise<GooglePlayApiError> {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
signal?.throwIfAborted();
|
||||
// Raw text or HTML can contain the request URL, including an upload secret.
|
||||
}
|
||||
const parsed = z
|
||||
.object({
|
||||
error: z.object({
|
||||
message: z.string().optional(),
|
||||
errors: z.array(z.object({ reason: z.string().optional() })).optional(),
|
||||
}),
|
||||
})
|
||||
.safeParse(data);
|
||||
return new GooglePlayApiError(
|
||||
response.status,
|
||||
parsed.data?.error.message ?? '',
|
||||
parsed.data?.error.errors?.flatMap(error => (error.reason ? [error.reason] : [])) ?? []
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const ReleaseStatusZ = z.enum(['draft', 'inProgress', 'halted', 'completed']);
|
||||
export type ReleaseStatus = z.output<typeof ReleaseStatusZ>;
|
||||
|
||||
@@ -88,7 +56,9 @@ const PostApi = {
|
||||
'/androidpublisher/v3/applications/:packageName/edits/:editId:commit': {
|
||||
path: z.object({ packageName: z.string().min(1), editId: z.string().min(1) }),
|
||||
request: z.object({}),
|
||||
query: z.object({ changesNotSentForReview: z.boolean().optional() }),
|
||||
query: z.object({
|
||||
changesNotSentForReview: z.boolean().optional(),
|
||||
}),
|
||||
response: z.object({ id: z.string().min(1), expiryTimeSeconds: z.string().optional() }),
|
||||
},
|
||||
'/androidpublisher/v3/applications/:packageName/edits/:editId:validate': {
|
||||
@@ -128,7 +98,10 @@ export class GooglePlayClient {
|
||||
private readonly baseUrl = 'https://androidpublisher.googleapis.com';
|
||||
private cachedToken?: Promise<{ value: string; expiresAt: number }>;
|
||||
|
||||
constructor(private readonly serviceAccount: GoogleServiceAccount) {}
|
||||
constructor(
|
||||
private readonly serviceAccount: GoogleServiceAccount,
|
||||
private readonly logger?: bunyan
|
||||
) {}
|
||||
|
||||
private async getTokenAsync(signal?: AbortSignal): Promise<string> {
|
||||
signal?.throwIfAborted();
|
||||
@@ -204,7 +177,21 @@ export class GooglePlayClient {
|
||||
for (const [key, value] of Object.entries(DeleteApi[path].path.parse(params))) {
|
||||
url = url.replace(`:${key}`, encodeURIComponent(value));
|
||||
}
|
||||
await this.requestAsync('DELETE', url, undefined, signal);
|
||||
try {
|
||||
await this.requestWithRetriesAsync(
|
||||
() => this.requestAsync('DELETE', url, undefined, signal),
|
||||
signal
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof AggregateError &&
|
||||
error.cause instanceof GooglePlayApiError &&
|
||||
error.cause.status === 404
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async sendJsonRequestAsync(
|
||||
@@ -236,12 +223,16 @@ export class GooglePlayClient {
|
||||
}
|
||||
}
|
||||
|
||||
const response = await this.requestAsync(
|
||||
method,
|
||||
search.size ? `${path}?${search}` : path,
|
||||
method === 'GET' ? undefined : JSON.stringify(parsedBody),
|
||||
signal,
|
||||
{ 'Content-Type': 'application/json' }
|
||||
const response = await this.requestWithRetriesAsync(
|
||||
() =>
|
||||
this.requestAsync(
|
||||
method,
|
||||
search.size ? `${path}?${search}` : path,
|
||||
method === 'GET' ? undefined : JSON.stringify(parsedBody),
|
||||
signal,
|
||||
{ 'Content-Type': 'application/json' }
|
||||
),
|
||||
signal
|
||||
);
|
||||
|
||||
let data: unknown;
|
||||
@@ -264,6 +255,47 @@ export class GooglePlayClient {
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
private async requestWithRetriesAsync<T>(
|
||||
request: () => Promise<T>,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
const errors: unknown[] = [];
|
||||
try {
|
||||
return await promiseRetryWithCondition(
|
||||
async () => {
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
return await request();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
error =>
|
||||
!signal?.aborted &&
|
||||
(error instanceof GooglePlayApiError
|
||||
? GooglePlayApiError.isRetryableStatus(error.status)
|
||||
: isConnectFailure(error) || isConnectionInterruptedError(error)),
|
||||
{ retries: 5, factor: 2, minTimeout: 1000, maxTimeout: 32_000, randomize: true },
|
||||
({ attemptNumber, maxAttemptsCount, error }) => {
|
||||
const reason =
|
||||
error instanceof GooglePlayApiError ? `HTTP ${error.status}` : 'network error';
|
||||
this.logger?.warn(
|
||||
`Retrying Google Play request (${reason}, attempt ${attemptNumber}/${maxAttemptsCount})...`
|
||||
);
|
||||
}
|
||||
)();
|
||||
} catch (error) {
|
||||
// Keep earlier failures: a later rejection does not prove that every attempt failed.
|
||||
if (errors.length > 1) {
|
||||
throw new AggregateError(errors, 'Google Play request failed after retries.', {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async requestAsync(
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||
path: string,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import fetch, { FetchError, Response } from 'node-fetch';
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
import promiseRetry from 'promise-retry';
|
||||
import { FileHandle } from 'node:fs/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { GooglePlayApiError, GooglePlayClient } from './GooglePlayClient';
|
||||
import { isConnectFailure, isConnectionInterruptedError } from '../../../utils/networkErrors';
|
||||
import { GooglePlayApiError } from './GooglePlayApiError';
|
||||
import { GooglePlayClient } from './GooglePlayClient';
|
||||
import { AndroidArtifactType } from './appArtifact';
|
||||
|
||||
const UploadResponseZ = z.object({
|
||||
@@ -53,8 +55,10 @@ export namespace GooglePlayResumableUpload {
|
||||
signal?.throwIfAborted();
|
||||
if (
|
||||
timeout.aborted ||
|
||||
isNetworkRetryable(error) ||
|
||||
(error instanceof GooglePlayApiError && (error.status === 429 || error.status >= 500))
|
||||
isConnectFailure(error) ||
|
||||
isConnectionInterruptedError(error) ||
|
||||
(error instanceof GooglePlayApiError &&
|
||||
GooglePlayApiError.isRetryableStatus(error.status))
|
||||
) {
|
||||
retry(
|
||||
timeout.aborted
|
||||
@@ -187,7 +191,7 @@ export namespace GooglePlayResumableUpload {
|
||||
const failure = new SystemError(
|
||||
'Google Play upload failed before a response was received.'
|
||||
);
|
||||
if (timeout.aborted || isNetworkRetryable(error)) {
|
||||
if (timeout.aborted || isConnectFailure(error) || isConnectionInterruptedError(error)) {
|
||||
checkServerOffset = true;
|
||||
retry(failure);
|
||||
}
|
||||
@@ -200,7 +204,7 @@ export namespace GooglePlayResumableUpload {
|
||||
checkServerOffset = true;
|
||||
return retry(new SystemError('Google Play upload response timed out.'));
|
||||
}
|
||||
if (response.status === 429 || response.status >= 500) {
|
||||
if (GooglePlayApiError.isRetryableStatus(response.status)) {
|
||||
checkServerOffset = true;
|
||||
retry(error);
|
||||
}
|
||||
@@ -276,15 +280,3 @@ function getContentTypeFromArtifactType(artifactType: AndroidArtifactType): stri
|
||||
? 'application/vnd.android.package-archive'
|
||||
: 'application/octet-stream';
|
||||
}
|
||||
|
||||
function isNetworkRetryable(error: unknown): boolean {
|
||||
const visited = new Set<Error>();
|
||||
while (error instanceof Error && !visited.has(error)) {
|
||||
if (error instanceof FetchError && error.type === 'system') {
|
||||
return true;
|
||||
}
|
||||
visited.add(error);
|
||||
error = error.cause;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -3,8 +3,11 @@ import { bunyan } from '@expo/logger';
|
||||
import fs from 'node:fs/promises';
|
||||
import promiseRetry from 'promise-retry';
|
||||
|
||||
import { promiseRetryWithCondition } from '../../../utils/promiseRetryWithCondition';
|
||||
|
||||
import { GooglePlayApiError } from './GooglePlayApiError';
|
||||
import { GooglePlayAuthRequestError } from './GooglePlayAuthClient';
|
||||
import { GooglePlayApiError, GooglePlayClient, ReleaseStatus } from './GooglePlayClient';
|
||||
import { GooglePlayClient, ReleaseStatus } from './GooglePlayClient';
|
||||
import { GooglePlayResumableUpload } from './GooglePlayResumableUpload';
|
||||
import { AndroidArtifactType } from './appArtifact';
|
||||
|
||||
@@ -141,7 +144,7 @@ export namespace GooglePlayUtils {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<void> {
|
||||
const commitOnceAsync = async ({
|
||||
const sendCommitAsync = async ({
|
||||
changesNotSentForReview,
|
||||
}: {
|
||||
changesNotSentForReview?: boolean;
|
||||
@@ -163,13 +166,11 @@ export namespace GooglePlayUtils {
|
||||
}
|
||||
if (
|
||||
!(error instanceof GooglePlayApiError) ||
|
||||
error.status >= 500 ||
|
||||
error.status === 408 ||
|
||||
error.status === 429
|
||||
GooglePlayApiError.isRetryableStatus(error.status)
|
||||
) {
|
||||
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. The request was not retried because it may have succeeded.`,
|
||||
{ cause: error }
|
||||
`Could not confirm whether Google Play saved the release for ${packageName}. Check its release status at https://play.google.com/console/ before submitting again.`,
|
||||
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
@@ -180,10 +181,42 @@ export namespace GooglePlayUtils {
|
||||
: '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: 13, 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} within five minutes. Wait a few minutes, then check the release at https://play.google.com/console/ before submitting again.`,
|
||||
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
try {
|
||||
// Omit the optional parameter for normal review submission. Some app states
|
||||
// reject its presence, even when false. True saves changes for review in Console.
|
||||
await commitOnceAsync({ changesNotSentForReview: changesNotSentForReview || undefined });
|
||||
await commitAsync({ changesNotSentForReview: changesNotSentForReview || undefined });
|
||||
} catch (error) {
|
||||
if (!(error instanceof GooglePlayApiError) || error.status !== 400) {
|
||||
throw error;
|
||||
@@ -196,7 +229,7 @@ export namespace GooglePlayUtils {
|
||||
logger.warn(
|
||||
'Google does not allow changesNotSentForReview for this app right now. Retrying commit once without it. The changes will be sent for review.'
|
||||
);
|
||||
await commitOnceAsync({});
|
||||
await commitAsync({});
|
||||
} else if (
|
||||
!changesNotSentForReview &&
|
||||
error.apiMessage.includes('Please set the query parameter changesNotSentForReview to true')
|
||||
@@ -204,7 +237,7 @@ export namespace GooglePlayUtils {
|
||||
logger.warn(
|
||||
'Google requires changesNotSentForReview=true. Retrying commit once. Send the saved changes for review from Play Console.'
|
||||
);
|
||||
await commitOnceAsync({ changesNotSentForReview: true });
|
||||
await commitAsync({ changesNotSentForReview: true });
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
@@ -212,21 +245,23 @@ export namespace GooglePlayUtils {
|
||||
}
|
||||
|
||||
export function mapGooglePlayError(error: unknown, packageName: string): unknown {
|
||||
if (error instanceof AggregateError) {
|
||||
return mapGooglePlayError(error.cause, packageName);
|
||||
}
|
||||
if (
|
||||
(error instanceof GooglePlayApiError || error instanceof GooglePlayAuthRequestError) &&
|
||||
(error.status === 408 || error.status === 429 || error.status >= 500)
|
||||
GooglePlayApiError.isRetryableStatus(error.status)
|
||||
) {
|
||||
return new SystemError(
|
||||
`Google Play is temporarily unavailable or rate limited (HTTP ${error.status}). Try again later.`,
|
||||
{ cause: error }
|
||||
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
|
||||
);
|
||||
}
|
||||
if (
|
||||
error instanceof GooglePlayAuthRequestError &&
|
||||
error.status >= 400 &&
|
||||
error.status < 500 &&
|
||||
error.status !== 408 &&
|
||||
error.status !== 429
|
||||
!GooglePlayApiError.isRetryableStatus(error.status)
|
||||
) {
|
||||
return new UserError(
|
||||
'EAS_GOOGLE_PLAY_INVALID_CREDENTIALS',
|
||||
@@ -282,9 +317,7 @@ export namespace GooglePlayUtils {
|
||||
return new UserError(
|
||||
code,
|
||||
`${detail}\n\nApp: ${packageName}\nGoogle Play Console: https://play.google.com/console/`,
|
||||
{
|
||||
cause: error,
|
||||
}
|
||||
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import nock from 'nock';
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
import { GooglePlayClient } from '../GooglePlayClient';
|
||||
import { createMockLogger } from '../../../../__tests__/utils/logger';
|
||||
|
||||
const packageName = 'dev.expo.submitfixture';
|
||||
const editPath = `/androidpublisher/v3/applications/${packageName}/edits/edit`;
|
||||
@@ -13,6 +13,11 @@ const api = (): nock.Scope =>
|
||||
});
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
jest.mock('promise-retry', () => {
|
||||
const actual = jest.requireActual('promise-retry');
|
||||
return (fn: Parameters<typeof actual>[0], options: object) =>
|
||||
actual(fn, { ...options, minTimeout: 1, maxTimeout: 1, randomize: false });
|
||||
});
|
||||
|
||||
const serviceAccount = {
|
||||
client_email: '[email protected]',
|
||||
@@ -75,11 +80,12 @@ it('rejects invalid rollout requests before sending them', async () => {
|
||||
).rejects.toBeInstanceOf(ZodError);
|
||||
});
|
||||
|
||||
it('does not retry an ambiguous edit commit', async () => {
|
||||
it('keeps the outcome unknown when a lost commit response is followed by an invalid edit', async () => {
|
||||
api()
|
||||
.post(`${editPath}:commit`, {})
|
||||
.query({ changesNotSentForReview: false })
|
||||
.replyWithError('Lost response');
|
||||
.replyWithError(Object.assign(new Error('Lost response'), { code: 'ECONNRESET' }));
|
||||
api().post(`${editPath}:commit`, {}).query({ changesNotSentForReview: false }).reply(404);
|
||||
await expect(
|
||||
client.postAsync(
|
||||
'/androidpublisher/v3/applications/:packageName/edits/:editId:commit',
|
||||
@@ -87,7 +93,62 @@ it('does not retry an ambiguous edit commit', async () => {
|
||||
{ packageName, editId: 'edit' },
|
||||
{ query: { changesNotSentForReview: false } }
|
||||
)
|
||||
).rejects.toBeInstanceOf(SystemError);
|
||||
).rejects.toMatchObject({
|
||||
errors: [expect.any(Error), expect.objectContaining({ status: 404 })],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([408, 429, 500, 503])('stops after five retries for HTTP %s', async status => {
|
||||
api().get(editPath).times(6).reply(status);
|
||||
await expect(
|
||||
client.getAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
|
||||
packageName,
|
||||
editId: 'edit',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
cause: expect.objectContaining({ status }),
|
||||
errors: expect.arrayContaining([expect.objectContaining({ status })]),
|
||||
});
|
||||
});
|
||||
|
||||
it('retries the same track update body after a temporary error', async () => {
|
||||
const body = {
|
||||
track: 'internal',
|
||||
releases: [{ versionCodes: ['123'], status: 'draft' as const }],
|
||||
};
|
||||
api().put(`${editPath}/tracks/internal`, body).reply(503);
|
||||
api().put(`${editPath}/tracks/internal`, body).reply(200, { track: 'internal' });
|
||||
await expect(
|
||||
client.putAsync(
|
||||
'/androidpublisher/v3/applications/:packageName/edits/:editId/tracks/:track',
|
||||
body,
|
||||
{ packageName, editId: 'edit', track: 'internal' }
|
||||
)
|
||||
).resolves.toEqual({ track: 'internal' });
|
||||
});
|
||||
|
||||
it('does not add retries to upload-session requests', async () => {
|
||||
api().post('/upload/session').reply(503);
|
||||
await expect(client.requestAsync('POST', '/upload/session')).rejects.toMatchObject({
|
||||
status: 503,
|
||||
});
|
||||
});
|
||||
|
||||
it('stops retrying when cancelled after a transient response', async () => {
|
||||
const controller = new AbortController();
|
||||
api()
|
||||
.get(editPath)
|
||||
.reply(() => {
|
||||
controller.abort();
|
||||
return [503];
|
||||
});
|
||||
await expect(
|
||||
client.getAsync(
|
||||
'/androidpublisher/v3/applications/:packageName/edits/:editId',
|
||||
{ packageName, editId: 'edit' },
|
||||
controller.signal
|
||||
)
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it('deletes an edit with an empty response', async () => {
|
||||
@@ -100,6 +161,44 @@ it('deletes an edit with an empty response', async () => {
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('reports a retry even when the next request succeeds', async () => {
|
||||
const logger = createMockLogger();
|
||||
client = new GooglePlayClient(serviceAccount, logger);
|
||||
api().get(editPath).reply(503);
|
||||
api().get(editPath).reply(200, { id: 'edit' });
|
||||
await client.getAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
|
||||
packageName,
|
||||
editId: 'edit',
|
||||
});
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Retrying Google Play request (HTTP 503, attempt 2/6)...'
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a missing edit after a lost delete response', async () => {
|
||||
api()
|
||||
.delete(editPath)
|
||||
.replyWithError(Object.assign(new Error('Lost response'), { code: 'ECONNRESET' }));
|
||||
api().delete(editPath).reply(404);
|
||||
await expect(
|
||||
client.deleteAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
|
||||
packageName,
|
||||
editId: 'edit',
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('retains an initial delete rejection', async () => {
|
||||
api().delete(editPath).reply(404);
|
||||
await expect(
|
||||
client.deleteAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
|
||||
packageName,
|
||||
editId: 'edit',
|
||||
})
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it('rejects malformed success responses without exposing their contents', async () => {
|
||||
api()
|
||||
.get(editPath)
|
||||
@@ -124,6 +223,42 @@ it('retains structured API errors', async () => {
|
||||
).rejects.toMatchObject({ status: 403, apiMessage: 'Denied', reasons: ['forbidden'] });
|
||||
});
|
||||
|
||||
it.each(['CERT_HAS_EXPIRED', 'EPROTO', 'EACCES', undefined])(
|
||||
'does not retry a system error with code %s',
|
||||
async code => {
|
||||
const logger = createMockLogger();
|
||||
client = new GooglePlayClient(serviceAccount, logger);
|
||||
api()
|
||||
.get(editPath)
|
||||
.replyWithError(Object.assign(new Error('Request failed'), { code }));
|
||||
await expect(
|
||||
client.getAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
|
||||
packageName,
|
||||
editId: 'edit',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
cause: expect.objectContaining({ type: 'system' }),
|
||||
});
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['EAI_AGAIN', 'ECONNREFUSED', 'ECONNRESET', 'UND_ERR_SOCKET'])(
|
||||
'retries a recognised network error %s',
|
||||
async code => {
|
||||
api()
|
||||
.get(editPath)
|
||||
.replyWithError(Object.assign(new Error('Request failed'), { code }));
|
||||
api().get(editPath).reply(200, { id: 'edit' });
|
||||
await expect(
|
||||
client.getAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
|
||||
packageName,
|
||||
editId: 'edit',
|
||||
})
|
||||
).resolves.toEqual({ id: 'edit' });
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['body', 'query'])('accepts extra fields in an empty %s object', async field => {
|
||||
api()
|
||||
.post(`/androidpublisher/v3/applications/${packageName}/edits`, {})
|
||||
|
||||
+133
-4
@@ -1,13 +1,20 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import nock from 'nock';
|
||||
import { Response } from 'node-fetch';
|
||||
|
||||
import { api, editPath, mockToken, packageName, serviceAccount } from './googlePlayTestUtils';
|
||||
import { createMockLogger } from '../../../../__tests__/utils/logger';
|
||||
import { GooglePlayAuthRequestError } from '../GooglePlayAuthClient';
|
||||
import { GooglePlayApiError, GooglePlayClient } from '../GooglePlayClient';
|
||||
import { GooglePlayUtils } from '../GooglePlayUtils';
|
||||
import { GooglePlayApiError } from '../GooglePlayApiError';
|
||||
import { GooglePlayClient } from '../GooglePlayClient';
|
||||
import { GooglePlayCommitOutcomeUnknownError, GooglePlayUtils } from '../GooglePlayUtils';
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
jest.mock('promise-retry', () => {
|
||||
const actual = jest.requireActual('promise-retry');
|
||||
return (fn: Parameters<typeof actual>[0], options: object) =>
|
||||
actual(fn, { ...options, minTimeout: 1, maxTimeout: 1, randomize: false });
|
||||
});
|
||||
beforeEach(() => nock.disableNetConnect());
|
||||
afterEach(() => {
|
||||
const pending = nock.pendingMocks();
|
||||
@@ -71,10 +78,11 @@ it.each([
|
||||
);
|
||||
}
|
||||
);
|
||||
it.each([408, 429, 503])('does not retry an uncertain HTTP %s commit', async status => {
|
||||
it.each([408, 429, 503])('retries a transient HTTP %s commit on the same edit', async status => {
|
||||
mockToken();
|
||||
api().post(`${editPath}:commit`).query(true).reply(status);
|
||||
await expect(commit()).rejects.toMatchObject({ cause: expect.objectContaining({ status }) });
|
||||
api().post(`${editPath}:commit`).query({}).reply(200, { id: 'edit' });
|
||||
await expect(commit()).resolves.toBeUndefined();
|
||||
});
|
||||
it('preserves cancellation before commit is sent', async () => {
|
||||
const controller = new AbortController();
|
||||
@@ -82,6 +90,74 @@ it('preserves cancellation before commit is sent', async () => {
|
||||
controller.abort(reason);
|
||||
await expect(commit({ signal: controller.signal })).rejects.toBe(reason);
|
||||
});
|
||||
const pendingUploadMessage = 'Some of the Android App Bundle uploads are not completed yet.';
|
||||
|
||||
it.each([false, true])(
|
||||
'waits for bundle processing without changing review settings (%s)',
|
||||
async changesNotSentForReview => {
|
||||
const logger = createMockLogger();
|
||||
const query = changesNotSentForReview ? { changesNotSentForReview: 'true' } : {};
|
||||
mockToken();
|
||||
api()
|
||||
.post(`${editPath}:commit`, {})
|
||||
.query(query)
|
||||
.reply(400, { error: { message: pendingUploadMessage } });
|
||||
api().post(`${editPath}:commit`, {}).query(query).reply(200, { id: 'edit' });
|
||||
await expect(commit({ changesNotSentForReview, logger })).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Google Play is still processing')
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('stops waiting for bundle processing after 14 attempts', async () => {
|
||||
mockToken();
|
||||
api()
|
||||
.post(`${editPath}:commit`)
|
||||
.query({})
|
||||
.times(14)
|
||||
.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);
|
||||
@@ -140,3 +216,56 @@ 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,
|
||||
});
|
||||
});
|
||||
@@ -89,14 +89,16 @@ it('uploads aligned chunks and reports server-confirmed progress', async () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['network', 429, 503])(
|
||||
it.each(['network', 408, 429, 503])(
|
||||
'recovers the accepted offset after a %s interruption',
|
||||
async failure => {
|
||||
mockToken();
|
||||
mockStart();
|
||||
const interrupted = uploadApi().put(session).matchHeader('content-range', 'bytes 0-11/12');
|
||||
if (failure === 'network') {
|
||||
interrupted.replyWithError('connection reset');
|
||||
interrupted.replyWithError(
|
||||
Object.assign(new Error('connection reset'), { code: 'ECONNRESET' })
|
||||
);
|
||||
} else {
|
||||
interrupted.reply(failure as number, { error: { message: 'retry' } });
|
||||
}
|
||||
@@ -121,7 +123,7 @@ it('recovers a completed upload when the final upload response is lost', async (
|
||||
uploadApi()
|
||||
.put(session)
|
||||
.matchHeader('content-length', '12')
|
||||
.replyWithError('lost final response');
|
||||
.replyWithError(Object.assign(new Error('lost final response'), { code: 'ECONNRESET' }));
|
||||
status().reply(200, { versionCode: 42 });
|
||||
await expect(upload()).resolves.toBe(42);
|
||||
});
|
||||
@@ -286,7 +288,7 @@ it('retries a network failure during session creation', async () => {
|
||||
api()
|
||||
.post(`/upload${editPath}/apks`)
|
||||
.query({ uploadType: 'resumable' })
|
||||
.replyWithError('connection reset');
|
||||
.replyWithError(Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }));
|
||||
mockStart();
|
||||
uploadApi().put(session).reply(200, { versionCode: 42 });
|
||||
await expect(upload()).resolves.toBe(42);
|
||||
@@ -347,7 +349,9 @@ it.each([
|
||||
mockStart();
|
||||
const finalChunk = uploadApi().put(session).matchHeader('content-length', '12');
|
||||
if (finalResponse === 'lost') {
|
||||
finalChunk.replyWithError('lost final response');
|
||||
finalChunk.replyWithError(
|
||||
Object.assign(new Error('lost final response'), { code: 'ECONNRESET' })
|
||||
);
|
||||
} else {
|
||||
finalChunk.reply(308, '', { Range: 'bytes=0-11' });
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { graphql } from 'gql.tada';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import {
|
||||
ArtifactPutError,
|
||||
ArtifactPutNetworkError,
|
||||
@@ -11,23 +9,7 @@ import {
|
||||
putArtifactWithRetriesAsync,
|
||||
} from './artifactPut';
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
import { getDeviceRunSessionWorkerApi } 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
|
||||
|
||||
type ArtifactMetadata = {
|
||||
deviceRunSessionId: string;
|
||||
@@ -72,54 +54,28 @@ async function createDeviceRunSessionArtifactUploadSessionAsync(
|
||||
{ deviceRunSessionId, artifactId, name, filename, kind, metadata, size }: ArtifactMetadata,
|
||||
signal: AbortSignal
|
||||
): Promise<ArtifactUploadSession> {
|
||||
const workerApi = getDeviceRunSessionWorkerApi(ctx);
|
||||
if (workerApi) {
|
||||
let uploadSession: ArtifactUploadSession;
|
||||
try {
|
||||
uploadSession = await workerApi.createArtifactUploadSessionAsync(
|
||||
{
|
||||
name,
|
||||
filename,
|
||||
...(kind !== undefined ? { kind } : {}),
|
||||
...(metadata !== undefined ? { metadata } : {}),
|
||||
size,
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
} catch (err) {
|
||||
signal.throwIfAborted();
|
||||
throw new SystemError(
|
||||
`Failed to create upload session for device run session artifact ${artifactId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
return uploadSession;
|
||||
}
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(
|
||||
CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION,
|
||||
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
|
||||
let uploadSession: ArtifactUploadSession;
|
||||
try {
|
||||
uploadSession = await workerApi.createArtifactUploadSessionAsync(
|
||||
{
|
||||
deviceRunSessionId,
|
||||
input: {
|
||||
name,
|
||||
filename,
|
||||
...(kind !== undefined ? { kind } : {}),
|
||||
...(metadata !== undefined ? { metadata } : {}),
|
||||
size,
|
||||
},
|
||||
name,
|
||||
filename,
|
||||
...(kind !== undefined ? { kind } : {}),
|
||||
...(metadata !== undefined ? { metadata } : {}),
|
||||
size,
|
||||
},
|
||||
graphqlAbortContext(signal)
|
||||
)
|
||||
.toPromise();
|
||||
signal.throwIfAborted();
|
||||
if (result.error) {
|
||||
{ signal }
|
||||
);
|
||||
} catch (err) {
|
||||
signal.throwIfAborted();
|
||||
throw new SystemError(
|
||||
`Failed to create upload session for device run session artifact ${artifactId}: ${result.error.message}`,
|
||||
{ cause: result.error }
|
||||
`Failed to create upload session for device run session artifact ${artifactId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
return result.data!.deviceRunSession.createArtifactUploadSession.uploadSession;
|
||||
signal.throwIfAborted();
|
||||
return uploadSession;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { graphql } from 'gql.tada';
|
||||
import fs from 'node:fs';
|
||||
import { StringDecoder } from 'node:string_decoder';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
@@ -10,26 +9,13 @@ import HttpLogStream from '../../logging/HttpLogStream';
|
||||
import RemoteLoggerStream from '../../logging/RemoteLoggerStream';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { type SignedUrl } from '../../storage/uploadWithSignedUrl';
|
||||
import { getDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
|
||||
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
|
||||
@@ -350,35 +336,17 @@ async function createEventLogUploadSessionAsync(
|
||||
ctx: CustomBuildContext,
|
||||
deviceRunSessionId: string
|
||||
): Promise<SignedUrl> {
|
||||
const workerApi = getDeviceRunSessionWorkerApi(ctx);
|
||||
if (workerApi) {
|
||||
try {
|
||||
return await workerApi.createEventLogUploadSessionAsync();
|
||||
} catch (err) {
|
||||
throw new SystemError(
|
||||
`Failed to create device run session event log upload session: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
}
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(CREATE_DEVICE_RUN_SESSION_EVENT_LOG_UPLOAD_SESSION_MUTATION, {
|
||||
deviceRunSessionId,
|
||||
})
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
|
||||
try {
|
||||
return await workerApi.createEventLogUploadSessionAsync();
|
||||
} catch (err) {
|
||||
throw new SystemError(
|
||||
`Failed to create device run session event log upload session: ${result.error.message}`,
|
||||
{ cause: result.error }
|
||||
`Failed to create device run session event log upload session: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
const uploadSession = result.data!.deviceRunSession.createEventLogUploadSession.uploadSession;
|
||||
return {
|
||||
url: uploadSession.url,
|
||||
headers: uploadSession.headers as Record<string, string>,
|
||||
};
|
||||
}
|
||||
|
||||
async function collectEventFileAsync({
|
||||
|
||||
@@ -2,7 +2,6 @@ import { type bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
|
||||
import spawn, { type SpawnOptions } from '@expo/turtle-spawn';
|
||||
import { graphql } from 'gql.tada';
|
||||
import fetch from 'node-fetch';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
@@ -12,25 +11,12 @@ import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { killProcessGroup } from '../../utils/processes';
|
||||
import { getDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
|
||||
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.
|
||||
@@ -122,35 +108,7 @@ async function createPreviewUploadSessionAsync(
|
||||
deviceRunSessionId: string,
|
||||
signal: AbortSignal
|
||||
): Promise<{ url: string; headers: Record<string, string> }> {
|
||||
const workerApi = getDeviceRunSessionWorkerApi(ctx);
|
||||
if (workerApi) {
|
||||
return await workerApi.createPreviewUploadSessionAsync({ 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.');
|
||||
}
|
||||
return {
|
||||
url: session.url,
|
||||
headers: session.headers as Record<string, string>,
|
||||
};
|
||||
return await requireDeviceRunSessionWorkerApi(ctx).createPreviewUploadSessionAsync({ signal });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ import spawn from '@expo/turtle-spawn';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import limitFactory from 'promise-limit';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -140,9 +141,8 @@ export async function uploadDeviceRunSessionScreenRecordingsAsync(
|
||||
recordings.map(recording =>
|
||||
limit(async () => {
|
||||
try {
|
||||
const metadata = RecordingManifestSchema.parse(
|
||||
JSON.parse(await readFile(path.join(recording.directory, 'session.json'), 'utf-8'))
|
||||
);
|
||||
const manifest = await readFile(path.join(recording.directory, 'session.json'));
|
||||
const metadata = RecordingManifestSchema.parse(JSON.parse(manifest.toString('utf-8')));
|
||||
if (path.basename(metadata.recording) !== metadata.recording) {
|
||||
throw new Error('Recording filename must not contain a directory.');
|
||||
}
|
||||
@@ -186,6 +186,31 @@ export async function uploadDeviceRunSessionScreenRecordingsAsync(
|
||||
openStream: () => createReadStream(recordingPath),
|
||||
attempts: 3,
|
||||
});
|
||||
try {
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId: `${recordingId}-metadata`,
|
||||
name: `${displayName} metadata`,
|
||||
filename: `${recordingId}.json`,
|
||||
kind: 'screen-recording-metadata',
|
||||
metadata: {
|
||||
__eas_type: 'screen-recording-metadata',
|
||||
recordingFilename: `${recordingId}.mp4`,
|
||||
udid: recording.udid,
|
||||
},
|
||||
size: manifest.length,
|
||||
openStream: () => Readable.from(manifest),
|
||||
attempts: 3,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not upload device screen recording metadata', error);
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
`Could not upload screen recording metadata for ${recording.deviceName}.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
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 { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import { getDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
|
||||
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
|
||||
|
||||
const SHORT_COMMIT_HASH_LENGTH = 7;
|
||||
const MAX_APP_SLUG_LENGTH = 80;
|
||||
@@ -17,38 +14,9 @@ const LEADING_OR_TRAILING_DOTS_AND_HYPHENS = /^[.-]+|[.-]+$/g;
|
||||
// A full git commit hash: 40 hex characters for SHA-1 or 64 for SHA-256.
|
||||
const FULL_GIT_COMMIT_HASH = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i;
|
||||
|
||||
const SCREENSHOT_SESSION_QUERY = graphql(`
|
||||
query ScreenshotSession($deviceRunSessionId: ID!) {
|
||||
deviceRunSessions {
|
||||
byId(deviceRunSessionId: $deviceRunSessionId) {
|
||||
platform
|
||||
app { slug }
|
||||
build {
|
||||
id
|
||||
gitCommitHash
|
||||
app { slug }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const AppSchema = z.object({ slug: z.string() });
|
||||
const ScreenshotSessionSchema = z.object({
|
||||
platform: z.enum(['IOS', 'ANDROID']),
|
||||
app: AppSchema,
|
||||
build: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
gitCommitHash: z.string().nullable(),
|
||||
app: AppSchema,
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Session details used to name screenshots. `app` is null when the source does not report an app
|
||||
* slug: the device run session worker API does not return one.
|
||||
* 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';
|
||||
@@ -63,30 +31,22 @@ export async function loadScreenshotSessionAsync(
|
||||
): Promise<ScreenshotSession | null> {
|
||||
try {
|
||||
const signal = AbortSignal.timeout(SESSION_LOOKUP_TIMEOUT_MS);
|
||||
const workerApi = getDeviceRunSessionWorkerApi(ctx);
|
||||
if (workerApi) {
|
||||
const application = await workerApi.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(signal))
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
return ScreenshotSessionSchema.parse(result.data?.deviceRunSessions.byId);
|
||||
const application = await requireDeviceRunSessionWorkerApi(ctx).getApplicationAsync({
|
||||
signal,
|
||||
});
|
||||
return {
|
||||
// www runs iOS sessions only on macOS workers and Android sessions only on Linux workers,
|
||||
// so the worker API does not return the platform.
|
||||
platform: ctx.runtimePlatform === BuildRuntimePlatform.DARWIN ? 'IOS' : 'ANDROID',
|
||||
app: null,
|
||||
build: application.build
|
||||
? {
|
||||
id: application.build.id,
|
||||
gitCommitHash: application.build.gitCommitHash,
|
||||
app: null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, deviceRunSessionId },
|
||||
|
||||
@@ -20,24 +20,10 @@ export const DEVICE_RUN_SESSION_TOKEN_HEADER = 'expo-device-run-session-token';
|
||||
|
||||
const DEVICE_RUN_SESSION_WORKER_PATH = 'device-run-session-worker/';
|
||||
|
||||
/**
|
||||
* www returns the entity value of the status. The client maps it to the GraphQL enum value, so
|
||||
* callers compare statuses the same way on both paths.
|
||||
*/
|
||||
const DEVICE_RUN_SESSION_STATUS_FROM_ENTITY_VALUE = {
|
||||
new: 'NEW',
|
||||
queued: 'QUEUED',
|
||||
starting: 'STARTING',
|
||||
'in-progress': 'IN_PROGRESS',
|
||||
stopped: 'STOPPED',
|
||||
errored: 'ERRORED',
|
||||
} as const;
|
||||
|
||||
const DeviceRunSessionStatusSchema = z.object({
|
||||
id: z.string(),
|
||||
status: z
|
||||
.enum(['new', 'queued', 'starting', 'in-progress', 'stopped', 'errored'])
|
||||
.transform(status => DEVICE_RUN_SESSION_STATUS_FROM_ENTITY_VALUE[status]),
|
||||
// The status entity values that www returns.
|
||||
status: z.enum(['new', 'queued', 'starting', 'in-progress', 'stopped', 'errored']),
|
||||
});
|
||||
|
||||
const UploadSessionSchema = z.object({
|
||||
@@ -102,11 +88,16 @@ export class DeviceRunSessionWorkerApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
/** The HTTP status of the response, or undefined when no response arrived. */
|
||||
readonly status: number | undefined
|
||||
readonly status: number | undefined,
|
||||
options?: ErrorOptions
|
||||
) {
|
||||
super(message);
|
||||
super(message, options);
|
||||
this.name = 'DeviceRunSessionWorkerApiError';
|
||||
}
|
||||
|
||||
get isTransient(): boolean {
|
||||
return this.status === undefined || this.status >= 500;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -232,7 +223,8 @@ export class DeviceRunSessionWorkerApi {
|
||||
`${routeDescription} failed before www responded: ${
|
||||
reason instanceof Error ? reason.message : String(reason)
|
||||
}. Check the network connection of the worker.`,
|
||||
undefined
|
||||
undefined,
|
||||
{ cause: reason }
|
||||
);
|
||||
}
|
||||
const response = responseResult.value;
|
||||
@@ -307,6 +299,24 @@ export function getDeviceRunSessionWorkerApi(
|
||||
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;
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
} from './remoteDeviceRunSession';
|
||||
import { LOCAL_EGRESS_PROXY_HOST, readLocalEgressHandoffAsync } from './localEgress';
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
import {
|
||||
IosSimulatorRecordingUtils,
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
import { startLogPhase } from '../../utils/logPhase';
|
||||
import { runServeSimActionAsync, stageServeSimAppAsync } from './serveSimActions';
|
||||
import { type ServeSimActivityPolling, startServeSimActivityPolling } from './serveSimActivity';
|
||||
import { verifyLocalEgressGuardAsync } from './localEgressGuard';
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { createStartupTasks } from './startupTasks';
|
||||
@@ -102,6 +104,16 @@ 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'}`;
|
||||
}
|
||||
@@ -114,6 +126,8 @@ export function createServeSimArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
websiteArgs = [],
|
||||
frameAncestorArgs: frameArgs = [],
|
||||
corsOriginArgs: corsArgs = [],
|
||||
shareUrl,
|
||||
disableShare = false,
|
||||
packageVersion,
|
||||
@@ -124,10 +138,13 @@ export function createServeSimArgs({
|
||||
openUrl,
|
||||
networkCapture = false,
|
||||
networkCaptureFields = [],
|
||||
networkCaptureProxy,
|
||||
}: {
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
websiteArgs?: string[];
|
||||
frameAncestorArgs?: string[];
|
||||
corsOriginArgs?: string[];
|
||||
shareUrl?: string;
|
||||
disableShare?: boolean;
|
||||
packageVersion?: string;
|
||||
@@ -135,6 +152,7 @@ export function createServeSimArgs({
|
||||
installAppPath?: string;
|
||||
networkCapture?: boolean;
|
||||
networkCaptureFields?: string[];
|
||||
networkCaptureProxy?: string;
|
||||
} & ServeSimLaunchOptions): string[] {
|
||||
return [
|
||||
createServeSimPackageSpec(packageVersion),
|
||||
@@ -158,6 +176,8 @@ export function createServeSimArgs({
|
||||
SERVE_SIM_VIDEO_FPS,
|
||||
...turnArgs,
|
||||
...websiteArgs,
|
||||
...frameArgs,
|
||||
...corsArgs,
|
||||
...(shareUrl ? ['--share-url', shareUrl] : []),
|
||||
...(disableShare ? ['--disable-share'] : []),
|
||||
...(launchAppIdentifier ? ['--launch-app-identifier', launchAppIdentifier] : []),
|
||||
@@ -172,6 +192,7 @@ export function createServeSimArgs({
|
||||
...networkCaptureFields.flatMap(field => ['--network-capture-field', field]),
|
||||
]
|
||||
: []),
|
||||
...(networkCaptureProxy !== undefined ? ['--network-capture-proxy', networkCaptureProxy] : []),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -179,12 +200,16 @@ 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,6 +239,8 @@ export function createExpoDeviceHubArgs({
|
||||
...(recordingDirectory ? ['--android-recording-directory', recordingDirectory] : []),
|
||||
...turnArgs,
|
||||
...websiteArgs,
|
||||
...frameArgs,
|
||||
...corsArgs,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -328,6 +355,8 @@ export type DeviceWebPreview = {
|
||||
|
||||
export type DeviceSessionHost = {
|
||||
openPreviewAsync(options: { baseDomain: string; authtoken: string }): Promise<DeviceWebPreview>;
|
||||
/** Last observed preview input. Always `undefined` on Android, which keeps no event log. */
|
||||
getLastPreviewActivityAt(): Date | undefined;
|
||||
/**
|
||||
* Terminal and idempotent. Never rejects: a failed finalization, host stop or upload is logged
|
||||
* and reported, so callers need no error handling around it.
|
||||
@@ -353,6 +382,10 @@ type DeviceSessionHostOptions = {
|
||||
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>;
|
||||
@@ -403,6 +436,8 @@ async function startDeviceSessionHostInternalAsync(
|
||||
onStartupError,
|
||||
disableShare = false,
|
||||
privatePreviewUrl,
|
||||
frameAncestors = [],
|
||||
corsOrigins = [],
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
@@ -464,7 +499,8 @@ async function startDeviceSessionHostInternalAsync(
|
||||
const packageSpec = isAndroid
|
||||
? createExpoDeviceHubPackageSpec(packageVersion)
|
||||
: createServeSimPackageSpec(packageVersion);
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { logger });
|
||||
const localEgress = !isAndroid ? await readLocalEgressHandoffAsync() : null;
|
||||
const previewExec = resolvePackageExec(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
isAndroid
|
||||
@@ -474,6 +510,8 @@ async function startDeviceSessionHostInternalAsync(
|
||||
packageVersion,
|
||||
recordingDirectory: recording?.directory,
|
||||
websiteArgs: websiteOriginArgs(env),
|
||||
frameAncestorArgs: frameAncestorArgs(frameAncestors),
|
||||
corsOriginArgs: corsOriginArgs(corsOrigins),
|
||||
})
|
||||
: createServeSimArgs({
|
||||
port,
|
||||
@@ -481,6 +519,8 @@ async function startDeviceSessionHostInternalAsync(
|
||||
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,
|
||||
@@ -490,6 +530,9 @@ async function startDeviceSessionHostInternalAsync(
|
||||
openUrl: iosSimulatorUdid ? undefined : openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
networkCaptureProxy: localEgress
|
||||
? `http://${LOCAL_EGRESS_PROXY_HOST}:${localEgress.port}`
|
||||
: undefined,
|
||||
})
|
||||
);
|
||||
logger.info(
|
||||
@@ -545,6 +588,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
const previewSetupController = new AbortController();
|
||||
let previewSetupTask: Promise<void> | undefined;
|
||||
let previewFailed = false;
|
||||
let previewActivity: ServeSimActivityPolling | null = null;
|
||||
|
||||
const host: DeviceSessionHost = {
|
||||
openPreviewAsync({ baseDomain, authtoken }) {
|
||||
@@ -602,6 +646,9 @@ async function startDeviceSessionHostInternalAsync(
|
||||
});
|
||||
return opening;
|
||||
},
|
||||
getLastPreviewActivityAt() {
|
||||
return previewActivity?.getLastActivityObservedAt();
|
||||
},
|
||||
finishAsync() {
|
||||
readinessController.abort(new Error('Simulator preview ended.'));
|
||||
return (finishTask ??= finishDeviceSessionHostAsync(ctx, {
|
||||
@@ -610,6 +657,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
previewSetupController.abort();
|
||||
await previewSetupTask;
|
||||
await sessionPreview?.stopAsync();
|
||||
await previewActivity?.stopAsync();
|
||||
},
|
||||
previewServer,
|
||||
screenshots,
|
||||
@@ -737,6 +785,9 @@ async function startDeviceSessionHostInternalAsync(
|
||||
secrets.push(previewToken);
|
||||
if (!isAndroid) {
|
||||
IosSimulatorRecordingUtils.useServeSimPackage(packageSpec);
|
||||
if (!finishTask) {
|
||||
previewActivity = startServeSimActivityPolling({ port, token: previewToken, logger });
|
||||
}
|
||||
}
|
||||
// Android installed FFmpeg before launching the host. The optional thumbnail must not delay
|
||||
// readiness on macOS, so it starts after its encoder is installed there.
|
||||
|
||||
@@ -12,13 +12,16 @@ export async function preparePreinstalledWdaAsync({
|
||||
appiumBinPath,
|
||||
appiumEnv,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
runtimeIdentifier: string | undefined;
|
||||
appiumHome: string;
|
||||
appiumBinPath: string;
|
||||
appiumEnv: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
signal?.throwIfAborted();
|
||||
const iosMajor = /^com\.apple\.CoreSimulator\.SimRuntime\.iOS-(\d+)(?:-|$)/.exec(
|
||||
runtimeIdentifier ?? ''
|
||||
)?.[1];
|
||||
@@ -52,10 +55,17 @@ export async function preparePreinstalledWdaAsync({
|
||||
// Appium's downloader inherits its pipes; killing only Appium leaves spawn's
|
||||
// close event pending. Kill the owned group, then await close before cleanup.
|
||||
const timeout = setTimeout(() => killProcessGroup(download.child, 'SIGKILL'), 60_000);
|
||||
const abort = (): void => killProcessGroup(download.child, 'SIGKILL');
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
if (signal?.aborted) {
|
||||
abort();
|
||||
}
|
||||
try {
|
||||
await download;
|
||||
signal?.throwIfAborted();
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
signal?.removeEventListener('abort', abort);
|
||||
}
|
||||
if (!(await fs.promises.stat(runner)).isDirectory()) {
|
||||
throw new Error('The downloaded WebDriverAgent runner is not an app directory.');
|
||||
@@ -67,6 +77,7 @@ export async function preparePreinstalledWdaAsync({
|
||||
};
|
||||
} catch (err) {
|
||||
await fs.promises.rm(outdir, { recursive: true, force: true });
|
||||
signal?.throwIfAborted();
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Could not prepare preinstalled WebDriverAgent; using the default xcodebuild startup.'
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import { graphql } from 'gql.tada';
|
||||
import nullthrows from 'nullthrows';
|
||||
import { z } from 'zod';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
@@ -27,51 +25,17 @@ import {
|
||||
parseOpenUrlInput,
|
||||
} from '../functions/launchApplication';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { isProcessGroupRunning } from '../../utils/processes';
|
||||
import { isProcessGroupRunning, killProcessGroup } from '../../utils/processes';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { createProcessOutput } from './processOutput';
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
import { getDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
|
||||
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;
|
||||
@@ -162,6 +126,8 @@ export type DeviceRunSessionIdleTimeout = {
|
||||
* begins, so a session nobody ever connects to still times out.
|
||||
*/
|
||||
getLastEventObservedAt: () => Date | undefined;
|
||||
/** Last observed preview input, counted like a session event. */
|
||||
getLastPreviewActivityAt?: () => Date | undefined;
|
||||
};
|
||||
|
||||
export async function waitForDeviceRunSessionStoppedAsync({
|
||||
@@ -215,9 +181,13 @@ export async function waitForDeviceRunSessionStoppedAsync({
|
||||
|
||||
while (!signal.aborted) {
|
||||
if (idleTimeout) {
|
||||
const lastEventObservedAt = idleTimeout.getLastEventObservedAt();
|
||||
if (lastEventObservedAt && lastEventObservedAt > lastActivityAt) {
|
||||
lastActivityAt = lastEventObservedAt;
|
||||
for (const observedAt of [
|
||||
idleTimeout.getLastEventObservedAt(),
|
||||
idleTimeout.getLastPreviewActivityAt?.(),
|
||||
]) {
|
||||
if (observedAt && observedAt > lastActivityAt) {
|
||||
lastActivityAt = observedAt;
|
||||
}
|
||||
}
|
||||
if (Date.now() - lastActivityAt.getTime() >= idleTimeout.maxIdleTimeMinutes * 60_000) {
|
||||
logger.info(
|
||||
@@ -230,15 +200,12 @@ export async function waitForDeviceRunSessionStoppedAsync({
|
||||
}
|
||||
try {
|
||||
const status = await fetchDeviceRunSessionStatusAsync(ctx, deviceRunSessionId, signal);
|
||||
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) {
|
||||
@@ -269,24 +236,14 @@ async function fetchDeviceRunSessionStatusAsync(
|
||||
ctx: CustomBuildContext,
|
||||
deviceRunSessionId: string,
|
||||
signal: AbortSignal
|
||||
): Promise<string | undefined> {
|
||||
const workerApi = getDeviceRunSessionWorkerApi(ctx);
|
||||
if (workerApi) {
|
||||
// One request per poll; the polling loop retries on its own schedule.
|
||||
return (
|
||||
await workerApi.getSessionAsync({
|
||||
signal,
|
||||
timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS,
|
||||
})
|
||||
).status;
|
||||
}
|
||||
const result = await ctx.graphqlClient
|
||||
.query(DEVICE_RUN_SESSION_STATUS_QUERY, { deviceRunSessionId })
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
return result.data?.deviceRunSessions?.byId?.status;
|
||||
): 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
|
||||
@@ -301,17 +258,9 @@ async function ensureDeviceRunSessionStoppedSafelyAsync({
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const workerApi = getDeviceRunSessionWorkerApi(ctx);
|
||||
if (workerApi) {
|
||||
await workerApi.stopAsync({ timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS });
|
||||
} else {
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(ENSURE_DEVICE_RUN_SESSION_STOPPED_MUTATION, { deviceRunSessionId })
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
}
|
||||
await requireDeviceRunSessionWorkerApi(ctx).stopAsync({
|
||||
timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not mark idle device run session as stopped', error, {
|
||||
@@ -368,14 +317,28 @@ function warmUpFfmpeg(env: BuildStepEnv): void {
|
||||
async function installFfmpegWithHomebrewAsync({
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
await spawn('brew', ['install', 'ffmpeg'], {
|
||||
signal?.throwIfAborted();
|
||||
const install = spawn('brew', ['install', 'ffmpeg'], {
|
||||
env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1' },
|
||||
logger,
|
||||
...(signal ? { detached: true } : {}),
|
||||
});
|
||||
const abort = (): void => killProcessGroup(install.child, 'SIGKILL');
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
if (signal?.aborted) {
|
||||
abort();
|
||||
}
|
||||
try {
|
||||
await install;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
}
|
||||
}
|
||||
|
||||
async function installFfmpegWithAptAsync({
|
||||
@@ -393,7 +356,7 @@ async function installFfmpegWithAptAsync({
|
||||
await spawn('sudo', ['apt-get', 'install', '-y', 'ffmpeg'], { env: aptEnv, logger });
|
||||
}
|
||||
|
||||
let ffmpegSetupPromise: Promise<void> | undefined;
|
||||
let ffmpegSetupPromise: Promise<'aborted' | void> | undefined;
|
||||
|
||||
/**
|
||||
* Install ffmpeg when the runtime does not already provide it. Device-session
|
||||
@@ -413,13 +376,16 @@ async function ensureFfmpegInstalledAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
signal?: AbortSignal;
|
||||
}): Promise<'aborted' | void> {
|
||||
try {
|
||||
const ffmpegPath = await findFfmpegOnPathAsync(env);
|
||||
signal?.throwIfAborted();
|
||||
if (ffmpegPath) {
|
||||
logger.info(`ffmpeg is already installed at ${ffmpegPath}.`);
|
||||
warmUpFfmpeg(env);
|
||||
@@ -433,13 +399,17 @@ async function ensureFfmpegInstalledAsync({
|
||||
} for the device session.`
|
||||
);
|
||||
if (isDarwin) {
|
||||
await installFfmpegWithHomebrewAsync({ env, logger });
|
||||
await installFfmpegWithHomebrewAsync({ env, logger, signal });
|
||||
} else {
|
||||
await installFfmpegWithAptAsync({ env, logger });
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
logger.info('Installed ffmpeg.');
|
||||
warmUpFfmpeg(env);
|
||||
} catch (err) {
|
||||
if (signal?.aborted) {
|
||||
return 'aborted';
|
||||
}
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not install ffmpeg for the device session', error, {
|
||||
level: 'warning',
|
||||
@@ -455,20 +425,46 @@ export async function ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
if (ffmpegSetupPromise) {
|
||||
await ffmpegSetupPromise;
|
||||
const sharedSetup = ffmpegSetupPromise;
|
||||
let outcome: 'aborted' | void;
|
||||
if (signal) {
|
||||
outcome = await new Promise<'aborted' | void>((resolve, reject) => {
|
||||
const abort = (): void => reject(signal.reason);
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
void sharedSetup.then(result => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
resolve(result);
|
||||
});
|
||||
if (signal.aborted) {
|
||||
abort();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
outcome = await sharedSetup;
|
||||
}
|
||||
if (outcome === 'aborted') {
|
||||
if (ffmpegSetupPromise === sharedSetup) {
|
||||
ffmpegSetupPromise = undefined;
|
||||
}
|
||||
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger, signal });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const setupPromise = ensureFfmpegInstalledAsync({ runtimePlatform, env, logger });
|
||||
const setupPromise = ensureFfmpegInstalledAsync({ runtimePlatform, env, logger, signal });
|
||||
ffmpegSetupPromise = setupPromise;
|
||||
try {
|
||||
await setupPromise;
|
||||
signal?.throwIfAborted();
|
||||
} finally {
|
||||
if (ffmpegSetupPromise === setupPromise) {
|
||||
ffmpegSetupPromise = undefined;
|
||||
@@ -476,12 +472,6 @@ export async function ensureFfmpegInstalledOnceAsync({
|
||||
}
|
||||
}
|
||||
|
||||
const TurnIceServersResponseSchema = z.object({
|
||||
data: z.object({
|
||||
iceServers: TurnIceServersSchema,
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Translate Cloudflare ICE servers into web preview CLI flags: `--stun-url` (the
|
||||
* credential-less entries) and `--turn-url`/`--turn-username`/`--turn-credential`
|
||||
@@ -523,10 +513,10 @@ export function turnIceServersToWebPreviewArgs(iceServers: TurnIceServers): stri
|
||||
*/
|
||||
export async function fetchWebPreviewTurnArgsAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
|
||||
{ logger }: { logger: bunyan }
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const iceServers = await fetchTurnIceServersAsync(ctx, { env, logger });
|
||||
const iceServers = await fetchTurnIceServersAsync(ctx, { logger });
|
||||
const args = turnIceServersToWebPreviewArgs(iceServers);
|
||||
if (args.length > 0) {
|
||||
logger.info('Configured the web preview with Cloudflare TURN ICE servers.');
|
||||
@@ -545,38 +535,13 @@ export async function fetchWebPreviewTurnArgsAsync(
|
||||
|
||||
async function fetchTurnIceServersAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
|
||||
{ logger }: { logger: bunyan }
|
||||
): Promise<TurnIceServers> {
|
||||
const workerApi = getDeviceRunSessionWorkerApi(ctx);
|
||||
if (workerApi) {
|
||||
return await workerApi.fetchTurnIceServersAsync({ timeoutMs: 5000, retries: 1, logger });
|
||||
}
|
||||
|
||||
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());
|
||||
return data.iceServers;
|
||||
return await requireDeviceRunSessionWorkerApi(ctx).fetchTurnIceServersAsync({
|
||||
timeoutMs: 5000,
|
||||
retries: 1,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadRemoteSessionConfigAsync({
|
||||
@@ -593,26 +558,15 @@ export async function uploadRemoteSessionConfigAsync({
|
||||
logger.info(
|
||||
`Reporting remote config to the API server (device run session: ${deviceRunSessionId}).`
|
||||
);
|
||||
const workerApi = getDeviceRunSessionWorkerApi(ctx);
|
||||
if (workerApi) {
|
||||
try {
|
||||
await workerApi.startAsync({ remoteConfig });
|
||||
} catch (err) {
|
||||
throw new SystemError(
|
||||
`Failed to start device run session ${deviceRunSessionId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(START_DEVICE_RUN_SESSION_MUTATION, { deviceRunSessionId, remoteConfig })
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
|
||||
try {
|
||||
await workerApi.startAsync({ remoteConfig });
|
||||
} catch (err) {
|
||||
throw new SystemError(
|
||||
`Failed to start device run session ${deviceRunSessionId}: ${result.error.message}`
|
||||
`Failed to start device run session ${deviceRunSessionId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -880,6 +834,58 @@ export function createPrivatePreviewUrlInputProvider(): ReturnType<
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Origins that may embed the preview host in a frame, next to the website origins. The host
|
||||
* drops values that are not an origin or a subdomain wildcard, so this parser only guards
|
||||
* the input shape.
|
||||
*/
|
||||
export function createFrameAncestorsInputProvider(): ReturnType<
|
||||
typeof BuildStepInput.createProvider
|
||||
> {
|
||||
return BuildStepInput.createProvider({
|
||||
id: 'frame_ancestors',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseFrameAncestorsInput(value: unknown): string[] {
|
||||
return parseOriginListInput(value, 'frame_ancestors', 'EAS_FRAME_ANCESTORS_INVALID_INPUT');
|
||||
}
|
||||
|
||||
/**
|
||||
* Origins whose pages may call the preview host's API cross-origin, such as a page that renders
|
||||
* the device with @expo/hub-client. Framing alone does not need it. The host refuses values that
|
||||
* are not an origin or a subdomain wildcard, so this parser only guards the input shape.
|
||||
*/
|
||||
export function createCorsOriginsInputProvider(): ReturnType<typeof BuildStepInput.createProvider> {
|
||||
return BuildStepInput.createProvider({
|
||||
id: 'cors_origins',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseCorsOriginsInput(value: unknown): string[] {
|
||||
return parseOriginListInput(value, 'cors_origins', 'EAS_CORS_ORIGINS_INVALID_INPUT');
|
||||
}
|
||||
|
||||
function parseOriginListInput(value: unknown, inputId: string, errorCode: string): string[] {
|
||||
if (value === undefined) {
|
||||
return [];
|
||||
}
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
!value.every(origin => typeof origin === 'string' && origin.trim().length > 0)
|
||||
) {
|
||||
throw new UserError(
|
||||
errorCode,
|
||||
`Input "${inputId}" must be an array of non-empty strings, one origin each.`
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: rawLaunchAppIdentifier,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
const REQUEST_TIMEOUT_MS = 5_000;
|
||||
|
||||
export type ServeSimActivityPolling = {
|
||||
/**
|
||||
* When a new event log entry (input or host action, not stream traffic) was first seen. Reports
|
||||
* now while the log is unreadable but serve-sim is up, so an unobservable session stays active.
|
||||
*/
|
||||
getLastActivityObservedAt: () => Date | undefined;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
/** Nothing listens on the port. A reset or dropped response can come from a running serve-sim. */
|
||||
function isServeSimDownError(err: unknown): boolean {
|
||||
// Not `instanceof`: undici errors can come from another realm.
|
||||
let error: unknown = err;
|
||||
for (let depth = 0; depth < 5 && typeof error === 'object' && error !== null; depth++) {
|
||||
const { code, cause } = error as { code?: unknown; cause?: unknown };
|
||||
if (code === 'ECONNREFUSED') {
|
||||
return true;
|
||||
}
|
||||
error = cause;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** A changed newest event ID counts as activity, timed by the local clock. */
|
||||
export function startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger,
|
||||
pollIntervalMs = POLL_INTERVAL_MS,
|
||||
}: {
|
||||
port: number;
|
||||
token: string;
|
||||
logger: bunyan;
|
||||
pollIntervalMs?: number;
|
||||
}): ServeSimActivityPolling {
|
||||
const controller = new AbortController();
|
||||
let lastEventId: number | undefined;
|
||||
let lastActivityObservedAt: Date | undefined;
|
||||
let eventLogUnavailable = false;
|
||||
let didWarn = false;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
let pending: Promise<void> = Promise.resolve();
|
||||
|
||||
const pollAsync = async (): Promise<void> => {
|
||||
try {
|
||||
const eventId = await fetchLatestServeSimEventIdAsync({
|
||||
port,
|
||||
token,
|
||||
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
|
||||
});
|
||||
eventLogUnavailable = false;
|
||||
if (eventId !== undefined && eventId !== lastEventId) {
|
||||
lastEventId = eventId;
|
||||
lastActivityObservedAt = new Date();
|
||||
}
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
// Only a down serve-sim, which receives no input either, keeps the last observation.
|
||||
eventLogUnavailable = !isServeSimDownError(err);
|
||||
if (!didWarn) {
|
||||
didWarn = true;
|
||||
logger.warn(
|
||||
{ err },
|
||||
eventLogUnavailable
|
||||
? 'Could not read the serve-sim event log. Preview input cannot be observed, so the ' +
|
||||
'idle timeout does not stop this session.'
|
||||
: 'Could not read serve-sim preview activity.'
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
const tick = (): void => {
|
||||
pending = pollAsync().finally(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
timer = setTimeout(tick, pollIntervalMs);
|
||||
timer.unref();
|
||||
}
|
||||
});
|
||||
};
|
||||
tick();
|
||||
return {
|
||||
getLastActivityObservedAt: () => (eventLogUnavailable ? new Date() : lastActivityObservedAt),
|
||||
stopAsync: async () => {
|
||||
controller.abort();
|
||||
clearTimeout(timer);
|
||||
await pending;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchLatestServeSimEventIdAsync({
|
||||
port,
|
||||
token,
|
||||
signal,
|
||||
}: {
|
||||
port: number;
|
||||
token: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<number | undefined> {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/event-log?limit=1`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new Error(`serve-sim event log request failed: ${response.status}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
let body: { events?: unknown } | undefined;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {}
|
||||
if (!Array.isArray(body?.events)) {
|
||||
throw new Error('serve-sim event log response has no events array.');
|
||||
}
|
||||
if (body.events.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const id = (body.events.at(-1) as { id?: unknown } | null)?.id;
|
||||
if (typeof id !== 'number') {
|
||||
throw new Error('serve-sim event log entry has no numeric ID.');
|
||||
}
|
||||
return id;
|
||||
}
|
||||
@@ -154,8 +154,10 @@ export namespace AndroidEmulatorUtils {
|
||||
try {
|
||||
let configIniFileContent = await fs.promises.readFile(configIniFile, 'utf-8');
|
||||
|
||||
logger.info('Setting hw.ramSize to 2048.');
|
||||
configIniFileContent = `${configIniFileContent}\nhw.ramSize=2048\n`;
|
||||
// Give interactive sessions more headroom for guest VSOCK packet allocations.
|
||||
const ramSize = env.DEVICE_RUN_SESSION_ID ? 4096 : 2048;
|
||||
logger.info(`Setting hw.ramSize to ${ramSize}.`);
|
||||
configIniFileContent = `${configIniFileContent}\nhw.ramSize=${ramSize}\n`;
|
||||
|
||||
const shouldResizeScreen =
|
||||
env.ANDROID_EMULATOR_ADJUST_SCREEN === 'true' || env.ANDROID_EMULATOR_ADJUST_SCREEN === '1';
|
||||
|
||||
@@ -136,7 +136,10 @@ describe('AndroidEmulatorUtils', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('does not apply an LCD configuration when it is omitted', async () => {
|
||||
it.each([
|
||||
[undefined, 2048],
|
||||
['device-run-session-id', 4096],
|
||||
])('configures memory for session %s without an LCD override', async (sessionId, ramSize) => {
|
||||
const deviceName = 'generic-emulator' as AndroidVirtualDeviceName;
|
||||
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
|
||||
const initialConfig = ['skin.path=pixel_9', 'showDeviceFrame=yes'].join('\n');
|
||||
@@ -156,12 +159,14 @@ describe('AndroidEmulatorUtils', () => {
|
||||
env: {
|
||||
HOME: '/home/expo',
|
||||
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
|
||||
DEVICE_RUN_SESSION_ID: sessionId,
|
||||
},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
const config = await fs.promises.readFile(`${avdDirectory}/config.ini`, 'utf8');
|
||||
expect(config).toContain(initialConfig);
|
||||
expect(config).toContain(`hw.ramSize=${ramSize}\n`);
|
||||
expect(config).not.toContain('hw.lcd.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import { isConnectFailure, isConnectionInterruptedError } from '../networkErrors';
|
||||
import { CombinedError } from '@urql/core';
|
||||
|
||||
import {
|
||||
describeError,
|
||||
findErrorCode,
|
||||
findGraphqlErrorCode,
|
||||
isConnectFailure,
|
||||
isConnectionInterruptedError,
|
||||
isTransferFailure,
|
||||
isTransientGraphqlError,
|
||||
} from '../networkErrors';
|
||||
|
||||
const { FetchError } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
it.each([
|
||||
'EAI_AGAIN',
|
||||
@@ -34,3 +46,77 @@ it.each(['ETIMEDOUT', 'CERT_HAS_EXPIRED', undefined])('does not automatically re
|
||||
expect(isConnectFailure(error)).toBe(false);
|
||||
expect(isConnectionInterruptedError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('finds the errno code on the cause of an undici fetch failure', () => {
|
||||
const error = new CombinedError({
|
||||
networkError: new TypeError('fetch failed', {
|
||||
cause: Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }),
|
||||
}),
|
||||
});
|
||||
expect(findErrorCode(error)).toBe('ECONNRESET');
|
||||
expect(findErrorCode(new Error('Request failed'))).toBeUndefined();
|
||||
expect(findErrorCode('Request failed')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('finds the www error code on a GraphQL error', () => {
|
||||
const error = new CombinedError({
|
||||
graphQLErrors: [
|
||||
{ message: 'Too many requests', extensions: { errorCode: 'TOO_MANY_REQUESTS' } },
|
||||
],
|
||||
});
|
||||
expect(findGraphqlErrorCode(error)).toBe('TOO_MANY_REQUESTS');
|
||||
expect(
|
||||
findGraphqlErrorCode(new CombinedError({ graphQLErrors: ['Build not found'] }))
|
||||
).toBeUndefined();
|
||||
expect(findGraphqlErrorCode(new Error('Request failed'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Request failed', 'ECONNRESET', 'Request failed (ECONNRESET)'],
|
||||
['read ECONNRESET', 'ECONNRESET', 'read ECONNRESET'],
|
||||
['Request failed', undefined, 'Request failed'],
|
||||
])('describes %p with code %p as %p', (message, code, expected) => {
|
||||
expect(describeError(Object.assign(new Error(message), { code }))).toBe(expected);
|
||||
});
|
||||
|
||||
it('treats a missing response, a 5xx response or an isTransient GraphQL error as transient', () => {
|
||||
expect(
|
||||
isTransientGraphqlError(new CombinedError({ networkError: new Error('fetch failed') }))
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTransientGraphqlError(
|
||||
new CombinedError({ graphQLErrors: ['Internal Server Error'], response: { status: 503 } })
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTransientGraphqlError(
|
||||
new CombinedError({
|
||||
graphQLErrors: [{ message: 'Database unavailable', extensions: { isTransient: true } }],
|
||||
response: { status: 200 },
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTransientGraphqlError(
|
||||
new CombinedError({ graphQLErrors: ['Build not found'], response: { status: 200 } })
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a node-fetch socket failure',
|
||||
new FetchError('request failed', 'system', { code: 'ECONNRESET' } as any),
|
||||
true,
|
||||
],
|
||||
['a node-fetch body size failure', new FetchError('too large', 'max-size'), false],
|
||||
['a body reset', Object.assign(new Error('aborted'), { code: 'ECONNRESET' }), true],
|
||||
[
|
||||
'a body closed early',
|
||||
Object.assign(new Error('closed'), { code: 'ERR_STREAM_PREMATURE_CLOSE' }),
|
||||
true,
|
||||
],
|
||||
['an HTTP error response', new Error('Unexpected response from server (500)'), false],
|
||||
])('classifies %s as a transfer failure or not', (_label, error, expected) => {
|
||||
expect(isTransferFailure(error)).toBe(expected);
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
|
||||
const CONNECT_FAILURE_CODES = new Set([
|
||||
'EAI_AGAIN',
|
||||
'ECONNREFUSED',
|
||||
@@ -30,3 +32,50 @@ export function isConnectionInterruptedError(error: unknown): boolean {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Errno code of the error or its causes; undici keeps it on cause, urql on networkError. */
|
||||
export function findErrorCode(error: unknown): string | undefined {
|
||||
let current: unknown = error;
|
||||
while (current instanceof Error) {
|
||||
if ('code' in current && typeof current.code === 'string') {
|
||||
return current.code;
|
||||
}
|
||||
current = current instanceof CombinedError ? current.networkError : current.cause;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function findGraphqlErrorCode(error: unknown): string | undefined {
|
||||
if (!(error instanceof CombinedError)) {
|
||||
return undefined;
|
||||
}
|
||||
const code = error.graphQLErrors.at(0)?.extensions.errorCode;
|
||||
return typeof code === 'string' ? code : undefined;
|
||||
}
|
||||
|
||||
export function describeError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const code = findErrorCode(error);
|
||||
return code && !message.includes(code) ? `${message} (${code})` : message;
|
||||
}
|
||||
|
||||
export function isTransientGraphqlError(error: CombinedError): boolean {
|
||||
return (
|
||||
Boolean(error.networkError) ||
|
||||
error.response?.status >= 500 ||
|
||||
error.graphQLErrors.some(graphqlError => graphqlError.extensions.isTransient === true)
|
||||
);
|
||||
}
|
||||
|
||||
// node-fetch throws type 'system' for a socket failure before the headers; after them it forwards
|
||||
// ECONNRESET or ERR_STREAM_PREMATURE_CLOSE to the body. HTTP error responses never throw.
|
||||
export function isTransferFailure(error: unknown): boolean {
|
||||
return (
|
||||
(error instanceof Error &&
|
||||
error.name === 'FetchError' &&
|
||||
'type' in error &&
|
||||
error.type === 'system') ||
|
||||
isConnectionInterruptedError(error) ||
|
||||
findErrorCode(error) === 'ERR_STREAM_PREMATURE_CLOSE'
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,13 @@ import promiseRetry from 'promise-retry';
|
||||
export function promiseRetryWithCondition<TFn extends (...args: any[]) => Promise<any>>(
|
||||
fn: TFn,
|
||||
retryConditionFn: (error: any) => boolean,
|
||||
options: { retries: number; factor?: number; minTimeout?: number; maxTimeout?: number } = {
|
||||
options: {
|
||||
retries: number;
|
||||
factor?: number;
|
||||
minTimeout?: number;
|
||||
maxTimeout?: number;
|
||||
randomize?: boolean;
|
||||
} = {
|
||||
retries: 3,
|
||||
factor: 2,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Datadog } from '../datadog';
|
||||
import { findErrorCode, findGraphqlErrorCode } from './networkErrors';
|
||||
import { promiseRetryWithCondition } from './promiseRetryWithCondition';
|
||||
|
||||
export async function retryOnNetworkFailureAsync<T>(
|
||||
attemptAsync: () => Promise<T>,
|
||||
{
|
||||
operation,
|
||||
signal,
|
||||
isRetryable,
|
||||
}: {
|
||||
operation: string;
|
||||
signal?: AbortSignal;
|
||||
isRetryable: (error: unknown) => boolean;
|
||||
}
|
||||
): Promise<T> {
|
||||
const shouldRetry = (error: unknown): boolean => !signal?.aborted && isRetryable(error);
|
||||
let attempts = 1;
|
||||
let lastError: unknown;
|
||||
const report = (outcome: 'recovered' | 'failed', error: unknown): void => {
|
||||
Datadog.log(`${operation} ${outcome} on attempt ${attempts}`, {
|
||||
event: 'network_retry',
|
||||
operation,
|
||||
outcome,
|
||||
attempts: String(attempts),
|
||||
code: findErrorCode(error) ?? findGraphqlErrorCode(error) ?? 'unknown',
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await promiseRetryWithCondition(
|
||||
async () => {
|
||||
signal?.throwIfAborted();
|
||||
return await attemptAsync();
|
||||
},
|
||||
shouldRetry,
|
||||
{ retries: 5, minTimeout: 1_000, factor: 2 },
|
||||
({ attemptNumber, error }) => {
|
||||
attempts = attemptNumber;
|
||||
lastError = error;
|
||||
}
|
||||
)();
|
||||
if (attempts > 1) {
|
||||
report('recovered', lastError);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (attempts > 1 && !signal?.aborted) {
|
||||
report('failed', error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,21 @@
|
||||
# create-eas-build-function
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 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.0.0",
|
||||
"version": "25.3.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "25.0.0"
|
||||
"@expo/steps": "25.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# @expo/downloader
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.0.0
|
||||
|
||||
No changes in this release.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "25.0.0",
|
||||
"version": "25.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# eas-build-cache-provider
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 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.0.0",
|
||||
"version": "25.3.0",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# @expo/eas-build-job
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.0.0
|
||||
|
||||
No changes in this release.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "25.0.0",
|
||||
"version": "25.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,9 +25,9 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "25.0.0",
|
||||
"@expo/logger": "25.3.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "25.0.0",
|
||||
"@expo/turtle-spawn": "25.3.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { DeviceRunSessionEvent, parseDeviceRunSessionEvents } from '../deviceRunSession';
|
||||
import {
|
||||
DeviceRunSessionEvent,
|
||||
parseDeviceRunSessionEvents,
|
||||
quoteAndroidShellCommand,
|
||||
validateAndroidLaunchOptions,
|
||||
} from '../deviceRunSession';
|
||||
|
||||
describe('parseDeviceRunSessionEvents', () => {
|
||||
it('parses, deduplicates, and sorts events from the dedicated event log', () => {
|
||||
@@ -33,6 +38,56 @@ describe('parseDeviceRunSessionEvents', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(quoteAndroidShellCommand, () => {
|
||||
it('quotes only arguments the device shell would split or expand', () => {
|
||||
expect(
|
||||
quoteAndroidShellCommand(['am', 'start', '--es', 'text', 'hello world', "it's", '', '-n'])
|
||||
).toEqual(['am', 'start', '--es', 'text', "'hello world'", "'it'\\''s'", "''", '-n']);
|
||||
});
|
||||
|
||||
it('rejects NUL characters', () => {
|
||||
expect(() => quoteAndroidShellCommand(['am', 'start', 'a\0b'])).toThrow(
|
||||
expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(validateAndroidLaunchOptions, () => {
|
||||
it('rejects only commands that are too long without the launch component', () => {
|
||||
const fixedLength = 'am start --es key -n'.length;
|
||||
const launchArgs = (valueLength: number) => ['--es', 'key', 'a'.repeat(valueLength)];
|
||||
|
||||
expect(() =>
|
||||
validateAndroidLaunchOptions({ launchArgs: launchArgs(65_000 - fixedLength) })
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
validateAndroidLaunchOptions({ launchArgs: launchArgs(65_000 - fixedLength + 1) })
|
||||
).toThrow(expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' }));
|
||||
});
|
||||
|
||||
it('counts bytes, not characters', () => {
|
||||
// "é" is two bytes in UTF-8 and is quoted, which adds two more.
|
||||
const fixedLength = "am start --es key '' -n".length;
|
||||
const launchArgs = (extraLength: number) => [
|
||||
'--es',
|
||||
'key',
|
||||
'a'.repeat(extraLength) + 'é'.repeat(32_488),
|
||||
];
|
||||
|
||||
expect(fixedLength + 1 + 2 * 32_488).toBe(65_000);
|
||||
expect(() => validateAndroidLaunchOptions({ launchArgs: launchArgs(1) })).not.toThrow();
|
||||
expect(() => validateAndroidLaunchOptions({ launchArgs: launchArgs(2) })).toThrow(
|
||||
expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' })
|
||||
);
|
||||
});
|
||||
|
||||
it('checks the URL to open', () => {
|
||||
expect(() => validateAndroidLaunchOptions({ openUrl: 'exp://example.test/a\0b' })).toThrow(
|
||||
expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createEvent(overrides: Partial<DeviceRunSessionEvent> = {}): string {
|
||||
return JSON.stringify(createEventValue(overrides));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { UserError } from './errors';
|
||||
|
||||
/**
|
||||
* Canonical record format for the activity events emitted during a remote device run (simulator)
|
||||
* session. Producers (e.g. the worker and agent-device) write these as NDJSON to the
|
||||
@@ -46,3 +48,54 @@ export function parseDeviceRunSessionEvents(eventLog: string): DeviceRunSessionE
|
||||
(left, right) => new Date(left.ts).getTime() - new Date(right.ts).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
// adb closes the connection for longer shell commands (about 65.5 KB with adb 37 on API 35).
|
||||
export const ANDROID_SHELL_COMMAND_MAX_BYTES = 65_000;
|
||||
|
||||
/**
|
||||
* Check Android launch inputs before a session starts. The launch component is only known once the
|
||||
* build is installed, so this rejects only commands that are too long without it; the launch itself
|
||||
* checks the exact command.
|
||||
*/
|
||||
export function validateAndroidLaunchOptions({
|
||||
launchArgs = [],
|
||||
openUrl,
|
||||
}: {
|
||||
launchArgs?: string[];
|
||||
openUrl?: string;
|
||||
}): void {
|
||||
quoteAndroidShellCommand(['am', 'start', ...launchArgs, '-n']);
|
||||
if (openUrl) {
|
||||
quoteAndroidShellCommand(['am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `adb shell` joins its arguments with spaces and the device shell parses the result again, so
|
||||
* each argument is quoted to reach the command as one literal word.
|
||||
*/
|
||||
export function quoteAndroidShellCommand(command: string[]): string[] {
|
||||
if (command.some(argument => argument.includes('\0'))) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
'Android "launch_args" and "open_url" cannot contain NUL characters.'
|
||||
);
|
||||
}
|
||||
const quotedCommand = command.map(quoteAndroidShellArgument);
|
||||
if (
|
||||
new TextEncoder().encode(quotedCommand.join(' ')).byteLength > ANDROID_SHELL_COMMAND_MAX_BYTES
|
||||
) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
`The Android launch command is longer than ${ANDROID_SHELL_COMMAND_MAX_BYTES} bytes after shell quoting. Shorten "launch_args" or "open_url".`
|
||||
);
|
||||
}
|
||||
return quotedCommand;
|
||||
}
|
||||
|
||||
function quoteAndroidShellArgument(argument: string): string {
|
||||
if (/^[\w@%+=:,./-]+$/.test(argument)) {
|
||||
return argument;
|
||||
}
|
||||
return `'${argument.replaceAll("'", `'\\''`)}'`;
|
||||
}
|
||||
@@ -1,5 +1,32 @@
|
||||
# eas-cli
|
||||
|
||||
## 25.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add a Free plan build limit checkout experiment to `eas build` that offers to open the Starter plan checkout. ([#4633](https://github.com/expo/eas-cli/pull/4633) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- Add a `--system-image` flag to `eas simulator:start` to select the Android SDK system image for the emulator. ([#4500](https://github.com/expo/eas-cli/pull/4500) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
|
||||
## 25.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add experimentation client. ([#4558](https://github.com/expo/eas-cli/pull/4558) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- Add a Free plan build limit guidance experiment for coding agents to `eas build`. ([#4631](https://github.com/expo/eas-cli/pull/4631) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- Add `eas simulator:control` to run serve-sim actions, with file upload and download, on a remote iOS simulator session. ([#4641](https://github.com/expo/eas-cli/pull/4641) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 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
|
||||
|
||||
+315
-162
File diff suppressed because it is too large.
Load diff
Generated
+40
@@ -45707,6 +45707,26 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "corsOrigins",
|
||||
"description": "Origins of pages that may call the session's preview host API cross-origin, for example to\nrender the device with @expo/hub-client. Framing the preview does not need this. Same value\nrules as frameAncestors. At most 10. The page still needs the preview token from remoteConfig.\nIf omitted, only expo.dev may call the preview API cross-origin.",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "egress",
|
||||
"description": "Where the virtual device's proxied network traffic exits to the internet. If\nomitted, all traffic exits from EAS infrastructure.",
|
||||
@@ -45731,6 +45751,26 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "frameAncestors",
|
||||
"description": "Origins of pages that may embed the session's preview host in a frame, next to expo.dev.\nEach value is an https origin such as https://app.example.com or a subdomain wildcard such\nas https://*.example.com; plain http is accepted for localhost only. At most 10. The\nembedding page still needs the preview token from remoteConfig, and whoever embeds the\npreview can also control the device. If omitted, only expo.dev may embed the preview.",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "ios",
|
||||
"description": "iOS Simulator options. Only supported when platform is IOS.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "25.0.0",
|
||||
"version": "25.3.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,11 +48,12 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "25.0.0",
|
||||
"@expo/eas-json": "25.0.0",
|
||||
"@expo/eas-build-job": "25.3.0",
|
||||
"@expo/eas-json": "25.3.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/experimentation": "0.0.5",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "25.0.0",
|
||||
"@expo/logger": "25.3.0",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -64,7 +65,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "25.0.0",
|
||||
"@expo/steps": "25.3.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -184,6 +185,7 @@
|
||||
],
|
||||
"bin": "eas",
|
||||
"commands": "./build/commands",
|
||||
"helpClass": "./build/help",
|
||||
"plugins": [
|
||||
"@oclif/plugin-autocomplete",
|
||||
"@expo/plugin-help",
|
||||
@@ -250,7 +252,7 @@
|
||||
},
|
||||
"update": {
|
||||
"node": {
|
||||
"version": "22.23.3"
|
||||
"version": "22.23.4"
|
||||
},
|
||||
"s3": {
|
||||
"templates": {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { mockCommandContext } from './utils';
|
||||
|
||||
jest.mock('fs');
|
||||
|
||||
class TestCommand extends EasCommand {
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.Experimentation,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {}
|
||||
}
|
||||
|
||||
describe(mockCommandContext, () => {
|
||||
it('provides a disabled experimentation client that returns defaults', async () => {
|
||||
const ctx = mockCommandContext(TestCommand, {});
|
||||
const client = await ctx.experimentation.getClientAsync();
|
||||
expect(client.getUserNamespace('any').getParam('variant', 'control')).toBe('control');
|
||||
expect(client.getAccountNamespace('any', { accountId: 'a' }).getParam('enabled', true)).toBe(
|
||||
true
|
||||
);
|
||||
expect(client.getDeviceNamespace('any').getParam('count', 3)).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,9 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { ContextInput, ContextOutput } from '../../commandUtils/EasCommand';
|
||||
import { DynamicConfigContextFn } from '../../commandUtils/context/DynamicProjectConfigContextField';
|
||||
import { ExperimentOverrides } from '../../experimentation/ExperimentOverrides';
|
||||
import { ExperimentationClient } from '../../experimentation/ExperimentationClient';
|
||||
import { LazyExperimentationClient } from '../../experimentation/LazyExperimentationClient';
|
||||
import {
|
||||
AppFragment,
|
||||
Role,
|
||||
@@ -96,6 +99,11 @@ export function mockCommandContext<
|
||||
if (contextKey === 'sessionManager') {
|
||||
result.sessionManager = {};
|
||||
}
|
||||
if (contextKey === 'experimentation') {
|
||||
result.experimentation = LazyExperimentationClient.fromClient(
|
||||
ExperimentationClient.createDisabled(ExperimentOverrides.empty())
|
||||
);
|
||||
}
|
||||
if (contextKey === 'projectConfig') {
|
||||
result.projectConfig = {
|
||||
exp: overrides.exp ?? getMockExpoConfig(),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Config, Help } from '@oclif/core';
|
||||
|
||||
import SimulatorControl from '../commands/simulator/control';
|
||||
import EasHelp from '../help';
|
||||
|
||||
describe(EasHelp, () => {
|
||||
const config = new Config({ root: __dirname });
|
||||
let run: jest.SpyInstance;
|
||||
let showHelp: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
run = jest.spyOn(SimulatorControl, 'run').mockResolvedValue(Promise.resolve());
|
||||
showHelp = jest.spyOn(Help.prototype, 'showHelp').mockResolvedValue();
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it.each([
|
||||
['simulator:control', 'rotate', '--help'],
|
||||
['sim:control', 'screenshot.capture', '-h', '--id', 'session-123'],
|
||||
['simulator:control', '--id', 'session-123', '--help', 'rotate'],
|
||||
])('routes action help through the command: %j', async (...argv) => {
|
||||
await new EasHelp(config).showHelp(argv);
|
||||
expect(run).toHaveBeenCalledWith(argv.slice(1), config);
|
||||
expect(showHelp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['simulator:control', '--help'],
|
||||
['sim:control', '-h'],
|
||||
['build', '--help'],
|
||||
['simulator:control', 'clipboard.write', '--', '--help'],
|
||||
])('preserves regular help and literal values: %j', async (...argv) => {
|
||||
await new EasHelp(config).showHelp(argv);
|
||||
expect(showHelp).toHaveBeenCalledWith(argv);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -21,12 +21,17 @@ export type AnalyticsEvent =
|
||||
| SubmissionEvent
|
||||
| MetadataEvent
|
||||
| BillingEvent
|
||||
| SimulatorEvent;
|
||||
| SimulatorEvent
|
||||
| ExperimentationEvent;
|
||||
|
||||
export enum CommandEvent {
|
||||
ACTION = 'action', // generic event type which is used to determine the 'daily active user' stat, include an `action: eas ${subcommand}` property inside of the event properties object
|
||||
}
|
||||
|
||||
export enum ExperimentationEvent {
|
||||
EXPERIMENT_VIEWED = 'Experiment Viewed',
|
||||
}
|
||||
|
||||
export enum BillingEvent {
|
||||
SUBSCRIBE_COMMAND = 'billing cli subscribe command',
|
||||
}
|
||||
@@ -64,6 +69,7 @@ export enum BuildEvent {
|
||||
BUILD_REQUEST_ATTEMPT = 'build cli build request attempt',
|
||||
BUILD_REQUEST_SUCCESS = 'build cli build request success',
|
||||
BUILD_REQUEST_FAIL = 'build cli build request fail',
|
||||
FREE_TIER_LIMIT_CHECKOUT_PROMPT = 'build cli free tier limit checkout prompt',
|
||||
|
||||
BUILD_STATUS_COMMAND = 'build cli build status',
|
||||
|
||||
@@ -105,6 +111,8 @@ export interface Analytics {
|
||||
export interface AnalyticsWithOrchestration extends Analytics {
|
||||
setActor(actor: Actor): void;
|
||||
flushAsync(): Promise<void>;
|
||||
/** The persisted RudderStack anonymous ID, or null when analytics is disabled. */
|
||||
getDeviceId(): string | null;
|
||||
}
|
||||
|
||||
const USER_SETTINGS_KEY_AMPLITUDE_ENABLED = 'amplitudeEnabled';
|
||||
@@ -183,6 +191,9 @@ class NoOpAnalytics implements AnalyticsWithOrchestration {
|
||||
logEvent(): void {}
|
||||
setActor(): void {}
|
||||
async flushAsync(): Promise<void> {}
|
||||
getDeviceId(): null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const RudderstackAnalyticsConfig =
|
||||
@@ -234,6 +245,10 @@ class RudderstackAnalytics implements AnalyticsWithOrchestration {
|
||||
this.identifiedActor = actor;
|
||||
}
|
||||
|
||||
public getDeviceId(): string {
|
||||
return this.persistentDeviceId;
|
||||
}
|
||||
|
||||
public logEvent(name: AnalyticsEvent, properties: AnalyticsEventProperties): void {
|
||||
const userId = this.identifiedActor?.id;
|
||||
const deviceId = this.persistentDeviceId;
|
||||
|
||||
@@ -140,3 +140,55 @@ describe(isAnalyticsOptedOutAsync, () => {
|
||||
await expect(isAnalyticsOptedOutAsync()).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDeviceId', () => {
|
||||
// Backs UserSettings with an in-memory store so a value written by createAnalyticsAsync is read back.
|
||||
function useInMemoryUserSettings(initial: Record<string, unknown>): void {
|
||||
const stored: Record<string, unknown> = { ...initial };
|
||||
(userSettingsMock.setAsync as jest.Mock).mockImplementation(
|
||||
async (key: string, value: unknown) => {
|
||||
stored[key] = value;
|
||||
return {};
|
||||
}
|
||||
);
|
||||
(userSettingsMock.getAsync as jest.Mock).mockImplementation(
|
||||
async (key: string, defaultValue: unknown) => (key in stored ? stored[key] : defaultValue)
|
||||
);
|
||||
}
|
||||
|
||||
it('returns the persisted analytics device ID', async () => {
|
||||
const analytics = await createAnalyticsAsync();
|
||||
expect(analytics.getDeviceId()).toBe('persistent-device-id');
|
||||
});
|
||||
|
||||
it('creates and persists a device ID when none is stored', async () => {
|
||||
userSettingsMock.getAsync.mockImplementation(async (key, defaultValue) =>
|
||||
key === 'analyticsDeviceId' ? null : defaultValue
|
||||
);
|
||||
const analytics = await createAnalyticsAsync();
|
||||
const deviceId = analytics.getDeviceId();
|
||||
expect(deviceId).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(userSettingsMock.setAsync).toHaveBeenCalledWith('analyticsDeviceId', deviceId);
|
||||
});
|
||||
|
||||
it('is null when DISABLE_EAS_ANALYTICS is set', async () => {
|
||||
useInMemoryUserSettings({ analyticsDeviceId: 'persistent-device-id' });
|
||||
process.env.DISABLE_EAS_ANALYTICS = '1';
|
||||
const analytics = await createAnalyticsAsync();
|
||||
expect(analytics.getDeviceId()).toBeNull();
|
||||
});
|
||||
|
||||
it('is null when running behind https_proxy', async () => {
|
||||
process.env.https_proxy = 'http://proxy.local:8080';
|
||||
const analytics = await createAnalyticsAsync();
|
||||
expect(analytics.getDeviceId()).toBeNull();
|
||||
});
|
||||
|
||||
it('is null when analytics were turned off with eas analytics off', async () => {
|
||||
userSettingsMock.getAsync.mockImplementation(async (key, defaultValue) =>
|
||||
key === 'analyticsEnabled' ? false : defaultValue
|
||||
);
|
||||
const analytics = await createAnalyticsAsync();
|
||||
expect(analytics.getDeviceId()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,7 @@ function hasBillingPermission(permissions: readonly Permission[]): boolean {
|
||||
* Whether the viewer can manage billing on the account with the credential used for this command.
|
||||
* The scopes of a scoped credential cap `effectivePermissions`.
|
||||
*/
|
||||
function canManageBilling(account: { viewerUserPermission2: ViewerPermissions }): boolean {
|
||||
export function canManageBilling(account: { viewerUserPermission2: ViewerPermissions }): boolean {
|
||||
return hasBillingPermission(account.viewerUserPermission2.effectivePermissions);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
import type { ExperimentationDefinition } from '@expo/experimentation';
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
|
||||
import { getAgentTelemetryContext } from '../../analytics/agent';
|
||||
import { BillingClient } from '../../billing/billingClient';
|
||||
import { openOrPrintUrlAsync } from '../../billing/openUrl';
|
||||
import { ExperimentOverrides } from '../../experimentation/ExperimentOverrides';
|
||||
import {
|
||||
EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
ExperimentationClient,
|
||||
createLoggerConfig,
|
||||
} from '../../experimentation/ExperimentationClient';
|
||||
import { LazyExperimentationClient } from '../../experimentation/LazyExperimentationClient';
|
||||
import { createMockAnalytics, findUnitsByValue } from '../../experimentation/__tests__/fixtures';
|
||||
import { getMockViewerUserPermission } from '../../__tests__/viewerUserPermission';
|
||||
import { Permission, Role } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { confirmAsync } from '../../prompts';
|
||||
import { Actor } from '../../user/User';
|
||||
import {
|
||||
FREE_TIER_LIMIT_CHECKOUT_EXPERIMENT_NAMESPACE,
|
||||
FreeTierLimitCheckoutExperiment,
|
||||
getFreeTierLimitCheckoutExperiment,
|
||||
isEligibleForFreeTierLimitCheckout,
|
||||
maybeOfferFreeTierLimitCheckoutAsync,
|
||||
resolveFreeTierLimitCheckoutVariantAsync,
|
||||
} from '../freeTierLimitCheckout';
|
||||
|
||||
jest.mock('../../analytics/agent');
|
||||
jest.mock('../../billing/billingClient');
|
||||
jest.mock('../../billing/openUrl');
|
||||
jest.mock('../../ora');
|
||||
jest.mock('../../prompts');
|
||||
|
||||
const EXPERIMENT_NAME = 'cli-build-free-tier-limit-checkout';
|
||||
const ACCOUNT_NAME = 'acme';
|
||||
const AUTHENTICATION_INFO = { accessToken: 'token', sessionSecret: null } as const;
|
||||
const ANALYTICS_EVENT_PROPERTIES = { tracking_id: 'tracking-id', platform: Platform.ANDROID };
|
||||
const LIMIT_MESSAGE = 'Error 1\nRun eas billing:subscribe starter --account acme to upgrade to the Starter plan.';
|
||||
|
||||
/** Every account is in the experiment: 50% control, 50% treatment. */
|
||||
const ACCOUNT_DEFINITION: ExperimentationDefinition = {
|
||||
experiments: [
|
||||
{
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
paramDefinitions: {
|
||||
variant: { type: 'uniform_choice', choices: ['control', 'treatment'] },
|
||||
},
|
||||
exposureLoggerType: 'rudderstack',
|
||||
},
|
||||
],
|
||||
namespaces: [
|
||||
{
|
||||
name: FREE_TIER_LIMIT_CHECKOUT_EXPERIMENT_NAMESPACE,
|
||||
numSegments: 1000,
|
||||
segmentExperimentSetupDefinitions: [
|
||||
{
|
||||
method: 'ADD',
|
||||
name: `${EXPERIMENT_NAME} full`,
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
numSegments: 1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function createClient(definition: ExperimentationDefinition = ACCOUNT_DEFINITION): {
|
||||
client: ExperimentationClient;
|
||||
analytics: ReturnType<typeof createMockAnalytics>;
|
||||
} {
|
||||
const analytics = createMockAnalytics();
|
||||
const client = new ExperimentationClient(
|
||||
{
|
||||
user: EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
account: definition,
|
||||
device: EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
},
|
||||
{ user: null, device: null },
|
||||
ExperimentOverrides.empty(),
|
||||
createLoggerConfig(analytics)
|
||||
);
|
||||
return { client, analytics };
|
||||
}
|
||||
|
||||
/** Probes a throwaway client, so the exposures it logs do not affect the client under test. */
|
||||
function findAccountIdsByVariant(): Map<string, string> {
|
||||
const { client } = createClient();
|
||||
return findUnitsByValue(
|
||||
accountId =>
|
||||
client.getAccountNamespace(FREE_TIER_LIMIT_CHECKOUT_EXPERIMENT_NAMESPACE, { accountId }),
|
||||
'variant',
|
||||
'control',
|
||||
['control', 'treatment']
|
||||
);
|
||||
}
|
||||
|
||||
function createOverriddenClient(variant: string): ExperimentationClient {
|
||||
return ExperimentationClient.createDisabled(
|
||||
new ExperimentOverrides(
|
||||
new Map([[FREE_TIER_LIMIT_CHECKOUT_EXPERIMENT_NAMESPACE, new Map([['variant', variant]])]])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function account(
|
||||
id: string,
|
||||
role: Parameters<typeof getMockViewerUserPermission>[0],
|
||||
{ effectivePermissions }: { effectivePermissions?: Permission[] } = {}
|
||||
): Actor['accounts'][number] {
|
||||
const viewerUserPermission2 = getMockViewerUserPermission(role);
|
||||
return {
|
||||
id,
|
||||
name: ACCOUNT_NAME,
|
||||
viewerUserPermission2: {
|
||||
...viewerUserPermission2,
|
||||
effectivePermissions: effectivePermissions ?? viewerUserPermission2.effectivePermissions,
|
||||
},
|
||||
} as Actor['accounts'][number];
|
||||
}
|
||||
|
||||
function createExperiment(
|
||||
accountId: string,
|
||||
{
|
||||
client = createClient().client,
|
||||
accounts = [account(accountId, Role.Admin)],
|
||||
nonInteractive = false,
|
||||
runFromCI = false,
|
||||
analytics = createMockAnalytics(),
|
||||
}: {
|
||||
client?: ExperimentationClient;
|
||||
accounts?: Actor['accounts'];
|
||||
nonInteractive?: boolean;
|
||||
runFromCI?: boolean;
|
||||
analytics?: ReturnType<typeof createMockAnalytics>;
|
||||
} = {}
|
||||
): FreeTierLimitCheckoutExperiment {
|
||||
return {
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
authenticationInfo: AUTHENTICATION_INFO,
|
||||
analytics,
|
||||
analyticsEventProperties: ANALYTICS_EVENT_PROPERTIES,
|
||||
actor: { id: 'actor-id', accounts } as Actor,
|
||||
accountId,
|
||||
accountName: ACCOUNT_NAME,
|
||||
nonInteractive,
|
||||
runFromCI,
|
||||
};
|
||||
}
|
||||
|
||||
const accountIdsByVariant = findAccountIdsByVariant();
|
||||
const treatmentAccountId = accountIdsByVariant.get('treatment')!;
|
||||
const controlAccountId = accountIdsByVariant.get('control')!;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
|
||||
});
|
||||
|
||||
describe(getFreeTierLimitCheckoutExperiment, () => {
|
||||
const ctx = {
|
||||
analytics: createMockAnalytics(),
|
||||
analyticsEventProperties: ANALYTICS_EVENT_PROPERTIES,
|
||||
user: { id: 'actor-id', accounts: [] } as unknown as Actor,
|
||||
accountId: 'account-id',
|
||||
accountName: ACCOUNT_NAME,
|
||||
nonInteractive: false,
|
||||
runFromCI: false,
|
||||
};
|
||||
|
||||
it('returns the handle when the command has an experimentation client and auth info', () => {
|
||||
const experimentation = new LazyExperimentationClient(async () => createClient().client);
|
||||
|
||||
expect(
|
||||
getFreeTierLimitCheckoutExperiment({
|
||||
...ctx,
|
||||
experimentation,
|
||||
authenticationInfo: AUTHENTICATION_INFO,
|
||||
})
|
||||
).toEqual({
|
||||
experimentation,
|
||||
authenticationInfo: AUTHENTICATION_INFO,
|
||||
analytics: ctx.analytics,
|
||||
analyticsEventProperties: ANALYTICS_EVENT_PROPERTIES,
|
||||
actor: ctx.user,
|
||||
accountId: 'account-id',
|
||||
accountName: ACCOUNT_NAME,
|
||||
nonInteractive: false,
|
||||
runFromCI: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined when the command has no experimentation client', () => {
|
||||
expect(
|
||||
getFreeTierLimitCheckoutExperiment({ ...ctx, authenticationInfo: AUTHENTICATION_INFO })
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when the command has no auth info', () => {
|
||||
expect(
|
||||
getFreeTierLimitCheckoutExperiment({
|
||||
...ctx,
|
||||
experimentation: new LazyExperimentationClient(async () => createClient().client),
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe(isEligibleForFreeTierLimitCheckout, () => {
|
||||
it.each([
|
||||
['admins', Role.Admin],
|
||||
['owners', Role.Owner],
|
||||
] as const)('accepts %s at an interactive terminal', (_label, role) => {
|
||||
expect(
|
||||
isEligibleForFreeTierLimitCheckout(
|
||||
createExperiment('account-id', { accounts: [account('account-id', role)] })
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects admins whose credential scopes do not allow managing billing', () => {
|
||||
expect(
|
||||
isEligibleForFreeTierLimitCheckout(
|
||||
createExperiment('account-id', {
|
||||
accounts: [
|
||||
account('account-id', Role.Admin, { effectivePermissions: [Permission.View] }),
|
||||
],
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['developers', Role.Developer],
|
||||
['view-only members', Role.ViewOnly],
|
||||
] as const)('rejects %s', (_label, role) => {
|
||||
expect(
|
||||
isEligibleForFreeTierLimitCheckout(
|
||||
createExperiment('account-id', { accounts: [account('account-id', role)] })
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects actors who are not a member of the account', () => {
|
||||
expect(
|
||||
isEligibleForFreeTierLimitCheckout(
|
||||
createExperiment('account-id', { accounts: [account('other-account-id', Role.Admin)] })
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-interactive runs', () => {
|
||||
expect(
|
||||
isEligibleForFreeTierLimitCheckout(createExperiment('account-id', { nonInteractive: true }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects runs from CI', () => {
|
||||
expect(
|
||||
isEligibleForFreeTierLimitCheckout(createExperiment('account-id', { runFromCI: true }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects runs driven by a coding agent', () => {
|
||||
jest
|
||||
.mocked(getAgentTelemetryContext)
|
||||
.mockReturnValue({ id: 'claude-code', sessionId: 'session-1' });
|
||||
|
||||
expect(isEligibleForFreeTierLimitCheckout(createExperiment('account-id'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveFreeTierLimitCheckoutVariantAsync, () => {
|
||||
it('does not load the client for ineligible runs', async () => {
|
||||
const loadAsync = jest.fn(async () => createClient().client);
|
||||
|
||||
expect(
|
||||
await resolveFreeTierLimitCheckoutVariantAsync({
|
||||
...createExperiment(treatmentAccountId, { nonInteractive: true }),
|
||||
experimentation: new LazyExperimentationClient(loadAsync),
|
||||
})
|
||||
).toBe('control');
|
||||
expect(loadAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the account variant and logs the exposure once', async () => {
|
||||
const { client, analytics } = createClient();
|
||||
const experiment = createExperiment(treatmentAccountId, { client });
|
||||
|
||||
expect(await resolveFreeTierLimitCheckoutVariantAsync(experiment)).toBe('treatment');
|
||||
expect(await resolveFreeTierLimitCheckoutVariantAsync(experiment)).toBe('treatment');
|
||||
|
||||
expect(analytics.logEvent).toHaveBeenCalledTimes(1);
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
variationName: 'treatment',
|
||||
unit: treatmentAccountId,
|
||||
});
|
||||
});
|
||||
|
||||
it('logs the exposure for control accounts too', async () => {
|
||||
const { client, analytics } = createClient();
|
||||
|
||||
expect(
|
||||
await resolveFreeTierLimitCheckoutVariantAsync(createExperiment(controlAccountId, { client }))
|
||||
).toBe('control');
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
variationName: 'control',
|
||||
unit: controlAccountId,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns control and logs no exposure when the namespace is missing from the config', async () => {
|
||||
const { client, analytics } = createClient(EMPTY_EXPERIMENTATION_DEFINITION);
|
||||
|
||||
expect(
|
||||
await resolveFreeTierLimitCheckoutVariantAsync(
|
||||
createExperiment(treatmentAccountId, { client })
|
||||
)
|
||||
).toBe('control');
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(maybeOfferFreeTierLimitCheckoutAsync, () => {
|
||||
const createCheckoutSessionAsync = jest.fn();
|
||||
const spinner = { start: jest.fn(), stop: jest.fn(), fail: jest.fn() };
|
||||
let logErrorSpy: jest.SpyInstance;
|
||||
let logLogSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
spinner.start.mockReturnValue(spinner);
|
||||
jest.mocked(ora).mockReturnValue(spinner as any);
|
||||
jest
|
||||
.mocked(BillingClient)
|
||||
.mockImplementation(() => ({ createCheckoutSessionAsync }) as unknown as BillingClient);
|
||||
logErrorSpy = jest.spyOn(Log, 'error').mockImplementation(() => {});
|
||||
logLogSpy = jest.spyOn(Log, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
function treatmentExperiment(
|
||||
options: Parameters<typeof createExperiment>[1] = {}
|
||||
): FreeTierLimitCheckoutExperiment {
|
||||
return createExperiment('account-id', { client: createOverriddenClient('treatment'), ...options });
|
||||
}
|
||||
|
||||
it('does nothing when the command has no experiment handle', async () => {
|
||||
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, undefined)).toBe(false);
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(logErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing in the control arm', async () => {
|
||||
const experiment = createExperiment('account-id', { client: createOverriddenClient('control') });
|
||||
|
||||
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, experiment)).toBe(false);
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(logErrorSpy).not.toHaveBeenCalled();
|
||||
expect(experiment.analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing for ineligible runs even in the treatment arm', async () => {
|
||||
const experiment = treatmentExperiment({ nonInteractive: true });
|
||||
|
||||
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, experiment)).toBe(false);
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(logErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints the error, then asks with No as the default', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(false);
|
||||
|
||||
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, treatmentExperiment())).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
expect(logErrorSpy).toHaveBeenCalledWith(LIMIT_MESSAGE);
|
||||
expect(confirmAsync).toHaveBeenCalledWith(
|
||||
{ message: 'Open the Starter plan checkout in your browser now?', initial: false },
|
||||
{ onCancel: expect.any(Function) }
|
||||
);
|
||||
expect(logErrorSpy.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(confirmAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the process alive on Ctrl+C so the build error still reports', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(undefined as unknown as boolean);
|
||||
const experiment = treatmentExperiment();
|
||||
|
||||
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, experiment)).toBe(true);
|
||||
|
||||
const [, options] = jest.mocked(confirmAsync).mock.calls[0];
|
||||
expect(options?.onCancel?.({} as any, {})).toBe(false);
|
||||
expect(BillingClient).not.toHaveBeenCalled();
|
||||
expect(experiment.analytics.logEvent).toHaveBeenCalledWith(
|
||||
'build cli free tier limit checkout prompt',
|
||||
{ ...ANALYTICS_EVENT_PROPERTIES, answer: 'cancel', checkout_created: false }
|
||||
);
|
||||
});
|
||||
|
||||
it('creates nothing when the person declines', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(false);
|
||||
const experiment = treatmentExperiment();
|
||||
|
||||
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, experiment)).toBe(true);
|
||||
|
||||
expect(BillingClient).not.toHaveBeenCalled();
|
||||
expect(openOrPrintUrlAsync).not.toHaveBeenCalled();
|
||||
expect(experiment.analytics.logEvent).toHaveBeenCalledWith(
|
||||
'build cli free tier limit checkout prompt',
|
||||
{ ...ANALYTICS_EVENT_PROPERTIES, answer: 'no', checkout_created: false }
|
||||
);
|
||||
});
|
||||
|
||||
it('opens the Starter checkout when the person accepts', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs_1', url: 'https://checkout.test/cs_1' });
|
||||
const experiment = treatmentExperiment();
|
||||
|
||||
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, experiment)).toBe(true);
|
||||
|
||||
expect(BillingClient).toHaveBeenCalledWith(AUTHENTICATION_INFO);
|
||||
expect(createCheckoutSessionAsync).toHaveBeenCalledWith('account-id', 'STARTER');
|
||||
expect(spinner.stop).toHaveBeenCalled();
|
||||
expect(spinner.fail).not.toHaveBeenCalled();
|
||||
expect(openOrPrintUrlAsync).toHaveBeenCalledWith('https://checkout.test/cs_1', {
|
||||
label: 'Checkout page',
|
||||
open: true,
|
||||
});
|
||||
expect(logLogSpy).not.toHaveBeenCalled();
|
||||
expect(experiment.analytics.logEvent).toHaveBeenCalledWith(
|
||||
'build cli free tier limit checkout prompt',
|
||||
{ ...ANALYTICS_EVENT_PROPERTIES, answer: 'yes', checkout_created: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('prints the billing link when the checkout session request fails', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
createCheckoutSessionAsync.mockRejectedValue(new Error('network down'));
|
||||
const experiment = treatmentExperiment();
|
||||
|
||||
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, experiment)).toBe(true);
|
||||
|
||||
expect(spinner.fail).toHaveBeenCalledWith('Failed to create a checkout session');
|
||||
expect(openOrPrintUrlAsync).not.toHaveBeenCalled();
|
||||
expect(logLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`/accounts/${ACCOUNT_NAME}/settings/billing`)
|
||||
);
|
||||
expect(experiment.analytics.logEvent).toHaveBeenCalledWith(
|
||||
'build cli free tier limit checkout prompt',
|
||||
{ ...ANALYTICS_EVENT_PROPERTIES, answer: 'yes', checkout_created: false }
|
||||
);
|
||||
});
|
||||
|
||||
it('prints the billing link when the checkout session has no URL', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs_1', url: null });
|
||||
const experiment = treatmentExperiment();
|
||||
|
||||
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, experiment)).toBe(true);
|
||||
|
||||
expect(spinner.fail).toHaveBeenCalledWith('Failed to create a checkout session');
|
||||
expect(openOrPrintUrlAsync).not.toHaveBeenCalled();
|
||||
expect(logLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`/accounts/${ACCOUNT_NAME}/settings/billing`)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import type { ExperimentationDefinition } from '@expo/experimentation';
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
|
||||
import { getAgentTelemetryContext } from '../../analytics/agent';
|
||||
import { ExperimentOverrides } from '../../experimentation/ExperimentOverrides';
|
||||
import {
|
||||
EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
ExperimentationClient,
|
||||
createLoggerConfig,
|
||||
} from '../../experimentation/ExperimentationClient';
|
||||
import { LazyExperimentationClient } from '../../experimentation/LazyExperimentationClient';
|
||||
import { createMockAnalytics, findUnitsByValue } from '../../experimentation/__tests__/fixtures';
|
||||
import {
|
||||
FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE,
|
||||
getFreeTierLimitAgentGuidanceExperiment,
|
||||
maybeAppendFreeTierLimitAgentGuidanceAsync,
|
||||
resolveFreeTierLimitAgentGuidanceVariantAsync,
|
||||
} from '../freeTierLimitGuidance';
|
||||
|
||||
jest.mock('../../analytics/agent');
|
||||
|
||||
const EXPERIMENT_NAME = 'cli-build-free-tier-limit-agent-guidance';
|
||||
|
||||
/** Every account is in the experiment: 50% control, 50% treatment. */
|
||||
const ACCOUNT_DEFINITION: ExperimentationDefinition = {
|
||||
experiments: [
|
||||
{
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
paramDefinitions: {
|
||||
variant: { type: 'uniform_choice', choices: ['control', 'treatment'] },
|
||||
},
|
||||
exposureLoggerType: 'rudderstack',
|
||||
},
|
||||
],
|
||||
namespaces: [
|
||||
{
|
||||
name: FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE,
|
||||
numSegments: 1000,
|
||||
segmentExperimentSetupDefinitions: [
|
||||
{
|
||||
method: 'ADD',
|
||||
name: `${EXPERIMENT_NAME} full`,
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
numSegments: 1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function createClient(): {
|
||||
client: ExperimentationClient;
|
||||
analytics: ReturnType<typeof createMockAnalytics>;
|
||||
} {
|
||||
const analytics = createMockAnalytics();
|
||||
const client = new ExperimentationClient(
|
||||
{
|
||||
user: EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
account: ACCOUNT_DEFINITION,
|
||||
device: EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
},
|
||||
{ user: null, device: null },
|
||||
ExperimentOverrides.empty(),
|
||||
createLoggerConfig(analytics)
|
||||
);
|
||||
return { client, analytics };
|
||||
}
|
||||
|
||||
/** Probes a throwaway client, so the exposures it logs do not affect the client under test. */
|
||||
function findAccountIdsByVariant(): Map<string, string> {
|
||||
const { client } = createClient();
|
||||
return findUnitsByValue(
|
||||
accountId =>
|
||||
client.getAccountNamespace(FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE, {
|
||||
accountId,
|
||||
}),
|
||||
'variant',
|
||||
'control',
|
||||
['control', 'treatment']
|
||||
);
|
||||
}
|
||||
|
||||
describe(getFreeTierLimitAgentGuidanceExperiment, () => {
|
||||
it('returns the handle when the command has an experimentation client', () => {
|
||||
const experimentation = new LazyExperimentationClient(async () => createClient().client);
|
||||
|
||||
expect(
|
||||
getFreeTierLimitAgentGuidanceExperiment({ experimentation, accountId: 'account-id' })
|
||||
).toEqual({ experimentation, accountId: 'account-id' });
|
||||
});
|
||||
|
||||
it('returns undefined when the command has no experimentation client', () => {
|
||||
expect(getFreeTierLimitAgentGuidanceExperiment({ accountId: 'account-id' })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveFreeTierLimitAgentGuidanceVariantAsync, () => {
|
||||
const accountIdsByVariant = findAccountIdsByVariant();
|
||||
const treatmentAccountId = accountIdsByVariant.get('treatment')!;
|
||||
const controlAccountId = accountIdsByVariant.get('control')!;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest
|
||||
.mocked(getAgentTelemetryContext)
|
||||
.mockReturnValue({ id: 'claude-code', sessionId: 'session-1' });
|
||||
});
|
||||
|
||||
it('does not load the client when no coding agent runs the CLI', async () => {
|
||||
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
|
||||
const loadAsync = jest.fn(async () => createClient().client);
|
||||
|
||||
expect(
|
||||
await resolveFreeTierLimitAgentGuidanceVariantAsync({
|
||||
experimentation: new LazyExperimentationClient(loadAsync),
|
||||
accountId: treatmentAccountId,
|
||||
})
|
||||
).toBe('control');
|
||||
expect(loadAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads the client once across repeated reads', async () => {
|
||||
const { client, analytics } = createClient();
|
||||
const loadAsync = jest.fn(async () => client);
|
||||
const experiment = {
|
||||
experimentation: new LazyExperimentationClient(loadAsync),
|
||||
accountId: treatmentAccountId,
|
||||
};
|
||||
|
||||
await resolveFreeTierLimitAgentGuidanceVariantAsync(experiment);
|
||||
await resolveFreeTierLimitAgentGuidanceVariantAsync(experiment);
|
||||
|
||||
expect(loadAsync).toHaveBeenCalledTimes(1);
|
||||
expect(analytics.logEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns control and logs no exposure when no coding agent runs the CLI', async () => {
|
||||
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
|
||||
const { client, analytics } = createClient();
|
||||
|
||||
expect(
|
||||
await resolveFreeTierLimitAgentGuidanceVariantAsync({
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
accountId: treatmentAccountId,
|
||||
})
|
||||
).toBe('control');
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the account variant and logs the exposure once when a coding agent runs the CLI', async () => {
|
||||
const { client, analytics } = createClient();
|
||||
|
||||
expect(
|
||||
await resolveFreeTierLimitAgentGuidanceVariantAsync({
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
accountId: treatmentAccountId,
|
||||
})
|
||||
).toBe('treatment');
|
||||
expect(
|
||||
await resolveFreeTierLimitAgentGuidanceVariantAsync({
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
accountId: treatmentAccountId,
|
||||
})
|
||||
).toBe('treatment');
|
||||
|
||||
expect(analytics.logEvent).toHaveBeenCalledTimes(1);
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
variationName: 'treatment',
|
||||
unit: treatmentAccountId,
|
||||
});
|
||||
});
|
||||
|
||||
it('logs the exposure for control accounts too', async () => {
|
||||
const { client, analytics } = createClient();
|
||||
|
||||
expect(
|
||||
await resolveFreeTierLimitAgentGuidanceVariantAsync({
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
accountId: controlAccountId,
|
||||
})
|
||||
).toBe('control');
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
variationName: 'control',
|
||||
unit: controlAccountId,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns control and logs no exposure when the namespace is missing from the config', async () => {
|
||||
const analytics = createMockAnalytics();
|
||||
const client = new ExperimentationClient(
|
||||
{
|
||||
user: EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
account: EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
device: EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
},
|
||||
{ user: null, device: null },
|
||||
ExperimentOverrides.empty(),
|
||||
createLoggerConfig(analytics)
|
||||
);
|
||||
|
||||
expect(
|
||||
await resolveFreeTierLimitAgentGuidanceVariantAsync({
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
accountId: treatmentAccountId,
|
||||
})
|
||||
).toBe('control');
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(maybeAppendFreeTierLimitAgentGuidanceAsync, () => {
|
||||
const treatmentAccountId = findAccountIdsByVariant().get('treatment')!;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest
|
||||
.mocked(getAgentTelemetryContext)
|
||||
.mockReturnValue({ id: 'claude-code', sessionId: 'session-1' });
|
||||
});
|
||||
|
||||
it('returns the message unchanged and logs no exposure when the command has no experimentation client', async () => {
|
||||
expect(await maybeAppendFreeTierLimitAgentGuidanceAsync('Error 1', Platform.ANDROID, undefined)).toBe(
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
|
||||
it('logs the exposure and appends the guidance in the treatment arm', async () => {
|
||||
const { client, analytics } = createClient();
|
||||
|
||||
expect(
|
||||
await maybeAppendFreeTierLimitAgentGuidanceAsync('Error 1', Platform.IOS, {
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
accountId: treatmentAccountId,
|
||||
})
|
||||
).toBe(
|
||||
[
|
||||
'Error 1',
|
||||
'Fix: Human required: iOS builds for this account are blocked until the Free plan resets. Upgrading costs money, so an account owner or admin decides.',
|
||||
].join('\n')
|
||||
);
|
||||
expect(analytics.logEvent).toHaveBeenCalledTimes(1);
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
variationName: 'treatment',
|
||||
unit: treatmentAccountId,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the message unchanged in the control arm', async () => {
|
||||
const client = ExperimentationClient.createDisabled(
|
||||
new ExperimentOverrides(
|
||||
new Map([
|
||||
[FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE, new Map([['variant', 'control']])],
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
expect(
|
||||
await maybeAppendFreeTierLimitAgentGuidanceAsync('Error 1', Platform.ANDROID, {
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
accountId: treatmentAccountId,
|
||||
})
|
||||
).toBe('Error 1');
|
||||
});
|
||||
|
||||
it('returns the message unchanged and logs no exposure when no coding agent runs the CLI', async () => {
|
||||
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
|
||||
const { client, analytics } = createClient();
|
||||
|
||||
expect(
|
||||
await maybeAppendFreeTierLimitAgentGuidanceAsync('Error 1', Platform.ANDROID, {
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
accountId: treatmentAccountId,
|
||||
})
|
||||
).toBe('Error 1');
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,11 +3,15 @@ import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql/error';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { getError } from '../../__tests__/commands/utils';
|
||||
import { getErrorAsync } from '../../__tests__/commands/utils';
|
||||
import { getAgentTelemetryContext } from '../../analytics/agent';
|
||||
import { EasCommandError } from '../../commandUtils/errors';
|
||||
import Build from '../../commands/build';
|
||||
import { ExperimentOverrides } from '../../experimentation/ExperimentOverrides';
|
||||
import { ExperimentationClient } from '../../experimentation/ExperimentationClient';
|
||||
import { LazyExperimentationClient } from '../../experimentation/LazyExperimentationClient';
|
||||
import Log, { link } from '../../log';
|
||||
import { handleBuildRequestError } from '../build';
|
||||
import { handleBuildRequestErrorAsync } from '../build';
|
||||
import {
|
||||
EasBuildDownForMaintenanceError,
|
||||
EasBuildFreeTierDisabledAndroidError,
|
||||
@@ -20,11 +24,45 @@ import {
|
||||
RequestValidationError,
|
||||
TurtleDeprecatedJobFormatError,
|
||||
} from '../errors';
|
||||
import {
|
||||
FreeTierLimitCheckoutExperiment,
|
||||
maybeOfferFreeTierLimitCheckoutAsync,
|
||||
} from '../freeTierLimitCheckout';
|
||||
import {
|
||||
FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE,
|
||||
FreeTierLimitAgentGuidanceExperiment,
|
||||
} from '../freeTierLimitGuidance';
|
||||
|
||||
jest.mock('../../analytics/agent');
|
||||
jest.mock('../freeTierLimitCheckout', () => ({
|
||||
...jest.requireActual('../freeTierLimitCheckout'),
|
||||
maybeOfferFreeTierLimitCheckoutAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const CHECKOUT_EXPERIMENT = { accountId: 'account-id' } as FreeTierLimitCheckoutExperiment;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
|
||||
jest.mocked(maybeOfferFreeTierLimitCheckoutAsync).mockResolvedValue(false);
|
||||
});
|
||||
|
||||
/** An experiment handle whose client is forced to the given variant through an override. */
|
||||
function createExperiment(variant: string): FreeTierLimitAgentGuidanceExperiment {
|
||||
return {
|
||||
accountId: 'account-id',
|
||||
experimentation: LazyExperimentationClient.fromClient(
|
||||
ExperimentationClient.createDisabled(
|
||||
new ExperimentOverrides(
|
||||
new Map([
|
||||
[FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE, new Map([['variant', variant]])],
|
||||
])
|
||||
)
|
||||
)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const EXPECTED_STRINGIFIED_GRAPHQL_ERROR_JSON = `Build request error details:
|
||||
{
|
||||
"message": "[GraphQL] Error 1\\n[GraphQL] Error 2\\n[GraphQL] Error 3",
|
||||
@@ -67,11 +105,15 @@ const getGraphQLError = (message: string, errorCode: string): GraphQLError => {
|
||||
|
||||
const assertReThrownError = (
|
||||
caughtError: CombinedError | Error,
|
||||
expectedType: typeof EasCommandError,
|
||||
expectedMessage: string = 'Error 1'
|
||||
expectedType: new (...args: any[]) => Error,
|
||||
expectedMessage: string = 'Error 1',
|
||||
{ messageAlreadyPrinted = false }: { messageAlreadyPrinted?: boolean } = {}
|
||||
): void => {
|
||||
expect(caughtError).toBeInstanceOf(expectedType);
|
||||
expect((caughtError as Error).message).toEqual(expectedMessage);
|
||||
expect((caughtError as EasCommandError).messageAlreadyPrinted ?? false).toBe(
|
||||
messageAlreadyPrinted
|
||||
);
|
||||
};
|
||||
|
||||
describe(Build.name, () => {
|
||||
@@ -84,7 +126,7 @@ describe(Build.name, () => {
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
try {
|
||||
handleBuildRequestError(error, platform);
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
} catch {}
|
||||
|
||||
expect(logDebugSpy).toBeCalledWith(EXPECTED_STRINGIFIED_GRAPHQL_ERROR_JSON);
|
||||
@@ -97,7 +139,7 @@ describe(Build.name, () => {
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
try {
|
||||
handleBuildRequestError(error, platform);
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
} catch {}
|
||||
|
||||
expect(logDebugSpy).toBeCalledWith(EXPECTED_STRINGIFIED_GRAPHQL_ERROR_JSON);
|
||||
@@ -111,7 +153,7 @@ describe(Build.name, () => {
|
||||
const error = new Error('Generic error');
|
||||
|
||||
try {
|
||||
handleBuildRequestError(error, platform);
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
} catch {}
|
||||
|
||||
expect(logDebugSpy).not.toHaveBeenCalled();
|
||||
@@ -123,7 +165,7 @@ describe(Build.name, () => {
|
||||
const error = new Error('Generic error');
|
||||
|
||||
try {
|
||||
handleBuildRequestError(error, platform);
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
} catch {}
|
||||
|
||||
expect(logDebugSpy).not.toHaveBeenCalled();
|
||||
@@ -139,9 +181,9 @@ describe(Build.name, () => {
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<TurtleDeprecatedJobFormatError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<TurtleDeprecatedJobFormatError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -162,8 +204,8 @@ describe(Build.name, () => {
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError =
|
||||
getError<EasBuildResourceClassNotAvailableInFreeTierError>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
await getErrorAsync<EasBuildResourceClassNotAvailableInFreeTierError>(async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(
|
||||
@@ -180,9 +222,9 @@ describe(Build.name, () => {
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<TurtleDeprecatedJobFormatError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<TurtleDeprecatedJobFormatError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -203,8 +245,8 @@ describe(Build.name, () => {
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError =
|
||||
getError<EasBuildResourceClassNotAvailableInFreeTierError>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
await getErrorAsync<EasBuildResourceClassNotAvailableInFreeTierError>(async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(
|
||||
@@ -224,9 +266,9 @@ describe(Build.name, () => {
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<EasBuildFreeTierDisabledError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierDisabledError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -244,9 +286,9 @@ describe(Build.name, () => {
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<EasBuildFreeTierDisabledError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierDisabledError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -266,7 +308,7 @@ describe(Build.name, () => {
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
try {
|
||||
handleBuildRequestError(error, platform);
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
} catch (caughtError) {
|
||||
assertReThrownError(caughtError as Error, EasBuildFreeTierDisabledIOSError, 'Error 1');
|
||||
}
|
||||
@@ -279,9 +321,9 @@ describe(Build.name, () => {
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<EasBuildFreeTierDisabledAndroidError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierDisabledAndroidError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -300,9 +342,9 @@ describe(Build.name, () => {
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<EasBuildFreeTierLimitExceededError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform, accountName);
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierLimitExceededError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform, accountName);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -313,6 +355,262 @@ describe(Build.name, () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('for free tier limit errors in the agent guidance experiment', () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.mocked(getAgentTelemetryContext)
|
||||
.mockReturnValue({ id: 'claude-code', sessionId: 'session-1' });
|
||||
});
|
||||
|
||||
it('appends the human required block for Android in the treatment arm', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const accountName = 'test-account';
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
|
||||
const error = new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierLimitExceededError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform, accountName, createExperiment('treatment'));
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierLimitExceededError,
|
||||
[
|
||||
'Error 1',
|
||||
'Run eas billing:subscribe starter --account test-account to upgrade to the Starter plan.',
|
||||
'Fix: Human required: Android builds for this account are blocked until the Free plan resets. Upgrading costs money, so an account owner or admin decides.',
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
it('appends the human required block for iOS in the treatment arm', async () => {
|
||||
const platform = Platform.IOS;
|
||||
const accountName = 'test-account';
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED');
|
||||
const error = new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
|
||||
const handleBuildRequestErrorThrownError =
|
||||
await getErrorAsync<EasBuildFreeTierIosLimitExceededError>(async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform, accountName, createExperiment('treatment'));
|
||||
});
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierIosLimitExceededError,
|
||||
[
|
||||
'Error 1',
|
||||
'Run eas billing:subscribe starter --account test-account to upgrade to the Starter plan.',
|
||||
'Fix: Human required: iOS builds for this account are blocked until the Free plan resets. Upgrading costs money, so an account owner or admin decides.',
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
it('does not append the block in the control arm', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const accountName = 'test-account';
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
|
||||
const error = new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierLimitExceededError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform, accountName, createExperiment('control'));
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierLimitExceededError,
|
||||
'Error 1\nRun eas billing:subscribe starter --account test-account to upgrade to the Starter plan.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not append the block when no coding agent runs the CLI', async () => {
|
||||
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
|
||||
const platform = Platform.ANDROID;
|
||||
const accountName = 'test-account';
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
|
||||
const error = new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierLimitExceededError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform, accountName, createExperiment('treatment'));
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierLimitExceededError,
|
||||
'Error 1\nRun eas billing:subscribe starter --account test-account to upgrade to the Starter plan.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not append the block to other server-side errors', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED');
|
||||
const error = new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierDisabledError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform, undefined, createExperiment('treatment'));
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierDisabledError,
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('for free tier limit errors in the checkout experiment', () => {
|
||||
it('offers the checkout with the limit message and marks the message as printed', async () => {
|
||||
jest.mocked(maybeOfferFreeTierLimitCheckoutAsync).mockResolvedValue(true);
|
||||
const platform = Platform.ANDROID;
|
||||
const accountName = 'test-account';
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
|
||||
const error = new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
const expectedMessage =
|
||||
'Error 1\nRun eas billing:subscribe starter --account test-account to upgrade to the Starter plan.';
|
||||
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierLimitExceededError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(
|
||||
error,
|
||||
platform,
|
||||
accountName,
|
||||
undefined,
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
expect(maybeOfferFreeTierLimitCheckoutAsync).toHaveBeenCalledWith(
|
||||
expectedMessage,
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierLimitExceededError,
|
||||
expectedMessage,
|
||||
{ messageAlreadyPrinted: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('offers the checkout for the iOS limit error too', async () => {
|
||||
jest.mocked(maybeOfferFreeTierLimitCheckoutAsync).mockResolvedValue(true);
|
||||
const platform = Platform.IOS;
|
||||
const accountName = 'test-account';
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED');
|
||||
const error = new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
const expectedMessage =
|
||||
'Error 1\nRun eas billing:subscribe starter --account test-account to upgrade to the Starter plan.';
|
||||
|
||||
const handleBuildRequestErrorThrownError =
|
||||
await getErrorAsync<EasBuildFreeTierIosLimitExceededError>(async () => {
|
||||
await handleBuildRequestErrorAsync(
|
||||
error,
|
||||
platform,
|
||||
accountName,
|
||||
undefined,
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
});
|
||||
|
||||
expect(maybeOfferFreeTierLimitCheckoutAsync).toHaveBeenCalledWith(
|
||||
expectedMessage,
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierIosLimitExceededError,
|
||||
expectedMessage,
|
||||
{ messageAlreadyPrinted: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the message for the command to print when nothing was offered', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const accountName = 'test-account';
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
|
||||
const error = new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierLimitExceededError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(
|
||||
error,
|
||||
platform,
|
||||
accountName,
|
||||
undefined,
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
expect(maybeOfferFreeTierLimitCheckoutAsync).toHaveBeenCalledTimes(1);
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierLimitExceededError,
|
||||
'Error 1\nRun eas billing:subscribe starter --account test-account to upgrade to the Starter plan.',
|
||||
{ messageAlreadyPrinted: false }
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the agent guidance along when both experiments are present', async () => {
|
||||
jest
|
||||
.mocked(getAgentTelemetryContext)
|
||||
.mockReturnValue({ id: 'claude-code', sessionId: 'session-1' });
|
||||
const platform = Platform.ANDROID;
|
||||
const accountName = 'test-account';
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
|
||||
const error = new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
|
||||
await getErrorAsync(async () => {
|
||||
await handleBuildRequestErrorAsync(
|
||||
error,
|
||||
platform,
|
||||
accountName,
|
||||
createExperiment('treatment'),
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
});
|
||||
|
||||
expect(maybeOfferFreeTierLimitCheckoutAsync).toHaveBeenCalledWith(
|
||||
[
|
||||
'Error 1',
|
||||
'Run eas billing:subscribe starter --account test-account to upgrade to the Starter plan.',
|
||||
'Fix: Human required: Android builds for this account are blocked until the Free plan resets. Upgrading costs money, so an account owner or admin decides.',
|
||||
].join('\n'),
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
});
|
||||
|
||||
it('does not offer the checkout for other server-side errors', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED');
|
||||
const error = new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierDisabledError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(
|
||||
error,
|
||||
platform,
|
||||
undefined,
|
||||
undefined,
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
expect(maybeOfferFreeTierLimitCheckoutAsync).not.toHaveBeenCalled();
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierDisabledError,
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('for EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
@@ -322,8 +620,8 @@ describe(Build.name, () => {
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError =
|
||||
getError<EasBuildFreeTierIosLimitExceededError>(() => {
|
||||
handleBuildRequestError(error, platform, accountName);
|
||||
await getErrorAsync<EasBuildFreeTierIosLimitExceededError>(async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform, accountName);
|
||||
});
|
||||
|
||||
assertReThrownError(
|
||||
@@ -342,8 +640,8 @@ describe(Build.name, () => {
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<RequestValidationError>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<RequestValidationError>(async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(
|
||||
@@ -360,8 +658,8 @@ describe(Build.name, () => {
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<RequestValidationError>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<RequestValidationError>(async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(
|
||||
@@ -383,9 +681,9 @@ describe(Build.name, () => {
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<EasBuildDownForMaintenanceError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildDownForMaintenanceError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -403,9 +701,9 @@ describe(Build.name, () => {
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<EasBuildDownForMaintenanceError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildDownForMaintenanceError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -426,9 +724,9 @@ describe(Build.name, () => {
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<EasBuildTooManyPendingBuildsError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildTooManyPendingBuildsError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -446,9 +744,9 @@ describe(Build.name, () => {
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<EasBuildTooManyPendingBuildsError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildTooManyPendingBuildsError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -471,8 +769,8 @@ describe(Build.name, () => {
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
const expectedMessage = `${EXPECTED_GENERIC_MESSAGE}\nRequest ID: ${mockRequestId}\nError message: Error 1`;
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<Error>(async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
|
||||
@@ -494,8 +792,8 @@ describe(Build.name, () => {
|
||||
`Request ID: ${mockRequestId}\n` +
|
||||
'Error message: Error 3';
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<Error>(async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
|
||||
@@ -509,8 +807,8 @@ describe(Build.name, () => {
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
const expectedMessage = `${EXPECTED_GENERIC_MESSAGE}\nError message: Error 1`;
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<Error>(async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
|
||||
@@ -537,8 +835,8 @@ describe(Build.name, () => {
|
||||
'Response status: 400 Bad Request\n' +
|
||||
`Request ID: ${mockRequestId}`;
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<Error>(async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
|
||||
@@ -561,7 +859,7 @@ describe(Build.name, () => {
|
||||
});
|
||||
|
||||
try {
|
||||
handleBuildRequestError(error, platform);
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
} catch {}
|
||||
|
||||
expect(logDebugSpy).toBeCalledWith(expect.stringContaining('"graphQLErrors": []'));
|
||||
@@ -597,8 +895,8 @@ describe(Build.name, () => {
|
||||
'Response status: 400 Bad Request\n' +
|
||||
`Request ID: ${mockRequestId}`;
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<Error>(async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
|
||||
@@ -625,8 +923,8 @@ describe(Build.name, () => {
|
||||
'Error message: Error 1\n' +
|
||||
'Response status: 400 Bad Request';
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<Error>(async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
|
||||
@@ -638,8 +936,8 @@ describe(Build.name, () => {
|
||||
const error = { graphQLErrors: [], message: 'Something went wrong' };
|
||||
const expectedMessage = `${EXPECTED_GENERIC_MESSAGE}\nError message: Something went wrong`;
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<Error>(async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
|
||||
@@ -654,8 +952,8 @@ describe(Build.name, () => {
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
const expectedMessage = `${EXPECTED_GENERIC_MESSAGE}\nRequest ID: ${mockRequestId}\nError message: Error 1`;
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<Error>(async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
|
||||
@@ -677,8 +975,8 @@ describe(Build.name, () => {
|
||||
`Request ID: ${mockRequestId}\n` +
|
||||
'Error message: Error 3';
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<Error>(async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
|
||||
@@ -692,8 +990,8 @@ describe(Build.name, () => {
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
const expectedMessage = `${EXPECTED_GENERIC_MESSAGE}\nError message: Error 1`;
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<Error>(async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
|
||||
@@ -708,8 +1006,8 @@ describe(Build.name, () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const error = new Error('Non-graphQL-related error');
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<Error>(async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
});
|
||||
|
||||
expect(handleBuildRequestErrorThrownError).toStrictEqual(error);
|
||||
@@ -720,8 +1018,8 @@ describe(Build.name, () => {
|
||||
const platform = Platform.IOS;
|
||||
const error = new Error('Non-graphQL-related error');
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<Error>(async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform);
|
||||
});
|
||||
|
||||
expect(handleBuildRequestErrorThrownError).toStrictEqual(error);
|
||||
|
||||
Loaded 100 of 199 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user