mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 03:38:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34e8b45bad | ||
|
|
d1de958201 | ||
|
|
2b672fc07f | ||
|
|
8453efcfb6 | ||
|
|
0456e8eea6 | ||
|
|
0ec9a81552 | ||
|
|
3b4fedeee5 | ||
|
|
570617f467 | ||
|
|
8262aa34c6 | ||
|
|
1f72736126 | ||
|
|
3328b60f85 | ||
|
|
4abb8601d5 | ||
|
|
9a881d8366 | ||
|
|
657034a1a6 | ||
|
|
bb98f5beef | ||
|
|
4aca8b1cc4 | ||
|
|
800a0e0893 | ||
|
|
2911b04ea1 | ||
|
|
50b381abe0 | ||
|
|
c8696f6012 | ||
|
|
45d795e076 | ||
|
|
8e98a915b9 | ||
|
|
73c65d3edb | ||
|
|
01e3be71aa | ||
|
|
991bbeae8c | ||
|
|
6c342abb97 | ||
|
|
1016aa35f3 | ||
|
|
ceb8f846c2 | ||
|
|
53cb3a7263 | ||
|
|
09188ab271 | ||
|
|
c4ffc1605e | ||
|
|
d90f022966 | ||
|
|
8b8362e4ae | ||
|
|
b5ac163b37 | ||
|
|
92c8944cda | ||
|
|
96581b8094 | ||
|
|
e229f5378f | ||
|
|
19657cc7ff | ||
|
|
be7acb7ab5 | ||
|
|
3c302a055d | ||
|
|
449bd4981e | ||
|
|
4fd36987ba | ||
|
|
01c1ffcaf4 | ||
|
|
b10cc829bd | ||
|
|
3d8ed4c81f | ||
|
|
a344c0e1e8 | ||
|
|
f80f548e2f | ||
|
|
c72ae9f9f6 | ||
|
|
bf0420cd29 | ||
|
|
738ccb1ff6 | ||
|
|
c487bcf1df | ||
|
|
bcaaaddab8 | ||
|
|
4e432fa537 | ||
|
|
817d06e66e | ||
|
|
76a9d17ea7 | ||
|
|
8ce87bfa56 | ||
|
|
2b7e8454e3 | ||
|
|
962a2a733e | ||
|
|
9e13c1e820 | ||
|
|
cadb7b2d1e | ||
|
|
718c82d8e1 | ||
|
|
5d31ecc2bc | ||
|
|
890286a08d | ||
|
|
33ff59a75e | ||
|
|
c2bbba6e78 |
No files matched your search
@@ -0,0 +1,43 @@
|
||||
name: Test recording recovery
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: &recording-paths
|
||||
- packages/build-tools/src/steps/utils/deviceRunSessionScreenRecordings.ts
|
||||
- packages/build-tools/src/steps/utils/deviceRunSessionArtifacts.ts
|
||||
- packages/build-tools/src/steps/utils/__tests__/*.test.ffmpeg.ts
|
||||
- packages/build-tools/jest/ffmpeg-config.ts
|
||||
- .github/workflows/test-recording-recovery.yml
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
paths: *recording-paths
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: test-recording-recovery-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
name: Test recording recovery with ffmpeg
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup tools
|
||||
uses: ./.github/actions/setup-mise
|
||||
- name: Install ffmpeg
|
||||
# The recording recovery test cuts and decodes real MP4 files with ffmpeg and ffprobe.
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Build build-tools dependencies
|
||||
# tsc emits the dist files that the test imports from workspace packages such as @expo/logger.
|
||||
run: yarn run -T lerna run typecheck --scope @expo/build-tools --include-dependencies
|
||||
- name: Run recording recovery test
|
||||
working-directory: packages/build-tools
|
||||
run: yarn jest-ffmpeg
|
||||
@@ -15,9 +15,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: ['20.18.3', '22', '24']
|
||||
node: ['22', '24']
|
||||
include:
|
||||
- node: '20.18.3'
|
||||
- node: '22'
|
||||
coverage: true
|
||||
name: Test with Node ${{ matrix.node }}
|
||||
steps:
|
||||
@@ -26,9 +26,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-mise
|
||||
env:
|
||||
MISE_NODE_VERSION: ${{ matrix.node }}
|
||||
- name: Install ffmpeg
|
||||
# build-tools recording-recovery tests cut and decode real MP4 files with ffmpeg and ffprobe.
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
env:
|
||||
|
||||
+79
-2
@@ -8,6 +8,81 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Let the MCP server read text and image files from sandboxes. ([#4555](https://github.com/expo/eas-cli/pull/4555) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [eas-cli][local-build-plugin] Deprecate Node.js 20, and print a warning when running on it. A future release will require Node.js 22 or newer. Development and CI now use Node.js 22. ([#4579](https://github.com/expo/eas-cli/pull/4579) by [@douglowder](https://github.com/douglowder))
|
||||
- [build-tools] Add `eas/run_agent`, a step that runs Claude Code for an agent run and connects it to the Expo MCP server. ([#4552](https://github.com/expo/eas-cli/pull/4552) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [build-tools] Support Codex in `eas/run_agent`. ([#4553](https://github.com/expo/eas-cli/pull/4553) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [build-tools] Write the agent's messages, tool calls, tool results and errors to the `eas/run_agent` job log as plain lines instead of raw JSON events. ([#4568](https://github.com/expo/eas-cli/pull/4568) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Show the average and P75 end-to-end run duration in `eas workflow:insights`. ([#4566](https://github.com/expo/eas-cli/pull/4566) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Ask for the App Store Connect API key type (team or individual) in the submission key prompt and skip the Issuer ID prompt for individual keys. ([#4589](https://github.com/expo/eas-cli/pull/4589) by [@sswrk](https://github.com/sswrk))
|
||||
- [build-tools] Add `eas/download_submission_archive` to request a fresh archive URL when the step runs. ([#4539](https://github.com/expo/eas-cli/pull/4539) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-build-job] Add an `uploadArtifact` command to the sandbox daemon protocol. ([#4562](https://github.com/expo/eas-cli/pull/4562) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [build-tools] Let connected sandboxes upload files as sandbox artifacts through the Expo MCP server. ([#4563](https://github.com/expo/eas-cli/pull/4563) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Look up ffmpeg on PATH instead of running it. ([#4582](https://github.com/expo/eas-cli/pull/4582) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Stop reporting individual App Store Connect API keys as not valid on Apple's servers when adding them in `eas credentials`. ([#4588](https://github.com/expo/eas-cli/pull/4588) by [@sswrk](https://github.com/sswrk))
|
||||
- [build-tools] Install ffmpeg for Appium simulator sessions. ([#4577](https://github.com/expo/eas-cli/pull/4577) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Close the file of an artifact upload before reporting that the upload is done. ([#4609](https://github.com/expo/eas-cli/pull/4609) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [24.12.1](https://github.com/expo/eas-cli/releases/tag/v24.12.1) - 2026-10-08
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Stop a simulator session that the server creates after Ctrl+C lands during `eas simulator` create. ([#4522](https://github.com/expo/eas-cli/pull/4522) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
|
||||
## [24.12.0](https://github.com/expo/eas-cli/releases/tag/v24.12.0) - 2026-10-07
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Fix `eas workflow:ssh` handling of missing builds IDs. ([#4529](https://github.com/expo/eas-cli/pull/4529) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [build-tools] Upload the embedded bundle from custom builds that use `eas/build`. ([#4494](https://github.com/expo/eas-cli/pull/4494) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add the `eas/upload_embedded_bundle` step for custom build configs. ([#4494](https://github.com/expo/eas-cli/pull/4494) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Include `assetMapUrl` and `sourceMapUrl` in the `eas update:view --json` output. Each value is a signed download URL, or null when the update was published without that file. ([#4534](https://github.com/expo/eas-cli/pull/4534) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Improve the embedded bundle upload output in build logs. ([#4557](https://github.com/expo/eas-cli/pull/4557) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Allow the unnotarized mitmproxy cask to run in iOS network capture sessions. ([#4556](https://github.com/expo/eas-cli/pull/4556) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Encode macOS simulator session previews with cwebp, because Homebrew's FFmpeg cannot encode WebP. ([#4549](https://github.com/expo/eas-cli/pull/4549) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Log analytics events when an `eas simulator` session request is sent, cancelled, or fails before the server answers. ([#4483](https://github.com/expo/eas-cli/pull/4483) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
|
||||
## [24.11.0](https://github.com/expo/eas-cli/releases/tag/v24.11.0) - 2026-10-05
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Record iOS Simulator sessions through serve-sim. ([#4474](https://github.com/expo/eas-cli/pull/4474) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add browser device login with resumable commands for chat agents. ([#4502](https://github.com/expo/eas-cli/pull/4502) by [@eiiot](https://github.com/eiiot))
|
||||
- [build-tools] Upload the display rotation agent-device reports for a screenshot as device run session artifact metadata. ([#4508](https://github.com/expo/eas-cli/pull/4508) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Refresh a WebP thumbnail of the simulator session screen every minute as a reusable session preview artifact. ([#4398](https://github.com/expo/eas-cli/pull/4398) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Select the containing iOS app when its archive also includes a nested App Clip. ([#4541](https://github.com/expo/eas-cli/pull/4541) by [@mozzius](https://github.com/mozzius))
|
||||
- [build-tools] Allow expo.test subdomains to open local and staging simulator previews. ([#4480](https://github.com/expo/eas-cli/pull/4480) by [@hirbod](https://github.com/hirbod))
|
||||
- [build-tools] Retry App Store Connect connection failures and safely recover interrupted status reads and upload commits. ([#4520](https://github.com/expo/eas-cli/pull/4520) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [build-tools] Remove the `eas/start_agent_device_remote_session` step; agent-device sessions use `eas/start_agent_device_session`. ([#4518](https://github.com/expo/eas-cli/pull/4518) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Report `eas-cli` as the request origin, and the coding agent when one is detected, when `eas simulator` creates a session, for analytics. ([#4523](https://github.com/expo/eas-cli/pull/4523) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
|
||||
## [24.10.0](https://github.com/expo/eas-cli/releases/tag/v24.10.0) - 2026-10-02
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--network-capture` to `eas simulator` to record HTTP(S) traffic from apps on the device, and `--network-capture-field` to also keep headers, query values, or request and response bodies. ([#4308](https://github.com/expo/eas-cli/pull/4308) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [24.9.0](https://github.com/expo/eas-cli/releases/tag/v24.9.0) - 2026-10-02
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--force-end-active-rollout` to `eas update`, `eas update:republish`, `eas update:roll-back-to-embedded` and `eas update:rollback`. ([#4233](https://github.com/expo/eas-cli/pull/4233) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Allow `eas simulator:start --platform android --egress local`. On Android, `--egress-allow localhost:<port>` also covers `10.0.2.2:<port>`, and refused destinations are shown as warnings. ([#4519](https://github.com/expo/eas-cli/pull/4519) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Support `eas/start_local_egress` on Linux for Android emulators: an always-listening relay for the emulator's proxy, a systemd scope and nftables fence that refuse traffic that cannot use the tunnel, and fence refusals in the session log. ([#4519](https://github.com/expo/eas-cli/pull/4519) by [@brentvatne](https://github.com/brentvatne))
|
||||
@@ -32,14 +107,13 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
- [eas-cli] Correct the `eas simulator:start --max-duration-minutes` help text: every plan can set a custom duration up to the plan's maximum, which is also the default. ([#4524](https://github.com/expo/eas-cli/pull/4524) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Fix `eas workflow:validate` crashing with certain workflow schemas, and exit with a non-zero code when validation fails. ([#4507](https://github.com/expo/eas-cli/pull/4507) by [@yuvalgnessin-qz](https://github.com/yuvalgnessin-qz))
|
||||
- [build-tools] Reuse App Store Connect tokens and refresh them before expiry so long uploads and processing waits remain authenticated. ([#4497](https://github.com/expo/eas-cli/pull/4497) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Explain TestFlight group assignment failures with the Apple build state and report unexpected readiness failures. ([#4521](https://github.com/expo/eas-cli/pull/4521) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Clear old Maestro CLI HTML reports before each test attempt so retries cannot upload an earlier report. ([#4409](https://github.com/expo/eas-cli/pull/4409) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Poll the web preview readiness every 250 ms instead of every second, so a session goes live sooner after its host is ready. ([#4496](https://github.com/expo/eas-cli/pull/4496) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Keep the `eas simulator` session spinner on one line, so resizing the terminal no longer leaves copies of it behind. The `eas simulator:stop` command is now printed once before the spinner starts. ([#4488](https://github.com/expo/eas-cli/pull/4488) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Disable agent-device's internal idle timeouts in remote sessions, so only the session's own idle and duration limits end it. ([#4485](https://github.com/expo/eas-cli/pull/4485) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Keep `eas simulator:events --follow` running while a session is queued or starting. ([#4506](https://github.com/expo/eas-cli/pull/4506) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [24.8.0](https://github.com/expo/eas-cli/releases/tag/v24.8.0) - 2026-09-24
|
||||
|
||||
### 🎉 New features
|
||||
@@ -69,6 +143,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
- [eas-cli] Stop creating `CLAUDE.md` in new apps; use `AGENTS.md` for agent instructions. ([#4446](https://github.com/expo/eas-cli/pull/4446) by [@davidmokos](https://github.com/davidmokos))
|
||||
- Upgrade `typescript` to 6.0.3, `ts-jest` to 29.4.12, `@types/node` to 20.19.43, `gql.tada` to 1.11.3, and `https-proxy-agent` to 7.0.6. ([#4400](https://github.com/expo/eas-cli/pull/4400) by [@wschurman](https://github.com/wschurman))
|
||||
- [build-tools] Use the website origin for simulator preview CORS and framing, and allow deploy previews and local website dev servers on staging. ([#4437](https://github.com/expo/eas-cli/pull/4437) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Report the web preview as `webPreviewUrl` and `webPreviewToken` in addition to the legacy serve-sim keys. ([#4467](https://github.com/expo/eas-cli/pull/4467) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [24.7.0](https://github.com/expo/eas-cli/releases/tag/v24.7.0) - 2026-09-16
|
||||
|
||||
@@ -181,6 +256,8 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
- [build-tools] Report a system error when project source refresh fails for a remote build. ([#4359](https://github.com/expo/eas-cli/pull/4359) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
- [eas-cli] Remove the obsolete `appVersion` runtime policy warning that referenced SDK 46 and expo-updates 0.14.4. ([#4375](https://github.com/expo/eas-cli/pull/4375) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [24.0.0](https://github.com/expo/eas-cli/releases/tag/v24.0.0) - 2026-09-09
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
@@ -164,7 +164,7 @@ Each package has independent TypeScript compilation:
|
||||
|
||||
TypeScript configs:
|
||||
|
||||
- `tsconfig.json` - Base configuration (extends @tsconfig/node20)
|
||||
- `tsconfig.json` - Base configuration (extends @tsconfig/node22)
|
||||
- `tsconfig.build.json` - Production builds
|
||||
- `tsconfig.allowUnused.json` - Development with relaxed rules
|
||||
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "24.8.0",
|
||||
"PRODUCTION": "24.8.0"
|
||||
"STAGING": "24.12.1",
|
||||
"PRODUCTION": "24.12.1"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "24.9.0",
|
||||
"version": "24.13.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 = "20.19.4"
|
||||
node = "22.23.3"
|
||||
+1
-1
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.11",
|
||||
"@types/node": "20.19.43",
|
||||
"@types/node": "22.20.5",
|
||||
"eslint-plugin-async-protect": "3.1.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-watch-typeahead": "2.2.2",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
bin/record-sim
|
||||
|
||||
.secrets*
|
||||
!.secrets.example
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Config } from '@jest/types';
|
||||
|
||||
import baseConfig from '../../../jest/jest.shared.config';
|
||||
|
||||
const config: Config.InitialOptions = {
|
||||
...baseConfig,
|
||||
rootDir: '../src',
|
||||
testMatch: ['**/__tests__/*.test.ffmpeg.ts'],
|
||||
clearMocks: true,
|
||||
setupFilesAfterEnv: ['<rootDir>/../jest/setup-tests.ts'],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "24.9.0",
|
||||
"version": "24.13.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -23,7 +23,6 @@
|
||||
"watch": "tsc --watch --preserveWatchOutput",
|
||||
"prebuild": "yarn gql",
|
||||
"build": "tsc",
|
||||
"build:record-sim": "mkdir -p bin && record_sim_bin_path=$(swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build --show-bin-path) && swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build && cp \"$record_sim_bin_path/record-sim\" bin/record-sim && chmod +x bin/record-sim",
|
||||
"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",
|
||||
@@ -31,6 +30,7 @@
|
||||
"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",
|
||||
"jest-ffmpeg": "jest --config jest/ffmpeg-config.ts",
|
||||
"jest-unit-watch": "jest --config jest/unit-config.ts --watch",
|
||||
"jest-integration-watch": "jest --config jest/integration-config.ts --watch",
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\"",
|
||||
@@ -41,17 +41,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "24.8.0",
|
||||
"@expo/eas-build-job": "24.9.0",
|
||||
"@expo/downloader": "24.13.0",
|
||||
"@expo/eas-build-job": "24.13.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "24.8.0",
|
||||
"@expo/logger": "24.13.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "24.9.0",
|
||||
"@expo/template-file": "24.8.0",
|
||||
"@expo/turtle-spawn": "24.8.0",
|
||||
"@expo/steps": "24.13.0",
|
||||
"@expo/template-file": "24.13.0",
|
||||
"@expo/turtle-spawn": "24.13.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
@@ -88,7 +88,7 @@
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.4",
|
||||
"@types/node": "20.19.43",
|
||||
"@types/node": "22.20.5",
|
||||
"@types/node-fetch": "^2.6.11",
|
||||
"@types/node-forge": "^1.3.11",
|
||||
"@types/plist": "^3.0.5",
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
.build/
|
||||
.swiftpm/
|
||||
DerivedData/
|
||||
.DS_Store
|
||||
@@ -1,28 +0,0 @@
|
||||
// swift-tools-version: 5.9
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "record-sim",
|
||||
platforms: [
|
||||
.macOS(.v14),
|
||||
],
|
||||
products: [
|
||||
.library(name: "RecordSim", targets: ["RecordSim"]),
|
||||
.executable(name: "record-sim", targets: ["record-sim"]),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "RecordSim",
|
||||
linkerSettings: [
|
||||
.linkedFramework("AVFoundation"),
|
||||
.linkedFramework("CoreGraphics"),
|
||||
.linkedFramework("CoreMedia"),
|
||||
.linkedFramework("CoreVideo"),
|
||||
.linkedFramework("IOSurface"),
|
||||
.linkedFramework("UniformTypeIdentifiers"),
|
||||
]
|
||||
),
|
||||
.executableTarget(name: "record-sim", dependencies: ["RecordSim"]),
|
||||
]
|
||||
)
|
||||
@@ -1,79 +0,0 @@
|
||||
# RecordSim
|
||||
|
||||
Small Swift library for non-exclusive iOS Simulator screen recording.
|
||||
|
||||
It implements one recording path:
|
||||
|
||||
1. Listen to SimRenderServer framebuffer callbacks.
|
||||
2. Copy the live `IOSurface` immediately into an owned `CVPixelBuffer`.
|
||||
3. Feed one continuous `AVAssetWriter`.
|
||||
4. Emit Apple HLS/CMAF-style fragmented MP4 segments.
|
||||
|
||||
The output is suitable for uploading while recording. `session.json` contains
|
||||
the ordered segment metadata needed to build a playlist later.
|
||||
|
||||
## Output
|
||||
|
||||
```text
|
||||
session/
|
||||
init.mp4
|
||||
session.json
|
||||
segments/
|
||||
segment-000000.m4s
|
||||
segment-000001.m4s
|
||||
```
|
||||
|
||||
`init.mp4` is the fMP4 initialization segment. Individual `.m4s` files are not
|
||||
standalone MP4 files. `session.json` stores the wall-clock timestamp for the
|
||||
first video frame plus the metadata needed to build a playlist: HLS version,
|
||||
target duration, media sequence, `initSegment`, and the ordered `segments` array
|
||||
with file path and duration for each media segment.
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
swift run record-sim \
|
||||
--udid <BOOTED_SIMULATOR_UDID> \
|
||||
--output /tmp/sim-recording \
|
||||
--segment-duration 120 \
|
||||
--fps 30 \
|
||||
--bitrate 30000000 \
|
||||
--codec h264
|
||||
```
|
||||
|
||||
For sparse 2h sessions, `--segment-duration 120` is a reasonable default: about
|
||||
60 media objects plus the init segment. The recorder appends a single duplicate
|
||||
hold frame near each segment boundary when the simulator is idle, so long idle
|
||||
periods still produce uploadable segments without encoding idle frames at 60fps.
|
||||
Use `--segment-duration 0` to write one continuous `recording.mp4` instead of
|
||||
`init.mp4` plus media segments.
|
||||
|
||||
Frame timestamps use monotonic host time, not wall-clock time. The recorder also
|
||||
probes the framebuffer seed once per second; if the surface changes without
|
||||
callbacks for 5 seconds, it captures that frame and rewires the private callback
|
||||
registration.
|
||||
|
||||
## Library
|
||||
|
||||
```swift
|
||||
let recorder = SimulatorRecorder(
|
||||
configuration: SimulatorRecordingConfiguration(
|
||||
deviceUDID: udid,
|
||||
outputDirectory: sessionDirectory,
|
||||
segmentDuration: 120
|
||||
)
|
||||
)
|
||||
|
||||
recorder.onSegment = { segment in
|
||||
// Enqueue upload of segment.url.
|
||||
}
|
||||
|
||||
try recorder.start()
|
||||
try recorder.waitUntilFirstFrame()
|
||||
// ...
|
||||
let manifest = try recorder.stop()
|
||||
```
|
||||
|
||||
`session.json` contains the precise wall-clock time for video PTS zero. In
|
||||
segmented mode it also contains the init segment and ordered media segment
|
||||
file/duration entries; in single-file mode it points at `recording.mp4`.
|
||||
-192
@@ -1,192 +0,0 @@
|
||||
import Foundation
|
||||
import IOSurface
|
||||
import ObjectiveC
|
||||
|
||||
final class FramebufferDisplaySource {
|
||||
struct SurfaceSnapshot {
|
||||
let surface: IOSurface
|
||||
let width: Int
|
||||
let height: Int
|
||||
let seed: UInt32
|
||||
}
|
||||
|
||||
private let deviceUDID: String
|
||||
private let callbackQueue: DispatchQueue
|
||||
private let onFrame: () -> Void
|
||||
private let onSurfaceChange: () -> Void
|
||||
private var descriptors: [NSObject] = []
|
||||
private var callbackUUIDs: [ObjectIdentifier: NSUUID] = [:]
|
||||
private var retainedBlocks: [AnyObject] = []
|
||||
private var ioClient: NSObject?
|
||||
|
||||
init(
|
||||
deviceUDID: String,
|
||||
callbackQueue: DispatchQueue,
|
||||
onFrame: @escaping () -> Void,
|
||||
onSurfaceChange: @escaping () -> Void
|
||||
) {
|
||||
self.deviceUDID = deviceUDID
|
||||
self.callbackQueue = callbackQueue
|
||||
self.onFrame = onFrame
|
||||
self.onSurfaceChange = onSurfaceChange
|
||||
}
|
||||
|
||||
func start() throws {
|
||||
try PrivateSimulatorFrameworks.load()
|
||||
guard let device = SimulatorDeviceLookup.find(udid: deviceUDID) else {
|
||||
throw RecorderError.make(1, "Simulator \(deviceUDID) not found")
|
||||
}
|
||||
let state = device.value(forKey: "stateString") as? String ?? "unknown"
|
||||
guard state == "Booted" else {
|
||||
throw RecorderError.make(2, "Simulator \(deviceUDID) is not booted (state: \(state))")
|
||||
}
|
||||
guard
|
||||
let io = device.perform(NSSelectorFromString("io"))?.takeUnretainedValue() as? NSObject
|
||||
else {
|
||||
throw RecorderError.make(3, "Failed to get simulator IO client")
|
||||
}
|
||||
ioClient = io
|
||||
try wireUpFramebuffer()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
unregisterCallbacks()
|
||||
descriptors.removeAll()
|
||||
retainedBlocks.removeAll()
|
||||
ioClient = nil
|
||||
}
|
||||
|
||||
func surfaceSnapshot() -> SurfaceSnapshot? {
|
||||
let surfaceSelector = NSSelectorFromString("framebufferSurface")
|
||||
var bestSnapshot: SurfaceSnapshot?
|
||||
var bestArea = 0
|
||||
for descriptor in descriptors {
|
||||
guard let surfaceObject = descriptor.perform(surfaceSelector)?.takeUnretainedValue()
|
||||
else {
|
||||
continue
|
||||
}
|
||||
let surface = unsafeBitCast(surfaceObject, to: IOSurface.self)
|
||||
let width = IOSurfaceGetWidth(surface)
|
||||
let height = IOSurfaceGetHeight(surface)
|
||||
let area = width * height
|
||||
if area > bestArea {
|
||||
bestSnapshot = SurfaceSnapshot(
|
||||
surface: surface,
|
||||
width: width,
|
||||
height: height,
|
||||
seed: IOSurfaceGetSeed(surface)
|
||||
)
|
||||
bestArea = area
|
||||
}
|
||||
}
|
||||
return bestSnapshot
|
||||
}
|
||||
|
||||
func rewireFramebuffer() throws {
|
||||
try wireUpFramebuffer()
|
||||
}
|
||||
|
||||
private func wireUpFramebuffer() throws {
|
||||
guard let io = ioClient else {
|
||||
throw RecorderError.make(3, "No simulator IO client")
|
||||
}
|
||||
io.perform(NSSelectorFromString("updateIOPorts"))
|
||||
let nextDescriptors = try findFramebufferDescriptors(io: io)
|
||||
unregisterCallbacks()
|
||||
descriptors = nextDescriptors
|
||||
retainedBlocks.removeAll()
|
||||
do {
|
||||
for descriptor in descriptors {
|
||||
try registerCallbacks(descriptor: descriptor)
|
||||
}
|
||||
} catch {
|
||||
unregisterCallbacks()
|
||||
descriptors.removeAll()
|
||||
retainedBlocks.removeAll()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func findFramebufferDescriptors(io: NSObject) throws -> [NSObject] {
|
||||
guard let ports = io.value(forKey: "deviceIOPorts") as? [NSObject] else {
|
||||
throw RecorderError.make(4, "Failed to read simulator IO ports")
|
||||
}
|
||||
let portIdentifierSelector = NSSelectorFromString("portIdentifier")
|
||||
let descriptorSelector = NSSelectorFromString("descriptor")
|
||||
let surfaceSelector = NSSelectorFromString("framebufferSurface")
|
||||
|
||||
var candidates: [NSObject] = []
|
||||
for port in ports {
|
||||
guard port.responds(to: portIdentifierSelector),
|
||||
let portIdentifier = port.perform(portIdentifierSelector)?.takeUnretainedValue(),
|
||||
"\(portIdentifier)" == "com.apple.framebuffer.display",
|
||||
port.responds(to: descriptorSelector),
|
||||
let descriptor = port.perform(descriptorSelector)?.takeUnretainedValue()
|
||||
as? NSObject,
|
||||
descriptor.responds(to: surfaceSelector)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
candidates.append(descriptor)
|
||||
}
|
||||
if candidates.isEmpty {
|
||||
throw RecorderError.make(5, "No framebuffer display descriptor found")
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
private func registerCallbacks(descriptor: NSObject) throws {
|
||||
let selector = NSSelectorFromString(
|
||||
"registerScreenCallbacksWithUUID:callbackQueue:frameCallback:surfacesChangedCallback:propertiesChangedCallback:"
|
||||
)
|
||||
guard descriptor.responds(to: selector) else {
|
||||
throw RecorderError.make(6, "Framebuffer descriptor does not support screen callbacks")
|
||||
}
|
||||
guard let msgSendPointer = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "objc_msgSend")
|
||||
else {
|
||||
throw RecorderError.make(7, "objc_msgSend not found")
|
||||
}
|
||||
typealias MsgSend =
|
||||
@convention(c) (
|
||||
AnyObject, Selector, AnyObject, AnyObject, AnyObject, AnyObject, AnyObject
|
||||
) -> Void
|
||||
let msgSend = unsafeBitCast(msgSendPointer, to: MsgSend.self)
|
||||
|
||||
let uuid = NSUUID()
|
||||
callbackUUIDs[ObjectIdentifier(descriptor)] = uuid
|
||||
|
||||
let frameCallback: @convention(block) () -> Void = { [weak self] in
|
||||
self?.onFrame()
|
||||
}
|
||||
let surfacesCallback: @convention(block) (AnyObject?, AnyObject?) -> Void = {
|
||||
[weak self] _, _ in
|
||||
self?.onSurfaceChange()
|
||||
}
|
||||
let propertiesCallback: @convention(block) () -> Void = {}
|
||||
retainedBlocks.append(frameCallback as AnyObject)
|
||||
retainedBlocks.append(surfacesCallback as AnyObject)
|
||||
retainedBlocks.append(propertiesCallback as AnyObject)
|
||||
|
||||
msgSend(
|
||||
descriptor,
|
||||
selector,
|
||||
uuid,
|
||||
callbackQueue as AnyObject,
|
||||
frameCallback as AnyObject,
|
||||
surfacesCallback as AnyObject,
|
||||
propertiesCallback as AnyObject
|
||||
)
|
||||
}
|
||||
|
||||
private func unregisterCallbacks() {
|
||||
let selector = NSSelectorFromString("unregisterScreenCallbacksWithUUID:")
|
||||
for descriptor in descriptors {
|
||||
if let uuid = callbackUUIDs[ObjectIdentifier(descriptor)],
|
||||
descriptor.responds(to: selector)
|
||||
{
|
||||
descriptor.perform(selector, with: uuid)
|
||||
}
|
||||
}
|
||||
callbackUUIDs.removeAll()
|
||||
}
|
||||
}
|
||||
-96
@@ -1,96 +0,0 @@
|
||||
import Foundation
|
||||
import ObjectiveC
|
||||
|
||||
enum XcodeDeveloperDirectory {
|
||||
static let current: String = {
|
||||
if let developerDir = ProcessInfo.processInfo.environment["DEVELOPER_DIR"],
|
||||
!developerDir.isEmpty
|
||||
{
|
||||
return developerDir
|
||||
}
|
||||
|
||||
let fallback = "/Applications/Xcode.app/Contents/Developer"
|
||||
let pipe = Pipe()
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/xcode-select")
|
||||
process.arguments = ["-p"]
|
||||
process.standardOutput = pipe
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
process.waitUntilExit()
|
||||
return String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? fallback
|
||||
}()
|
||||
}
|
||||
|
||||
enum PrivateSimulatorFrameworks {
|
||||
static func load() throws {
|
||||
try loadResult.get()
|
||||
}
|
||||
|
||||
private static let loadResult: Result<Void, Error> = {
|
||||
let developerDir = XcodeDeveloperDirectory.current
|
||||
let candidates = [
|
||||
"/Library/Developer/PrivateFrameworks/CoreSimulator.framework/CoreSimulator",
|
||||
"\(developerDir)/Library/PrivateFrameworks/CoreSimulator.framework/CoreSimulator",
|
||||
"\(developerDir)/../SharedFrameworks/SimulatorKit.framework/SimulatorKit",
|
||||
"\(developerDir)/Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit",
|
||||
]
|
||||
var failures: [String] = []
|
||||
for path in candidates {
|
||||
if dlopen(path, RTLD_NOW) == nil {
|
||||
let message = dlerror().map { String(cString: $0) } ?? "unknown error"
|
||||
failures.append("\(path): \(message)")
|
||||
}
|
||||
}
|
||||
guard NSClassFromString("SimServiceContext") != nil else {
|
||||
let tried = candidates.joined(separator: ", ")
|
||||
let errors = failures.isEmpty ? "no dlopen errors" : failures.joined(separator: "; ")
|
||||
return .failure(
|
||||
RecorderError.make(
|
||||
8,
|
||||
"Failed to load CoreSimulator/SimulatorKit (tried: \(tried); errors: \(errors))"
|
||||
)
|
||||
)
|
||||
}
|
||||
return .success(())
|
||||
}()
|
||||
}
|
||||
|
||||
enum SimulatorDeviceLookup {
|
||||
private static let context: NSObject? = {
|
||||
guard let contextClass = NSClassFromString("SimServiceContext") as? NSObject.Type else {
|
||||
return nil
|
||||
}
|
||||
let sharedSelector = NSSelectorFromString("sharedServiceContextForDeveloperDir:error:")
|
||||
return contextClass.perform(
|
||||
sharedSelector, with: XcodeDeveloperDirectory.current, with: nil)?
|
||||
.takeUnretainedValue() as? NSObject
|
||||
}()
|
||||
|
||||
static func find(udid: String) -> NSObject? {
|
||||
guard let context else {
|
||||
return nil
|
||||
}
|
||||
let deviceSetSelector = NSSelectorFromString("defaultDeviceSetWithError:")
|
||||
guard
|
||||
let deviceSet = context.perform(deviceSetSelector, with: nil)?
|
||||
.takeUnretainedValue() as? NSObject
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
guard let devices = deviceSet.value(forKey: "devices") as? [NSObject] else {
|
||||
return nil
|
||||
}
|
||||
return devices.first {
|
||||
($0.value(forKey: "UDID") as? NSUUID)?.uuidString == udid
|
||||
}
|
||||
}
|
||||
|
||||
static func stateString(udid: String) -> String? {
|
||||
find(udid: udid)?.value(forKey: "stateString") as? String
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public enum RecorderCodec: String, Codable, Sendable {
|
||||
case h264
|
||||
case hevc
|
||||
}
|
||||
|
||||
public struct SimulatorRecordingConfiguration: Sendable {
|
||||
public var deviceUDID: String
|
||||
public var outputDirectory: URL
|
||||
public var fps: Int
|
||||
public var bitrate: Int
|
||||
public var codec: RecorderCodec
|
||||
public var segmentDuration: TimeInterval
|
||||
public var maxPendingFrames: Int
|
||||
public var overwrite: Bool
|
||||
|
||||
public init(
|
||||
deviceUDID: String,
|
||||
outputDirectory: URL,
|
||||
fps: Int = 30,
|
||||
bitrate: Int = 30_000_000,
|
||||
codec: RecorderCodec = .h264,
|
||||
segmentDuration: TimeInterval = 120,
|
||||
maxPendingFrames: Int = 8,
|
||||
overwrite: Bool = true
|
||||
) {
|
||||
self.deviceUDID = deviceUDID
|
||||
self.outputDirectory = outputDirectory
|
||||
self.fps = fps
|
||||
self.bitrate = bitrate
|
||||
self.codec = codec
|
||||
self.segmentDuration = segmentDuration
|
||||
self.maxPendingFrames = maxPendingFrames
|
||||
self.overwrite = overwrite
|
||||
}
|
||||
}
|
||||
|
||||
public enum SegmentKind: String, Codable, Sendable {
|
||||
case initialization
|
||||
case media
|
||||
}
|
||||
|
||||
public struct SegmentOutput: Sendable {
|
||||
public let kind: SegmentKind
|
||||
public let relativePath: String
|
||||
public let url: URL
|
||||
public let byteCount: Int
|
||||
public let durationSeconds: Double?
|
||||
}
|
||||
|
||||
public struct MediaSegmentRecord: Codable, Sendable {
|
||||
public let file: String
|
||||
public let durationSeconds: Double
|
||||
}
|
||||
|
||||
public struct FirstFrameWallClock: Codable, Sendable {
|
||||
public let unixMs: Int64
|
||||
public let iso8601: String
|
||||
}
|
||||
|
||||
public enum RecordingFinalizationStage: Sendable {
|
||||
case captureStopped
|
||||
case videoSaved
|
||||
}
|
||||
|
||||
public struct RecordingManifest: Codable, Sendable {
|
||||
public let firstFrameWallClock: FirstFrameWallClock
|
||||
public let width: Int
|
||||
public let height: Int
|
||||
public let hlsVersion: Int?
|
||||
public let hlsTargetDurationSeconds: Int?
|
||||
public let hlsMediaSequence: Int?
|
||||
public let recording: String?
|
||||
public let initSegment: String?
|
||||
public let segments: [MediaSegmentRecord]
|
||||
}
|
||||
-140
@@ -1,140 +0,0 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
final class RecordingOutputWriter: NSObject, AVAssetWriterDelegate {
|
||||
private let rootDirectory: URL
|
||||
private let segmentsDirectory: URL
|
||||
let singleRecordingURL: URL
|
||||
private let stateQueue = DispatchQueue(label: "record-sim.segment-writer")
|
||||
private let notificationQueue = DispatchQueue(label: "record-sim.segment-notifications")
|
||||
private let onSegment: ((SegmentOutput) -> Void)?
|
||||
private var initSegmentPath: String?
|
||||
private var segments: [MediaSegmentRecord] = []
|
||||
private var nextMediaSegmentIndex = 0
|
||||
private var firstError: Error?
|
||||
|
||||
init(rootDirectory: URL, onSegment: ((SegmentOutput) -> Void)?) {
|
||||
self.rootDirectory = rootDirectory
|
||||
self.segmentsDirectory = rootDirectory.appendingPathComponent("segments", isDirectory: true)
|
||||
self.singleRecordingURL = rootDirectory.appendingPathComponent("recording.mp4")
|
||||
self.onSegment = onSegment
|
||||
super.init()
|
||||
}
|
||||
|
||||
func prepare(overwrite: Bool, segmented: Bool) throws {
|
||||
if overwrite {
|
||||
try? FileManager.default.removeItem(at: rootDirectory)
|
||||
}
|
||||
try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true)
|
||||
if segmented {
|
||||
try FileManager.default.createDirectory(at: segmentsDirectory, withIntermediateDirectories: true)
|
||||
}
|
||||
}
|
||||
|
||||
var error: Error? {
|
||||
stateQueue.sync { firstError }
|
||||
}
|
||||
|
||||
func assetWriter(
|
||||
_ writer: AVAssetWriter,
|
||||
didOutputSegmentData segmentData: Data,
|
||||
segmentType: AVAssetSegmentType,
|
||||
segmentReport: AVAssetSegmentReport?
|
||||
) {
|
||||
var notification: SegmentOutput?
|
||||
|
||||
stateQueue.sync {
|
||||
if firstError != nil {
|
||||
return
|
||||
}
|
||||
do {
|
||||
switch segmentType {
|
||||
case .initialization:
|
||||
let relativePath = "init.mp4"
|
||||
let url = rootDirectory.appendingPathComponent(relativePath)
|
||||
try segmentData.write(to: url, options: .atomic)
|
||||
initSegmentPath = relativePath
|
||||
notification = SegmentOutput(
|
||||
kind: .initialization,
|
||||
relativePath: relativePath,
|
||||
url: url,
|
||||
byteCount: segmentData.count,
|
||||
durationSeconds: nil
|
||||
)
|
||||
case .separable:
|
||||
let index = nextMediaSegmentIndex
|
||||
let relativePath = String(format: "segments/segment-%06d.m4s", index)
|
||||
let url = rootDirectory.appendingPathComponent(relativePath)
|
||||
try segmentData.write(to: url, options: .atomic)
|
||||
|
||||
let videoReport = segmentReport?.trackReports.first(where: { $0.mediaType == .video })
|
||||
let duration = videoReport.flatMap { timeSeconds($0.duration) } ?? 0
|
||||
let record = MediaSegmentRecord(
|
||||
file: relativePath,
|
||||
durationSeconds: duration
|
||||
)
|
||||
nextMediaSegmentIndex += 1
|
||||
segments.append(record)
|
||||
notification = SegmentOutput(
|
||||
kind: .media,
|
||||
relativePath: relativePath,
|
||||
url: url,
|
||||
byteCount: segmentData.count,
|
||||
durationSeconds: duration
|
||||
)
|
||||
default:
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
firstError = error
|
||||
}
|
||||
}
|
||||
|
||||
if let notification, let onSegment {
|
||||
notificationQueue.async {
|
||||
onSegment(notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func writeManifest(
|
||||
configuration: SimulatorRecordingConfiguration,
|
||||
firstFrameWallClock: Date,
|
||||
width: Int,
|
||||
height: Int
|
||||
) throws -> RecordingManifest {
|
||||
try stateQueue.sync {
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
let segmented = configuration.segmentDuration > 0
|
||||
if segmented, initSegmentPath == nil {
|
||||
throw RecorderError.make(50, "Missing initialization segment")
|
||||
}
|
||||
if segmented, segments.isEmpty {
|
||||
throw RecorderError.make(51, "No media segments were written")
|
||||
}
|
||||
let targetDuration = segmented
|
||||
? max(1, Int(ceil(segments.map(\.durationSeconds).max() ?? configuration.segmentDuration)))
|
||||
: nil
|
||||
let manifest = RecordingManifest(
|
||||
firstFrameWallClock: FirstFrameWallClock(
|
||||
unixMs: unixMs(firstFrameWallClock),
|
||||
iso8601: iso8601(firstFrameWallClock)
|
||||
),
|
||||
width: width,
|
||||
height: height,
|
||||
hlsVersion: segmented ? 7 : nil,
|
||||
hlsTargetDurationSeconds: targetDuration,
|
||||
hlsMediaSequence: segmented ? 0 : nil,
|
||||
recording: segmented ? nil : "recording.mp4",
|
||||
initSegment: segmented ? initSegmentPath : nil,
|
||||
segments: segments
|
||||
)
|
||||
let data = try JSONEncoder.prettySorted.encode(manifest)
|
||||
try data.write(to: rootDirectory.appendingPathComponent("session.json"), options: .atomic)
|
||||
return manifest
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,644 +0,0 @@
|
||||
import AVFoundation
|
||||
import CoreVideo
|
||||
import Foundation
|
||||
import IOSurface
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
public final class SimulatorRecorder {
|
||||
public var onSegment: ((SegmentOutput) -> Void)?
|
||||
public var onSimulatorStopped: ((String) -> Void)?
|
||||
public var onFinalizationStage: ((RecordingFinalizationStage) -> Void)?
|
||||
|
||||
private static let callbackStalenessTimeout: TimeInterval = 5
|
||||
private static let firstFrameRewireInterval: TimeInterval = 1
|
||||
private let configuration: SimulatorRecordingConfiguration
|
||||
private let callbackQueue = DispatchQueue(
|
||||
label: "record-sim.frame-callbacks", qos: .userInteractive)
|
||||
private let writerQueue = DispatchQueue(label: "record-sim.asset-writer", qos: .userInitiated)
|
||||
private let eventQueue = DispatchQueue(label: "record-sim.events", qos: .utility)
|
||||
private let pendingLock = NSLock()
|
||||
private var pendingFrames = 0
|
||||
private var displaySource: FramebufferDisplaySource?
|
||||
private var outputWriter: RecordingOutputWriter?
|
||||
private var writer: AVAssetWriter?
|
||||
private var input: AVAssetWriterInput?
|
||||
private var adaptor: AVAssetWriterInputPixelBufferAdaptor?
|
||||
private var boundaryTimer: DispatchSourceTimer?
|
||||
private var monotonicClock = MonotonicClock()
|
||||
private var firstAcceptedCaptureTime: CMTime?
|
||||
private var firstAcceptedWallClock: Date?
|
||||
private var recordingWidth: Int?
|
||||
private var recordingHeight: Int?
|
||||
private var lastPTS: CMTime?
|
||||
private var lastSeed: UInt32?
|
||||
private var lastFrameCallbackElapsed: TimeInterval?
|
||||
private var lastFirstFrameRewireElapsed: TimeInterval = 0
|
||||
private var lastAppendedPixelBuffer: CVPixelBuffer?
|
||||
private var nextBoundaryElapsed: TimeInterval = 0
|
||||
private var simulatorStoppedReason: String?
|
||||
private var stopped = false
|
||||
private var firstFrameReady = false
|
||||
private let firstFrameSemaphore = DispatchSemaphore(value: 0)
|
||||
private var firstError: Error?
|
||||
|
||||
public init(configuration: SimulatorRecordingConfiguration) {
|
||||
self.configuration = configuration
|
||||
}
|
||||
|
||||
public func start() throws {
|
||||
try validateConfiguration()
|
||||
|
||||
let outputWriter = RecordingOutputWriter(
|
||||
rootDirectory: configuration.outputDirectory,
|
||||
onSegment: { [weak self] segment in
|
||||
self?.onSegment?(segment)
|
||||
})
|
||||
try outputWriter.prepare(
|
||||
overwrite: configuration.overwrite, segmented: configuration.segmentDuration > 0)
|
||||
self.outputWriter = outputWriter
|
||||
|
||||
monotonicClock = MonotonicClock()
|
||||
nextBoundaryElapsed = configuration.segmentDuration
|
||||
lastFirstFrameRewireElapsed = 0
|
||||
stopped = false
|
||||
|
||||
let source = FramebufferDisplaySource(
|
||||
deviceUDID: configuration.deviceUDID,
|
||||
callbackQueue: callbackQueue,
|
||||
onFrame: { [weak self] in
|
||||
self?.captureFrame(force: false, reason: .callback)
|
||||
},
|
||||
onSurfaceChange: { [weak self] in
|
||||
self?.captureFrame(force: true, reason: .surfaceChange)
|
||||
}
|
||||
)
|
||||
try source.start()
|
||||
displaySource = source
|
||||
|
||||
startBoundaryTimer()
|
||||
}
|
||||
|
||||
public func waitUntilFirstFrame(timeoutSeconds: TimeInterval = 15) throws {
|
||||
let alreadyReady = try writerQueue.sync {
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
return firstFrameReady
|
||||
}
|
||||
if alreadyReady {
|
||||
return
|
||||
}
|
||||
if firstFrameSemaphore.wait(timeout: .now() + timeoutSeconds) == .timedOut {
|
||||
throw RecorderError.make(20, "Timed out waiting for first frame")
|
||||
}
|
||||
try writerQueue.sync {
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
if !firstFrameReady {
|
||||
throw RecorderError.make(21, "Recorder stopped before first frame")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func simulatorStopReason() -> String? {
|
||||
callbackQueue.sync {
|
||||
simulatorStoppedReason
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func stop() throws -> RecordingManifest {
|
||||
callbackQueue.sync {
|
||||
stopped = true
|
||||
boundaryTimer?.cancel()
|
||||
boundaryTimer = nil
|
||||
displaySource?.stop()
|
||||
displaySource = nil
|
||||
}
|
||||
onFinalizationStage?(.captureStopped)
|
||||
|
||||
return try writerQueue.sync {
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
guard let writer, let input, let outputWriter else {
|
||||
let error = RecorderError.make(22, "No frames were captured")
|
||||
firstError = error
|
||||
signalFirstFrameReadyIfNeeded()
|
||||
throw error
|
||||
}
|
||||
try appendTailFrameIfNeeded(finalPTS: monotonicClock.elapsedTime())
|
||||
input.markAsFinished()
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
writer.finishWriting {
|
||||
semaphore.signal()
|
||||
}
|
||||
if semaphore.wait(timeout: .now() + 60) == .timedOut {
|
||||
throw RecorderError.make(23, "Timed out finishing AVAssetWriter")
|
||||
}
|
||||
if writer.status == .failed {
|
||||
throw writer.error ?? RecorderError.make(24, "AVAssetWriter failed")
|
||||
}
|
||||
if let error = outputWriter.error {
|
||||
throw error
|
||||
}
|
||||
onFinalizationStage?(.videoSaved)
|
||||
guard let firstAcceptedWallClock else {
|
||||
throw RecorderError.make(25, "Missing first frame wall-clock timestamp")
|
||||
}
|
||||
guard let recordingWidth, let recordingHeight else {
|
||||
throw RecorderError.make(27, "Missing recording dimensions")
|
||||
}
|
||||
return try outputWriter.writeManifest(
|
||||
configuration: configuration,
|
||||
firstFrameWallClock: firstAcceptedWallClock,
|
||||
width: recordingWidth,
|
||||
height: recordingHeight
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private enum CaptureReason {
|
||||
case callback
|
||||
case surfaceChange
|
||||
case boundary
|
||||
case healthProbe
|
||||
}
|
||||
|
||||
private func validateConfiguration() throws {
|
||||
guard !configuration.deviceUDID.isEmpty else {
|
||||
throw RecorderError.make(30, "deviceUDID must not be empty")
|
||||
}
|
||||
guard configuration.fps > 0, configuration.fps <= 120 else {
|
||||
throw RecorderError.make(31, "fps must be between 1 and 120")
|
||||
}
|
||||
guard configuration.bitrate > 0 else {
|
||||
throw RecorderError.make(32, "bitrate must be positive")
|
||||
}
|
||||
guard configuration.segmentDuration >= 0 else {
|
||||
throw RecorderError.make(33, "segmentDuration must be non-negative")
|
||||
}
|
||||
guard configuration.maxPendingFrames > 0 else {
|
||||
throw RecorderError.make(34, "maxPendingFrames must be positive")
|
||||
}
|
||||
}
|
||||
|
||||
private func startBoundaryTimer() {
|
||||
let timer = DispatchSource.makeTimerSource(queue: callbackQueue)
|
||||
timer.schedule(deadline: .now() + 1, repeating: 1, leeway: .milliseconds(100))
|
||||
timer.setEventHandler { [weak self] in
|
||||
self?.probeSimulatorState()
|
||||
self?.probeFramebufferHealth()
|
||||
self?.appendBoundaryFrameIfNeeded()
|
||||
}
|
||||
timer.resume()
|
||||
boundaryTimer = timer
|
||||
}
|
||||
|
||||
private func appendBoundaryFrameIfNeeded() {
|
||||
if stopped {
|
||||
return
|
||||
}
|
||||
guard configuration.segmentDuration > 0 else {
|
||||
return
|
||||
}
|
||||
writerQueue.sync {
|
||||
guard firstAcceptedCaptureTime != nil else {
|
||||
return
|
||||
}
|
||||
let currentPTSSeconds = CMTimeGetSeconds(
|
||||
normalizedPresentationTime(for: monotonicClock.elapsedTime()))
|
||||
guard currentPTSSeconds >= nextBoundaryElapsed else {
|
||||
return
|
||||
}
|
||||
let boundary = nextBoundaryElapsed
|
||||
guard let lastPTS else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
if CMTimeGetSeconds(lastPTS) < boundary {
|
||||
let boundaryPTS = CMTime(seconds: boundary, preferredTimescale: 1_000_000_000)
|
||||
guard try appendHeldFrame(at: boundaryPTS) else {
|
||||
return
|
||||
}
|
||||
}
|
||||
nextBoundaryElapsed = boundary + configuration.segmentDuration
|
||||
} catch {
|
||||
firstError = error
|
||||
signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func captureFrame(force: Bool, reason: CaptureReason) {
|
||||
if stopped {
|
||||
return
|
||||
}
|
||||
if reason == .callback {
|
||||
lastFrameCallbackElapsed = monotonicClock.elapsedSeconds()
|
||||
}
|
||||
guard let snapshot = displaySource?.surfaceSnapshot() else {
|
||||
return
|
||||
}
|
||||
let surface = snapshot.surface
|
||||
let surfaceWidth = snapshot.width
|
||||
let surfaceHeight = snapshot.height
|
||||
guard surfaceWidth > 0, surfaceHeight > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
let seed = snapshot.seed
|
||||
let matchesLastAppendedSeed = writerQueue.sync { lastSeed == seed }
|
||||
if !force, matchesLastAppendedSeed {
|
||||
return
|
||||
}
|
||||
|
||||
guard reservePendingFrame() else {
|
||||
return
|
||||
}
|
||||
|
||||
let capturedAt = monotonicClock.elapsedTime()
|
||||
let capturedAtWallClock = Date()
|
||||
do {
|
||||
let pixelBuffer = try copySurfaceToOwnedPixelBuffer(
|
||||
surface,
|
||||
width: surfaceWidth,
|
||||
height: surfaceHeight
|
||||
)
|
||||
appendOwned(
|
||||
pixelBuffer: pixelBuffer,
|
||||
seed: seed,
|
||||
capturedAt: capturedAt,
|
||||
capturedAtWallClock: capturedAtWallClock
|
||||
)
|
||||
} catch {
|
||||
releasePendingFrame()
|
||||
writerQueue.async {
|
||||
self.firstError = error
|
||||
self.signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func probeFramebufferHealth() {
|
||||
guard !stopped, let displaySource else {
|
||||
return
|
||||
}
|
||||
let elapsed = monotonicClock.elapsedSeconds()
|
||||
if !isFirstFrameReady(),
|
||||
elapsed - lastFirstFrameRewireElapsed >= Self.firstFrameRewireInterval
|
||||
{
|
||||
lastFirstFrameRewireElapsed = elapsed
|
||||
rewireFramebuffer()
|
||||
captureFrame(force: true, reason: .healthProbe)
|
||||
return
|
||||
}
|
||||
|
||||
guard let snapshot = displaySource.surfaceSnapshot(),
|
||||
snapshot.width > 0,
|
||||
snapshot.height > 0
|
||||
else {
|
||||
rewireFramebuffer()
|
||||
return
|
||||
}
|
||||
|
||||
let observedSeed = snapshot.seed
|
||||
let lastAppendedSeed = writerQueue.sync { lastSeed }
|
||||
guard let lastAppendedSeed, observedSeed != lastAppendedSeed else {
|
||||
return
|
||||
}
|
||||
|
||||
let lastCallback = lastFrameCallbackElapsed ?? 0
|
||||
if elapsed - lastCallback >= Self.callbackStalenessTimeout {
|
||||
captureFrame(force: false, reason: .healthProbe)
|
||||
rewireFramebuffer()
|
||||
}
|
||||
}
|
||||
|
||||
private func probeSimulatorState() {
|
||||
guard !stopped, simulatorStoppedReason == nil else {
|
||||
return
|
||||
}
|
||||
let state = SimulatorDeviceLookup.stateString(udid: configuration.deviceUDID) ?? "missing"
|
||||
guard state != "Booted" else {
|
||||
return
|
||||
}
|
||||
simulatorStoppedReason = state
|
||||
let error = RecorderError.make(26, "Simulator stopped before first frame (state: \(state))")
|
||||
writerQueue.async {
|
||||
if self.firstError == nil, !self.firstFrameReady {
|
||||
self.firstError = error
|
||||
self.signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
}
|
||||
let onSimulatorStopped = onSimulatorStopped
|
||||
eventQueue.async {
|
||||
onSimulatorStopped?(state)
|
||||
}
|
||||
}
|
||||
|
||||
private func rewireFramebuffer() {
|
||||
guard let displaySource else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try displaySource.rewireFramebuffer()
|
||||
} catch {
|
||||
writerQueue.async {
|
||||
self.firstError = error
|
||||
self.signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reservePendingFrame() -> Bool {
|
||||
pendingLock.lock()
|
||||
defer { pendingLock.unlock() }
|
||||
if pendingFrames >= configuration.maxPendingFrames {
|
||||
return false
|
||||
}
|
||||
pendingFrames += 1
|
||||
return true
|
||||
}
|
||||
|
||||
private func releasePendingFrame() {
|
||||
pendingLock.lock()
|
||||
pendingFrames = max(0, pendingFrames - 1)
|
||||
pendingLock.unlock()
|
||||
}
|
||||
|
||||
private func copySurfaceToOwnedPixelBuffer(
|
||||
_ surface: IOSurface,
|
||||
width: Int,
|
||||
height: Int
|
||||
) throws -> CVPixelBuffer {
|
||||
let pool = try writerQueue.sync {
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
if writer == nil {
|
||||
try startWriter(width: width, height: height)
|
||||
}
|
||||
guard let pool = adaptor?.pixelBufferPool else {
|
||||
throw RecorderError.make(40, "AVAssetWriter pixel buffer pool is unavailable")
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
var output: CVPixelBuffer?
|
||||
let status = CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &output)
|
||||
guard status == kCVReturnSuccess, let destination = output else {
|
||||
throw RecorderError.make(41, "Failed to allocate pixel buffer from pool: \(status)")
|
||||
}
|
||||
|
||||
let destinationLockStatus = CVPixelBufferLockBaseAddress(destination, [])
|
||||
guard destinationLockStatus == kCVReturnSuccess else {
|
||||
throw RecorderError.make(50, "Failed to lock pixel buffer: \(destinationLockStatus)")
|
||||
}
|
||||
let surfaceLockStatus = IOSurfaceLock(surface, .readOnly, nil)
|
||||
guard surfaceLockStatus == KERN_SUCCESS else {
|
||||
CVPixelBufferUnlockBaseAddress(destination, [])
|
||||
throw RecorderError.make(49, "Failed to lock IOSurface: \(surfaceLockStatus)")
|
||||
}
|
||||
defer {
|
||||
IOSurfaceUnlock(surface, .readOnly, nil)
|
||||
CVPixelBufferUnlockBaseAddress(destination, [])
|
||||
}
|
||||
|
||||
guard let destinationAddress = CVPixelBufferGetBaseAddress(destination) else {
|
||||
throw RecorderError.make(42, "Pixel buffer has no base address")
|
||||
}
|
||||
let sourceAddress = IOSurfaceGetBaseAddress(surface)
|
||||
let sourceStride = IOSurfaceGetBytesPerRow(surface)
|
||||
let destinationStride = CVPixelBufferGetBytesPerRow(destination)
|
||||
let copyWidth = min(width, IOSurfaceGetWidth(surface), CVPixelBufferGetWidth(destination))
|
||||
let copyHeight = min(
|
||||
height, IOSurfaceGetHeight(surface), CVPixelBufferGetHeight(destination))
|
||||
let copyBytes = min(copyWidth * 4, sourceStride, destinationStride)
|
||||
for row in 0..<copyHeight {
|
||||
memcpy(
|
||||
destinationAddress.advanced(by: row * destinationStride),
|
||||
sourceAddress.advanced(by: row * sourceStride),
|
||||
copyBytes
|
||||
)
|
||||
}
|
||||
return destination
|
||||
}
|
||||
|
||||
private func appendOwned(
|
||||
pixelBuffer: CVPixelBuffer,
|
||||
seed: UInt32,
|
||||
capturedAt: CMTime,
|
||||
capturedAtWallClock: Date
|
||||
) {
|
||||
writerQueue.async {
|
||||
defer { self.releasePendingFrame() }
|
||||
if self.firstError != nil {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try self.appendOnWriterQueue(
|
||||
pixelBuffer: pixelBuffer,
|
||||
seed: seed,
|
||||
capturedAt: capturedAt,
|
||||
capturedAtWallClock: capturedAtWallClock
|
||||
)
|
||||
} catch {
|
||||
self.firstError = error
|
||||
self.signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func appendOnWriterQueue(
|
||||
pixelBuffer: CVPixelBuffer,
|
||||
seed: UInt32,
|
||||
capturedAt: CMTime,
|
||||
capturedAtWallClock: Date
|
||||
) throws {
|
||||
guard let writer, let input, let adaptor else {
|
||||
throw RecorderError.make(43, "AVAssetWriter is not initialized")
|
||||
}
|
||||
let isFirstFrame = firstAcceptedCaptureTime == nil
|
||||
let pts = isFirstFrame ? .zero : normalizedPresentationTime(for: capturedAt)
|
||||
if let lastPTS, CMTimeCompare(pts, lastPTS) <= 0 {
|
||||
return
|
||||
}
|
||||
guard try appendPendingBoundaryFrames(before: pts) else {
|
||||
return
|
||||
}
|
||||
guard input.isReadyForMoreMediaData else {
|
||||
return
|
||||
}
|
||||
if !adaptor.append(pixelBuffer, withPresentationTime: pts) {
|
||||
throw writer.error ?? RecorderError.make(44, "Failed to append pixel buffer")
|
||||
}
|
||||
if isFirstFrame {
|
||||
firstAcceptedCaptureTime = capturedAt
|
||||
firstAcceptedWallClock = capturedAtWallClock
|
||||
}
|
||||
lastPTS = pts
|
||||
lastSeed = seed
|
||||
lastAppendedPixelBuffer = pixelBuffer
|
||||
signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
|
||||
private func appendTailFrameIfNeeded(finalPTS: CMTime) throws {
|
||||
let deadline = Date().addingTimeInterval(5)
|
||||
while true {
|
||||
if try appendTailFrame(finalPTS: finalPTS) {
|
||||
return
|
||||
}
|
||||
if Date() >= deadline {
|
||||
throw RecorderError.make(45, "Timed out waiting to append tail frame")
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.05)
|
||||
}
|
||||
}
|
||||
|
||||
private func appendTailFrame(finalPTS: CMTime) throws -> Bool {
|
||||
let pts = normalizedPresentationTime(for: finalPTS)
|
||||
return try appendHeldFrame(at: pts)
|
||||
}
|
||||
|
||||
private func appendPendingBoundaryFrames(before pts: CMTime) throws -> Bool {
|
||||
guard configuration.segmentDuration > 0,
|
||||
firstAcceptedCaptureTime != nil
|
||||
else {
|
||||
return true
|
||||
}
|
||||
|
||||
while CMTimeGetSeconds(pts) > nextBoundaryElapsed {
|
||||
if let lastPTS, CMTimeGetSeconds(lastPTS) >= nextBoundaryElapsed {
|
||||
nextBoundaryElapsed += configuration.segmentDuration
|
||||
continue
|
||||
}
|
||||
|
||||
let boundaryPTS = CMTime(
|
||||
seconds: nextBoundaryElapsed, preferredTimescale: 1_000_000_000)
|
||||
guard try appendHeldFrame(at: boundaryPTS) else {
|
||||
return false
|
||||
}
|
||||
nextBoundaryElapsed += configuration.segmentDuration
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func appendHeldFrame(at pts: CMTime) throws -> Bool {
|
||||
guard let input,
|
||||
let adaptor,
|
||||
let lastPTS,
|
||||
let lastAppendedPixelBuffer
|
||||
else {
|
||||
return true
|
||||
}
|
||||
if CMTimeCompare(pts, lastPTS) <= 0 {
|
||||
return true
|
||||
}
|
||||
guard input.isReadyForMoreMediaData else {
|
||||
return false
|
||||
}
|
||||
if !adaptor.append(lastAppendedPixelBuffer, withPresentationTime: pts) {
|
||||
throw writer?.error ?? RecorderError.make(45, "Failed to append held frame")
|
||||
}
|
||||
self.lastPTS = pts
|
||||
return true
|
||||
}
|
||||
|
||||
private func normalizedPresentationTime(for capturedAt: CMTime) -> CMTime {
|
||||
let start = firstAcceptedCaptureTime ?? capturedAt
|
||||
return CMTimeSubtract(capturedAt, start)
|
||||
}
|
||||
|
||||
private func startWriter(width: Int, height: Int) throws {
|
||||
guard let outputWriter else {
|
||||
throw RecorderError.make(46, "Missing output writer")
|
||||
}
|
||||
|
||||
let writer: AVAssetWriter
|
||||
if configuration.segmentDuration > 0 {
|
||||
writer = AVAssetWriter(contentType: .mpeg4Movie)
|
||||
writer.delegate = outputWriter
|
||||
writer.outputFileTypeProfile = .mpeg4AppleHLS
|
||||
writer.preferredOutputSegmentInterval = CMTime(
|
||||
seconds: configuration.segmentDuration,
|
||||
preferredTimescale: 600
|
||||
)
|
||||
} else {
|
||||
writer = try AVAssetWriter(outputURL: outputWriter.singleRecordingURL, fileType: .mp4)
|
||||
}
|
||||
writer.initialSegmentStartTime = .zero
|
||||
|
||||
let input = AVAssetWriterInput(
|
||||
mediaType: .video,
|
||||
outputSettings: assetWriterSettings(width: width, height: height)
|
||||
)
|
||||
input.expectsMediaDataInRealTime = true
|
||||
|
||||
let attributes: [String: Any] = [
|
||||
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
|
||||
kCVPixelBufferWidthKey as String: width,
|
||||
kCVPixelBufferHeightKey as String: height,
|
||||
kCVPixelBufferIOSurfacePropertiesKey as String: [:],
|
||||
]
|
||||
let adaptor = AVAssetWriterInputPixelBufferAdaptor(
|
||||
assetWriterInput: input,
|
||||
sourcePixelBufferAttributes: attributes
|
||||
)
|
||||
|
||||
guard writer.canAdd(input) else {
|
||||
throw RecorderError.make(47, "AVAssetWriter cannot add video input")
|
||||
}
|
||||
writer.add(input)
|
||||
guard writer.startWriting() else {
|
||||
throw writer.error ?? RecorderError.make(48, "AVAssetWriter failed to start")
|
||||
}
|
||||
writer.startSession(atSourceTime: .zero)
|
||||
|
||||
self.writer = writer
|
||||
self.input = input
|
||||
self.adaptor = adaptor
|
||||
recordingWidth = width
|
||||
recordingHeight = height
|
||||
}
|
||||
|
||||
private func assetWriterSettings(width: Int, height: Int) -> [String: Any] {
|
||||
let codecType: AVVideoCodecType = configuration.codec == .hevc ? .hevc : .h264
|
||||
var compression: [String: Any] = [
|
||||
AVVideoAverageBitRateKey: configuration.bitrate,
|
||||
AVVideoExpectedSourceFrameRateKey: configuration.fps,
|
||||
AVVideoAllowFrameReorderingKey: true,
|
||||
]
|
||||
if configuration.segmentDuration > 0 {
|
||||
compression[AVVideoMaxKeyFrameIntervalKey] = max(
|
||||
1,
|
||||
Int((configuration.segmentDuration * Double(configuration.fps)).rounded())
|
||||
)
|
||||
}
|
||||
if configuration.codec == .h264 {
|
||||
compression[AVVideoProfileLevelKey] = AVVideoProfileLevelH264HighAutoLevel
|
||||
}
|
||||
return [
|
||||
AVVideoCodecKey: codecType,
|
||||
AVVideoWidthKey: width,
|
||||
AVVideoHeightKey: height,
|
||||
AVVideoCompressionPropertiesKey: compression,
|
||||
]
|
||||
}
|
||||
|
||||
private func signalFirstFrameReadyIfNeeded() {
|
||||
if firstFrameReady {
|
||||
return
|
||||
}
|
||||
firstFrameReady = true
|
||||
firstFrameSemaphore.signal()
|
||||
}
|
||||
|
||||
private func isFirstFrameReady() -> Bool {
|
||||
writerQueue.sync {
|
||||
firstFrameReady
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
|
||||
enum RecorderError {
|
||||
static func make(_ code: Int, _ message: String) -> NSError {
|
||||
NSError(
|
||||
domain: "RecordSim",
|
||||
code: code,
|
||||
userInfo: [NSLocalizedDescriptionKey: message]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension JSONEncoder {
|
||||
static var prettySorted: JSONEncoder {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
return encoder
|
||||
}
|
||||
}
|
||||
|
||||
func timeSeconds(_ time: CMTime) -> Double? {
|
||||
guard time.isValid, !time.isIndefinite, !time.isNegativeInfinity, !time.isPositiveInfinity else {
|
||||
return nil
|
||||
}
|
||||
return CMTimeGetSeconds(time)
|
||||
}
|
||||
|
||||
func iso8601(_ date: Date) -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
func unixMs(_ date: Date) -> Int64 {
|
||||
Int64((date.timeIntervalSince1970 * 1000).rounded())
|
||||
}
|
||||
|
||||
struct MonotonicClock {
|
||||
private static let timescale: CMTimeScale = 1_000_000_000
|
||||
|
||||
let startedAtUptimeNanoseconds: UInt64
|
||||
|
||||
init() {
|
||||
startedAtUptimeNanoseconds = DispatchTime.now().uptimeNanoseconds
|
||||
}
|
||||
|
||||
func elapsedSeconds() -> TimeInterval {
|
||||
TimeInterval(elapsedNanoseconds()) / TimeInterval(Self.timescale)
|
||||
}
|
||||
|
||||
func elapsedTime() -> CMTime {
|
||||
CMTime(value: CMTimeValue(elapsedNanoseconds()), timescale: Self.timescale)
|
||||
}
|
||||
|
||||
private func elapsedNanoseconds() -> UInt64 {
|
||||
DispatchTime.now().uptimeNanoseconds - startedAtUptimeNanoseconds
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import Foundation
|
||||
import RecordSim
|
||||
|
||||
struct CLIOptions {
|
||||
var udid: String?
|
||||
var output: String?
|
||||
var segmentDuration: TimeInterval = 120
|
||||
var fps = 30
|
||||
var bitrate = 30_000_000
|
||||
var codec: RecorderCodec = .h264
|
||||
}
|
||||
|
||||
private var requestedStopSignal: CInt = 0
|
||||
private var simulatorStopped: CInt = 0
|
||||
|
||||
private func handleStopSignal(_ signal: CInt) {
|
||||
requestedStopSignal = signal
|
||||
}
|
||||
|
||||
private func isStopRequested() -> Bool {
|
||||
requestedStopSignal != 0 || simulatorStopped != 0
|
||||
}
|
||||
|
||||
func usage() -> String {
|
||||
"""
|
||||
Usage:
|
||||
record-sim --udid <UDID> --output <session-dir> [options]
|
||||
|
||||
Options:
|
||||
--segment-duration <seconds> Target fMP4 media segment duration. Use 0 for one MP4. Default: 120
|
||||
--fps <fps> Expected source frame rate. Default: 30
|
||||
--bitrate <bits/sec> AVAssetWriter average bitrate. Default: 30000000
|
||||
--codec <h264|hevc> Video codec. Default: h264
|
||||
-h, --help Show this help
|
||||
"""
|
||||
}
|
||||
|
||||
func parseOptions(_ args: [String]) throws -> CLIOptions {
|
||||
var options = CLIOptions()
|
||||
var i = 0
|
||||
|
||||
func next(_ flag: String) throws -> String {
|
||||
i += 1
|
||||
guard i < args.count else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing value for \(flag)"])
|
||||
}
|
||||
return args[i]
|
||||
}
|
||||
|
||||
while i < args.count {
|
||||
let arg = args[i]
|
||||
switch arg {
|
||||
case "--udid", "-u":
|
||||
options.udid = try next(arg)
|
||||
case "--output", "--output-dir", "--out", "-o":
|
||||
options.output = try next(arg)
|
||||
case "--segment-duration":
|
||||
options.segmentDuration = Double(try next(arg)) ?? -1
|
||||
case "--fps":
|
||||
options.fps = Int(try next(arg)) ?? 0
|
||||
case "--bitrate":
|
||||
options.bitrate = Int(try next(arg)) ?? 0
|
||||
case "--codec":
|
||||
let raw = try next(arg)
|
||||
guard let codec = RecorderCodec(rawValue: raw) else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--codec must be h264 or hevc"])
|
||||
}
|
||||
options.codec = codec
|
||||
case "--help", "-h":
|
||||
print(usage())
|
||||
exit(0)
|
||||
default:
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Unknown argument: \(arg)"])
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
|
||||
guard options.udid != nil else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing required --udid"])
|
||||
}
|
||||
guard options.output != nil else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing required --output"])
|
||||
}
|
||||
guard options.segmentDuration >= 0 else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--segment-duration must be non-negative"])
|
||||
}
|
||||
guard options.fps > 0, options.fps <= 120 else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--fps must be between 1 and 120"])
|
||||
}
|
||||
guard options.bitrate > 0 else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--bitrate must be positive"])
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
do {
|
||||
signal(SIGINT, handleStopSignal)
|
||||
signal(SIGTERM, handleStopSignal)
|
||||
|
||||
let options = try parseOptions(Array(CommandLine.arguments.dropFirst()))
|
||||
let configuration = SimulatorRecordingConfiguration(
|
||||
deviceUDID: options.udid!,
|
||||
outputDirectory: URL(fileURLWithPath: options.output!),
|
||||
fps: options.fps,
|
||||
bitrate: options.bitrate,
|
||||
codec: options.codec,
|
||||
segmentDuration: options.segmentDuration
|
||||
)
|
||||
|
||||
let recorder = SimulatorRecorder(configuration: configuration)
|
||||
recorder.onSimulatorStopped = { state in
|
||||
simulatorStopped = 1
|
||||
fputs("[record-sim] simulator stopped state=\(state); finalizing recording\n", stderr)
|
||||
}
|
||||
recorder.onFinalizationStage = { stage in
|
||||
switch stage {
|
||||
case .captureStopped:
|
||||
fputs("[record-sim] screen capture stopped; saving final video\n", stderr)
|
||||
case .videoSaved:
|
||||
fputs("[record-sim] final video saved; writing recording details\n", stderr)
|
||||
}
|
||||
}
|
||||
recorder.onSegment = { segment in
|
||||
switch segment.kind {
|
||||
case .initialization:
|
||||
fputs("[record-sim] init \(segment.relativePath) bytes=\(segment.byteCount)\n", stderr)
|
||||
case .media:
|
||||
let duration = segment.durationSeconds ?? 0
|
||||
fputs(String(format: "[record-sim] segment %@ duration=%.3fs bytes=%d\n", segment.relativePath, duration, segment.byteCount), stderr)
|
||||
}
|
||||
}
|
||||
|
||||
try recorder.start()
|
||||
try recorder.waitUntilFirstFrame()
|
||||
fputs("[record-sim] recording -> \(options.output!)\n", stderr)
|
||||
|
||||
while !isStopRequested() {
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
}
|
||||
|
||||
if requestedStopSignal != 0 {
|
||||
let signalName = switch requestedStopSignal {
|
||||
case SIGINT: "SIGINT"
|
||||
case SIGTERM: "SIGTERM"
|
||||
default: "signal \(requestedStopSignal)"
|
||||
}
|
||||
fputs("[record-sim] received \(signalName); finishing recording\n", stderr)
|
||||
}
|
||||
let manifest = try recorder.stop()
|
||||
if let recording = manifest.recording {
|
||||
fputs("[record-sim] done recording=\(recording)\n", stderr)
|
||||
} else {
|
||||
fputs("[record-sim] done segments=\(manifest.segments.count)\n", stderr)
|
||||
}
|
||||
fputs("[record-sim] manifest \(URL(fileURLWithPath: options.output!).appendingPathComponent("session.json").path)\n", stderr)
|
||||
} catch {
|
||||
fputs("error: \(error.localizedDescription)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Android } from '@expo/eas-build-job';
|
||||
|
||||
export function resolveArtifactPath(job: Android.Job): string {
|
||||
return job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}';
|
||||
}
|
||||
@@ -61,7 +61,7 @@ jest.mock('../../utils/expoUpdates', () => ({
|
||||
}));
|
||||
jest.mock('../../utils/expoUpdatesEmbedded', () => ({
|
||||
...jest.requireActual('../../utils/expoUpdatesEmbedded'),
|
||||
uploadEmbeddedBundleAsync: jest.fn(),
|
||||
uploadEmbeddedBundleAsync: jest.fn(async () => ({ status: 'uploaded' })),
|
||||
}));
|
||||
jest.mock('../../utils/hooks', () => ({
|
||||
Hook: {
|
||||
@@ -344,7 +344,29 @@ describe(androidBuilder, () => {
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
|
||||
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ job: ctx.job, env: ctx.env, projectDir: '/workingdir/build' })
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['uploaded', BuildPhaseResult.SUCCESS],
|
||||
['skipped', BuildPhaseResult.SKIPPED],
|
||||
['failed', BuildPhaseResult.WARNING],
|
||||
] as const)('marks the phase from the %s result', async (uploadResult, phaseResult) => {
|
||||
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValueOnce({ status: uploadResult });
|
||||
const ctx = createContext({ sdkVersion: '58.0.0' });
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(datadogDistributionMock).toHaveBeenCalledWith(
|
||||
'eas.build.phase_duration',
|
||||
expect.any(Number),
|
||||
expect.objectContaining({
|
||||
build_phase: BuildPhase.UPLOAD_EMBEDDED_BUNDLE.toLowerCase(),
|
||||
result: phaseResult,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('skips by default on SDK 57 and earlier', async () => {
|
||||
@@ -371,7 +393,9 @@ describe(androidBuilder, () => {
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
|
||||
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ job: ctx.job })
|
||||
);
|
||||
});
|
||||
|
||||
it('skips on SDK 58 and later when EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE is 0', async () => {
|
||||
@@ -393,7 +417,9 @@ describe(androidBuilder, () => {
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
|
||||
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ job: ctx.job })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
runGradleCommand,
|
||||
} from '../android/gradle';
|
||||
import { formatGradleProfileReport, parseGradleProfile } from '../android/gradleProfile';
|
||||
import { resolveArtifactPath } from '../android/resolve';
|
||||
import { Sentry } from '../sentry';
|
||||
import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
|
||||
import { prebuildAsync } from '../common/prebuild';
|
||||
@@ -220,7 +221,7 @@ async function buildInnerAsync(
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
|
||||
await uploadApplicationArchive(ctx, {
|
||||
patternOrPath: ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}',
|
||||
patternOrPath: resolveArtifactPath(ctx.job),
|
||||
rootDir: ctx.getReactNativeProjectDirectory(),
|
||||
logger: ctx.logger,
|
||||
});
|
||||
@@ -228,7 +229,18 @@ async function buildInnerAsync(
|
||||
|
||||
if (shouldUploadEmbeddedBundle(ctx)) {
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
const { status } = await uploadEmbeddedBundleAsync({
|
||||
job: ctx.job,
|
||||
env: ctx.env,
|
||||
logger: ctx.logger,
|
||||
projectDir: ctx.getReactNativeProjectDirectory(),
|
||||
appConfig: await ctx.appConfig,
|
||||
});
|
||||
if (status === 'skipped') {
|
||||
ctx.markBuildPhaseSkipped();
|
||||
} else if (status === 'failed') {
|
||||
ctx.markBuildPhaseHasWarnings();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -237,7 +237,7 @@ async function buildInnerAsync(
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
|
||||
await uploadApplicationArchive(ctx, {
|
||||
patternOrPath: resolveArtifactPath(ctx),
|
||||
patternOrPath: resolveArtifactPath(ctx.job),
|
||||
rootDir: ctx.getReactNativeProjectDirectory(),
|
||||
logger: ctx.logger,
|
||||
});
|
||||
@@ -245,7 +245,18 @@ async function buildInnerAsync(
|
||||
|
||||
if (shouldUploadEmbeddedBundle(ctx)) {
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
const { status } = await uploadEmbeddedBundleAsync({
|
||||
job: ctx.job,
|
||||
env: ctx.env,
|
||||
logger: ctx.logger,
|
||||
projectDir: ctx.getReactNativeProjectDirectory(),
|
||||
appConfig: await ctx.appConfig,
|
||||
});
|
||||
if (status === 'skipped') {
|
||||
ctx.markBuildPhaseSkipped();
|
||||
} else if (status === 'failed') {
|
||||
ctx.markBuildPhaseHasWarnings();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -63,12 +63,12 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
public readonly logger: bunyan;
|
||||
public readonly graphqlClient: Client;
|
||||
public readonly runtimeApi: BuilderRuntimeApi;
|
||||
public readonly expoApiV2BaseUrl?: string;
|
||||
public readonly mcpServerUrl?: string;
|
||||
public job: TJob;
|
||||
public metadata?: Metadata;
|
||||
|
||||
private _env: Env;
|
||||
private readonly expoApiV2BaseUrl?: string;
|
||||
private readonly pendingMetricUploads: Promise<void>[] = [];
|
||||
|
||||
constructor(buildCtx: BuildContext<TJob>) {
|
||||
|
||||
@@ -15,10 +15,10 @@ export function resolveScheme(ctx: BuildContext<Ios.Job>): string {
|
||||
return schemes[0];
|
||||
}
|
||||
|
||||
export function resolveArtifactPath(ctx: BuildContext<Ios.Job>): string {
|
||||
if (ctx.job.applicationArchivePath) {
|
||||
return ctx.job.applicationArchivePath;
|
||||
} else if (ctx.job.simulator) {
|
||||
export function resolveArtifactPath(job: Ios.Job): string {
|
||||
if (job.applicationArchivePath) {
|
||||
return job.applicationArchivePath;
|
||||
} else if (job.simulator) {
|
||||
return 'ios/build/Build/Products/*simulator/*.app';
|
||||
} else {
|
||||
return 'ios/build/*.ipa';
|
||||
|
||||
@@ -10,6 +10,7 @@ import { configureIosVersionFunction } from './functions/configureIosVersion';
|
||||
import { createSubmissionEntityFunction } from './functions/createSubmissionEntity';
|
||||
import { createDownloadArtifactFunction } from './functions/downloadArtifact';
|
||||
import { createDownloadBuildFunction } from './functions/downloadBuild';
|
||||
import { createDownloadSubmissionArchiveFunction } from './functions/downloadSubmissionArchive';
|
||||
import { createEasDeployBuildFunction } from './functions/deploy';
|
||||
import { createEasExportBuildFunction } from './functions/export';
|
||||
import { eagerBundleBuildFunction } from './functions/eagerBundle';
|
||||
@@ -27,6 +28,7 @@ import { createLaunchApplicationFunction } from './functions/launchApplication';
|
||||
import { createPrebuildBuildFunction } from './functions/prebuild';
|
||||
import { createReadAppConfigBuildFunction } from './functions/readAppConfig';
|
||||
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
|
||||
import { createReadAndroidAppInfoBuildFunction } from './functions/readAndroidAppInfo';
|
||||
import { createReadPackageJsonBuildFunction } from './functions/readPackageJson';
|
||||
import { createRepackBuildFunction } from './functions/repack';
|
||||
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
|
||||
@@ -43,9 +45,9 @@ import { runGradleFunction } from './functions/runGradle';
|
||||
import { createMaestroTestsBuildFunction } from './functions/maestroTests';
|
||||
import { createSaveBuildCacheFunction } from './functions/saveBuildCache';
|
||||
import { createRolloutPosthogFlagFunction } from './functions/rolloutPosthogFlag';
|
||||
import { createRunAgentBuildFunction } from './functions/runAgent';
|
||||
import { createSaveCacheFunction } from './functions/saveCache';
|
||||
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
|
||||
import { createStartAgentDeviceSessionBuildFunction } from './functions/startAgentDeviceSession';
|
||||
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
|
||||
import { createStartArgentRemoteSessionBuildFunction } from './functions/startArgentRemoteSession';
|
||||
@@ -62,6 +64,7 @@ import { createStartSandboxBuildFunction } from './functions/startSandbox';
|
||||
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
|
||||
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
|
||||
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from './functions/uploadDeviceRunSessionScreenRecordings';
|
||||
import { createUploadEmbeddedBundleBuildFunction } from './functions/uploadEmbeddedBundle';
|
||||
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
|
||||
import { createUpdateTestFlightMetadataBuildFunction } from './functions/updateTestFlightMetadata';
|
||||
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
|
||||
@@ -82,7 +85,9 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createInstallNodeModulesBuildFunction(),
|
||||
createPrebuildBuildFunction(),
|
||||
createReadIpaInfoBuildFunction(),
|
||||
createReadAndroidAppInfoBuildFunction(),
|
||||
createDownloadBuildFunction(ctx),
|
||||
createDownloadSubmissionArchiveFunction(ctx),
|
||||
createInstallBuildFunction(),
|
||||
createLaunchApplicationFunction(),
|
||||
createEasExportBuildFunction(),
|
||||
@@ -104,7 +109,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
generateGymfileFromTemplateFunction(),
|
||||
runFastlaneFunction(),
|
||||
parseXcactivitylogFunction(),
|
||||
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
|
||||
createStartAgentDeviceSessionBuildFunction(ctx),
|
||||
createStartArgentRemoteSessionBuildFunction(ctx),
|
||||
createStartAppiumRemoteSessionBuildFunction(ctx),
|
||||
@@ -120,6 +124,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createStartServeSimLogsBuildFunction(),
|
||||
createCollectServeSimLogsBuildFunction(ctx),
|
||||
createStartSandboxBuildFunction(ctx),
|
||||
createRunAgentBuildFunction(ctx),
|
||||
createCollectServeSimMetricsBuildFunction(ctx),
|
||||
createInstallMaestroBuildFunction(),
|
||||
createInstallMitmproxyBuildFunction(),
|
||||
@@ -147,6 +152,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
functions.push(
|
||||
...[
|
||||
createFindAndUploadBuildArtifactsBuildFunction(ctx),
|
||||
createUploadEmbeddedBundleBuildFunction(ctx),
|
||||
createResolveBuildConfigBuildFunction(ctx),
|
||||
createGetCredentialsForBuildTriggeredByGithubIntegration(ctx),
|
||||
]
|
||||
|
||||
@@ -219,4 +219,71 @@ describe(createEasBuildBuildFunctionGroup, () => {
|
||||
'Build function group is not supported in generic jobs.'
|
||||
);
|
||||
});
|
||||
|
||||
describe('embedded bundle upload', () => {
|
||||
function getStepNames(
|
||||
options: Parameters<typeof createMockBuildToolsContext>[0],
|
||||
{ sdkVersion, env = {} }: { sdkVersion?: string; env?: Record<string, string> }
|
||||
): string[] {
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(createMockBuildToolsContext(options));
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger: createMockLogger(),
|
||||
staticContextContent: { metadata: sdkVersion ? { sdkVersion } : null },
|
||||
});
|
||||
globalCtx.updateEnv(env);
|
||||
return functionGroup
|
||||
.createBuildStepsFromFunctionGroupCall(globalCtx)
|
||||
.map(step => step.displayName);
|
||||
}
|
||||
|
||||
it.each([
|
||||
['Android', { platform: Platform.ANDROID }],
|
||||
['Android with credentials', { platform: Platform.ANDROID, buildCredentials: { test: {} } }],
|
||||
['iOS with credentials', { platform: Platform.IOS, buildCredentials: { test: {} } }],
|
||||
])('uploads the embedded bundle after the build artifacts on SDK 58 (%s)', (_, options) => {
|
||||
const stepNames = getStepNames(options, { sdkVersion: '58.0.0' });
|
||||
|
||||
expect(stepNames.indexOf('Upload embedded bundle')).toBe(
|
||||
stepNames.indexOf('Find and upload build artifacts') + 1
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Android', { platform: Platform.ANDROID }],
|
||||
['Android with credentials', { platform: Platform.ANDROID, buildCredentials: { test: {} } }],
|
||||
['iOS with credentials', { platform: Platform.IOS, buildCredentials: { test: {} } }],
|
||||
])('passes ignore_error to the embedded bundle step (%s)', (_, options) => {
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(createMockBuildToolsContext(options));
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger: createMockLogger(),
|
||||
staticContextContent: { metadata: { sdkVersion: '58.0.0' } },
|
||||
});
|
||||
|
||||
const uploadStep = functionGroup
|
||||
.createBuildStepsFromFunctionGroupCall(globalCtx)
|
||||
.find(step => step.displayName === 'Upload embedded bundle');
|
||||
|
||||
expect(uploadStep).toBeDefined();
|
||||
expect(uploadStep!.inputs?.find(input => input.id === 'ignore_error')?.rawValue).toBe(true);
|
||||
});
|
||||
|
||||
it('does not upload the embedded bundle on SDK 57 without the opt-in', () => {
|
||||
expect(getStepNames({}, { sdkVersion: '57.0.0' })).not.toContain('Upload embedded bundle');
|
||||
});
|
||||
|
||||
it('uploads the embedded bundle on SDK 57 with the opt-in', () => {
|
||||
expect(
|
||||
getStepNames(
|
||||
{},
|
||||
{ sdkVersion: '57.0.0', env: { EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1' } }
|
||||
)
|
||||
).toContain('Upload embedded bundle');
|
||||
});
|
||||
|
||||
it('does not upload the embedded bundle for iOS simulator builds', () => {
|
||||
expect(
|
||||
getStepNames({ platform: Platform.IOS, simulator: true }, { sdkVersion: '58.0.0' })
|
||||
).not.toContain('Upload embedded bundle');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import path from 'path';
|
||||
|
||||
import { shouldUseEagerBundle } from '../../common/eagerBundle';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { shouldUploadEmbeddedBundle } from '../../utils/expoUpdatesEmbedded';
|
||||
import { calculateEASUpdateRuntimeVersionFunction } from '../functions/calculateEASUpdateRuntimeVersion';
|
||||
import { createCheckoutBuildFunction } from '../functions/checkout';
|
||||
import { configureAndroidVersionFunction } from '../functions/configureAndroidVersion';
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
import { runFastlaneFunction } from '../functions/runFastlane';
|
||||
import { runGradleFunction } from '../functions/runGradle';
|
||||
import { createSaveBuildCacheFunction } from '../functions/saveBuildCache';
|
||||
import { createUploadEmbeddedBundleBuildFunction } from '../functions/uploadEmbeddedBundle';
|
||||
import { createSetUpNpmrcBuildFunction } from '../functions/useNpmToken';
|
||||
|
||||
interface HelperFunctionsInput {
|
||||
@@ -303,6 +305,19 @@ function createStepsForIosBuildWithCredentials({
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
...(shouldUploadEmbeddedBundle({
|
||||
env: globalCtx.env,
|
||||
metadata: globalCtx.staticContext.metadata,
|
||||
})
|
||||
? [
|
||||
createUploadEmbeddedBundleBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: { ignore_error: true },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
@@ -390,6 +405,19 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
...(shouldUploadEmbeddedBundle({
|
||||
env: globalCtx.env,
|
||||
metadata: globalCtx.staticContext.metadata,
|
||||
})
|
||||
? [
|
||||
createUploadEmbeddedBundleBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: { ignore_error: true },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
@@ -483,6 +511,19 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
...(shouldUploadEmbeddedBundle({
|
||||
env: globalCtx.env,
|
||||
metadata: globalCtx.staticContext.metadata,
|
||||
})
|
||||
? [
|
||||
createUploadEmbeddedBundleBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: { ignore_error: true },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+47
@@ -0,0 +1,47 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { readAndroidArtifactInfoAsync } from '../../utils/android/appArtifact';
|
||||
import { createReadAndroidAppInfoBuildFunction } from '../readAndroidAppInfo';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
jest.unmock('@expo/spawn-async');
|
||||
|
||||
const fixturesDirectory = path.join(__dirname, 'fixtures/android-app-info');
|
||||
|
||||
it.each(['apk', 'aab'])('reads a real %s and publishes build step outputs', async type => {
|
||||
const step = createReadAndroidAppInfoBuildFunction().createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({
|
||||
projectTargetDirectory: fixturesDirectory,
|
||||
staticContextContent: { job: {} },
|
||||
}),
|
||||
{ callInputs: { artifact_path: `app.${type}` } }
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(step.outputById.artifact_type.value).toBe(type);
|
||||
expect(step.outputById.package_name.value).toBe('com.example.app');
|
||||
});
|
||||
|
||||
it('reports an invalid ZIP as a user error', async () => {
|
||||
await expect(readAndroidArtifactInfoAsync(__filename)).rejects.toMatchObject({
|
||||
constructor: UserError,
|
||||
errorCode: 'EAS_READ_ANDROID_APP_INFO_INVALID_ARTIFACT',
|
||||
cause: expect.any(Error),
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['apk', 'aab'])('retains the native tool error for an invalid %s manifest', async type => {
|
||||
await expect(
|
||||
readAndroidArtifactInfoAsync(path.join(fixturesDirectory, `invalid-manifest-${type}.zip`))
|
||||
).rejects.toMatchObject({
|
||||
constructor: UserError,
|
||||
errorCode: 'EAS_READ_ANDROID_APP_INFO_INVALID_MANIFEST',
|
||||
cause: expect.any(Error),
|
||||
});
|
||||
});
|
||||
+96
-6
@@ -16,7 +16,7 @@ import {
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForFileAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
|
||||
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
|
||||
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
@@ -27,6 +27,14 @@ jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/agentDeviceEvents');
|
||||
jest.mock('../../utils/appiumEvents');
|
||||
jest.mock('../../utils/agentDeviceArtifacts');
|
||||
// The agent-device session step boots the emulator itself; this test covers the session host.
|
||||
jest.mock('../startAndroidEmulator', () => ({
|
||||
startAndroidEmulatorAsync: jest.fn().mockResolvedValue({
|
||||
serialId: 'emulator-5554',
|
||||
emulatorPromise: Promise.resolve(),
|
||||
shouldAdjustAnimationScale: true,
|
||||
}),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
spawnDetached: jest.fn(),
|
||||
@@ -48,6 +56,15 @@ beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// The daemon package is external. No installation or daemon process runs in these tests.
|
||||
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
// Only the emulator logcat folder: the Appium step writes into its own temp folder.
|
||||
const mkdtemp = fs.promises.mkdtemp.bind(fs.promises);
|
||||
jest
|
||||
.spyOn(fs.promises, 'mkdtemp')
|
||||
.mockImplementation(async prefix =>
|
||||
String(prefix).includes('eas-android-emulator-logcat-')
|
||||
? '/tmp/eas-android-emulator-logcat-test'
|
||||
: mkdtemp(prefix)
|
||||
);
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '{}' } as never);
|
||||
jest
|
||||
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
|
||||
@@ -89,7 +106,7 @@ afterEach(() => {
|
||||
|
||||
describe.each([
|
||||
['Appium', createStartAppiumRemoteSessionBuildFunction],
|
||||
['Agent Device', createStartAgentDeviceRemoteSessionBuildFunction],
|
||||
['Agent Device', createStartAgentDeviceSessionBuildFunction],
|
||||
] as const)('%s host ownership', (name, createFunction) => {
|
||||
async function runAsync() {
|
||||
const fn = createFunction({} as CustomBuildContext);
|
||||
@@ -99,10 +116,10 @@ describe.each([
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
// Inputs a step does not get default to undefined, like in a real step call.
|
||||
inputs: new Proxy({} as Record<string, { value: unknown }>, {
|
||||
get: (target, id: string) => target[id] ?? { value: undefined },
|
||||
}),
|
||||
outputs: {},
|
||||
env: {
|
||||
DEVICE_RUN_SESSION_ID: 'session-id',
|
||||
@@ -243,3 +260,76 @@ describe.each([
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Appium ffmpeg setup', () => {
|
||||
async function runAppiumAsync(): Promise<void> {
|
||||
const fn = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await fn.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: new Proxy({} as Record<string, { value: unknown }>, {
|
||||
get: (target, id: string) => target[id] ?? { value: undefined },
|
||||
}),
|
||||
outputs: {},
|
||||
env: {
|
||||
DEVICE_RUN_SESSION_ID: 'session-id',
|
||||
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
|
||||
NGROK_AUTHTOKEN: 'token',
|
||||
},
|
||||
} as never
|
||||
);
|
||||
}
|
||||
|
||||
function holdFfmpegInstall({ appiumError }: { appiumError?: Error } = {}) {
|
||||
let finish!: (outcome: 'installed' | 'failed') => void;
|
||||
const install = new Promise((resolve, reject) => {
|
||||
finish = outcome =>
|
||||
outcome === 'installed' ? resolve({}) : reject(new Error('apt-get failed'));
|
||||
});
|
||||
jest.mocked(spawn).mockImplementation(((command: string, args: string[]) => {
|
||||
if (command === 'ffmpeg') {
|
||||
return Promise.reject(new Error('ffmpeg missing'));
|
||||
}
|
||||
if (args.includes('install') && args.includes('ffmpeg')) {
|
||||
return install;
|
||||
}
|
||||
return command === 'npm' && appiumError
|
||||
? Promise.reject(appiumError)
|
||||
: Promise.resolve({ stdout: '{}' });
|
||||
}) as never);
|
||||
return finish;
|
||||
}
|
||||
|
||||
it.each(['installed', 'failed'] as const)(
|
||||
'starts Appium after the ffmpeg install settles (%s)',
|
||||
async outcome => {
|
||||
const finishFfmpegInstall = holdFfmpegInstall();
|
||||
|
||||
const run = runAppiumAsync();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
|
||||
finishFfmpegInstall(outcome);
|
||||
await run;
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('waits for the ffmpeg install before failing on an Appium install error', async () => {
|
||||
const appiumError = new Error('npm failed');
|
||||
const finishFfmpegInstall = holdFfmpegInstall({ appiumError });
|
||||
|
||||
let settled = false;
|
||||
const run = runAppiumAsync().finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(settled).toBe(false);
|
||||
|
||||
finishFfmpegInstall('installed');
|
||||
await expect(run).rejects.toBe(appiumError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Client, CombinedError } from '@urql/core';
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
import fs from 'node:fs';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { createDownloadSubmissionArchiveFunction } from '../downloadSubmissionArchive';
|
||||
|
||||
const submissionId = 'aabbccdd-1234-4321-abcd-123456789012';
|
||||
const url = 'https://storage.example/app.ipa?signature=secret';
|
||||
|
||||
it('reports invalid submission ID and extensions together', async () => {
|
||||
const mutation = jest.fn();
|
||||
const step = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { submission_id: 'invalid', extensions: [123] },
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toMatchObject({
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({ path: ['submissionId'] }),
|
||||
expect.objectContaining({ path: ['extensions', 0] }),
|
||||
]),
|
||||
});
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requests a fresh URL on each execution, downloads without auth and does not log the URL', async () => {
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
submission: { generateSubmissionArchiveDownloadUrl: url },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
const logger = createMockLogger();
|
||||
const fn = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
url,
|
||||
body: Readable.from(Buffer.from('IPA bytes')),
|
||||
headers: { get: () => null },
|
||||
} as unknown as Response);
|
||||
const step = fn.createBuildStepFromFunctionCall(createGlobalContextMock({ logger }), {
|
||||
callInputs: { submission_id: submissionId },
|
||||
});
|
||||
await step.executeAsync();
|
||||
expect(await fs.promises.readFile(step.getOutputValueByName('artifact_path')!, 'utf8')).toBe(
|
||||
'IPA bytes'
|
||||
);
|
||||
}
|
||||
expect(mutation).toHaveBeenCalledTimes(2);
|
||||
expect(fetch).toHaveBeenCalledWith(url, { headers: undefined, signal: undefined });
|
||||
expect(JSON.stringify(jest.mocked(logger.info).mock.calls)).not.toContain(url);
|
||||
});
|
||||
|
||||
it('preserves the API error as the cause without downloading', async () => {
|
||||
const error = new CombinedError({ networkError: new Error('Connection reset') });
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({
|
||||
error,
|
||||
}),
|
||||
}));
|
||||
const step = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { submission_id: submissionId },
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toMatchObject({
|
||||
message: 'Could not request the submission archive. Try again later.',
|
||||
cause: error,
|
||||
});
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(mutation).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('retries a temporary URL request failure and then downloads the archive', async () => {
|
||||
const mutation = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
toPromise: async () => ({
|
||||
error: new CombinedError({ networkError: new Error('Connection reset') }),
|
||||
}),
|
||||
})
|
||||
.mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
data: { submission: { generateSubmissionArchiveDownloadUrl: url } },
|
||||
}),
|
||||
});
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
url,
|
||||
body: Readable.from(Buffer.from('IPA bytes')),
|
||||
headers: { get: () => null },
|
||||
} as unknown as Response);
|
||||
const step = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { submission_id: submissionId },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mutation).toHaveBeenCalledTimes(2);
|
||||
expect(await fs.promises.readFile(step.getOutputValueByName('artifact_path')!, 'utf8')).toBe(
|
||||
'IPA bytes'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not retry an access error', async () => {
|
||||
const error = new CombinedError({ graphQLErrors: [{ message: 'Not authorized' }] });
|
||||
const mutation = jest.fn(() => ({ toPromise: async () => ({ error }) }));
|
||||
const step = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { submission_id: submissionId },
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toMatchObject({ cause: error });
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not send another URL request after the step times out during retry backoff', async () => {
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({
|
||||
error: new CombinedError({ networkError: new Error('Connection reset') }),
|
||||
}),
|
||||
}));
|
||||
const step = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { submission_id: submissionId },
|
||||
timeoutMs: 100,
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow('timed out');
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the requested extension for an archive without a filename extension', async () => {
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
submission: { generateSubmissionArchiveDownloadUrl: 'https://storage.example/archive' },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
url: 'https://storage.example/archive',
|
||||
body: Readable.from(Buffer.from('Android archive bytes')),
|
||||
headers: { get: () => null },
|
||||
} as unknown as Response);
|
||||
const step = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { submission_id: submissionId, extensions: ['aab'] },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
const artifactPath = step.getOutputValueByName('artifact_path')!;
|
||||
expect(artifactPath).toMatch(/\.aab$/);
|
||||
expect(await fs.promises.readFile(artifactPath, 'utf8')).toBe('Android archive bytes');
|
||||
});
|
||||
|
||||
it('propagates the original download error', async () => {
|
||||
const error = new Error('Download failed');
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({
|
||||
data: { submission: { generateSubmissionArchiveDownloadUrl: url } },
|
||||
}),
|
||||
}));
|
||||
jest.mocked(fetch).mockRejectedValue(error);
|
||||
const step = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { submission_id: submissionId },
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toBe(error);
|
||||
});
|
||||
@@ -463,114 +463,127 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('installs the prebuilt WDA cache for each available iOS runtime', async () => {
|
||||
const homeDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'install-maestro-test-')
|
||||
);
|
||||
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'maestro-runner') {
|
||||
return { stdout: 'maestro-runner 1.2.3\n' };
|
||||
}
|
||||
if (command === 'xcrun') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
runtimes: [
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
|
||||
isAvailable: true,
|
||||
version: '18.3.1',
|
||||
},
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-0',
|
||||
isAvailable: true,
|
||||
version: '26.0',
|
||||
},
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-17-5',
|
||||
isAvailable: false,
|
||||
version: '17.5',
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (command === 'xcodebuild') {
|
||||
return { stdout: 'Xcode 26.0\nBuild version 17A324\n' };
|
||||
}
|
||||
if (command === 'tar') {
|
||||
const maestroRunnerHome = args[args.indexOf('-C') + 1];
|
||||
const productsDirectory = path.join(
|
||||
maestroRunnerHome,
|
||||
'cache',
|
||||
'wda-builds',
|
||||
'generic',
|
||||
'DerivedData',
|
||||
'Build',
|
||||
'Products'
|
||||
);
|
||||
await fs.promises.mkdir(productsDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(productsDirectory, 'WebDriverAgentRunner.xctestrun'),
|
||||
'cached'
|
||||
);
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as any);
|
||||
|
||||
try {
|
||||
await writeInstalledWdaVersion(homeDirectory, '11.1.3');
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com',
|
||||
HOME: homeDirectory,
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenCalledWith(
|
||||
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.0-wda-11.1.3.tar.gz',
|
||||
expect.stringMatching(/install_maestro_runner_wda_cache.*\/wda-cache\.tar\.gz$/),
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
it.each(['1.1.27', '1.1.28', '1.2.3'])(
|
||||
'prepares the WDA cache only for legacy Runner versions (%s)',
|
||||
async version => {
|
||||
const homeDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'install-maestro-test-')
|
||||
);
|
||||
for (const runtimeVersion of ['18.3', '26.0']) {
|
||||
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'maestro-runner') {
|
||||
return { stdout: `maestro-runner ${version}\n` };
|
||||
}
|
||||
if (command === 'xcrun') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
runtimes: [
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
|
||||
isAvailable: true,
|
||||
version: '18.3.1',
|
||||
},
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-0',
|
||||
isAvailable: true,
|
||||
version: '26.0',
|
||||
},
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-17-5',
|
||||
isAvailable: false,
|
||||
version: '17.5',
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (command === 'xcodebuild') {
|
||||
return { stdout: 'Xcode 26.0\nBuild version 17A324\n' };
|
||||
}
|
||||
if (command === 'tar') {
|
||||
const maestroRunnerHome = args[args.indexOf('-C') + 1];
|
||||
const productsDirectory = path.join(
|
||||
maestroRunnerHome,
|
||||
'cache',
|
||||
'wda-builds',
|
||||
'generic',
|
||||
'DerivedData',
|
||||
'Build',
|
||||
'Products'
|
||||
);
|
||||
await fs.promises.mkdir(productsDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(productsDirectory, 'WebDriverAgentRunner.xctestrun'),
|
||||
'cached'
|
||||
);
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as any);
|
||||
|
||||
try {
|
||||
await writeInstalledWdaVersion(homeDirectory, '11.1.3');
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com',
|
||||
HOME: homeDirectory,
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe(version);
|
||||
if (version !== '1.1.27') {
|
||||
expect(mockedDownloadFile).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn.mock.calls.map(([command]) => command)).not.toContain('xcrun');
|
||||
await expect(
|
||||
fs.promises.access(path.join(homeDirectory, '.maestro-runner', 'cache'))
|
||||
).rejects.toThrow();
|
||||
return;
|
||||
}
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenCalledWith(
|
||||
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.0-wda-11.1.3.tar.gz',
|
||||
expect.stringMatching(/install_maestro_runner_wda_cache.*\/wda-cache\.tar\.gz$/),
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
);
|
||||
for (const runtimeVersion of ['18.3', '26.0']) {
|
||||
await expect(
|
||||
fs.promises.readFile(
|
||||
path.join(
|
||||
homeDirectory,
|
||||
'.maestro-runner',
|
||||
'cache',
|
||||
'wda-builds',
|
||||
`sim-ios${runtimeVersion}-iphone`,
|
||||
'DerivedData',
|
||||
'Build',
|
||||
'Products',
|
||||
'WebDriverAgentRunner.xctestrun'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
).resolves.toBe('cached');
|
||||
}
|
||||
await expect(
|
||||
fs.promises.readFile(
|
||||
path.join(
|
||||
homeDirectory,
|
||||
'.maestro-runner',
|
||||
'cache',
|
||||
'wda-builds',
|
||||
`sim-ios${runtimeVersion}-iphone`,
|
||||
'DerivedData',
|
||||
'Build',
|
||||
'Products',
|
||||
'WebDriverAgentRunner.xctestrun'
|
||||
),
|
||||
'utf8'
|
||||
fs.promises.access(
|
||||
path.join(homeDirectory, '.maestro-runner', 'cache', 'wda-builds', 'sim-ios17.5-iphone')
|
||||
)
|
||||
).resolves.toBe('cached');
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
|
||||
}
|
||||
await expect(
|
||||
fs.promises.access(
|
||||
path.join(homeDirectory, '.maestro-runner', 'cache', 'wda-builds', 'sim-ios17.5-iphone')
|
||||
)
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
it('does not download the WDA cache when the installed WDA version is unknown', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => ({
|
||||
stdout:
|
||||
command === 'xcodebuild' ? 'Xcode 26.5\nBuild version 17F90\n' : 'maestro-runner 1.2.3\n',
|
||||
command === 'xcodebuild' ? 'Xcode 26.5\nBuild version 17F90\n' : 'maestro-runner 1.1.27\n',
|
||||
})) as any);
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
@@ -593,7 +606,7 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
switch (command) {
|
||||
case 'maestro-runner':
|
||||
return { stdout: 'maestro-runner 1.2.3\n' };
|
||||
return { stdout: 'maestro-runner 1.1.27\n' };
|
||||
case 'xcodebuild':
|
||||
return { stdout: 'Xcode 26.5\nBuild version 17F90\n' };
|
||||
case 'xcrun':
|
||||
@@ -633,7 +646,7 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).resolves.toBeUndefined();
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.2.3');
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.1.27');
|
||||
expect(mockedDownloadFile.mock.calls.map(([url]) => url)).toEqual([
|
||||
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.5-wda-11.1.3.tar.gz',
|
||||
'https://storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.5-wda-11.1.3.tar.gz',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BuildRuntimePlatform, BuildStep } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { fs, vol } from 'memfs';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createInstallMitmproxyBuildFunction } from '../installMitmproxy';
|
||||
@@ -11,6 +12,58 @@ jest.mock('@expo/turtle-spawn', () => ({
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
// An x86 Homebrew prefix, to check that the app is found from PATH and not from process.arch.
|
||||
const APP = '/usr/local/Caskroom/mitmproxy/12.2.3/mitmproxy.app';
|
||||
const LINK = '/usr/local/bin/mitmdump';
|
||||
|
||||
function installCask(): void {
|
||||
vol.fromJSON({ [`${APP}/Contents/MacOS/mitmdump`]: '', '/usr/local/bin': null });
|
||||
fs.symlinkSync(`${APP}/Contents/MacOS/mitmdump`, LINK);
|
||||
}
|
||||
|
||||
/**
|
||||
* `which` finds mitmdump once the cask is installed, `brew` installs it, `xattr -p` reports
|
||||
* quarantine while `stuck` is set, and each `mitmdump --version` takes the next result.
|
||||
*/
|
||||
function mockCommands({
|
||||
installed = false,
|
||||
stuck = false,
|
||||
runs = [],
|
||||
}: {
|
||||
installed?: boolean;
|
||||
stuck?: boolean;
|
||||
runs?: boolean[];
|
||||
}): void {
|
||||
if (installed) {
|
||||
installCask();
|
||||
}
|
||||
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'which' && !installed) {
|
||||
throw new Error('not found');
|
||||
}
|
||||
if (command === 'which') {
|
||||
return { stdout: `${LINK}\n` };
|
||||
}
|
||||
if (command === 'brew' && !installed) {
|
||||
installed = true;
|
||||
installCask();
|
||||
}
|
||||
if (command === 'xattr' && args[0] === '-p' && !stuck) {
|
||||
throw new Error('No such xattr: com.apple.quarantine');
|
||||
}
|
||||
if (command === 'mitmdump' && !runs.shift()) {
|
||||
throw new Error('not found');
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as never);
|
||||
}
|
||||
|
||||
function spawnedCommands(): string[] {
|
||||
return mockedSpawn.mock.calls.map(([command, args]) =>
|
||||
command === 'xattr' ? `xattr ${args?.[0]}` : command
|
||||
);
|
||||
}
|
||||
|
||||
describe('createInstallMitmproxyBuildFunction', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -24,28 +77,51 @@ describe('createInstallMitmproxyBuildFunction', () => {
|
||||
});
|
||||
}
|
||||
|
||||
it('does not install when mitmdump is already on PATH', async () => {
|
||||
mockedSpawn.mockResolvedValueOnce({} as never);
|
||||
it('takes the app behind mitmdump on PATH out of quarantine before running it', async () => {
|
||||
mockCommands({ installed: true, runs: [true] });
|
||||
|
||||
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('mitmdump', ['--version'], expect.anything());
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xattr',
|
||||
['-dr', 'com.apple.quarantine', APP],
|
||||
expect.anything()
|
||||
);
|
||||
expect(spawnedCommands()).toEqual(['which', 'xattr -dr', 'xattr -p', 'mitmdump']);
|
||||
});
|
||||
|
||||
it('warns instead of installing outside EAS Build VMs', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('not found'));
|
||||
it('kills a mitmdump --version that does not return', async () => {
|
||||
mockCommands({ installed: true, runs: [true] });
|
||||
|
||||
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'mitmdump',
|
||||
['--version'],
|
||||
expect.objectContaining({ stdio: 'ignore', timeout: 60_000, killSignal: 'SIGKILL' })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not run a mitmdump that stays quarantined', async () => {
|
||||
mockCommands({ installed: true, stuck: true });
|
||||
|
||||
await expect(
|
||||
createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync()
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(spawnedCommands()).not.toContain('mitmdump');
|
||||
});
|
||||
|
||||
it('does not change quarantine outside EAS Build VMs', async () => {
|
||||
mockCommands({ installed: true, stuck: true, runs: [true] });
|
||||
|
||||
await createStep({}).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
expect(spawnedCommands()).toEqual(['mitmdump']);
|
||||
});
|
||||
|
||||
it('installs the cask with Homebrew and leaves auto-update off', async () => {
|
||||
mockedSpawn
|
||||
.mockRejectedValueOnce(new Error('not found'))
|
||||
.mockResolvedValueOnce({} as never)
|
||||
.mockResolvedValueOnce({} as never);
|
||||
it('installs the cask with Homebrew and takes it out of quarantine', async () => {
|
||||
mockCommands({ runs: [false, true] });
|
||||
|
||||
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
|
||||
|
||||
@@ -54,13 +130,19 @@ describe('createInstallMitmproxyBuildFunction', () => {
|
||||
['install', '--cask', 'mitmproxy'],
|
||||
expect.objectContaining({ env: expect.objectContaining({ HOMEBREW_NO_AUTO_UPDATE: '1' }) })
|
||||
);
|
||||
expect(spawnedCommands()).toEqual([
|
||||
'which',
|
||||
'mitmdump',
|
||||
'brew',
|
||||
'which',
|
||||
'xattr -dr',
|
||||
'xattr -p',
|
||||
'mitmdump',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not fail the job when mitmdump is still not runnable', async () => {
|
||||
mockedSpawn
|
||||
.mockRejectedValueOnce(new Error('not found'))
|
||||
.mockResolvedValueOnce({} as never)
|
||||
.mockRejectedValueOnce(new Error('not found'));
|
||||
mockCommands({ runs: [false, false] });
|
||||
|
||||
await expect(
|
||||
createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync()
|
||||
|
||||
+12
-6
@@ -18,7 +18,7 @@ import {
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
|
||||
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
|
||||
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
|
||||
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
|
||||
@@ -29,6 +29,12 @@ jest.mock('../../../utils/processes');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/agentDeviceArtifacts');
|
||||
// The agent-device session step boots the Simulator itself; this test covers local egress.
|
||||
jest.mock('../startIosSimulator', () => ({
|
||||
bootIosSimulatorAsync: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ deviceIdentifier: 'sim', udid: 'sim', displayName: 'iPhone' }),
|
||||
}));
|
||||
jest.mock('../../utils/argentArtifacts');
|
||||
jest.mock('../../utils/agentDeviceEvents', () => ({
|
||||
startAgentDeviceEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
|
||||
@@ -78,7 +84,7 @@ const fields = {
|
||||
egressPort: handoff.port,
|
||||
};
|
||||
const controllers = [
|
||||
['agent-device', createStartAgentDeviceRemoteSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
|
||||
['agent-device', createStartAgentDeviceSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
|
||||
['Appium', createStartAppiumRemoteSessionBuildFunction, 'appiumUrl'],
|
||||
['Argent', createStartArgentRemoteSessionBuildFunction, 'toolsUrl'],
|
||||
['web preview', createStartWebPreviewRemoteSessionBuildFunction, 'previewUrl'],
|
||||
@@ -93,10 +99,10 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
await createFunction(ctx).fn!(
|
||||
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
// Inputs a step does not get default to undefined, like in a real step call.
|
||||
inputs: new Proxy({} as Record<string, { value: unknown }>, {
|
||||
get: (target, id: string) => target[id] ?? { value: undefined },
|
||||
}),
|
||||
outputs: {},
|
||||
env,
|
||||
signal,
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import turtleSpawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { EventEmitter, once } from 'node:events';
|
||||
import path from 'node:path';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import * as agentRun from '../../utils/agentRun';
|
||||
import { leaseAgentRunProviderCredentialsAsync } from '../../utils/agentRunLease';
|
||||
import { createRunAgentBuildFunction } from '../runAgent';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('node:child_process', () => ({
|
||||
...jest.requireActual('node:child_process'),
|
||||
spawn: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/agentRunLease');
|
||||
|
||||
const installMock = jest.mocked(turtleSpawn);
|
||||
const spawnMock = jest.mocked(spawn);
|
||||
const leaseMock = jest.mocked(leaseAgentRunProviderCredentialsAsync);
|
||||
|
||||
const ctx = {
|
||||
expoApiV2BaseUrl: 'https://staging-api.expo.test/v2/',
|
||||
mcpServerUrl: 'wss://staging-mcp.expo.test',
|
||||
} as any;
|
||||
const inputs = {
|
||||
agent_kind: { value: 'claude-code' },
|
||||
agent_run_id: { value: '0199b0a0-1111-7222-8333-444455556666' },
|
||||
max_duration_seconds: { value: 1800 },
|
||||
prompt: { value: 'Fix the failing test.' },
|
||||
};
|
||||
const env = {
|
||||
PATH: '/usr/local/bin:/usr/bin',
|
||||
HOME: '/home/expo',
|
||||
EXPO_TOKEN: 'expo-token',
|
||||
__API_SERVER_URL: 'https://staging-api.expo.test',
|
||||
EAS_BUILD_ID: 'build-id',
|
||||
NODE_OPTIONS: '--max-old-space-size=3072',
|
||||
USER_DEFINED_SECRET: 'secret',
|
||||
};
|
||||
|
||||
async function runAsync(
|
||||
overrides: {
|
||||
ctx?: any;
|
||||
inputs?: Record<string, { value: unknown }>;
|
||||
env?: Record<string, string>;
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
const fn = createRunAgentBuildFunction(overrides.ctx ?? ctx);
|
||||
await fn.fn!({ logger: createMockLogger() } as any, {
|
||||
inputs: overrides.inputs ?? inputs,
|
||||
outputs: {},
|
||||
env: overrides.env ?? env,
|
||||
});
|
||||
}
|
||||
|
||||
function mockSuccessfulRun(): { mcpConfig?: any } {
|
||||
const captured: { mcpConfig?: any } = {};
|
||||
leaseMock.mockResolvedValue({ provider: 'anthropic', accessToken: 'anthropic-access-token' });
|
||||
installMock.mockResolvedValue({} as any);
|
||||
spawnMock.mockImplementation(((_command: string, _args: string[], spawnOptions: any) => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdin: { on: jest.fn(), end: jest.fn() },
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
});
|
||||
captured.mcpConfig = JSON.parse(
|
||||
vol.readFileSync(path.join(spawnOptions.env.HOME, 'mcp.json'), 'utf8') as string
|
||||
);
|
||||
process.nextTick(() => {
|
||||
child.emit('exit', 0, null);
|
||||
child.stdout.end();
|
||||
child.stderr.end();
|
||||
void Promise.all([once(child.stdout, 'end'), once(child.stderr, 'end')]).then(() =>
|
||||
child.emit('close')
|
||||
);
|
||||
});
|
||||
return child;
|
||||
}) as any);
|
||||
return captured;
|
||||
}
|
||||
|
||||
describe(createRunAgentBuildFunction, () => {
|
||||
it('is the eas/run_agent function', () => {
|
||||
expect(createRunAgentBuildFunction(ctx).getFullId()).toBe('eas/run_agent');
|
||||
});
|
||||
|
||||
it('passes none of the job environment to npm or the agent', async () => {
|
||||
mockSuccessfulRun();
|
||||
|
||||
await runAsync();
|
||||
|
||||
expect(leaseMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
expoApiV2BaseUrl: 'https://staging-api.expo.test/v2/',
|
||||
expoToken: 'expo-token',
|
||||
agentRunId: '0199b0a0-1111-7222-8333-444455556666',
|
||||
})
|
||||
);
|
||||
const npmEnv = installMock.mock.calls[0][2]!.env!;
|
||||
const agentEnv = spawnMock.mock.calls[0][2].env!;
|
||||
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'LANG', 'PATH', 'TMPDIR']);
|
||||
expect(Object.keys(agentEnv).sort()).toEqual([
|
||||
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
|
||||
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
'HOME',
|
||||
'LANG',
|
||||
'PATH',
|
||||
'TMPDIR',
|
||||
]);
|
||||
expect(agentEnv.PATH).toBe('/usr/local/bin:/usr/bin');
|
||||
expect(agentEnv.HOME).not.toBe('/home/expo');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['wss://staging-mcp.expo.test', 'https://staging-mcp.expo.test/mcp'],
|
||||
['ws://localhost:8787', 'http://localhost:8787/mcp'],
|
||||
])('points the agent at the HTTP endpoint of %s', async (mcpServerUrl, mcpUrl) => {
|
||||
const captured = mockSuccessfulRun();
|
||||
|
||||
await runAsync({ ctx: { ...ctx, mcpServerUrl } });
|
||||
|
||||
expect(captured.mcpConfig.mcpServers.expo.url).toBe(mcpUrl);
|
||||
});
|
||||
|
||||
it('stops the run at max_duration_seconds and reports a timeout', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
|
||||
const runAgentSpy = jest.spyOn(agentRun, 'runAgentAsync').mockImplementation(
|
||||
({ signal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
signal!.addEventListener('abort', () => reject(signal!.reason));
|
||||
})
|
||||
);
|
||||
try {
|
||||
const run = runAsync({ inputs: { ...inputs, max_duration_seconds: { value: 60 } } });
|
||||
const rejection = expect(run).rejects.toMatchObject({
|
||||
errorCode: 'EAS_RUN_AGENT_TIMEOUT',
|
||||
message: expect.stringContaining('60 seconds'),
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(59_000);
|
||||
expect(runAgentSpy.mock.calls[0][0].signal!.aborted).toBe(false);
|
||||
await jest.advanceTimersByTimeAsync(1_000);
|
||||
await rejection;
|
||||
} finally {
|
||||
runAgentSpy.mockRestore();
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an unknown agent_kind', { inputs: { ...inputs, agent_kind: { value: 'gemini' } } }],
|
||||
['a malformed agent_run_id', { inputs: { ...inputs, agent_run_id: { value: 'not-a-uuid' } } }],
|
||||
['an empty agent_run_id', { inputs: { ...inputs, agent_run_id: { value: '' } } }],
|
||||
['a zero duration', { inputs: { ...inputs, max_duration_seconds: { value: 0 } } }],
|
||||
['a negative duration', { inputs: { ...inputs, max_duration_seconds: { value: -300 } } }],
|
||||
['a fractional duration', { inputs: { ...inputs, max_duration_seconds: { value: 12.5 } } }],
|
||||
['an empty prompt', { inputs: { ...inputs, prompt: { value: '' } } }],
|
||||
['no EXPO_TOKEN', { env: { ...env, EXPO_TOKEN: '' } }],
|
||||
['no PATH', { env: { ...env, PATH: '' } }],
|
||||
['no Expo API URL', { ctx: { ...ctx, expoApiV2BaseUrl: undefined } }],
|
||||
['no MCP server URL', { ctx: { ...ctx, mcpServerUrl: undefined } }],
|
||||
])('throws a system error for %s', async (_name, overrides) => {
|
||||
await expect(runAsync(overrides)).rejects.toBeInstanceOf(SystemError);
|
||||
|
||||
expect(leaseMock).not.toHaveBeenCalled();
|
||||
expect(installMock).not.toHaveBeenCalled();
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports every invalid input at once', async () => {
|
||||
await expect(
|
||||
runAsync({
|
||||
inputs: {
|
||||
...inputs,
|
||||
agent_run_id: { value: 'not-a-uuid' },
|
||||
max_duration_seconds: { value: 0 },
|
||||
},
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'The "agent_run_id" input must be the UUID of an agent run, but received "not-a-uuid". ' +
|
||||
'The "max_duration_seconds" input must be a positive whole number of seconds, but received "0".'
|
||||
);
|
||||
});
|
||||
});
|
||||
+29
-82
@@ -1,10 +1,9 @@
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
|
||||
@@ -21,10 +20,7 @@ import {
|
||||
waitForFileAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { type StartupTasks, createStartupTasks } from '../../utils/startupTasks';
|
||||
import {
|
||||
createStartAgentDeviceRemoteSessionBuildFunction,
|
||||
runAgentDeviceRemoteSessionAsync,
|
||||
} from '../startAgentDeviceRemoteSession';
|
||||
import { runAgentDeviceRemoteSessionAsync } from '../startAgentDeviceRemoteSession';
|
||||
|
||||
// The daemon entry path and the state directory are resolved from the home directory when
|
||||
// the module loads, so point it at a temp home we can populate.
|
||||
@@ -70,28 +66,34 @@ const mockTunnelStopAsync = jest.fn();
|
||||
const mockDaemonStopAsync = jest.fn();
|
||||
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. */
|
||||
async function runAsync(
|
||||
logger: { info: jest.Mock; warn: jest.Mock },
|
||||
logger: { info: jest.Mock; warn: jest.Mock; child: jest.Mock },
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
launchInputs: Record<string, { value: unknown }> = {}
|
||||
capture: CaptureInputs = NO_CAPTURE
|
||||
): Promise<void> {
|
||||
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
|
||||
await buildFunction.fn!(
|
||||
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
max_duration_seconds: { value: undefined },
|
||||
...launchInputs,
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env: {},
|
||||
logger: logger as never,
|
||||
runtimePlatform,
|
||||
sessionEnv: {
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
ngrokTunnelDomain: 'tunnel.example.com',
|
||||
ngrokAuthtoken: 'ngrok-token',
|
||||
},
|
||||
packageVersion: undefined,
|
||||
maxIdleTimeMinutes: undefined,
|
||||
maxDurationSeconds: undefined,
|
||||
capture,
|
||||
tasks: createStartupTasks(logger as never),
|
||||
device: { booted: Promise.resolve(), ready: Promise.resolve() },
|
||||
});
|
||||
}
|
||||
|
||||
describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () => {
|
||||
describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
@@ -141,7 +143,6 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.LINUX);
|
||||
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
|
||||
@@ -162,36 +163,12 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
|
||||
it('hands network capture to serve-sim', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] },
|
||||
open_url: { value: 'exp://127.0.0.1:8081' },
|
||||
});
|
||||
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
})
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'serve-sim will launch host.exp.Exponent with arguments ' +
|
||||
'["-EXDevMenuIsOnboardingFinished","1"], then open exp://127.0.0.1:8081.'
|
||||
);
|
||||
});
|
||||
|
||||
it('hands network capture to serve-sim on an iOS session', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
|
||||
network_capture: { value: true },
|
||||
network_capture_fields: { value: ['header'] },
|
||||
networkCapture: true,
|
||||
networkCaptureFields: ['header'],
|
||||
});
|
||||
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
@@ -200,26 +177,6 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
|
||||
);
|
||||
});
|
||||
|
||||
it('fails before starting the daemon when network capture is asked for on Android', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
await expect(
|
||||
runAsync(logger, BuildRuntimePlatform.LINUX, { network_capture: { value: true } })
|
||||
).rejects.toThrow('records traffic through serve-sim on an iOS simulator');
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails before starting the daemon when a launch is asked for on Android', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
await expect(
|
||||
runAsync(logger, BuildRuntimePlatform.LINUX, {
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
})
|
||||
).rejects.toThrow('runs on linux');
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts the session host without waiting for the agent-device daemon', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const sessionHost = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
@@ -323,8 +280,7 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
|
||||
packageVersion: undefined,
|
||||
maxIdleTimeMinutes: undefined,
|
||||
maxDurationSeconds: undefined,
|
||||
launch: {},
|
||||
capture: { networkCapture: false, networkCaptureFields: [] },
|
||||
capture: NO_CAPTURE,
|
||||
tasks,
|
||||
device: typeof device === 'function' ? device(tasks) : device,
|
||||
});
|
||||
@@ -415,13 +371,4 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('declares the launch inputs', () => {
|
||||
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
|
||||
const globalCtx = createGlobalContextMock();
|
||||
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
|
||||
});
|
||||
});
|
||||
+3
@@ -85,8 +85,10 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: {
|
||||
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewApiUrl: 'https://web-preview.example.test',
|
||||
webPreviewToken: 'tok-1',
|
||||
previewToken: 'tok-1',
|
||||
},
|
||||
})
|
||||
@@ -132,6 +134,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
ctx,
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
remoteConfig: {
|
||||
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewApiUrl: 'https://web-preview.example.test',
|
||||
},
|
||||
|
||||
+89
-1
@@ -3,6 +3,7 @@ import { generateKeyPairSync } from 'node:crypto';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { AscApiClient } from '../../utils/ios/AscApiClient';
|
||||
import {
|
||||
createUpdateTestFlightMetadataBuildFunction,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
} from '../updateTestFlightMetadata';
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
jest.mock('../../../sentry', () => ({ Sentry: { capture: jest.fn() } }));
|
||||
|
||||
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
||||
const key = { keyId: 'TESTKEY', privateKey };
|
||||
@@ -56,8 +58,21 @@ function mockAssignedGroups(ids: string[] = []): void {
|
||||
.reply(200, { data: ids.map(id => ({ id })) });
|
||||
}
|
||||
|
||||
function mockInternalBuildState(state: string | null = 'READY_FOR_BETA_TESTING'): void {
|
||||
api()
|
||||
.get('/v1/builds/build/buildBetaDetail')
|
||||
.query(true)
|
||||
.reply(
|
||||
state ? 200 : 503,
|
||||
state
|
||||
? { data: { id: 'detail', attributes: { internalBuildState: state } } }
|
||||
: { errors: [{ code: 'UNAVAILABLE' }] }
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(() => nock.disableNetConnect());
|
||||
beforeEach(() => {
|
||||
jest.mocked(Sentry.capture).mockClear();
|
||||
options.logger = createMockLogger();
|
||||
options.client = new AscApiClient({ key });
|
||||
});
|
||||
@@ -378,6 +393,7 @@ it('attempts all localization changes and reports every failed update', async ()
|
||||
|
||||
it.each([1, 2])('explains automatic internal group rejection (%s errors)', async errorCount => {
|
||||
mockBuild();
|
||||
mockInternalBuildState();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
@@ -438,6 +454,7 @@ it('tries group assignment if the changelog update fails', async () => {
|
||||
|
||||
it('reports both write failures', async () => {
|
||||
mockBuild();
|
||||
mockInternalBuildState();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
@@ -459,6 +476,7 @@ it.each([false, true])(
|
||||
'attempts all groups and reports assignment errors (multiple: %s)',
|
||||
async multiple => {
|
||||
mockBuild();
|
||||
mockInternalBuildState();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
@@ -484,7 +502,7 @@ it.each([false, true])(
|
||||
.reply(204);
|
||||
await expect(
|
||||
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A', 'B', 'C'] })
|
||||
).rejects.toThrow(multiple ? /FIRST_GROUP_FAILED.*Partial assignment/ : 'Partial assignment');
|
||||
).rejects.toThrow(multiple ? 'FIRST_GROUP_FAILED' : 'Partial assignment');
|
||||
expect(options.logger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Group "B" (B): assignment failed.')
|
||||
);
|
||||
@@ -531,3 +549,73 @@ it('propagates Apple write failures', async () => {
|
||||
.reply(403, { errors: [{ code: 'FORBIDDEN', detail: 'Not allowed' }] });
|
||||
await expect(updateTestFlightMetadataAsync(options)).rejects.toThrow('403');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['MISSING_EXPORT_COMPLIANCE', 'Complete the export compliance questions'],
|
||||
['IN_EXPORT_COMPLIANCE_REVIEW', 'Wait for Apple to approve'],
|
||||
['EXPIRED', 'ASSIGNMENT_FAILED'],
|
||||
['PROCESSING', 'ASSIGNMENT_FAILED'],
|
||||
['READY_FOR_BETA_TESTING', 'ASSIGNMENT_FAILED'],
|
||||
['IN_BETA_TESTING', 'ASSIGNMENT_FAILED'],
|
||||
[null, 'ASSIGNMENT_FAILED'],
|
||||
] as const)(
|
||||
'shares one diagnostic lookup across failed assignments (state: %s)',
|
||||
async (state, guidance) => {
|
||||
mockBuild();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, {
|
||||
data: ['A', 'B', 'C'].map(id => ({ id, attributes: { name: id } })),
|
||||
});
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id: 'A' }],
|
||||
})
|
||||
.reply(403, { errors: [{ code: 'ASSIGNMENT_FAILED' }] });
|
||||
mockInternalBuildState(state);
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id: 'B' }],
|
||||
})
|
||||
.reply(422, { errors: [{ code: 'ASSIGNMENT_FAILED' }] });
|
||||
// A failed assignment must not prevent the next group from being assigned.
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id: 'C' }],
|
||||
})
|
||||
.reply(204);
|
||||
const failure = await updateTestFlightMetadataAsync({
|
||||
...options,
|
||||
changelog: '',
|
||||
groups: ['A', 'B', 'C'],
|
||||
}).catch(error => error);
|
||||
expect(failure).toBeInstanceOf(AggregateError);
|
||||
expect(failure.errors).toHaveLength(2);
|
||||
if (state === 'MISSING_EXPORT_COMPLIANCE' || state === 'IN_EXPORT_COMPLIANCE_REVIEW') {
|
||||
expect(failure.errors[0]).toBe(failure.errors[1]);
|
||||
} else {
|
||||
expect(failure.errors[0]).toMatchObject({ status: 403 });
|
||||
expect(failure.errors[1]).toMatchObject({ status: 422 });
|
||||
}
|
||||
expect(failure.errors[0].message).toContain(guidance);
|
||||
expect(
|
||||
jest
|
||||
.mocked(options.logger.info)
|
||||
.mock.calls.filter(([message]) => String(message).includes('internal TestFlight state ='))
|
||||
).toEqual([[`Apple build build: internal TestFlight state = ${state ?? 'UNKNOWN'}.`]]);
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
`TestFlight group assignment failed (state: ${state ?? 'UNKNOWN'})`,
|
||||
{
|
||||
level: 'error',
|
||||
tags: { step: 'eas/update_testflight_metadata', internal_build_state: state ?? 'UNKNOWN' },
|
||||
extras: {
|
||||
buildId: 'build',
|
||||
assignmentError: expect.stringContaining('ASSIGNMENT_FAILED'),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
+2
-1
@@ -20,7 +20,8 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockReset()
|
||||
.mockImplementation(async (_ctx, { stream }) => {
|
||||
.mockImplementation(async (_ctx, { openStream }) => {
|
||||
const stream = openStream();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stream.once('end', resolve);
|
||||
stream.once('error', reject);
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { ProjectConfig } from '@expo/config';
|
||||
import { BuildJob } from '@expo/eas-build-job';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createTestAndroidJob } from '../../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../../context';
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { readAppConfig } from '../../../utils/appConfig';
|
||||
import { uploadEmbeddedBundleAsync } from '../../../utils/expoUpdatesEmbedded';
|
||||
import { createUploadEmbeddedBundleBuildFunction } from '../uploadEmbeddedBundle';
|
||||
|
||||
jest.mock('../../../utils/appConfig');
|
||||
jest.mock('../../../utils/expoUpdatesEmbedded');
|
||||
|
||||
function mockAppConfig(updatesUrl: string | undefined): void {
|
||||
jest.mocked(readAppConfig).mockResolvedValue({
|
||||
exp: { name: 'my-app', slug: 'my-app', updates: updatesUrl ? { url: updatesUrl } : undefined },
|
||||
} as ProjectConfig);
|
||||
}
|
||||
|
||||
function createCustomContext(job: BuildJob): CustomBuildContext<BuildJob> {
|
||||
return new CustomBuildContext(
|
||||
new BuildContext(job, {
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test' },
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
uploadArtifact: jest.fn(),
|
||||
workingdir: '',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
describe(createUploadEmbeddedBundleBuildFunction, () => {
|
||||
const job = createTestAndroidJob({});
|
||||
const customContext = createCustomContext(job);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('uploads the embedded bundle from the working directory', async () => {
|
||||
mockAppConfig('https://u.expo.dev/project-id');
|
||||
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValue({ status: 'uploaded' });
|
||||
const globalContext = createGlobalContextMock({});
|
||||
globalContext.updateEnv({ EAS_BUILD_ID: 'build-123' });
|
||||
const buildStep =
|
||||
createUploadEmbeddedBundleBuildFunction(customContext).createBuildStepFromFunctionCall(
|
||||
globalContext
|
||||
);
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
job,
|
||||
env: expect.objectContaining({ EAS_BUILD_ID: 'build-123' }),
|
||||
projectDir: globalContext.defaultWorkingDirectory,
|
||||
appConfig: expect.objectContaining({ updates: { url: 'https://u.expo.dev/project-id' } }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('logs when the embedded bundle upload is skipped', async () => {
|
||||
mockAppConfig('https://u.expo.dev/project-id');
|
||||
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValue({ status: 'skipped' });
|
||||
const buildStep = createUploadEmbeddedBundleBuildFunction(
|
||||
customContext
|
||||
).createBuildStepFromFunctionCall(createGlobalContextMock({}));
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.ctx.logger.info).toHaveBeenCalledWith('Skipping embedded bundle upload.');
|
||||
});
|
||||
|
||||
it('fails the step when the upload fails', async () => {
|
||||
mockAppConfig('https://u.expo.dev/project-id');
|
||||
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValue({ status: 'failed' });
|
||||
const buildStep = createUploadEmbeddedBundleBuildFunction(
|
||||
customContext
|
||||
).createBuildStepFromFunctionCall(createGlobalContextMock({}));
|
||||
|
||||
await expect(buildStep.executeAsync()).rejects.toThrow('Failed to upload embedded bundle.');
|
||||
});
|
||||
|
||||
it('does not fail the step when the upload fails and ignore_error is set', async () => {
|
||||
mockAppConfig('https://u.expo.dev/project-id');
|
||||
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValue({ status: 'failed' });
|
||||
const buildStep = createUploadEmbeddedBundleBuildFunction(
|
||||
customContext
|
||||
).createBuildStepFromFunctionCall(createGlobalContextMock({}), {
|
||||
callInputs: { ignore_error: true },
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.ctx.logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Failed to upload embedded bundle. Ignoring error.'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails the step when the app config cannot be read', async () => {
|
||||
jest.mocked(readAppConfig).mockRejectedValue(new Error('Invalid app config'));
|
||||
const buildStep = createUploadEmbeddedBundleBuildFunction(
|
||||
customContext
|
||||
).createBuildStepFromFunctionCall(createGlobalContextMock({}));
|
||||
|
||||
await expect(buildStep.executeAsync()).rejects.toThrow('Invalid app config');
|
||||
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fail the step when the app config cannot be read and ignore_error is set', async () => {
|
||||
jest.mocked(readAppConfig).mockRejectedValue(new Error('Invalid app config'));
|
||||
const buildStep = createUploadEmbeddedBundleBuildFunction(
|
||||
customContext
|
||||
).createBuildStepFromFunctionCall(createGlobalContextMock({}), {
|
||||
callInputs: { ignore_error: true },
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.ctx.logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Failed to upload embedded bundle. Ignoring error.'
|
||||
);
|
||||
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import { graphql } from 'gql.tada';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { downloadBuildAsync } from './downloadBuild';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import { promiseRetryWithCondition } from '../../utils/promiseRetryWithCondition';
|
||||
|
||||
const CREATE_ARCHIVE_DOWNLOAD_URL = graphql(`
|
||||
mutation GenerateSubmissionArchiveDownloadUrl($submissionId: ID!) {
|
||||
submission {
|
||||
generateSubmissionArchiveDownloadUrl(submissionId: $submissionId)
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export function createDownloadSubmissionArchiveFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'download_submission_archive',
|
||||
name: 'Download submission archive',
|
||||
__metricsId: 'eas/download_submission_archive',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'submission_id',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'extensions',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
defaultValue: ['apk', 'aab', 'ipa'],
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'artifact_path',
|
||||
required: true,
|
||||
}),
|
||||
],
|
||||
fn: async (stepsCtx, { inputs, outputs, signal }) => {
|
||||
const { submissionId, extensions } = z
|
||||
.object({
|
||||
submissionId: z.string().uuid(),
|
||||
extensions: z.array(z.string()),
|
||||
})
|
||||
.parse({
|
||||
submissionId: inputs.submission_id.value,
|
||||
extensions: inputs.extensions.value,
|
||||
});
|
||||
|
||||
stepsCtx.logger.info(`Downloading archive for submission ${submissionId}...`);
|
||||
|
||||
const applicationArchiveUrl = await promiseRetryWithCondition(
|
||||
async () => {
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(CREATE_ARCHIVE_DOWNLOAD_URL, { submissionId }, graphqlAbortContext(signal))
|
||||
.toPromise();
|
||||
|
||||
signal?.throwIfAborted();
|
||||
|
||||
if (result.error) {
|
||||
throw result.error.networkError || result.error.response?.status >= 500
|
||||
? new SystemError('Could not request the submission archive. Try again later.', {
|
||||
cause: result.error,
|
||||
})
|
||||
: new UserError(
|
||||
'EAS_SUBMISSION_ARCHIVE_FETCH_FAILED',
|
||||
'Could not request the submission archive. Check your project access and the submission archive.',
|
||||
{ cause: result.error }
|
||||
);
|
||||
}
|
||||
|
||||
const url = result.data?.submission.generateSubmissionArchiveDownloadUrl;
|
||||
|
||||
if (!url) {
|
||||
throw new SystemError(
|
||||
'The server did not return a submission archive URL. Try again later.'
|
||||
);
|
||||
}
|
||||
return url;
|
||||
},
|
||||
error => !signal?.aborted && error instanceof SystemError,
|
||||
{ retries: 3, minTimeout: 1000, maxTimeout: 1000 },
|
||||
({ attemptNumber, maxAttemptsCount }) => {
|
||||
stepsCtx.logger.info(
|
||||
`Retrying submission archive URL request (${attemptNumber}/${maxAttemptsCount})...`
|
||||
);
|
||||
}
|
||||
)();
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: stepsCtx.logger,
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
applicationArchiveUrl,
|
||||
robotAccessToken: null,
|
||||
extensions,
|
||||
signal,
|
||||
});
|
||||
|
||||
outputs.artifact_path.set(artifactPath);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -199,10 +199,15 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
}
|
||||
|
||||
if (backend === 'maestro-runner' && global.runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await installMaestroRunnerWdaCache({
|
||||
logger,
|
||||
env,
|
||||
});
|
||||
// Runner 1.1.28 switched the default iOS driver to the bundled DeviceLab agent.
|
||||
// Use the detected version after installation, including when `latest` was requested.
|
||||
const installedRunnerVersion = semver.coerce(maestroVersionResult.value);
|
||||
if (!installedRunnerVersion || semver.lt(installedRunnerVersion, '1.1.28')) {
|
||||
await installMaestroRunnerWdaCache({
|
||||
logger,
|
||||
env,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`${backend} ${maestroVersionResult.value} is ready.`);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { BuildFunction, BuildRuntimePlatform, BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { Sentry } from '../../sentry';
|
||||
|
||||
@@ -13,7 +14,10 @@ export function createInstallMitmproxyBuildFunction(): BuildFunction {
|
||||
__metricsId: 'eas/install_mitmproxy',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
fn: async ({ logger }, { env }) => {
|
||||
if (await isMitmproxyAvailableAsync(env)) {
|
||||
// Some images ship the cask, and its quarantined first launch can hang.
|
||||
const canLaunch =
|
||||
env.EAS_BUILD_RUNNER !== 'eas-build' || (await removeMitmproxyQuarantineAsync(env));
|
||||
if (canLaunch && (await isMitmproxyAvailableAsync(env))) {
|
||||
logger.info('mitmproxy is already installed.');
|
||||
return;
|
||||
}
|
||||
@@ -28,9 +32,11 @@ export function createInstallMitmproxyBuildFunction(): BuildFunction {
|
||||
try {
|
||||
logger.info('Installing mitmproxy with Homebrew.');
|
||||
await installMitmproxyWithHomebrewAsync({ env, logger });
|
||||
|
||||
if (!(await removeMitmproxyQuarantineAsync(env))) {
|
||||
throw new Error('Could not launch mitmproxy because it is still quarantined.');
|
||||
}
|
||||
if (!(await isMitmproxyAvailableAsync(env))) {
|
||||
throw new Error('`brew install --cask mitmproxy` succeeded but mitmdump is not on PATH.');
|
||||
throw new Error('`brew install --cask mitmproxy` succeeded but mitmdump did not run.');
|
||||
}
|
||||
logger.info('Installed mitmproxy.');
|
||||
} catch (err) {
|
||||
@@ -48,7 +54,18 @@ export function createInstallMitmproxyBuildFunction(): BuildFunction {
|
||||
}
|
||||
|
||||
async function isMitmproxyAvailableAsync(env: BuildStepEnv): Promise<boolean> {
|
||||
return (await asyncResult(spawn('mitmdump', ['--version'], { env }))).ok;
|
||||
// Kills a first launch that Gatekeeper still holds, if the quarantine check missed it. Without
|
||||
// pipes, a child process that outlives mitmdump cannot hold the call open past the timeout.
|
||||
return (
|
||||
await asyncResult(
|
||||
spawn('mitmdump', ['--version'], {
|
||||
env,
|
||||
stdio: 'ignore',
|
||||
timeout: 60_000,
|
||||
killSignal: 'SIGKILL',
|
||||
})
|
||||
)
|
||||
).ok;
|
||||
}
|
||||
|
||||
async function installMitmproxyWithHomebrewAsync({
|
||||
@@ -63,3 +80,25 @@ async function installMitmproxyWithHomebrewAsync({
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gatekeeper has rejected the mitmproxy 12.2.3 cask as unnotarized since 2026-10-06, and a
|
||||
* quarantined first launch then never returns. Takes the app behind the `mitmdump` on PATH out of
|
||||
* quarantine, and returns false when it stays quarantined, so it is not launched.
|
||||
*/
|
||||
async function removeMitmproxyQuarantineAsync(env: BuildStepEnv): Promise<boolean> {
|
||||
const which = await asyncResult(spawn('which', ['mitmdump'], { env, stdio: 'pipe' }));
|
||||
const mitmdump = which.ok
|
||||
? await fs.promises.realpath(which.value.stdout.trim()).catch(() => null)
|
||||
: null;
|
||||
if (!mitmdump) {
|
||||
return true;
|
||||
}
|
||||
const appEnd = mitmdump.lastIndexOf('.app/');
|
||||
const app = appEnd === -1 ? mitmdump : mitmdump.slice(0, appEnd + '.app'.length);
|
||||
await asyncResult(spawn('xattr', ['-dr', 'com.apple.quarantine', app], { env, stdio: 'pipe' }));
|
||||
const quarantined = await asyncResult(
|
||||
spawn('xattr', ['-p', 'com.apple.quarantine', mitmdump], { env, stdio: 'pipe' })
|
||||
);
|
||||
return !quarantined.ok;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { readAndroidArtifactInfoAsync } from '../utils/android/appArtifact';
|
||||
|
||||
export function createReadAndroidAppInfoBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'read_android_app_info',
|
||||
name: 'Read Android app info',
|
||||
__metricsId: 'eas/read_android_app_info',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'artifact_path',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({ id: 'artifact_type', required: true }),
|
||||
BuildStepOutput.createProvider({ id: 'package_name', required: true }),
|
||||
],
|
||||
fn: async (ctx, { inputs, outputs, signal }) => {
|
||||
const artifactPath = path.resolve(
|
||||
ctx.workingDirectory,
|
||||
z.string().min(1).parse(inputs.artifact_path.value)
|
||||
);
|
||||
ctx.logger.info(`Reading Android artifact: ${artifactPath}.`);
|
||||
const artifact = await readAndroidArtifactInfoAsync(artifactPath, signal);
|
||||
ctx.logger.info(`Android artifact type: ${artifact.artifactType}.`);
|
||||
ctx.logger.info(`Android package name: ${artifact.packageName}.`);
|
||||
outputs.artifact_type.set(artifact.artifactType);
|
||||
outputs.package_name.set(artifact.packageName);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { AGENT_KINDS, runAgentAsync } from '../utils/agentRun';
|
||||
|
||||
export function createRunAgentBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'run_agent',
|
||||
name: 'Run agent',
|
||||
__metricsId: 'eas/run_agent',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.LINUX],
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'agent_kind',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'agent_run_id',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_duration_seconds',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'prompt',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, env, signal }) => {
|
||||
const inputErrors = {
|
||||
agentKind: `The "agent_kind" input must be one of ${AGENT_KINDS.join(', ')}, but received "${String(inputs.agent_kind.value)}".`,
|
||||
agentRunId: `The "agent_run_id" input must be the UUID of an agent run, but received "${String(inputs.agent_run_id.value)}".`,
|
||||
maxDurationSeconds: `The "max_duration_seconds" input must be a positive whole number of seconds, but received "${String(inputs.max_duration_seconds.value)}".`,
|
||||
prompt: 'The "prompt" input must be a non-empty string.',
|
||||
};
|
||||
const parsedInputs = z
|
||||
.object({
|
||||
agent_kind: z.enum(AGENT_KINDS, { error: inputErrors.agentKind }),
|
||||
agent_run_id: z.uuid({ error: inputErrors.agentRunId }),
|
||||
max_duration_seconds: z
|
||||
.number({ error: inputErrors.maxDurationSeconds })
|
||||
.int({ error: inputErrors.maxDurationSeconds })
|
||||
.positive({ error: inputErrors.maxDurationSeconds }),
|
||||
prompt: z.string({ error: inputErrors.prompt }).min(1, { error: inputErrors.prompt }),
|
||||
})
|
||||
.safeParse({
|
||||
agent_kind: inputs.agent_kind.value,
|
||||
agent_run_id: inputs.agent_run_id.value,
|
||||
max_duration_seconds: inputs.max_duration_seconds.value,
|
||||
prompt: inputs.prompt.value,
|
||||
});
|
||||
if (!parsedInputs.success) {
|
||||
throw createValidationError(parsedInputs.error);
|
||||
}
|
||||
const {
|
||||
agent_kind: agentKind,
|
||||
agent_run_id: agentRunId,
|
||||
max_duration_seconds: maxDurationSeconds,
|
||||
prompt,
|
||||
} = parsedInputs.data;
|
||||
const parsedEnv = z
|
||||
.object({
|
||||
EXPO_TOKEN: z
|
||||
.string({ error: 'EXPO_TOKEN is required to run an agent.' })
|
||||
.min(1, { error: 'EXPO_TOKEN is required to run an agent.' }),
|
||||
PATH: z
|
||||
.string({ error: 'PATH is required to run an agent.' })
|
||||
.min(1, { error: 'PATH is required to run an agent.' }),
|
||||
EAS_BUILD_NPM_CACHE_URL: z.string().optional(),
|
||||
})
|
||||
.safeParse(env);
|
||||
if (!parsedEnv.success) {
|
||||
throw createValidationError(parsedEnv.error);
|
||||
}
|
||||
const {
|
||||
EXPO_TOKEN: expoToken,
|
||||
PATH: pathEnv,
|
||||
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
|
||||
} = parsedEnv.data;
|
||||
if (!ctx.expoApiV2BaseUrl) {
|
||||
throw new SystemError('Expo API URL is required to run an agent.');
|
||||
}
|
||||
if (!ctx.mcpServerUrl) {
|
||||
throw new SystemError('MCP server URL is required to run an agent.');
|
||||
}
|
||||
// The worker config holds the MCP server's WebSocket URL; its HTTP endpoint is on the same host.
|
||||
const mcpUrl = new URL('/mcp', ctx.mcpServerUrl);
|
||||
mcpUrl.protocol = mcpUrl.protocol.replace(/^ws/, 'http');
|
||||
|
||||
const timeoutError = new UserError(
|
||||
'EAS_RUN_AGENT_TIMEOUT',
|
||||
`The agent did not finish within its limit of ${maxDurationSeconds} seconds, so it was stopped. Raise the agent's maximum duration or give it a smaller task, then start a new agent run.`
|
||||
);
|
||||
const timeoutController = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => timeoutController.abort(timeoutError),
|
||||
maxDurationSeconds * 1000
|
||||
);
|
||||
try {
|
||||
await runAgentAsync({
|
||||
agentKind,
|
||||
agentRunId,
|
||||
prompt,
|
||||
expoToken,
|
||||
expoApiV2BaseUrl: ctx.expoApiV2BaseUrl,
|
||||
mcpUrl: mcpUrl.toString(),
|
||||
pathEnv,
|
||||
npmCacheUrl,
|
||||
logger: stepCtx.logger,
|
||||
signal: signal
|
||||
? AbortSignal.any([signal, timeoutController.signal])
|
||||
: timeoutController.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createValidationError(error: z.ZodError): SystemError {
|
||||
return new SystemError(error.issues.map(issue => issue.message).join(' '));
|
||||
}
|
||||
@@ -1,22 +1,13 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
type BuildStepEnv,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import { type BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { uploadRemoteSessionConfigWithLocalEgressAsync } from '../utils/localEgressSession';
|
||||
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
@@ -26,22 +17,15 @@ import {
|
||||
resolvePackageInstall,
|
||||
} from '../../utils/packageManager';
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
parseNetworkCaptureInputs,
|
||||
} from '../utils/networkCaptureFields';
|
||||
import { type parseNetworkCaptureInputs } from '../utils/networkCaptureFields';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
|
||||
import { type StartupTasks, createStartupTasks } from '../utils/startupTasks';
|
||||
import { type StartupTasks } from '../utils/startupTasks';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
@@ -66,81 +50,6 @@ const AGENT_DEVICE_DAEMON_ENV = {
|
||||
AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS: '0',
|
||||
};
|
||||
|
||||
export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_agent_device_remote_session',
|
||||
name: 'Start agent device remote session',
|
||||
__metricsId: 'eas/start_agent_device_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_idle_time_minutes',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_duration_seconds',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
|
||||
// Fail fast before any expensive setup if the injected env vars are missing.
|
||||
const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(env);
|
||||
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
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 }
|
||||
);
|
||||
const capture = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture?.value,
|
||||
networkCaptureFields: inputs.network_capture_fields?.value,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
|
||||
// Earlier steps booted the device and installed and launched the app.
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
runtimePlatform,
|
||||
sessionEnv,
|
||||
packageVersion,
|
||||
maxIdleTimeMinutes,
|
||||
maxDurationSeconds,
|
||||
launch,
|
||||
capture,
|
||||
tasks: createStartupTasks(logger),
|
||||
device: { booted: Promise.resolve(), ready: Promise.resolve() },
|
||||
});
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export type AgentDeviceRemoteSessionEnv = {
|
||||
deviceRunSessionId: string;
|
||||
ngrokTunnelDomain: string;
|
||||
@@ -186,7 +95,6 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
packageVersion,
|
||||
maxIdleTimeMinutes,
|
||||
maxDurationSeconds,
|
||||
launch,
|
||||
capture,
|
||||
tasks,
|
||||
device,
|
||||
@@ -199,7 +107,6 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
packageVersion: string | undefined;
|
||||
maxIdleTimeMinutes: number | undefined;
|
||||
maxDurationSeconds: number | undefined;
|
||||
launch: ReturnType<typeof parseServeSimLaunchInputs>;
|
||||
capture: ReturnType<typeof parseNetworkCaptureInputs>;
|
||||
tasks: StartupTasks;
|
||||
device: { booted: Promise<unknown>; ready: Promise<unknown> };
|
||||
@@ -247,18 +154,11 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
// A boot cannot be cancelled, so stop waiting for it when startup is aborted.
|
||||
await tasks.untilAborted(device.booted);
|
||||
tasks.signal.throwIfAborted();
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
taskLogger.info(launchDescription);
|
||||
}
|
||||
sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
networkCapture: capture.networkCapture,
|
||||
networkCaptureFields: capture.networkCaptureFields,
|
||||
});
|
||||
|
||||
@@ -17,10 +17,7 @@ import {
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorName, IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { withLocalEgressSession } from '../utils/localEgressSession';
|
||||
import {
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
import { selectXcodeDeveloperDirectoryAsync } from '../utils/remoteDeviceRunSession';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
parseNetworkCaptureInputs,
|
||||
@@ -47,9 +44,9 @@ 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 replaces eas/start_ios_simulator or eas/start_android_emulator, eas/download_build,
|
||||
* eas/install_build, eas/launch_application and eas/start_agent_device_remote_session,
|
||||
* so the parts that do not depend on each other can run at the same time:
|
||||
* 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 ─────────┘ │
|
||||
@@ -261,8 +258,6 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
// 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,
|
||||
// The app is launched with simctl / adb above, not by serve-sim.
|
||||
launch: parseServeSimLaunchInputs({}, { runtimePlatform }),
|
||||
capture,
|
||||
tasks,
|
||||
device: { booted, ready },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
@@ -37,6 +38,7 @@ import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
|
||||
import {
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
@@ -108,12 +110,17 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
const device = await resolveAppiumDeviceAsync({ runtimePlatform, env, logger });
|
||||
const { appiumHome, appiumBinPath, appiumEnv } = await installAppiumAsync({
|
||||
versionSpec,
|
||||
driverName: device.driverName,
|
||||
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 appiumProcess = spawnDetached({
|
||||
command: appiumBinPath,
|
||||
|
||||
@@ -52,6 +52,8 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
signal,
|
||||
workingDirectory: stepCtx.workingDirectory,
|
||||
env: commandEnv,
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
sandboxId,
|
||||
});
|
||||
try {
|
||||
await daemon.ready;
|
||||
|
||||
@@ -102,10 +102,16 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
// WEB_PREVIEW_ONLY sessions read webPreviewUrl and webPreviewToken. Legacy SERVE_SIM
|
||||
// sessions read previewUrl and previewToken. The server drops the keys that the
|
||||
// session type does not use.
|
||||
remoteConfig: {
|
||||
webPreviewUrl: webPreview.previewPageUrl,
|
||||
previewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken ? { previewToken: webPreview.previewToken } : {}),
|
||||
...(webPreview.previewToken
|
||||
? { webPreviewToken: webPreview.previewToken, previewToken: webPreview.previewToken }
|
||||
: {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from 'node:path';
|
||||
import limitFactory from 'promise-limit';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Sentry } from '../../sentry';
|
||||
import { AscApiClient, AscApiRequestError } from '../utils/ios/AscApiClient';
|
||||
import { AscApiUtils } from '../utils/ios/AscApiUtils';
|
||||
|
||||
@@ -191,6 +192,7 @@ export async function updateTestFlightMetadataAsync({
|
||||
})
|
||||
: [];
|
||||
const assignedIds = new Set(assignedGroups.map(group => group.id));
|
||||
let internalBuildStateVerification: Promise<void> | undefined;
|
||||
const groupLimit = limitFactory<void>(1);
|
||||
const groupResults = await Promise.allSettled(
|
||||
requestedGroups.map(group =>
|
||||
@@ -219,6 +221,17 @@ export async function updateTestFlightMetadataAsync({
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`❌ Group ${label}: assignment failed. ${String(error)}`);
|
||||
|
||||
internalBuildStateVerification ??=
|
||||
verifyInternalBuildStateValidForGroupAssignmentAsync({
|
||||
client,
|
||||
buildId,
|
||||
appId: app.id,
|
||||
logger,
|
||||
error,
|
||||
});
|
||||
await internalBuildStateVerification;
|
||||
|
||||
if (isInternalGroupAssignmentError(error)) {
|
||||
throw new UserError(
|
||||
'EAS_TESTFLIGHT_INTERNAL_GROUP_ASSIGNMENT_FAILED',
|
||||
@@ -229,6 +242,7 @@ export async function updateTestFlightMetadataAsync({
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
logger.info(`✅ Group ${label}: assignment completed.`);
|
||||
@@ -269,6 +283,48 @@ export async function updateTestFlightMetadataAsync({
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyInternalBuildStateValidForGroupAssignmentAsync({
|
||||
client,
|
||||
appId,
|
||||
buildId,
|
||||
logger,
|
||||
error,
|
||||
}: {
|
||||
client: AscApiClient;
|
||||
appId: string;
|
||||
buildId: string;
|
||||
logger: bunyan;
|
||||
error: unknown;
|
||||
}): Promise<void> {
|
||||
let state = 'UNKNOWN';
|
||||
try {
|
||||
const { data } = await client.getAsync('/v1/builds/:id/buildBetaDetail', {}, { id: buildId });
|
||||
state = data.attributes.internalBuildState;
|
||||
} catch (diagnosticError) {
|
||||
logger.warn(`Could not read the TestFlight state: ${String(diagnosticError)}`);
|
||||
}
|
||||
|
||||
logger.info(`Apple build ${buildId}: internal TestFlight state = ${state}.`);
|
||||
|
||||
Sentry.capture(`TestFlight group assignment failed (state: ${state})`, {
|
||||
level: 'error',
|
||||
tags: { step: 'eas/update_testflight_metadata', internal_build_state: state },
|
||||
extras: { buildId, assignmentError: String(error) },
|
||||
});
|
||||
|
||||
if (state === 'MISSING_EXPORT_COMPLIANCE' || state === 'IN_EXPORT_COMPLIANCE_REVIEW') {
|
||||
throw new UserError(
|
||||
'EAS_TESTFLIGHT_GROUP_ASSIGNMENT_FAILED',
|
||||
`Apple could not add the uploaded build to a TestFlight group (state: ${state}). ` +
|
||||
(state === 'MISSING_EXPORT_COMPLIANCE'
|
||||
? 'Complete the export compliance questions for this build in App Store Connect. For future builds, set ITSAppUsesNonExemptEncryption in the iOS Info.plist to the value that matches your app. '
|
||||
: 'Wait for Apple to approve the export compliance information, then assign the group in App Store Connect. ') +
|
||||
`The binary is already uploaded; do not upload it again to fix group assignment. Manage this build at https://appstoreconnect.apple.com/apps/${appId}/testflight`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Apple returns a generic 422 code, so match the title or detail too.
|
||||
function isInternalGroupAssignmentError(error: unknown): boolean {
|
||||
if (error instanceof AggregateError) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { BuildJob, UserError } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { readAppConfig } from '../../utils/appConfig';
|
||||
import { uploadEmbeddedBundleAsync } from '../../utils/expoUpdatesEmbedded';
|
||||
|
||||
export function createUploadEmbeddedBundleBuildFunction(
|
||||
ctx: CustomBuildContext<BuildJob>
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'upload_embedded_bundle',
|
||||
name: 'Upload embedded bundle',
|
||||
__metricsId: 'eas/upload_embedded_bundle',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'ignore_error',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { env, inputs }) => {
|
||||
try {
|
||||
const appConfig = (
|
||||
await readAppConfig({
|
||||
projectDir: stepCtx.workingDirectory,
|
||||
env,
|
||||
logger: stepCtx.logger,
|
||||
sdkVersion: stepCtx.global.staticContext.metadata?.sdkVersion,
|
||||
})
|
||||
).exp;
|
||||
const { status } = await uploadEmbeddedBundleAsync({
|
||||
job: ctx.job,
|
||||
env,
|
||||
logger: stepCtx.logger,
|
||||
projectDir: stepCtx.workingDirectory,
|
||||
appConfig,
|
||||
});
|
||||
if (status === 'skipped') {
|
||||
stepCtx.logger.info('Skipping embedded bundle upload.');
|
||||
} else if (status === 'failed') {
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_EMBEDDED_BUNDLE_FAILED',
|
||||
'Failed to upload embedded bundle. See the warning above for details.'
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (inputs.ignore_error.value) {
|
||||
stepCtx.logger.warn({ err }, 'Failed to upload embedded bundle. Ignoring error.');
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -155,21 +155,11 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
});
|
||||
|
||||
stepsCtx.logger.info('Committing upload...');
|
||||
await client.patchAsync(
|
||||
`/v1/buildUploadFiles/:id`,
|
||||
{
|
||||
data: {
|
||||
type: 'buildUploadFiles',
|
||||
id: buildFileResponse.data.id,
|
||||
attributes: {
|
||||
uploaded: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: buildFileResponse.data.id,
|
||||
}
|
||||
);
|
||||
await AscApiUtils.commitBuildUploadFileAsync({
|
||||
client,
|
||||
fileId: buildFileResponse.data.id,
|
||||
logger: stepsCtx.logger,
|
||||
});
|
||||
|
||||
stepsCtx.logger.info('Checking upload file status...');
|
||||
const waitingForFileStartedAt = Date.now();
|
||||
|
||||
@@ -10,12 +10,27 @@ import { setTimeout } from 'node:timers/promises';
|
||||
|
||||
import { Sentry } from '../../sentry';
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageExec,
|
||||
} from '../../utils/packageManager';
|
||||
import { isProcessGroupRunning, killProcessGroup } from '../../utils/processes';
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
|
||||
const IOS_SIMULATOR_RECORDING_POLL_INTERVAL_MS = 2_000;
|
||||
const RECORD_SIM_FINISH_TIMEOUT_MS = 70_000;
|
||||
const RECORD_SIM_FORCE_STOP_TIMEOUT_MS = 5_000;
|
||||
const RECORD_SIM_MAX_ATTEMPTS_PER_BOOT = 3;
|
||||
const RECORD_SIM_COMMAND = 'record-sim';
|
||||
// Once the poller stops, each recording stops in parallel within 60 + 130 + 5 + 30 = 225 s.
|
||||
const SERVE_SIM_START_TIMEOUT_MS = 60_000;
|
||||
/** serve-sim finalizes an active recording within this grace period after SIGTERM. */
|
||||
export const SERVE_SIM_STOP_GRACE_PERIOD_MS = 90_000;
|
||||
const SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS = 30_000;
|
||||
// serve-sim may be stopping at the same time: its grace period, the manifest wait, and 10 s.
|
||||
const SERVE_SIM_FINISH_TIMEOUT_MS =
|
||||
SERVE_SIM_STOP_GRACE_PERIOD_MS + SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS + 10_000;
|
||||
const SERVE_SIM_FORCE_STOP_TIMEOUT_MS = 5_000;
|
||||
const SERVE_SIM_RECORDING_STARTED = 'serve-sim:recording-started';
|
||||
// The serve-sim package the session host started; every recorder runs `record-video` from it.
|
||||
let serveSimPackageSpec: string | null = null;
|
||||
|
||||
type IosSimulatorRecording = {
|
||||
id: string;
|
||||
@@ -25,6 +40,7 @@ type IosSimulatorRecording = {
|
||||
outputDirectory: string;
|
||||
startedAt: Date;
|
||||
getOutput: () => string;
|
||||
hasStarted: () => boolean;
|
||||
};
|
||||
|
||||
type ActiveIosSimulatorRecording = IosSimulatorRecording & {
|
||||
@@ -35,11 +51,15 @@ type ActiveIosSimulatorRecording = IosSimulatorRecording & {
|
||||
type IosSimulatorRecordingSession = {
|
||||
env: Env;
|
||||
logger: bunyan;
|
||||
recordSimCommand: string;
|
||||
recordingsRootDirectory: string;
|
||||
activeRecordings: Map<IosSimulatorUuid, ActiveIosSimulatorRecording>;
|
||||
completedRecordings: IosSimulatorRecording[];
|
||||
recordingFailureCounts: Map<IosSimulatorUuid, number>;
|
||||
/** Failed attempts for the whole session; unlike recordingFailureCounts, never reset. */
|
||||
failedAttemptCounts: Map<IosSimulatorUuid, number>;
|
||||
recordingRetryAt: Map<IosSimulatorUuid, number>;
|
||||
serveBearerTokens: Map<IosSimulatorUuid, string>;
|
||||
completedServeBearerTokens: Map<IosSimulatorUuid, string>;
|
||||
pollingPromise: Promise<void>;
|
||||
abortController: AbortController;
|
||||
};
|
||||
@@ -47,31 +67,31 @@ type IosSimulatorRecordingSession = {
|
||||
let activeIosSimulatorRecordingSession: IosSimulatorRecordingSession | null = null;
|
||||
|
||||
export namespace IosSimulatorRecordingUtils {
|
||||
/** One session host serves every device of a job, so the first package wins. */
|
||||
export function useServeSimPackage(packageSpec: string): void {
|
||||
serveSimPackageSpec ??= packageSpec;
|
||||
}
|
||||
|
||||
export async function startAsync({ env, logger }: { env: Env; logger: bunyan }): Promise<void> {
|
||||
if (activeIosSimulatorRecordingSession) {
|
||||
logger.info('iOS Simulator screen recording polling is already running.');
|
||||
return;
|
||||
}
|
||||
|
||||
const recordSimCommand = await resolveRecordSimCommandAsync({ env });
|
||||
if (!recordSimCommand) {
|
||||
logger.warn(
|
||||
'record-sim binary is not available; iOS Simulator screen recordings are disabled.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const recordingsRootDirectory = await mkdtemp(
|
||||
path.join(os.tmpdir(), 'ios-simulator-recordings-')
|
||||
);
|
||||
const session: IosSimulatorRecordingSession = {
|
||||
env,
|
||||
logger,
|
||||
recordSimCommand,
|
||||
recordingsRootDirectory,
|
||||
activeRecordings: new Map(),
|
||||
completedRecordings: [],
|
||||
recordingFailureCounts: new Map(),
|
||||
failedAttemptCounts: new Map(),
|
||||
recordingRetryAt: new Map(),
|
||||
serveBearerTokens: new Map(),
|
||||
completedServeBearerTokens: new Map(),
|
||||
pollingPromise: Promise.resolve(),
|
||||
abortController: new AbortController(),
|
||||
};
|
||||
@@ -94,6 +114,7 @@ export namespace IosSimulatorRecordingUtils {
|
||||
}[]
|
||||
> {
|
||||
const session = activeIosSimulatorRecordingSession;
|
||||
serveSimPackageSpec = null;
|
||||
if (!session) {
|
||||
logger.info('No iOS Simulator screen recordings are running.');
|
||||
return [];
|
||||
@@ -105,41 +126,55 @@ export namespace IosSimulatorRecordingUtils {
|
||||
await Promise.all(
|
||||
[...session.activeRecordings.values()].map(async recording => {
|
||||
logger.info(`Stopping screen recording for ${recording.deviceName}.`);
|
||||
recording.recordingProcess.kill('SIGINT');
|
||||
const startState = await waitForRecorderStartAsync(recording, SERVE_SIM_START_TIMEOUT_MS);
|
||||
if (startState === 'exited') {
|
||||
return;
|
||||
}
|
||||
if (startState === 'timeout') {
|
||||
logger.warn(
|
||||
{ recorderOutput: recording.getOutput().trim() },
|
||||
`Screen recording for ${recording.deviceName} did not report its start within ${Math.round(
|
||||
SERVE_SIM_START_TIMEOUT_MS / 1_000
|
||||
)} seconds and will be stopped.`
|
||||
);
|
||||
}
|
||||
const finishTimeoutMs =
|
||||
startState === 'started' ? SERVE_SIM_FINISH_TIMEOUT_MS : SERVE_SIM_FORCE_STOP_TIMEOUT_MS;
|
||||
signalRecorder(recording.recordingProcess, 'SIGINT');
|
||||
const finished = await Promise.race([
|
||||
recording.completionPromise.then(() => true),
|
||||
setTimeout(RECORD_SIM_FINISH_TIMEOUT_MS, false, { ref: false }),
|
||||
setTimeout(finishTimeoutMs, false, { ref: false }),
|
||||
]);
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recordSimOutput = recording.getOutput().trim();
|
||||
const finishTimeoutSeconds = Math.round(RECORD_SIM_FINISH_TIMEOUT_MS / 1_000);
|
||||
const recorderOutput = recording.getOutput().trim();
|
||||
const finishTimeoutSeconds = Math.round(finishTimeoutMs / 1_000);
|
||||
const stopFailure =
|
||||
startState === 'started'
|
||||
? `did not finish within ${finishTimeoutSeconds} seconds`
|
||||
: `did not stop within ${finishTimeoutSeconds} seconds of SIGINT`;
|
||||
logger.warn(
|
||||
{ recordSimOutput },
|
||||
`Screen recording for ${recording.deviceName} did not finish within ${finishTimeoutSeconds} seconds and will be stopped.${
|
||||
recordSimOutput
|
||||
? `\nRecent recorder messages:\n${recordSimOutput}`
|
||||
{ recorderOutput },
|
||||
`Screen recording for ${recording.deviceName} ${stopFailure} and will be killed.${
|
||||
recorderOutput
|
||||
? `\nRecent recorder messages:\n${recorderOutput}`
|
||||
: '\nNo recorder messages were captured.'
|
||||
}`
|
||||
);
|
||||
recording.recordingProcess.kill('SIGKILL');
|
||||
signalRecorder(recording.recordingProcess, 'SIGKILL');
|
||||
const killed = await Promise.race([
|
||||
recording.completionPromise.then(() => true),
|
||||
setTimeout(RECORD_SIM_FORCE_STOP_TIMEOUT_MS, false, { ref: false }),
|
||||
setTimeout(SERVE_SIM_FORCE_STOP_TIMEOUT_MS, false, { ref: false }),
|
||||
]);
|
||||
if (!killed) {
|
||||
logger.warn(
|
||||
`iOS Simulator recording process for ${recording.deviceName} did not exit after SIGKILL.`
|
||||
);
|
||||
// Recorder output stays in the job log: it can contain the serve-sim bearer token.
|
||||
Sentry.capture(
|
||||
`iOS Simulator recording process for ${recording.deviceName} did not exit after SIGKILL.`,
|
||||
{
|
||||
extras: {
|
||||
output: recording.getOutput(),
|
||||
},
|
||||
}
|
||||
`iOS Simulator recording process for ${recording.deviceName} did not exit after SIGKILL.`
|
||||
);
|
||||
}
|
||||
})
|
||||
@@ -148,7 +183,31 @@ export namespace IosSimulatorRecordingUtils {
|
||||
const completedRecordings = [...session.completedRecordings].sort(
|
||||
(a, b) => a.startedAt.getTime() - b.startedAt.getTime()
|
||||
);
|
||||
return completedRecordings.map(recording => ({
|
||||
const recordingsWithManifests = await Promise.all(
|
||||
completedRecordings.map(async recording => {
|
||||
if (
|
||||
await waitForRecordingManifestAsync(
|
||||
recording.outputDirectory,
|
||||
SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS
|
||||
)
|
||||
) {
|
||||
return recording;
|
||||
}
|
||||
reportLostRecording(logger, recording.deviceName, recording.getOutput().trim());
|
||||
return null;
|
||||
})
|
||||
);
|
||||
const finalizedRecordings = recordingsWithManifests.filter(recording => recording !== null);
|
||||
const recordedUdids = new Set(finalizedRecordings.map(recording => recording.udid));
|
||||
for (const [failedUdid, attempts] of session.failedAttemptCounts) {
|
||||
if (!recordedUdids.has(failedUdid)) {
|
||||
Sentry.capture('iOS Simulator screen recording failed on every attempt', {
|
||||
level: 'warning',
|
||||
extras: { attempts },
|
||||
});
|
||||
}
|
||||
}
|
||||
return finalizedRecordings.map(recording => ({
|
||||
udid: recording.udid,
|
||||
deviceName: recording.deviceName,
|
||||
runtimeDisplayName: recording.runtimeDisplayName,
|
||||
@@ -174,18 +233,38 @@ async function pollIosSimulatorRecordingsAsync(
|
||||
break;
|
||||
}
|
||||
listDevicesErrorCount = 0;
|
||||
const readyServers = new Map(
|
||||
(await readServeSimServersAsync(SERVE_SIM_STATE_DIR)).flatMap(server =>
|
||||
server.token ? [[server.udid, server.token] as const] : []
|
||||
)
|
||||
);
|
||||
|
||||
const bootedUdids = new Set(bootedDevices.map(device => device.udid));
|
||||
for (const udid of session.recordingFailureCounts.keys()) {
|
||||
for (const udid of session.serveBearerTokens.keys()) {
|
||||
if (!bootedUdids.has(udid)) {
|
||||
session.recordingFailureCounts.delete(udid);
|
||||
session.recordingRetryAt.delete(udid);
|
||||
session.serveBearerTokens.delete(udid);
|
||||
session.completedServeBearerTokens.delete(udid);
|
||||
}
|
||||
}
|
||||
|
||||
for (const device of bootedDevices) {
|
||||
const token = readyServers.get(device.udid);
|
||||
const packageSpec = serveSimPackageSpec;
|
||||
if (!token || !packageSpec) {
|
||||
continue;
|
||||
}
|
||||
if (session.serveBearerTokens.get(device.udid) !== token) {
|
||||
session.serveBearerTokens.set(device.udid, token);
|
||||
session.recordingFailureCounts.delete(device.udid);
|
||||
session.recordingRetryAt.delete(device.udid);
|
||||
session.completedServeBearerTokens.delete(device.udid);
|
||||
}
|
||||
if (
|
||||
session.completedServeBearerTokens.get(device.udid) === token ||
|
||||
session.activeRecordings.has(device.udid) ||
|
||||
(session.recordingFailureCounts.get(device.udid) ?? 0) >= RECORD_SIM_MAX_ATTEMPTS_PER_BOOT
|
||||
Date.now() < (session.recordingRetryAt.get(device.udid) ?? 0)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -193,6 +272,7 @@ async function pollIosSimulatorRecordingsAsync(
|
||||
udid: device.udid,
|
||||
deviceName: device.name,
|
||||
runtimeDisplayName: device.runtimeDisplayName,
|
||||
packageSpec,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -227,50 +307,90 @@ async function startIosSimulatorRecordingAsync(
|
||||
udid,
|
||||
deviceName,
|
||||
runtimeDisplayName,
|
||||
packageSpec,
|
||||
}: {
|
||||
udid: IosSimulatorUuid;
|
||||
deviceName: string;
|
||||
runtimeDisplayName: string;
|
||||
packageSpec: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const startedAt = new Date();
|
||||
const serveBearerToken = session.serveBearerTokens.get(udid);
|
||||
const recordingId = randomUUID();
|
||||
const outputDirectory = path.join(session.recordingsRootDirectory, recordingId);
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
|
||||
session.logger.info(`Starting screen recording for ${deviceName}.`);
|
||||
const recorderExec = resolvePackageExec(
|
||||
resolveConfiguredPackageManager(session.env, PackageManager.NPM),
|
||||
[packageSpec, 'record-video']
|
||||
);
|
||||
const recordingSpawn = spawn(
|
||||
session.recordSimCommand,
|
||||
['--udid', udid, '--output', outputDirectory, '--segment-duration', '0'],
|
||||
recorderExec.command,
|
||||
[...recorderExec.args, '--udid', udid, '--output', outputDirectory],
|
||||
{
|
||||
env: session.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
}
|
||||
);
|
||||
const getOutput = captureProcessOutput(recordingSpawn.child);
|
||||
const completionPromise = recordingSpawn
|
||||
.then(() => undefined)
|
||||
.catch((err: unknown) => {
|
||||
session.recordingFailureCounts.set(udid, (session.recordingFailureCounts.get(udid) ?? 0) + 1);
|
||||
const { getOutput, hasStarted } = captureProcessOutput(recordingSpawn.child);
|
||||
const saveFinalizedRecordingAsync = async (): Promise<boolean> => {
|
||||
if (
|
||||
!(await waitForRecordingManifestAsync(outputDirectory, SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (serveBearerToken) {
|
||||
session.completedServeBearerTokens.set(udid, serveBearerToken);
|
||||
}
|
||||
session.completedRecordings.push({
|
||||
id: recordingId,
|
||||
udid,
|
||||
deviceName,
|
||||
runtimeDisplayName,
|
||||
outputDirectory,
|
||||
startedAt,
|
||||
getOutput,
|
||||
hasStarted,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
const completionPromise = (async () => {
|
||||
let spawnError: unknown;
|
||||
try {
|
||||
await recordingSpawn;
|
||||
} catch (err) {
|
||||
spawnError = err;
|
||||
}
|
||||
// A package-manager wrapper can exit while its record-video child still
|
||||
// runs in the detached group. Keep this device active until the group exits.
|
||||
await waitForRecordingProcessGroupExitAsync(recordingSpawn.child);
|
||||
// Host shutdown can end the recorder's lease after its video has been saved.
|
||||
if ((hasStarted() || !spawnError) && (await saveFinalizedRecordingAsync())) {
|
||||
return;
|
||||
}
|
||||
if (spawnError) {
|
||||
const err = spawnError;
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('iOS Simulator screen recording process failed', error);
|
||||
session.logger.warn(
|
||||
{ err: error, recordSimOutput: getOutput() },
|
||||
{ err: error, recorderOutput: getOutput() },
|
||||
`Screen recording process failed for ${deviceName}.`
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
session.activeRecordings.delete(udid);
|
||||
session.completedRecordings.push({
|
||||
id: recordingId,
|
||||
udid,
|
||||
deviceName,
|
||||
runtimeDisplayName,
|
||||
outputDirectory,
|
||||
startedAt,
|
||||
getOutput,
|
||||
});
|
||||
});
|
||||
}
|
||||
if (hasStarted()) {
|
||||
reportLostRecording(session.logger, deviceName, getOutput().trim());
|
||||
} else if (!spawnError) {
|
||||
session.logger.warn(
|
||||
{ recorderOutput: getOutput().trim() },
|
||||
`Screen recording for ${deviceName} exited before it started; it will be retried.`
|
||||
);
|
||||
}
|
||||
scheduleRecordingRetry(session, udid);
|
||||
})().finally(() => {
|
||||
session.activeRecordings.delete(udid);
|
||||
});
|
||||
|
||||
session.activeRecordings.set(udid, {
|
||||
id: recordingId,
|
||||
@@ -282,31 +402,103 @@ async function startIosSimulatorRecordingAsync(
|
||||
completionPromise,
|
||||
startedAt,
|
||||
getOutput,
|
||||
hasStarted,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveRecordSimCommandAsync({ env }: { env: Env }): Promise<string | null> {
|
||||
try {
|
||||
await spawn('which', [RECORD_SIM_COMMAND], { env });
|
||||
return RECORD_SIM_COMMAND;
|
||||
} catch {}
|
||||
async function waitForRecordingManifestAsync(
|
||||
outputDirectory: string,
|
||||
timeoutMs: number
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await access(path.join(outputDirectory, 'session.json'));
|
||||
return true;
|
||||
} catch {
|
||||
await setTimeout(1_000);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const packagedRecordSimPath = path.join(__dirname, '..', '..', '..', 'bin', RECORD_SIM_COMMAND);
|
||||
try {
|
||||
await access(packagedRecordSimPath);
|
||||
return packagedRecordSimPath;
|
||||
} catch {
|
||||
return null;
|
||||
async function waitForRecorderStartAsync(
|
||||
recording: ActiveIosSimulatorRecording,
|
||||
timeoutMs: number
|
||||
): Promise<'started' | 'exited' | 'timeout'> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (recording.hasStarted()) {
|
||||
return 'started';
|
||||
}
|
||||
const exited = await Promise.race([
|
||||
recording.completionPromise.then(() => true),
|
||||
setTimeout(100, false),
|
||||
]);
|
||||
if (exited) {
|
||||
return 'exited';
|
||||
}
|
||||
}
|
||||
return 'timeout';
|
||||
}
|
||||
|
||||
function reportLostRecording(logger: bunyan, deviceName: string, recorderOutput: string): void {
|
||||
logger.warn(
|
||||
{ recorderOutput },
|
||||
`Screen recording for ${deviceName} ended without a manifest; its footage cannot be uploaded.`
|
||||
);
|
||||
Sentry.capture('iOS Simulator screen recording ended without a manifest', {
|
||||
level: 'warning',
|
||||
extras: { deviceName },
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleRecordingRetry(
|
||||
session: IosSimulatorRecordingSession,
|
||||
udid: IosSimulatorUuid
|
||||
): void {
|
||||
session.failedAttemptCounts.set(udid, (session.failedAttemptCounts.get(udid) ?? 0) + 1);
|
||||
const failures = (session.recordingFailureCounts.get(udid) ?? 0) + 1;
|
||||
session.recordingFailureCounts.set(udid, failures);
|
||||
session.recordingRetryAt.set(
|
||||
udid,
|
||||
Date.now() + Math.min(300_000, 25_000 * 2 ** Math.min(failures - 1, 4))
|
||||
);
|
||||
}
|
||||
|
||||
function signalRecorder(child: ChildProcess, signal: NodeJS.Signals): void {
|
||||
// killProcessGroup skips a child without a pid.
|
||||
if (child.pid === undefined) {
|
||||
child.kill(signal);
|
||||
return;
|
||||
}
|
||||
killProcessGroup(child, signal);
|
||||
}
|
||||
|
||||
async function waitForRecordingProcessGroupExitAsync(child: ChildProcess): Promise<void> {
|
||||
if (child.pid === undefined) {
|
||||
return;
|
||||
}
|
||||
// Bounded by finishAsync's SIGKILL; unref'd so it cannot keep the worker alive.
|
||||
while (isProcessGroupRunning(child.pid)) {
|
||||
await setTimeout(100, undefined, { ref: false });
|
||||
}
|
||||
}
|
||||
|
||||
function captureProcessOutput(recordingProcess: ChildProcess): () => string {
|
||||
function captureProcessOutput(recordingProcess: ChildProcess): {
|
||||
getOutput: () => string;
|
||||
hasStarted: () => boolean;
|
||||
} {
|
||||
let output = '';
|
||||
let started = false;
|
||||
const appendChunk = (chunk: Buffer | string): void => {
|
||||
// Keep enough recorder stderr/stdout for diagnostics without retaining unbounded output.
|
||||
output = `${output}${chunk.toString()}`.slice(-16_384);
|
||||
output = `${output}${chunk.toString()}`;
|
||||
if (output.includes(SERVE_SIM_RECORDING_STARTED)) {
|
||||
started = true;
|
||||
}
|
||||
output = output.slice(-16_384);
|
||||
};
|
||||
recordingProcess.stdout?.on('data', appendChunk);
|
||||
recordingProcess.stderr?.on('data', appendChunk);
|
||||
return () => output;
|
||||
return { getOutput: () => output, hasStarted: () => started };
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
import { type Env } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { type ChildProcess } from 'node:child_process';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { rm, writeFile } from 'node:fs/promises';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils';
|
||||
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../serveSimMetricsRecorder');
|
||||
jest.mock('../../../sentry');
|
||||
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
} as unknown as bunyan;
|
||||
const env = {} as Env;
|
||||
const udid = '06B546AC-7B06-4BED-83DB-83603E8EB537';
|
||||
|
||||
beforeEach(() => {
|
||||
IosSimulatorRecordingUtils.useServeSimPackage('@expo/serve-sim@next');
|
||||
jest
|
||||
.spyOn(IosSimulatorUtils, 'getAvailableDevicesAsync')
|
||||
.mockResolvedValue([{ udid, name: 'iPhone 17 Pro', runtimeDisplayName: 'iOS 26.4' } as never]);
|
||||
jest.mocked(readServeSimServersAsync).mockReset();
|
||||
jest.mocked(spawn).mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function waitForRecorderSpawnAsync(): Promise<void> {
|
||||
for (let attempt = 0; attempt < 50; attempt++) {
|
||||
if (jest.mocked(spawn).mock.calls.length > 0) {
|
||||
return;
|
||||
}
|
||||
await setTimeout(10);
|
||||
}
|
||||
throw new Error('serve-sim recorder did not start');
|
||||
}
|
||||
|
||||
// A recorder the test controls: `exit` ends it cleanly, `fail` ends it with an error.
|
||||
function mockRecorder({ kill }: { kill?: (signal: NodeJS.Signals) => void } = {}): {
|
||||
child: ChildProcess;
|
||||
exit: () => void;
|
||||
fail: (error: Error) => void;
|
||||
} {
|
||||
let exit: () => void = () => {};
|
||||
let fail: (error: Error) => void = () => {};
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: jest.fn((signal: NodeJS.Signals) => {
|
||||
kill?.(signal);
|
||||
return true;
|
||||
}),
|
||||
}) as unknown as ChildProcess;
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
exit = resolve;
|
||||
fail = reject;
|
||||
});
|
||||
jest.mocked(spawn).mockReturnValue(Object.assign(done, { child }) as never);
|
||||
return { child, exit: () => exit(), fail: error => fail(error) };
|
||||
}
|
||||
|
||||
function mockFailingRecorder(): void {
|
||||
jest.mocked(spawn).mockImplementation((_command, args) => {
|
||||
void writeFile(`${args[6]}/session.json`, JSON.stringify({ recording: 'recording.mp4' }));
|
||||
return Object.assign(Promise.reject(new Error('recorder exited')), {
|
||||
child: {
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: jest.fn(),
|
||||
},
|
||||
}) as never;
|
||||
});
|
||||
}
|
||||
|
||||
test('waits for a token-gated serve-sim session before starting a recorder', async () => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563' }]);
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
|
||||
expect(jest.mocked(spawn)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('starts serve-sim record-video and returns only a finalized recording', async () => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
let resolveProcess: () => void = () => {};
|
||||
const stderr = new PassThrough();
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdout: new PassThrough(),
|
||||
stderr,
|
||||
kill: jest.fn(),
|
||||
}) as unknown as ChildProcess;
|
||||
const processPromise = Object.assign(
|
||||
new Promise<void>(resolve => {
|
||||
resolveProcess = resolve;
|
||||
}),
|
||||
{
|
||||
child,
|
||||
}
|
||||
);
|
||||
jest.mocked(spawn).mockReturnValue(processPromise as never);
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
const [command, args, options] = jest.mocked(spawn).mock.calls[0];
|
||||
expect(command).toBe('npx');
|
||||
expect(args.slice(0, 5)).toEqual([
|
||||
'--yes',
|
||||
'@expo/serve-sim@next',
|
||||
'record-video',
|
||||
'--udid',
|
||||
udid,
|
||||
]);
|
||||
expect(args[5]).toBe('--output');
|
||||
expect(args).not.toContain('--segment-duration');
|
||||
expect(options?.detached).toBe(true);
|
||||
|
||||
const outputDirectory = args[6];
|
||||
jest.mocked(child.kill).mockImplementation(() => {
|
||||
resolveProcess();
|
||||
void setTimeout(20).then(() =>
|
||||
writeFile(`${outputDirectory}/session.json`, JSON.stringify({ recording: 'recording.mp4' }))
|
||||
);
|
||||
return true;
|
||||
});
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await setTimeout(20);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
stderr.write('serve-sim:recording-started\n');
|
||||
stderr.write('x'.repeat(17_000));
|
||||
const recordings = await finishing;
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGINT');
|
||||
expect(recordings).toEqual([
|
||||
{
|
||||
udid,
|
||||
deviceName: 'iPhone 17 Pro',
|
||||
runtimeDisplayName: 'iOS 26.4',
|
||||
directory: outputDirectory,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('reports a started recording that ends without a manifest to Sentry', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
let exitRecorder: () => void = () => {};
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: jest.fn(),
|
||||
}) as unknown as ChildProcess;
|
||||
jest.mocked(spawn).mockReturnValue(
|
||||
Object.assign(
|
||||
new Promise<void>(resolve => {
|
||||
exitRecorder = resolve;
|
||||
}),
|
||||
{ child }
|
||||
) as never
|
||||
);
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
child.stderr?.emit('data', 'serve-sim:recording-started\n');
|
||||
child.stderr?.emit('data', 'GET http://127.0.0.1:43563/record?token=session-token failed\n');
|
||||
exitRecorder();
|
||||
await setTimeout(50);
|
||||
currentTime += 31_000;
|
||||
await setTimeout(1_100);
|
||||
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'iOS Simulator screen recording ended without a manifest',
|
||||
{ level: 'warning', extras: { deviceName: 'iPhone 17 Pro' } }
|
||||
);
|
||||
expect(JSON.stringify(jest.mocked(Sentry.capture).mock.calls)).not.toContain('session-token');
|
||||
});
|
||||
|
||||
test('says when a recorder never reported its start before it is stopped', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
let exitRecorder: () => void = () => {};
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: jest.fn(() => {
|
||||
exitRecorder();
|
||||
return true;
|
||||
}),
|
||||
}) as unknown as ChildProcess;
|
||||
jest.mocked(spawn).mockReturnValue(
|
||||
Object.assign(
|
||||
new Promise<void>(resolve => {
|
||||
exitRecorder = resolve;
|
||||
}),
|
||||
{ child }
|
||||
) as never
|
||||
);
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await setTimeout(50);
|
||||
currentTime += 61_000;
|
||||
await setTimeout(200);
|
||||
currentTime += 31_000;
|
||||
|
||||
expect(await finishing).toEqual([]);
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGINT');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'Screen recording for iPhone 17 Pro did not report its start within 60 seconds and will be stopped.'
|
||||
);
|
||||
});
|
||||
|
||||
test('retries after a failed recorder when the serve-sim session token changes', async () => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValueOnce([{ udid, url: 'http://127.0.0.1:43563', token: 'old-token' }])
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'new-token' }]);
|
||||
mockFailingRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
await setTimeout(2_100);
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(2);
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toEqual([]);
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'iOS Simulator screen recording failed on every attempt',
|
||||
expect.objectContaining({ level: 'warning', extras: { attempts: 2 } })
|
||||
);
|
||||
});
|
||||
|
||||
test('reports a device that failed and then shut down', async () => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
mockFailingRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockResolvedValue([]);
|
||||
await setTimeout(2_100);
|
||||
|
||||
expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toEqual([]);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'iOS Simulator screen recording failed on every attempt',
|
||||
expect.objectContaining({ extras: { attempts: 1 } })
|
||||
);
|
||||
});
|
||||
|
||||
test('retries after the server lease expires without a token change', async () => {
|
||||
const startTime = Date.now();
|
||||
let currentTime = startTime;
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
mockFailingRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
currentTime += 26_000;
|
||||
await setTimeout(2_100);
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('does not start a second recording for a server token after a late manifest', async () => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
jest.mocked(spawn).mockImplementation((_command, args) => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: jest.fn(),
|
||||
}) as unknown as ChildProcess;
|
||||
void setTimeout(20).then(() =>
|
||||
writeFile(`${args[6]}/session.json`, JSON.stringify({ recording: 'recording.mp4' }))
|
||||
);
|
||||
return Object.assign(Promise.resolve(), { child }) as never;
|
||||
});
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
await setTimeout(2_100);
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('keeps a recorder active after its package-manager wrapper exits', async () => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
let groupRunning = true;
|
||||
const originalKill = process.kill.bind(process);
|
||||
const signalGroup = jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (pid !== -4321) {
|
||||
return originalKill(pid, signal);
|
||||
}
|
||||
if (signal === 0) {
|
||||
if (groupRunning) {
|
||||
return true;
|
||||
}
|
||||
throw Object.assign(new Error('No such process group'), { code: 'ESRCH' });
|
||||
}
|
||||
if (signal === 'SIGINT') {
|
||||
void setTimeout(20).then(async () => {
|
||||
const args = jest.mocked(spawn).mock.calls[0][1];
|
||||
await writeFile(`${args[6]}/session.json`, JSON.stringify({ recording: 'recording.mp4' }));
|
||||
groupRunning = false;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
throw new Error(`Unexpected signal: ${signal}`);
|
||||
});
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
pid: 4321,
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: jest.fn(),
|
||||
}) as unknown as ChildProcess;
|
||||
jest.mocked(spawn).mockReturnValue(Object.assign(Promise.resolve(), { child }) as never);
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
child.stderr?.emit('data', 'serve-sim:recording-started\n');
|
||||
await setTimeout(2_100);
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
|
||||
const recordings = await IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
expect(signalGroup).toHaveBeenCalledWith(-4321, 'SIGINT');
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
expect(recordings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('does not signal a recorder that exits before it reports its start', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
const recorder = mockRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await setTimeout(50);
|
||||
recorder.exit();
|
||||
await setTimeout(50);
|
||||
// Past the manifest wait, so the recorder's completion settles.
|
||||
currentTime += 31_000;
|
||||
|
||||
expect(await finishing).toEqual([]);
|
||||
expect(recorder.child.kill).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'Screen recording for iPhone 17 Pro exited before it started; it will be retried.'
|
||||
);
|
||||
});
|
||||
|
||||
test('kills a recorder that ignores SIGINT and reports one that survives SIGKILL', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
const recorder = mockRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
recorder.child.stderr?.emit('data', 'GET /record?token=session-token\n');
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await setTimeout(50);
|
||||
currentTime += 61_000;
|
||||
|
||||
expect(await finishing).toEqual([]);
|
||||
expect(recorder.child.kill).toHaveBeenCalledWith('SIGINT');
|
||||
expect(recorder.child.kill).toHaveBeenCalledWith('SIGKILL');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.stringContaining(
|
||||
'Screen recording for iPhone 17 Pro did not stop within 5 seconds of SIGINT and will be killed.'
|
||||
)
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'iOS Simulator recording process for iPhone 17 Pro did not exit after SIGKILL.'
|
||||
);
|
||||
expect(JSON.stringify(jest.mocked(Sentry.capture).mock.calls)).not.toContain('session-token');
|
||||
}, 20_000);
|
||||
|
||||
test('reports a finalized recording whose manifest is gone at finish', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
const recorder = mockRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
const manifest = `${jest.mocked(spawn).mock.calls[0][1][6]}/session.json`;
|
||||
recorder.child.stderr?.emit('data', 'serve-sim:recording-started\n');
|
||||
await writeFile(manifest, JSON.stringify({ recording: 'recording.mp4' }));
|
||||
recorder.exit();
|
||||
await setTimeout(100);
|
||||
await rm(manifest);
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await setTimeout(50);
|
||||
currentTime += 31_000;
|
||||
|
||||
expect(await finishing).toEqual([]);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'iOS Simulator screen recording ended without a manifest',
|
||||
{ level: 'warning', extras: { deviceName: 'iPhone 17 Pro' } }
|
||||
);
|
||||
});
|
||||
|
||||
test.each([0, 20])(
|
||||
'keeps a started recording that exits with an error when its manifest appears after %i ms',
|
||||
async manifestDelayMs => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
const recorder = mockRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
const outputDirectory = jest.mocked(spawn).mock.calls[0][1][6];
|
||||
recorder.child.stderr?.emit('data', 'serve-sim:recording-started\n');
|
||||
const manifestWritten = setTimeout(manifestDelayMs).then(() =>
|
||||
writeFile(`${outputDirectory}/session.json`, JSON.stringify({ recording: 'recording.mp4' }))
|
||||
);
|
||||
if (manifestDelayMs === 0) {
|
||||
await manifestWritten;
|
||||
}
|
||||
recorder.fail(new Error('record-video exited with code 1'));
|
||||
|
||||
const recordings = await IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await manifestWritten;
|
||||
|
||||
expect(recordings).toEqual([
|
||||
{
|
||||
udid,
|
||||
deviceName: 'iPhone 17 Pro',
|
||||
runtimeDisplayName: 'iOS 26.4',
|
||||
directory: outputDirectory,
|
||||
},
|
||||
]);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
test('reports a started recorder that fails without a manifest', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
const recorder = mockRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
recorder.child.stderr?.emit('data', 'serve-sim:recording-started\n');
|
||||
recorder.fail(new Error('record-video exited with code 1'));
|
||||
await setTimeout(50);
|
||||
currentTime += 31_000;
|
||||
await setTimeout(1_100);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'Screen recording process failed for iPhone 17 Pro.'
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'iOS Simulator screen recording ended without a manifest',
|
||||
expect.objectContaining({ level: 'warning' })
|
||||
);
|
||||
});
|
||||
|
||||
test('records every booted device with the first serve-sim package', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
const secondUdid = '1C17CD51-5926-4838-951B-6D941C7BEDFF';
|
||||
IosSimulatorRecordingUtils.useServeSimPackage('@expo/serve-sim@later');
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([
|
||||
{ udid, name: 'iPhone 17 Pro', runtimeDisplayName: 'iOS 26.4' } as never,
|
||||
{ udid: secondUdid, name: 'iPhone 17', runtimeDisplayName: 'iOS 26.4' } as never,
|
||||
]);
|
||||
jest.mocked(readServeSimServersAsync).mockResolvedValue([
|
||||
{ udid, url: 'http://127.0.0.1:43563', token: 'first-token' },
|
||||
{ udid: secondUdid, url: 'http://127.0.0.1:43563', token: 'second-token' },
|
||||
]);
|
||||
const recorder = mockRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
for (let attempt = 0; attempt < 50 && jest.mocked(spawn).mock.calls.length < 2; attempt++) {
|
||||
await setTimeout(10);
|
||||
}
|
||||
|
||||
const calls = jest.mocked(spawn).mock.calls.map(([, args]) => args);
|
||||
expect(calls.map(args => args[4]).sort()).toEqual([udid, secondUdid].sort());
|
||||
expect(calls.every(args => args[1] === '@expo/serve-sim@next')).toBe(true);
|
||||
|
||||
recorder.exit();
|
||||
await setTimeout(50);
|
||||
currentTime += 31_000;
|
||||
await IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
});
|
||||
@@ -104,6 +104,39 @@ describe(listAgentDeviceArtifactsAsync, () => {
|
||||
})
|
||||
).rejects.toThrow('agent-device daemon does not expose artifact inventory.');
|
||||
});
|
||||
|
||||
it('keeps a screenshot display rotation and drops an unknown one', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
artifacts: [
|
||||
{
|
||||
id: 'rotated',
|
||||
artifactType: 'screenshot',
|
||||
filename: 'rotated.png',
|
||||
displayRotation: 'landscape-left',
|
||||
},
|
||||
{
|
||||
id: 'future',
|
||||
artifactType: 'screenshot',
|
||||
filename: 'future.png',
|
||||
displayRotation: 'sideways',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const artifacts = await listAgentDeviceArtifactsAsync({
|
||||
daemonUrl: 'http://127.0.0.1:1234',
|
||||
daemonToken: 'daemon-token',
|
||||
});
|
||||
|
||||
expect(artifacts.map(artifact => artifact.displayRotation)).toEqual([
|
||||
'landscape-left',
|
||||
undefined,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
@@ -120,8 +153,8 @@ describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
await uploadAgentDeviceArtifactAsync(ctx, {
|
||||
@@ -146,7 +179,7 @@ describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
filename: 'report.json',
|
||||
kind: 'agent-device-test-report',
|
||||
size: data.length,
|
||||
stream: expect.anything(),
|
||||
openStream: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,8 +191,8 @@ describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
await uploadAgentDeviceArtifactAsync(ctx, {
|
||||
@@ -181,6 +214,63 @@ describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads a screenshot display rotation as artifact metadata', async () => {
|
||||
const data = Buffer.from('png-data');
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
await uploadAgentDeviceArtifactAsync(ctx, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
daemonUrl: 'http://127.0.0.1:1234',
|
||||
daemonToken: 'daemon-token',
|
||||
logger: createLoggerMock(),
|
||||
artifact: {
|
||||
id: 'artifact-id',
|
||||
artifactType: 'screenshot',
|
||||
filename: 'shot.png',
|
||||
displayRotation: 'landscape-right',
|
||||
},
|
||||
});
|
||||
|
||||
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
kind: 'screenshot',
|
||||
metadata: { displayRotation: 'landscape-right' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads no metadata when the artifact has no display rotation', async () => {
|
||||
const data = Buffer.from('artifact-data');
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
await uploadAgentDeviceArtifactAsync(ctx, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
daemonUrl: 'http://127.0.0.1:1234',
|
||||
daemonToken: 'daemon-token',
|
||||
logger: createLoggerMock(),
|
||||
artifact: { id: 'artifact-id', artifactType: 'screenshot', filename: 'shot.png' },
|
||||
});
|
||||
|
||||
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls[0][1]).not.toHaveProperty(
|
||||
'metadata'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(pollAgentDeviceArtifactsForUploadAsync, () => {
|
||||
@@ -255,8 +345,8 @@ describe(pollAgentDeviceArtifactsForUploadAsync, () => {
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockRejectedValueOnce(new Error('upload failed'))
|
||||
.mockImplementationOnce(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import turtleSpawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { EventEmitter, once } from 'node:events';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { killProcessGroup } from '../../../utils/processes';
|
||||
import { runAgentAsync } from '../agentRun';
|
||||
import {
|
||||
type AgentRunProviderCredentials,
|
||||
leaseAgentRunProviderCredentialsAsync,
|
||||
} from '../agentRunLease';
|
||||
import { CLAUDE_CODE_CLI } from '../agents/claude';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('node:child_process', () => ({
|
||||
...jest.requireActual('node:child_process'),
|
||||
spawn: jest.fn(),
|
||||
}));
|
||||
jest.mock('../agentRunLease');
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../utils/processes', () => ({
|
||||
...jest.requireActual('../../../utils/processes'),
|
||||
killProcessGroup: jest.fn(),
|
||||
}));
|
||||
|
||||
const installMock = jest.mocked(turtleSpawn);
|
||||
const spawnMock = jest.mocked(spawn);
|
||||
const leaseMock = jest.mocked(leaseAgentRunProviderCredentialsAsync);
|
||||
const killProcessGroupMock = jest.mocked(killProcessGroup);
|
||||
|
||||
const agentRunId = '0199b0a0-1111-7222-8333-444455556666';
|
||||
const anthropicLease: AgentRunProviderCredentials = {
|
||||
provider: 'anthropic',
|
||||
accessToken: 'anthropic-access-token',
|
||||
};
|
||||
const openaiLease: AgentRunProviderCredentials = {
|
||||
provider: 'openai',
|
||||
idToken: 'openai-id-token',
|
||||
accessToken: 'openai-access-token',
|
||||
accountId: 'openai-account-id',
|
||||
};
|
||||
|
||||
interface AgentInvocation {
|
||||
command: string;
|
||||
args: string[];
|
||||
env: Record<string, string>;
|
||||
cwd: string;
|
||||
stdin: jest.Mock;
|
||||
child: EventEmitter & { stdout: PassThrough; stderr: PassThrough };
|
||||
/** The run removes its files when it ends, so call this while the agent is running. */
|
||||
readHomeFile(relativePath: string): { content: string; mode: number };
|
||||
exit(status: number | null, signal?: NodeJS.Signals, options?: { closeOutput: boolean }): void;
|
||||
closeOutput(): void;
|
||||
}
|
||||
|
||||
function mockSpawn({
|
||||
onAgent,
|
||||
onInstall,
|
||||
}: {
|
||||
onAgent: (invocation: AgentInvocation) => void;
|
||||
onInstall?: () => void;
|
||||
}): AgentInvocation[] {
|
||||
installMock.mockImplementation((() => {
|
||||
try {
|
||||
onInstall?.();
|
||||
return Promise.resolve({});
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}) as any);
|
||||
const invocations: AgentInvocation[] = [];
|
||||
spawnMock.mockImplementation(((command: string, args: string[], spawnOptions: any) => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
pid: 4242,
|
||||
stdin: { on: jest.fn(), end: jest.fn() },
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
});
|
||||
// Like a real child: `close` comes after everything it printed has been read.
|
||||
const closeOutput = (): void => {
|
||||
child.stdout.end();
|
||||
child.stderr.end();
|
||||
void Promise.all([once(child.stdout, 'end'), once(child.stderr, 'end')]).then(() =>
|
||||
child.emit('close')
|
||||
);
|
||||
};
|
||||
const invocation: AgentInvocation = {
|
||||
command,
|
||||
args,
|
||||
env: spawnOptions.env,
|
||||
cwd: spawnOptions.cwd,
|
||||
stdin: child.stdin.end,
|
||||
child,
|
||||
readHomeFile(relativePath) {
|
||||
const filePath = path.join(spawnOptions.env.HOME, relativePath);
|
||||
return {
|
||||
content: vol.readFileSync(filePath, 'utf8') as string,
|
||||
mode: vol.statSync(filePath).mode & 0o777,
|
||||
};
|
||||
},
|
||||
exit(status, signal, options = { closeOutput: true }) {
|
||||
process.nextTick(() => {
|
||||
child.emit('exit', status, signal ?? null);
|
||||
if (options.closeOutput) {
|
||||
closeOutput();
|
||||
}
|
||||
});
|
||||
},
|
||||
closeOutput,
|
||||
};
|
||||
invocations.push(invocation);
|
||||
onAgent(invocation);
|
||||
return child;
|
||||
}) as any);
|
||||
return invocations;
|
||||
}
|
||||
|
||||
function runDirectories(): string[] {
|
||||
return (vol.readdirSync(os.tmpdir()) as string[]).filter(name =>
|
||||
name.startsWith('eas-agent-run-')
|
||||
);
|
||||
}
|
||||
|
||||
function createOptions(
|
||||
overrides: Partial<Parameters<typeof runAgentAsync>[0]> = {}
|
||||
): Parameters<typeof runAgentAsync>[0] {
|
||||
return {
|
||||
agentKind: 'claude-code',
|
||||
agentRunId,
|
||||
prompt: 'Fix the failing test.',
|
||||
expoToken: 'expo-token',
|
||||
expoApiV2BaseUrl: 'https://staging-api.expo.test/v2/',
|
||||
mcpUrl: 'https://staging-mcp.expo.test/mcp',
|
||||
pathEnv: '/usr/local/bin:/usr/bin',
|
||||
npmCacheUrl: 'https://npm-cache.expo.test',
|
||||
logger: createMockLogger(),
|
||||
signal: new AbortController().signal,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe(runAgentAsync, () => {
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
expect(runDirectories()).toEqual([]);
|
||||
});
|
||||
|
||||
it('runs Claude Code with MCP as its only tool source and an allowlisted environment', async () => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const invocations = mockSpawn({
|
||||
onAgent: agent => {
|
||||
const mcpConfig = agent.readHomeFile('mcp.json');
|
||||
expect(JSON.parse(mcpConfig.content)).toEqual({
|
||||
mcpServers: {
|
||||
expo: {
|
||||
type: 'http',
|
||||
url: 'https://staging-mcp.expo.test/mcp',
|
||||
headers: {
|
||||
Authorization: 'Bearer expo-token',
|
||||
'X-Expo-Agent-Run-Id': agentRunId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mcpConfig.mode).toBe(0o600);
|
||||
agent.exit(0);
|
||||
},
|
||||
});
|
||||
|
||||
await runAgentAsync(createOptions());
|
||||
|
||||
expect(installMock.mock.calls[0].slice(0, 2)).toEqual([
|
||||
'npm',
|
||||
[
|
||||
'install',
|
||||
'--prefix',
|
||||
expect.stringMatching(/eas-agent-run-.*\/cli$/),
|
||||
'--no-save',
|
||||
'--package-lock=false',
|
||||
'--no-audit',
|
||||
'--no-fund',
|
||||
'--registry',
|
||||
'https://npm-cache.expo.test',
|
||||
'@anthropic-ai/[email protected]',
|
||||
],
|
||||
]);
|
||||
expect(invocations).toHaveLength(1);
|
||||
const [agent] = invocations;
|
||||
const runDirectory = path.dirname(agent.env.HOME);
|
||||
expect(path.dirname(runDirectory)).toBe(os.tmpdir());
|
||||
expect(agent.command).toBe(path.join(runDirectory, 'cli', 'node_modules', '.bin', 'claude'));
|
||||
expect(agent.args).toEqual([
|
||||
'-p',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--tools',
|
||||
'',
|
||||
'--allowedTools',
|
||||
'mcp__expo',
|
||||
'--mcp-config',
|
||||
path.join(agent.env.HOME, 'mcp.json'),
|
||||
'--strict-mcp-config',
|
||||
]);
|
||||
expect(agent.env).toEqual({
|
||||
PATH: '/usr/local/bin:/usr/bin',
|
||||
HOME: path.join(runDirectory, 'home'),
|
||||
TMPDIR: path.join(runDirectory, 'tmp'),
|
||||
LANG: 'C.UTF-8',
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'anthropic-access-token',
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
||||
});
|
||||
expect(agent.cwd).toBe(path.join(runDirectory, 'cwd'));
|
||||
expect(agent.stdin).toHaveBeenCalledWith('Fix the failing test.');
|
||||
});
|
||||
|
||||
it('writes the agent output as log lines with the leased credentials masked', async () => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const logger = createMockLogger();
|
||||
mockSpawn({
|
||||
onAgent: agent => {
|
||||
// One JSON event split across two chunks, in the middle of the token.
|
||||
agent.child.stdout.write(
|
||||
'{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"token: anthropic-acc'
|
||||
);
|
||||
agent.child.stdout.write('ess-token"}]}}\n');
|
||||
agent.child.stdout.write(
|
||||
'{"type":"assistant","message":{"content":[{"type":"text","text":"Done.\\nThe token was anthropic-access-token.\\n"}]}}\n'
|
||||
);
|
||||
agent.child.stdout.write(
|
||||
'{"type":"result","subtype":"error_during_execution","is_error":true,"errors":["boom"]}\n'
|
||||
);
|
||||
agent.child.stderr.write('warning: anthropic-access-token\n');
|
||||
agent.exit(0);
|
||||
},
|
||||
});
|
||||
|
||||
await runAgentAsync(createOptions({ logger }));
|
||||
|
||||
const child = jest.mocked(logger.child);
|
||||
expect(child.mock.calls).toEqual([[{ source: 'stdout' }], [{ source: 'stderr' }]]);
|
||||
const [stdoutLogger, stderrLogger] = child.mock.results.map(result => result.value);
|
||||
expect(stdoutLogger.info.mock.calls).toEqual([
|
||||
['Tool result: token: [redacted]'],
|
||||
['Agent: Done.\nThe token was [redacted].'],
|
||||
]);
|
||||
expect(stdoutLogger.error.mock.calls).toEqual([['Agent failed: boom']]);
|
||||
expect(stderrLogger.info.mock.calls).toEqual([['warning: [redacted]']]);
|
||||
});
|
||||
|
||||
it('reports a line the formatter cannot handle instead of ending the run', async () => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const logger = createMockLogger();
|
||||
const formatOutputLine = jest
|
||||
.spyOn(CLAUDE_CODE_CLI, 'formatOutputLine')
|
||||
.mockImplementationOnce(() => {
|
||||
throw new RangeError('Maximum call stack size exceeded');
|
||||
});
|
||||
mockSpawn({
|
||||
onAgent: agent => {
|
||||
agent.child.stdout.write(
|
||||
'{"type":"assistant","message":{"content":[{"type":"tool_use","name":"t","input":{}}]}}\n'
|
||||
);
|
||||
agent.child.stdout.write(
|
||||
'{"type":"assistant","message":{"content":[{"type":"text","text":"Still here."}]}}\n'
|
||||
);
|
||||
agent.exit(0);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await runAgentAsync(createOptions({ logger }));
|
||||
} finally {
|
||||
formatOutputLine.mockRestore();
|
||||
}
|
||||
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not format a line of the agent output',
|
||||
expect.any(RangeError)
|
||||
);
|
||||
const [stdoutLogger] = jest.mocked(logger.child).mock.results.map(result => result.value);
|
||||
expect(stdoutLogger.info.mock.calls).toEqual([['Agent: Still here.']]);
|
||||
});
|
||||
|
||||
it('runs Codex with its shell tool off and the credentials in a private home', async () => {
|
||||
leaseMock.mockResolvedValue(openaiLease);
|
||||
const invocations = mockSpawn({
|
||||
onAgent: agent => {
|
||||
const config = agent.readHomeFile('.codex/config.toml');
|
||||
expect(config.content).toContain(
|
||||
'[features]\nshell_tool = false\nview_image = false\nimage_generation = false\napps = false\nplugins = false\n'
|
||||
);
|
||||
expect(config.content).toContain('sandbox_mode = "read-only"');
|
||||
expect(config.content).toContain('[agents]\nenabled = false\n');
|
||||
expect(config.content).toContain(
|
||||
[
|
||||
'[mcp_servers.expo]',
|
||||
'url = "http://localhost:8787/mcp"',
|
||||
'default_tools_approval_mode = "approve"',
|
||||
'',
|
||||
'[mcp_servers.expo.http_headers]',
|
||||
'"Authorization" = "Bearer expo-token"',
|
||||
`"X-Expo-Agent-Run-Id" = "${agentRunId}"`,
|
||||
].join('\n')
|
||||
);
|
||||
expect(config.mode).toBe(0o600);
|
||||
const auth = agent.readHomeFile('.codex/auth.json');
|
||||
expect(JSON.parse(auth.content)).toEqual({
|
||||
auth_mode: 'chatgptAuthTokens',
|
||||
tokens: {
|
||||
id_token: 'openai-id-token',
|
||||
access_token: 'openai-access-token',
|
||||
refresh_token: '',
|
||||
account_id: 'openai-account-id',
|
||||
},
|
||||
last_refresh: expect.any(String),
|
||||
});
|
||||
expect(auth.mode).toBe(0o600);
|
||||
agent.exit(0);
|
||||
},
|
||||
});
|
||||
|
||||
await runAgentAsync(
|
||||
createOptions({
|
||||
agentKind: 'codex',
|
||||
mcpUrl: 'http://localhost:8787/mcp',
|
||||
npmCacheUrl: undefined,
|
||||
})
|
||||
);
|
||||
|
||||
const npmArgs = installMock.mock.calls[0][1]!;
|
||||
expect(npmArgs).not.toContain('--registry');
|
||||
expect(npmArgs.at(-1)).toBe('@openai/[email protected]');
|
||||
const [agent] = invocations;
|
||||
const runDirectory = path.dirname(agent.env.HOME);
|
||||
expect(agent.command).toBe(path.join(runDirectory, 'cli', 'node_modules', '.bin', 'codex'));
|
||||
expect(agent.args).toEqual(['exec', '--strict-config', '--json', '--skip-git-repo-check']);
|
||||
expect(agent.cwd).toBe(path.join(runDirectory, 'cwd'));
|
||||
expect(agent.env).toEqual({
|
||||
PATH: '/usr/local/bin:/usr/bin',
|
||||
HOME: path.join(runDirectory, 'home'),
|
||||
TMPDIR: path.join(runDirectory, 'tmp'),
|
||||
LANG: 'C.UTF-8',
|
||||
});
|
||||
expect(agent.stdin).toHaveBeenCalledWith('Fix the failing test.');
|
||||
});
|
||||
|
||||
it('escalates to SIGKILL when the agent ignores the stop', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const controller = new AbortController();
|
||||
let agentStarted!: () => void;
|
||||
const started = new Promise<void>(resolve => {
|
||||
agentStarted = resolve;
|
||||
});
|
||||
const invocations = mockSpawn({ onAgent: () => agentStarted() });
|
||||
|
||||
const run = runAgentAsync(createOptions({ signal: controller.signal }));
|
||||
const rejection = expect(run).rejects.toMatchObject({ name: 'AbortError' });
|
||||
await started;
|
||||
const [agent] = invocations;
|
||||
|
||||
controller.abort();
|
||||
expect(killProcessGroupMock.mock.calls).toEqual([[agent.child, 'SIGTERM']]);
|
||||
await jest.advanceTimersByTimeAsync(10_000);
|
||||
expect(killProcessGroupMock.mock.calls).toEqual([
|
||||
[agent.child, 'SIGTERM'],
|
||||
[agent.child, 'SIGKILL'],
|
||||
]);
|
||||
agent.exit(null, 'SIGKILL');
|
||||
|
||||
await rejection;
|
||||
expect(leaseMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ends what the agent left running and waits a bounded time for its output', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
let agentStarted!: () => void;
|
||||
const started = new Promise<void>(resolve => {
|
||||
agentStarted = resolve;
|
||||
});
|
||||
const invocations = mockSpawn({ onAgent: () => agentStarted() });
|
||||
|
||||
const run = runAgentAsync(createOptions());
|
||||
await started;
|
||||
invocations[0].exit(0, undefined, { closeOutput: false });
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
await expect(run).resolves.toBeUndefined();
|
||||
expect(killProcessGroupMock.mock.calls).toEqual([[invocations[0].child, 'SIGKILL']]);
|
||||
});
|
||||
|
||||
it('stops the agent when the step is aborted and fails with the abort reason', async () => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('the step was canceled');
|
||||
const invocations = mockSpawn({
|
||||
onAgent: agent => {
|
||||
queueMicrotask(() => {
|
||||
controller.abort(reason);
|
||||
agent.exit(null, 'SIGTERM');
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await expect(runAgentAsync(createOptions({ signal: controller.signal }))).rejects.toBe(reason);
|
||||
|
||||
expect(killProcessGroupMock.mock.calls).toEqual([
|
||||
[invocations[0].child, 'SIGTERM'],
|
||||
[invocations[0].child, 'SIGKILL'],
|
||||
]);
|
||||
expect(leaseMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resumes Claude Code with new credentials when the lease changed during the run', async () => {
|
||||
const renewedLease: AgentRunProviderCredentials = {
|
||||
...anthropicLease,
|
||||
accessToken: 'renewed-access-token',
|
||||
};
|
||||
leaseMock.mockResolvedValueOnce(anthropicLease).mockResolvedValueOnce(renewedLease);
|
||||
const logger = createMockLogger();
|
||||
const invocations = mockSpawn({
|
||||
onAgent: agent => {
|
||||
if (invocations.length === 2) {
|
||||
agent.child.stderr.write('tokens: anthropic-access-token renewed-access-token\n');
|
||||
}
|
||||
agent.exit(invocations.length === 1 ? 1 : 0);
|
||||
},
|
||||
});
|
||||
|
||||
await runAgentAsync(createOptions({ logger }));
|
||||
|
||||
expect(installMock).toHaveBeenCalledTimes(1);
|
||||
expect(invocations).toHaveLength(2);
|
||||
const [first, resumed] = invocations;
|
||||
expect(resumed.args).toEqual(['-p', '--continue', ...first.args.slice(1)]);
|
||||
expect(resumed.env.CLAUDE_CODE_OAUTH_TOKEN).toBe('renewed-access-token');
|
||||
expect(resumed.stdin).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^Your previous run of this task stopped.*\n\nFix the failing test\.$/s)
|
||||
);
|
||||
const child = jest.mocked(logger.child);
|
||||
expect(child.mock.calls).toEqual([
|
||||
[{ source: 'stdout' }],
|
||||
[{ source: 'stderr' }],
|
||||
[{ source: 'stdout' }],
|
||||
[{ source: 'stderr' }],
|
||||
]);
|
||||
const [, , , resumedStderrLogger] = child.mock.results.map(result => result.value);
|
||||
expect(resumedStderrLogger.info.mock.calls).toEqual([['tokens: [redacted] [redacted]']]);
|
||||
});
|
||||
|
||||
it('resumes Codex with new credentials when the lease changed during the run', async () => {
|
||||
const renewedLease: AgentRunProviderCredentials = {
|
||||
...openaiLease,
|
||||
accessToken: 'renewed-access-token',
|
||||
idToken: 'renewed-id-token',
|
||||
};
|
||||
leaseMock.mockResolvedValueOnce(openaiLease).mockResolvedValueOnce(renewedLease);
|
||||
const invocations = mockSpawn({
|
||||
onAgent: agent => {
|
||||
if (invocations.length === 1) {
|
||||
agent.exit(1);
|
||||
return;
|
||||
}
|
||||
expect(JSON.parse(agent.readHomeFile('.codex/auth.json').content).tokens).toMatchObject({
|
||||
id_token: 'renewed-id-token',
|
||||
access_token: 'renewed-access-token',
|
||||
});
|
||||
agent.exit(0);
|
||||
},
|
||||
});
|
||||
|
||||
await runAgentAsync(createOptions({ agentKind: 'codex' }));
|
||||
|
||||
expect(invocations).toHaveLength(2);
|
||||
expect(invocations[1].args).toEqual([
|
||||
'exec',
|
||||
'--strict-config',
|
||||
'--json',
|
||||
'--skip-git-repo-check',
|
||||
'resume',
|
||||
'--last',
|
||||
'-',
|
||||
]);
|
||||
expect(invocations[1].stdin).toHaveBeenCalledWith(
|
||||
expect.stringContaining('\n\nFix the failing test.')
|
||||
);
|
||||
expect(invocations[1].cwd).toBe(invocations[0].cwd);
|
||||
});
|
||||
|
||||
it('does not resume an agent when the step was aborted while its credentials were renewed', async () => {
|
||||
const controller = new AbortController();
|
||||
leaseMock.mockResolvedValueOnce(anthropicLease).mockImplementationOnce(async () => {
|
||||
controller.abort();
|
||||
return { ...anthropicLease, accessToken: 'renewed-access-token' };
|
||||
});
|
||||
const invocations = mockSpawn({ onAgent: agent => agent.exit(1) });
|
||||
|
||||
await expect(runAgentAsync(createOptions({ signal: controller.signal }))).rejects.toMatchObject(
|
||||
{ name: 'AbortError' }
|
||||
);
|
||||
|
||||
expect(invocations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[2, undefined, 'exited with code 2'],
|
||||
[null, 'SIGKILL' as const, 'was terminated by SIGKILL'],
|
||||
])(
|
||||
'fails when the agent fails and the lease is unchanged (status %p, signal %p)',
|
||||
async (status, signal, description) => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const invocations = mockSpawn({ onAgent: agent => agent.exit(status, signal) });
|
||||
|
||||
await expect(runAgentAsync(createOptions())).rejects.toMatchObject({
|
||||
errorCode: 'EAS_RUN_AGENT_FAILED',
|
||||
message: expect.stringContaining(description),
|
||||
});
|
||||
|
||||
expect(invocations).toHaveLength(1);
|
||||
expect(leaseMock).toHaveBeenCalledTimes(2);
|
||||
}
|
||||
);
|
||||
|
||||
it('fails with the lease error when the lease cannot be renewed after the agent failed', async () => {
|
||||
leaseMock
|
||||
.mockResolvedValueOnce(anthropicLease)
|
||||
.mockRejectedValueOnce(new Error('lease refused'));
|
||||
const invocations = mockSpawn({ onAgent: agent => agent.exit(1) });
|
||||
|
||||
await expect(runAgentAsync(createOptions())).rejects.toThrow('lease refused');
|
||||
|
||||
expect(invocations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports an agent CLI that cannot start', async () => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
mockSpawn({
|
||||
onAgent: agent => {
|
||||
process.nextTick(() => agent.child.emit('error', new Error('spawn claude ENOENT')));
|
||||
},
|
||||
});
|
||||
|
||||
await expect(runAgentAsync(createOptions())).rejects.toThrow(
|
||||
'Could not start the agent CLI on the worker: spawn claude ENOENT.'
|
||||
);
|
||||
|
||||
expect(leaseMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cleans up when the installation fails', async () => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const invocations = mockSpawn({
|
||||
onInstall: () => {
|
||||
throw new Error('npm ERR! network');
|
||||
},
|
||||
onAgent: agent => agent.exit(0),
|
||||
});
|
||||
|
||||
await expect(runAgentAsync(createOptions())).rejects.toBeInstanceOf(SystemError);
|
||||
|
||||
expect(invocations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not install or start anything when the lease is refused', async () => {
|
||||
leaseMock.mockRejectedValue(new Error('lease refused'));
|
||||
mockSpawn({ onAgent: agent => agent.exit(0) });
|
||||
|
||||
await expect(runAgentAsync(createOptions())).rejects.toThrow('lease refused');
|
||||
|
||||
expect(installMock).not.toHaveBeenCalled();
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses credentials of the wrong provider', async () => {
|
||||
leaseMock.mockResolvedValue(openaiLease);
|
||||
const invocations = mockSpawn({ onAgent: agent => agent.exit(0) });
|
||||
|
||||
await expect(runAgentAsync(createOptions({ agentKind: 'claude-code' }))).rejects.toBeInstanceOf(
|
||||
SystemError
|
||||
);
|
||||
|
||||
expect(invocations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import fetch, { type Response } from 'node-fetch';
|
||||
|
||||
import { leaseAgentRunProviderCredentialsAsync } from '../agentRunLease';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const agentRunId = '0199b0a0-1111-7222-8333-444455556666';
|
||||
const options = {
|
||||
expoApiV2BaseUrl: 'https://staging-api.expo.test/v2/',
|
||||
expoToken: 'expo-token',
|
||||
agentRunId,
|
||||
};
|
||||
|
||||
function createResponse(status: number, body: unknown): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => {
|
||||
if (body === undefined) {
|
||||
throw new Error('not JSON');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe(leaseAgentRunProviderCredentialsAsync, () => {
|
||||
it('leases Anthropic credentials with the job token', async () => {
|
||||
const lease = { provider: 'anthropic', accessToken: 'access-token' };
|
||||
fetchMock.mockResolvedValue(
|
||||
createResponse(200, { data: { ...lease, expiresAt: '2026-10-06T12:00:00.000Z' } })
|
||||
);
|
||||
|
||||
await expect(leaseAgentRunProviderCredentialsAsync(options)).resolves.toEqual(lease);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe(`https://staging-api.expo.test/v2/agent-runs/${agentRunId}/auth-lease`);
|
||||
expect(init).toMatchObject({
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer expo-token' },
|
||||
});
|
||||
});
|
||||
|
||||
it('leases OpenAI credentials', async () => {
|
||||
const lease = {
|
||||
provider: 'openai',
|
||||
idToken: 'id-token',
|
||||
accessToken: 'access-token',
|
||||
accountId: 'account-id',
|
||||
};
|
||||
fetchMock.mockResolvedValue(createResponse(200, { data: lease }));
|
||||
|
||||
await expect(leaseAgentRunProviderCredentialsAsync(options)).resolves.toEqual(lease);
|
||||
});
|
||||
|
||||
it('reports a 4xx with the message from the server and does not retry', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
createResponse(400, {
|
||||
errors: [
|
||||
{
|
||||
code: 'AGENT_RUN_PROVIDER_CONNECTION_DELETED',
|
||||
message: 'The provider connection for this agent run was deleted.',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const lease = leaseAgentRunProviderCredentialsAsync(options);
|
||||
|
||||
await expect(lease).rejects.toBeInstanceOf(UserError);
|
||||
await expect(lease).rejects.toMatchObject({
|
||||
errorCode: 'EAS_RUN_AGENT_CREDENTIALS_UNAVAILABLE',
|
||||
message: 'The provider connection for this agent run was deleted.',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports a 4xx without a readable body', async () => {
|
||||
fetchMock.mockResolvedValue(createResponse(401, undefined));
|
||||
|
||||
await expect(leaseAgentRunProviderCredentialsAsync(options)).rejects.toThrow(
|
||||
'Expo refused to issue provider credentials for this agent run (HTTP 401).'
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries server errors and network failures', async () => {
|
||||
jest.useFakeTimers();
|
||||
const lease = { provider: 'anthropic', accessToken: 'access-token' };
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponse(503, undefined))
|
||||
.mockRejectedValueOnce(new Error('socket hang up'))
|
||||
.mockResolvedValueOnce(createResponse(200, { data: lease }));
|
||||
|
||||
const assertion = expect(leaseAgentRunProviderCredentialsAsync(options)).resolves.toEqual(
|
||||
lease
|
||||
);
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('retries when the body of a successful response cannot be read', async () => {
|
||||
jest.useFakeTimers();
|
||||
const lease = { provider: 'anthropic', accessToken: 'access-token' };
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponse(200, undefined))
|
||||
.mockResolvedValueOnce(createResponse(200, { data: lease }));
|
||||
|
||||
const assertion = expect(leaseAgentRunProviderCredentialsAsync(options)).resolves.toEqual(
|
||||
lease
|
||||
);
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('gives up after three failed attempts', async () => {
|
||||
jest.useFakeTimers();
|
||||
fetchMock.mockResolvedValue(createResponse(500, undefined));
|
||||
|
||||
const assertion = expect(leaseAgentRunProviderCredentialsAsync(options)).rejects.toBeInstanceOf(
|
||||
SystemError
|
||||
);
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('rejects a successful response that is not a lease', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
createResponse(200, { data: { provider: 'anthropic', refreshToken: 'nope' } })
|
||||
);
|
||||
|
||||
await expect(leaseAgentRunProviderCredentialsAsync(options)).rejects.toBeInstanceOf(
|
||||
SystemError
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -124,8 +124,8 @@ describe(uploadArgentArtifactAsync, () => {
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
await uploadArgentArtifactAsync(ctx, {
|
||||
@@ -151,7 +151,7 @@ describe(uploadArgentArtifactAsync, () => {
|
||||
filename: 'report.json',
|
||||
kind: undefined,
|
||||
size: data.length,
|
||||
stream: expect.anything(),
|
||||
openStream: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,8 +168,8 @@ describe(uploadArgentArtifactAsync, () => {
|
||||
.mockResolvedValueOnce(new Response(Readable.from([Buffer.from('artifact-data')])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
await uploadArgentArtifactAsync(ctx, {
|
||||
@@ -345,8 +345,8 @@ describe(pollArgentArtifactsForUploadAsync, () => {
|
||||
});
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementation(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
.mockImplementation(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
const pollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
|
||||
@@ -371,7 +371,7 @@ describe(pollArgentArtifactsForUploadAsync, () => {
|
||||
name: 'b.json (artifact-b)',
|
||||
filename: 'b.json',
|
||||
size: 'artifact-b-data'.length,
|
||||
stream: expect.anything(),
|
||||
openStream: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
|
||||
kind: 'agent-device-test-report',
|
||||
metadata: { firstFrameRecordAt: 'test-time' },
|
||||
size: reportedSize,
|
||||
stream,
|
||||
openStream: () => stream,
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
@@ -103,7 +103,7 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
|
||||
filename: 'logs.ndjson',
|
||||
kind: 'simulator-log',
|
||||
size: 0,
|
||||
stream: Readable.from([]),
|
||||
openStream: () => Readable.from([]),
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
import { readFile, readdir, writeFile } from 'node:fs/promises';
|
||||
import { Readable } from 'node:stream';
|
||||
import os from 'node:os';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import {
|
||||
captureDeviceRunSessionPreviewAsync,
|
||||
ensureMacosPreviewEncoderInstalledAsync,
|
||||
startDeviceRunSessionPreview,
|
||||
} from '../deviceRunSessionPreview';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockReset()
|
||||
.mockResolvedValue(new Response('', { status: 200 }));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await preview?.stopAsync();
|
||||
preview = undefined;
|
||||
jest.useRealTimers();
|
||||
});
|
||||
const start = () => {
|
||||
preview = startDeviceRunSessionPreview({
|
||||
ctx,
|
||||
deviceRunSessionId: 'session-id',
|
||||
captureAsync,
|
||||
logger,
|
||||
});
|
||||
return preview;
|
||||
};
|
||||
|
||||
it('uploads immediately and reuses the URL every 60 seconds', async () => {
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://uploads.expo.test/preview',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: image,
|
||||
headers: { 'content-type': 'image/webp' },
|
||||
})
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(59_999);
|
||||
expect(captureAsync).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(captureAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
await preview!.stopAsync();
|
||||
await jest.advanceTimersByTimeAsync(120_000);
|
||||
expect(captureAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('retries capture errors without creating an empty artifact', async () => {
|
||||
captureAsync.mockRejectedValueOnce(new Error('device not ready'));
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries a failed upload with the same URL', async () => {
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 403 }));
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not log the signed upload URL when the upload request fails', async () => {
|
||||
const { FetchError } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValueOnce(
|
||||
new FetchError('network timeout at: https://uploads.expo.test/preview', 'request-timeout')
|
||||
);
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
const [fields] = jest.mocked(logger.warn).mock.calls[0] as unknown as [{ err: Error }];
|
||||
expect(fields.err.message).toBe(
|
||||
'Session preview upload request failed (name: FetchError, type: request-timeout).'
|
||||
);
|
||||
expect(JSON.stringify(jest.mocked(logger.warn).mock.calls)).not.toContain('uploads.expo.test');
|
||||
});
|
||||
|
||||
it('reuses the upload URL for the full session', async () => {
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(119 * 60_000);
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not overlap captures and aborts pending work when stopped', async () => {
|
||||
captureAsync.mockImplementation(
|
||||
signal =>
|
||||
new Promise((_, reject) => {
|
||||
signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
|
||||
})
|
||||
);
|
||||
const handle = start();
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(captureAsync).toHaveBeenCalledTimes(1);
|
||||
await handle.stopAsync();
|
||||
expect(captureAsync.mock.calls[0][0].aborted).toBe(true);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not upload a capture that finishes after shutdown', async () => {
|
||||
let finish!: (image: Buffer) => void;
|
||||
captureAsync.mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finish = resolve;
|
||||
})
|
||||
);
|
||||
const handle = start();
|
||||
const stopping = handle.stopAsync();
|
||||
finish(image);
|
||||
await stopping;
|
||||
expect(mutation).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;
|
||||
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' } }
|
||||
)
|
||||
);
|
||||
logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockReset()
|
||||
.mockResolvedValue(new Response('', { status: 200 }));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await preview?.stopAsync();
|
||||
preview = undefined;
|
||||
jest.restoreAllMocks();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const start = () => {
|
||||
const graphqlClient = new Client({
|
||||
url: 'https://api.expo.test/graphql',
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: { headers: { Authorization: 'Bearer worker-token' } },
|
||||
});
|
||||
preview = startDeviceRunSessionPreview({
|
||||
ctx: { graphqlClient } as CustomBuildContext,
|
||||
deviceRunSessionId: 'session-id',
|
||||
captureAsync: async () => Buffer.from('webp-image'),
|
||||
logger,
|
||||
});
|
||||
return preview;
|
||||
};
|
||||
|
||||
it('preserves the worker authentication 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(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(['shutdown', 'timeout'])('aborts a stalled GraphQL request on %s', async reason => {
|
||||
const timeout = new AbortController();
|
||||
jest.spyOn(AbortSignal, 'timeout').mockReturnValue(timeout.signal);
|
||||
let requestSignal: AbortSignal | undefined;
|
||||
let rejectRequest!: (error: Error) => void;
|
||||
graphqlFetch.mockImplementation(
|
||||
(_url, options) =>
|
||||
new Promise((_, reject) => {
|
||||
rejectRequest = reject;
|
||||
requestSignal = options.signal;
|
||||
requestSignal!.addEventListener('abort', () => reject(requestSignal!.reason), {
|
||||
once: true,
|
||||
});
|
||||
})
|
||||
);
|
||||
const handle = start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
let stopped = false;
|
||||
let stopping: Promise<void> | undefined;
|
||||
if (reason === 'shutdown') {
|
||||
stopping = handle.stopAsync().then(() => {
|
||||
stopped = true;
|
||||
});
|
||||
} else {
|
||||
timeout.abort(new Error('preview attempt timed out'));
|
||||
}
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
const aborted = requestSignal!.aborted;
|
||||
const stoppedAfterAbort = stopped;
|
||||
// Always release the fake transport so a regression cannot hang test cleanup.
|
||||
rejectRequest(new Error('test cleanup'));
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(aborted).toBe(true);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
if (stopping) {
|
||||
expect(stoppedAfterAbort).toBe(true);
|
||||
await stopping;
|
||||
} else {
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
graphqlFetch.mockResolvedValue(
|
||||
new globalThis.Response(
|
||||
JSON.stringify({
|
||||
data: { deviceRunSession: { createPreviewUploadSession: { uploadSession } } },
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json' } }
|
||||
)
|
||||
);
|
||||
jest.spyOn(AbortSignal, 'timeout').mockReturnValue(new AbortController().signal);
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(captureDeviceRunSessionPreviewAsync, () => {
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0xff, 0x00]);
|
||||
const webp = Buffer.from('encoded-webp');
|
||||
const resized = Buffer.from('resized-png');
|
||||
it.each([
|
||||
[BuildRuntimePlatform.DARWIN, 'selected-device'],
|
||||
[BuildRuntimePlatform.LINUX, 'selected-device'],
|
||||
[BuildRuntimePlatform.LINUX, 'no-device-id'],
|
||||
] as const)(
|
||||
'captures the selected device on %s and removes temporary files',
|
||||
async (runtimePlatform, device) => {
|
||||
jest.mocked(spawn).mockImplementation((command, args) => {
|
||||
if (command === 'adb' && args[0] === 'devices') {
|
||||
return Promise.resolve({
|
||||
stdout: 'List of devices attached\nselected-device\tdevice\noffline-device\toffline\n',
|
||||
}) as ReturnType<typeof spawn>;
|
||||
}
|
||||
if (command === 'adb') {
|
||||
expect(args).toEqual(['-s', 'selected-device', 'exec-out', 'screencap', '-p']);
|
||||
return Object.assign(Promise.resolve({ stdout: '', stderr: '' }), {
|
||||
child: { stdout: Readable.from([png]) },
|
||||
}) as ReturnType<typeof spawn>;
|
||||
}
|
||||
return (async () => {
|
||||
if (command === 'xcrun') {
|
||||
expect(args.slice(0, 4)).toEqual(['simctl', 'io', 'selected-device', 'screenshot']);
|
||||
await writeFile(args[4], Uint8Array.from(png));
|
||||
} else if (command === 'sips') {
|
||||
expect(args.slice(0, 2)).toEqual(['-Z', '320']);
|
||||
expect(await readFile(args[2])).toEqual(png);
|
||||
await writeFile(args[args.indexOf('--out') + 1], Uint8Array.from(resized));
|
||||
} else if (command === 'cwebp') {
|
||||
expect(await readFile(args[args.indexOf('-o') - 1])).toEqual(resized);
|
||||
await writeFile(args[args.indexOf('-o') + 1], Uint8Array.from(webp));
|
||||
} else {
|
||||
// Homebrew's FFmpeg cannot encode WebP, so macOS must not use it.
|
||||
expect(runtimePlatform).toBe(BuildRuntimePlatform.LINUX);
|
||||
expect(command).toBe('ffmpeg');
|
||||
expect(await readFile(args[args.indexOf('-i') + 1])).toEqual(png);
|
||||
expect(args).toContain('libwebp');
|
||||
await writeFile(args[args.length - 1], Uint8Array.from(webp));
|
||||
}
|
||||
return { stdout: '', stderr: '' };
|
||||
})() as ReturnType<typeof spawn>;
|
||||
});
|
||||
expect(
|
||||
await captureDeviceRunSessionPreviewAsync({
|
||||
runtimePlatform,
|
||||
device,
|
||||
env: {},
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
).toEqual(webp);
|
||||
expect(
|
||||
(await readdir(os.tmpdir())).filter(name => name.startsWith('session-preview-'))
|
||||
).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
it('skips ambiguous Android devices instead of capturing the wrong screen', async () => {
|
||||
jest
|
||||
.mocked(spawn)
|
||||
.mockResolvedValue({ stdout: 'device-one\tdevice\ndevice-two\tdevice\n' } as Awaited<
|
||||
ReturnType<typeof spawn>
|
||||
>);
|
||||
await expect(
|
||||
captureDeviceRunSessionPreviewAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
device: 'no-device-id',
|
||||
env: {},
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
).rejects.toThrow('Expected exactly one connected Android device');
|
||||
});
|
||||
|
||||
it('cleans up temporary files when capture fails', async () => {
|
||||
jest.mocked(spawn).mockRejectedValue(new Error('capture failed'));
|
||||
await expect(
|
||||
captureDeviceRunSessionPreviewAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
device: 'device',
|
||||
env: {},
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
).rejects.toThrow('capture failed');
|
||||
expect(
|
||||
(await readdir(os.tmpdir())).filter(name => name.startsWith('session-preview-'))
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(ensureMacosPreviewEncoderInstalledAsync, () => {
|
||||
const logger = { info: jest.fn() } as unknown as bunyan;
|
||||
|
||||
it.each([true, false])(
|
||||
'installs webp only when cwebp is missing (present: %s)',
|
||||
async present => {
|
||||
jest.mocked(spawn).mockReset();
|
||||
jest
|
||||
.mocked(spawn)
|
||||
.mockImplementation(
|
||||
command =>
|
||||
(command === 'cwebp' && !present
|
||||
? Promise.reject(new Error('command not found: cwebp'))
|
||||
: Promise.resolve({ stdout: '', stderr: '' })) as ReturnType<typeof spawn>
|
||||
);
|
||||
await ensureMacosPreviewEncoderInstalledAsync({ env: { HOME: '/Users/expo' }, logger });
|
||||
const brewCalls = jest.mocked(spawn).mock.calls.filter(([command]) => command === 'brew');
|
||||
if (present) {
|
||||
expect(brewCalls).toEqual([]);
|
||||
} else {
|
||||
expect(brewCalls).toEqual([
|
||||
[
|
||||
'brew',
|
||||
['install', 'webp'],
|
||||
{ env: { HOME: '/Users/expo', HOMEBREW_NO_AUTO_UPDATE: '1' }, logger },
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects when webp cannot be installed', async () => {
|
||||
jest.mocked(spawn).mockReset();
|
||||
jest.mocked(spawn).mockRejectedValue(new Error('brew failed'));
|
||||
await expect(ensureMacosPreviewEncoderInstalledAsync({ env: {}, logger })).rejects.toThrow(
|
||||
'brew failed'
|
||||
);
|
||||
});
|
||||
});
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import type { BuildStepEnv } from '@expo/steps';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { copyFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import {
|
||||
findUnlistedDeviceScreenRecordingsAsync,
|
||||
uploadDeviceRunSessionScreenRecordingsAsync,
|
||||
} from '../deviceRunSessionScreenRecordings';
|
||||
|
||||
// Runs with `yarn jest-ffmpeg`, which needs ffmpeg and ffprobe on PATH.
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
let handle: (request: IncomingMessage, response: ServerResponse) => void;
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let ctx: CustomBuildContext;
|
||||
let url: string;
|
||||
let directory: string;
|
||||
let creationBodies: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
creationBodies = [];
|
||||
directory = await mkdtemp(path.join(tmpdir(), 'recording-recovery-'));
|
||||
server = createServer((request, response) => {
|
||||
if (request.url === '/graphql') {
|
||||
request.setEncoding('utf8');
|
||||
let body = '';
|
||||
request.on('data', chunk => (body += chunk));
|
||||
request.on('end', () => {
|
||||
creationBodies.push(body);
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: { url: `${url}/artifact`, headers: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
} else {
|
||||
handle(request, response);
|
||||
}
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Missing test server address');
|
||||
}
|
||||
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;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('recovers unlisted recordings from a killed host and flags the unfinished ones as partial', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const env = process.env as BuildStepEnv;
|
||||
const cut = path.join(directory, 'cut');
|
||||
const empty = path.join(directory, 'empty');
|
||||
const failed = path.join(directory, 'failed');
|
||||
const done = path.join(directory, 'done');
|
||||
await mkdir(cut);
|
||||
await mkdir(empty);
|
||||
await mkdir(failed);
|
||||
await mkdir(done);
|
||||
const manifest = {
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
status: 'recording',
|
||||
recording: 'recording.mp4.partial',
|
||||
firstFrameWallClock: { iso8601: '2026-07-10T10:00:00.000Z' },
|
||||
width: 128,
|
||||
height: 96,
|
||||
};
|
||||
execFileSync('ffmpeg', [
|
||||
'-v',
|
||||
'error',
|
||||
'-f',
|
||||
'lavfi',
|
||||
'-i',
|
||||
'color=c=red:s=128x96:r=1',
|
||||
'-t',
|
||||
'3',
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-g',
|
||||
'1',
|
||||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
'-movflags',
|
||||
'frag_keyframe+empty_moov',
|
||||
'-f',
|
||||
'mp4',
|
||||
path.join(cut, 'recording.mp4.partial'),
|
||||
]);
|
||||
await writeFile(path.join(cut, 'session.json'), JSON.stringify(manifest));
|
||||
await writeFile(path.join(empty, 'recording.mp4.partial'), new Uint8Array(28));
|
||||
await writeFile(path.join(empty, 'session.json'), JSON.stringify(manifest));
|
||||
await copyFile(
|
||||
path.join(cut, 'recording.mp4.partial'),
|
||||
path.join(failed, 'recording.mp4.partial')
|
||||
);
|
||||
await writeFile(
|
||||
path.join(failed, 'session.json'),
|
||||
JSON.stringify({
|
||||
...manifest,
|
||||
status: 'failed',
|
||||
error: 'Recording finalization exceeded 30000 ms.',
|
||||
})
|
||||
);
|
||||
|
||||
await copyFile(path.join(cut, 'recording.mp4.partial'), path.join(done, 'recording.mp4'));
|
||||
await writeFile(
|
||||
path.join(done, 'session.json'),
|
||||
JSON.stringify({ ...manifest, status: 'complete', recording: 'recording.mp4' })
|
||||
);
|
||||
|
||||
const recordings = await findUnlistedDeviceScreenRecordingsAsync({
|
||||
root: directory,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
expect(recordings).toEqual([
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
directory: cut,
|
||||
},
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
directory: done,
|
||||
},
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
directory: failed,
|
||||
},
|
||||
]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('empty/recording.mp4.partial'));
|
||||
|
||||
let putBytes = 0;
|
||||
handle = (request, response) => {
|
||||
request.on('data', chunk => (putBytes += chunk.length));
|
||||
request.on('end', () => {
|
||||
response.statusCode = 200;
|
||||
response.end();
|
||||
});
|
||||
};
|
||||
await expect(
|
||||
uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
recordings,
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
expect(putBytes).toBe(3 * (await stat(path.join(cut, 'recording.mp4.partial'))).size);
|
||||
const inputs = creationBodies
|
||||
.map(body => JSON.parse(body).variables.input)
|
||||
.sort((a, b) => a.filename.localeCompare(b.filename));
|
||||
expect(inputs.map(input => input.filename)).toEqual(['cut.mp4', 'done.mp4', 'failed.mp4']);
|
||||
expect(inputs.map(input => input.name.includes(', partial)'))).toEqual([true, false, true]);
|
||||
expect(inputs[1].metadata).not.toHaveProperty('partial');
|
||||
expect(inputs[0].metadata).toMatchObject({
|
||||
partial: true,
|
||||
partialReason: 'The Device Hub stopped before it could finalize the recording.',
|
||||
width: 128,
|
||||
height: 96,
|
||||
});
|
||||
expect(inputs[2].metadata).toMatchObject({
|
||||
partial: true,
|
||||
partialReason: 'Recording finalization exceeded 30000 ms.',
|
||||
});
|
||||
});
|
||||
@@ -27,18 +27,17 @@ beforeEach(async () => {
|
||||
directory = await mkdtemp(path.join(os.tmpdir(), 'screenshots-test-'));
|
||||
uploaded = [];
|
||||
failedUploads = new Map();
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_ctx, { stream }) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
uploaded.push(Buffer.concat(chunks));
|
||||
});
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementation(async (_ctx, { openStream }) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of openStream()) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
uploaded.push(Buffer.concat(chunks));
|
||||
});
|
||||
});
|
||||
afterEach(async () => {
|
||||
for (const [, { stream }] of jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls) {
|
||||
stream.destroy();
|
||||
}
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
const flush = () =>
|
||||
|
||||
+21
-151
@@ -1,9 +1,8 @@
|
||||
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 { createReadStream } from 'node:fs';
|
||||
import { copyFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
|
||||
import type { Socket } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
@@ -13,10 +12,7 @@ import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import {
|
||||
findUnlistedDeviceScreenRecordingsAsync,
|
||||
uploadDeviceRunSessionScreenRecordingsAsync,
|
||||
} from '../deviceRunSessionScreenRecordings';
|
||||
import { findUnlistedDeviceScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
jest.unmock('node:fs');
|
||||
@@ -47,13 +43,11 @@ let creates: number;
|
||||
let authorization: string | undefined;
|
||||
let stallCreation: boolean;
|
||||
let creationStarted: ReturnType<typeof deferred<IncomingMessage>>;
|
||||
let creationBodies: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
creates = 0;
|
||||
authorization = undefined;
|
||||
stallCreation = false;
|
||||
creationBodies = [];
|
||||
creationStarted = deferred();
|
||||
directory = await mkdtemp(path.join(tmpdir(), 'recording-upload-http-'));
|
||||
server = createServer((request, response) => {
|
||||
@@ -61,10 +55,7 @@ beforeEach(async () => {
|
||||
creates++;
|
||||
authorization = request.headers.authorization;
|
||||
creationStarted.resolve(request);
|
||||
request.setEncoding('utf8');
|
||||
let body = '';
|
||||
request.on('data', chunk => (body += chunk));
|
||||
request.on('end', () => creationBodies.push(body));
|
||||
request.resume();
|
||||
request.on('end', () => {
|
||||
if (stallCreation) {
|
||||
return;
|
||||
@@ -131,8 +122,8 @@ it('replays the full file after a transient PUT failure without recreating the u
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
size: payload.length,
|
||||
stream: open(),
|
||||
reopenStream: open,
|
||||
openStream: open,
|
||||
attempts: 3,
|
||||
});
|
||||
expect(creates).toBe(1);
|
||||
expect(authorization).toBe('Bearer test-auth');
|
||||
@@ -165,7 +156,11 @@ it('keeps a slow PUT alive while its body is still flowing', async () => {
|
||||
onChunk();
|
||||
});
|
||||
const stream = new Readable({ read() {} });
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, { ...metadata, size: 3, stream });
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
size: 3,
|
||||
openStream: () => stream,
|
||||
});
|
||||
const settled = expect(upload).resolves.toBeUndefined();
|
||||
stream.push('a');
|
||||
await waitForBytes(1);
|
||||
@@ -205,8 +200,8 @@ it('retries a PUT that stalled past the idle deadline with a fresh stream', asyn
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
size: 9,
|
||||
stream: open(),
|
||||
reopenStream: open,
|
||||
openStream: open,
|
||||
attempts: 3,
|
||||
});
|
||||
const settled = expect(upload).resolves.toBeUndefined();
|
||||
await firstBodyRead.promise;
|
||||
@@ -231,11 +226,11 @@ it('cancels an active PUT socket and destroys its source without retrying', asyn
|
||||
const controller = new AbortController();
|
||||
const stream = new Readable({ read() {} });
|
||||
stream.push(Buffer.alloc(1024));
|
||||
const reopenStream = jest.fn(() => Readable.from('unexpected retry'));
|
||||
const openStream = jest.fn(() => stream);
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
stream,
|
||||
reopenStream,
|
||||
openStream,
|
||||
attempts: 3,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const rejected = expect(upload).rejects.toThrow('test cancellation');
|
||||
@@ -244,7 +239,7 @@ it('cancels an active PUT socket and destroys its source without retrying', asyn
|
||||
await rejected;
|
||||
await closed.promise;
|
||||
expect(stream.destroyed).toBe(true);
|
||||
expect(reopenStream).not.toHaveBeenCalled();
|
||||
expect(openStream).toHaveBeenCalledTimes(1);
|
||||
expect(puts).toBe(1);
|
||||
});
|
||||
|
||||
@@ -268,8 +263,8 @@ it.each([200, 400, 503])(
|
||||
};
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
stream: Readable.from('recording'),
|
||||
reopenStream: () => Readable.from('recording'),
|
||||
openStream: () => Readable.from('recording'),
|
||||
attempts: 3,
|
||||
});
|
||||
if (status === 200) {
|
||||
await expect(upload).resolves.toBeUndefined();
|
||||
@@ -291,10 +286,10 @@ it('cancels urql session creation with auth intact and never starts a PUT', asyn
|
||||
const puts = jest.fn();
|
||||
handle = puts;
|
||||
const controller = new AbortController();
|
||||
const stream = Readable.from('recording');
|
||||
const openStream = jest.fn(() => Readable.from('recording'));
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
stream,
|
||||
openStream,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const rejected = expect(upload).rejects.toThrow('test cancellation');
|
||||
@@ -306,7 +301,7 @@ it('cancels urql session creation with auth intact and never starts a PUT', asyn
|
||||
expect(authorization).toBe('Bearer test-auth');
|
||||
expect(creates).toBe(1);
|
||||
expect(puts).not.toHaveBeenCalled();
|
||||
expect(stream.destroyed).toBe(true);
|
||||
expect(openStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('says ffprobe is missing instead of calling an unlisted recording undecodable', async () => {
|
||||
@@ -336,128 +331,3 @@ it('says ffprobe is missing instead of calling an unlisted recording undecodable
|
||||
'ffprobe is not available; cannot recover the unlisted recording session/recording.mp4.partial.'
|
||||
);
|
||||
});
|
||||
|
||||
it('recovers unlisted recordings from a killed host and flags the unfinished ones as partial', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const env = process.env as BuildStepEnv;
|
||||
const cut = path.join(directory, 'cut');
|
||||
const empty = path.join(directory, 'empty');
|
||||
const failed = path.join(directory, 'failed');
|
||||
const done = path.join(directory, 'done');
|
||||
await mkdir(cut);
|
||||
await mkdir(empty);
|
||||
await mkdir(failed);
|
||||
await mkdir(done);
|
||||
const manifest = {
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
status: 'recording',
|
||||
recording: 'recording.mp4.partial',
|
||||
firstFrameWallClock: { iso8601: '2026-07-10T10:00:00.000Z' },
|
||||
width: 128,
|
||||
height: 96,
|
||||
};
|
||||
execFileSync('ffmpeg', [
|
||||
'-v',
|
||||
'error',
|
||||
'-f',
|
||||
'lavfi',
|
||||
'-i',
|
||||
'color=c=red:s=128x96:r=1',
|
||||
'-t',
|
||||
'3',
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-g',
|
||||
'1',
|
||||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
'-movflags',
|
||||
'frag_keyframe+empty_moov',
|
||||
'-f',
|
||||
'mp4',
|
||||
path.join(cut, 'recording.mp4.partial'),
|
||||
]);
|
||||
await writeFile(path.join(cut, 'session.json'), JSON.stringify(manifest));
|
||||
await writeFile(path.join(empty, 'recording.mp4.partial'), new Uint8Array(28));
|
||||
await writeFile(path.join(empty, 'session.json'), JSON.stringify(manifest));
|
||||
await copyFile(
|
||||
path.join(cut, 'recording.mp4.partial'),
|
||||
path.join(failed, 'recording.mp4.partial')
|
||||
);
|
||||
await writeFile(
|
||||
path.join(failed, 'session.json'),
|
||||
JSON.stringify({
|
||||
...manifest,
|
||||
status: 'failed',
|
||||
error: 'Recording finalization exceeded 30000 ms.',
|
||||
})
|
||||
);
|
||||
|
||||
await copyFile(path.join(cut, 'recording.mp4.partial'), path.join(done, 'recording.mp4'));
|
||||
await writeFile(
|
||||
path.join(done, 'session.json'),
|
||||
JSON.stringify({ ...manifest, status: 'complete', recording: 'recording.mp4' })
|
||||
);
|
||||
|
||||
const recordings = await findUnlistedDeviceScreenRecordingsAsync({
|
||||
root: directory,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
expect(recordings).toEqual([
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
directory: cut,
|
||||
},
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
directory: done,
|
||||
},
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
directory: failed,
|
||||
},
|
||||
]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('empty/recording.mp4.partial'));
|
||||
|
||||
let putBytes = 0;
|
||||
handle = (request, response) => {
|
||||
request.on('data', chunk => (putBytes += chunk.length));
|
||||
request.on('end', () => {
|
||||
response.statusCode = 200;
|
||||
response.end();
|
||||
});
|
||||
};
|
||||
await expect(
|
||||
uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
recordings,
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
expect(putBytes).toBe(3 * (await stat(path.join(cut, 'recording.mp4.partial'))).size);
|
||||
const inputs = creationBodies
|
||||
.map(body => JSON.parse(body).variables.input)
|
||||
.sort((a, b) => a.filename.localeCompare(b.filename));
|
||||
expect(inputs.map(input => input.filename)).toEqual(['cut.mp4', 'done.mp4', 'failed.mp4']);
|
||||
expect(inputs.map(input => input.name.includes(', partial)'))).toEqual([true, false, true]);
|
||||
expect(inputs[1].metadata).not.toHaveProperty('partial');
|
||||
expect(inputs[0].metadata).toMatchObject({
|
||||
partial: true,
|
||||
partialReason: 'The Device Hub stopped before it could finalize the recording.',
|
||||
width: 128,
|
||||
height: 96,
|
||||
});
|
||||
expect(inputs[2].metadata).toMatchObject({
|
||||
partial: true,
|
||||
partialReason: 'Recording finalization exceeded 30000 ms.',
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,12 @@ import {
|
||||
findUnlistedDeviceScreenRecordingsAsync,
|
||||
uploadDeviceRunSessionScreenRecordingsAsync,
|
||||
} from '../deviceRunSessionScreenRecordings';
|
||||
import {
|
||||
ensureMacosPreviewEncoderInstalledAsync,
|
||||
startDeviceRunSessionPreview,
|
||||
} from '../deviceRunSessionPreview';
|
||||
import { startDeviceSessionHostAsync } from '../deviceSessionHost';
|
||||
import { spawnDetached } from '../remoteDeviceRunSession';
|
||||
import { ensureFfmpegInstalledOnceAsync, spawnDetached } from '../remoteDeviceRunSession';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
jest.mock('../deviceRunSessionArtifacts');
|
||||
@@ -30,6 +34,11 @@ jest.mock('../remoteDeviceRunSession', () => ({
|
||||
fetchWebPreviewTurnArgsAsync: jest.fn().mockResolvedValue([]),
|
||||
spawnDetached: jest.fn(),
|
||||
}));
|
||||
jest.mock('../deviceRunSessionPreview', () => ({
|
||||
...jest.requireActual('../deviceRunSessionPreview'),
|
||||
ensureMacosPreviewEncoderInstalledAsync: jest.fn(),
|
||||
startDeviceRunSessionPreview: jest.fn(),
|
||||
}));
|
||||
jest.mock('../deviceRunSessionScreenRecordings', () => ({
|
||||
...jest.requireActual('../deviceRunSessionScreenRecordings'),
|
||||
findUnlistedDeviceScreenRecordingsAsync: jest.fn(),
|
||||
@@ -64,8 +73,14 @@ async function startHostAsync() {
|
||||
});
|
||||
}
|
||||
|
||||
const stopSessionPreview = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
stopSessionPreview.mockResolvedValue(undefined);
|
||||
jest.mocked(startDeviceRunSessionPreview).mockReturnValue({ stopAsync: stopSessionPreview });
|
||||
jest.mocked(ensureFfmpegInstalledOnceAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(ensureMacosPreviewEncoderInstalledAsync).mockResolvedValue(undefined);
|
||||
stopServer.mockResolvedValue(undefined);
|
||||
closeTunnel.mockResolvedValue(undefined);
|
||||
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockReset().mockResolvedValue(false);
|
||||
@@ -464,9 +479,9 @@ it.each([BuildRuntimePlatform.LINUX, BuildRuntimePlatform.DARWIN])(
|
||||
const uploads: Buffer[] = [];
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementation(async (_ctx, { stream }) => {
|
||||
.mockImplementation(async (_ctx, { openStream }) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
for await (const chunk of openStream()) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
uploads.push(Buffer.concat(chunks));
|
||||
@@ -502,3 +517,60 @@ it.each([BuildRuntimePlatform.LINUX, BuildRuntimePlatform.DARWIN])(
|
||||
await expect(access(directory)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
}
|
||||
);
|
||||
|
||||
it.each([BuildRuntimePlatform.LINUX, BuildRuntimePlatform.DARWIN])(
|
||||
'refreshes the session preview of the ready device until the %s host finishes',
|
||||
async runtimePlatform => {
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(startDeviceRunSessionPreview).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceRunSessionPreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ deviceRunSessionId: 'drs-id', captureAsync: expect.any(Function) })
|
||||
);
|
||||
await host.finishAsync();
|
||||
expect(stopSessionPreview).toHaveBeenCalledTimes(1);
|
||||
expect(stopSessionPreview.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
stopServer.mock.invocationCallOrder[0]
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('does not delay macOS readiness on encoder setup or start the preview after finish', async () => {
|
||||
const install = deferred<void>();
|
||||
jest.mocked(ensureMacosPreviewEncoderInstalledAsync).mockReturnValueOnce(install.promise);
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
await host.finishAsync();
|
||||
install.resolve();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(startDeviceRunSessionPreview).not.toHaveBeenCalled();
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the session running when encoder setup for the preview fails', async () => {
|
||||
jest
|
||||
.mocked(ensureMacosPreviewEncoderInstalledAsync)
|
||||
.mockRejectedValueOnce(new Error('brew failed'));
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(startDeviceRunSessionPreview).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.objectContaining({ message: 'brew failed' }) },
|
||||
'Could not start refreshing the session preview.'
|
||||
);
|
||||
await host.finishAsync();
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
Robin, sparrow, leiothrix and chat
|
||||
Zażółć gęślą jaźń
|
||||
€ 🐦
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
@@ -5,6 +5,7 @@ import spawn from '@expo/turtle-spawn';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
clearTimeout as clearTimeoutCallback,
|
||||
@@ -16,6 +17,11 @@ import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import {
|
||||
IosSimulatorRecordingUtils,
|
||||
SERVE_SIM_STOP_GRACE_PERIOD_MS,
|
||||
} from '../IosSimulatorRecordingUtils';
|
||||
import * as remoteDeviceRunSession from '../remoteDeviceRunSession';
|
||||
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
|
||||
import { sleepAsync } from '../../../utils/retry';
|
||||
import { uploadDeviceRunSessionScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
|
||||
@@ -592,19 +598,26 @@ describe(websiteOriginServeSimArgs, () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('names staging, its deploy previews and each website dev port on staging', () => {
|
||||
it('names staging, its deploy previews and local website subdomains on staging', () => {
|
||||
const args = websiteOriginServeSimArgs({ EXPO_STAGING: '1' } as BuildStepEnv);
|
||||
expect(args.slice(0, 4)).toEqual([
|
||||
expect(args).toEqual([
|
||||
'--cors-origin',
|
||||
'https://staging.expo.dev',
|
||||
'--frame-ancestor',
|
||||
'https://staging.expo.dev',
|
||||
'--cors-origin',
|
||||
'https://*.expo.dev',
|
||||
'--frame-ancestor',
|
||||
'https://*.expo.dev',
|
||||
'--cors-origin',
|
||||
'https://expo.test',
|
||||
'--frame-ancestor',
|
||||
'https://expo.test',
|
||||
'--cors-origin',
|
||||
'https://*.expo.test',
|
||||
'--frame-ancestor',
|
||||
'https://*.expo.test',
|
||||
]);
|
||||
expect(args).toContain('https://*.expo.dev');
|
||||
expect(args).toContain('https://expo.test:13001');
|
||||
expect(args).toContain('https://expo.test:13215');
|
||||
expect(args).not.toContain('https://expo.test:13216');
|
||||
expect(args).not.toContain('https://expo.dev');
|
||||
});
|
||||
|
||||
it('names only https origins', () => {
|
||||
@@ -614,12 +627,18 @@ describe(websiteOriginServeSimArgs, () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('names the website dev ports on local, without the deploy-preview wildcard', () => {
|
||||
it('names local website subdomains without the deploy-preview wildcard on local', () => {
|
||||
for (const env of [{ EXPO_LOCAL: '1' }, { EXPO_LOCAL: '1', EXPO_STAGING: '1' }]) {
|
||||
const args = websiteOriginServeSimArgs(env as BuildStepEnv);
|
||||
expect(args).toContain('https://expo.test:13001');
|
||||
expect(args).not.toContain('https://*.expo.dev');
|
||||
expect(args.filter(value => value === 'https://expo.test')).toHaveLength(2);
|
||||
expect(websiteOriginServeSimArgs(env as BuildStepEnv)).toEqual([
|
||||
'--cors-origin',
|
||||
'https://expo.test',
|
||||
'--frame-ancestor',
|
||||
'https://expo.test',
|
||||
'--cors-origin',
|
||||
'https://*.expo.test',
|
||||
'--frame-ancestor',
|
||||
'https://*.expo.test',
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -692,6 +711,56 @@ describe(startNgrokTunnelAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('spawnDetached process group shutdown', () => {
|
||||
const env = {} as BuildStepEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
const spawned = Object.assign(Promise.resolve(undefined), {
|
||||
child: { pid: 4321, unref: jest.fn(), once: jest.fn() },
|
||||
});
|
||||
jest.mocked(spawn).mockReturnValue(spawned as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('waits for a detached child after its package-manager wrapper exits', async () => {
|
||||
let groupChecks = 0;
|
||||
const kill = jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (pid === -4321 && signal === 0) {
|
||||
groupChecks += 1;
|
||||
if (groupChecks < 4) {
|
||||
return true;
|
||||
}
|
||||
throw new Error('Process group exited');
|
||||
}
|
||||
if (pid === -4321 && signal === 'SIGTERM') {
|
||||
return true;
|
||||
}
|
||||
throw new Error(`Unexpected process signal: ${pid} ${signal}`);
|
||||
});
|
||||
|
||||
const detached = spawnDetached({ command: 'npx', args: [], env, stopGracePeriodMs: 90_000 });
|
||||
await detached.stopAsync();
|
||||
|
||||
expect(jest.mocked(sleepAsync)).toHaveBeenCalledWith(100);
|
||||
expect(kill).toHaveBeenCalledWith(-4321, 'SIGTERM');
|
||||
expect(kill).not.toHaveBeenCalledWith(-4321, 'SIGKILL');
|
||||
expect(kill).not.toHaveBeenCalledWith(4321, 0);
|
||||
});
|
||||
|
||||
it('kills a detached child that outlives the shutdown deadline', async () => {
|
||||
const kill = jest.spyOn(process, 'kill').mockReturnValue(true);
|
||||
|
||||
const detached = spawnDetached({ command: 'npx', args: [], env, stopGracePeriodMs: 0 });
|
||||
await detached.stopAsync();
|
||||
|
||||
expect(kill).toHaveBeenCalledWith(-4321, 'SIGTERM');
|
||||
expect(kill).toHaveBeenCalledWith(-4321, 'SIGKILL');
|
||||
});
|
||||
});
|
||||
|
||||
describe(startDeviceSessionHostAsync, () => {
|
||||
const baseDomain = 'eas-simulator.ngrok.dev';
|
||||
const turnArgs = [
|
||||
@@ -748,6 +817,25 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('gives serve-sim its recording grace period on shutdown', async () => {
|
||||
const spawnDetachedSpy = jest.spyOn(remoteDeviceRunSession, 'spawnDetached');
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(spawnDetachedSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ stopGracePeriodMs: SERVE_SIM_STOP_GRACE_PERIOD_MS })
|
||||
);
|
||||
await host.finishAsync();
|
||||
});
|
||||
|
||||
it('installs ffmpeg before starting expo-device-hub for Linux', async () => {
|
||||
const packageVersion = '1.2.3';
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
@@ -755,11 +843,9 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
url: () => 'https://android-preview.example.test',
|
||||
close,
|
||||
} as never);
|
||||
// `env` has no PATH, so the host installs ffmpeg.
|
||||
jest
|
||||
.mocked(spawn)
|
||||
.mockReturnValueOnce(
|
||||
Promise.reject(new Error('ffmpeg is missing')) as ReturnType<typeof spawn>
|
||||
)
|
||||
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>)
|
||||
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>);
|
||||
|
||||
@@ -773,24 +859,24 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
const spawnCalls = jest.mocked(spawn).mock.calls;
|
||||
expect(spawnCalls[0]).toEqual(['ffmpeg', ['-version'], { env }]);
|
||||
expect(spawnCalls[1]).toEqual([
|
||||
expect(spawnCalls[0]).toEqual([
|
||||
'sudo',
|
||||
['apt-get', 'update'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ DEBIAN_FRONTEND: 'noninteractive' }),
|
||||
}),
|
||||
]);
|
||||
expect(spawnCalls[2]).toEqual([
|
||||
expect(spawnCalls[1]).toEqual([
|
||||
'sudo',
|
||||
['apt-get', 'install', '-y', 'ffmpeg'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ DEBIAN_FRONTEND: 'noninteractive' }),
|
||||
}),
|
||||
]);
|
||||
expect(spawnCalls[2]).toEqual(['ffmpeg', ['-version'], { env }]);
|
||||
const expoDeviceHubCallIndex = spawnCalls.findIndex(([command]) => command === 'npx');
|
||||
expect(expoDeviceHubCallIndex).toBeGreaterThan(2);
|
||||
expect(jest.mocked(spawn).mock.invocationCallOrder[2]).toBeLessThan(
|
||||
expect(jest.mocked(spawn).mock.invocationCallOrder[1]).toBeLessThan(
|
||||
jest.mocked(spawn).mock.invocationCallOrder[expoDeviceHubCallIndex]
|
||||
);
|
||||
const [command, args] = spawnCalls[expoDeviceHubCallIndex];
|
||||
@@ -940,6 +1026,7 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
});
|
||||
|
||||
it('launches serve-sim with bun x when EAS_OVERRIDE_PACKAGE_MANAGER is bun', async () => {
|
||||
const usePackage = jest.spyOn(IosSimulatorRecordingUtils, 'useServeSimPackage');
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://ios-preview.example.test',
|
||||
@@ -969,6 +1056,7 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
packageVersion: '4.5.6',
|
||||
}),
|
||||
]);
|
||||
expect(usePackage).toHaveBeenCalledWith('@expo/[email protected]');
|
||||
|
||||
await host.finishAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
@@ -1419,6 +1507,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
|
||||
describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
const spawnMock = jest.mocked(spawn);
|
||||
let binDirectory: string;
|
||||
|
||||
function spawnResolved(): ReturnType<typeof spawn> {
|
||||
return Promise.resolve({}) as unknown as ReturnType<typeof spawn>;
|
||||
@@ -1428,68 +1517,140 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
return Promise.reject(new Error('boom')) as unknown as ReturnType<typeof spawn>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
function spawnPending(): ReturnType<typeof spawn> {
|
||||
return new Promise(() => {}) as unknown as ReturnType<typeof spawn>;
|
||||
}
|
||||
|
||||
async function installFakeFfmpegAsync({ mode }: { mode: number }): Promise<string> {
|
||||
const ffmpegPath = path.join(binDirectory, 'ffmpeg');
|
||||
await fs.writeFile(ffmpegPath, '#!/bin/sh\n');
|
||||
await fs.chmod(ffmpegPath, mode);
|
||||
return ffmpegPath;
|
||||
}
|
||||
|
||||
function createPathEnv(): BuildStepEnv {
|
||||
return { ...createEnvMock(), PATH: binDirectory };
|
||||
}
|
||||
|
||||
const warmUpCall = ['ffmpeg', ['-version'], expect.anything()];
|
||||
|
||||
beforeEach(async () => {
|
||||
spawnMock.mockReset();
|
||||
jest.mocked(Sentry).capture.mockReset();
|
||||
binDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'ffmpeg-path-'));
|
||||
});
|
||||
|
||||
it('does not install when ffmpeg is on PATH', async () => {
|
||||
spawnMock.mockReturnValueOnce(spawnResolved());
|
||||
afterEach(async () => {
|
||||
await fs.rm(binDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('finds ffmpeg on PATH without waiting for it to run, then warms it up', async () => {
|
||||
const ffmpegPath = await installFakeFfmpegAsync({ mode: 0o755 });
|
||||
spawnMock.mockReturnValueOnce(spawnPending());
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createEnvMock(),
|
||||
logger: createLoggerMock(),
|
||||
env: createPathEnv(),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
expect(spawnMock).toHaveBeenCalledWith('ffmpeg', ['-version'], expect.anything());
|
||||
expect(spawnMock).toHaveBeenCalledWith(...warmUpCall);
|
||||
expect(logger.info).toHaveBeenCalledWith(`ffmpeg is already installed at ${ffmpegPath}.`);
|
||||
});
|
||||
|
||||
it('installs ffmpeg with Homebrew on darwin when it is missing', async () => {
|
||||
spawnMock.mockReturnValueOnce(spawnRejected()).mockReturnValueOnce(spawnResolved());
|
||||
it('installs ffmpeg when the file on PATH is not executable', async () => {
|
||||
await installFakeFfmpegAsync({ mode: 0o644 });
|
||||
spawnMock.mockReturnValue(spawnResolved());
|
||||
|
||||
await ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createEnvMock(),
|
||||
env: createPathEnv(),
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
|
||||
expect(spawnMock).toHaveBeenLastCalledWith(
|
||||
expect(spawnMock).toHaveBeenCalledWith('brew', ['install', 'ffmpeg'], expect.anything());
|
||||
});
|
||||
|
||||
it('installs ffmpeg when the ffmpeg entry on PATH is a directory', async () => {
|
||||
await fs.mkdir(path.join(binDirectory, 'ffmpeg'));
|
||||
spawnMock.mockReturnValue(spawnResolved());
|
||||
|
||||
await ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createPathEnv(),
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith('brew', ['install', 'ffmpeg'], expect.anything());
|
||||
});
|
||||
|
||||
it('treats an empty PATH entry as the current directory', async () => {
|
||||
await installFakeFfmpegAsync({ mode: 0o755 });
|
||||
jest.spyOn(process, 'cwd').mockReturnValue(binDirectory);
|
||||
spawnMock.mockReturnValue(spawnResolved());
|
||||
|
||||
try {
|
||||
await ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: { ...createEnvMock(), PATH: `${path.delimiter}/nonexistent` },
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
} finally {
|
||||
jest.mocked(process.cwd).mockRestore();
|
||||
}
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
expect(spawnMock).toHaveBeenCalledWith(...warmUpCall);
|
||||
});
|
||||
|
||||
it('installs ffmpeg with Homebrew on darwin when it is missing, then warms it up', async () => {
|
||||
spawnMock.mockReturnValue(spawnResolved());
|
||||
|
||||
await ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createPathEnv(),
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
|
||||
expect(spawnMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'brew',
|
||||
['install', 'ffmpeg'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ HOMEBREW_NO_AUTO_UPDATE: '1' }),
|
||||
})
|
||||
);
|
||||
expect(spawnMock).toHaveBeenLastCalledWith(...warmUpCall);
|
||||
});
|
||||
|
||||
it('installs ffmpeg with apt on linux when it is missing', async () => {
|
||||
spawnMock
|
||||
.mockReturnValueOnce(spawnRejected()) // ffmpeg -version
|
||||
.mockReturnValueOnce(spawnResolved()) // apt-get update
|
||||
.mockReturnValueOnce(spawnResolved()); // apt-get install
|
||||
spawnMock.mockReturnValue(spawnResolved());
|
||||
|
||||
await ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: createEnvMock(),
|
||||
env: createPathEnv(),
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
expect(spawnMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'sudo',
|
||||
['apt-get', 'update'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ DEBIAN_FRONTEND: 'noninteractive' }),
|
||||
})
|
||||
);
|
||||
expect(spawnMock).toHaveBeenLastCalledWith(
|
||||
expect(spawnMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'sudo',
|
||||
['apt-get', 'install', '-y', 'ffmpeg'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ DEBIAN_FRONTEND: 'noninteractive' }),
|
||||
})
|
||||
);
|
||||
expect(spawnMock).toHaveBeenLastCalledWith(...warmUpCall);
|
||||
});
|
||||
|
||||
it('shares an in-flight ffmpeg setup between callers', async () => {
|
||||
@@ -1498,21 +1659,21 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
finishInstall = resolve;
|
||||
});
|
||||
spawnMock
|
||||
.mockReturnValueOnce(spawnRejected()) // ffmpeg -version
|
||||
.mockReturnValueOnce(spawnResolved()) // apt-get update
|
||||
.mockReturnValueOnce(pendingInstall as unknown as ReturnType<typeof spawn>); // apt-get install
|
||||
.mockReturnValueOnce(pendingInstall as unknown as ReturnType<typeof spawn>) // apt-get install
|
||||
.mockReturnValueOnce(spawnResolved()); // ffmpeg -version warm-up
|
||||
const options = {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: createEnvMock(),
|
||||
env: createPathEnv(),
|
||||
logger: createLoggerMock(),
|
||||
};
|
||||
|
||||
const firstSetup = ensureFfmpegInstalledOnceAsync(options);
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
expect(spawnMock).toHaveBeenCalledTimes(3);
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
const secondSetup = ensureFfmpegInstalledOnceAsync(options);
|
||||
expect(spawnMock).toHaveBeenCalledTimes(3);
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
finishInstall?.();
|
||||
await Promise.all([firstSetup, secondSetup]);
|
||||
@@ -1521,18 +1682,17 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
|
||||
it('still installs on linux when the apt index refresh fails', async () => {
|
||||
spawnMock
|
||||
.mockReturnValueOnce(spawnRejected()) // ffmpeg -version
|
||||
.mockReturnValueOnce(spawnRejected()) // apt-get update
|
||||
.mockReturnValueOnce(spawnResolved()); // apt-get install
|
||||
.mockReturnValue(spawnResolved());
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: createEnvMock(),
|
||||
env: createPathEnv(),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(spawnMock).toHaveBeenLastCalledWith(
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'sudo',
|
||||
['apt-get', 'install', '-y', 'ffmpeg'],
|
||||
expect.anything()
|
||||
@@ -1541,17 +1701,18 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
});
|
||||
|
||||
it('warns and resolves when the install fails, so the session still starts', async () => {
|
||||
spawnMock.mockReturnValueOnce(spawnRejected()).mockReturnValueOnce(spawnRejected());
|
||||
spawnMock.mockReturnValueOnce(spawnRejected());
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await expect(
|
||||
ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createEnvMock(),
|
||||
env: createPathEnv(),
|
||||
logger,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(jest.mocked(Sentry).capture).toHaveBeenCalled();
|
||||
});
|
||||
@@ -1559,7 +1720,7 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
// 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.
|
||||
it('resolves when the availability check throws synchronously', async () => {
|
||||
it('resolves when the install throws synchronously', async () => {
|
||||
spawnMock.mockImplementationOnce(() => {
|
||||
throw new Error('sync spawn failure');
|
||||
});
|
||||
@@ -1568,7 +1729,7 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
await expect(
|
||||
ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createEnvMock(),
|
||||
env: createPathEnv(),
|
||||
logger,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
@@ -1576,4 +1737,29 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(jest.mocked(Sentry).capture).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['rejects', () => spawnRejected()],
|
||||
[
|
||||
'throws synchronously',
|
||||
() => {
|
||||
throw new Error('sync spawn failure');
|
||||
},
|
||||
],
|
||||
])('ignores a warm-up that %s', async (_, warmUp) => {
|
||||
await installFakeFfmpegAsync({ mode: 0o755 });
|
||||
spawnMock.mockImplementationOnce(warmUp as () => ReturnType<typeof spawn>);
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await expect(
|
||||
ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createPathEnv(),
|
||||
logger,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,451 @@
|
||||
jest.unmock('fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
import { SandboxDaemonErrorCode, SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type Client, CombinedError } from '@urql/core';
|
||||
import fetch, { type RequestInit } from 'node-fetch';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { type Readable } from 'node:stream';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { inspect } from 'node:util';
|
||||
|
||||
import { SandboxArtifactUploadManager, startSandboxArtifactUploadAsync } from '../sandboxArtifacts';
|
||||
|
||||
const { AbortError, FetchError, Response } = jest.requireActual(
|
||||
'node-fetch'
|
||||
) as typeof import('node-fetch');
|
||||
|
||||
const ARTIFACT_ID = '0199c0de-7b3a-7c1e-8f00-1234567890ab';
|
||||
const SIGNED_URL = 'https://uploads.expo.test/artifact?X-Amz-Signature=secret-signature';
|
||||
const DOWNLOAD_URL = 'https://downloads.expo.test/artifact?X-Amz-Signature=download-signature';
|
||||
|
||||
const createdResult = {
|
||||
data: {
|
||||
sandbox: {
|
||||
createArtifactUploadSession: {
|
||||
artifact: { id: ARTIFACT_ID },
|
||||
uploadSession: { url: SIGNED_URL, headers: { 'If-None-Match': '*' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const finalizedResult = {
|
||||
data: { sandbox: { finalizeArtifact: { id: ARTIFACT_ID, downloadUrl: DOWNLOAD_URL } } },
|
||||
};
|
||||
|
||||
describe('sandbox artifact uploads', () => {
|
||||
let directory: string;
|
||||
let filePath: string;
|
||||
let mutation: jest.Mock;
|
||||
let graphqlClient: Client;
|
||||
let logger: { info: jest.Mock; error: jest.Mock };
|
||||
let uploadArtifactAsync: (options: {
|
||||
filePath: string;
|
||||
name: string;
|
||||
signal: AbortSignal;
|
||||
}) => ReturnType<typeof startSandboxArtifactUploadAsync>;
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'sandbox-artifacts-'));
|
||||
filePath = path.join(directory, 'crash.log');
|
||||
await fs.writeFile(filePath, 'crash');
|
||||
jest.mocked(fetch).mockReset();
|
||||
mutation = jest.fn();
|
||||
logger = { info: jest.fn(), error: jest.fn() };
|
||||
graphqlClient = { mutation } as unknown as Client;
|
||||
uploadArtifactAsync = async options =>
|
||||
await startSandboxArtifactUploadAsync({
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
logger: logger as unknown as bunyan,
|
||||
...options,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns the artifact id before the PUT completes', async () => {
|
||||
mockMutationResults(createdResult, finalizedResult);
|
||||
let respond!: (response: InstanceType<typeof Response>) => void;
|
||||
jest.mocked(fetch).mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
respond = resolve;
|
||||
}) as ReturnType<typeof fetch>
|
||||
);
|
||||
|
||||
const upload = await uploadArtifactAsync({
|
||||
filePath,
|
||||
name: 'Crash log',
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(upload.id).toBe(ARTIFACT_ID);
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sandboxId: 'sandbox-id', input: { name: 'Crash log', filename: 'crash.log', size: 5 } },
|
||||
expect.anything()
|
||||
);
|
||||
respond(new Response('', { status: 200 }));
|
||||
await expect(upload.completed).resolves.toEqual({
|
||||
status: 'uploaded',
|
||||
downloadUrl: DOWNLOAD_URL,
|
||||
});
|
||||
expect(mutation).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
{ artifactId: ARTIFACT_ID },
|
||||
expect.anything()
|
||||
);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends exactly the stat size on every attempt when the file grows', async () => {
|
||||
mutation
|
||||
.mockReturnValueOnce({
|
||||
toPromise: async () => {
|
||||
await fs.appendFile(filePath, ' and more');
|
||||
return createdResult;
|
||||
},
|
||||
})
|
||||
.mockReturnValueOnce({ toPromise: async () => finalizedResult });
|
||||
const bodies: string[] = [];
|
||||
const respondAfterReadingBody =
|
||||
(status: number) =>
|
||||
async (_url: unknown, init?: RequestInit): Promise<InstanceType<typeof Response>> => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of init!.body as Readable) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
bodies.push(Buffer.concat(chunks).toString());
|
||||
return new Response('', { status });
|
||||
};
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockImplementationOnce(respondAfterReadingBody(503) as unknown as typeof fetch)
|
||||
.mockImplementationOnce(respondAfterReadingBody(200) as unknown as typeof fetch);
|
||||
|
||||
const upload = await uploadArtifactAsync({
|
||||
filePath,
|
||||
name: 'Crash log',
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
await upload.completed;
|
||||
|
||||
expect(bodies).toEqual(['crash', 'crash']);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads an empty file with an empty body', async () => {
|
||||
await fs.writeFile(filePath, '');
|
||||
mockMutationResults(createdResult, finalizedResult);
|
||||
let body: string | undefined;
|
||||
jest.mocked(fetch).mockImplementationOnce((async (_url: unknown, init?: RequestInit) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of init!.body as Readable) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
body = Buffer.concat(chunks).toString();
|
||||
return new Response('', { status: 200 });
|
||||
}) as unknown as typeof fetch);
|
||||
|
||||
const upload = await uploadArtifactAsync({
|
||||
filePath,
|
||||
name: 'Empty log',
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
await upload.completed;
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sandboxId: 'sandbox-id', input: { name: 'Empty log', filename: 'crash.log', size: 0 } },
|
||||
expect.anything()
|
||||
);
|
||||
expect(body).toBe('');
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('finalizes when a retry fails the upload precondition', async () => {
|
||||
mockMutationResults(createdResult, finalizedResult);
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValueOnce(
|
||||
new FetchError(`request to ${SIGNED_URL} failed, reason: socket hang up`, 'system', {
|
||||
code: 'ECONNRESET',
|
||||
} as any)
|
||||
)
|
||||
.mockResolvedValueOnce(new Response('', { status: 412, statusText: 'Precondition Failed' }));
|
||||
|
||||
const upload = await uploadArtifactAsync({
|
||||
filePath,
|
||||
name: 'Crash log',
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
await upload.completed;
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(mutation).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
{ artifactId: ARTIFACT_ID },
|
||||
expect.anything()
|
||||
);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenLastCalledWith(
|
||||
`Uploaded sandbox artifact "Crash log" (${ARTIFACT_ID}, 5 bytes).`
|
||||
);
|
||||
});
|
||||
|
||||
it('retries finalize after a server error and logs a failure that persists', async () => {
|
||||
mockMutationResults(
|
||||
createdResult,
|
||||
{
|
||||
error: new CombinedError({
|
||||
networkError: new Error('Bad Gateway'),
|
||||
response: { status: 502 },
|
||||
}),
|
||||
},
|
||||
{
|
||||
error: new CombinedError({
|
||||
graphQLErrors: ['Sandbox artifact "Crash log" has no uploaded file.'],
|
||||
response: { status: 200 },
|
||||
}),
|
||||
}
|
||||
);
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
|
||||
|
||||
const upload = await uploadArtifactAsync({
|
||||
filePath,
|
||||
name: 'Crash log',
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
await expect(upload.completed).resolves.toEqual({ status: 'failed' });
|
||||
expect(mutation).toHaveBeenCalledTimes(3);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
`Uploaded sandbox artifact "Crash log" (${ARTIFACT_ID}), but could not finalize it.`
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['VALIDATION_ERROR', 'UNAUTHORIZED'])(
|
||||
'rejects a %s upload session error as a bad request with the GraphQL message',
|
||||
async errorCode => {
|
||||
const message =
|
||||
'Sandbox already has an artifact named "Crash log". Use a different name for this artifact.';
|
||||
mockMutationResults({
|
||||
error: new CombinedError({ graphQLErrors: [{ message, extensions: { errorCode } }] }),
|
||||
});
|
||||
|
||||
const upload = uploadArtifactAsync({
|
||||
filePath,
|
||||
name: 'Crash log',
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
await expect(upload).rejects.toMatchObject({
|
||||
code: SandboxDaemonErrorCode.BAD_REQUEST,
|
||||
message,
|
||||
});
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects an unexpected upload session error as a system error', async () => {
|
||||
mockMutationResults({
|
||||
error: new CombinedError({
|
||||
graphQLErrors: [
|
||||
{ message: 'Request failed.', extensions: { errorCode: 'UNEXPECTED_ERROR' } },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const upload = uploadArtifactAsync({
|
||||
filePath,
|
||||
name: 'Crash log',
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
await expect(upload).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(upload).rejects.toMatchObject({ message: 'Request failed.' });
|
||||
});
|
||||
|
||||
it('rejects a missing file as a bad request without creating an upload session', async () => {
|
||||
const missingPath = path.join(directory, 'missing.log');
|
||||
|
||||
const upload = uploadArtifactAsync({
|
||||
filePath: missingPath,
|
||||
name: 'Crash log',
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
await expect(upload).rejects.toMatchObject({
|
||||
code: SandboxDaemonErrorCode.BAD_REQUEST,
|
||||
message: `File does not exist: ${missingPath}`,
|
||||
});
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the signed URL out of logs', async () => {
|
||||
mockMutationResults(createdResult);
|
||||
jest.mocked(fetch).mockRejectedValue(
|
||||
new FetchError(`request to ${SIGNED_URL} failed, reason: socket hang up`, 'system', {
|
||||
code: 'ECONNRESET',
|
||||
} as any)
|
||||
);
|
||||
|
||||
const upload = await uploadArtifactAsync({
|
||||
filePath,
|
||||
name: 'Crash log',
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
await upload.completed;
|
||||
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
const logged = inspect([logger.info.mock.calls, logger.error.mock.calls], { depth: 10 });
|
||||
expect(fetch).toHaveBeenCalledTimes(3);
|
||||
expect(logged).toContain('Artifact PUT failed: ECONNRESET.');
|
||||
expect(logged).not.toContain('secret-signature');
|
||||
});
|
||||
|
||||
it('waits for an aborted upload before the manager reports that it stopped', async () => {
|
||||
mockMutationResults(createdResult);
|
||||
mockPutUntilAborted();
|
||||
const controller = new AbortController();
|
||||
const manager = createManager(controller.signal);
|
||||
|
||||
await expect(
|
||||
manager.startAsync({ filePath, name: 'Crash log', yieldTimeMs: 0 })
|
||||
).resolves.toEqual({
|
||||
id: ARTIFACT_ID,
|
||||
status: 'uploading',
|
||||
});
|
||||
controller.abort();
|
||||
await manager.stoppedPromise;
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
`Failed to upload sandbox artifact "Crash log" (${ARTIFACT_ID}).`
|
||||
);
|
||||
await expect(
|
||||
manager.startAsync({ filePath, name: 'Late log', yieldTimeMs: 0 })
|
||||
).rejects.toMatchObject({
|
||||
name: 'AbortError',
|
||||
});
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
case: 'a finalized upload as uploaded with its download URL',
|
||||
finalizeResult: finalizedResult,
|
||||
outcome: { status: 'uploaded', downloadUrl: DOWNLOAD_URL },
|
||||
},
|
||||
{
|
||||
case: 'a failed finalization as failed',
|
||||
finalizeResult: {
|
||||
error: new CombinedError({
|
||||
graphQLErrors: ['Sandbox artifact "Crash log" has no uploaded file.'],
|
||||
response: { status: 200 },
|
||||
}),
|
||||
},
|
||||
outcome: { status: 'failed' },
|
||||
},
|
||||
{
|
||||
case: 'a finalized artifact without a download URL as failed',
|
||||
finalizeResult: {
|
||||
data: { sandbox: { finalizeArtifact: { id: ARTIFACT_ID, downloadUrl: null } } },
|
||||
},
|
||||
outcome: { status: 'failed' },
|
||||
},
|
||||
])(
|
||||
'waits for an upload that ends within the yield time and reports $case',
|
||||
async ({ finalizeResult, outcome }) => {
|
||||
mockMutationResults(createdResult, finalizeResult);
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
|
||||
const manager = createManager(new AbortController().signal);
|
||||
|
||||
await expect(
|
||||
manager.startAsync({ filePath, name: 'Crash log', yieldTimeMs: 30_000 })
|
||||
).resolves.toEqual({ id: ARTIFACT_ID, ...outcome });
|
||||
}
|
||||
);
|
||||
|
||||
it('reports an upload that outlasts the yield time as uploading and keeps uploading', async () => {
|
||||
let finalizeCalled!: () => void;
|
||||
const finalizeCalledPromise = new Promise<void>(resolve => {
|
||||
finalizeCalled = resolve;
|
||||
});
|
||||
mockMutationResults(createdResult);
|
||||
mutation.mockReturnValueOnce({
|
||||
toPromise: async () => {
|
||||
finalizeCalled();
|
||||
return finalizedResult;
|
||||
},
|
||||
});
|
||||
let respond!: (response: InstanceType<typeof Response>) => void;
|
||||
jest.mocked(fetch).mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
respond = resolve;
|
||||
}) as ReturnType<typeof fetch>
|
||||
);
|
||||
const manager = createManager(new AbortController().signal);
|
||||
|
||||
await expect(
|
||||
manager.startAsync({ filePath, name: 'Crash log', yieldTimeMs: 10 })
|
||||
).resolves.toEqual({ id: ARTIFACT_ID, status: 'uploading' });
|
||||
respond(new Response('', { status: 200 }));
|
||||
await finalizeCalledPromise;
|
||||
});
|
||||
|
||||
it('counts upload session creation toward the yield time', async () => {
|
||||
mutation.mockReturnValueOnce({
|
||||
toPromise: async () => {
|
||||
await delay(400);
|
||||
return createdResult;
|
||||
},
|
||||
});
|
||||
mockPutUntilAborted();
|
||||
const controller = new AbortController();
|
||||
const manager = createManager(controller.signal);
|
||||
const startedAt = performance.now();
|
||||
|
||||
await expect(
|
||||
manager.startAsync({ filePath, name: 'Crash log', yieldTimeMs: 400 })
|
||||
).resolves.toEqual({ id: ARTIFACT_ID, status: 'uploading' });
|
||||
expect(performance.now() - startedAt).toBeLessThan(600);
|
||||
controller.abort();
|
||||
await manager.stoppedPromise;
|
||||
});
|
||||
|
||||
function createManager(signal: AbortSignal): SandboxArtifactUploadManager {
|
||||
return new SandboxArtifactUploadManager({
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
logger: logger as unknown as bunyan,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
function mockPutUntilAborted(): void {
|
||||
jest.mocked(fetch).mockImplementationOnce((async (_url: unknown, init?: RequestInit) => {
|
||||
await new Promise<never>((_resolve, reject) => {
|
||||
init!.signal!.addEventListener('abort', () => {
|
||||
reject(new AbortError('The user aborted a request.'));
|
||||
});
|
||||
});
|
||||
}) as unknown as typeof fetch);
|
||||
}
|
||||
|
||||
function mockMutationResults(...results: unknown[]): void {
|
||||
for (const result of results) {
|
||||
mutation.mockReturnValueOnce({ toPromise: async () => result });
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import { SandboxDaemonResponseZ, SystemError } from '@expo/eas-build-job';
|
||||
import Log from '@expo/logger';
|
||||
import { type Client } from '@urql/core';
|
||||
import http from 'node:http';
|
||||
import net, { AddressInfo } from 'node:net';
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
|
||||
import * as sandboxCommandImplementations from '../sandboxCommandImplementations';
|
||||
import { startSandboxDaemonAsync } from '../sandboxDaemon';
|
||||
|
||||
jest.unmock('@expo/logger');
|
||||
@@ -14,6 +16,7 @@ jest.unmock('node:fs/promises');
|
||||
|
||||
describe(startSandboxDaemonAsync.name, () => {
|
||||
const logger = Log.child({ buildStepId: 'sandbox-test' });
|
||||
const graphqlClient = {} as Client;
|
||||
it.each(['ftp://localhost', 'not a URL'])('reports invalid server URL %s', async serverUrl => {
|
||||
const errorLog = jest.spyOn(logger, 'error').mockImplementation(() => {});
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
@@ -23,6 +26,8 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
});
|
||||
try {
|
||||
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
|
||||
@@ -48,6 +53,8 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
});
|
||||
try {
|
||||
const socket = await connection;
|
||||
@@ -82,6 +89,8 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
});
|
||||
try {
|
||||
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
|
||||
@@ -112,6 +121,8 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
signal: controller.signal,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
});
|
||||
const connectionError = daemon.ready.catch(error => error);
|
||||
|
||||
@@ -140,6 +151,8 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
});
|
||||
|
||||
await expect(daemon.ready).rejects.toThrow('Sandbox MCP server connection failed');
|
||||
@@ -173,6 +186,8 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
});
|
||||
const socket = await connection;
|
||||
const daemon = await daemonPromise;
|
||||
@@ -204,6 +219,8 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
});
|
||||
const socket = await connection;
|
||||
await daemon.ready;
|
||||
@@ -247,6 +264,8 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
});
|
||||
const socket = await connection;
|
||||
await daemon.ready;
|
||||
@@ -289,7 +308,7 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
id: '3',
|
||||
error: { code: -32603, message: 'Command session 999 does not exist.' },
|
||||
error: { code: 1, message: 'Command session 999 does not exist.' },
|
||||
});
|
||||
|
||||
await daemon.stopAsync();
|
||||
@@ -297,6 +316,68 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
await new Promise<void>(resolve => httpServer.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('returns an internal error for a command failure without a daemon error code', async () => {
|
||||
const createCommandImplementations =
|
||||
sandboxCommandImplementations.createSandboxCommandImplementations;
|
||||
const createSpy = jest
|
||||
.spyOn(sandboxCommandImplementations, 'createSandboxCommandImplementations')
|
||||
.mockImplementation(options => {
|
||||
const created = createCommandImplementations(options);
|
||||
return {
|
||||
...created,
|
||||
commandImplementations: {
|
||||
...created.commandImplementations,
|
||||
execCommand: async () => {
|
||||
throw new Error('Unexpected failure');
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
const httpServer = http.createServer();
|
||||
const mcpServer = new WebSocketServer({ noServer: true });
|
||||
httpServer.on('upgrade', (request, socket, head) => {
|
||||
mcpServer.handleUpgrade(request, socket, head, client =>
|
||||
mcpServer.emit('connection', client)
|
||||
);
|
||||
});
|
||||
await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve));
|
||||
const address = httpServer.address() as AddressInfo;
|
||||
const connection = new Promise<WebSocket>(resolve => mcpServer.once('connection', resolve));
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential: 'secret-token',
|
||||
serverUrl: `ws://127.0.0.1:${address.port}`,
|
||||
reconnectDelayMs: 10,
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
});
|
||||
const socket = await connection;
|
||||
await daemon.ready;
|
||||
const commandResponse = new Promise<string>(resolve =>
|
||||
socket.once('message', data => resolve(`${data}`))
|
||||
);
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 'command-1',
|
||||
method: 'execCommand',
|
||||
params: { cmd: 'pwd' },
|
||||
})
|
||||
);
|
||||
|
||||
expect(JSON.parse(await commandResponse)).toEqual({
|
||||
jsonrpc: '2.0',
|
||||
id: 'command-1',
|
||||
error: { code: -32603, message: 'Unexpected failure' },
|
||||
});
|
||||
await daemon.stopAsync();
|
||||
mcpServer.close();
|
||||
await new Promise<void>(resolve => httpServer.close(() => resolve()));
|
||||
createSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('cancels the reconnect delay when stopped', async () => {
|
||||
const httpServer = http.createServer();
|
||||
const mcpServer = new WebSocketServer({ noServer: true });
|
||||
@@ -315,6 +396,8 @@ describe(startSandboxDaemonAsync.name, () => {
|
||||
logger,
|
||||
workingDirectory: process.cwd(),
|
||||
env: process.env,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
});
|
||||
const socket = await connection;
|
||||
const daemon = await daemonPromise;
|
||||
|
||||
@@ -3,8 +3,12 @@ jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
import { type SandboxDaemonCommandResult, type SandboxDaemonMethod } from '@expo/eas-build-job';
|
||||
import fs from 'node:fs/promises';
|
||||
import { type SandboxDaemonCommandResult, SandboxDaemonErrorCode } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type Client } from '@urql/core';
|
||||
import fetch from 'node-fetch';
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import fs, { type FileHandle } from 'node:fs/promises';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -16,18 +20,34 @@ import {
|
||||
createSandboxCommandImplementations,
|
||||
} from '../sandboxCommandImplementations';
|
||||
|
||||
const READ_FILE_LIMITS = { maxTextBytes: 40_000, maxImageBytes: 3_000_000 };
|
||||
const FIXTURES_DIRECTORY = path.join(__dirname, 'fixtures');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
const ARTIFACT_ID = '0199c0de-7b3a-7c1e-8f00-1234567890ab';
|
||||
|
||||
describe('sandbox daemon commands', () => {
|
||||
let commandImplementations: SandboxDaemonCommandImplementations;
|
||||
let stopAsync: () => Promise<void>;
|
||||
let workingDirectory: string;
|
||||
let mutation: jest.Mock;
|
||||
let graphqlClient: Client;
|
||||
let logger: bunyan;
|
||||
|
||||
beforeEach(async () => {
|
||||
workingDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'sandbox-command-executor-'));
|
||||
mutation = jest.fn();
|
||||
graphqlClient = { mutation } as unknown as Client;
|
||||
logger = { info: jest.fn(), error: jest.fn() } as unknown as bunyan;
|
||||
const controller = new AbortController();
|
||||
const commands = createSandboxCommandImplementations({
|
||||
workingDirectory,
|
||||
env: process.env,
|
||||
signal: controller.signal,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
logger,
|
||||
});
|
||||
commandImplementations = commands.commandImplementations;
|
||||
stopAsync = async () => {
|
||||
@@ -55,6 +75,9 @@ describe('sandbox daemon commands', () => {
|
||||
workingDirectory,
|
||||
env: process.env,
|
||||
signal: controller.signal,
|
||||
graphqlClient,
|
||||
sandboxId: 'sandbox-id',
|
||||
logger,
|
||||
});
|
||||
const command = commands.commandImplementations.execCommand({ cmd: 'printf should-not-run' });
|
||||
controller.abort();
|
||||
@@ -194,9 +217,10 @@ describe('sandbox daemon commands', () => {
|
||||
it('reports a missing working directory', async () => {
|
||||
await expect(
|
||||
commandImplementations.execCommand({ cmd: 'true', workdir: 'missing' })
|
||||
).rejects.toThrow(
|
||||
`Working directory does not exist: ${path.join(workingDirectory, 'missing')}`
|
||||
);
|
||||
).rejects.toMatchObject({
|
||||
code: SandboxDaemonErrorCode.BAD_REQUEST,
|
||||
message: `Working directory does not exist: ${path.join(workingDirectory, 'missing')}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a working directory that is not a directory', async () => {
|
||||
@@ -205,7 +229,10 @@ describe('sandbox daemon commands', () => {
|
||||
|
||||
await expect(
|
||||
commandImplementations.execCommand({ cmd: 'true', workdir: 'file' })
|
||||
).rejects.toThrow(`Working directory is not a directory: ${file}`);
|
||||
).rejects.toMatchObject({
|
||||
code: SandboxDaemonErrorCode.BAD_REQUEST,
|
||||
message: `Working directory is not a directory: ${file}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports an inaccessible working directory', async () => {
|
||||
@@ -217,7 +244,10 @@ describe('sandbox daemon commands', () => {
|
||||
|
||||
await expect(
|
||||
commandImplementations.execCommand({ cmd: 'true', workdir: 'inaccessible' })
|
||||
).rejects.toThrow(`Working directory is not accessible: ${directory}`);
|
||||
).rejects.toMatchObject({
|
||||
code: SandboxDaemonErrorCode.BAD_REQUEST,
|
||||
message: `Working directory is not accessible: ${directory}`,
|
||||
});
|
||||
|
||||
await fs.chmod(directory, 0o700);
|
||||
});
|
||||
@@ -237,10 +267,226 @@ describe('sandbox daemon commands', () => {
|
||||
|
||||
expect(isProcessRunning(childPid)).toBe(false);
|
||||
});
|
||||
describe('readFile', () => {
|
||||
let readLengths: number[];
|
||||
|
||||
beforeEach(() => {
|
||||
readLengths = [];
|
||||
const open = fs.open;
|
||||
jest.spyOn(fs, 'open').mockImplementation(async (...args) => {
|
||||
const file = await open(...args);
|
||||
const read = file.read.bind(file);
|
||||
file.read = ((buffer: Buffer, offset: number, length: number, position: number) => {
|
||||
readLengths.push(length);
|
||||
return read(buffer, offset, length, position);
|
||||
}) as FileHandle['read'];
|
||||
return file;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function readAsync(
|
||||
contents: Buffer | string,
|
||||
limits: Partial<typeof READ_FILE_LIMITS> = {}
|
||||
) {
|
||||
await fs.writeFile(path.join(workingDirectory, 'file'), contents);
|
||||
return await commandImplementations.readFile({
|
||||
path: 'file',
|
||||
...READ_FILE_LIMITS,
|
||||
...limits,
|
||||
});
|
||||
}
|
||||
|
||||
async function readFixtureAsync(name: string, limits: Partial<typeof READ_FILE_LIMITS> = {}) {
|
||||
return await commandImplementations.readFile({
|
||||
path: path.join(FIXTURES_DIRECTORY, name),
|
||||
...READ_FILE_LIMITS,
|
||||
...limits,
|
||||
});
|
||||
}
|
||||
|
||||
it.each([
|
||||
['leiothrix.png', 'image/png'],
|
||||
['sparrow.jpg', 'image/jpeg'],
|
||||
['robin.gif', 'image/gif'],
|
||||
['chat.webp', 'image/webp'],
|
||||
])('returns %s as an %s image with its data', async (name, mimeType) => {
|
||||
const contents = readFileSync(path.join(FIXTURES_DIRECTORY, name));
|
||||
|
||||
expect(await readFixtureAsync(name)).toEqual({
|
||||
kind: 'image',
|
||||
mimeType,
|
||||
data: contents.toString('base64'),
|
||||
});
|
||||
expect(readLengths).toEqual([12, contents.length]);
|
||||
});
|
||||
|
||||
it('detects an image by its content instead of its extension', async () => {
|
||||
await fs.copyFile(
|
||||
path.join(FIXTURES_DIRECTORY, 'leiothrix.png'),
|
||||
path.join(workingDirectory, 'screenshot.txt')
|
||||
);
|
||||
|
||||
expect(
|
||||
await commandImplementations.readFile({ path: 'screenshot.txt', ...READ_FILE_LIMITS })
|
||||
).toMatchObject({ kind: 'image', mimeType: 'image/png' });
|
||||
});
|
||||
|
||||
it('reports an image over the byte limit after reading only its header', async () => {
|
||||
const { size } = statSync(path.join(FIXTURES_DIRECTORY, 'leiothrix.png'));
|
||||
|
||||
expect(await readFixtureAsync('leiothrix.png', { maxImageBytes: size - 1 })).toEqual({
|
||||
kind: 'image',
|
||||
mimeType: 'image/png',
|
||||
size,
|
||||
error: 'tooLarge',
|
||||
});
|
||||
expect(readLengths).toEqual([12]);
|
||||
});
|
||||
|
||||
it('returns a text file within the limit in full', async () => {
|
||||
expect(await readFixtureAsync('birds.txt')).toEqual({
|
||||
kind: 'text',
|
||||
text: 'Robin, sparrow, leiothrix and chat\nZażółć gęślą jaźń\n€ 🐦\n',
|
||||
truncated: false,
|
||||
size: 71,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads no more text than the limit without splitting a character', async () => {
|
||||
expect(await readFixtureAsync('birds.txt', { maxTextBytes: 38 })).toEqual({
|
||||
kind: 'text',
|
||||
text: 'Robin, sparrow, leiothrix and chat\nZa',
|
||||
truncated: true,
|
||||
size: 71,
|
||||
});
|
||||
expect(readLengths).toEqual([12, 38]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['invalid UTF-8', Buffer.from([0x61, 0x80, 0x62])],
|
||||
['an incomplete final character', Buffer.from([0x61, 0xe2, 0x82])],
|
||||
])('reports a file with %s as binary', async (_, contents) => {
|
||||
expect(await readAsync(contents)).toEqual({ kind: 'binary', size: 3 });
|
||||
});
|
||||
|
||||
it('reports a missing file', async () => {
|
||||
await expect(
|
||||
commandImplementations.readFile({ path: 'missing', ...READ_FILE_LIMITS })
|
||||
).rejects.toMatchObject({
|
||||
code: SandboxDaemonErrorCode.BAD_REQUEST,
|
||||
message: `File does not exist: ${path.join(workingDirectory, 'missing')}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports an inaccessible file', async () => {
|
||||
const file = path.join(workingDirectory, 'inaccessible');
|
||||
await fs.writeFile(file, 'content', { mode: 0o000 });
|
||||
|
||||
await expect(
|
||||
commandImplementations.readFile({ path: 'inaccessible', ...READ_FILE_LIMITS })
|
||||
).rejects.toMatchObject({
|
||||
code: SandboxDaemonErrorCode.BAD_REQUEST,
|
||||
message: `File is not accessible: ${file}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a directory', async () => {
|
||||
const directory = path.join(workingDirectory, 'directory');
|
||||
await fs.mkdir(directory);
|
||||
|
||||
await expect(
|
||||
commandImplementations.readFile({ path: 'directory', ...READ_FILE_LIMITS })
|
||||
).rejects.toMatchObject({
|
||||
code: SandboxDaemonErrorCode.BAD_REQUEST,
|
||||
message: `${directory} is not a regular file.`,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a FIFO without waiting for a writer', async () => {
|
||||
const fifo = path.join(workingDirectory, 'fifo');
|
||||
expect(spawnSync('mkfifo', [fifo]).status).toBe(0);
|
||||
|
||||
await expect(
|
||||
commandImplementations.readFile({ path: 'fifo', ...READ_FILE_LIMITS })
|
||||
).rejects.toMatchObject({
|
||||
code: SandboxDaemonErrorCode.BAD_REQUEST,
|
||||
message: `${fifo} is not a regular file.`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a directory with a hint to upload an archive', async () => {
|
||||
await fs.mkdir(path.join(workingDirectory, 'dist'));
|
||||
|
||||
await expect(
|
||||
commandImplementations.uploadArtifact({ path: 'dist', name: 'Build output' })
|
||||
).rejects.toMatchObject({
|
||||
code: SandboxDaemonErrorCode.BAD_REQUEST,
|
||||
message: expect.stringContaining(
|
||||
`${path.join(workingDirectory, 'dist')} is not a regular file. To upload a directory, create an archive first`
|
||||
),
|
||||
});
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads an artifact from a path relative to the working directory and waits for it by default', async () => {
|
||||
await fs.mkdir(path.join(workingDirectory, 'logs'));
|
||||
await fs.writeFile(path.join(workingDirectory, 'logs', 'crash.log'), 'crash');
|
||||
mutation
|
||||
.mockReturnValueOnce({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
sandbox: {
|
||||
createArtifactUploadSession: {
|
||||
artifact: { id: ARTIFACT_ID },
|
||||
uploadSession: { url: 'https://uploads.expo.test/artifact', headers: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
sandbox: {
|
||||
finalizeArtifact: {
|
||||
id: ARTIFACT_ID,
|
||||
downloadUrl: 'https://downloads.expo.test/artifact',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
|
||||
|
||||
const result = await commandImplementations.uploadArtifact({
|
||||
path: 'logs/crash.log',
|
||||
name: 'Crash log',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: ARTIFACT_ID,
|
||||
status: 'uploaded',
|
||||
downloadUrl: 'https://downloads.expo.test/artifact',
|
||||
});
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sandboxId: 'sandbox-id', input: { name: 'Crash log', filename: 'crash.log', size: 5 } },
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
async function readUntilAsync(
|
||||
initial: SandboxDaemonCommandResult<SandboxDaemonMethod>,
|
||||
isReady: (output: string, result: SandboxDaemonCommandResult<SandboxDaemonMethod>) => boolean
|
||||
): Promise<SandboxDaemonCommandResult<SandboxDaemonMethod>> {
|
||||
initial: SandboxDaemonCommandResult<'execCommand' | 'writeStdin'>,
|
||||
isReady: (
|
||||
output: string,
|
||||
result: SandboxDaemonCommandResult<'execCommand' | 'writeStdin'>
|
||||
) => boolean
|
||||
): Promise<SandboxDaemonCommandResult<'execCommand' | 'writeStdin'>> {
|
||||
let result = initial;
|
||||
let output = result.output;
|
||||
const deadline = Date.now() + 10_000;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { Readable } from 'node:stream';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -14,7 +13,7 @@ const ctx = {} as CustomBuildContext;
|
||||
const filePath = path.join(os.tmpdir(), 'logs.ndjson');
|
||||
const args = { deviceRunSessionId: 'session', udid: 'A', filePath, logger };
|
||||
|
||||
it('uploads NDJSON with explicit user-app scope and destroys the stream afterward', async () => {
|
||||
it('uploads NDJSON with explicit user-app scope', async () => {
|
||||
await writeFile(filePath, '{"pid":42}\n');
|
||||
await uploadServeSimLogsFileAsync(ctx, args);
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
|
||||
@@ -26,9 +25,6 @@ it('uploads NDJSON with explicit user-app scope and destroys the stream afterwar
|
||||
metadata: expect.objectContaining({ scope: 'user-apps', udid: 'A' }),
|
||||
})
|
||||
);
|
||||
expect(
|
||||
(jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls[0][1].stream as Readable).destroyed
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('skips empty and missing files', async () => {
|
||||
@@ -38,15 +34,15 @@ it('skips empty and missing files', async () => {
|
||||
expect(uploadDeviceRunSessionArtifactAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles a file read error while upload allocation is pending without an unhandled error', async () => {
|
||||
it('aborts the upload on a file read error without an unhandled error', async () => {
|
||||
await writeFile(filePath, '{}\n');
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { stream, signal }) => {
|
||||
.mockImplementationOnce(async (_ctx, { openStream, signal }) => {
|
||||
const aborted = new Promise<void>((_resolve, reject) => {
|
||||
signal!.addEventListener('abort', () => reject(signal!.reason), { once: true });
|
||||
});
|
||||
(stream as Readable).destroy(new Error('read failure'));
|
||||
openStream().destroy(new Error('read failure'));
|
||||
await aborted;
|
||||
});
|
||||
await expect(uploadServeSimLogsFileAsync(ctx, args)).resolves.toBeUndefined();
|
||||
|
||||
@@ -50,8 +50,8 @@ describe(uploadServeSimMetricsFileAsync, () => {
|
||||
let uploaded = '';
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementation(async (_ctx, { stream }) => {
|
||||
uploaded = await readStreamAsync(stream);
|
||||
.mockImplementation(async (_ctx, { openStream }) => {
|
||||
uploaded = await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
await uploadServeSimMetricsFileAsync(ctx, {
|
||||
@@ -87,8 +87,8 @@ describe(uploadServeSimMetricsFileAsync, () => {
|
||||
await writeFile(filePath, NDJSON);
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementation(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
.mockImplementation(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
await uploadServeSimMetricsFileAsync(ctx, {
|
||||
|
||||
@@ -20,6 +20,11 @@ const AgentDeviceArtifactSchema = z.object({
|
||||
id: z.string(),
|
||||
artifactType: z.string().nullish(),
|
||||
filename: z.string(),
|
||||
// Display rotation of a screenshot. An unknown value is dropped so it cannot block the upload.
|
||||
displayRotation: z
|
||||
.enum(['portrait', 'portrait-upside-down', 'landscape-left', 'landscape-right'])
|
||||
.nullish()
|
||||
.catch(undefined),
|
||||
});
|
||||
const AgentDeviceArtifactsListResponseSchema = z.object({
|
||||
artifacts: z.array(AgentDeviceArtifactSchema),
|
||||
@@ -154,8 +159,11 @@ export async function uploadAgentDeviceArtifactAsync(
|
||||
name: `${artifact.filename} (${artifact.id})`,
|
||||
filename: artifact.filename,
|
||||
kind: artifact.artifactType ?? undefined,
|
||||
...(artifact.displayRotation
|
||||
? { metadata: { displayRotation: artifact.displayRotation } }
|
||||
: {}),
|
||||
size,
|
||||
stream: createReadStream(temporaryArtifactPath),
|
||||
openStream: () => createReadStream(temporaryArtifactPath),
|
||||
});
|
||||
} finally {
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import turtleSpawn from '@expo/turtle-spawn';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import readline from 'node:readline';
|
||||
|
||||
import { leaseAgentRunProviderCredentialsAsync } from './agentRunLease';
|
||||
import { type AgentCli, type AgentInvocation, type AgentLogMessage } from './agents/agent';
|
||||
import { CLAUDE_CODE_CLI, prepareClaudeCodeAsync } from './agents/claude';
|
||||
import { CODEX_CLI, prepareCodexAsync } from './agents/codex';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { killProcessGroup } from '../../utils/processes';
|
||||
|
||||
export type AgentKind = 'claude-code' | 'codex';
|
||||
|
||||
const AGENT_CLIS: Record<AgentKind, AgentCli> = {
|
||||
'claude-code': CLAUDE_CODE_CLI,
|
||||
codex: CODEX_CLI,
|
||||
};
|
||||
export const AGENT_KINDS = Object.keys(AGENT_CLIS) as AgentKind[];
|
||||
const KILL_GRACE_PERIOD_MS = 10_000;
|
||||
const OUTPUT_CLOSE_GRACE_PERIOD_MS = 5_000;
|
||||
|
||||
export async function runAgentAsync({
|
||||
agentKind,
|
||||
agentRunId,
|
||||
prompt,
|
||||
expoToken,
|
||||
expoApiV2BaseUrl,
|
||||
mcpUrl,
|
||||
pathEnv,
|
||||
npmCacheUrl,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
agentKind: AgentKind;
|
||||
agentRunId: string;
|
||||
prompt: string;
|
||||
expoToken: string;
|
||||
expoApiV2BaseUrl: string;
|
||||
/** The Expo MCP server's streamable HTTP endpoint. */
|
||||
mcpUrl: string;
|
||||
pathEnv: string;
|
||||
npmCacheUrl?: string;
|
||||
logger: bunyan;
|
||||
signal: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const leaseOptions = { expoApiV2BaseUrl, expoToken, agentRunId, signal };
|
||||
let credentials = await leaseAgentRunProviderCredentialsAsync(leaseOptions);
|
||||
const secrets = new Set<string>();
|
||||
|
||||
const runDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-agent-run-'));
|
||||
try {
|
||||
const cliDirectory = path.join(runDirectory, 'cli');
|
||||
const homeDirectory = path.join(runDirectory, 'home');
|
||||
const tmpDirectory = path.join(runDirectory, 'tmp');
|
||||
const workingDirectory = path.join(runDirectory, 'cwd');
|
||||
await Promise.all(
|
||||
[cliDirectory, homeDirectory, tmpDirectory, workingDirectory].map(directory =>
|
||||
fs.mkdir(directory)
|
||||
)
|
||||
);
|
||||
// Of the job's environment, only `PATH` reaches npm and the agent.
|
||||
const minimalEnv = {
|
||||
PATH: pathEnv,
|
||||
HOME: homeDirectory,
|
||||
TMPDIR: tmpDirectory,
|
||||
LANG: 'C.UTF-8',
|
||||
};
|
||||
|
||||
const { packageSpec, bin, formatOutputLine } = AGENT_CLIS[agentKind];
|
||||
logger.info(`Installing ${packageSpec}.`);
|
||||
try {
|
||||
await turtleSpawn(
|
||||
'npm',
|
||||
[
|
||||
'install',
|
||||
...['--prefix', cliDirectory],
|
||||
...['--no-save', '--package-lock=false', '--no-audit', '--no-fund'],
|
||||
...(npmCacheUrl ? ['--registry', npmCacheUrl] : []),
|
||||
packageSpec,
|
||||
],
|
||||
{ env: minimalEnv, signal }
|
||||
);
|
||||
} catch (error) {
|
||||
signal.throwIfAborted();
|
||||
throw new SystemError(
|
||||
`Could not install ${packageSpec} on the worker, so the agent cannot start. This is usually a temporary network or registry problem; start the agent run again.`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
|
||||
const mcpServer = {
|
||||
url: mcpUrl,
|
||||
headers: { Authorization: `Bearer ${expoToken}`, 'X-Expo-Agent-Run-Id': agentRunId },
|
||||
};
|
||||
logger.info('Starting the agent.');
|
||||
for (let isResuming = false; ; isResuming = true) {
|
||||
signal.throwIfAborted();
|
||||
let invocation: AgentInvocation;
|
||||
switch (agentKind) {
|
||||
case 'claude-code':
|
||||
invocation = await prepareClaudeCodeAsync({
|
||||
homeDirectory,
|
||||
minimalEnv,
|
||||
mcpServer,
|
||||
prompt,
|
||||
credentials,
|
||||
isResuming,
|
||||
});
|
||||
break;
|
||||
case 'codex':
|
||||
invocation = await prepareCodexAsync({
|
||||
homeDirectory,
|
||||
minimalEnv,
|
||||
mcpServer,
|
||||
prompt,
|
||||
credentials,
|
||||
isResuming,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
for (const secret of invocation.secrets) {
|
||||
secrets.add(secret);
|
||||
}
|
||||
const { exitCode, exitSignal } = await runAgentProcessAsync({
|
||||
command: path.join(cliDirectory, 'node_modules', '.bin', bin),
|
||||
...invocation,
|
||||
cwd: workingDirectory,
|
||||
formatOutputLine,
|
||||
secrets: [...secrets],
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
if (exitCode === 0) {
|
||||
return;
|
||||
}
|
||||
const exitDescription = exitSignal
|
||||
? `was terminated by ${exitSignal}`
|
||||
: `exited with code ${exitCode}`;
|
||||
|
||||
// www hands out the stored token until it has expired, so a different token means the
|
||||
// one the agent used has expired.
|
||||
const nextCredentials = await leaseAgentRunProviderCredentialsAsync(leaseOptions);
|
||||
if (nextCredentials.accessToken === credentials.accessToken) {
|
||||
throw new UserError(
|
||||
'EAS_RUN_AGENT_FAILED',
|
||||
`The agent ${exitDescription} before it finished the task. Its output above shows what went wrong; fix the cause and start a new agent run.`
|
||||
);
|
||||
}
|
||||
logger.info(
|
||||
`The agent ${exitDescription} after its provider credentials expired. Resuming it with new credentials.`
|
||||
);
|
||||
credentials = nextCredentials;
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(runDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function runAgentProcessAsync({
|
||||
command,
|
||||
args,
|
||||
env,
|
||||
cwd,
|
||||
stdin,
|
||||
formatOutputLine,
|
||||
secrets,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
command: string;
|
||||
args: string[];
|
||||
env: Record<string, string>;
|
||||
cwd: string;
|
||||
stdin: string;
|
||||
formatOutputLine: AgentCli['formatOutputLine'];
|
||||
secrets: string[];
|
||||
logger: bunyan;
|
||||
signal: AbortSignal;
|
||||
}): Promise<{ exitCode: number | null; exitSignal: NodeJS.Signals | null }> {
|
||||
signal.throwIfAborted();
|
||||
// Detached, so the agent leads a process group that can be signaled as a whole.
|
||||
const child = spawn(command, args, { cwd, env, detached: true });
|
||||
const outputClosed = new Promise<void>(resolve => child.once('close', () => resolve()));
|
||||
const redact = (text: string): string =>
|
||||
secrets.reduce((masked, secret) => masked.replaceAll(secret, '[redacted]'), text);
|
||||
const stdoutLogger = logger.child({ source: 'stdout' });
|
||||
readline.createInterface({ input: child.stdout }).on('line', line => {
|
||||
let messages: AgentLogMessage[];
|
||||
try {
|
||||
messages = formatOutputLine(line);
|
||||
} catch (error) {
|
||||
// An exception in a readline listener is uncaught and would end the worker process.
|
||||
Sentry.capture('Could not format a line of the agent output', error as Error);
|
||||
return;
|
||||
}
|
||||
for (const { level, message } of messages) {
|
||||
stdoutLogger[level](redact(message).trimEnd());
|
||||
}
|
||||
});
|
||||
const stderrLogger = logger.child({ source: 'stderr' });
|
||||
readline.createInterface({ input: child.stderr }).on('line', line => {
|
||||
stderrLogger.info(redact(line));
|
||||
});
|
||||
// The agent may exit before it reads the prompt; its exit status reports that.
|
||||
child.stdin.on('error', () => {});
|
||||
child.stdin.end(stdin);
|
||||
|
||||
let killTimer: NodeJS.Timeout | undefined;
|
||||
const stop = (): void => {
|
||||
if (killTimer) {
|
||||
return;
|
||||
}
|
||||
killProcessGroup(child, 'SIGTERM');
|
||||
killTimer = setTimeout(() => killProcessGroup(child, 'SIGKILL'), KILL_GRACE_PERIOD_MS);
|
||||
};
|
||||
signal.addEventListener('abort', stop);
|
||||
let exitCode: number | null;
|
||||
let exitSignal: NodeJS.Signals | null;
|
||||
try {
|
||||
[exitCode, exitSignal] = await once(child, 'exit');
|
||||
} catch (error: any) {
|
||||
// `once` rejects when the child emits `error`, which is how a failed spawn reports.
|
||||
throw new SystemError(
|
||||
`Could not start the agent CLI on the worker: ${error.message}. Start the agent run again, and contact Expo support if it keeps failing.`,
|
||||
{ cause: error }
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(killTimer);
|
||||
signal.removeEventListener('abort', stop);
|
||||
}
|
||||
|
||||
// Whatever the agent left running ends with it. Something outside its process group could
|
||||
// still hold the output open, so the wait for the last lines is bounded.
|
||||
killProcessGroup(child, 'SIGKILL');
|
||||
let outputGraceTimer: NodeJS.Timeout | undefined;
|
||||
await Promise.race([
|
||||
outputClosed,
|
||||
new Promise<void>(resolve => {
|
||||
outputGraceTimer = setTimeout(resolve, OUTPUT_CLOSE_GRACE_PERIOD_MS);
|
||||
}),
|
||||
]);
|
||||
clearTimeout(outputGraceTimer);
|
||||
|
||||
signal.throwIfAborted();
|
||||
return { exitCode, exitSignal };
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import fetch from 'node-fetch';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { promiseRetryWithCondition } from '../../utils/promiseRetryWithCondition';
|
||||
|
||||
const AgentRunProviderCredentialsZ = z.discriminatedUnion('provider', [
|
||||
z.object({
|
||||
provider: z.literal('anthropic'),
|
||||
accessToken: z.string().min(1),
|
||||
}),
|
||||
z.object({
|
||||
provider: z.literal('openai'),
|
||||
idToken: z.string().min(1),
|
||||
accessToken: z.string().min(1),
|
||||
accountId: z.string().min(1),
|
||||
}),
|
||||
]);
|
||||
const LeaseResponseZ = z.object({ data: AgentRunProviderCredentialsZ });
|
||||
const ErrorResponseZ = z.object({
|
||||
errors: z.array(z.object({ message: z.string().min(1) })).min(1),
|
||||
});
|
||||
|
||||
export type AgentRunProviderCredentials = z.infer<typeof AgentRunProviderCredentialsZ>;
|
||||
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const RETRY_DELAY_MS = 2_000;
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
class RetryableLeaseError extends Error {}
|
||||
|
||||
export async function leaseAgentRunProviderCredentialsAsync(options: {
|
||||
expoApiV2BaseUrl: string;
|
||||
expoToken: string;
|
||||
agentRunId: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<AgentRunProviderCredentials> {
|
||||
try {
|
||||
return await promiseRetryWithCondition(
|
||||
requestLeaseAsync,
|
||||
error => error instanceof RetryableLeaseError,
|
||||
{ retries: MAX_ATTEMPTS - 1, factor: 1, minTimeout: RETRY_DELAY_MS }
|
||||
)(options);
|
||||
} catch (error) {
|
||||
if (error instanceof RetryableLeaseError) {
|
||||
throw new SystemError(
|
||||
`Could not lease provider credentials for the agent run because the Expo API failed ${MAX_ATTEMPTS} times in a row. This is usually temporary; start the agent run again, and contact Expo support if it keeps failing.`,
|
||||
{ cause: error.cause ?? error }
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Which failures count as retryable follows www's contract for this route.
|
||||
async function requestLeaseAsync({
|
||||
expoApiV2BaseUrl,
|
||||
expoToken,
|
||||
agentRunId,
|
||||
signal,
|
||||
}: {
|
||||
expoApiV2BaseUrl: string;
|
||||
expoToken: string;
|
||||
agentRunId: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<AgentRunProviderCredentials> {
|
||||
const url = new URL(`agent-runs/${agentRunId}/auth-lease`, expoApiV2BaseUrl).toString();
|
||||
let response;
|
||||
let body: unknown;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${expoToken}` },
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
signal,
|
||||
});
|
||||
// Reading the body can fail the way the request can, so it is retried with it.
|
||||
body = response.ok ? await response.json() : undefined;
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
throw new RetryableLeaseError('The request to the Expo API failed.', { cause: error });
|
||||
}
|
||||
if (response.ok) {
|
||||
const parsed = LeaseResponseZ.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new SystemError(
|
||||
'The Expo API returned provider credentials in an unexpected format, so the agent cannot run. This is a problem on our side; contact Expo support if it keeps happening.',
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
return parsed.data.data;
|
||||
}
|
||||
if (response.status < 500) {
|
||||
const errorBody = ErrorResponseZ.safeParse(await response.json().catch(() => null));
|
||||
throw new UserError(
|
||||
'EAS_RUN_AGENT_CREDENTIALS_UNAVAILABLE',
|
||||
errorBody.success
|
||||
? errorBody.data.errors[0].message
|
||||
: `Expo refused to issue provider credentials for this agent run (HTTP ${response.status}). The run may have ended, or its provider connection may have been removed. Start a new agent run.`
|
||||
);
|
||||
}
|
||||
throw new RetryableLeaseError(`The Expo API responded with HTTP ${response.status}.`);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { CLAUDE_CODE_CLI } from '../claude';
|
||||
import { readFixtureLines } from './readFixture';
|
||||
|
||||
const format = CLAUDE_CODE_CLI.formatOutputLine;
|
||||
|
||||
function formatFixture(name: string): ReturnType<typeof format> {
|
||||
return readFixtureLines(name).flatMap(line => format(line));
|
||||
}
|
||||
|
||||
describe('CLAUDE_CODE_CLI.formatOutputLine', () => {
|
||||
it('renders a run with a tool call and skips thinking and token counts', () => {
|
||||
expect(formatFixture('claude-code-tool-call.jsonl')).toEqual([
|
||||
{ level: 'info', message: 'MCP server expo is connected.' },
|
||||
{ level: 'info', message: 'Tool call: mcp__expo__sandbox_exec {"command":"echo hi"}' },
|
||||
{ level: 'info', message: 'Tool result: stub ran: echo hi' },
|
||||
{
|
||||
level: 'info',
|
||||
message:
|
||||
'Agent: The command returned exactly: `stub ran: echo hi`\n\nThe tools I can call are:\n\n1. `mcp__expo__sandbox_exec` — Execute a shell command in a running sandbox\n2. `mcp__expo__sandbox_list` — List the sandboxes of the project',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders a run that failed before it could do anything', () => {
|
||||
expect(formatFixture('claude-code-not-logged-in.jsonl')).toEqual([
|
||||
{ level: 'info', message: 'MCP server expo is connected.' },
|
||||
{ level: 'info', message: 'Agent: Not logged in · Please run /login' },
|
||||
{ level: 'error', message: 'Agent failed: Not logged in · Please run /login' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports every MCP server, warning about one the agent could not connect to', () => {
|
||||
const event = {
|
||||
type: 'system',
|
||||
subtype: 'init',
|
||||
mcp_servers: [
|
||||
{ name: 'expo', status: 'failed' },
|
||||
{ name: 'other', status: 'connected' },
|
||||
],
|
||||
};
|
||||
expect(format(JSON.stringify(event))).toEqual([
|
||||
{ level: 'warn', message: 'MCP server expo is not connected (failed).' },
|
||||
{ level: 'info', message: 'MCP server other is connected.' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders a failed tool result as a warning and joins its text blocks', () => {
|
||||
const event = {
|
||||
type: 'user',
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'toolu_1',
|
||||
is_error: true,
|
||||
content: [
|
||||
{ type: 'text', text: 'first' },
|
||||
{ type: 'image', source: {} },
|
||||
{ type: 'text', text: 'second' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(format(JSON.stringify(event))).toEqual([
|
||||
{ level: 'warn', message: 'Tool error: first\nsecond' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('describes a failure without a result text by its errors, then by its subtype', () => {
|
||||
expect(
|
||||
format(
|
||||
JSON.stringify({
|
||||
type: 'result',
|
||||
subtype: 'error_max_turns',
|
||||
is_error: true,
|
||||
errors: ['Reached max turns (1)'],
|
||||
})
|
||||
)
|
||||
).toEqual([{ level: 'error', message: 'Agent failed: Reached max turns (1)' }]);
|
||||
expect(
|
||||
format(
|
||||
JSON.stringify({
|
||||
type: 'result',
|
||||
subtype: 'error_max_turns',
|
||||
is_error: true,
|
||||
result: '',
|
||||
errors: ['Reached max turns (1)'],
|
||||
})
|
||||
)
|
||||
).toEqual([{ level: 'error', message: 'Agent failed: Reached max turns (1)' }]);
|
||||
expect(
|
||||
format(
|
||||
JSON.stringify({ type: 'result', subtype: 'error_max_turns', is_error: true, errors: [] })
|
||||
)
|
||||
).toEqual([{ level: 'error', message: 'Agent failed: error_max_turns' }]);
|
||||
expect(
|
||||
format(JSON.stringify({ type: 'result', subtype: 'error_during_execution', is_error: true }))
|
||||
).toEqual([{ level: 'error', message: 'Agent failed: error_during_execution' }]);
|
||||
});
|
||||
|
||||
it('skips what it does not know without failing', () => {
|
||||
expect(format(JSON.stringify({ type: 'rate_limit_event', rate_limit_info: {} }))).toEqual([]);
|
||||
expect(format(JSON.stringify({ type: 'stream_event', event: {} }))).toEqual([]);
|
||||
expect(format(JSON.stringify({ type: 'assistant', message: { content: 'text' } }))).toEqual([]);
|
||||
expect(format(JSON.stringify({ type: 'user', message: { content: 'the prompt' } }))).toEqual(
|
||||
[]
|
||||
);
|
||||
expect(format(JSON.stringify({ type: 'result', subtype: 'success', is_error: 'yes' }))).toEqual(
|
||||
[]
|
||||
);
|
||||
expect(format('null')).toEqual([]);
|
||||
expect(format('[]')).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the known blocks of a message with an unknown block', () => {
|
||||
const event = {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [
|
||||
{ type: 'server_tool_use', id: 'srvtoolu_1', name: 'web_search', input: {} },
|
||||
{ type: 'text', text: 'Searching.' },
|
||||
{ type: 'tool_use', id: 'toolu_1', name: 'mcp__expo__sandbox_list', input: 'bad' },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(format(JSON.stringify(event))).toEqual([
|
||||
{ level: 'info', message: 'Agent: Searching.' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes a line that is not JSON through as it is', () => {
|
||||
expect(format('Error: ENOENT: no such file or directory')).toEqual([
|
||||
{ level: 'info', message: 'Error: ENOENT: no such file or directory' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { CODEX_CLI } from '../codex';
|
||||
import { readFixtureLines } from './readFixture';
|
||||
|
||||
const format = CODEX_CLI.formatOutputLine;
|
||||
|
||||
function formatFixture(name: string): ReturnType<typeof format> {
|
||||
return readFixtureLines(name).flatMap(line => format(line));
|
||||
}
|
||||
|
||||
describe('CODEX_CLI.formatOutputLine', () => {
|
||||
it('renders a run with two tool calls and skips the thread and turn events', () => {
|
||||
expect(formatFixture('codex-tool-calls.jsonl')).toEqual([
|
||||
{
|
||||
level: 'info',
|
||||
message:
|
||||
'Agent: I will run these three operations in order and report the exact result of each.\n',
|
||||
},
|
||||
{ level: 'info', message: 'Tool call: expo.sandbox_exec {"command":"echo hi"}' },
|
||||
{ level: 'info', message: 'Tool result: stub ran: echo hi' },
|
||||
{ level: 'info', message: 'Tool call: expo.sandbox_list {}' },
|
||||
{ level: 'info', message: 'Tool result: no sandboxes' },
|
||||
{
|
||||
level: 'info',
|
||||
message:
|
||||
'Agent: The exact results, in order:\n\n1. `expo sandbox_exec` with command `echo hi`:\n ```json\n {"content":[{"type":"text","text":"stub ran: echo hi"}],"isError":false}\n ```\n\n2. `expo sandbox_list`:\n ```json\n {"content":[{"type":"text","text":"no sandboxes"}],"isError":false}\n ```\n\n3. `apply_patch` to create `probe.txt` with the single line `hello` failed:\n ```text\n patch rejected: writing is blocked by read-only sandbox; rejected by user approval settings\n ```\n The file was not created.',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders a refused tool call as a warning', () => {
|
||||
expect(formatFixture('codex-tool-call-refused.jsonl')).toEqual([
|
||||
{
|
||||
level: 'info',
|
||||
message:
|
||||
"Agent: I will run these three operations in order and report each tool's actual result.\n",
|
||||
},
|
||||
{ level: 'info', message: 'Tool call: expo.sandbox_exec {"command":"echo hi"}' },
|
||||
{
|
||||
level: 'warn',
|
||||
message: 'Tool error: MCP tool call requires approval, but approval policy is never',
|
||||
},
|
||||
{
|
||||
level: 'info',
|
||||
message: expect.stringMatching(
|
||||
/^Agent: 1\. Called `sandbox_exec` on the `expo` MCP server/
|
||||
),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders a failed turn with its transient errors as warnings and the final one as an error', () => {
|
||||
const reconnecting = (attempt: number): { level: 'warn'; message: string } => ({
|
||||
level: 'warn',
|
||||
message: `Error: Reconnecting... ${attempt}/5 (workspace routing discovery unauthorized (401))`,
|
||||
});
|
||||
expect(formatFixture('codex-failed-turn.jsonl')).toEqual([
|
||||
reconnecting(2),
|
||||
reconnecting(3),
|
||||
reconnecting(4),
|
||||
reconnecting(5),
|
||||
{
|
||||
level: 'warn',
|
||||
message:
|
||||
'Error: Falling back from WebSockets to HTTPS transport. workspace routing discovery unauthorized (401)',
|
||||
},
|
||||
reconnecting(1),
|
||||
reconnecting(2),
|
||||
reconnecting(3),
|
||||
reconnecting(4),
|
||||
reconnecting(5),
|
||||
{ level: 'warn', message: 'Error: workspace routing discovery unauthorized (401)' },
|
||||
{ level: 'error', message: 'Agent failed: workspace routing discovery unauthorized (401)' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders an agent message once, when it completes', () => {
|
||||
const item = {
|
||||
id: 'item_1',
|
||||
type: 'agent_message',
|
||||
text: 'Hello',
|
||||
};
|
||||
expect(format(JSON.stringify({ type: 'item.started', item }))).toEqual([]);
|
||||
expect(format(JSON.stringify({ type: 'item.completed', item }))).toEqual([
|
||||
{ level: 'info', message: 'Agent: Hello' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders a tool call without arguments', () => {
|
||||
const item = {
|
||||
id: 'item_1',
|
||||
type: 'mcp_tool_call',
|
||||
server: 'expo',
|
||||
tool: 'sandbox_list',
|
||||
arguments: null,
|
||||
result: null,
|
||||
error: null,
|
||||
status: 'in_progress',
|
||||
};
|
||||
expect(format(JSON.stringify({ type: 'item.started', item }))).toEqual([
|
||||
{ level: 'info', message: 'Tool call: expo.sandbox_list {}' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders a tool result the server marked as an error as a warning', () => {
|
||||
const item = {
|
||||
id: 'item_1',
|
||||
type: 'mcp_tool_call',
|
||||
server: 'expo',
|
||||
tool: 'sandbox_exec',
|
||||
arguments: { command: 'false' },
|
||||
result: { content: [{ type: 'text', text: 'exit code 1' }], structured_content: null },
|
||||
error: null,
|
||||
status: 'failed',
|
||||
};
|
||||
expect(format(JSON.stringify({ type: 'item.completed', item }))).toEqual([
|
||||
{ level: 'warn', message: 'Tool error: exit code 1' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips what it does not know without failing', () => {
|
||||
expect(format(JSON.stringify({ type: 'turn.completed', usage: {} }))).toEqual([]);
|
||||
expect(
|
||||
format(
|
||||
JSON.stringify({
|
||||
type: 'item.completed',
|
||||
item: { id: 'item_1', type: 'reasoning', text: 'Thinking.' },
|
||||
})
|
||||
)
|
||||
).toEqual([]);
|
||||
expect(format(JSON.stringify({ type: 'item.completed', item: 'bad' }))).toEqual([]);
|
||||
expect(format(JSON.stringify({ type: 'error', message: 42 }))).toEqual([]);
|
||||
expect(format('true')).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes a line that is not JSON through as it is', () => {
|
||||
expect(format('thread panicked')).toEqual([{ level: 'info', message: 'thread panicked' }]);
|
||||
});
|
||||
});
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
{"type": "system", "subtype": "init", "cwd": "/tmp/eas-agent-run-example/cwd", "session_id": "11111111-1111-4111-8111-111111111111", "tools": ["mcp__expo__sandbox_exec", "mcp__expo__sandbox_list"], "mcp_servers": [{"name": "expo", "status": "connected", "source": "dynamic"}], "model": "claude-haiku-4-5-20251001", "permissionMode": "default", "apiKeySource": "none", "claude_code_version": "2.1.291", "uuid": "22222222-2222-4222-8222-000000000000"}
|
||||
{"type": "assistant", "message": {"diagnostics": null, "id": "msg_001", "container": null, "model": "<synthetic>", "role": "assistant", "stop_details": null, "stop_reason": "stop_sequence", "stop_sequence": "", "type": "message", "usage": {"output_tokens_details": null, "input_tokens": 0, "output_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": null, "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": null, "iterations": null, "speed": null, "fallback_credit": null}, "content": [{"type": "text", "text": "Not logged in · Please run /login"}], "context_management": null}, "parent_tool_use_id": null, "session_id": "11111111-1111-4111-8111-111111111111", "uuid": "22222222-2222-4222-8222-000000000000", "timestamp": "2026-10-06T12:24:54.066Z", "error": "authentication_failed", "is_api_error_message": true}
|
||||
{"duration_api_ms": 0, "stop_reason": "stop_sequence", "session_id": "11111111-1111-4111-8111-111111111111", "total_cost_usd": 0, "usage": {"output_tokens_details": {"thinking_tokens": 0}, "input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 0, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": "", "iterations": [], "speed": "standard", "fallback_credit": null}, "modelUsage": {}, "permission_denials": [], "terminal_reason": "api_error", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": true, "num_turns": 1, "subtype": "success", "api_error_status": null, "result": "Not logged in · Please run /login", "type": "result", "duration_ms": 20, "uuid": "22222222-2222-4222-8222-000000000000", "queued_turn_count": 0, "result_index": 0}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{"type": "system", "subtype": "init", "cwd": "/tmp/eas-agent-run-example/cwd", "session_id": "11111111-1111-4111-8111-111111111111", "tools": ["mcp__expo__sandbox_exec", "mcp__expo__sandbox_list"], "mcp_servers": [{"name": "expo", "status": "connected", "source": "dynamic"}], "model": "claude-haiku-4-5-20251001", "permissionMode": "default", "apiKeySource": "none", "claude_code_version": "2.1.291", "uuid": "22222222-2222-4222-8222-000000000000"}
|
||||
{"type": "system", "subtype": "thinking_tokens", "estimated_tokens": 50, "estimated_tokens_delta": 50, "session_id": "11111111-1111-4111-8111-111111111111", "uuid": "22222222-2222-4222-8222-000000000000"}
|
||||
{"type": "system", "subtype": "thinking_tokens", "estimated_tokens": 135, "estimated_tokens_delta": 85, "session_id": "11111111-1111-4111-8111-111111111111", "uuid": "22222222-2222-4222-8222-000000000000"}
|
||||
{"type": "assistant", "message": {"model": "claude-haiku-4-5-20251001", "id": "msg_001", "type": "message", "role": "assistant", "content": [{"type": "thinking", "thinking": "", "signature": "signature-removed"}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 9, "cache_creation_input_tokens": 8450, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 8450}, "output_tokens": 1, "service_tier": "standard", "inference_geo": "not_available"}, "input_transformations": [], "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "11111111-1111-4111-8111-111111111111", "uuid": "22222222-2222-4222-8222-000000000000", "timestamp": "2026-10-06T12:24:56.369Z", "request_id": "req_001", "thinking_duration_ms": 1012}
|
||||
{"type": "assistant", "message": {"model": "claude-haiku-4-5-20251001", "id": "msg_001", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_001", "name": "mcp__expo__sandbox_exec", "input": {"command": "echo hi"}, "caller": {"type": "direct"}}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 9, "cache_creation_input_tokens": 8450, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 8450}, "output_tokens": 1, "service_tier": "standard", "inference_geo": "not_available"}, "input_transformations": [], "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "11111111-1111-4111-8111-111111111111", "uuid": "22222222-2222-4222-8222-000000000000", "timestamp": "2026-10-06T12:24:56.590Z", "request_id": "req_001", "tool_use_meta": [{"id": "toolu_01FxZWi8cmA45ua2osBST33c", "display_name": "Sandbox Exec", "server_display_name": "expo-stub"}]}
|
||||
{"type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_001", "type": "tool_result", "content": [{"type": "text", "text": "stub ran: echo hi"}]}]}, "parent_tool_use_id": null, "session_id": "11111111-1111-4111-8111-111111111111", "uuid": "22222222-2222-4222-8222-000000000000", "timestamp": "2026-10-06T12:24:56.604Z", "tool_use_result": [{"type": "text", "text": "stub ran: echo hi"}]}
|
||||
{"type": "system", "subtype": "thinking_tokens", "estimated_tokens": 50, "estimated_tokens_delta": 50, "session_id": "11111111-1111-4111-8111-111111111111", "uuid": "22222222-2222-4222-8222-000000000000"}
|
||||
{"type": "system", "subtype": "thinking_tokens", "estimated_tokens": 156, "estimated_tokens_delta": 106, "session_id": "11111111-1111-4111-8111-111111111111", "uuid": "22222222-2222-4222-8222-000000000000"}
|
||||
{"type": "assistant", "message": {"model": "claude-haiku-4-5-20251001", "id": "msg_002", "type": "message", "role": "assistant", "content": [{"type": "thinking", "thinking": "", "signature": "signature-removed"}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 8, "cache_creation_input_tokens": 206, "cache_read_input_tokens": 8450, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 206}, "output_tokens": 2, "service_tier": "standard", "inference_geo": "not_available"}, "input_transformations": [], "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "11111111-1111-4111-8111-111111111111", "uuid": "22222222-2222-4222-8222-000000000000", "timestamp": "2026-10-06T12:24:58.292Z", "request_id": "req_002", "thinking_duration_ms": 828}
|
||||
{"type": "assistant", "message": {"model": "claude-haiku-4-5-20251001", "id": "msg_002", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "The command returned exactly: `stub ran: echo hi`\n\nThe tools I can call are:\n\n1. `mcp__expo__sandbox_exec` — Execute a shell command in a running sandbox\n2. `mcp__expo__sandbox_list` — List the sandboxes of the project"}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 8, "cache_creation_input_tokens": 206, "cache_read_input_tokens": 8450, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 206}, "output_tokens": 2, "service_tier": "standard", "inference_geo": "not_available"}, "input_transformations": [], "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "11111111-1111-4111-8111-111111111111", "uuid": "22222222-2222-4222-8222-000000000000", "timestamp": "2026-10-06T12:24:58.835Z", "request_id": "req_002"}
|
||||
{"duration_api_ms": 4085, "stop_reason": "end_turn", "session_id": "11111111-1111-4111-8111-111111111111", "total_cost_usd": 0, "usage": {"input_tokens": 17, "cache_creation_input_tokens": 8656, "cache_read_input_tokens": 8450, "output_tokens": 310, "output_tokens_details": {"thinking_tokens": 180}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 8656, "ephemeral_5m_input_tokens": 0}, "inference_geo": "not_available", "iterations": [], "speed": "standard", "fallback_credit": null}, "modelUsage": {"claude-haiku-4-5-20251001": {"inputTokens": 17, "outputTokens": 310, "cacheReadInputTokens": 8450, "cacheCreationInputTokens": 8656, "webSearchRequests": 0, "costUSD": 0, "contextWindow": 200000, "maxOutputTokens": 32000, "thinkingTokens": 180, "canonicalModel": "claude-haiku-4-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "terminal_reason": "completed", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": false, "num_turns": 2, "subtype": "success", "api_error_status": null, "result": "The command returned exactly: `stub ran: echo hi`\n\nThe tools I can call are:\n\n1. `mcp__expo__sandbox_exec` — Execute a shell command in a running sandbox\n2. `mcp__expo__sandbox_list` — List the sandboxes of the project", "ttft_ms": 1696, "type": "result", "duration_ms": 4257, "uuid": "22222222-2222-4222-8222-000000000000", "ttft_stream_ms": 684, "time_to_request_ms": 85, "first_content_frame_ms": 684, "queued_turn_count": 0, "result_index": 0}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{"type": "thread.started", "thread_id": "01a10000-0000-7000-8000-000000000001"}
|
||||
{"type": "turn.started"}
|
||||
{"type": "error", "message": "Reconnecting... 2/5 (workspace routing discovery unauthorized (401))"}
|
||||
{"type": "error", "message": "Reconnecting... 3/5 (workspace routing discovery unauthorized (401))"}
|
||||
{"type": "error", "message": "Reconnecting... 4/5 (workspace routing discovery unauthorized (401))"}
|
||||
{"type": "error", "message": "Reconnecting... 5/5 (workspace routing discovery unauthorized (401))"}
|
||||
{"type": "item.completed", "item": {"id": "item_0", "type": "error", "message": "Falling back from WebSockets to HTTPS transport. workspace routing discovery unauthorized (401)"}}
|
||||
{"type": "error", "message": "Reconnecting... 1/5 (workspace routing discovery unauthorized (401))"}
|
||||
{"type": "error", "message": "Reconnecting... 2/5 (workspace routing discovery unauthorized (401))"}
|
||||
{"type": "error", "message": "Reconnecting... 3/5 (workspace routing discovery unauthorized (401))"}
|
||||
{"type": "error", "message": "Reconnecting... 4/5 (workspace routing discovery unauthorized (401))"}
|
||||
{"type": "error", "message": "Reconnecting... 5/5 (workspace routing discovery unauthorized (401))"}
|
||||
{"type": "error", "message": "workspace routing discovery unauthorized (401)"}
|
||||
{"type": "turn.failed", "error": {"message": "workspace routing discovery unauthorized (401)"}}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{"type": "thread.started", "thread_id": "01a10000-0000-7000-8000-000000000001"}
|
||||
{"type": "turn.started"}
|
||||
{"type": "item.completed", "item": {"id": "item_0", "type": "agent_message", "text": "I will run these three operations in order and report each tool's actual result.\n"}}
|
||||
{"type": "item.started", "item": {"id": "item_1", "type": "mcp_tool_call", "server": "expo", "tool": "sandbox_exec", "arguments": {"command": "echo hi"}, "result": null, "error": null, "status": "in_progress"}}
|
||||
{"type": "item.completed", "item": {"id": "item_1", "type": "mcp_tool_call", "server": "expo", "tool": "sandbox_exec", "arguments": {"command": "echo hi"}, "result": null, "error": {"message": "MCP tool call requires approval, but approval policy is never"}, "status": "failed"}}
|
||||
{"type": "item.completed", "item": {"id": "item_2", "type": "agent_message", "text": "1. Called `sandbox_exec` on the `expo` MCP server with command `echo hi`. Actual result:\n ```text\n MCP tool call requires approval, but approval policy is never\n ```\n The command did not run.\n\n2. Used `apply_patch` to create `probe.txt` with the single line `hello`. Actual result:\n ```text\n Script error:\n patch rejected: writing is blocked by read-only sandbox; rejected by user approval settings\n ```\n The file was not created.\n\n3. All callable tool names (being callable does not mean the current permissions allow running them):\n\n Direct tools:\n - `functions.exec`\n - `functions.wait`\n - `functions.request_user_input`\n - `functions.request_user_input_async`\n - `clock.sleep`\n\n Nested tools inside `functions.exec`:\n - `apply_patch`\n - `clock__curr_time`\n - `create_goal`\n - `get_goal`\n - `list_mcp_resource_templates`\n - `list_mcp_resources`\n - `mcp__expo__sandbox_exec`\n - `mcp__expo__sandbox_list`\n - `read_mcp_resource`\n - `update_goal`"}}
|
||||
{"type": "turn.completed", "usage": {"input_tokens": 48856, "cached_input_tokens": 28416, "cache_write_input_tokens": 0, "output_tokens": 518, "reasoning_output_tokens": 0}}
|
||||
@@ -0,0 +1,9 @@
|
||||
{"type": "thread.started", "thread_id": "01a10000-0000-7000-8000-000000000001"}
|
||||
{"type": "turn.started"}
|
||||
{"type": "item.completed", "item": {"id": "item_0", "type": "agent_message", "text": "I will run these three operations in order and report the exact result of each.\n"}}
|
||||
{"type": "item.started", "item": {"id": "item_1", "type": "mcp_tool_call", "server": "expo", "tool": "sandbox_exec", "arguments": {"command": "echo hi"}, "result": null, "error": null, "status": "in_progress"}}
|
||||
{"type": "item.completed", "item": {"id": "item_1", "type": "mcp_tool_call", "server": "expo", "tool": "sandbox_exec", "arguments": {"command": "echo hi"}, "result": {"content": [{"type": "text", "text": "stub ran: echo hi"}], "structured_content": null}, "error": null, "status": "completed"}}
|
||||
{"type": "item.started", "item": {"id": "item_2", "type": "mcp_tool_call", "server": "expo", "tool": "sandbox_list", "arguments": {}, "result": null, "error": null, "status": "in_progress"}}
|
||||
{"type": "item.completed", "item": {"id": "item_2", "type": "mcp_tool_call", "server": "expo", "tool": "sandbox_list", "arguments": {}, "result": {"content": [{"type": "text", "text": "no sandboxes"}], "structured_content": null}, "error": null, "status": "completed"}}
|
||||
{"type": "item.completed", "item": {"id": "item_3", "type": "agent_message", "text": "The exact results, in order:\n\n1. `expo sandbox_exec` with command `echo hi`:\n ```json\n {\"content\":[{\"type\":\"text\",\"text\":\"stub ran: echo hi\"}],\"isError\":false}\n ```\n\n2. `expo sandbox_list`:\n ```json\n {\"content\":[{\"type\":\"text\",\"text\":\"no sandboxes\"}],\"isError\":false}\n ```\n\n3. `apply_patch` to create `probe.txt` with the single line `hello` failed:\n ```text\n patch rejected: writing is blocked by read-only sandbox; rejected by user approval settings\n ```\n The file was not created."}}
|
||||
{"type": "turn.completed", "usage": {"input_tokens": 49143, "cached_input_tokens": 28672, "cache_write_input_tokens": 0, "output_tokens": 354, "reasoning_output_tokens": 0}}
|
||||
@@ -0,0 +1,11 @@
|
||||
import path from 'node:path';
|
||||
|
||||
const realFs = jest.requireActual<typeof import('node:fs')>('node:fs');
|
||||
|
||||
/** The lines of a capture of an agent's stdout; ids and local details replaced, text in English. */
|
||||
export function readFixtureLines(name: string): string[] {
|
||||
return realFs
|
||||
.readFileSync(path.join(__dirname, 'fixtures', name), 'utf8')
|
||||
.split('\n')
|
||||
.filter(line => line !== '');
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type AgentRunProviderCredentials } from '../agentRunLease';
|
||||
|
||||
export const MCP_SERVER_NAME = 'expo';
|
||||
|
||||
export interface McpServer {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface AgentInvocation {
|
||||
args: string[];
|
||||
env: Record<string, string>;
|
||||
stdin: string;
|
||||
secrets: string[];
|
||||
}
|
||||
|
||||
export interface AgentLogMessage {
|
||||
level: 'info' | 'warn' | 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface AgentCli {
|
||||
packageSpec: string;
|
||||
bin: string;
|
||||
formatOutputLine(line: string): AgentLogMessage[];
|
||||
}
|
||||
|
||||
export function parseJsonLine(line: string): unknown {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function tolerantArray<Element extends z.ZodType>(element: Element) {
|
||||
// `.catch(undefined)` turns an element the schema rejects into `undefined`.
|
||||
return z
|
||||
.array(element.optional().catch(undefined))
|
||||
.transform(elements =>
|
||||
elements.filter((parsed): parsed is z.output<Element> => parsed !== undefined)
|
||||
);
|
||||
}
|
||||
|
||||
/** The text blocks of a content list, as both the Anthropic API and MCP shape them. */
|
||||
export const ContentBlocksSchema = tolerantArray(
|
||||
z.object({ type: z.literal('text'), text: z.string() })
|
||||
);
|
||||
|
||||
export function contentBlocksText(blocks: z.output<typeof ContentBlocksSchema>): string {
|
||||
return blocks.map(block => block.text).join('\n');
|
||||
}
|
||||
|
||||
// Repeats the task because Codex's `resume --last` starts a new session when none was saved,
|
||||
// and that session would otherwise have no task.
|
||||
export function createContinuationPrompt(prompt: string): string {
|
||||
return `Your previous run of this task stopped before it was finished. Continue it from where it stopped; do not start over. The task was:\n\n${prompt}`;
|
||||
}
|
||||
|
||||
export function createProviderMismatchError(
|
||||
cliName: string,
|
||||
credentials: AgentRunProviderCredentials
|
||||
): SystemError {
|
||||
return new SystemError(
|
||||
`The agent run uses ${cliName}, but Expo issued ${credentials.provider} credentials for it. This is a problem on our side; contact Expo support.`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
type AgentCli,
|
||||
type AgentInvocation,
|
||||
type AgentLogMessage,
|
||||
ContentBlocksSchema,
|
||||
MCP_SERVER_NAME,
|
||||
type McpServer,
|
||||
contentBlocksText,
|
||||
createContinuationPrompt,
|
||||
createProviderMismatchError,
|
||||
parseJsonLine,
|
||||
tolerantArray,
|
||||
} from './agent';
|
||||
import { type AgentRunProviderCredentials } from '../agentRunLease';
|
||||
|
||||
// The flags and the output format below are only known to hold for this version.
|
||||
export const CLAUDE_CODE_CLI: AgentCli = {
|
||||
packageSpec: '@anthropic-ai/[email protected]',
|
||||
bin: 'claude',
|
||||
formatOutputLine: formatClaudeCodeOutputLine,
|
||||
};
|
||||
|
||||
export async function prepareClaudeCodeAsync({
|
||||
homeDirectory,
|
||||
minimalEnv,
|
||||
mcpServer,
|
||||
prompt,
|
||||
credentials,
|
||||
isResuming,
|
||||
}: {
|
||||
homeDirectory: string;
|
||||
minimalEnv: Record<string, string>;
|
||||
mcpServer: McpServer;
|
||||
prompt: string;
|
||||
credentials: AgentRunProviderCredentials;
|
||||
isResuming: boolean;
|
||||
}): Promise<AgentInvocation> {
|
||||
if (credentials.provider !== 'anthropic') {
|
||||
throw createProviderMismatchError('Claude Code', credentials);
|
||||
}
|
||||
const mcpConfigPath = path.join(homeDirectory, 'mcp.json');
|
||||
await fs.writeFile(
|
||||
mcpConfigPath,
|
||||
JSON.stringify({ mcpServers: { [MCP_SERVER_NAME]: { type: 'http', ...mcpServer } } }),
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
return {
|
||||
args: [
|
||||
'-p',
|
||||
...(isResuming ? ['--continue'] : []),
|
||||
// Print mode requires `--verbose` for `stream-json`.
|
||||
...['--output-format', 'stream-json', '--verbose'],
|
||||
// No built-in tools. Print mode denies any tool without an allow rule, so the MCP server
|
||||
// gets one.
|
||||
...['--tools', '', '--allowedTools', `mcp__${MCP_SERVER_NAME}`],
|
||||
...['--mcp-config', mcpConfigPath, '--strict-mcp-config'],
|
||||
],
|
||||
env: {
|
||||
...minimalEnv,
|
||||
CLAUDE_CODE_OAUTH_TOKEN: credentials.accessToken,
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
||||
},
|
||||
stdin: isResuming ? createContinuationPrompt(prompt) : prompt,
|
||||
secrets: [credentials.accessToken],
|
||||
};
|
||||
}
|
||||
|
||||
const AssistantBlockSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('text'), text: z.string() }),
|
||||
z.object({
|
||||
type: z.literal('tool_use'),
|
||||
name: z.string(),
|
||||
input: z.record(z.string(), z.unknown()),
|
||||
}),
|
||||
]);
|
||||
const ToolResultBlockSchema = z.object({
|
||||
type: z.literal('tool_result'),
|
||||
content: z.union([z.string(), ContentBlocksSchema]).default(''),
|
||||
is_error: z.boolean().optional(),
|
||||
});
|
||||
const ClaudeCodeEventSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('system'),
|
||||
subtype: z.literal('init'),
|
||||
mcp_servers: tolerantArray(z.object({ name: z.string(), status: z.string() })),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('assistant'),
|
||||
message: z.object({ content: tolerantArray(AssistantBlockSchema) }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('user'),
|
||||
message: z.object({ content: tolerantArray(ToolResultBlockSchema) }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('result'),
|
||||
subtype: z.string(),
|
||||
is_error: z.boolean(),
|
||||
result: z.string().optional(),
|
||||
errors: z.array(z.string()).optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
function formatClaudeCodeOutputLine(line: string): AgentLogMessage[] {
|
||||
const json = parseJsonLine(line);
|
||||
if (json === undefined) {
|
||||
return [{ level: 'info', message: line }];
|
||||
}
|
||||
const event = ClaudeCodeEventSchema.safeParse(json);
|
||||
if (!event.success) {
|
||||
return [];
|
||||
}
|
||||
switch (event.data.type) {
|
||||
case 'system':
|
||||
return event.data.mcp_servers.map(server =>
|
||||
server.status === 'connected'
|
||||
? { level: 'info', message: `MCP server ${server.name} is connected.` }
|
||||
: {
|
||||
level: 'warn',
|
||||
message: `MCP server ${server.name} is not connected (${server.status}).`,
|
||||
}
|
||||
);
|
||||
case 'assistant':
|
||||
return event.data.message.content.map(block => {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
return { level: 'info', message: `Agent: ${block.text}` };
|
||||
case 'tool_use':
|
||||
return {
|
||||
level: 'info',
|
||||
message: `Tool call: ${block.name} ${JSON.stringify(block.input)}`,
|
||||
};
|
||||
}
|
||||
});
|
||||
case 'user':
|
||||
return event.data.message.content.map(block => {
|
||||
const text =
|
||||
typeof block.content === 'string' ? block.content : contentBlocksText(block.content);
|
||||
return block.is_error
|
||||
? { level: 'warn', message: `Tool error: ${text}` }
|
||||
: { level: 'info', message: `Tool result: ${text}` };
|
||||
});
|
||||
case 'result': {
|
||||
// `result` repeats the agent's last text block, so on success there is nothing to add.
|
||||
const { is_error, result, errors, subtype } = event.data;
|
||||
return is_error
|
||||
? [
|
||||
{
|
||||
level: 'error',
|
||||
message: `Agent failed: ${result || errors?.join('\n') || subtype}`,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
type AgentCli,
|
||||
type AgentInvocation,
|
||||
type AgentLogMessage,
|
||||
ContentBlocksSchema,
|
||||
MCP_SERVER_NAME,
|
||||
type McpServer,
|
||||
contentBlocksText,
|
||||
createContinuationPrompt,
|
||||
createProviderMismatchError,
|
||||
parseJsonLine,
|
||||
} from './agent';
|
||||
import { type AgentRunProviderCredentials } from '../agentRunLease';
|
||||
|
||||
// The flags, settings and output format below are only known to hold for this version.
|
||||
export const CODEX_CLI: AgentCli = {
|
||||
packageSpec: '@openai/[email protected]',
|
||||
bin: 'codex',
|
||||
formatOutputLine: formatCodexOutputLine,
|
||||
};
|
||||
|
||||
export async function prepareCodexAsync({
|
||||
homeDirectory,
|
||||
minimalEnv,
|
||||
mcpServer,
|
||||
prompt,
|
||||
credentials,
|
||||
isResuming,
|
||||
}: {
|
||||
homeDirectory: string;
|
||||
minimalEnv: Record<string, string>;
|
||||
mcpServer: McpServer;
|
||||
prompt: string;
|
||||
credentials: AgentRunProviderCredentials;
|
||||
isResuming: boolean;
|
||||
}): Promise<AgentInvocation> {
|
||||
if (credentials.provider !== 'openai') {
|
||||
throw createProviderMismatchError('Codex', credentials);
|
||||
}
|
||||
const codexHomeDirectory = path.join(homeDirectory, '.codex');
|
||||
await fs.mkdir(codexHomeDirectory, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(codexHomeDirectory, 'config.toml'),
|
||||
[
|
||||
'approval_policy = "never"',
|
||||
// Codex always offers `apply_patch`.
|
||||
'sandbox_mode = "read-only"',
|
||||
'web_search = "disabled"',
|
||||
'check_for_update_on_startup = false',
|
||||
'',
|
||||
'[features]',
|
||||
'shell_tool = false',
|
||||
'view_image = false',
|
||||
'image_generation = false',
|
||||
// Apps and plugins installed on the credential owner's ChatGPT account.
|
||||
'apps = false',
|
||||
'plugins = false',
|
||||
'',
|
||||
// A subagent's session could otherwise be the one `resume --last` picks.
|
||||
'[agents]',
|
||||
'enabled = false',
|
||||
'',
|
||||
'[analytics]',
|
||||
'enabled = false',
|
||||
'',
|
||||
`[mcp_servers.${MCP_SERVER_NAME}]`,
|
||||
// JSON strings are valid TOML basic strings.
|
||||
`url = ${JSON.stringify(mcpServer.url)}`,
|
||||
// `approval_policy` would otherwise refuse the server's tools that are not read-only.
|
||||
'default_tools_approval_mode = "approve"',
|
||||
'',
|
||||
`[mcp_servers.${MCP_SERVER_NAME}.http_headers]`,
|
||||
...Object.entries(mcpServer.headers).map(
|
||||
([name, value]) => `${JSON.stringify(name)} = ${JSON.stringify(value)}`
|
||||
),
|
||||
'',
|
||||
].join('\n'),
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(codexHomeDirectory, 'auth.json'),
|
||||
JSON.stringify({
|
||||
auth_mode: 'chatgptAuthTokens',
|
||||
tokens: {
|
||||
id_token: credentials.idToken,
|
||||
access_token: credentials.accessToken,
|
||||
// www keeps the refresh token.
|
||||
refresh_token: '',
|
||||
account_id: credentials.accountId,
|
||||
},
|
||||
// Codex sends no bearer token unless this is set.
|
||||
last_refresh: new Date().toISOString(),
|
||||
}),
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
return {
|
||||
args: [
|
||||
'exec',
|
||||
// Without it Codex skips settings it does not know, leaving on whatever they turn off.
|
||||
'--strict-config',
|
||||
...['--json', '--skip-git-repo-check'],
|
||||
...(isResuming ? ['resume', '--last', '-'] : []),
|
||||
],
|
||||
env: minimalEnv,
|
||||
stdin: isResuming ? createContinuationPrompt(prompt) : prompt,
|
||||
secrets: [credentials.accessToken, credentials.idToken],
|
||||
};
|
||||
}
|
||||
|
||||
const CodexItemSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('agent_message'), text: z.string() }),
|
||||
z.object({
|
||||
type: z.literal('mcp_tool_call'),
|
||||
server: z.string(),
|
||||
tool: z.string(),
|
||||
arguments: z.record(z.string(), z.unknown()).nullable(),
|
||||
result: z.object({ content: ContentBlocksSchema }).nullable(),
|
||||
error: z.object({ message: z.string() }).nullable(),
|
||||
status: z.string(),
|
||||
}),
|
||||
z.object({ type: z.literal('error'), message: z.string() }),
|
||||
]);
|
||||
const CodexEventSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('item.started'), item: CodexItemSchema.optional().catch(undefined) }),
|
||||
z.object({
|
||||
type: z.literal('item.completed'),
|
||||
item: CodexItemSchema.optional().catch(undefined),
|
||||
}),
|
||||
z.object({ type: z.literal('error'), message: z.string() }),
|
||||
z.object({ type: z.literal('turn.failed'), error: z.object({ message: z.string() }) }),
|
||||
]);
|
||||
|
||||
function formatCodexOutputLine(line: string): AgentLogMessage[] {
|
||||
const json = parseJsonLine(line);
|
||||
if (json === undefined) {
|
||||
return [{ level: 'info', message: line }];
|
||||
}
|
||||
const event = CodexEventSchema.safeParse(json);
|
||||
if (!event.success) {
|
||||
return [];
|
||||
}
|
||||
switch (event.data.type) {
|
||||
case 'item.started': {
|
||||
const item = event.data.item;
|
||||
if (item?.type !== 'mcp_tool_call') {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
level: 'info',
|
||||
message: `Tool call: ${item.server}.${item.tool} ${JSON.stringify(item.arguments ?? {})}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
case 'item.completed':
|
||||
return formatCompletedItem(event.data.item);
|
||||
case 'error':
|
||||
return [{ level: 'warn', message: `Error: ${event.data.message}` }];
|
||||
case 'turn.failed':
|
||||
return [{ level: 'error', message: `Agent failed: ${event.data.error.message}` }];
|
||||
}
|
||||
}
|
||||
|
||||
function formatCompletedItem(
|
||||
item: z.output<typeof CodexItemSchema> | undefined
|
||||
): AgentLogMessage[] {
|
||||
switch (item?.type) {
|
||||
case 'agent_message':
|
||||
return [{ level: 'info', message: `Agent: ${item.text}` }];
|
||||
case 'mcp_tool_call': {
|
||||
const resultText = contentBlocksText(item.result?.content ?? []);
|
||||
return item.status === 'failed'
|
||||
? [{ level: 'warn', message: `Tool error: ${item.error?.message ?? resultText}` }]
|
||||
: [{ level: 'info', message: `Tool result: ${resultText}` }];
|
||||
}
|
||||
case 'error':
|
||||
return [{ level: 'warn', message: `Error: ${item.message}` }];
|
||||
case undefined:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
import { z } from 'zod';
|
||||
|
||||
const PostApi = {
|
||||
'/token': {
|
||||
request: z.object({
|
||||
grant_type: z.literal('urn:ietf:params:oauth:grant-type:jwt-bearer'),
|
||||
assertion: z.string().min(1),
|
||||
}),
|
||||
response: z.object({
|
||||
access_token: z.string().min(1),
|
||||
expires_in: z.number().finite().gt(60),
|
||||
token_type: z.string().refine(value => value.toLowerCase() === 'bearer'),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const OAuthErrorZ = z.enum([
|
||||
'invalid_request',
|
||||
'invalid_client',
|
||||
'invalid_grant',
|
||||
'unauthorized_client',
|
||||
'unsupported_grant_type',
|
||||
'invalid_scope',
|
||||
'server_error',
|
||||
'temporarily_unavailable',
|
||||
]);
|
||||
|
||||
export class GooglePlayAuthRequestError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly errorCode?: z.output<typeof OAuthErrorZ>
|
||||
) {
|
||||
super(`Google Play OAuth request failed (HTTP ${status})${errorCode ? `: ${errorCode}` : ''}.`);
|
||||
}
|
||||
}
|
||||
|
||||
export class GooglePlayAuthClient {
|
||||
private readonly baseUrl = 'https://oauth2.googleapis.com';
|
||||
|
||||
async postAsync<TPath extends keyof typeof PostApi>(
|
||||
path: TPath,
|
||||
body: z.input<(typeof PostApi)[TPath]['request']>,
|
||||
signal?: AbortSignal
|
||||
): Promise<z.output<(typeof PostApi)[TPath]['response']>> {
|
||||
const schema = PostApi[path];
|
||||
return await this.requestAsync({
|
||||
method: 'POST',
|
||||
path,
|
||||
body,
|
||||
requestSchema: schema.request,
|
||||
responseSchema: schema.response,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
private async requestAsync({
|
||||
method,
|
||||
path,
|
||||
body,
|
||||
requestSchema,
|
||||
responseSchema,
|
||||
signal,
|
||||
}: {
|
||||
method: 'POST';
|
||||
path: string;
|
||||
body: unknown;
|
||||
requestSchema: z.ZodType<any>;
|
||||
responseSchema: z.ZodType<any>;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<any> {
|
||||
const parsedBody = await asyncResult((async () => requestSchema.parse(body))());
|
||||
if (!parsedBody.ok) {
|
||||
throw new Error(
|
||||
`Malformed request to Google Play OAuth: ${z.prettifyError(
|
||||
parsedBody.enforceError() as z.ZodError
|
||||
)}`
|
||||
);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(new URL(path, this.baseUrl).toString(), {
|
||||
method,
|
||||
redirect: 'manual',
|
||||
signal,
|
||||
body: new URLSearchParams(parsedBody.value),
|
||||
});
|
||||
} catch (cause) {
|
||||
signal?.throwIfAborted();
|
||||
throw new SystemError('Google Play OAuth request failed before a response was received.', {
|
||||
cause,
|
||||
});
|
||||
}
|
||||
let text: string;
|
||||
try {
|
||||
text = await response.text();
|
||||
} catch (cause) {
|
||||
signal?.throwIfAborted();
|
||||
throw new SystemError(
|
||||
`Could not read the Google Play OAuth response (HTTP ${response.status}).`,
|
||||
{
|
||||
cause,
|
||||
}
|
||||
);
|
||||
}
|
||||
const parsedJson = await asyncResult((async () => JSON.parse(text))());
|
||||
if (!response.ok) {
|
||||
const errorCode = OAuthErrorZ.safeParse(parsedJson.ok ? parsedJson.value?.error : undefined);
|
||||
throw new GooglePlayAuthRequestError(
|
||||
response.status,
|
||||
errorCode.success ? errorCode.data : undefined
|
||||
);
|
||||
}
|
||||
if (!parsedJson.ok) {
|
||||
throw new SystemError(
|
||||
`Malformed JSON response from Google Play OAuth (HTTP ${response.status}).`
|
||||
);
|
||||
}
|
||||
const parsedResponse = await asyncResult(
|
||||
(async () => responseSchema.parse(parsedJson.value))()
|
||||
);
|
||||
if (!parsedResponse.ok) {
|
||||
throw new SystemError(
|
||||
`Malformed response from Google Play OAuth (HTTP ${response.status}): ${z.prettifyError(
|
||||
parsedResponse.enforceError() as z.ZodError
|
||||
)}`
|
||||
);
|
||||
}
|
||||
return parsedResponse.value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as jose from 'jose';
|
||||
import { KeyObject } from 'node:crypto';
|
||||
|
||||
import { GooglePlayAuthClient } from './GooglePlayAuthClient';
|
||||
|
||||
export type GoogleServiceAccount = {
|
||||
client_email: string;
|
||||
private_key: KeyObject;
|
||||
private_key_id?: string;
|
||||
};
|
||||
|
||||
export namespace GooglePlayAuthUtils {
|
||||
async function createAssertionAsync(serviceAccount: GoogleServiceAccount): Promise<string> {
|
||||
return await new jose.SignJWT({
|
||||
scope: 'https://www.googleapis.com/auth/androidpublisher',
|
||||
})
|
||||
.setProtectedHeader({ alg: 'RS256', typ: 'JWT', kid: serviceAccount.private_key_id })
|
||||
.setIssuer(serviceAccount.client_email)
|
||||
.setAudience('https://oauth2.googleapis.com/token')
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('1h')
|
||||
.sign(serviceAccount.private_key);
|
||||
}
|
||||
|
||||
export async function createTokenAsync(
|
||||
serviceAccount: GoogleServiceAccount,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ value: string; expiresAt: number }> {
|
||||
const startedAt = Date.now();
|
||||
const assertion = await createAssertionAsync(serviceAccount);
|
||||
const response = await new GooglePlayAuthClient().postAsync(
|
||||
'/token',
|
||||
{ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion },
|
||||
signal
|
||||
);
|
||||
return { value: response.access_token, expiresAt: startedAt + response.expires_in * 1000 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
import { z } from 'zod';
|
||||
|
||||
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}` : '.'}`);
|
||||
}
|
||||
}
|
||||
|
||||
const ReleaseStatusZ = z.enum(['draft', 'inProgress', 'halted', 'completed']);
|
||||
|
||||
const ReleaseNoteZ = z.object({
|
||||
language: z.string(),
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
const ReleaseZ = z.object({
|
||||
name: z.string().optional(),
|
||||
versionCodes: z.array(z.string()),
|
||||
status: ReleaseStatusZ,
|
||||
userFraction: z.number().gt(0).lt(1).optional(),
|
||||
releaseNotes: z.array(ReleaseNoteZ).optional(),
|
||||
});
|
||||
|
||||
const GetApi = {
|
||||
'/androidpublisher/v3/applications/:packageName/edits/:editId': {
|
||||
path: z.object({ packageName: z.string().min(1), editId: z.string().min(1) }),
|
||||
request: z.object({}),
|
||||
query: z.object({}),
|
||||
response: z.object({ id: z.string().min(1), expiryTimeSeconds: z.string().optional() }),
|
||||
},
|
||||
'/androidpublisher/v3/applications/:packageName/edits/:editId/tracks/:track': {
|
||||
path: z.object({
|
||||
packageName: z.string().min(1),
|
||||
editId: z.string().min(1),
|
||||
track: z.string().min(1),
|
||||
}),
|
||||
request: z.object({}),
|
||||
query: z.object({}),
|
||||
response: z.object({
|
||||
track: z.string().min(1),
|
||||
releases: z.array(ReleaseZ).optional(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const PostApi = {
|
||||
'/androidpublisher/v3/applications/:packageName/edits': {
|
||||
path: z.object({ packageName: z.string().min(1) }),
|
||||
request: z.object({}),
|
||||
query: z.object({}),
|
||||
response: z.object({ id: z.string().min(1), expiryTimeSeconds: z.string().optional() }),
|
||||
},
|
||||
'/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() }),
|
||||
response: z.object({ id: z.string().min(1), expiryTimeSeconds: z.string().optional() }),
|
||||
},
|
||||
'/androidpublisher/v3/applications/:packageName/edits/:editId:validate': {
|
||||
path: z.object({ packageName: z.string().min(1), editId: z.string().min(1) }),
|
||||
request: z.object({}),
|
||||
query: z.object({}),
|
||||
response: z.object({ id: z.string().min(1), expiryTimeSeconds: z.string().optional() }),
|
||||
},
|
||||
};
|
||||
|
||||
const PutApi = {
|
||||
'/androidpublisher/v3/applications/:packageName/edits/:editId/tracks/:track': {
|
||||
path: z.object({
|
||||
packageName: z.string().min(1),
|
||||
editId: z.string().min(1),
|
||||
track: z.string().min(1),
|
||||
}),
|
||||
request: z.object({
|
||||
track: z.string().min(1),
|
||||
releases: z.array(ReleaseZ).optional(),
|
||||
}),
|
||||
query: z.object({}),
|
||||
response: z.object({
|
||||
track: z.string().min(1),
|
||||
releases: z.array(ReleaseZ).optional(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const DeleteApi = {
|
||||
'/androidpublisher/v3/applications/:packageName/edits/:editId': {
|
||||
path: z.object({ packageName: z.string().min(1), editId: z.string().min(1) }),
|
||||
},
|
||||
};
|
||||
|
||||
export class GooglePlayClient {
|
||||
private readonly baseUrl = 'https://androidpublisher.googleapis.com';
|
||||
private cachedToken?: Promise<{ value: string; expiresAt: number }>;
|
||||
|
||||
constructor(private readonly serviceAccount: GoogleServiceAccount) {}
|
||||
|
||||
private async getTokenAsync(signal?: AbortSignal): Promise<string> {
|
||||
signal?.throwIfAborted();
|
||||
|
||||
while (this.cachedToken) {
|
||||
const pending = this.cachedToken;
|
||||
const cached = await pending;
|
||||
signal?.throwIfAborted();
|
||||
if (Date.now() < cached.expiresAt - 60_000) {
|
||||
return cached.value;
|
||||
}
|
||||
if (this.cachedToken === pending) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const pending = GooglePlayAuthUtils.createTokenAsync(this.serviceAccount, signal);
|
||||
this.cachedToken = pending;
|
||||
|
||||
try {
|
||||
return (await pending).value;
|
||||
} catch (error) {
|
||||
if (this.cachedToken === pending) {
|
||||
this.cachedToken = undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getAsync<TPath extends keyof typeof GetApi>(
|
||||
path: TPath,
|
||||
params: z.input<(typeof GetApi)[TPath]['path']>,
|
||||
signal?: AbortSignal
|
||||
): Promise<z.output<(typeof GetApi)[TPath]['response']>> {
|
||||
return await this.sendJsonRequestAsync('GET', path, GetApi[path], {}, params, {}, signal);
|
||||
}
|
||||
|
||||
async postAsync<TPath extends keyof typeof PostApi>(
|
||||
path: TPath,
|
||||
body: z.input<(typeof PostApi)[TPath]['request']>,
|
||||
params: z.input<(typeof PostApi)[TPath]['path']>,
|
||||
options: { query?: z.input<(typeof PostApi)[TPath]['query']>; signal?: AbortSignal } = {}
|
||||
): Promise<z.output<(typeof PostApi)[TPath]['response']>> {
|
||||
return await this.sendJsonRequestAsync(
|
||||
'POST',
|
||||
path,
|
||||
PostApi[path],
|
||||
body,
|
||||
params,
|
||||
options.query ?? {},
|
||||
options.signal
|
||||
);
|
||||
}
|
||||
|
||||
async putAsync<TPath extends keyof typeof PutApi>(
|
||||
path: TPath,
|
||||
body: z.input<(typeof PutApi)[TPath]['request']>,
|
||||
params: z.input<(typeof PutApi)[TPath]['path']>,
|
||||
signal?: AbortSignal
|
||||
): Promise<z.output<(typeof PutApi)[TPath]['response']>> {
|
||||
return await this.sendJsonRequestAsync('PUT', path, PutApi[path], body, params, {}, signal);
|
||||
}
|
||||
|
||||
async deleteAsync<TPath extends keyof typeof DeleteApi>(
|
||||
path: TPath,
|
||||
params: z.input<(typeof DeleteApi)[TPath]['path']>,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
let url: string = path;
|
||||
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);
|
||||
}
|
||||
|
||||
private async sendJsonRequestAsync(
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||
path: string,
|
||||
schema: {
|
||||
path: z.ZodType<Record<string, string>>;
|
||||
request: z.ZodType;
|
||||
query: z.ZodType;
|
||||
response: z.ZodType;
|
||||
},
|
||||
body: unknown,
|
||||
params: unknown,
|
||||
query: unknown,
|
||||
signal?: AbortSignal
|
||||
): Promise<any> {
|
||||
const parsedBody = schema.request.parse(body);
|
||||
|
||||
for (const [key, value] of Object.entries(schema.path.parse(params))) {
|
||||
path = path.replace(`:${key}`, encodeURIComponent(value));
|
||||
}
|
||||
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(
|
||||
schema.query.parse(query) as Record<string, unknown>
|
||||
)) {
|
||||
if (value !== undefined) {
|
||||
search.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
const response = await this.requestAsync(
|
||||
method,
|
||||
search.size ? `${path}?${search}` : path,
|
||||
method === 'GET' ? undefined : JSON.stringify(parsedBody),
|
||||
signal
|
||||
);
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
signal?.throwIfAborted();
|
||||
throw new SystemError(
|
||||
`Could not read the Google Play JSON response (HTTP ${response.status}).`
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = schema.response.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
throw new SystemError(
|
||||
`Malformed response from Google Play (HTTP ${response.status}): ${z.prettifyError(parsed.error)}`
|
||||
);
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
private async requestAsync(
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||
path: string,
|
||||
body?: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Response> {
|
||||
const url = new URL(path, this.baseUrl);
|
||||
const token = await this.getTokenAsync(signal);
|
||||
let response: Response;
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
response = await fetch(url.toString(), {
|
||||
method,
|
||||
body,
|
||||
signal,
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
redirect: 'manual',
|
||||
});
|
||||
} catch {
|
||||
signal?.throwIfAborted();
|
||||
throw new SystemError('Google Play request failed before a response was received.');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
signal?.throwIfAborted();
|
||||
}
|
||||
const parsed = z
|
||||
.object({
|
||||
error: z.object({
|
||||
message: z.string().optional(),
|
||||
errors: z.array(z.object({ reason: z.string().optional() })).optional(),
|
||||
}),
|
||||
})
|
||||
.safeParse(data);
|
||||
throw new GooglePlayApiError(
|
||||
response.status,
|
||||
parsed.success ? (parsed.data.error.message ?? '') : '',
|
||||
parsed.success
|
||||
? (parsed.data.error.errors?.flatMap(error => (error.reason ? [error.reason] : [])) ?? [])
|
||||
: []
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import nock from 'nock';
|
||||
import { inspect } from 'node:util';
|
||||
|
||||
import { GooglePlayAuthClient, GooglePlayAuthRequestError } from '../GooglePlayAuthClient';
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
|
||||
const request = {
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer' as const,
|
||||
assertion: 'caller-signed-assertion',
|
||||
};
|
||||
let client: GooglePlayAuthClient;
|
||||
beforeEach(() => {
|
||||
nock.disableNetConnect();
|
||||
client = new GooglePlayAuthClient();
|
||||
});
|
||||
afterEach(() => {
|
||||
const pending = nock.pendingMocks();
|
||||
nock.cleanAll();
|
||||
nock.enableNetConnect();
|
||||
expect(pending).toEqual([]);
|
||||
});
|
||||
|
||||
it('posts the caller request and returns a validated response on each call', async () => {
|
||||
nock('https://oauth2.googleapis.com')
|
||||
.post('/token', request)
|
||||
.reply(200, { access_token: 'first-token', expires_in: 3600, token_type: 'Bearer' })
|
||||
.post('/token', request)
|
||||
.reply(200, { access_token: 'second-token', expires_in: 1800, token_type: 'Bearer' });
|
||||
await expect(client.postAsync('/token', request)).resolves.toEqual({
|
||||
access_token: 'first-token',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer',
|
||||
});
|
||||
await expect(client.postAsync('/token', request)).resolves.toEqual({
|
||||
access_token: 'second-token',
|
||||
expires_in: 1800,
|
||||
token_type: 'Bearer',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ access_token: '', expires_in: 3600, token_type: 'Bearer' },
|
||||
{ access_token: 'example-token', expires_in: 60, token_type: 'Bearer' },
|
||||
{ access_token: 'example-token', expires_in: '3600', token_type: 'Bearer' },
|
||||
{ access_token: 'example-token', expires_in: 3600, token_type: 1 },
|
||||
{ access_token: 'example-token', expires_in: 3600, token_type: 'Basic' },
|
||||
])(
|
||||
'includes validation details without token data for invalid OAuth responses (%j)',
|
||||
async body => {
|
||||
nock('https://oauth2.googleapis.com').post('/token').reply(200, body);
|
||||
const result = client.postAsync('/token', request);
|
||||
await expect(result).rejects.toThrow('Malformed response from Google Play OAuth (HTTP 200):');
|
||||
try {
|
||||
await result;
|
||||
} catch (error) {
|
||||
expect(inspect(error, { depth: null })).not.toContain('example-token');
|
||||
expect((error as Error).message).toMatch(/access_token|expires_in|token_type/);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
[{ error: 'invalid_grant', error_description: 'example-token' }, 'invalid_grant'],
|
||||
[{ error: 'example-token' }, undefined],
|
||||
['<html>example-token</html>', undefined],
|
||||
])(
|
||||
'keeps HTTP status and known OAuth codes without retaining response data (%j)',
|
||||
async (body, errorCode) => {
|
||||
nock('https://oauth2.googleapis.com').post('/token').reply(400, body);
|
||||
let error: unknown;
|
||||
try {
|
||||
await client.postAsync('/token', request);
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
expect(error).toBeInstanceOf(GooglePlayAuthRequestError);
|
||||
expect(error).toMatchObject({ status: 400, errorCode });
|
||||
expect((error as Error).message).toContain('HTTP 400');
|
||||
if (errorCode) {
|
||||
expect((error as Error).message).toContain(errorCode);
|
||||
}
|
||||
expect(inspect(error, { depth: null })).not.toContain('example-token');
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['network failure', 'malformed JSON'])('returns a system error for %s', async failure => {
|
||||
const endpoint = nock('https://oauth2.googleapis.com').post('/token');
|
||||
if (failure === 'network failure') {
|
||||
endpoint.replyWithError('connection failed');
|
||||
} else {
|
||||
endpoint.reply(200, 'example-token is not JSON');
|
||||
}
|
||||
const result = client.postAsync('/token', request);
|
||||
await expect(result).rejects.toBeInstanceOf(SystemError);
|
||||
if (failure === 'network failure') {
|
||||
await expect(result).rejects.toMatchObject({
|
||||
cause: { message: expect.stringContaining('connection failed') },
|
||||
});
|
||||
} else {
|
||||
await expect(result).rejects.toThrow(
|
||||
'Malformed JSON response from Google Play OAuth (HTTP 200).'
|
||||
);
|
||||
try {
|
||||
await result;
|
||||
} catch (error) {
|
||||
expect(inspect(error, { depth: null })).not.toContain('example-token');
|
||||
}
|
||||
}
|
||||
});
|
||||
Loaded 100 of 208 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user