Compare commits

..
Author SHA1 Message Date
szdziedzicandClaude Opus 5.5 b10d44f0ed [eas-cli] Add CHANGELOG entry
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-24 23:49:48 +02:00
szdziedzicandClaude Opus 5.5 1e2a72982f [eas-cli] Stop spinners from repeating their text after a terminal resize
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-24 23:49:23 +02:00
287 changed files with 4190 additions and 26900 deletions

No files matched your search

@@ -1,43 +0,0 @@
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
+2 -2
View File
@@ -15,9 +15,9 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node: ['22', '24']
node: ['20.18.3', '22', '24']
include:
- node: '22'
- node: '20.18.3'
coverage: true
name: Test with Node ${{ matrix.node }}
steps:
+2 -114
View File
@@ -10,116 +10,10 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 Bug fixes
### 🧹 Chores
## [24.13.0](https://github.com/expo/eas-cli/releases/tag/v24.13.0) - 2026-10-12
### 🎉 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))
## [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))
- [eas-cli] Stop spinners from printing their text over and over after the terminal is resized. ([#4473](https://github.com/expo/eas-cli/pull/4473) 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))
- [eas-cli] Add `--upload-source-maps` to `eas update`, which uploads the generated source maps to EAS so that stack traces from an update can be symbolicated. Source maps are stripped of `sourcesContent` before upload. ([#4436](https://github.com/expo/eas-cli/pull/4436) by [@douglowder](https://github.com/douglowder))
- [build-tools] Upload the embedded bundle after build by default for projects on SDK 58 and later. Set `EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE` to `0` to disable it. On SDK 57 and below the feature remains experimental and opt-in via `EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE`. ([#4433](https://github.com/expo/eas-cli/pull/4433) by [@brentvatne](https://github.com/brentvatne))
- [build-tools] Save manual iOS and Android live preview screenshots as session artifacts when supported by the preview server. ([#4481](https://github.com/expo/eas-cli/pull/4481) by [@vonovak](https://github.com/vonovak))
- [build-tools] Add the `eas/install_mitmproxy` step. ([#4307](https://github.com/expo/eas-cli/pull/4307) by [@gwdp](https://github.com/gwdp))
- [build-tools] Start serve-sim with `--network-capture`, and with `--network-capture-field` for each requested field, when a simulator session requests network capture. ([#4307](https://github.com/expo/eas-cli/pull/4307) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Allow workflow submissions to skip loading app config when project and application identifiers are supplied. ([#4471](https://github.com/expo/eas-cli/pull/4471) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Record Android device sessions and upload the screen recording, including the partial recording a killed Device Hub leaves behind. ([#4418](https://github.com/expo/eas-cli/pull/4418) by [@vonovak](https://github.com/vonovak))
- [build-tools] Add start and collect steps that attach bounded simulator logs from serve-sim to device run sessions. ([#4463](https://github.com/expo/eas-cli/pull/4463) by [@sjkim-expo](https://github.com/sjkim-expo))
- [build-tools] Add `eas/start_agent_device_session`, one step for agent-device sessions that boots the device, downloads, installs and launches the app, and starts the session, with the independent parts in parallel. ([#4491](https://github.com/expo/eas-cli/pull/4491) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-build-job] Add `sentry` workflow interpolation context schema for Sentry-triggered workflow runs. ([#4453](https://github.com/expo/eas-cli/pull/4453) by [@sswrk](https://github.com/sswrk))
### 🐛 Bug fixes
- [eas-cli] Stop `eas update:rollback` from always forcing non-interactive mode. ([#4504](https://github.com/expo/eas-cli/pull/4504) by [@gwdp](https://github.com/gwdp))
- [build-tools] Pass the step cancellation signal to `eas/download_build`. ([#4511](https://github.com/expo/eas-cli/pull/4511) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Accept `null` for the TestFlight group `hasAccessToAllBuilds` attribute from App Store Connect. ([#4528](https://github.com/expo/eas-cli/pull/4528) by [@sswrk](https://github.com/sswrk))
- [eas-cli] Support `queued` and `starting` status filters in `eas simulator:list` and keep simulator commands compatible with the current GraphQL schema. ([#4527](https://github.com/expo/eas-cli/pull/4527) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Explain how to resolve missing or expired Apple agreements when App Store Connect rejects a request. ([#4513](https://github.com/expo/eas-cli/pull/4513) by [@sjchmiela](https://github.com/sjchmiela))
- [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))
## [24.8.0](https://github.com/expo/eas-cli/releases/tag/v24.8.0) - 2026-09-24
### 🎉 New features
@@ -127,7 +21,6 @@ This is the log of notable changes to EAS CLI and related packages.
- [eas-cli] Add `--build-fingerprint` to `eas simulator` to install the newest build with a given fingerprint. ([#4472](https://github.com/expo/eas-cli/pull/4472) by [@tchayen](https://github.com/tchayen))
- [eas-cli] Accept build IDs in `workflow:ssh <id>`. ([#4452](https://github.com/expo/eas-cli/pull/4452) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [eas-cli] Make `eas simulator:*` commands visible in help output. ([#4456](https://github.com/expo/eas-cli/pull/4456) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Add `eas/update_testflight_metadata` to set changelogs and TestFlight groups for an uploaded build with an App Store Connect API key. The step fails if a requested group does not exist. ([#4475](https://github.com/expo/eas-cli/pull/4475) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Pass `--share-url` when launching serve-sim so Share copies the expo.dev preview page instead of the tunnel URL. ([#4434](https://github.com/expo/eas-cli/pull/4434) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas integrations:supabase:advisors` to list unresolved Supabase Security and Performance Advisor findings for the linked Supabase project. ([#4342](https://github.com/expo/eas-cli/pull/4342) by [@fiberjw](https://github.com/fiberjw))
- [eas-cli] Accept individual App Store Connect API keys for submissions, TestFlight setup, and metadata. ([#4249](https://github.com/expo/eas-cli/pull/4249) by [@sswrk](https://github.com/sswrk))
@@ -136,20 +29,16 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 Bug fixes
- [build-tools] Report early Argent server exits during simulator startup instead of waiting for the full readiness timeout. ([#4454](https://github.com/expo/eas-cli/pull/4454) by [@sjkim-expo](https://github.com/sjkim-expo))
- [eas-cli] Stop spinners from printing a new line on every frame after the terminal gets narrower. ([#4470](https://github.com/expo/eas-cli/pull/4470) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Exclude time waiting for simulator concurrency from the session startup timeout and show queue progress. ([#4411](https://github.com/expo/eas-cli/pull/4411) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] List `eas simulator` only once in `eas --help`. The `sim` shorthand is now a hidden alias, so it still runs but no longer duplicates the entry. ([#4457](https://github.com/expo/eas-cli/pull/4457) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [build-tools] Start the Argent tool-server with `ARGENT_EMULATOR_NO_WINDOW=1`, so Argent can boot the Android emulator again after it stops. Without it, the windowed emulator fails on the Linux image because `libpulse.so.0` is missing. ([#4465](https://github.com/expo/eas-cli/pull/4465) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Fix `eas account:view` printing `Role: undefined` for the new Release Manager role. ([#4300](https://github.com/expo/eas-cli/pull/4300) by [@byronkarlen](https://github.com/byronkarlen))
- [eas-cli] Include legacy serve-sim sessions in `eas simulator:list --type web-preview-only`, and list `web-preview-only` only once in the `--type` options. ([#4466](https://github.com/expo/eas-cli/pull/4466) by [@szdziedzic](https://github.com/szdziedzic))
### 🧹 Chores
- [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))
- 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. ([#XXXX](https://github.com/expo/eas-cli/pull/XXXX) 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
@@ -261,7 +150,6 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 Bug fixes
- [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
+1 -1
View File
@@ -164,7 +164,7 @@ Each package has independent TypeScript compilation:
TypeScript configs:
- `tsconfig.json` - Base configuration (extends @tsconfig/node22)
- `tsconfig.json` - Base configuration (extends @tsconfig/node20)
- `tsconfig.build.json` - Production builds
- `tsconfig.allowUnused.json` - Development with relaxed rules
+1 -1
View File
@@ -12,7 +12,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that you d
to access the functionality of and directly benefit from the
functionality of the Licensed Work.
Change Date: 2029-10-01
Change Date: 2029-07-15
Change License: MIT
View File
Whitespace-only changes.
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "24.13.0",
"PRODUCTION": "24.13.0"
"STAGING": "24.8.0",
"PRODUCTION": "24.8.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "24.13.0",
"version": "24.8.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+1 -1
View File
@@ -6,4 +6,4 @@ MISE_NODE_COREPACK = "true"
actionlint = "1.7.12"
gcloud = "570.0.0"
jq = "1.8.1"
node = "22.23.3"
node = "20.19.4"
+1 -1
View File
@@ -22,7 +22,7 @@
},
"devDependencies": {
"@types/jest": "29.5.11",
"@types/node": "22.20.5",
"@types/node": "20.19.43",
"eslint-plugin-async-protect": "3.1.0",
"jest": "29.7.0",
"jest-watch-typeahead": "2.2.2",
+1
View File
@@ -1,5 +1,6 @@
node_modules/
dist/
bin/record-sim
.secrets*
!.secrets.example
@@ -1,13 +0,0 @@
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;
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "24.13.0",
"version": "24.8.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -23,6 +23,7 @@
"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",
@@ -30,7 +31,6 @@
"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.13.0",
"@expo/eas-build-job": "24.13.0",
"@expo/downloader": "24.8.0",
"@expo/eas-build-job": "24.8.0",
"@expo/env": "^0.4.0",
"@expo/logger": "24.13.0",
"@expo/logger": "24.8.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.13.0",
"@expo/template-file": "24.13.0",
"@expo/turtle-spawn": "24.13.0",
"@expo/steps": "24.8.0",
"@expo/template-file": "24.8.0",
"@expo/turtle-spawn": "24.8.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": "22.20.5",
"@types/node": "20.19.43",
"@types/node-fetch": "^2.6.11",
"@types/node-forge": "^1.3.11",
"@types/plist": "^3.0.5",
@@ -0,0 +1,4 @@
.build/
.swiftpm/
DerivedData/
.DS_Store
@@ -0,0 +1,28 @@
// 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"]),
]
)
@@ -0,0 +1,79 @@
# 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`.
@@ -0,0 +1,192 @@
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()
}
}
@@ -0,0 +1,96 @@
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
}
}
@@ -0,0 +1,77 @@
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]
}
@@ -0,0 +1,140 @@
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
}
}
}
@@ -0,0 +1,644 @@
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
}
}
}
@@ -0,0 +1,60 @@
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
}
}
@@ -0,0 +1,159 @@
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)
}
@@ -1,5 +0,0 @@
import { Android } from '@expo/eas-build-job';
export function resolveArtifactPath(job: Android.Job): string {
return job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}';
}
@@ -60,8 +60,7 @@ jest.mock('../../utils/expoUpdates', () => ({
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync: jest.fn(async () => null),
}));
jest.mock('../../utils/expoUpdatesEmbedded', () => ({
...jest.requireActual('../../utils/expoUpdatesEmbedded'),
uploadEmbeddedBundleAsync: jest.fn(async () => ({ status: 'uploaded' })),
uploadEmbeddedBundleAsync: jest.fn(),
}));
jest.mock('../../utils/hooks', () => ({
Hook: {
@@ -318,108 +317,36 @@ describe(androidBuilder, () => {
expect(runBuilderWithHooksAsync).toHaveBeenCalledWith(ctx, expect.any(Function));
});
describe('embedded bundle upload phase', () => {
function createContext({
env = {},
sdkVersion,
}: {
env?: Record<string, string>;
sdkVersion?: string;
}): BuildContext<Android.Job> {
return new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
...env,
},
uploadArtifact: jest.fn(),
metadata: sdkVersion ? { sdkVersion } : undefined,
});
}
it('runs by default on SDK 58 and later', async () => {
const ctx = createContext({ sdkVersion: '58.0.0' });
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
expect.objectContaining({ job: ctx.job, env: ctx.env, projectDir: '/workingdir/build' })
);
it('runs the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is set', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1',
},
uploadArtifact: jest.fn(),
});
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);
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
});
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect.objectContaining({
build_phase: BuildPhase.UPLOAD_EMBEDDED_BUNDLE.toLowerCase(),
result: phaseResult,
})
);
it('skips the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is not set', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
});
it('skips by default on SDK 57 and earlier', async () => {
const ctx = createContext({ sdkVersion: '57.0.0' });
await androidBuilder(ctx);
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
it('skips by default when the SDK version is unknown', async () => {
const ctx = createContext({});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
it('runs on SDK 57 and earlier when EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE is 1', async () => {
const ctx = createContext({
sdkVersion: '57.0.0',
env: { EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE: '1' },
});
await androidBuilder(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 () => {
const ctx = createContext({
sdkVersion: '58.0.0',
env: { EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE: '0' },
});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
it('runs on SDK 57 and earlier when the experimental EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is 1', async () => {
const ctx = createContext({
sdkVersion: '57.0.0',
env: { EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1' },
});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
expect.objectContaining({ job: ctx.job })
);
});
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
});
+4 -19
View File
@@ -11,7 +11,6 @@ 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';
@@ -33,10 +32,7 @@ import {
configureExpoUpdatesIfInstalledAsync,
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
} from '../utils/expoUpdates';
import {
shouldUploadEmbeddedBundle,
uploadEmbeddedBundleAsync,
} from '../utils/expoUpdatesEmbedded';
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
@@ -221,26 +217,15 @@ async function buildInnerAsync(
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
await uploadApplicationArchive(ctx, {
patternOrPath: resolveArtifactPath(ctx.job),
patternOrPath: ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}',
rootDir: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
});
});
if (shouldUploadEmbeddedBundle(ctx)) {
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
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();
}
await uploadEmbeddedBundleAsync(ctx);
});
}
+4 -18
View File
@@ -29,10 +29,7 @@ import {
configureExpoUpdatesIfInstalledAsync,
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
} from '../utils/expoUpdates';
import {
shouldUploadEmbeddedBundle,
uploadEmbeddedBundleAsync,
} from '../utils/expoUpdatesEmbedded';
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
@@ -237,26 +234,15 @@ async function buildInnerAsync(
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
await uploadApplicationArchive(ctx, {
patternOrPath: resolveArtifactPath(ctx.job),
patternOrPath: resolveArtifactPath(ctx),
rootDir: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
});
});
if (shouldUploadEmbeddedBundle(ctx)) {
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
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();
}
await uploadEmbeddedBundleAsync(ctx);
});
}
@@ -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>) {
+4 -4
View File
@@ -15,10 +15,10 @@ export function resolveScheme(ctx: BuildContext<Ios.Job>): string {
return schemes[0];
}
export function resolveArtifactPath(job: Ios.Job): string {
if (job.applicationArchivePath) {
return job.applicationArchivePath;
} else if (job.simulator) {
export function resolveArtifactPath(ctx: BuildContext<Ios.Job>): string {
if (ctx.job.applicationArchivePath) {
return ctx.job.applicationArchivePath;
} else if (ctx.job.simulator) {
return 'ios/build/Build/Products/*simulator/*.app';
} else {
return 'ios/build/*.ipa';
+2 -18
View File
@@ -10,7 +10,6 @@ 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';
@@ -20,7 +19,6 @@ import { generateGymfileFromTemplateFunction } from './functions/generateGymfile
import { createGetCredentialsForBuildTriggeredByGithubIntegration } from './functions/getCredentialsForBuildTriggeredByGitHubIntegration';
import { injectAndroidCredentialsFunction } from './functions/injectAndroidCredentials';
import { createInstallMaestroBuildFunction } from './functions/installMaestro';
import { createInstallMitmproxyBuildFunction } from './functions/installMitmproxy';
import { createInstallBuildFunction } from './functions/installBuild';
import { createInstallNodeModulesBuildFunction } from './functions/installNodeModules';
import { createInstallPodsBuildFunction } from './functions/installPods';
@@ -28,7 +26,6 @@ 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';
@@ -45,10 +42,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 { createStartAgentDeviceSessionBuildFunction } from './functions/startAgentDeviceSession';
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
import { createStartArgentRemoteSessionBuildFunction } from './functions/startArgentRemoteSession';
import { createStartAppiumRemoteSessionBuildFunction } from './functions/startAppiumRemoteSession';
@@ -58,15 +54,11 @@ import { createStartIosSimulatorRecordingsBuildFunction } from './functions/star
import { createStartLocalEgressBuildFunction } from './functions/startLocalEgress';
import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/startWebPreviewRemoteSession';
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
import { createStartServeSimLogsBuildFunction } from './functions/startServeSimLogs';
import { createCollectServeSimLogsBuildFunction } from './functions/collectServeSimLogs';
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';
import { createWaitForPosthogMetricFunction } from './functions/waitForPosthogMetric';
import { createUploadPosthogSourcemapsFunction } from './functions/uploadPosthogSourcemaps';
@@ -85,9 +77,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createInstallNodeModulesBuildFunction(),
createPrebuildBuildFunction(),
createReadIpaInfoBuildFunction(),
createReadAndroidAppInfoBuildFunction(),
createDownloadBuildFunction(ctx),
createDownloadSubmissionArchiveFunction(ctx),
createInstallBuildFunction(),
createLaunchApplicationFunction(),
createEasExportBuildFunction(),
@@ -109,7 +99,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
generateGymfileFromTemplateFunction(),
runFastlaneFunction(),
parseXcactivitylogFunction(),
createStartAgentDeviceSessionBuildFunction(ctx),
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
createStartArgentRemoteSessionBuildFunction(ctx),
createStartAppiumRemoteSessionBuildFunction(ctx),
createStartAndroidEmulatorBuildFunction(),
@@ -121,13 +111,9 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
createStartWebPreviewRemoteSessionBuildFunction(ctx),
createStartServeSimMetricsBuildFunction(),
createStartServeSimLogsBuildFunction(),
createCollectServeSimLogsBuildFunction(ctx),
createStartSandboxBuildFunction(ctx),
createRunAgentBuildFunction(ctx),
createCollectServeSimMetricsBuildFunction(ctx),
createInstallMaestroBuildFunction(),
createInstallMitmproxyBuildFunction(),
createInstallPodsBuildFunction(),
createSendSlackMessageFunction(),
@@ -142,7 +128,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createSubmissionEntityFunction(),
createUploadToAscBuildFunction(),
createUpdateTestFlightMetadataBuildFunction(),
createReportMaestroTestResultsFunction(ctx),
createMaestroTestsBuildFunction(ctx),
@@ -152,7 +137,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
functions.push(
...[
createFindAndUploadBuildArtifactsBuildFunction(ctx),
createUploadEmbeddedBundleBuildFunction(ctx),
createResolveBuildConfigBuildFunction(ctx),
createGetCredentialsForBuildTriggeredByGithubIntegration(ctx),
]
@@ -219,71 +219,4 @@ 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,7 +10,6 @@ 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';
@@ -33,7 +32,6 @@ 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 {
@@ -305,19 +303,6 @@ 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,
@@ -405,19 +390,6 @@ 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,
@@ -511,19 +483,6 @@ 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,
@@ -1,47 +0,0 @@
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),
});
});
@@ -1,335 +0,0 @@
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import type { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
import { turtleFetch } from '../../../utils/turtleFetch';
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import {
spawnDetached,
startNgrokTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
jest.mock('@expo/turtle-spawn');
jest.mock('../../../sentry');
jest.mock('../../../utils/turtleFetch');
jest.mock('../../../utils/AndroidEmulatorUtils');
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(),
startNgrokTunnelAsync: jest.fn(),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
waitForFileAsync: jest.fn(),
}));
const finishHost = jest.fn();
const closePreview = jest.fn();
const stopTool = jest.fn();
const stopTunnel = jest.fn();
const stopEvents = jest.fn();
const openPreview = jest.fn();
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
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)
.mockResolvedValue([{ serialId: 'emulator-5554', state: 'device' } as never]);
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 4567, token: 'daemon-token' });
for (const stop of [finishHost, closePreview, stopTool, stopTunnel, stopEvents]) {
stop.mockResolvedValue(undefined);
}
openPreview.mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://preview.example.test',
closeAsync: closePreview,
});
jest
.mocked(startDeviceSessionHostAsync)
.mockResolvedValue({ openPreviewAsync: openPreview, finishAsync: finishHost });
jest.mocked(spawnDetached).mockReturnValue({
pid: undefined,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: stopTool,
});
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
url: 'https://tool.example.test',
subdomainId: 'tool-id',
stopAsync: stopTunnel,
});
const events = { stopAsync: stopEvents, getLastEventObservedAt: () => undefined };
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue(events);
jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue(events);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe.each([
['Appium', createStartAppiumRemoteSessionBuildFunction],
['Agent Device', createStartAgentDeviceSessionBuildFunction],
] as const)('%s host ownership', (name, createFunction) => {
async function runAsync() {
const fn = createFunction({} as CustomBuildContext);
await fn.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
// Inputs a step does not get default to undefined, like in a real step call.
inputs: new Proxy({} as Record<string, { value: unknown }>, {
get: (target, id: string) => target[id] ?? { value: undefined },
}),
outputs: {},
env: {
DEVICE_RUN_SESSION_ID: 'session-id',
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
NGROK_AUTHTOKEN: 'token',
},
} as never
);
}
it('holds the host until session completion', async () => {
await runAsync();
expect(openPreview).toHaveBeenCalledWith({ baseDomain: 'example.test' });
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewApiUrl: 'https://preview.example.test',
}),
})
);
expect(finishHost).toHaveBeenCalledTimes(1);
expect(closePreview).not.toHaveBeenCalled();
expect(finishHost.mock.invocationCallOrder[0]).toBeGreaterThan(
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
);
expect(stopTool).toHaveBeenCalledTimes(1);
expect(stopEvents).toHaveBeenCalledTimes(1);
expect(stopTunnel).toHaveBeenCalledTimes(1);
});
it('stops automation while recording finalization or upload is pending', async () => {
let release!: () => void;
let stopped!: () => void;
const pendingFinish = new Promise<void>(resolve => {
release = resolve;
});
const toolStopped = new Promise<void>(resolve => {
stopped = resolve;
});
finishHost.mockReturnValueOnce(pendingFinish);
stopTool.mockImplementationOnce(async () => {
stopped();
});
let completed = false;
const running = runAsync().then(() => {
completed = true;
});
try {
await toolStopped;
expect(stopTunnel).toHaveBeenCalledTimes(1);
expect(finishHost).toHaveBeenCalledTimes(1);
expect(completed).toBe(false);
} finally {
release();
await running;
}
});
it('finalizes recording and stops automation while the tunnel close is pending', async () => {
let release!: () => void;
let finished!: () => void;
const pendingClose = new Promise<void>(resolve => {
release = resolve;
});
const hostFinished = new Promise<void>(resolve => {
finished = resolve;
});
const toolStopped = new Promise<void>(resolve => {
stopTool.mockImplementationOnce(async () => resolve());
});
stopTunnel.mockReturnValueOnce(pendingClose);
finishHost.mockImplementationOnce(async () => {
finished();
});
const running = runAsync();
try {
// Both settle while the tunnel close is still held open.
await Promise.all([hostFinished, toolStopped]);
expect(stopTunnel).toHaveBeenCalledTimes(1);
} finally {
release();
await running;
}
});
it('still stops the automation process when event collection stop rejects', async () => {
stopEvents.mockRejectedValueOnce(new Error('collector failed'));
// Agent Device wraps its collector stop in a catch-all helper; Appium does not.
const outcome = await runAsync().then(
() => 'resolved',
(err: Error) => err.message
);
expect(outcome).toBe(name === 'Appium' ? 'collector failed' : 'resolved');
expect(stopTool).toHaveBeenCalledTimes(1);
expect(stopTunnel).toHaveBeenCalledTimes(1);
expect(finishHost).toHaveBeenCalledTimes(1);
});
it('still stops automation when recording finalization rejects', async () => {
finishHost.mockRejectedValueOnce(new Error('recording cleanup failed'));
await expect(runAsync()).rejects.toThrow('recording cleanup failed');
expect(stopTool).toHaveBeenCalledTimes(1);
expect(stopTunnel).toHaveBeenCalledTimes(1);
expect(Sentry.capture).not.toHaveBeenCalled();
});
it.each(['preview', 'config', 'wait'])('finishes the host after %s fails', async phase => {
const error = new Error(`${phase} failed`);
// A teardown failure must not replace the error that ended the session.
finishHost.mockRejectedValueOnce(new Error('recording cleanup failed'));
if (phase === 'preview') {
openPreview.mockRejectedValueOnce(error);
} else if (phase === 'config') {
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
} else {
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
}
await expect(runAsync()).rejects.toBe(error);
expect(finishHost).toHaveBeenCalledTimes(1);
// An agent-device preview failure aborts the daemon task, which then may not start its
// daemon or tunnel. Everything that started must be stopped.
const abortsDaemonTask = name === 'Agent Device' && phase === 'preview';
expect(stopTool).toHaveBeenCalledTimes(
abortsDaemonTask ? jest.mocked(spawnDetached).mock.calls.length : 1
);
expect(stopTunnel).toHaveBeenCalledTimes(
abortsDaemonTask ? jest.mocked(startNgrokTunnelAsync).mock.calls.length : 1
);
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.objectContaining({ message: 'recording cleanup failed' }) },
'Could not stop the session host during remote session teardown.'
);
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not stop the session host after the remote session failed',
expect.objectContaining({ message: 'recording cleanup failed' }),
{ level: 'warning' }
);
});
});
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);
});
});
@@ -11,7 +11,6 @@ import * as tar from 'tar';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import * as files from '../../../utils/files';
import { createDownloadBuildFunction, downloadBuildAsync } from '../downloadBuild';
// contains a 'TestApp.app/TestApp' file with 'i am executable' content
@@ -256,144 +255,6 @@ describe('downloadBuild', () => {
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
});
it('passes the abort signal to the build lookup', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest
.mocked(fetch)
.mockResolvedValue(
createSuccessfulResponse({ body: APP_TAR_GZ_BUFFER, url: APPLICATION_ARCHIVE_URL })
);
const buildId = randomUUID();
await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
signal: new AbortController().signal,
});
expect(graphqlClient.query).toHaveBeenCalledWith(
expect.anything(),
{ buildId },
{ fetch: expect.any(Function) }
);
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ signal: expect.any(Object) })
);
});
it('stops a pending build lookup when aborted', async () => {
// The lookup request runs through the abort-aware fetch from graphqlAbortContext and
// hangs until its signal fires, like a stalled API call.
const realFetch = globalThis.fetch;
globalThis.fetch = jest.fn(
(_input: unknown, init?: RequestInit) =>
new Promise<never>((_resolve, reject) => {
init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason));
})
) as typeof globalThis.fetch;
const graphqlClient = {
query: jest.fn((_query, _variables, context: { fetch: typeof globalThis.fetch }) => ({
toPromise: () =>
context.fetch('https://api.expo.dev/graphql', {}).then(
() => ({ data: undefined }),
(networkError: Error) => ({ error: new CombinedError({ networkError }) })
),
})),
} as unknown as Client;
const controller = new AbortController();
const failure = new Error('daemon failed');
try {
const download = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
signal: controller.signal,
});
await new Promise(resolve => setImmediate(resolve));
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
controller.abort(failure);
await expect(download).rejects.toBe(failure);
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
} finally {
globalThis.fetch = realFetch;
}
});
it('lets a started extraction finish before it reports an abort', async () => {
jest
.mocked(fetch)
.mockResolvedValue(
createSuccessfulResponse({ body: APP_TAR_GZ_BUFFER, url: APPLICATION_ARCHIVE_URL })
);
const { decompressTarAsync } = jest.requireActual<typeof files>('../../../utils/files');
const controller = new AbortController();
const failure = new Error('daemon failed');
let extractionDirectory: string | undefined;
let extractionFinished = false;
const decompressSpy = jest.spyOn(files, 'decompressTarAsync').mockImplementation(async args => {
extractionDirectory = args.destinationDirectory;
// The startup fails while the archive is being extracted.
controller.abort(failure);
await decompressTarAsync(args);
extractionFinished = true;
});
try {
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient: createMockGraphqlClient({}),
robotAccessToken: null,
extensions: ['app'],
signal: controller.signal,
})
).rejects.toBe(failure);
// The download settled only after the extraction wrote its last file.
expect(extractionFinished).toBe(true);
expect(
await fs.promises.readFile(
path.join(extractionDirectory!, 'TestApp.app', 'TestApp'),
'utf8'
)
).toBe('i am executable\n');
} finally {
decompressSpy.mockRestore();
}
});
it('does not look up the build when already aborted', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
const failure = new Error('boot failed');
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
signal: AbortSignal.abort(failure),
})
).rejects.toBe(failure);
expect(graphqlClient.query).not.toHaveBeenCalled();
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('rejects missing or ambiguous build sources', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
@@ -563,34 +424,6 @@ describe('downloadBuild', () => {
});
describe('createDownloadBuildFunction', () => {
it('aborts a pending archive request when the step times out', async () => {
let requestAborted = false;
jest.mocked(fetch).mockImplementation(
async (_url, options) =>
await new Promise<Response>((_resolve, reject) => {
options?.signal?.addEventListener('abort', () => {
requestAborted = true;
reject(new Error('Archive request aborted'));
});
})
);
const graphqlClient = createMockGraphqlClient({});
const step = createDownloadBuildFunction({
graphqlClient,
} as any).createBuildStepFromFunctionCall(
createGlobalContextMock({ staticContextContent: { job: {} } }),
{
callInputs: { application_archive_url: APPLICATION_ARCHIVE_URL, extensions: ['ipa'] },
timeoutMs: 100,
}
);
await expect(step.executeAsync()).rejects.toThrow('timed out');
expect(fetch).toHaveBeenCalledTimes(1);
expect(requestAborted).toBe(true);
expect(graphqlClient.query).not.toHaveBeenCalled();
});
it('should download a build', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
@@ -1,192 +0,0 @@
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,127 +463,114 @@ describe('createInstallMaestroBuildFunction', () => {
}
});
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-')
);
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.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('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 }
);
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.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.1.27\n',
command === 'xcodebuild' ? 'Xcode 26.5\nBuild version 17F90\n' : 'maestro-runner 1.2.3\n',
})) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock({
@@ -606,7 +593,7 @@ describe('createInstallMaestroBuildFunction', () => {
mockedSpawn.mockImplementation((async (command: string) => {
switch (command) {
case 'maestro-runner':
return { stdout: 'maestro-runner 1.1.27\n' };
return { stdout: 'maestro-runner 1.2.3\n' };
case 'xcodebuild':
return { stdout: 'Xcode 26.5\nBuild version 17F90\n' };
case 'xcrun':
@@ -646,7 +633,7 @@ describe('createInstallMaestroBuildFunction', () => {
});
await expect(step.executeAsync()).resolves.toBeUndefined();
expect(step.getOutputValueByName('maestro_version')).toBe('1.1.27');
expect(step.getOutputValueByName('maestro_version')).toBe('1.2.3');
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,151 +0,0 @@
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';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
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();
});
function createStep(env: Record<string, string>): BuildStep {
const globalCtx = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.DARWIN });
globalCtx.updateEnv(env);
return createInstallMitmproxyBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: {},
});
}
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(
'xattr',
['-dr', 'com.apple.quarantine', APP],
expect.anything()
);
expect(spawnedCommands()).toEqual(['which', 'xattr -dr', 'xattr -p', 'mitmdump']);
});
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(spawnedCommands()).toEqual(['mitmdump']);
});
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();
expect(mockedSpawn).toHaveBeenCalledWith(
'brew',
['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 () => {
mockCommands({ runs: [false, false] });
await expect(
createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync()
).resolves.toBeUndefined();
});
});
@@ -271,124 +271,20 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockedSpawn.mock.calls[0][1]).toContain('--parallel=2');
});
it.each(['html', 'allure'])(
'accepts %s output_format with maestro-runner and keeps JUnit',
async format => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const copyFileSpy = jest.spyOn(fs, 'copyFile').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
backend: 'maestro-runner',
output_format: format,
});
await step.executeAsync();
expect(mockedSpawn.mock.calls[0][1]).toEqual(
expect.arrayContaining([
'--output=/home/expo/.maestro/tests/android-maestro-runner-attempt-0',
])
);
expect(copyFileSpy).toHaveBeenCalledWith(
'/home/expo/.maestro/tests/android-maestro-runner-attempt-0/junit-report.xml',
'/home/expo/.maestro/tests/junit-reports/android-maestro-junit-attempt-0.xml'
);
expect(parser.mergeJUnitReports).not.toHaveBeenCalled();
expect(step.getOutputValueByName('final_report_path')).toBe(
format === 'html'
? '/home/expo/.maestro/tests/android-maestro-runner-attempt-0/report.html'
: '/home/expo/.maestro/tests/android-maestro-runner-attempt-0/allure-results'
);
expect(mockedRunnerHarvest).not.toHaveBeenCalled();
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name:
format === 'html' ? 'Maestro Runner HTML Report' : 'Maestro Runner Allure Results',
paths: [
format === 'html'
? '/home/expo/.maestro/tests/android-maestro-runner-attempt-0'
: '/home/expo/.maestro/tests/android-maestro-runner-attempt-0/allure-results',
],
}),
})
);
}
);
it('does not expose a selected runner report before an attempt starts', async () => {
const step = createStep({
flow_path: [],
platform: 'android',
backend: 'maestro-runner',
output_format: 'html',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(step.getOutputValueByName('final_report_path')).toBeUndefined();
expect(step.getOutputValueByName('junit_report_directory')).toBe(
'/home/expo/.maestro/tests/junit-reports'
);
});
it('rejects an unknown output_format with maestro-runner', async () => {
it('rejects a non-junit output_format with maestro-runner', async () => {
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
backend: 'maestro-runner',
output_format: 'csv',
output_format: 'html',
});
await expect(step.executeAsync()).rejects.toThrow(
'maestro-runner supports "junit", "html", and "allure" output_format values'
'maestro-runner only supports the "junit" output_format'
);
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('uploads the last runner HTML report even when the test fails', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
jest.spyOn(fs, 'copyFile').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
backend: 'maestro-runner',
output_format: 'html',
retries: 1,
retry_failed_only: false,
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
name: 'Maestro Runner HTML Report',
paths: ['/home/expo/.maestro/tests/android-maestro-runner-attempt-1'],
}),
})
);
expect(step.getOutputValueByName('final_report_path')).toBe(
'/home/expo/.maestro/tests/android-maestro-runner-attempt-1/report.html'
);
});
it('does not fail a passing test when the runner report upload fails', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest.spyOn(fs, 'copyFile').mockResolvedValue();
mockUploadArtifact.mockRejectedValue(new Error('upload failed'));
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
backend: 'maestro-runner',
output_format: 'allure',
});
await expect(step.executeAsync()).resolves.toBeUndefined();
});
it('logs that maestro-runner does not support direct DADB', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest.spyOn(fs, 'copyFile').mockResolvedValue();
@@ -1070,7 +966,7 @@ describe('createMaestroTestsBuildFunction', () => {
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('uses $HOME/.maestro/tests output path and uploads the Maestro CLI HTML report', async () => {
it('uses $HOME/.maestro/tests output path for non-junit formats (e.g. html)', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
@@ -1081,119 +977,11 @@ describe('createMaestroTestsBuildFunction', () => {
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests.
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests so the
// whole-directory upload picks it up.
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(/\.maestro\/tests\/android-maestro-html\.html$/);
expect(outputArg).not.toMatch(/junit-reports/);
expect(step.getOutputValueByName('final_report_path')).toBe(
'/home/expo/.maestro/tests/android-maestro-html.html'
);
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name: 'Maestro HTML Report',
paths: ['/home/expo/.maestro/tests/android-maestro-html.html'],
}),
})
);
});
it('uploads the Maestro CLI HTML report even when tests fail', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'html',
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
name: 'Maestro HTML Report',
paths: ['/home/expo/.maestro/tests/android-maestro-html.html'],
}),
})
);
});
it('warns that Maestro CLI does not support Allure reports', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
const logger = createMockLogger();
jest.mocked(logger.child).mockReturnValue(logger);
const step = createStep(
{
flow_path: ['flows/a.yaml'],
output_format: 'allure',
platform: 'android',
},
{ logger }
);
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(logger.warn).toHaveBeenCalledWith(
'Maestro CLI does not support Allure reports; no Allure artifact was uploaded.'
);
expect(mockUploadArtifact).not.toHaveBeenCalled();
});
it('clears the Maestro CLI HTML report before every retry attempt', async () => {
const removeSpy = jest.spyOn(fs, 'rm').mockResolvedValue();
mockedSpawn.mockRejectedValue(rejectExit1());
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'html',
platform: 'android',
retries: 1,
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(
removeSpy.mock.calls.filter(
([target]) => target === '/home/expo/.maestro/tests/android-maestro-html.html'
)
).toHaveLength(2);
expect(removeSpy).toHaveBeenNthCalledWith(
1,
'/home/expo/.maestro/tests/android-maestro-html.html',
{
force: true,
}
);
expect(removeSpy).toHaveBeenNthCalledWith(
2,
'/home/expo/.maestro/tests/android-maestro-html.html',
{
force: true,
}
);
expect(removeSpy.mock.invocationCallOrder[0]).toBeLessThan(
mockedSpawn.mock.invocationCallOrder[0]
);
expect(removeSpy.mock.invocationCallOrder[1]).toBeLessThan(
mockedSpawn.mock.invocationCallOrder[1]
);
});
it('does not run Maestro CLI or upload old HTML if the report cannot be cleared', async () => {
jest.spyOn(fs, 'rm').mockImplementation(async target => {
if (target === '/home/expo/.maestro/tests/android-maestro-html.html') {
throw new Error('permission denied');
}
});
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'html',
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(SystemError);
expect(mockedSpawn).not.toHaveBeenCalled();
expect(mockUploadArtifact).not.toHaveBeenCalled();
});
it('uses lowercase extension for non-junit formats regardless of input casing', async () => {
@@ -1428,31 +1216,28 @@ describe('createMaestroTestsBuildFunction', () => {
);
});
it.each(['junit', undefined])(
'uploads runner screenshots with output_format=%s',
async format => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const shot = makeShot(0);
jest.spyOn(fs, 'copyFile').mockResolvedValue();
mockedRunnerHarvest.mockResolvedValue([shot]);
const parseFlowResultsSpy = jest.spyOn(parser, 'parseMaestroRunnerReport').mockResolvedValue({
flows: [{ name: 'Login', sourceFile: 'a.yaml', status: 'failed' }],
});
it('discovers maestro-runner flow results from the attempt report directory', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const shot = makeShot(0);
jest.spyOn(fs, 'copyFile').mockResolvedValue();
mockedRunnerHarvest.mockResolvedValue([shot]);
const parseFlowResultsSpy = jest.spyOn(parser, 'parseMaestroRunnerReport').mockResolvedValue({
flows: [{ name: 'Login', sourceFile: 'a.yaml', status: 'failed' }],
});
const step = createStep({
flow_path: ['a.yaml'],
platform: 'ios',
...(format === undefined ? {} : { output_format: format }),
backend: 'maestro-runner',
});
await step.executeAsync();
const step = createStep({
flow_path: ['a.yaml'],
platform: 'ios',
output_format: 'junit',
backend: 'maestro-runner',
});
await step.executeAsync();
expect(parseFlowResultsSpy).toHaveBeenCalledWith(
'/home/expo/.maestro/tests/ios-maestro-runner-attempt-0'
);
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
}
);
expect(parseFlowResultsSpy).toHaveBeenCalledWith(
'/home/expo/.maestro/tests/ios-maestro-runner-attempt-0'
);
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
});
it('uploads screenshots even when all attempts fail, before throwing ERR_MAESTRO_TESTS_FAILED', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
@@ -1516,15 +1301,14 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockUploadArtifact).toHaveBeenCalledTimes(30);
});
it('does not harvest or upload screenshots for Maestro CLI HTML', async () => {
it('does not harvest or upload screenshots when output_format is not junit', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'html' });
await step.executeAsync();
expect(mockedHarvest).not.toHaveBeenCalled();
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
expect(mockUploadArtifact.mock.calls[0][0].artifact.name).toBe('Maestro HTML Report');
expect(mockUploadArtifact).not.toHaveBeenCalled();
});
it('uploads only the final attempt for a pure-failure flow, but every attempt for a flaky flow', async () => {
@@ -7,7 +7,6 @@ import { type CustomBuildContext } from '../../../customBuildContext';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { isProcessDescendantOfAsync } from '../../../utils/processes';
import { turtleFetch } from '../../../utils/turtleFetch';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import {
monitorLocalEgressAsync,
readLocalEgressHandoffAsync,
@@ -15,10 +14,11 @@ import {
stopLocalEgressResourcesAsync,
} from '../../utils/localEgress';
import {
startDeviceWebPreviewWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
@@ -27,14 +27,7 @@ jest.mock('@expo/turtle-spawn');
jest.mock('../../../utils/IosSimulatorUtils');
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() }),
@@ -65,15 +58,12 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
subdomainId: 'controller-id',
stopAsync: jest.fn(),
}),
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
const logger = {
info: jest.fn(),
warn: jest.fn(),
child: jest.fn().mockReturnThis(),
} as unknown as bunyan;
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const ctx = {} as CustomBuildContext;
const env = { DEVICE_RUN_SESSION_ID: 'session-id' };
const handoff = { url: 'https://egress.test', token: 'secret', fingerprint: 'key=', port: 8899 };
@@ -84,7 +74,7 @@ const fields = {
egressPort: handoff.port,
};
const controllers = [
['agent-device', createStartAgentDeviceSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
['agent-device', createStartAgentDeviceRemoteSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
['Appium', createStartAppiumRemoteSessionBuildFunction, 'appiumUrl'],
['Argent', createStartArgentRemoteSessionBuildFunction, 'toolsUrl'],
['web preview', createStartWebPreviewRemoteSessionBuildFunction, 'previewUrl'],
@@ -92,17 +82,16 @@ const controllers = [
describe.each(controllers)('%s local egress', (_name, createFunction, controllerField) => {
const stopPreview = jest.fn();
const openPreview = jest.fn();
const stopEgress = jest.fn();
let lifetime: AbortSignal;
async function run(signal?: AbortSignal): Promise<void> {
await createFunction(ctx).fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as BuildStepContext,
{
// Inputs a step does not get default to undefined, like in a real step call.
inputs: new Proxy({} as Record<string, { value: unknown }>, {
get: (target, id: string) => target[id] ?? { value: undefined },
}),
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env,
signal,
@@ -130,15 +119,11 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(handoff);
jest.mocked(monitorLocalEgressAsync).mockResolvedValue(undefined);
openPreview.mockResolvedValue({
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://preview.test',
previewToken: 'preview-secret',
closeAsync: jest.fn(),
});
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: openPreview,
finishAsync: stopPreview,
stopAsync: stopPreview,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
@@ -184,14 +169,12 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
expect(stopEgress).not.toHaveBeenCalled();
});
it.each(['startup', 'preview', 'report', 'wait', 'teardown'])(
it.each(['startup', 'report', 'wait', 'teardown'])(
'releases egress after %s fails',
async phase => {
const error = new Error(`${phase} failed`);
if (phase === 'startup') {
jest.mocked(startDeviceSessionHostAsync).mockRejectedValue(error);
} else if (phase === 'preview') {
openPreview.mockRejectedValue(error);
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockRejectedValue(error);
} else if (phase === 'report') {
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValue(error);
} else if (phase === 'wait') {
@@ -203,7 +186,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
expect(lifetime.aborted).toBe(true);
expect(stopEgress).toHaveBeenCalledTimes(1);
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(
phase === 'startup' || phase === 'preview' || phase === 'report' ? 0 : 1
phase === 'startup' || phase === 'report' ? 0 : 1
);
}
);
@@ -1,187 +0,0 @@
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".'
);
});
});
@@ -1,70 +0,0 @@
import { type bunyan } from '@expo/logger';
import { type BuildStepContext, type BuildStepEnv } from '@expo/steps';
import { type CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { getDeviceRunSessionIdOrThrow } from '../../utils/remoteDeviceRunSession';
import { uploadServeSimLogsFileAsync } from '../../utils/serveSimLogsArtifacts';
import { ServeSimLogsRecorder } from '../../utils/serveSimLogsRecorder';
import { createCollectServeSimLogsBuildFunction } from '../collectServeSimLogs';
import { createStartServeSimLogsBuildFunction } from '../startServeSimLogs';
jest.mock('../../../sentry');
jest.mock('../../utils/serveSimLogsRecorder');
jest.mock('../../utils/serveSimLogsArtifacts');
jest.mock('../../utils/remoteDeviceRunSession');
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const ctx = {} as CustomBuildContext;
const step = { logger } as BuildStepContext;
const args = { inputs: {}, outputs: {}, env: {} as BuildStepEnv };
beforeEach(() => {
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('session-id');
jest.mocked(ServeSimLogsRecorder.finishAsync).mockResolvedValue([]);
});
it('starts collection and tolerates startup failures', async () => {
jest.mocked(ServeSimLogsRecorder.startAsync).mockRejectedValueOnce(new Error('disk failure'));
await expect(createStartServeSimLogsBuildFunction().fn?.(step, args)).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not start serve-sim simulator logs',
expect.any(Error)
);
});
it('uploads each device with its own bounded signal', async () => {
jest.mocked(ServeSimLogsRecorder.finishAsync).mockResolvedValue([
{ udid: 'A', filePath: '/tmp/A.ndjson' },
{ udid: 'B', filePath: '/tmp/B.ndjson' },
]);
await createCollectServeSimLogsBuildFunction(ctx).fn?.(step, args);
expect(uploadServeSimLogsFileAsync).toHaveBeenCalledTimes(2);
expect(uploadServeSimLogsFileAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
deviceRunSessionId: 'session-id',
udid: 'A',
filePath: '/tmp/A.ndjson',
signal: expect.any(AbortSignal),
})
);
expect(jest.mocked(uploadServeSimLogsFileAsync).mock.calls[0][1].signal).not.toBe(
jest.mocked(uploadServeSimLogsFileAsync).mock.calls[1][1].signal
);
});
it('skips empty collection and warns on finalization failure', async () => {
await createCollectServeSimLogsBuildFunction(ctx).fn?.(step, args);
expect(uploadServeSimLogsFileAsync).not.toHaveBeenCalled();
jest.mocked(ServeSimLogsRecorder.finishAsync).mockRejectedValueOnce(new Error('failure'));
await expect(
createCollectServeSimLogsBuildFunction(ctx).fn?.(step, args)
).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not finalize serve-sim simulator logs',
expect.any(Error)
);
});
@@ -1,26 +1,26 @@
import { BuildRuntimePlatform } from '@expo/steps';
import { BuildRuntimePlatform, type BuildStepContext } 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';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import {
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
} from '../../utils/remoteDeviceRunSession';
import { type StartupTasks, createStartupTasks } from '../../utils/startupTasks';
import { runAgentDeviceRemoteSessionAsync } from '../startAgentDeviceRemoteSession';
import { createStartAgentDeviceRemoteSessionBuildFunction } 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.
@@ -40,7 +40,6 @@ jest.mock('../../utils/agentDeviceArtifacts', () => ({
jest.mock('../../utils/agentDeviceEvents', () => ({
startAgentDeviceEventCollectionAsync: jest.fn(),
}));
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
getDeviceRunSessionIdOrThrow: jest.fn(),
@@ -48,6 +47,7 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
getNgrokTunnelDomainOrThrow: jest.fn(),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
spawnDetached: jest.fn(),
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
startNgrokTunnelAsync: jest.fn(),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
@@ -66,34 +66,28 @@ 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; child: jest.Mock },
logger: { info: jest.Mock; warn: jest.Mock },
runtimePlatform: BuildRuntimePlatform,
capture: CaptureInputs = NO_CAPTURE
launchInputs: Record<string, { value: unknown }> = {}
): Promise<void> {
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() },
});
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
);
}
describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () => {
beforeEach(async () => {
jest.clearAllMocks();
@@ -110,7 +104,6 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
jest.mocked(spawnDetached).mockReturnValue({
pid: 4242,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: mockDaemonStopAsync,
});
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 5678, token: 'daemon-token' });
@@ -119,13 +112,10 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
subdomainId: 'agent-device-abc',
stopAsync: mockTunnelStopAsync,
});
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.tunnel.example.com',
closeAsync: jest.fn(),
}),
finishAsync: mockPreviewStopAsync,
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.tunnel.example.com',
stopAsync: mockPreviewStopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
@@ -139,11 +129,12 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
});
it('reports the preview URL and tears every resource down', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const logger = { info: jest.fn(), warn: jest.fn() };
await runAsync(logger, BuildRuntimePlatform.LINUX);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
);
@@ -163,212 +154,47 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
});
it('hands network capture to serve-sim', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
const logger = { info: jest.fn(), warn: jest.fn() };
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
networkCapture: true,
networkCaptureFields: ['header'],
launch_app_identifier: { value: 'host.exp.Exponent' },
launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] },
open_url: { value: 'exp://127.0.0.1:8081' },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ networkCapture: true, networkCaptureFields: ['header'] })
);
});
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()!(
ctx,
{} as never
);
let markHostStarted!: () => void;
const hostStarted = new Promise<void>(resolve => {
markHostStarted = resolve;
});
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async () => {
markHostStarted();
return sessionHost;
});
// The daemon credentials only appear after the session host started. A sequential
// startup would wait here forever.
jest.mocked(waitForFileAsync).mockImplementation(async () => {
await hostStarted;
return { port: 5678, token: 'daemon-token' };
});
await runAsync(logger, BuildRuntimePlatform.DARWIN);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
agentDeviceRemoteSessionUrl: 'https://agent-device-abc.tunnel.example.com',
agentDeviceRemoteSessionToken: 'daemon-token',
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
}),
runtimePlatform: BuildRuntimePlatform.DARWIN,
launchAppIdentifier: 'host.exp.Exponent',
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
openUrl: 'exp://127.0.0.1:8081',
})
);
});
it('stops the daemon and its tunnel when the session host fails to start', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
jest
.mocked(startDeviceSessionHostAsync)
.mockRejectedValue(new Error('serve-sim did not start'));
await expect(runAsync(logger, BuildRuntimePlatform.DARWIN)).rejects.toThrow(
'serve-sim did not start'
);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
// The failure aborts the daemon task, which then may not start its daemon or tunnel.
// Whatever started is stopped.
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(jest.mocked(spawnDetached).mock.calls.length);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(
jest.mocked(startNgrokTunnelAsync).mock.calls.length
expect(logger.info).toHaveBeenCalledWith(
'serve-sim will launch host.exp.Exponent with arguments ' +
'["-EXDevMenuIsOnboardingFinished","1"], then open exp://127.0.0.1:8081.'
);
});
it('stops the session host and the daemon when the daemon credentials never appear', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
jest.mocked(waitForFileAsync).mockRejectedValue(new Error('no daemon credentials'));
it('fails before starting the daemon when a launch is asked for on Android', async () => {
const logger = { info: jest.fn(), warn: jest.fn() };
await expect(runAsync(logger, BuildRuntimePlatform.DARWIN)).rejects.toThrow(
'no daemon credentials'
);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
await expect(
runAsync(logger, BuildRuntimePlatform.LINUX, {
launch_app_identifier: { value: 'host.exp.Exponent' },
})
).rejects.toThrow('runs on linux');
expect(spawnDetached).not.toHaveBeenCalled();
});
describe('runAgentDeviceRemoteSessionAsync with a device that is still starting', () => {
function deferred(): {
promise: Promise<void>;
resolve: () => void;
reject: (e: Error) => void;
} {
let resolve!: () => void;
let reject!: (e: Error) => void;
const promise = new Promise<void>((res, rej) => {
resolve = res;
reject = rej;
});
promise.catch(() => {});
return { promise, resolve, reject };
}
it('declares the launch inputs', () => {
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
const globalCtx = createGlobalContextMock();
type Device = { booted: Promise<unknown>; ready: Promise<unknown> };
function startSession(device: Device | ((tasks: StartupTasks) => Device)) {
const logger = {
info: jest.fn(),
warn: jest.fn(),
child: jest.fn().mockReturnThis(),
} as never;
const tasks = createStartupTasks(logger);
return runAgentDeviceRemoteSessionAsync(ctx, {
env: {},
logger,
runtimePlatform: BuildRuntimePlatform.DARWIN,
sessionEnv: {
deviceRunSessionId: 'device-run-session-id',
ngrokTunnelDomain: 'tunnel.example.com',
ngrokAuthtoken: 'ngrok-token',
},
packageVersion: undefined,
maxIdleTimeMinutes: undefined,
maxDurationSeconds: undefined,
capture: NO_CAPTURE,
tasks,
device: typeof device === 'function' ? device(tasks) : device,
});
}
async function flushAsync(): Promise<void> {
for (let i = 0; i < 20; i++) {
await new Promise(resolve => setImmediate(resolve));
}
}
it('starts the daemon during the boot and the session host after it', async () => {
const booted = deferred();
const session = startSession({ booted: booted.promise, ready: booted.promise });
await flushAsync();
expect(spawnDetached).toHaveBeenCalledTimes(1);
expect(startNgrokTunnelAsync).toHaveBeenCalledTimes(1);
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
booted.resolve();
await session;
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
});
it('reports the session as ready only after the app is launched', async () => {
const ready = deferred();
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
await flushAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
ready.resolve();
await session;
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
});
it('stops waiting for the boot when the daemon fails', async () => {
jest.mocked(waitForFileAsync).mockRejectedValue(new Error('no daemon credentials'));
const neverBooted = new Promise<void>(() => {});
// In the combined step, `ready` stops waiting for the boot on an abort, like this.
await expect(
startSession(tasks => ({ booted: neverBooted, ready: tasks.untilAborted(neverBooted) }))
).rejects.toThrow('no daemon credentials');
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('stops the session host when the app fails while the daemon install hangs', async () => {
jest.mocked(spawn).mockImplementation(((
command: string,
args: string[],
options?: { signal?: AbortSignal }
) =>
command === 'bun' && args[0] === 'add'
? new Promise((_resolve, reject) => {
options!.signal!.addEventListener('abort', () => reject(options!.signal!.reason));
})
: Promise.resolve(undefined)) as never);
const ready = deferred();
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
await flushAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
ready.reject(new Error('simctl install failed'));
await expect(session).rejects.toThrow('simctl install failed');
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(spawnDetached).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('stops everything it started when the app install fails', async () => {
const ready = deferred();
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
await flushAsync();
ready.reject(new Error('simctl install failed'));
await expect(session).rejects.toThrow('simctl install failed');
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
expect(
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
});
});
@@ -75,7 +75,6 @@ describe(startAgentDeviceDaemonAsync, () => {
jest.mocked(spawnDetached).mockReturnValue({
pid: 1234,
getOutput: () => '',
getExitError: () => undefined,
stopAsync,
});
jest.mocked(spawn).mockImplementation((async (
@@ -109,12 +108,7 @@ describe(startAgentDeviceDaemonAsync, () => {
expect(spawnDetached).toHaveBeenCalledWith({
command: 'node',
args: [path.join(addCwd, 'node_modules/agent-device/dist/src/internal/daemon.js')],
env: expect.objectContaining({
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0',
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS: '0',
AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS: '0',
}),
env: expect.objectContaining({ AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' }),
});
await handle.stopAsync();
@@ -135,36 +129,6 @@ describe(startAgentDeviceDaemonAsync, () => {
);
});
it('kills the install and does not fall back to git when aborted', async () => {
jest.mocked(spawn).mockImplementation(
((_command: string, _args: string[], options?: { signal?: AbortSignal }) =>
new Promise((_resolve, reject) => {
options!.signal!.addEventListener('abort', () => reject(options!.signal!.reason));
})) as never
);
const controller = new AbortController();
const failure = new Error('app install failed');
const daemon = startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
env: {},
logger,
signal: controller.signal,
});
await new Promise(resolve => setImmediate(resolve));
controller.abort(failure);
await expect(daemon).rejects.toBe(failure);
expect(spawn).toHaveBeenCalledTimes(1);
expect(spawn).toHaveBeenCalledWith(
'bun',
['add', '[email protected]'],
expect.objectContaining({ signal: controller.signal })
);
expect(spawnDetached).not.toHaveBeenCalled();
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('falls back to git clone when the published daemon is missing', async () => {
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => {
if (command === 'bun' && args[0] === '--version') {
@@ -201,12 +165,7 @@ describe(startAgentDeviceDaemonAsync, () => {
command: 'bun',
args: ['run', 'src/daemon.ts'],
cwd: '/tmp/agent-device-src',
env: expect.objectContaining({
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0',
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS: '0',
AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS: '0',
}),
env: expect.objectContaining({ AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' }),
});
expect(Sentry.capture).toHaveBeenCalledWith(
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
@@ -1,400 +0,0 @@
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
import { downloadBuildAsync } from '../downloadBuild';
import { installBuildAsync } from '../installBuild';
import { launchApplicationAsync } from '../launchApplication';
import {
getAgentDeviceRemoteSessionEnvOrThrow,
runAgentDeviceRemoteSessionAsync,
} from '../startAgentDeviceRemoteSession';
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
import { startAndroidEmulatorAsync } from '../startAndroidEmulator';
import { bootIosSimulatorAsync } from '../startIosSimulator';
jest.mock('../../utils/localEgressSession', () => ({
withLocalEgressSession: (fn: unknown) => fn,
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
}));
jest.mock('../startIosSimulator', () => ({ bootIosSimulatorAsync: jest.fn() }));
jest.mock('../startAndroidEmulator', () => ({ startAndroidEmulatorAsync: jest.fn() }));
jest.mock('../downloadBuild', () => ({ downloadBuildAsync: jest.fn() }));
jest.mock('../installBuild', () => ({ installBuildAsync: jest.fn() }));
jest.mock('../launchApplication', () => ({
...jest.requireActual('../launchApplication'),
launchApplicationAsync: jest.fn(),
}));
jest.mock('../startAgentDeviceRemoteSession', () => ({
getAgentDeviceRemoteSessionEnvOrThrow: jest.fn(),
runAgentDeviceRemoteSessionAsync: jest.fn(),
}));
const graphqlClient = { query: jest.fn() };
const ctx = { graphqlClient } as unknown as CustomBuildContext;
const sessionEnv = {
deviceRunSessionId: 'device-run-session-id',
ngrokTunnelDomain: 'tunnel.example.com',
ngrokAuthtoken: 'ngrok-token',
};
type Device = { booted: Promise<unknown>; ready: Promise<unknown> };
function deferred<T = void>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (err: Error) => void;
} {
let resolve!: (value: T) => void;
let reject!: (err: Error) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
promise.catch(() => {});
return { promise, resolve, reject };
}
async function flushAsync(): Promise<void> {
for (let i = 0; i < 20; i++) {
await new Promise(resolve => setImmediate(resolve));
}
}
function runStep(
runtimePlatform: BuildRuntimePlatform,
inputValues: Record<string, unknown> = {}
): Promise<void> {
const buildFunction = createStartAgentDeviceSessionBuildFunction(ctx);
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const inputs = Object.fromEntries(
[
'device_identifier',
'system_image_package',
'lcd_width',
'lcd_height',
'lcd_density',
'build_id',
'application_archive_url',
'launch_args',
'open_url',
'network_capture',
'network_capture_fields',
'package_version',
'max_idle_time_minutes',
'max_duration_seconds',
].map(id => [id, { value: inputValues[id] }])
);
return buildFunction.fn!(
{
logger,
global: {
runtimePlatform,
staticContext: { job: { secrets: { robotAccessToken: 'robot-token' } } },
},
} as unknown as BuildStepContext,
{ inputs, outputs: {}, env: {}, signal: undefined } as never
) as Promise<void>;
}
/** The `device` promises handed to the shared agent-device session code. */
function sessionDevice(): Device {
return jest.mocked(runAgentDeviceRemoteSessionAsync).mock.calls[0][1].device;
}
describe(createStartAgentDeviceSessionBuildFunction, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(getAgentDeviceRemoteSessionEnvOrThrow).mockReturnValue(sessionEnv);
jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async (_ctx, { device }) => {
await device.ready;
});
jest.mocked(bootIosSimulatorAsync).mockResolvedValue({
deviceIdentifier: 'iPhone 17' as never,
udid: 'udid' as never,
displayName: 'iPhone 17',
});
jest.mocked(startAndroidEmulatorAsync).mockResolvedValue({
serialId: 'emulator-5554' as never,
emulatorPromise: Promise.resolve(),
shouldAdjustAnimationScale: true,
});
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/App.app' });
jest
.mocked(installBuildAsync)
.mockResolvedValue({ applicationIdentifier: 'dev.example.app', activityName: '.Main' });
jest.mocked(launchApplicationAsync).mockResolvedValue(undefined);
});
it('downloads the build and starts the session while the iOS Simulator boots', async () => {
const boot = deferred<Awaited<ReturnType<typeof bootIosSimulatorAsync>>>();
jest.mocked(bootIosSimulatorAsync).mockReturnValue(boot.promise);
const step = runStep(BuildRuntimePlatform.DARWIN, {
build_id: 'build-id',
launch_args: ['-flag'],
open_url: 'exp://example.test',
});
await flushAsync();
expect(
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
).toBeLessThan(jest.mocked(bootIosSimulatorAsync).mock.invocationCallOrder[0]);
expect(downloadBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({
buildId: 'build-id',
extensions: ['app'],
graphqlClient,
robotAccessToken: 'robot-token',
})
);
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(1);
// The install needs the booted Simulator.
expect(installBuildAsync).not.toHaveBeenCalled();
boot.resolve({
deviceIdentifier: 'iPhone 17' as never,
udid: 'udid' as never,
displayName: '',
});
await step;
expect(installBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({ artifactPath: '/tmp/App.app' })
);
expect(launchApplicationAsync).toHaveBeenCalledWith(
expect.objectContaining({
applicationIdentifier: 'dev.example.app',
launchArgs: ['-flag'],
openUrl: 'exp://example.test',
})
);
expect(jest.mocked(installBuildAsync).mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(launchApplicationAsync).mock.invocationCallOrder[0]
);
});
it('waits for the download when the Simulator boots first', async () => {
const download = deferred<{ artifactPath: string }>();
jest.mocked(downloadBuildAsync).mockReturnValue(download.promise);
const step = runStep(BuildRuntimePlatform.DARWIN, {
application_archive_url: 'https://example.test/app.tar.gz',
});
await flushAsync();
expect(installBuildAsync).not.toHaveBeenCalled();
download.resolve({ artifactPath: '/tmp/App.app' });
await step;
expect(installBuildAsync).toHaveBeenCalledTimes(1);
});
it('makes the session ready when the device boots if there is no app', async () => {
await runStep(BuildRuntimePlatform.DARWIN);
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
await expect(sessionDevice().ready).resolves.toBeUndefined();
});
it('boots the Android Emulator with the device inputs', async () => {
await runStep(BuildRuntimePlatform.LINUX, {
device_identifier: 'pixel_7',
system_image_package: 'system-images;android-35-ext15;google_apis_playstore;x86_64',
lcd_width: 720,
lcd_height: 1600,
lcd_density: 300,
build_id: 'build-id',
});
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({
deviceIdentifier: 'pixel_7',
systemImagePackage: 'system-images;android-35-ext15;google_apis_playstore;x86_64',
lcdWidth: 720,
lcdHeight: 1600,
lcdDensity: 300,
})
);
expect(downloadBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({ extensions: ['apk'] })
);
expect(launchApplicationAsync).toHaveBeenCalledWith(
expect.objectContaining({ applicationIdentifier: 'dev.example.app', activityName: '.Main' })
);
});
it('fails the session when the download fails, without an unhandled rejection', async () => {
const boot = deferred<Awaited<ReturnType<typeof bootIosSimulatorAsync>>>();
jest.mocked(bootIosSimulatorAsync).mockReturnValue(boot.promise);
jest.mocked(downloadBuildAsync).mockRejectedValue(new Error('download failed'));
const stepFailure = expect(
runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })
).rejects.toThrow('download failed');
// The download fails while the boot still runs. Nothing inside the step may leave
// that rejection unhandled; Jest fails the test if it does.
await flushAsync();
boot.resolve({
deviceIdentifier: 'iPhone 17' as never,
udid: 'udid' as never,
displayName: '',
});
await stepFailure;
expect(installBuildAsync).not.toHaveBeenCalled();
});
/** A download that runs until its abort signal fires, like a stalled one. */
function mockStalledDownload(): { aborted: () => boolean; settled: () => boolean } {
let aborted = false;
let settled = false;
jest.mocked(downloadBuildAsync).mockImplementation(
({ signal }) =>
new Promise((_resolve, reject) => {
signal!.addEventListener('abort', () => {
aborted = true;
// Settles a bit later, like a request that winds down.
setImmediate(() => {
settled = true;
reject(new Error('The user aborted a request.'));
});
});
})
);
return { aborted: () => aborted, settled: () => settled };
}
/** Runs the session like the real one: fails with the daemon, then waits for every part. */
function mockSessionWithFailingDaemon(beforeFailure?: () => Promise<void>): void {
jest
.mocked(runAgentDeviceRemoteSessionAsync)
.mockImplementation(async (_ctx, { tasks, device }) => {
await beforeFailure?.();
const daemon = tasks.run('agent-device daemon', async () => {
throw new Error('daemon failed');
});
try {
await Promise.all([daemon, device.ready]);
} finally {
await Promise.allSettled([daemon, device.ready]);
}
});
}
it('stops the download and waits for it when the boot fails', async () => {
const download = mockStalledDownload();
jest.mocked(bootIosSimulatorAsync).mockRejectedValue(new Error('boot failed'));
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'boot failed'
);
expect(download.aborted()).toBe(true);
// The step returned only after the download stopped.
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
});
it('stops a stalled download and does not install when the daemon fails', async () => {
const download = mockStalledDownload();
mockSessionWithFailingDaemon();
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'daemon failed'
);
expect(download.aborted()).toBe(true);
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
});
it('does not launch the app when startup fails during the install', async () => {
const install = deferred<Awaited<ReturnType<typeof installBuildAsync>>>();
const installStarted = deferred();
jest.mocked(installBuildAsync).mockImplementation(() => {
installStarted.resolve();
return install.promise;
});
mockSessionWithFailingDaemon(async () => {
await installStarted.promise;
// The install finishes only after the daemon failed.
setImmediate(() => install.resolve({ applicationIdentifier: 'dev.example.app' }));
});
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'daemon failed'
);
expect(installBuildAsync).toHaveBeenCalledTimes(1);
expect(launchApplicationAsync).not.toHaveBeenCalled();
});
it('rejects conflicting application inputs before it boots anything', async () => {
await expect(
runStep(BuildRuntimePlatform.DARWIN, {
build_id: 'build-id',
application_archive_url: 'https://example.test/app.tar.gz',
})
).rejects.toThrow('Pass only one of build_id or application_archive_url.');
await expect(
runStep(BuildRuntimePlatform.DARWIN, { open_url: 'exp://example.test' })
).rejects.toThrow('launch_args and open_url need an application');
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('declares the step inputs', () => {
const buildFunction = createStartAgentDeviceSessionBuildFunction(ctx);
const globalCtx = createGlobalContextMock();
expect(
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual([
'device_identifier',
'system_image_package',
'lcd_width',
'lcd_height',
'lcd_density',
'build_id',
'application_archive_url',
'launch_args',
'open_url',
'network_capture',
'network_capture_fields',
'package_version',
'max_idle_time_minutes',
'max_duration_seconds',
]);
});
it('passes network capture to the session', async () => {
await runStep(BuildRuntimePlatform.DARWIN, {
network_capture: true,
network_capture_fields: ['header', 'response-body'],
});
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
capture: { networkCapture: true, networkCaptureFields: ['header', 'response-body'] },
})
);
});
it('rejects network capture on Android before it boots anything', async () => {
await expect(
runStep(BuildRuntimePlatform.LINUX, { network_capture: true, system_image_package: 'x' })
).rejects.toThrow('records traffic through serve-sim on an iOS simulator');
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
});
@@ -8,7 +8,6 @@ import {
AndroidVirtualDeviceName,
} from '../../../utils/AndroidEmulatorUtils';
import { retryAsync } from '../../../utils/retry';
import { resolveAndroidEmulatorLocalEgressAsync } from '../../utils/androidLocalEgress';
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
jest.mock('@expo/turtle-spawn', () => ({
@@ -20,10 +19,6 @@ jest.mock('../../../utils/retry', () => ({
retryAsync: jest.fn(),
}));
jest.mock('../../utils/androidLocalEgress', () => ({
resolveAndroidEmulatorLocalEgressAsync: jest.fn(),
}));
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
AndroidEmulatorUtils: {
defaultSystemImagePackage: 'system-images;android-30;default;x86_64',
@@ -40,7 +35,6 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
const mockedResolveLocalEgress = jest.mocked(resolveAndroidEmulatorLocalEgressAsync);
const mockedMkdtemp = jest.spyOn(fs.promises, 'mkdtemp');
function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.ProcessEnv) {
const logger = createMockLogger();
@@ -71,7 +65,6 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined);
mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync.mockResolvedValue(undefined);
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined);
mockedResolveLocalEgress.mockResolvedValue(null);
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
let lastErr: unknown;
@@ -90,82 +83,6 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
mockedMkdtemp.mockRestore();
});
describe('local egress', () => {
function createLocalEgress() {
return {
launchGate: {
emulatorArgs: ['-http-proxy', '127.0.0.1:8898', '-no-metrics'],
wrapperCommand: 'bash',
wrapperArgs: ['-c', 'gate', 'eas-egress-gate'],
admitAsync: jest.fn(),
},
networkReadyTarget: { host: '192.0.2.1', port: 443 },
configureBootedEmulatorAsync: jest.fn().mockResolvedValue(undefined),
};
}
it('starts the emulator exactly as before without a local egress session', async () => {
await createStep().executeAsync();
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith({
deviceName: 'EasAndroidDevice01',
env: expect.any(Object),
logcatDirectory: '/tmp/logcat-directory',
});
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith({
env: expect.any(Object),
serialId: 'emulator-default',
timeoutMs: 60_000,
logger: expect.anything(),
});
expect(
mockedSpawn.mock.calls.filter(
([command, args]) =>
['sudo', 'nft', 'systemd-run'].includes(command) ||
(command === 'adb' && (args.includes('reverse') || args.includes('http_proxy')))
)
).toEqual([]);
});
it('fences, gates and configures the emulator', async () => {
const localEgress = createLocalEgress();
mockedResolveLocalEgress.mockResolvedValue(localEgress);
mockedAndroidUtils.startAsync.mockResolvedValueOnce(createStartResult('emulator-base'));
await createStep().executeAsync();
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
expect.objectContaining({ launchGate: localEgress.launchGate })
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith(
expect.objectContaining({ networkReadyTarget: { host: '192.0.2.1', port: 443 } })
);
expect(localEgress.configureBootedEmulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({ serialId: 'emulator-base' })
);
});
it('retries a boot whose local egress configuration fails', async () => {
const localEgress = createLocalEgress();
localEgress.configureBootedEmulatorAsync
.mockRejectedValueOnce(new Error('netsimd outside the fence'))
.mockResolvedValueOnce(undefined);
mockedResolveLocalEgress.mockResolvedValue(localEgress);
mockedAndroidUtils.startAsync
.mockResolvedValueOnce(createStartResult('emulator-1111'))
.mockResolvedValueOnce(createStartResult('emulator-2222'));
await createStep().executeAsync();
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({ serialId: 'emulator-1111' })
);
expect(localEgress.configureBootedEmulatorAsync).toHaveBeenLastCalledWith(
expect.objectContaining({ serialId: 'emulator-2222' })
);
});
});
it('passes profile and LCD inputs to emulator creation', async () => {
const systemImagePackage = 'system-images;android-35;default;x86_64';
await createStep({
@@ -6,7 +6,6 @@ import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import { isProcessDescendantOfAsync } from '../../../utils/processes';
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
import { startArgentEventCollectionAsync } from '../../utils/argentEvents';
@@ -17,6 +16,7 @@ import {
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
@@ -42,17 +42,15 @@ jest.mock('../../utils/argentEvents', () => ({
...jest.requireActual('../../utils/argentEvents'),
startArgentEventCollectionAsync: jest.fn(),
}));
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
ensureFfmpegInstalledOnceAsync: jest.fn(),
finishRemoteSessionAsync: jest.requireActual('../../utils/remoteDeviceRunSession')
.finishRemoteSessionAsync,
getDeviceRunSessionIdOrThrow: jest.fn(),
getNgrokAuthtokenOrThrow: jest.fn(),
getNgrokTunnelDomainOrThrow: jest.fn(),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
spawnDetached: jest.fn(),
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
startNgrokTunnelAsync: jest.fn(),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
@@ -88,7 +86,6 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
jest.mocked(spawnDetached).mockReturnValue({
pid: 4242,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: jest.fn(),
});
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
@@ -96,13 +93,10 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
subdomainId: 'argent-abc',
stopAsync: mockTunnelStopAsync,
});
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.tunnel.example.com',
closeAsync: jest.fn(),
}),
finishAsync: mockPreviewStopAsync,
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.tunnel.example.com',
stopAsync: mockPreviewStopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
@@ -119,75 +113,6 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
await fs.promises.rm(TEST_HOME, { recursive: true, force: true });
});
it('reports an early exit with output before opening tunnels or publishing readiness', async () => {
jest.mocked(spawnDetached).mockReturnValue({
pid: 4242,
getOutput: () => 'could not bind server port',
getExitError: () => new Error('process exited with code 1'),
stopAsync: jest.fn(),
});
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
await expect(
buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
)
).rejects.toThrow(
'Argent exited before becoming ready: process exited with code 1\nArgent tool-server output:\ncould not bind server port'
);
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
});
it.each(['preview', 'config', 'wait'])(
'finishes the host and tools after %s fails',
async phase => {
const error = new Error(`${phase} failed`);
if (phase === 'preview') {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: jest.fn().mockRejectedValue(error),
finishAsync: mockPreviewStopAsync,
});
} else if (phase === 'config') {
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
} else {
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
}
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
await expect(
buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
)
).rejects.toBe(error);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(mockStopAsync).toHaveBeenCalledTimes(1);
expect(jest.mocked(spawnDetached).mock.results[0].value.stopAsync).toHaveBeenCalledTimes(1);
}
);
it('enables the event log flag, shares one path, and starts/stops the collector', async () => {
const ctx = {} as unknown as CustomBuildContext;
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
@@ -252,7 +177,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
);
@@ -291,7 +216,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
} as never
);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
runtimePlatform: BuildRuntimePlatform.DARWIN,
@@ -341,49 +266,4 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
});
it('stops automation without waiting for recording upload', async () => {
let release!: () => void;
let stopped!: () => void;
const pendingFinish = new Promise<void>(resolve => {
release = resolve;
});
const toolStopped = new Promise<void>(resolve => {
stopped = resolve;
});
mockPreviewStopAsync.mockReturnValueOnce(pendingFinish);
const stopServer = jest.fn(async () => {
stopped();
});
jest.mocked(spawnDetached).mockReturnValueOnce({
pid: 4242,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: stopServer,
});
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
const running = buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
);
try {
await toolStopped;
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(stopServer).toHaveBeenCalledTimes(1);
} finally {
release();
await running;
}
});
});
@@ -94,59 +94,6 @@ describe(waitForArgentToolServerStateAsync, () => {
return JSON.stringify({ port, pid, token });
}
it('reports process exit instead of waiting for the startup timeout', async () => {
const getExitError = jest.fn(() => new Error('server exited with code 1'));
await expect(
waitForArgentToolServerStateAsync({
stateDir,
ancestorPid: process.pid,
timeoutMs: 100,
pollIntervalMs: 1,
getExitError,
})
).rejects.toThrow('server exited with code 1');
expect(getExitError).toHaveBeenCalledTimes(1);
});
it('detects an exit after polling has begun', async () => {
const getExitError = jest
.fn<Error | undefined, []>()
.mockReturnValueOnce(undefined)
.mockReturnValueOnce(undefined)
.mockReturnValue(new Error('server stopped'));
await expect(
waitForArgentToolServerStateAsync({
stateDir,
ancestorPid: process.pid,
timeoutMs: 100,
pollIntervalMs: 1,
getExitError,
})
).rejects.toThrow('server stopped');
expect(getExitError).toHaveBeenCalledTimes(3);
});
it('rejects an exit observed while reading a matching state file', async () => {
await fs.promises.writeFile(
path.join(stateDir, 'tool-server.json'),
stateJson({ port: 4321, pid: process.pid })
);
const getExitError = jest
.fn<Error | undefined, []>()
.mockReturnValueOnce(undefined)
.mockReturnValue(new Error('server exited during startup'));
await expect(
waitForArgentToolServerStateAsync({
stateDir,
ancestorPid: process.ppid,
timeoutMs: 100,
pollIntervalMs: 1,
getExitError,
})
).rejects.toThrow('server exited during startup');
expect(getExitError).toHaveBeenCalledTimes(2);
});
it.each(['tool-server.json', 'tool-server-012345abcdef.json'])(
'reads a matching process from %s',
async fileName => {
@@ -1,8 +1,7 @@
import { type bunyan } from '@expo/logger';
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import { type BuildStepContext } from '@expo/steps';
import fs from 'node:fs';
import { startAndroidLocalEgressAsync } from '../../utils/androidLocalEgress';
import {
LOCAL_EGRESS_HANDOFF_PATH,
configureSystemProxyAsync,
@@ -21,9 +20,6 @@ jest.mock('../../utils/localEgress', () => ({
startChiselServerAsync: jest.fn(),
writeLocalEgressHandoffAsync: jest.fn(),
}));
jest.mock('../../utils/androidLocalEgress', () => ({
startAndroidLocalEgressAsync: jest.fn(),
}));
jest.mock('../../utils/localEgressGuard', () => ({
stopLocalEgressGuardRelaysAsync: jest.fn().mockResolvedValue(undefined),
}));
@@ -35,12 +31,7 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
}));
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const server = {
pid: 12345,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: jest.fn(),
};
const server = { pid: 12345, getOutput: () => '', stopAsync: jest.fn() };
const tunnel = {
url: 'https://egress.example.com',
subdomainId: 'egress-id',
@@ -56,82 +47,6 @@ async function start(signal?: AbortSignal): Promise<void> {
});
}
async function startOn(runtimePlatform: BuildRuntimePlatform): Promise<void> {
await createStartLocalEgressBuildFunction().fn!(
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
{
env: {},
inputs: {},
outputs: {},
} as any
);
}
describe('local egress platforms', () => {
const androidStop = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(fs.promises, 'mkdtemp').mockResolvedValue('/tmp/egress-acquisition-test');
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
jest.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
server.stopAsync.mockResolvedValue(undefined);
tunnel.stopAsync.mockResolvedValue(undefined);
androidStop.mockResolvedValue(undefined);
jest.mocked(findAvailablePortAsync).mockResolvedValue(52001);
jest.mocked(downloadChiselAsync).mockResolvedValue('/tmp/egress-acquisition-test/chisel');
jest.mocked(startChiselServerAsync).mockResolvedValue({ process: server, fingerprint: 'key=' });
jest.mocked(startNgrokTunnelAsync).mockResolvedValue(tunnel);
jest.mocked(configureSystemProxyAsync).mockResolvedValue({ service: 'Ethernet' });
jest.mocked(writeLocalEgressHandoffAsync).mockResolvedValue(undefined);
jest.mocked(startAndroidLocalEgressAsync).mockResolvedValue({ stopAsync: androidStop });
});
afterEach(async () => {
await stopLocalEgressResourcesAsync(logger);
jest.restoreAllMocks();
});
it('sets the macOS system proxy and writes the same handoff as before', async () => {
await startOn(BuildRuntimePlatform.DARWIN);
expect(configureSystemProxyAsync).toHaveBeenCalled();
expect(startAndroidLocalEgressAsync).not.toHaveBeenCalled();
expect(writeLocalEgressHandoffAsync).toHaveBeenCalledWith({
url: tunnel.url,
token: expect.any(String),
fingerprint: 'key=',
port: 8899,
});
});
it('starts the Android relay and fence on Linux instead of the system proxy', async () => {
await startOn(BuildRuntimePlatform.LINUX);
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
expect(startAndroidLocalEgressAsync).toHaveBeenCalledWith(
expect.objectContaining({
chiselPid: server.pid,
proxyPort: 8899,
controlPort: 52001,
workDir: '/tmp/egress-acquisition-test',
})
);
expect(writeLocalEgressHandoffAsync).toHaveBeenCalledWith(
expect.objectContaining({ platform: 'android' })
);
await stopLocalEgressResourcesAsync(logger);
expect(androidStop).toHaveBeenCalledTimes(1);
expect(server.stopAsync).toHaveBeenCalledTimes(1);
});
it('releases the tunnel when the Android fence cannot start', async () => {
jest.mocked(startAndroidLocalEgressAsync).mockRejectedValue(new Error('nft failed'));
await expect(startOn(BuildRuntimePlatform.LINUX)).rejects.toThrow('nft failed');
expect(server.stopAsync).toHaveBeenCalledTimes(1);
expect(tunnel.stopAsync).toHaveBeenCalledTimes(1);
expect(writeLocalEgressHandoffAsync).not.toHaveBeenCalled();
});
});
describe('local egress acquisition', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -3,22 +3,22 @@ import { BuildRuntimePlatform, type BuildStepContext, type BuildStepEnv } from '
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import {
getDeviceRunSessionIdOrThrow,
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
startDeviceWebPreviewWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
getDeviceRunSessionIdOrThrow: jest.fn(),
getNgrokTunnelDomainOrThrow: jest.fn(),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
@@ -56,13 +56,10 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('device-run-session-id');
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockResolvedValue(undefined);
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
closeAsync: jest.fn(),
}),
finishAsync: stopAsync,
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
stopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
@@ -70,14 +67,11 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
});
it('reports the session token when serve-sim minted one', async () => {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
previewToken: 'tok-1',
closeAsync: jest.fn(),
}),
finishAsync: stopAsync,
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
previewToken: 'tok-1',
stopAsync,
});
await runAsync(BuildRuntimePlatform.DARWIN);
@@ -85,32 +79,14 @@ 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',
},
})
);
});
it.each(['preview', 'config', 'wait'])('finishes the session after %s fails', async phase => {
const error = new Error(`${phase} failed`);
if (phase === 'preview') {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: jest.fn().mockRejectedValue(error),
finishAsync: stopAsync,
});
} else if (phase === 'config') {
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
} else {
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
}
await expect(runAsync(BuildRuntimePlatform.LINUX)).rejects.toBe(error);
expect(stopAsync).toHaveBeenCalledTimes(1);
});
it.each([
[BuildRuntimePlatform.DARWIN, true],
[BuildRuntimePlatform.LINUX, false],
@@ -118,8 +94,9 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
await runAsync(runtimePlatform);
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(selectsXcode ? 1 : 0);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(ctx, {
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(ctx, {
runtimePlatform,
baseDomain: 'tunnel.example.com',
env,
logger,
timeoutMs: 60_000,
@@ -127,14 +104,11 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
launchAppIdentifier: undefined,
launchArgs: [],
openUrl: undefined,
networkCapture: false,
networkCaptureFields: [],
});
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
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',
},
@@ -160,21 +134,19 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
'launch_app_identifier',
'launch_args',
'open_url',
'network_capture',
'network_capture_fields',
'package_version',
'max_duration_seconds',
]);
});
it('hands the launch inputs to the session host and announces them', async () => {
it('hands the launch inputs to the web preview and announces them', async () => {
await runAsync(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(
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
launchAppIdentifier: 'host.exp.Exponent',
@@ -188,36 +160,12 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
);
});
it('hands network capture to the session host', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
network_capture: { value: true },
network_capture_fields: { value: ['header', 'response-body'] },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
networkCapture: true,
networkCaptureFields: ['header', 'response-body'],
})
);
});
it('fails before starting anything when network capture is asked for on Android', async () => {
await expect(
runAsync(BuildRuntimePlatform.LINUX, {
network_capture: { value: true },
})
).rejects.toThrow('this session runs on linux');
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
});
it('fails before starting anything when a launch is asked for on Android', async () => {
await expect(
runAsync(BuildRuntimePlatform.LINUX, {
launch_app_identifier: { value: 'host.exp.Exponent' },
})
).rejects.toThrow('runs on linux');
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
expect(startDeviceWebPreviewWithTunnelAsync).not.toHaveBeenCalled();
});
});
@@ -1,621 +0,0 @@
import nock from 'nock';
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,
updateTestFlightMetadataAsync,
} from '../updateTestFlightMetadata';
jest.unmock('node-fetch');
jest.mock('../../../sentry', () => ({ Sentry: { capture: jest.fn() } }));
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
const key = { keyId: 'TESTKEY', privateKey };
const changelog = 'Test "quotes"\n$(not-a-command)';
const options = {
client: new AscApiClient({ key }),
buildUploadId: 'upload',
changelog,
groups: [] as string[],
logger: createMockLogger(),
};
const api = () => nock('https://api.appstoreconnect.apple.com');
function mockBuild(
state = 'COMPLETE',
buildId: string | null = 'build',
primaryLocale: string | null = 'en-US'
): void {
api()
.get('/v1/buildUploads/upload')
.query(true)
.reply(200, {
data: {
type: 'buildUploads',
id: 'upload',
attributes: { state: { state } },
relationships: { build: { data: buildId ? { type: 'builds', id: buildId } : null } },
},
});
if (state === 'COMPLETE' && buildId) {
api()
.get(`/v1/builds/${buildId}/app`)
.query(true)
.reply(200, {
data: { id: 'app', attributes: primaryLocale ? { primaryLocale } : {} },
});
}
}
function mockAssignedGroups(ids: string[] = []): void {
api()
.get('/v1/betaGroups')
.query({ 'filter[builds]': 'build', limit: '200' })
.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 });
});
afterAll(() => nock.enableNetConnect());
afterEach(() => {
try {
expect(nock.pendingMocks()).toEqual([]);
} finally {
nock.cleanAll();
}
});
it('accepts group arrays and preserves changelog input without shell parsing', async () => {
const fn = createUpdateTestFlightMetadataBuildFunction();
const spy = jest.spyOn(fn, 'fn').mockImplementation(async (_ctx, { inputs }) => {
expect(inputs.groups.value).toEqual(['A, "B"']);
expect(inputs.changelog.value).toBe(changelog);
});
const step = fn.createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: {
asc_api_key_path: '/tmp/key.json',
build_upload_id: 'upload',
groups: ['A, "B"'],
changelog,
},
});
await step.executeAsync();
expect(spy).toHaveBeenCalled();
});
it('uses empty metadata defaults', async () => {
const fn = createUpdateTestFlightMetadataBuildFunction();
const spy = jest.spyOn(fn, 'fn').mockImplementation(async (_ctx, { inputs }) => {
expect(inputs.groups.value).toEqual([]);
expect(inputs.changelog.value).toBe('');
});
const step = fn.createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { asc_api_key_path: '/tmp/key.json', build_upload_id: 'upload' },
});
await step.executeAsync();
expect(spy).toHaveBeenCalled();
});
it('skips automatic internal groups and groups that already contain the build', async () => {
mockBuild();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, {
data: [
{
id: 'automatic',
attributes: { name: 'Internal', isInternalGroup: true, hasAccessToAllBuilds: true },
},
{ id: 'assigned', attributes: { name: 'QA' } },
{
id: 'manual',
attributes: { name: 'External', isInternalGroup: false, hasAccessToAllBuilds: true },
},
],
});
api()
.get('/v1/betaGroups')
.query({ 'filter[builds]': 'build', limit: '200' })
.reply(200, { data: [{ id: 'assigned', attributes: { name: 'QA' } }] });
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'manual' }],
})
.reply(204);
await updateTestFlightMetadataAsync({
...options,
changelog: '',
groups: ['Internal', 'QA', 'External'],
});
expect(options.logger.info).toHaveBeenCalledWith(
expect.stringContaining('"Internal" (automatic): automatic access')
);
expect(options.logger.info).toHaveBeenCalledWith(
expect.stringContaining('"QA" (assigned): build already assigned')
);
expect(options.logger.info).toHaveBeenCalledWith(
expect.stringContaining('"External" (manual): assignment completed')
);
});
it.each(['en-US', null])(
'creates a localization when none exist (primary locale: %s)',
async primaryLocale => {
mockBuild('COMPLETE', 'build', primaryLocale);
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
api()
.post('/v1/betaBuildLocalizations', {
data: {
type: 'betaBuildLocalizations',
attributes: { locale: 'en-US', whatsNew: changelog },
relationships: { build: { data: { type: 'builds', id: 'build' } } },
},
})
.reply(201, { data: { id: 'locale' } });
await updateTestFlightMetadataAsync(options);
expect(options.logger.info).toHaveBeenCalledWith(
`Updating changelog: ${JSON.stringify(changelog)}`
);
expect(options.logger.info).toHaveBeenCalledWith(
expect.stringContaining('Locale "en-US": localization created')
);
}
);
it('reads all localization pages and updates existing localizations without replacing them', async () => {
mockBuild();
api()
.get('/v1/builds/build/betaBuildLocalizations')
.query(true)
.reply(200, {
data: [{ id: 'en-US', attributes: { locale: 'en-US' } }],
links: {
next: 'https://api.appstoreconnect.apple.com/v1/builds/build/betaBuildLocalizations?limit=200&cursor=next',
},
});
api()
.get('/v1/builds/build/betaBuildLocalizations')
.query({ limit: '200', cursor: 'next' })
.reply(200, { data: [{ id: 'pl', attributes: { locale: 'pl' } }] });
for (const id of ['en-US', 'pl']) {
api()
.patch(`/v1/betaBuildLocalizations/${id}`, {
data: { type: 'betaBuildLocalizations', id, attributes: { whatsNew: changelog } },
})
.reply(200, { data: { id } });
}
await updateTestFlightMetadataAsync(options);
for (const id of ['en-US', 'pl']) {
expect(options.logger.info).toHaveBeenCalledWith(
expect.stringContaining(`Locale "${id}" (${id})`)
);
}
});
it('updates by ID when Apple omits a localization locale and creates the primary locale', async () => {
mockBuild();
api()
.get('/v1/builds/build/betaBuildLocalizations')
.query(true)
.reply(200, { data: [{ id: 'localization' }] });
api()
.patch('/v1/betaBuildLocalizations/localization', {
data: {
type: 'betaBuildLocalizations',
id: 'localization',
attributes: { whatsNew: changelog },
},
})
.reply(200, { data: { id: 'localization' } });
api()
.post('/v1/betaBuildLocalizations', {
data: {
type: 'betaBuildLocalizations',
attributes: { locale: 'en-US', whatsNew: changelog },
relationships: { build: { data: { type: 'builds', id: 'build' } } },
},
})
.reply(201, { data: { id: 'primary' } });
await updateTestFlightMetadataAsync(options);
});
it('assigns requested groups when hasAccessToAllBuilds is null', async () => {
mockBuild();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, {
data: [
{
id: 'internal',
attributes: { name: 'Internal', isInternalGroup: true, hasAccessToAllBuilds: false },
},
{
id: 'external',
attributes: {
name: 'test external',
isInternalGroup: false,
hasAccessToAllBuilds: null,
publicLinkEnabled: false,
publicLink: 'https://testflight.apple.com/join/abc',
},
},
{
id: 'requested-external',
attributes: { name: 'Beta', isInternalGroup: false, hasAccessToAllBuilds: null },
},
],
});
for (const id of ['internal', 'requested-external']) {
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id }],
})
.reply(204);
}
await expect(
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['Internal', 'Beta'] })
).resolves.toBeUndefined();
});
it('adds groups without changing changelog or submitting beta review', async () => {
mockBuild();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, { data: [{ id: 'group', attributes: { name: 'A, "B"' } }] });
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'group' }],
})
.reply(204);
await updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A, "B"', 'A, "B"'] });
});
it('reports missing groups independently of the changelog task', async () => {
mockBuild();
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
api()
.post('/v1/betaBuildLocalizations')
.reply(201, { data: { id: 'locale' } });
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, { data: [] });
await expect(updateTestFlightMetadataAsync({ ...options, groups: ['missing'] })).rejects.toThrow(
'The following TestFlight group was not found in App Store Connect: "missing".'
);
});
it('reads all pages and adds every group with a requested name', async () => {
mockBuild();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, {
data: [{ id: 'first', attributes: { name: 'A' } }],
links: {
next: 'https://api.appstoreconnect.apple.com/v1/betaGroups?filter%5Bapp%5D=app&limit=200&cursor=page-2',
},
});
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200', cursor: 'page-2' })
.reply(200, {
data: [
{ id: 'second', attributes: { name: 'A' } },
{ id: 'other', attributes: { name: 'B' } },
],
});
for (const id of ['first', 'second']) {
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id }],
})
.reply(204);
}
await updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A'] });
});
it('assigns found groups before reporting missing requested names', async () => {
mockBuild();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'group' }],
})
.reply(204);
await expect(
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A', 'B', 'C'] })
).rejects.toThrow(
'The following TestFlight groups were not found in App Store Connect: "B", "C".'
);
});
it('attempts all localization changes and reports every failed update', async () => {
mockBuild();
api()
.get('/v1/builds/build/betaBuildLocalizations')
.query(true)
.reply(200, {
data: ['pl', 'fr', 'de'].map(locale => ({ id: locale, attributes: { locale } })),
});
for (const id of ['pl', 'fr', 'de']) {
api()
.patch(`/v1/betaBuildLocalizations/${id}`)
.reply(
id === 'de' ? 200 : 403,
id === 'de' ? { data: { id } } : { errors: [{ code: `${id}_FAILED` }] }
);
}
api()
.post('/v1/betaBuildLocalizations', {
data: {
type: 'betaBuildLocalizations',
attributes: { locale: 'en-US', whatsNew: changelog },
relationships: { build: { data: { type: 'builds', id: 'build' } } },
},
})
.reply(201, { data: { id: 'locale' } });
await expect(updateTestFlightMetadataAsync(options)).rejects.toThrow(/pl_FAILED.*fr_FAILED/);
expect(options.logger.error).toHaveBeenCalledWith(expect.stringContaining('"pl" (pl)'));
expect(options.logger.error).toHaveBeenCalledWith(expect.stringContaining('"fr" (fr)'));
});
it.each([1, 2])('explains automatic internal group rejection (%s errors)', async errorCount => {
mockBuild();
mockInternalBuildState();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
api()
.post('/v1/builds/build/relationships/betaGroups')
.reply(422, {
errors: Array.from({ length: errorCount }, () => ({
code: 'ENTITY_UNPROCESSABLE',
title: 'Builds cannot be assigned to this internal group.',
detail: 'Cannot add internal group to a build.',
})),
});
await expect(
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A'] })
).rejects.toThrow(
"App Store Connect can't add this build to a requested internal TestFlight group."
);
});
it('stops after 20 group pages', async () => {
mockBuild();
const next =
'https://api.appstoreconnect.apple.com/v1/betaGroups?filter%5Bapp%5D=app&limit=200&cursor=next';
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, { data: [], links: { next } });
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200', cursor: 'next' })
.times(19)
.reply(200, { data: [], links: { next } });
await expect(
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A'] })
).rejects.toThrow('We only support TestFlight group lists with up to 20 pages');
});
it('tries group assignment if the changelog update fails', async () => {
mockBuild();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
api()
.post('/v1/betaBuildLocalizations')
.reply(403, { errors: [{ code: 'FORBIDDEN', detail: 'Not allowed' }] });
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'group' }],
})
.reply(204);
await expect(updateTestFlightMetadataAsync({ ...options, groups: ['A'] })).rejects.toThrow('403');
});
it('reports both write failures', async () => {
mockBuild();
mockInternalBuildState();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
api()
.post('/v1/betaBuildLocalizations')
.reply(403, { errors: [{ code: 'CHANGELOG_FAILED' }] });
api()
.post('/v1/builds/build/relationships/betaGroups')
.reply(403, { errors: [{ code: 'GROUPS_FAILED' }] });
await expect(updateTestFlightMetadataAsync({ ...options, groups: ['A'] })).rejects.toThrow(
/CHANGELOG_FAILED.*GROUPS_FAILED/
);
});
it.each([false, true])(
'attempts all groups and reports assignment errors (multiple: %s)',
async multiple => {
mockBuild();
mockInternalBuildState();
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(
multiple ? 403 : 204,
multiple ? { errors: [{ code: 'FIRST_GROUP_FAILED' }] } : undefined
);
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'B' }],
})
.reply(422, { errors: [{ code: 'ENTITY_UNPROCESSABLE', title: 'Partial assignment' }] });
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'C' }],
})
.reply(204);
await expect(
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A', 'B', 'C'] })
).rejects.toThrow(multiple ? 'FIRST_GROUP_FAILED' : 'Partial assignment');
expect(options.logger.error).toHaveBeenCalledWith(
expect.stringContaining('Group "B" (B): assignment failed.')
);
}
);
it('reads all membership pages before assigning groups', async () => {
mockBuild();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
api()
.get('/v1/betaGroups')
.query({ 'filter[builds]': 'build', limit: '200' })
.reply(200, {
data: [],
links: {
next: 'https://api.appstoreconnect.apple.com/v1/betaGroups?filter%5Bbuilds%5D=build&limit=200&cursor=next',
},
});
api()
.get('/v1/betaGroups')
.query({ 'filter[builds]': 'build', limit: '200', cursor: 'next' })
.reply(200, { data: [{ id: 'group' }] });
await updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A'] });
});
it.each(['PROCESSING', 'FAILED'])('rejects a %s upload', async state => {
mockBuild(state);
await expect(updateTestFlightMetadataAsync(options)).rejects.toThrow('not ready');
});
it('rejects a completed upload without its build relationship', async () => {
mockBuild('COMPLETE', null);
await expect(updateTestFlightMetadataAsync(options)).rejects.toThrow('not ready');
});
it('propagates Apple write failures', async () => {
mockBuild();
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
api()
.post('/v1/betaBuildLocalizations')
.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'),
},
}
);
}
);
@@ -20,8 +20,7 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockReset()
.mockImplementation(async (_ctx, { openStream }) => {
const stream = openStream();
.mockImplementation(async (_ctx, { stream }) => {
await new Promise<void>((resolve, reject) => {
stream.once('end', resolve);
stream.once('error', reject);
@@ -30,20 +29,7 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
});
});
it.each([
{
platform: BuildRuntimePlatform.DARWIN,
udid: SIMULATOR_UDID,
name: 'iPhone 16',
runtime: 'iOS 18.6',
},
{
platform: BuildRuntimePlatform.LINUX,
udid: 'emulator-5554',
name: 'Pixel',
runtime: 'Android 16',
},
])('uploads device metadata on $platform', async ({ platform, udid, name, runtime }) => {
it('uploads simulator device metadata', async () => {
const recordingDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'ios-recording-test-'));
const recordingPath = path.join(recordingDirectory, 'recording.mp4');
await fs.writeFile(recordingPath, 'recording');
@@ -59,7 +45,7 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
try {
const globalContext = createGlobalContextMock({
runtimePlatform: platform,
runtimePlatform: BuildRuntimePlatform.DARWIN,
});
const buildStep = createUploadDeviceRunSessionScreenRecordingsBuildFunction(
{} as CustomBuildContext
@@ -68,9 +54,9 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
callInputs: {
recordings_json: [
{
udid,
deviceName: name,
runtimeDisplayName: runtime,
udid: SIMULATOR_UDID,
deviceName: 'iPhone 16',
runtimeDisplayName: 'iOS 18.6',
directory: recordingDirectory,
},
],
@@ -82,13 +68,13 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
name: `${name} screen recording (${udid.slice(0, 8)}-…, started at Jul 10, 2026, 10:00:00.000 UTC)`,
name: 'iPhone 16 screen recording (01234567-…, started at Jul 10, 2026, 10:00:00.000 UTC)',
metadata: {
__eas_type: 'screen-recording',
__eas_screen_recording: '1',
udid,
deviceName: name,
runtimeDisplayName: runtime,
udid: SIMULATOR_UDID,
deviceName: 'iPhone 16',
runtimeDisplayName: 'iOS 18.6',
firstFrameAt: '2026-07-10T10:00:00.000Z',
width: 1179,
height: 2556,
@@ -1,129 +0,0 @@
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();
});
});
@@ -1,46 +0,0 @@
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
import { type CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { getDeviceRunSessionIdOrThrow } from '../utils/remoteDeviceRunSession';
import { uploadServeSimLogsFileAsync } from '../utils/serveSimLogsArtifacts';
import { ServeSimLogsRecorder } from '../utils/serveSimLogsRecorder';
const UPLOAD_TIMEOUT_MS = 30_000;
export function createCollectServeSimLogsBuildFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'collect_serve_sim_logs',
name: 'Collect serve-sim simulator logs',
__metricsId: 'eas/collect_serve_sim_logs',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
fn: async ({ logger }, { env }) => {
try {
const collected = await ServeSimLogsRecorder.finishAsync({ logger });
if (collected.length === 0) {
logger.info('No simulator logs collected; skipping upload.');
return;
}
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
for (const { udid, filePath } of collected) {
// Each device gets its own budget so one stuck upload does not cancel the rest.
await uploadServeSimLogsFileAsync(ctx, {
deviceRunSessionId,
udid,
filePath,
logger,
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
});
}
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not finalize serve-sim simulator logs', error);
logger.warn(
{ err: error },
'Could not finalize simulator logs; the session result is unchanged.'
);
}
},
});
}
@@ -25,7 +25,6 @@ import plist from 'plist';
import { CustomBuildContext } from '../../customBuildContext';
import { formatBytes } from '../../utils/artifacts';
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
import { pluralize } from '../../utils/strings';
@@ -79,7 +78,7 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
required: true,
}),
],
fn: async (stepsCtx, { inputs, outputs, signal }) => {
fn: async (stepsCtx, { inputs, outputs }) => {
const { logger } = stepsCtx;
const extensions = z.array(z.string()).parse(inputs.extensions.value);
@@ -120,7 +119,6 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
graphqlClient: ctx.graphqlClient,
robotAccessToken: stepsCtx.global.staticContext.job.secrets?.robotAccessToken ?? null,
extensions,
signal,
});
outputs.artifact_path.set(artifactPath);
@@ -131,17 +129,11 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
async function fetchApplicationArchiveUrlAsync({
buildId,
graphqlClient,
signal,
}: {
buildId: string;
graphqlClient: Client;
signal?: AbortSignal;
}): Promise<string> {
signal?.throwIfAborted();
const result = await graphqlClient
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
.toPromise();
signal?.throwIfAborted();
const result = await graphqlClient.query(BUILD_BY_ID_QUERY, { buildId }).toPromise();
if (result.error) {
const { error } = result;
@@ -169,11 +161,9 @@ export async function downloadBuildAsync(
graphqlClient: Client;
robotAccessToken: string | null;
extensions: string[];
/** Stops the build lookup, the request and the file write when aborted, and skips the rest. */
signal?: AbortSignal;
}
): Promise<{ artifactPath: string }> {
const { logger, graphqlClient, robotAccessToken, extensions, signal } = params;
const { logger, graphqlClient, robotAccessToken, extensions } = params;
let downloadUrl: string;
let headers: { Authorization: string } | undefined;
@@ -188,7 +178,7 @@ export async function downloadBuildAsync(
headers = undefined;
} else if (params.buildId) {
const buildId = z.string().uuid().parse(params.buildId);
downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient, signal });
downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient });
headers = robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined;
} else {
throw new UserError(
@@ -201,8 +191,7 @@ export async function downloadBuildAsync(
path.join(os.tmpdir(), 'download_build-downloaded-')
);
signal?.throwIfAborted();
const response = await retryOnDNSFailure(fetch)(downloadUrl, { headers, signal });
const response = await retryOnDNSFailure(fetch)(downloadUrl, { headers });
if (!response.ok) {
const textResult = await asyncResult(response.text());
@@ -212,9 +201,7 @@ export async function downloadBuildAsync(
const archiveFilename = resolveArchiveFilename({ response, extensions });
const archivePath = path.join(downloadDestinationDirectory, archiveFilename);
// On abort, node-fetch emits an error on the body stream, which fails the pipeline.
await streamPipeline(response.body, fs.createWriteStream(archivePath));
signal?.throwIfAborted();
const { size } = await fs.promises.stat(archivePath);
@@ -230,15 +217,10 @@ export async function downloadBuildAsync(
const extractionDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'download_build-extracted-')
);
// Not interrupted on abort: tar cannot stop its in-flight file writes, so an abort during
// the extraction lets it finish (local, bounded work) and is reported right after it.
// This way no file is written after this function settles.
signal?.throwIfAborted();
await decompressTarAsync({
archivePath,
destinationDirectory: extractionDirectory,
});
signal?.throwIfAborted();
const matchingFiles = await glob(`**/*.(${extensions.join('|')})`, {
absolute: true,
@@ -1,114 +0,0 @@
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,15 +199,10 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
}
if (backend === 'maestro-runner' && global.runtimePlatform === BuildRuntimePlatform.DARWIN) {
// 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,
});
}
await installMaestroRunnerWdaCache({
logger,
env,
});
}
logger.info(`${backend} ${maestroVersionResult.value} is ready.`);
@@ -1,104 +0,0 @@
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';
export function createInstallMitmproxyBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'install_mitmproxy',
name: 'Install mitmproxy',
__metricsId: 'eas/install_mitmproxy',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
fn: async ({ logger }, { 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;
}
if (env.EAS_BUILD_RUNNER !== 'eas-build') {
logger.warn(
'mitmproxy is not installed and network capture needs it. Install it with `brew install --cask mitmproxy` and rerun the job.'
);
return;
}
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 did not run.');
}
logger.info('Installed mitmproxy.');
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not install mitmproxy for the device session', error, {
level: 'warning',
});
logger.warn(
{ err: error },
'Could not install mitmproxy, so network capture will record nothing in this session. Re-run the session to retry the install, or run it without network capture.'
);
}
},
});
}
async function isMitmproxyAvailableAsync(env: BuildStepEnv): Promise<boolean> {
// 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({
env,
logger,
}: {
env: BuildStepEnv;
logger: bunyan;
}): Promise<void> {
await spawn('brew', ['install', '--cask', 'mitmproxy'], {
env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1' },
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;
}
@@ -107,8 +107,6 @@ function buildMaestroArgs({
return { executable: 'maestro', args };
}
case 'maestro-runner': {
// The runner writes every report format to one directory. output_format only
// selects which extra report artifact EAS uploads after the test.
const args = [`--platform=${platform}`, 'test', `--output=${output}`, '--flatten'];
if (shards !== undefined && shards > 1) {
args.push(`--parallel=${shards}`);
@@ -215,20 +213,23 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
}
const testsDirectory = path.join(home, '.maestro', 'tests');
const junitReportDirectory = path.join(testsDirectory, 'junit-reports');
const junitFinalReportPath = path.join(testsDirectory, `${platform}-maestro-junit.xml`);
const finalReportPath =
outputFormat === 'junit'
? path.join(testsDirectory, `${platform}-maestro-junit.xml`)
: undefined;
// Public docs (EAS workflows pre-packaged-jobs) document
// `${MAESTRO_TESTS_DIR}` for users to save screenshots/recordings into
// the uploaded dir.
const spawnEnv: NodeJS.ProcessEnv = { ...env, MAESTRO_TESTS_DIR: testsDirectory };
// Publish known paths before backend validation so `if: always()` upload
// steps can use them after an early failure. final_report_path names the
// report selected by output_format, not every report the backend produces.
// Outputs are published BEFORE any throw below so downstream
// `if: always()` upload steps still see populated values when this
// step fails early.
outputs.tests_directory.set(testsDirectory);
outputs.junit_report_directory.set(junitReportDirectory);
if (outputFormat === 'junit') {
outputs.final_report_path.set(junitFinalReportPath);
if (finalReportPath !== undefined) {
outputs.final_report_path.set(finalReportPath);
}
// Resolved after the output assignments above: an invalid backend input or
@@ -238,16 +239,6 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
input: inputs.backend.value,
env,
});
// The runner can write JUnit regardless of output_format; Maestro CLI only
// writes it when JUnit is selected. Either command may fail before it writes a file.
const mayHaveJUnitReports = backend === 'maestro-runner' || outputFormat === 'junit';
const maestroCliOutputPath =
backend === 'maestro' && outputFormat && outputFormat !== 'junit'
? path.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
: null;
if (backend === 'maestro' && outputFormat === 'html' && maestroCliOutputPath) {
outputs.final_report_path.set(maestroCliOutputPath);
}
const flowPaths = parseInput(
FlowPathSchema,
@@ -271,14 +262,10 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
undefined,
'android_connection_mode and EAS_MAESTRO_ANDROID_CONNECTION_MODE must be either "adb" or "dadb".'
);
if (
backend === 'maestro-runner' &&
outputFormat !== undefined &&
!['junit', 'html', 'allure'].includes(outputFormat)
) {
if (backend === 'maestro-runner' && outputFormat !== undefined && outputFormat !== 'junit') {
throw new UserError(
'ERR_MAESTRO_INVALID_INPUT',
`maestro-runner supports "junit", "html", and "allure" output_format values, but received "${outputFormat}".`
`maestro-runner only supports the "junit" output_format, but received "${outputFormat}".`
);
}
const retryFailedOnly = inputs.retry_failed_only.value as boolean;
@@ -350,14 +337,17 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
}
for (let attempt = 0; attempt <= retries; attempt++) {
// maestro-runner writes all report formats and screenshot metadata here.
// maestro-runner writes its JUnit report and screenshot metadata to this directory.
const runnerOutputDirectory = path.join(
testsDirectory,
`${platform}-maestro-runner-attempt-${attempt}`
);
const outputPath = mayHaveJUnitReports
? path.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
: maestroCliOutputPath;
const outputPath =
outputFormat === 'junit'
? path.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
: backend === 'maestro' && outputFormat
? path.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
: null;
const { executable, args: maestroArgs } = buildMaestroArgs({
backend,
platform,
@@ -381,17 +371,6 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
logger.warn({ err }, `Failed to clear ${runnerOutputDirectory} before the attempt.`);
}
}
// Maestro CLI uses the same HTML path for every retry. Remove the previous report
// before spawning so an attempt that fails before report generation cannot upload it.
if (backend === 'maestro' && outputFormat === 'html' && maestroCliOutputPath) {
try {
await fs.rm(maestroCliOutputPath, { force: true });
} catch (err) {
throw new SystemError('Failed to clear the previous Maestro HTML report', {
cause: err,
});
}
}
const attemptStartedAtMs = Date.now();
@@ -433,8 +412,9 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
reportDirectories.push(runnerOutputDirectory);
}
// The pre-packaged job reports test cases only for JUnit (the default format).
// Individual screenshots need those test cases to be visible on the website.
// Harvest this attempt's failure screenshots before any retry subsetting. Gated on
// junit: test-case-result rows (and therefore the summary icons) only exist for junit
// runs, so harvesting other formats would just create orphan artifacts the website hides.
if (outputFormat === 'junit') {
let screenshots: HarvestedScreenshot[];
switch (backend) {
@@ -524,15 +504,14 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
await sleepAsync(2000);
}
// Only JUnit is merged into final_report_path. Runner HTML/Allure keeps its
// per-attempt JUnit files, but final_report_path names the selected report.
// On data errors (bad XML, missing input), fall back to copy-latest.
// Smart merge first; on data errors (bad XML, missing input) fall back
// to copy-latest so the caller still gets a single JUnit file.
// Filesystem errors short-circuit straight to SystemError.
if (outputFormat === 'junit') {
if (finalReportPath !== undefined) {
try {
await mergeJUnitReports({
sourceDir: junitReportDirectory,
outputPath: junitFinalReportPath,
outputPath: finalReportPath,
});
} catch (mergeErr: any) {
if (isFilesystemError(mergeErr)) {
@@ -542,7 +521,7 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
try {
await copyLatestAttemptXml({
sourceDir: junitReportDirectory,
outputPath: junitFinalReportPath,
outputPath: finalReportPath,
});
} catch (copyErr: any) {
// Swallow: a copy failure here usually means maestro itself failed
@@ -550,13 +529,14 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
// the real reason and cancel billing for a user-side failure — let
// the lastAttemptExitCode check below surface ERR_MAESTRO_TESTS_FAILED.
logger.warn(
`Failed to produce final_report_path at ${junitFinalReportPath}: ${copyErr?.message ?? copyErr}`
`Failed to produce final_report_path at ${finalReportPath}: ${copyErr?.message ?? copyErr}`
);
}
}
}
// Upload before the failure verdict so fully-failed runs still get screenshots.
// Upload before the ERR_MAESTRO_TESTS_FAILED throw below so fully-failed runs (which need
// screenshots most) still upload. Harvest only ran for junit, so guard the same way.
if (outputFormat === 'junit') {
await uploadFailureScreenshotsAsync({
harvested,
@@ -567,81 +547,6 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
});
}
// Upload the selected non-JUnit report before the failure verdict. The pre-packaged
// job also uploads testsDirectory, but a named artifact makes the report easy to find.
let selectedReport:
| { name: string; artifactPath: string; finalReportPath: string }
| undefined;
switch (outputFormat) {
case 'html': {
switch (backend) {
case 'maestro': {
if (!maestroCliOutputPath) {
break;
}
selectedReport = {
name: 'Maestro HTML Report',
artifactPath: maestroCliOutputPath,
finalReportPath: maestroCliOutputPath,
};
break;
}
case 'maestro-runner': {
const reportDirectory = reportDirectories.at(-1);
if (!reportDirectory) {
break;
}
// HTML references nearby screenshots, so keep its whole attempt directory.
selectedReport = {
name: 'Maestro Runner HTML Report',
artifactPath: reportDirectory,
finalReportPath: path.join(reportDirectory, 'report.html'),
};
break;
}
}
break;
}
case 'allure': {
switch (backend) {
case 'maestro':
logger.warn(
'Maestro CLI does not support Allure reports; no Allure artifact was uploaded.'
);
break;
case 'maestro-runner': {
const reportDirectory = reportDirectories.at(-1);
if (!reportDirectory) {
break;
}
const allureResultsDirectory = path.join(reportDirectory, 'allure-results');
selectedReport = {
name: 'Maestro Runner Allure Results',
artifactPath: allureResultsDirectory,
finalReportPath: allureResultsDirectory,
};
break;
}
}
break;
}
}
if (selectedReport) {
outputs.final_report_path.set(selectedReport.finalReportPath);
try {
await ctx.runtimeApi.uploadArtifact({
artifact: {
type: GenericArtifactType.OTHER,
name: selectedReport.name,
paths: [selectedReport.artifactPath],
},
logger,
});
} catch (err: any) {
logger.warn({ err }, `Failed to upload ${selectedReport.name}.`);
}
}
// The retry loop exits via success (0), numeric status (retryable),
// or throw (infra). A non-null non-zero status means the user's tests
// failed every attempt.
@@ -1,42 +0,0 @@
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);
},
});
}
@@ -1,136 +0,0 @@
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,14 +1,22 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { type BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import {
BuildFunction,
BuildRuntimePlatform,
type BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
} 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 } from '../utils/localEgressSession';
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import {
uploadRemoteSessionConfigWithLocalEgressAsync,
withLocalEgressSession,
} from '../utils/localEgressSession';
import { Sentry } from '../../sentry';
import {
PackageManager,
@@ -17,16 +25,18 @@ import {
resolvePackageInstall,
} from '../../utils/packageManager';
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
import { type parseNetworkCaptureInputs } from '../utils/networkCaptureFields';
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
import { type StartupTasks } from '../utils/startupTasks';
import {
type DetachedProcessHandle,
finishRemoteSessionAsync,
createServeSimLaunchInputProviders,
describeServeSimLaunch,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
@@ -41,238 +51,174 @@ const STARTUP_TIMEOUT_MS = 60_000;
const AGENT_DEVICE_DAEMON_ENV = {
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
AGENT_DEVICE_RETAIN_ARTIFACTS: '1',
// The session lifetime is owned by max_idle_time_minutes / max_duration_seconds
// and the device run session stop, so disable agent-device's own idle timers.
// Without this the daemon exits after 5 minutes with no open session, and the
// tunnel is left without a daemon behind it.
AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0',
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS: '0',
AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS: '0',
};
export type AgentDeviceRemoteSessionEnv = {
deviceRunSessionId: string;
ngrokTunnelDomain: string;
ngrokAuthtoken: string;
};
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(),
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: DEVICE_RUN_SESSION_ID (to report the remote config
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
// for our ngrok tunnels), and NGROK_AUTHTOKEN (to authenticate them).
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
/**
* Reads the env vars that the API server injects into a device run session job:
* DEVICE_RUN_SESSION_ID (to report the remote config back to the API server),
* EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain for our ngrok tunnels), and
* NGROK_AUTHTOKEN (to authenticate them).
*/
export function getAgentDeviceRemoteSessionEnvOrThrow(
env: BuildStepEnv
): AgentDeviceRemoteSessionEnv {
return {
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
ngrokTunnelDomain: getNgrokTunnelDomainOrThrow(env),
ngrokAuthtoken: getNgrokAuthtokenOrThrow(env),
};
}
const packageVersion = inputs.package_version.value as string | undefined;
// A missing or non-positive value disables the idle timeout (opt-in feature).
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 }
);
logger.info(
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
);
/**
* Starts the agent-device daemon, its tunnel, the session host and the web preview,
* reports the session as ready, and keeps it alive until it stops.
*
* The daemon does not need the device, so it starts at once. The session host starts
* when `device.booted` resolves. The session is reported as ready only when both are
* up and `device.ready` (the app is installed and launched) resolved too.
*
* The first failure aborts `tasks.signal`: each part stops before its next stage, and
* the teardown then stops whatever was started. `device.ready` must settle soon after
* an abort, because the teardown waits for it.
*/
export async function runAgentDeviceRemoteSessionAsync(
ctx: CustomBuildContext,
{
env,
logger,
signal,
runtimePlatform,
sessionEnv: { deviceRunSessionId, ngrokTunnelDomain, ngrokAuthtoken },
packageVersion,
maxIdleTimeMinutes,
maxDurationSeconds,
capture,
tasks,
device,
}: {
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
runtimePlatform: BuildRuntimePlatform;
sessionEnv: AgentDeviceRemoteSessionEnv;
packageVersion: string | undefined;
maxIdleTimeMinutes: number | undefined;
maxDurationSeconds: number | undefined;
capture: ReturnType<typeof parseNetworkCaptureInputs>;
tasks: StartupTasks;
device: { booted: Promise<unknown>; ready: Promise<unknown> };
}
): Promise<void> {
logger.info(
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
);
let daemonProcess: DetachedProcessHandle | undefined;
let agentDeviceTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let eventCollection: Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>> | undefined;
let sessionFailed = false;
// Each task stores what it started, so the teardown below can stop it even when
// another task failed first.
const agentDeviceStartup = tasks.run('agent-device daemon', async taskLogger => {
taskLogger.info('Launching agent-device daemon.');
daemonProcess = await startAgentDeviceDaemonAsync({
packageVersion,
env,
logger: taskLogger,
signal: tasks.signal,
});
taskLogger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
// The daemon is stored above, so the teardown stops it. The wait itself is bounded
// (STARTUP_TIMEOUT_MS) and only reads a file, so it may finish in the background.
const daemonInfo = await tasks.untilAborted(waitForDaemonInfoAsync({ daemonProcess }));
taskLogger.info(`Daemon is listening on port ${daemonInfo.port}; loaded auth token.`);
tasks.signal.throwIfAborted();
agentDeviceTunnel = await startNgrokTunnelAsync({
port: daemonInfo.port,
subdomainPrefix: 'agent-device',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
logger: taskLogger,
});
taskLogger.info(`Tunnel is ready at ${agentDeviceTunnel.url}.`);
return { ...daemonInfo, remoteSessionUrl: agentDeviceTunnel.url };
});
const sessionHostStartup = tasks.run('session host', async taskLogger => {
// A boot cannot be cancelled, so stop waiting for it when startup is aborted.
await tasks.untilAborted(device.booted);
tasks.signal.throwIfAborted();
sessionHost = await startDeviceSessionHostAsync(ctx, {
runtimePlatform,
env,
logger: taskLogger,
timeoutMs: STARTUP_TIMEOUT_MS,
networkCapture: capture.networkCapture,
networkCaptureFields: capture.networkCaptureFields,
});
tasks.signal.throwIfAborted();
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
taskLogger.info(
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
);
return webPreview;
});
try {
const [
{ port: daemonPort, token: daemonToken, remoteSessionUrl: agentDeviceRemoteSessionUrl },
webPreview,
] = await Promise.all([agentDeviceStartup, sessionHostStartup, device.ready]).catch(
(err: unknown) => {
// Also aborts for a `device.ready` that does not come from `tasks.run`.
tasks.abort(err);
throw err;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
);
logger.info(tasks.summary());
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
deviceRunSessionId,
remoteConfig: {
agentDeviceRemoteSessionUrl,
agentDeviceRemoteSessionToken: daemonToken,
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
});
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
daemonUrl: `http://127.0.0.1:${daemonPort}`,
daemonToken,
logger,
});
logger.info('Launching agent-device daemon.');
const daemonProcess = await startAgentDeviceDaemonAsync({ packageVersion, env, logger });
eventCollection = await startAgentDeviceEventCollectionAsync({
ctx,
deviceRunSessionId,
stateDir: AGENT_DEVICE_STATE_DIR,
logger,
});
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
const { port: daemonPort, token: daemonToken } = await waitForDaemonInfoAsync({
daemonProcess,
});
logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
maxDurationSeconds,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
}
: undefined,
});
} catch (error) {
sessionFailed = true;
throw error;
} finally {
// Promise.all rejects on the first failure while other tasks can still be starting.
// They stop at their next abort check. Wait for all of them, so the teardown sees
// everything that was started.
await Promise.allSettled([agentDeviceStartup, sessionHostStartup, device.ready]);
const startedDaemon = daemonProcess;
await finishRemoteSessionAsync({
logger,
sessionFailed,
teardown: [
['agent-device tunnel', agentDeviceTunnel?.stopAsync()],
[
'agent-device daemon',
startedDaemon &&
(async () => {
try {
if (eventCollection) {
await stopAgentDeviceEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
const agentDeviceTunnel = await startNgrokTunnelAsync({
port: daemonPort,
subdomainPrefix: 'agent-device',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
logger,
});
const agentDeviceRemoteSessionUrl = agentDeviceTunnel.url;
logger.info(`Tunnel is ready at ${agentDeviceRemoteSessionUrl}.`);
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
let eventCollection:
| Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>>
| undefined;
try {
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
runtimePlatform,
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
});
logger.info(
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
);
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
deviceRunSessionId,
remoteConfig: {
agentDeviceRemoteSessionUrl,
agentDeviceRemoteSessionToken: daemonToken,
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
});
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
daemonUrl: `http://127.0.0.1:${daemonPort}`,
daemonToken,
logger,
});
eventCollection = await startAgentDeviceEventCollectionAsync({
ctx,
deviceRunSessionId,
stateDir: AGENT_DEVICE_STATE_DIR,
logger,
});
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
maxDurationSeconds,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
}
} finally {
await startedDaemon.stopAsync();
}
})(),
],
['session host', sessionHost?.finishAsync()],
],
});
}
: undefined,
});
} finally {
if (webPreview) {
await webPreview.stopAsync();
}
await agentDeviceTunnel.stopAsync();
if (eventCollection) {
await stopAgentDeviceEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
}
await daemonProcess.stopAsync();
}
}),
});
}
export async function startAgentDeviceDaemonAsync({
packageVersion,
env,
logger,
signal,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
/** Kills the install and stops before the daemon starts, when aborted. No git fallback then. */
signal?: AbortSignal;
}): Promise<DetachedProcessHandle> {
const packageSpec = createAgentDevicePackageSpec(packageVersion);
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
@@ -285,14 +231,13 @@ export async function startAgentDeviceDaemonAsync({
try {
const add = resolvePackageAdd(packageManager, packageSpec);
logger.info(`Installing ${packageSpec} with ${add.command}.`);
await spawn(add.command, add.args, { cwd: installDir, env, logger, signal });
await spawn(add.command, add.args, { cwd: installDir, env, logger });
const daemonPath = getInstalledAgentDeviceDaemonPath(installDir);
if (!fs.existsSync(daemonPath)) {
throw new SystemError(`Expected agent-device daemon entry at ${daemonPath}.`);
}
signal?.throwIfAborted();
logger.info(`Launching daemon from ${daemonPath} after ${add.command} install.`);
const daemonProcess = spawnDetached({
command: 'node',
@@ -308,10 +253,6 @@ export async function startAgentDeviceDaemonAsync({
};
} catch (err) {
await fs.promises.rm(installDir, { recursive: true, force: true });
// An abort is not an install problem: stop instead of falling back to git.
if (signal?.aborted) {
throw err;
}
const error = err instanceof Error ? err : new Error(String(err));
const bunVersion = await getBunVersionForDiagnosticsAsync(env);
Sentry.capture(
@@ -334,7 +275,7 @@ export async function startAgentDeviceDaemonAsync({
logger.warn(
`Failed to start daemon from ${packageSpec} via ${packageManager}; falling back to git clone: ${error.message}`
);
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger, signal });
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger });
}
}
@@ -364,19 +305,17 @@ async function startAgentDeviceDaemonFromGitAsync({
packageVersion,
env,
logger,
signal,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
}): Promise<DetachedProcessHandle> {
logger.info(
packageVersion
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
: `Cloning agent-device (latest) into ${SRC_DIR}.`
);
await cloneAgentDeviceAsync({ packageVersion, env, logger, signal });
await cloneAgentDeviceAsync({ packageVersion, env, logger });
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
const install = resolvePackageInstall(packageManager, { production: true });
@@ -385,10 +324,8 @@ async function startAgentDeviceDaemonFromGitAsync({
cwd: SRC_DIR,
env,
logger,
signal,
});
signal?.throwIfAborted();
logger.info('Launching daemon from cloned agent-device source.');
// Git fallback is TypeScript source. The published path runs node on dist JS.
return spawnDetached({
@@ -403,18 +340,15 @@ async function cloneAgentDeviceAsync({
packageVersion,
env,
logger,
signal,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
}): Promise<void> {
const branchArgs = packageVersion ? ['--branch', `v${packageVersion}`] : [];
await spawn('git', ['clone', '--depth', '1', ...branchArgs, AGENT_DEVICE_REPO_URL, SRC_DIR], {
env,
logger,
signal,
});
}
@@ -1,267 +0,0 @@
import { UserError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { type CustomBuildContext } from '../../customBuildContext';
import {
AndroidDeviceName,
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorName, IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
import { withLocalEgressSession } from '../utils/localEgressSession';
import { selectXcodeDeveloperDirectoryAsync } from '../utils/remoteDeviceRunSession';
import {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
} from '../utils/networkCaptureFields';
import { createStartupTasks } from '../utils/startupTasks';
import { downloadBuildAsync } from './downloadBuild';
import { installBuildAsync } from './installBuild';
import {
launchApplicationAsync,
parseLaunchArgsInput,
parseOpenUrlInput,
} from './launchApplication';
import {
getAgentDeviceRemoteSessionEnvOrThrow,
runAgentDeviceRemoteSessionAsync,
} from './startAgentDeviceRemoteSession';
import { startAndroidEmulatorAsync } from './startAndroidEmulator';
import { bootIosSimulatorAsync } from './startIosSimulator';
const ANDROID_DEVICE_NAME = 'EasAndroidDevice01' as AndroidVirtualDeviceName;
/**
* One step for a whole agent-device session: boot the device, download, install and
* launch the app, and start the agent-device daemon and the web preview.
*
* It does the work of eas/start_ios_simulator or eas/start_android_emulator,
* eas/download_build, eas/install_build and eas/launch_application, plus the agent-device
* session, so the parts that do not depend on each other can run at the same time:
*
* boot ─────────────┬─► install ─► launch ─┐
* download ─────────┘ │
* boot ─► session host ─► web preview ─────┼─► ready
* agent-device daemon ─► tunnel ───────────┘
*
* The first failure aborts the rest: the download stops, and nothing installs, launches
* or starts after it. A boot cannot be cancelled, so it can still run when a failed
* step returns.
*/
export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_agent_device_session',
name: 'Start agent-device session',
__metricsId: 'eas/start_agent_device_session',
inputProviders: [
// Device. On iOS a Simulator name or UDID, on Android an AVD hardware profile.
BuildStepInput.createProvider({
id: 'device_identifier',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'system_image_package',
required: false,
defaultValue: AndroidEmulatorUtils.defaultSystemImagePackage,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'lcd_width',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'lcd_height',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'lcd_density',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
// Application. Pass at most one of build_id and application_archive_url.
BuildStepInput.createProvider({
id: 'build_id',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'application_archive_url',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'launch_args',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
BuildStepInput.createProvider({
id: 'open_url',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
// Session.
...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 (stepsCtx, { inputs, env, signal }) => {
const { logger, global } = stepsCtx;
const { runtimePlatform } = global;
const isIos = runtimePlatform === BuildRuntimePlatform.DARWIN;
// Validate everything before any expensive work starts.
const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(env);
const buildId = inputs.build_id.value as string | undefined;
const applicationArchiveUrl = inputs.application_archive_url.value as string | undefined;
if (buildId && applicationArchiveUrl) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
'Pass only one of build_id or application_archive_url.'
);
}
const hasApplication = Boolean(buildId || applicationArchiveUrl);
const launchArgs = parseLaunchArgsInput(inputs.launch_args.value);
const openUrl =
inputs.open_url.value === undefined ? undefined : parseOpenUrlInput(inputs.open_url.value);
if (!hasApplication && (launchArgs.length > 0 || openUrl)) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
'launch_args and open_url need an application: pass build_id or application_archive_url.'
);
}
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
const capture = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture.value,
networkCaptureFields: inputs.network_capture_fields.value,
},
{ runtimePlatform }
);
if (isIos) {
// Before the boot, so every Xcode tool below uses the same developer directory.
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const tasks = createStartupTasks(logger);
const booted = tasks.run(
isIos ? 'iOS Simulator boot' : 'Android Emulator boot',
async taskLogger => {
if (isIos) {
await bootIosSimulatorAsync({
deviceIdentifier: deviceIdentifier as IosSimulatorUuid | IosSimulatorName | undefined,
env,
logger: taskLogger,
});
return;
}
await startAndroidEmulatorAsync({
deviceName: ANDROID_DEVICE_NAME,
systemImagePackage: `${inputs.system_image_package.value}`,
deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined,
lcdWidth: inputs.lcd_width.value as number | undefined,
lcdHeight: inputs.lcd_height.value as number | undefined,
lcdDensity: inputs.lcd_density.value as number | undefined,
logcatDirectory: await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
),
env,
logger: taskLogger,
});
}
);
const downloaded = hasApplication
? tasks.run('build download', async taskLogger => {
const { artifactPath } = await downloadBuildAsync({
logger: taskLogger,
...(buildId ? { buildId } : { applicationArchiveUrl: applicationArchiveUrl! }),
graphqlClient: ctx.graphqlClient,
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
extensions: isIos ? ['app'] : ['apk'],
signal: tasks.signal,
});
return artifactPath;
})
: undefined;
// `ready` settles only after everything it started has settled, so the session
// teardown, which waits for it, never leaves a download running. It must also settle
// soon after an abort: it stops waiting for the boot then, and the download stops.
const ready = downloaded
? tasks.run('app install and launch', async taskLogger => {
const [download, boot] = await Promise.allSettled([
downloaded,
tasks.untilAborted(booted),
]);
// After a failure elsewhere, report that failure, not the abort it caused here.
tasks.signal.throwIfAborted();
if (download.status === 'rejected') {
throw download.reason;
}
if (boot.status === 'rejected') {
throw boot.reason;
}
const { applicationIdentifier, activityName } = await installBuildAsync({
artifactPath: download.value,
runtimePlatform,
env,
logger: taskLogger,
});
tasks.signal.throwIfAborted();
await launchApplicationAsync({
applicationIdentifier,
activityName,
launchArgs,
openUrl,
runtimePlatform,
env,
logger: taskLogger,
});
})
: tasks.untilAborted(booted);
await runAgentDeviceRemoteSessionAsync(ctx, {
env,
logger,
signal,
runtimePlatform,
sessionEnv,
packageVersion: inputs.package_version.value as string | undefined,
// A missing or non-positive value disables the idle timeout (opt-in feature).
maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined,
maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined,
capture,
tasks,
device: { booted, ready },
});
}),
});
}
@@ -1,10 +1,8 @@
import { UserError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
BuildFunction,
BuildRuntimePlatform,
type BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
@@ -16,12 +14,10 @@ import path from 'node:path';
import {
AndroidDeviceName,
AndroidDeviceSerialId,
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../../utils/AndroidEmulatorUtils';
import { retryAsync } from '../../utils/retry';
import { resolveAndroidEmulatorLocalEgressAsync } from '../utils/androidLocalEgress';
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
@@ -84,20 +80,140 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
);
outputs.logcat_directory.set(logcatDirectory);
if (env.EAS_NO_EMULATOR_HOST_SUPPORT_CHECK !== '1') {
await assertAndroidEmulatorHostSupportAsync({ env });
} else {
logger.info(
'Skipping Android emulator host support check because $EAS_NO_EMULATOR_HOST_SUPPORT_CHECK is enabled.'
);
}
try {
const availableDevices = await AndroidEmulatorUtils.getAvailableDevicesAsync({ env });
logger.info(`Available Android devices:\n- ${availableDevices.join(`\n- `)}`);
} catch (error) {
logger.info('Failed to list available Android devices.', error);
} finally {
logger.info('');
}
const deviceName = `${inputs.device_name.value}` as AndroidVirtualDeviceName;
const { serialId, emulatorPromise, shouldAdjustAnimationScale } =
await startAndroidEmulatorAsync({
deviceName,
systemImagePackage: `${inputs.system_image_package.value}`,
// We can cast because allowedValueTypeName validated this is a string.
deviceIdentifier: inputs.device_identifier.value as AndroidDeviceName | undefined,
lcdWidth: inputs.lcd_width.value as number | undefined,
lcdHeight: inputs.lcd_height.value as number | undefined,
lcdDensity: inputs.lcd_density.value as number | undefined,
logcatDirectory,
env,
const systemImagePackage = `${inputs.system_image_package.value}`;
// We can cast because allowedValueTypeName validated this is a string.
const deviceIdentifier = inputs.device_identifier.value as AndroidDeviceName | undefined;
const lcdWidth = inputs.lcd_width.value as number | undefined;
const lcdHeight = inputs.lcd_height.value as number | undefined;
const lcdDensity = inputs.lcd_density.value as number | undefined;
const shouldAdjustAnimationScale =
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== 'false' &&
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== '0';
if (!shouldAdjustAnimationScale) {
logger.info(
'Skipping Android emulator animation scale adjustments because $ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE is disabled.'
);
}
logger.info('Making sure system image is installed');
await retryAsync(
async () => {
await spawn('sdkmanager', [systemImagePackage], {
env,
logger,
});
},
{
logger,
});
retryOptions: {
retries: 3, // Retry 3 times
retryIntervalMs: 1_000,
},
}
);
let emulatorPromise = null;
let serialId = null;
await retryAsync(
async attemptCount => {
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
const attempt = attemptCount + 1;
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
const attemptSuffix = attempt > 1 ? ` (attempt ${attempt}/${maxAttempts})` : '';
let attemptSerialId = null;
try {
logger.info(`Creating emulator device${attemptSuffix}.`);
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage,
deviceIdentifier: deviceIdentifier ?? null,
lcdWidth: lcdWidth ?? null,
lcdHeight: lcdHeight ?? null,
lcdDensity: lcdDensity ?? null,
env,
logger,
});
logger.info(`Starting emulator device${attemptSuffix}.`);
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName,
env,
logcatDirectory,
});
attemptSerialId = startResult.serialId;
await AndroidEmulatorUtils.waitForReadyAsync({
env,
serialId: attemptSerialId,
timeoutMs,
logger,
});
if (shouldAdjustAnimationScale) {
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
env,
logger,
serialId: attemptSerialId,
});
}
logger.info(`${deviceName} is ready.`);
serialId = attemptSerialId;
emulatorPromise = startResult.emulatorPromise;
} catch (err) {
logger.warn(
{ err },
`${deviceName} failed to start on attempt ${attempt}/${maxAttempts}.`
);
try {
if (attemptSerialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId: attemptSerialId,
deviceName,
env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName,
env,
});
}
} catch (cleanupErr) {
logger.warn({ err: cleanupErr }, `Failed to clean up ${deviceName}.`);
}
throw err;
}
},
{
logger,
retryOptions: {
retries: ANDROID_STARTUP_RETRIES_COUNT,
retryIntervalMs: 1_000,
},
}
);
if (!serialId || !emulatorPromise) {
throw new Error(`Failed to start emulator ${deviceName}.`);
}
const count = Number(inputs.count.value ?? 1);
if (count > 1) {
@@ -190,167 +306,6 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
});
}
/**
* Checks the host, installs the system image, then creates and starts one emulator and
* waits until it is ready, with retries.
*/
export async function startAndroidEmulatorAsync({
deviceName,
systemImagePackage,
deviceIdentifier,
lcdWidth,
lcdHeight,
lcdDensity,
logcatDirectory,
env,
logger,
}: {
deviceName: AndroidVirtualDeviceName;
systemImagePackage: string;
deviceIdentifier?: AndroidDeviceName;
lcdWidth?: number;
lcdHeight?: number;
lcdDensity?: number;
logcatDirectory: string;
env: BuildStepEnv;
logger: bunyan;
}): Promise<{
serialId: AndroidDeviceSerialId;
emulatorPromise: Promise<unknown>;
shouldAdjustAnimationScale: boolean;
}> {
if (env.EAS_NO_EMULATOR_HOST_SUPPORT_CHECK !== '1') {
await assertAndroidEmulatorHostSupportAsync({ env });
} else {
logger.info(
'Skipping Android emulator host support check because $EAS_NO_EMULATOR_HOST_SUPPORT_CHECK is enabled.'
);
}
try {
const availableDevices = await AndroidEmulatorUtils.getAvailableDevicesAsync({ env });
logger.info(`Available Android devices:\n- ${availableDevices.join(`\n- `)}`);
} catch (error) {
logger.info('Failed to list available Android devices.', error);
} finally {
logger.info('');
}
const shouldAdjustAnimationScale =
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== 'false' &&
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== '0';
if (!shouldAdjustAnimationScale) {
logger.info(
'Skipping Android emulator animation scale adjustments because $ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE is disabled.'
);
}
const localEgress = await resolveAndroidEmulatorLocalEgressAsync();
logger.info('Making sure system image is installed');
await retryAsync(
async () => {
await spawn('sdkmanager', [systemImagePackage], {
env,
logger,
});
},
{
logger,
retryOptions: {
retries: 3, // Retry 3 times
retryIntervalMs: 1_000,
},
}
);
let emulatorPromise = null;
let serialId = null;
await retryAsync(
async attemptCount => {
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
const attempt = attemptCount + 1;
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
const attemptSuffix = attempt > 1 ? ` (attempt ${attempt}/${maxAttempts})` : '';
let attemptSerialId = null;
try {
logger.info(`Creating emulator device${attemptSuffix}.`);
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage,
deviceIdentifier: deviceIdentifier ?? null,
lcdWidth: lcdWidth ?? null,
lcdHeight: lcdHeight ?? null,
lcdDensity: lcdDensity ?? null,
env,
logger,
});
logger.info(`Starting emulator device${attemptSuffix}.`);
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName,
env,
logcatDirectory,
...(localEgress ? { launchGate: localEgress.launchGate } : {}),
});
attemptSerialId = startResult.serialId;
await AndroidEmulatorUtils.waitForReadyAsync({
env,
serialId: attemptSerialId,
timeoutMs,
logger,
...(localEgress ? { networkReadyTarget: localEgress.networkReadyTarget } : {}),
});
if (shouldAdjustAnimationScale) {
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
env,
logger,
serialId: attemptSerialId,
});
}
await localEgress?.configureBootedEmulatorAsync({ serialId: attemptSerialId, logger });
logger.info(`${deviceName} is ready.`);
serialId = attemptSerialId;
emulatorPromise = startResult.emulatorPromise;
} catch (err) {
logger.warn({ err }, `${deviceName} failed to start on attempt ${attempt}/${maxAttempts}.`);
try {
if (attemptSerialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId: attemptSerialId,
deviceName,
env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName,
env,
});
}
} catch (cleanupErr) {
logger.warn({ err: cleanupErr }, `Failed to clean up ${deviceName}.`);
}
throw err;
}
},
{
logger,
retryOptions: {
retries: ANDROID_STARTUP_RETRIES_COUNT,
retryIntervalMs: 1_000,
},
}
);
if (!serialId || !emulatorPromise) {
throw new Error(`Failed to start emulator ${deviceName}.`);
}
return { serialId, emulatorPromise, shouldAdjustAnimationScale };
}
async function assertAndroidEmulatorHostSupportAsync({
env,
}: {
@@ -1,6 +1,5 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
BuildFunction,
BuildRuntimePlatform,
@@ -20,11 +19,6 @@ import {
uploadRemoteSessionConfigWithLocalEgressAsync,
withLocalEgressSession,
} from '../utils/localEgressSession';
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
} from '../utils/networkCaptureFields';
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
import {
@@ -38,14 +32,13 @@ import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
import {
createServeSimLaunchInputProviders,
describeServeSimLaunch,
ensureFfmpegInstalledOnceAsync,
finishRemoteSessionAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
@@ -70,7 +63,6 @@ export function createStartAppiumRemoteSessionBuildFunction(
__metricsId: 'eas/start_appium_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
...createNetworkCaptureInputProviders(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -97,30 +89,18 @@ export function createStartAppiumRemoteSessionBuildFunction(
},
{ runtimePlatform }
);
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
{ runtimePlatform }
);
const versionSpec = resolveAppium3VersionSpec(packageVersion);
logger.info(
`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
const device = await resolveAppiumDeviceAsync({ runtimePlatform, env, logger });
// Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the
// simulator stream with it, and UiAutomator2 merges long recordings with it. The
// macOS session image does not ship ffmpeg, so install it concurrently with Appium.
// Both installs settle before the step continues or fails, so no install outlives it.
const [appiumInstall] = await Promise.all([
asyncResult(
installAppiumAsync({ versionSpec, driverName: device.driverName, env, logger })
),
ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger }),
]);
const { appiumHome, appiumBinPath, appiumEnv } = appiumInstall.enforceValue();
const { appiumHome, appiumBinPath, appiumEnv } = await installAppiumAsync({
versionSpec,
driverName: device.driverName,
env,
logger,
});
const appiumProcess = spawnDetached({
command: appiumBinPath,
@@ -158,8 +138,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
logger,
});
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let sessionFailed = false;
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
try {
appiumTunnel = await startNgrokTunnelAsync({
port: APPIUM_PORT,
@@ -175,18 +154,16 @@ export function createStartAppiumRemoteSessionBuildFunction(
if (launchDescription) {
logger.info(launchDescription);
}
sessionHost = await startDeviceSessionHostAsync(ctx, {
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
runtimePlatform,
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
networkCapture,
networkCaptureFields,
});
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
@@ -220,29 +197,16 @@ export function createStartAppiumRemoteSessionBuildFunction(
}
: undefined,
});
} catch (error) {
sessionFailed = true;
throw error;
} finally {
await finishRemoteSessionAsync({
logger,
sessionFailed,
teardown: [
['Appium tunnel', appiumTunnel?.stopAsync()],
[
'Appium server',
(async () => {
try {
await eventCollection.stopAsync();
} finally {
await appiumProcess.stopAsync();
await fs.promises.rm(appiumHome, { recursive: true, force: true });
}
})(),
],
['session host', sessionHost?.finishAsync()],
],
});
if (webPreview) {
await webPreview.stopAsync();
}
if (appiumTunnel) {
await appiumTunnel.stopAsync();
}
await eventCollection.stopAsync();
await appiumProcess.stopAsync();
await fs.promises.rm(appiumHome, { recursive: true, force: true });
}
}),
});
@@ -18,11 +18,6 @@ import {
uploadRemoteSessionConfigWithLocalEgressAsync,
withLocalEgressSession,
} from '../utils/localEgressSession';
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
} from '../utils/networkCaptureFields';
import { Sentry } from '../../sentry';
import {
PackageManager,
@@ -37,13 +32,13 @@ import {
createServeSimLaunchInputProviders,
describeServeSimLaunch,
ensureFfmpegInstalledOnceAsync,
finishRemoteSessionAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
@@ -80,7 +75,6 @@ export function createStartArgentRemoteSessionBuildFunction(
__metricsId: 'eas/start_argent_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
...createNetworkCaptureInputProviders(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -121,13 +115,6 @@ export function createStartArgentRemoteSessionBuildFunction(
},
{ runtimePlatform }
);
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
{ runtimePlatform }
);
logger.info(
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
@@ -197,7 +184,6 @@ export function createStartArgentRemoteSessionBuildFunction(
stateDir: ARGENT_STATE_DIR,
ancestorPid: argentServer.pid,
timeoutMs: STARTUP_TIMEOUT_MS,
getExitError: argentServer.getExitError,
});
toolServerPort = toolServerState.port;
toolServerToken = toolServerState.token;
@@ -230,8 +216,7 @@ export function createStartArgentRemoteSessionBuildFunction(
});
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let sessionFailed = false;
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
try {
toolsTunnel = await startNgrokTunnelAsync({
port: toolServerPort,
@@ -248,18 +233,16 @@ export function createStartArgentRemoteSessionBuildFunction(
if (launchDescription) {
logger.info(launchDescription);
}
sessionHost = await startDeviceSessionHostAsync(ctx, {
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
runtimePlatform,
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
networkCapture,
networkCaptureFields,
});
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
logger.info(
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
);
@@ -293,46 +276,23 @@ export function createStartArgentRemoteSessionBuildFunction(
}
: undefined,
});
} catch (error) {
sessionFailed = true;
throw error;
} finally {
await finishRemoteSessionAsync({
logger,
sessionFailed,
teardown: [
['Argent tunnel', toolsTunnel?.stopAsync()],
[
'Argent tool-server',
(async () => {
try {
await stopArgentEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
artifactPollAbortController.abort();
try {
await artifactPollingPromise;
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture(
'Could not finish Argent remote session artifact polling',
error
);
logger.warn(
{ err: error },
'Could not finish Argent remote session artifact polling.'
);
}
} finally {
await argentServer.stopAsync();
}
})(),
],
['session host', sessionHost?.finishAsync()],
],
});
if (webPreview) {
await webPreview.stopAsync();
}
if (toolsTunnel) {
await toolsTunnel.stopAsync();
}
await stopArgentEventCollectionSafelyAsync({ eventCollection, deviceRunSessionId, logger });
artifactPollAbortController.abort();
try {
await artifactPollingPromise;
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not finish Argent remote session artifact polling', error);
logger.warn({ err: error }, 'Could not finish Argent remote session artifact polling.');
}
await argentServer.stopAsync();
}
}),
});
@@ -411,26 +371,16 @@ export async function waitForArgentToolServerStateAsync({
ancestorPid,
timeoutMs,
pollIntervalMs = 1_000,
getExitError,
}: {
stateDir: string;
ancestorPid: number;
timeoutMs: number;
pollIntervalMs?: number;
getExitError?: () => Error | undefined;
}): Promise<ArgentToolServerState> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
const throwIfExited = (): void => {
const error = getExitError?.();
if (error) {
throw new SystemError(`Argent exited before becoming ready: ${error.message}`);
}
};
while (Date.now() < deadline) {
throwIfExited();
let matchingState: ArgentToolServerState | undefined;
try {
const stateFileNames = (await fs.promises.readdir(stateDir)).filter(
name => name.startsWith('tool-server') && name.endsWith('.json')
@@ -441,8 +391,7 @@ export async function waitForArgentToolServerStateAsync({
await fs.promises.readFile(path.join(stateDir, stateFileName), 'utf8')
);
if (await isProcessDescendantOfAsync(state.pid, ancestorPid)) {
matchingState = state;
break;
return state;
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT' && !(err instanceof SystemError)) {
@@ -453,11 +402,6 @@ export async function waitForArgentToolServerStateAsync({
} catch (err) {
lastError = err;
}
// Keep process failures outside the retryable state-file read errors.
throwIfExited();
if (matchingState) {
return matchingState;
}
await sleepAsync(pollIntervalMs);
}
@@ -47,18 +47,59 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
}),
],
fn: async ({ logger }, { inputs, env }) => {
try {
const availableDevices = await IosSimulatorUtils.getAvailableDevicesAsync({
env,
filter: 'available',
});
logger.info(
`Available Simulator devices:\n- ${availableDevices
.map(device => device.displayName)
.join(`\n- `)}`
);
} catch (error) {
logger.info('Failed to list available Simulator devices.', error);
} finally {
logger.info('');
}
const deviceIdentifierInput = inputs.device_identifier.value?.toString() as
| IosSimulatorUuid
| IosSimulatorName
| undefined;
const originalDeviceIdentifier =
deviceIdentifierInput ?? (await findMostGenericIphoneUuidAsync({ env }));
const enableAccessibilitySettings = Boolean(inputs.enable_accessibility_settings.value);
const { deviceIdentifier: originalDeviceIdentifier, displayName: formattedDevice } =
await bootIosSimulatorAsync({
deviceIdentifier: deviceIdentifierInput,
enableAccessibilitySettings,
if (!originalDeviceIdentifier) {
throw new Error('Could not find an iPhone among available simulator devices.');
}
if (enableAccessibilitySettings) {
await IosSimulatorUtils.enableAccessibilitySettingsAsync({
deviceIdentifier: originalDeviceIdentifier,
env,
logger,
});
}
const udid = await bootWithLocalEgressAsync({
deviceIdentifier: originalDeviceIdentifier,
env,
logger,
});
try {
await IosSimulatorUtils.disableApsdAsync({ udid, env });
} catch (err) {
logger.warn({ err }, 'Failed to disable apsd in the Simulator.');
}
await IosSimulatorUtils.waitForReadyAsync({ udid, env });
logger.info('');
const device = await IosSimulatorUtils.getDeviceAsync({ udid, env });
const formattedDevice = device?.displayName ?? originalDeviceIdentifier;
logger.info(`${formattedDevice} is ready.`);
const count = Number(inputs.count.value ?? 1);
if (count > 1) {
@@ -109,67 +150,6 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
});
}
/**
* Boots one iOS Simulator and waits until it is ready: the requested device, or the
* most generic iPhone when none is given.
*/
export async function bootIosSimulatorAsync({
deviceIdentifier: deviceIdentifierInput,
enableAccessibilitySettings = false,
env,
logger,
}: {
deviceIdentifier?: IosSimulatorUuid | IosSimulatorName;
enableAccessibilitySettings?: boolean;
env: BuildStepEnv;
logger: bunyan;
}): Promise<{
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
udid: IosSimulatorUuid;
displayName: string;
}> {
try {
const availableDevices = await IosSimulatorUtils.getAvailableDevicesAsync({
env,
filter: 'available',
});
logger.info(
`Available Simulator devices:\n- ${availableDevices
.map(device => device.displayName)
.join(`\n- `)}`
);
} catch (error) {
logger.info('Failed to list available Simulator devices.', error);
} finally {
logger.info('');
}
const deviceIdentifier = deviceIdentifierInput ?? (await findMostGenericIphoneUuidAsync({ env }));
if (!deviceIdentifier) {
throw new Error('Could not find an iPhone among available simulator devices.');
}
if (enableAccessibilitySettings) {
await IosSimulatorUtils.enableAccessibilitySettingsAsync({ deviceIdentifier, env });
}
const udid = await bootWithLocalEgressAsync({ deviceIdentifier, env, logger });
try {
await IosSimulatorUtils.disableApsdAsync({ udid, env });
} catch (err) {
logger.warn({ err }, 'Failed to disable apsd in the Simulator.');
}
await IosSimulatorUtils.waitForReadyAsync({ udid, env });
logger.info('');
const device = await IosSimulatorUtils.getDeviceAsync({ udid, env });
const displayName = device?.displayName ?? deviceIdentifier;
logger.info(`${displayName} is ready.`);
return { deviceIdentifier, udid, displayName };
}
/**
* Boot a device with the local egress environment in place before anything
* inside it starts. `simctl boot` returns once the simulator's launchd is up
@@ -4,7 +4,6 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { startAndroidLocalEgressAsync } from '../utils/androidLocalEgress';
import {
CHISEL_VERSION,
LOCAL_EGRESS_HANDOFF_PATH,
@@ -80,9 +79,8 @@ function awaitLocalEgressAcquisitionAsync<T>(
* at boot. Once a simulator boots, `eas/start_ios_simulator` also sets proxy
* environment variables inside it for clients that read them (gRPC, libcurl)
* and installs the local egress guard, which refuses connections that ignore
* both. On Linux it starts the Android emulator relay and fence instead; see
* androidLocalEgress.ts. The shared session cleanup releases the resources
* started here when the session ends, with a job finalizer as a fallback.
* both. The shared session cleanup releases the resources started here when
* the session ends, with a job finalizer as a fallback.
*/
export function createStartLocalEgressBuildFunction(): BuildFunction {
return new BuildFunction({
@@ -90,15 +88,13 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
id: 'start_local_egress',
name: 'Start local egress',
__metricsId: 'eas/start_local_egress',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX],
fn: async ({ logger, global }, { env, signal }) => {
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
fn: async ({ logger }, { env, signal }) => {
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
const isAndroid = global?.runtimePlatform === BuildRuntimePlatform.LINUX;
let workDir: string | undefined;
let server: DetachedProcessHandle | undefined;
let tunnel: NgrokTunnelHandle | undefined;
let android: { stopAsync: () => Promise<void> } | undefined;
let finishSetup!: () => void;
const setupFinished = new Promise<void>(resolve => {
finishSetup = resolve;
@@ -109,7 +105,6 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
await setupFinished;
const results = await Promise.allSettled([
Promise.resolve().then(() => stopLocalEgressGuardRelaysAsync(logger)),
Promise.resolve().then(() => android?.stopAsync()),
Promise.resolve().then(() => tunnel?.stopAsync()),
Promise.resolve().then(() => server?.stopAsync()),
]);
@@ -174,35 +169,6 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
);
startupSignal.throwIfAborted();
if (isAndroid) {
if (server.pid === undefined) {
throw new Error('The reverse tunnel server has no process id.');
}
android = await startAndroidLocalEgressAsync({
env,
logger,
workDir,
chiselPid: server.pid,
proxyPort: LOCAL_EGRESS_PROXY_PORT,
controlPort,
});
startupSignal.throwIfAborted();
await writeLocalEgressHandoffAsync({
url: tunnel.url,
token: credentials.password,
fingerprint: started.fingerprint,
port: LOCAL_EGRESS_PROXY_PORT,
platform: 'android',
});
startupSignal.throwIfAborted();
logger.info(
"Local egress is configured for the Android emulator. Its TCP traffic goes through the emulator's " +
'proxy and fails until the EAS CLI egress client connects, then exits from that machine. ' +
'Traffic that cannot use the tunnel, such as UDP, is refused and reported here.'
);
return;
}
const { service } = await configureSystemProxyAsync({
env,
logger,
@@ -3,7 +3,6 @@ import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@exp
import { graphql } from 'gql.tada';
import { CustomBuildContext } from '../../customBuildContext';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import { withLogPhaseAsync } from '../../utils/logPhase';
import { startSandboxDaemonAsync } from '../utils/sandboxDaemon';
@@ -52,8 +51,6 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
signal,
workingDirectory: stepCtx.workingDirectory,
env: commandEnv,
graphqlClient: ctx.graphqlClient,
sandboxId,
});
try {
await daemon.ready;
@@ -79,7 +76,19 @@ export async function markSandboxReadyAsync(
): Promise<void> {
signal?.throwIfAborted();
const result = await ctx.graphqlClient
.mutation(MARK_SANDBOX_READY_MUTATION, { sandboxId }, graphqlAbortContext(signal))
.mutation(
MARK_SANDBOX_READY_MUTATION,
{ sandboxId },
signal
? {
fetch: (input, init) =>
fetch(input, {
...init,
signal: init?.signal ? AbortSignal.any([signal, init.signal]) : signal,
}),
}
: undefined
)
.toPromise();
signal?.throwIfAborted();
if (result.error) {
@@ -1,26 +0,0 @@
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
import { Sentry } from '../../sentry';
import { ServeSimLogsRecorder } from '../utils/serveSimLogsRecorder';
export function createStartServeSimLogsBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_serve_sim_logs',
name: 'Start serve-sim simulator logs',
__metricsId: 'eas/start_serve_sim_logs',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
fn: async ({ logger }) => {
try {
await ServeSimLogsRecorder.startAsync({ logger });
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not start serve-sim simulator logs', error);
logger.warn(
{ err: error },
'Could not start simulator log collection; the session will continue.'
);
}
},
});
}
@@ -6,11 +6,6 @@ import {
} from '@expo/steps';
import { CustomBuildContext } from '../../customBuildContext';
import { startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
} from '../utils/networkCaptureFields';
import {
uploadRemoteSessionConfigWithLocalEgressAsync,
withLocalEgressSession,
@@ -22,6 +17,7 @@ import {
getNgrokTunnelDomainOrThrow,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
startDeviceWebPreviewWithTunnelAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
@@ -37,7 +33,6 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
__metricsId: 'eas/start_serve_sim_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
...createNetworkCaptureInputProviders(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -63,13 +58,6 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
},
{ runtimePlatform }
);
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
{ runtimePlatform }
);
logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`);
const launchDescription = describeServeSimLaunch(launch);
@@ -81,8 +69,9 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const sessionHost = await startDeviceSessionHostAsync(ctx, {
const webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
runtimePlatform,
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
@@ -90,28 +79,19 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
networkCapture,
networkCaptureFields,
});
logger.info(`Preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`);
try {
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
logger.info(`Preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`);
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
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
? { webPreviewToken: webPreview.previewToken, previewToken: webPreview.previewToken }
: {}),
...(webPreview.previewToken ? { previewToken: webPreview.previewToken } : {}),
},
logger,
});
@@ -124,7 +104,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
signal,
});
} finally {
await sessionHost.finishAsync();
await webPreview.stopAsync();
}
}),
});
@@ -1,340 +0,0 @@
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
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';
export function createUpdateTestFlightMetadataBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'update_testflight_metadata',
name: 'Update TestFlight metadata',
__metricsId: 'eas/update_testflight_metadata',
inputProviders: [
BuildStepInput.createProvider({
id: 'asc_api_key_path',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'build_upload_id',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'changelog',
required: false,
defaultValue: '',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'groups',
required: false,
defaultValue: [],
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
],
fn: async (ctx, { inputs }) => {
const parsedInputs = z
.object({
asc_api_key_path: z.string(),
build_upload_id: z.string(),
changelog: z.string(),
groups: z.array(z.string()),
})
.parse({
asc_api_key_path: inputs.asc_api_key_path.value,
build_upload_id: inputs.build_upload_id.value,
changelog: inputs.changelog.value,
groups: inputs.groups.value,
});
const keyPath = path.resolve(ctx.workingDirectory, parsedInputs.asc_api_key_path);
const key = await AscApiUtils.loadApiKeyAsync({ keyPath });
const client = new AscApiClient({ key, logger: ctx.logger });
await updateTestFlightMetadataAsync({
client,
buildUploadId: parsedInputs.build_upload_id,
changelog: parsedInputs.changelog,
groups: parsedInputs.groups,
logger: ctx.logger,
});
ctx.logger.info('TestFlight metadata updated.');
},
});
}
export async function updateTestFlightMetadataAsync({
client,
buildUploadId,
changelog,
groups,
logger,
}: {
client: AscApiClient;
buildUploadId: string;
changelog: string;
groups: string[];
logger: bunyan;
}): Promise<void> {
const { data: upload } = await client.getAsync(
'/v1/buildUploads/:id',
{
'fields[buildUploads]': ['state', 'build'],
include: ['build'],
},
{ id: buildUploadId }
);
const buildId = upload.relationships?.build?.data?.id;
if (upload.attributes?.state?.state !== 'COMPLETE' || !buildId) {
throw new UserError(
'EAS_TESTFLIGHT_BUILD_NOT_READY',
'The uploaded build is not ready for TestFlight metadata. Run eas/upload_to_asc with wait_for_processing: true first.'
);
}
logger.info(`Updating TestFlight metadata for Apple build ${buildId}...`);
const { data: app } = await client.getAsync('/v1/builds/:id/app', {}, { id: buildId });
const limit = limitFactory<void>(1);
const results = await Promise.allSettled([
limit(async () => {
if (!changelog) {
return;
}
logger.info(`Updating changelog: ${JSON.stringify(changelog)}`);
const primaryLocale = app.attributes?.primaryLocale || 'en-US';
if (!app.attributes?.primaryLocale) {
logger.warn('App Store Connect did not return the primary locale; using "en-US".');
}
logger.info(`Primary locale: ${JSON.stringify(primaryLocale)}.`);
const localizations = await AscApiUtils.getAllBetaBuildLocalizationsAsync({
client,
buildId,
});
const localizationLimit = limitFactory<void>(1);
const updates = localizations.map(localization =>
localizationLimit(async () => {
const label = `${JSON.stringify(localization.attributes?.locale ?? 'unknown')} (${localization.id})`;
try {
await client.patchAsync(
'/v1/betaBuildLocalizations/:id',
{
data: {
type: 'betaBuildLocalizations',
id: localization.id,
attributes: { whatsNew: changelog },
},
},
{ id: localization.id }
);
} catch (error) {
logger.error(`❌ Locale ${label}: changelog update failed. ${String(error)}`);
throw error;
}
logger.info(`✅ Locale ${label}: changelog updated.`);
})
);
if (!localizations.some(localization => localization.attributes?.locale === primaryLocale)) {
updates.push(
localizationLimit(async () => {
try {
await client.postAsync('/v1/betaBuildLocalizations', {
data: {
type: 'betaBuildLocalizations',
attributes: { locale: primaryLocale, whatsNew: changelog },
relationships: { build: { data: { type: 'builds', id: buildId } } },
},
});
} catch (error) {
logger.error(
`❌ Locale ${JSON.stringify(primaryLocale)}: localization creation failed. ${String(error)}`
);
throw error;
}
logger.info(`✅ Locale ${JSON.stringify(primaryLocale)}: localization created.`);
})
);
}
const localizationResults = await Promise.allSettled(updates);
const localizationFailures = localizationResults.filter(
result => result.status === 'rejected'
);
if (localizationFailures.length === 1) {
throw localizationFailures[0].reason;
}
if (localizationFailures.length > 1) {
throw new AggregateError(
localizationFailures.map(failure => failure.reason),
`Failed to update TestFlight localizations: ${localizationFailures.map(failure => String(failure.reason)).join('; ')}`
);
}
}),
limit(async () => {
if (!groups.length) {
return;
}
logger.info(`Apple app: ${app.id}. Requested groups: ${JSON.stringify(groups)}.`);
const requestedNames = new Set(groups);
const allGroups = await AscApiUtils.getAllBetaGroupsAsync({ client, appId: app.id });
const requestedGroups = allGroups.filter(group =>
requestedNames.has(group.attributes?.name ?? '')
);
const foundNames = new Set(requestedGroups.map(group => group.attributes?.name));
const missingNames = [...requestedNames].filter(name => !foundNames.has(name));
logger.info(`Found ${requestedGroups.length} TestFlight group(s).`);
const assignedGroups = requestedGroups.length
? await AscApiUtils.getAllBetaGroupsAsync({
client,
buildId,
})
: [];
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 =>
groupLimit(async () => {
const label = `${JSON.stringify(group.attributes?.name)} (${group.id})`;
if (assignedIds.has(group.id)) {
logger.info(`✅ Group ${label}: build already assigned; no assignment needed.`);
return;
}
if (
group.attributes?.isInternalGroup === true &&
group.attributes.hasAccessToAllBuilds === true
) {
logger.info(
`✅ Group ${label}: automatic access to all builds; no assignment needed.`
);
return;
}
try {
await client.postAsync(
'/v1/builds/:id/relationships/betaGroups',
{
data: [{ type: 'betaGroups', id: group.id }],
},
{ id: buildId }
);
} 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',
"App Store Connect can't add this build to a requested internal TestFlight group. " +
"Internal groups that automatically receive new builds can't be assigned to manually. " +
'Remove the group from the list, or turn off automatic distribution in App Store Connect. ' +
`Manage groups at https://appstoreconnect.apple.com/apps/${app.id}/testflight`,
{ cause: error }
);
}
throw error;
}
logger.info(`✅ Group ${label}: assignment completed.`);
})
)
);
const groupFailures = groupResults.filter(result => result.status === 'rejected');
if (missingNames.length) {
const error = new UserError(
'EAS_TESTFLIGHT_GROUPS_NOT_FOUND',
`The following TestFlight group${missingNames.length > 1 ? 's were' : ' was'} not found in App Store Connect: ${missingNames.map(name => `"${name}"`).join(', ')}. Check the group names and try again.`
);
logger.error(`❌ ${error.message}`);
groupFailures.push({ status: 'rejected', reason: error });
}
if (groupFailures.length === 1) {
throw groupFailures[0].reason;
}
if (groupFailures.length > 1) {
throw new AggregateError(
groupFailures.map(failure => failure.reason),
`Failed to assign TestFlight groups: ${groupFailures.map(failure => String(failure.reason)).join('; ')}`
);
}
}),
]);
const failures = results.filter(result => result.status === 'rejected');
if (failures.length === 1) {
throw failures[0].reason;
}
if (failures.length > 1) {
throw new AggregateError(
failures.map(failure => failure.reason),
`Failed to update the TestFlight changelog and groups: ${failures
.map(failure => String(failure.reason))
.join('; ')}`
);
}
}
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) {
return error.errors.some(isInternalGroupAssignmentError);
}
return (
error instanceof AscApiRequestError &&
error.status === 422 &&
error.code === 'ENTITY_UNPROCESSABLE' &&
(error.responseJson.title === 'Builds cannot be assigned to this internal group.' ||
error.responseJson.detail === 'Cannot add internal group to a build.')
);
}
@@ -1,17 +1,53 @@
import { SystemError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import { createReadStream } from 'node:fs';
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import limitFactory from 'promise-limit';
import { z } from 'zod';
import { type CustomBuildContext } from '../../customBuildContext';
import {
parseDeviceScreenRecordings,
uploadDeviceRunSessionScreenRecordingsAsync,
} from '../utils/deviceRunSessionScreenRecordings';
import { Sentry } from '../../sentry';
import { formatBytes } from '../../utils/artifacts';
import { uploadDeviceRunSessionArtifactAsync } from '../utils/deviceRunSessionArtifacts';
import { getDeviceRunSessionIdOrThrow } from '../utils/remoteDeviceRunSession';
const RecordingsSchema = z.array(
z.object({
udid: z.string(),
deviceName: z.string(),
runtimeDisplayName: z.string(),
directory: z.string(),
})
);
const RecordingManifestSchema = z.object({
firstFrameWallClock: z.object({
iso8601: z.string(),
}),
width: z.number().int().positive(),
height: z.number().int().positive(),
recording: z.string(),
});
const recordingStartTimeFormatter = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3,
hourCycle: 'h23',
timeZone: 'UTC',
timeZoneName: 'short',
});
export function createUploadDeviceRunSessionScreenRecordingsBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
@@ -29,16 +65,69 @@ export function createUploadDeviceRunSessionScreenRecordingsBuildFunction(
}),
],
fn: async ({ logger }, { env, inputs }) => {
const recordings = parseDeviceScreenRecordings(inputs.recordings_json.value ?? []);
const result = RecordingsSchema.safeParse(inputs.recordings_json.value ?? []);
if (!result.success) {
throw new SystemError('Invalid iOS Simulator recordings input.', {
cause: result.error,
});
}
const recordings = result.data;
if (recordings.length === 0) {
logger.info('No device screen recordings found; skipping uploads.');
logger.info('No iOS Simulator recordings found; skipping uploads.');
return;
}
await uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
logger,
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
recordings,
});
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const limit = limitFactory(5);
await Promise.all(
recordings.map(recording =>
limit(async () => {
try {
const metadata = RecordingManifestSchema.parse(
JSON.parse(await readFile(path.join(recording.directory, 'session.json'), 'utf-8'))
);
const startedAt = recordingStartTimeFormatter.format(
new Date(metadata.firstFrameWallClock.iso8601)
);
const shortUdid = `${recording.udid.slice(0, 8)}-…`;
const displayName = `${recording.deviceName} screen recording (${shortUdid}, started at ${startedAt})`;
const recordingPath = path.join(recording.directory, metadata.recording);
const { size } = await stat(recordingPath);
const recordingId = path.basename(recording.directory);
logger.info(
`Uploading screen recording for ${recording.deviceName} (${formatBytes(size)}).`
);
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId,
artifactId: recordingId,
name: displayName,
filename: `${recordingId}.mp4`,
kind: 'screen-recording',
metadata: {
__eas_type: 'screen-recording',
__eas_screen_recording: '1',
udid: recording.udid,
deviceName: recording.deviceName,
runtimeDisplayName: recording.runtimeDisplayName,
firstFrameAt: metadata.firstFrameWallClock.iso8601,
width: metadata.width,
height: metadata.height,
},
size,
stream: createReadStream(recordingPath),
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not upload iOS Simulator screen recording', error);
logger.warn(
{ err: error },
`Could not upload screen recording for ${recording.deviceName}.`
);
}
})
)
);
},
});
}
@@ -1,57 +0,0 @@
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;
}
},
});
}
@@ -7,6 +7,7 @@ import {
BuildStepOutput,
} from '@expo/steps';
import fs from 'fs-extra';
import * as jose from 'jose';
import fetch from 'node-fetch';
import path from 'node:path';
import { setTimeout } from 'node:timers/promises';
@@ -86,8 +87,29 @@ export function createUploadToAscBuildFunction(): BuildFunction {
throw new Error(`ASC API Key file not found: ${ascApiKeyPath}`);
}
const key = await AscApiUtils.loadApiKeyAsync({ keyPath: ascApiKeyPath });
const client = new AscApiClient({ key, logger: stepsCtx.logger });
const ascApiKeyJson = await fs.readJson(ascApiKeyPath);
const ascApiKey = z
.object({
issuer_id: z.string().nullish(),
key_id: z.string(),
key: z.string(),
})
.parse(ascApiKeyJson);
const privateKey = await jose.importPKCS8(ascApiKey.key, 'ES256');
const jwt = new jose.SignJWT({})
.setProtectedHeader({ alg: 'ES256', kid: ascApiKey.key_id })
.setAudience('appstoreconnect-v1')
.setExpirationTime('20m');
if (ascApiKey.issuer_id) {
jwt.setIssuer(ascApiKey.issuer_id);
} else {
// Nullish issuer_id means an individual API key
jwt.setSubject('user');
}
const token = await jwt.sign(privateKey);
const client = new AscApiClient({ token, logger: stepsCtx.logger });
stepsCtx.logger.info(
`Reading App information for Apple app identifier: ${appleAppIdentifier}...`
@@ -155,11 +177,21 @@ export function createUploadToAscBuildFunction(): BuildFunction {
});
stepsCtx.logger.info('Committing upload...');
await AscApiUtils.commitBuildUploadFileAsync({
client,
fileId: buildFileResponse.data.id,
logger: stepsCtx.logger,
});
await client.patchAsync(
`/v1/buildUploadFiles/:id`,
{
data: {
type: 'buildUploadFiles',
id: buildFileResponse.data.id,
attributes: {
uploaded: true,
},
},
},
{
id: buildFileResponse.data.id,
}
);
stepsCtx.logger.info('Checking upload file status...');
const waitingForFileStartedAt = Date.now();
@@ -10,27 +10,12 @@ 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;
// 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;
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';
type IosSimulatorRecording = {
id: string;
@@ -40,7 +25,6 @@ type IosSimulatorRecording = {
outputDirectory: string;
startedAt: Date;
getOutput: () => string;
hasStarted: () => boolean;
};
type ActiveIosSimulatorRecording = IosSimulatorRecording & {
@@ -51,15 +35,11 @@ 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;
};
@@ -67,31 +47,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(),
};
@@ -114,7 +94,6 @@ export namespace IosSimulatorRecordingUtils {
}[]
> {
const session = activeIosSimulatorRecordingSession;
serveSimPackageSpec = null;
if (!session) {
logger.info('No iOS Simulator screen recordings are running.');
return [];
@@ -126,55 +105,41 @@ export namespace IosSimulatorRecordingUtils {
await Promise.all(
[...session.activeRecordings.values()].map(async recording => {
logger.info(`Stopping screen recording for ${recording.deviceName}.`);
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');
recording.recordingProcess.kill('SIGINT');
const finished = await Promise.race([
recording.completionPromise.then(() => true),
setTimeout(finishTimeoutMs, false, { ref: false }),
setTimeout(RECORD_SIM_FINISH_TIMEOUT_MS, false, { ref: false }),
]);
if (finished) {
return;
}
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`;
const recordSimOutput = recording.getOutput().trim();
const finishTimeoutSeconds = Math.round(RECORD_SIM_FINISH_TIMEOUT_MS / 1_000);
logger.warn(
{ recorderOutput },
`Screen recording for ${recording.deviceName} ${stopFailure} and will be killed.${
recorderOutput
? `\nRecent recorder messages:\n${recorderOutput}`
{ recordSimOutput },
`Screen recording for ${recording.deviceName} did not finish within ${finishTimeoutSeconds} seconds and will be stopped.${
recordSimOutput
? `\nRecent recorder messages:\n${recordSimOutput}`
: '\nNo recorder messages were captured.'
}`
);
signalRecorder(recording.recordingProcess, 'SIGKILL');
recording.recordingProcess.kill('SIGKILL');
const killed = await Promise.race([
recording.completionPromise.then(() => true),
setTimeout(SERVE_SIM_FORCE_STOP_TIMEOUT_MS, false, { ref: false }),
setTimeout(RECORD_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.`
`iOS Simulator recording process for ${recording.deviceName} did not exit after SIGKILL.`,
{
extras: {
output: recording.getOutput(),
},
}
);
}
})
@@ -183,31 +148,7 @@ export namespace IosSimulatorRecordingUtils {
const completedRecordings = [...session.completedRecordings].sort(
(a, b) => a.startedAt.getTime() - b.startedAt.getTime()
);
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 => ({
return completedRecordings.map(recording => ({
udid: recording.udid,
deviceName: recording.deviceName,
runtimeDisplayName: recording.runtimeDisplayName,
@@ -233,38 +174,18 @@ 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.serveBearerTokens.keys()) {
for (const udid of session.recordingFailureCounts.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) ||
Date.now() < (session.recordingRetryAt.get(device.udid) ?? 0)
(session.recordingFailureCounts.get(device.udid) ?? 0) >= RECORD_SIM_MAX_ATTEMPTS_PER_BOOT
) {
continue;
}
@@ -272,7 +193,6 @@ async function pollIosSimulatorRecordingsAsync(
udid: device.udid,
deviceName: device.name,
runtimeDisplayName: device.runtimeDisplayName,
packageSpec,
});
}
} catch (err) {
@@ -307,90 +227,50 @@ 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(
recorderExec.command,
[...recorderExec.args, '--udid', udid, '--output', outputDirectory],
session.recordSimCommand,
['--udid', udid, '--output', outputDirectory, '--segment-duration', '0'],
{
env: session.env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: true,
}
);
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 getOutput = captureProcessOutput(recordingSpawn.child);
const completionPromise = recordingSpawn
.then(() => undefined)
.catch((err: unknown) => {
session.recordingFailureCounts.set(udid, (session.recordingFailureCounts.get(udid) ?? 0) + 1);
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('iOS Simulator screen recording process failed', error);
session.logger.warn(
{ err: error, recorderOutput: getOutput() },
{ err: error, recordSimOutput: getOutput() },
`Screen recording process failed for ${deviceName}.`
);
}
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);
});
})
.finally(() => {
session.activeRecordings.delete(udid);
session.completedRecordings.push({
id: recordingId,
udid,
deviceName,
runtimeDisplayName,
outputDirectory,
startedAt,
getOutput,
});
});
session.activeRecordings.set(udid, {
id: recordingId,
@@ -402,103 +282,31 @@ async function startIosSimulatorRecordingAsync(
completionPromise,
startedAt,
getOutput,
hasStarted,
});
}
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;
}
async function resolveRecordSimCommandAsync({ env }: { env: Env }): Promise<string | null> {
try {
await spawn('which', [RECORD_SIM_COMMAND], { env });
return RECORD_SIM_COMMAND;
} catch {}
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 });
const packagedRecordSimPath = path.join(__dirname, '..', '..', '..', 'bin', RECORD_SIM_COMMAND);
try {
await access(packagedRecordSimPath);
return packagedRecordSimPath;
} catch {
return null;
}
}
function captureProcessOutput(recordingProcess: ChildProcess): {
getOutput: () => string;
hasStarted: () => boolean;
} {
function captureProcessOutput(recordingProcess: ChildProcess): () => string {
let output = '';
let started = false;
const appendChunk = (chunk: Buffer | string): void => {
output = `${output}${chunk.toString()}`;
if (output.includes(SERVE_SIM_RECORDING_STARTED)) {
started = true;
}
output = output.slice(-16_384);
// Keep enough recorder stderr/stdout for diagnostics without retaining unbounded output.
output = `${output}${chunk.toString()}`.slice(-16_384);
};
recordingProcess.stdout?.on('data', appendChunk);
recordingProcess.stderr?.on('data', appendChunk);
return { getOutput: () => output, hasStarted: () => started };
return () => output;
}
@@ -1,539 +0,0 @@
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,39 +104,6 @@ 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, () => {
@@ -153,8 +120,8 @@ describe(uploadAgentDeviceArtifactAsync, () => {
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { openStream }) => {
await readStreamAsync(openStream());
.mockImplementationOnce(async (_ctx, { stream }) => {
await readStreamAsync(stream);
});
await uploadAgentDeviceArtifactAsync(ctx, {
@@ -179,7 +146,7 @@ describe(uploadAgentDeviceArtifactAsync, () => {
filename: 'report.json',
kind: 'agent-device-test-report',
size: data.length,
openStream: expect.any(Function),
stream: expect.anything(),
});
});
@@ -191,8 +158,8 @@ describe(uploadAgentDeviceArtifactAsync, () => {
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { openStream }) => {
await readStreamAsync(openStream());
.mockImplementationOnce(async (_ctx, { stream }) => {
await readStreamAsync(stream);
});
await uploadAgentDeviceArtifactAsync(ctx, {
@@ -214,63 +181,6 @@ 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, () => {
@@ -345,8 +255,8 @@ describe(pollAgentDeviceArtifactsForUploadAsync, () => {
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockRejectedValueOnce(new Error('upload failed'))
.mockImplementationOnce(async (_ctx, { openStream }) => {
await readStreamAsync(openStream());
.mockImplementationOnce(async (_ctx, { stream }) => {
await readStreamAsync(stream);
});
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
@@ -1,592 +0,0 @@
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);
});
});
@@ -1,148 +0,0 @@
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);
});
});
@@ -1,319 +0,0 @@
import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { createMockLogger } from '../../../__tests__/utils/logger';
import {
buildEgressFenceRuleset,
findProcessesOutsideScope,
getCgroupLevel,
listEmulatorProcessesAsync,
parseAdbReversePorts,
parseConnectAuthority,
parseFenceCounters,
parseFenceLogLine,
parseLoopbackListenerPorts,
resolveAndroidEmulatorLocalEgressAsync,
startLocalEgressRelayAsync,
} from '../androidLocalEgress';
import { writeLocalEgressHandoffAsync } from '../localEgress';
describe(parseConnectAuthority, () => {
it('parses IPv4, hostname and bracketed IPv6 authorities', () => {
expect(parseConnectAuthority('CONNECT 192.0.2.1:443 HTTP/1.1\r\nHost: x\r\n')).toEqual({
host: '192.0.2.1',
port: 443,
});
expect(parseConnectAuthority('CONNECT Example.com:8443 HTTP/1.0\r\n')).toEqual({
host: 'example.com',
port: 8443,
});
expect(parseConnectAuthority('CONNECT [2001:db8::1]:443 HTTP/1.1\r\n')).toEqual({
host: '2001:db8::1',
port: 443,
});
});
it('ignores requests that are not CONNECT', () => {
expect(parseConnectAuthority('GET http://example.com/ HTTP/1.1\r\n')).toBeNull();
});
});
describe(buildEgressFenceRuleset, () => {
it('refuses traffic from the session scope that cannot use the tunnel', () => {
const ruleset = buildEgressFenceRuleset({ cgroupLevel: 2 });
expect(ruleset).toContain('socket cgroupv2 level 2 @fenced jump fence');
expect(ruleset).toContain('oif "lo" meta l4proto tcp accept');
expect(ruleset).toContain('oif "lo" ip daddr 127.0.0.53 udp dport 53 accept');
expect(ruleset).toContain('log prefix "eas-egress " level info');
expect(ruleset).toContain('meta l4proto tcp counter name fence_tcp reject with tcp reset');
expect(ruleset).toContain(
'meta l4proto udp counter name fence_udp reject with icmpx admin-prohibited'
);
expect(ruleset).not.toMatch(/fence_\w+ accept/);
});
});
describe(parseFenceLogLine, () => {
it('parses a refused IPv4 TCP connection from the emulator', () => {
expect(
parseFenceLogLine(
'eas-egress IN= OUT=ens3 SRC=10.128.0.5 DST=172.253.155.190 LEN=60 TOS=0x00 PREC=0x00 TTL=64 ID=1 DF PROTO=TCP SPT=42408 DPT=443 WINDOW=64240 RES=0x00 SYN URGP=0'
)
).toEqual({
process: 'Android emulator',
pid: null,
function: 'tcp',
action: 'blocked',
peer: '172.253.155.190:443',
callers: [],
note: expect.stringContaining('the emulator itself opened'),
});
});
it('labels QUIC and compresses IPv6 destinations', () => {
expect(
parseFenceLogLine(
'eas-egress IN= OUT=ens3 SRC=10.128.0.5 DST=142.250.152.94 LEN=1278 PROTO=UDP SPT=51000 DPT=443 LEN=1258'
)
).toMatchObject({
function: 'udp',
peer: '142.250.152.94:443',
note: expect.stringContaining('QUIC'),
});
expect(
parseFenceLogLine(
'eas-egress IN= OUT=ens3 SRC=fe80:0000:0000:0000:0000:0000:0000:0001 DST=ff02:0000:0000:0000:0000:0000:0000:00fb LEN=100 TC=0 HOPLIMIT=255 FLOWLBL=0 PROTO=UDP SPT=5353 DPT=5353 LEN=60'
)
).toMatchObject({ function: 'udp', peer: '[ff02::fb]:5353', note: undefined });
});
it('reports ICMP without a port', () => {
expect(
parseFenceLogLine(
'eas-egress IN= OUT=ens3 SRC=10.128.0.5 DST=8.8.8.8 LEN=84 PROTO=ICMP TYPE=8 CODE=0 ID=7 SEQ=1'
)
).toMatchObject({ function: 'icmp', action: 'blocked', peer: '8.8.8.8' });
});
it('ignores unrelated kernel log lines', () => {
expect(parseFenceLogLine('kvm: some unrelated message')).toBeNull();
expect(parseFenceLogLine('eas-egress IN= OUT=ens3')).toBeNull();
});
});
describe(parseFenceCounters, () => {
it('reads packet counts from nft JSON output', () => {
expect(
parseFenceCounters(
JSON.stringify({
nftables: [
{ metainfo: { version: '1.1.6' } },
{ counter: { family: 'inet', name: 'fence_tcp', table: 'eas_fence', packets: 4 } },
{ counter: { family: 'inet', name: 'fence_udp', table: 'eas_fence', packets: 57 } },
{ counter: { family: 'inet', name: 'fence_other', table: 'eas_fence', packets: 2 } },
],
})
)
).toEqual({ tcp: 4, udp: 57, other: 2 });
});
});
describe('process coverage', () => {
let procRoot: string;
beforeEach(async () => {
procRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'fake-proc-'));
});
afterEach(async () => {
await fs.promises.rm(procRoot, { recursive: true, force: true });
});
async function addProcessAsync(pid: number, comm: string, cgroup: string): Promise<void> {
await fs.promises.mkdir(path.join(procRoot, String(pid)));
await fs.promises.writeFile(path.join(procRoot, String(pid), 'comm'), `${comm}\n`);
await fs.promises.writeFile(path.join(procRoot, String(pid), 'cgroup'), `0::${cgroup}\n`);
}
it('lists emulator processes and finds those outside the session scope', async () => {
const scope = 'system.slice/eas-egress-session.scope';
await addProcessAsync(10, 'qemu-system-x86', `/${scope}`);
await addProcessAsync(11, 'netsimd', `/${scope}`);
await addProcessAsync(12, 'netsimd', '/system.slice/eas-build-worker.service');
await addProcessAsync(13, 'node', '/system.slice/eas-build-worker.service');
await fs.promises.mkdir(path.join(procRoot, 'self'));
const processes = await listEmulatorProcessesAsync(procRoot);
expect(processes.map(({ pid }) => pid).sort()).toEqual([10, 11, 12]);
expect(findProcessesOutsideScope(processes, scope)).toEqual([
{ pid: 12, comm: 'netsimd', cgroup: '/system.slice/eas-build-worker.service' },
]);
expect(getCgroupLevel(scope)).toBe(2);
});
});
describe('adb reverse parsing', () => {
it('finds loopback ports a process listens on', () => {
const ss = [
'LISTEN 0 4096 127.0.0.1:8899 0.0.0.0:* users:(("chisel",pid=4321,fd=9))',
'LISTEN 0 4096 127.0.0.1:8082 0.0.0.0:* users:(("chisel",pid=4321,fd=10))',
'LISTEN 0 4096 127.0.0.1:5037 0.0.0.0:* users:(("adb",pid=999,fd=3))',
'LISTEN 0 4096 127.0.0.1:52001 0.0.0.0:* users:(("chisel",pid=43210,fd=3))',
].join('\n');
expect(parseLoopbackListenerPorts(ss, 4321)).toEqual([8082, 8899]);
});
it('parses adb reverse --list output', () => {
expect(
parseAdbReversePorts('emulator-5554 tcp:8082 tcp:8082\nemulator-5554 tcp:8083 tcp:8083\n')
).toEqual(new Set([8082, 8083]));
});
});
describe(startLocalEgressRelayAsync, () => {
async function getFreePortAsync(): Promise<number> {
const server = net.createServer();
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as net.AddressInfo;
await new Promise<void>(resolve => server.close(() => resolve()));
return port;
}
async function exchangeAsync(port: number, request: string): Promise<string> {
return await new Promise((resolve, reject) => {
const socket = net.connect({ host: '127.0.0.1', port });
let received = '';
socket.on('data', chunk => {
received += chunk.toString();
});
socket.on('error', reject);
socket.on('close', () => resolve(received));
socket.write(request);
});
}
async function startUpstreamAsync(
port: number
): Promise<{ requests: string[]; close: () => Promise<void> }> {
const requests: string[] = [];
const server = net.createServer(socket => {
socket.once('data', chunk => {
requests.push(chunk.toString());
socket.end('upstream reply');
});
});
await new Promise<void>(resolve => server.listen(port, '127.0.0.1', resolve));
return {
requests,
close: async () => await new Promise<void>(resolve => server.close(() => resolve())),
};
}
it('answers the readiness CONNECT itself, even without an egress client', async () => {
const logger = createMockLogger();
const relay = await startLocalEgressRelayAsync({
port: await getFreePortAsync(),
upstreamPort: await getFreePortAsync(),
logger,
});
try {
await expect(
exchangeAsync(relay.port, 'CONNECT 192.0.2.1:443 HTTP/1.1\r\nHost: 192.0.2.1:443\r\n\r\n')
).resolves.toBe('HTTP/1.1 200 Connection Established\r\n\r\n');
expect(logger.warn).not.toHaveBeenCalled();
} finally {
await relay.stopAsync();
}
});
it('refuses while the client is detached and forwards once it attaches', async () => {
const logger = createMockLogger();
const upstreamPort = await getFreePortAsync();
const relay = await startLocalEgressRelayAsync({
port: await getFreePortAsync(),
upstreamPort,
logger,
});
const request = 'CONNECT 93.184.216.34:443 HTTP/1.1\r\nHost: 93.184.216.34:443\r\n\r\n';
try {
await expect(exchangeAsync(relay.port, request)).resolves.toMatch(
/^HTTP\/1\.1 503 Service Unavailable\r\n[\s\S]*not connected/
);
await exchangeAsync(relay.port, request);
expect(logger.warn).toHaveBeenCalledTimes(1);
const upstream = await startUpstreamAsync(upstreamPort);
try {
await expect(exchangeAsync(relay.port, request)).resolves.toBe('upstream reply');
expect(upstream.requests).toEqual([request]);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('2 request(s) from the Android emulator were refused')
);
} finally {
await upstream.close();
}
} finally {
await relay.stopAsync();
}
});
it('unwraps a CONNECT to itself and forwards the inner request', async () => {
const upstreamPort = await getFreePortAsync();
const relay = await startLocalEgressRelayAsync({
port: await getFreePortAsync(),
upstreamPort,
logger: createMockLogger(),
});
const upstream = await startUpstreamAsync(upstreamPort);
const inner = 'CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n';
try {
await expect(
exchangeAsync(
relay.port,
`CONNECT 10.0.2.2:${relay.port} HTTP/1.1\r\nHost: 10.0.2.2:${relay.port}\r\n\r\n${inner}`
)
).resolves.toBe('HTTP/1.1 200 Connection Established\r\n\r\nupstream reply');
expect(upstream.requests).toEqual([inner]);
} finally {
await upstream.close();
await relay.stopAsync();
}
});
});
describe(resolveAndroidEmulatorLocalEgressAsync, () => {
let handoffPath: string;
beforeEach(async () => {
handoffPath = path.join(
await fs.promises.mkdtemp(path.join(os.tmpdir(), 'android-egress-handoff-')),
'handoff.json'
);
});
afterEach(async () => {
await fs.promises.rm(path.dirname(handoffPath), { recursive: true, force: true });
});
it('returns null without a local egress session', async () => {
await expect(resolveAndroidEmulatorLocalEgressAsync({ handoffPath })).resolves.toBeNull();
});
it('fails when a session exists but the fence was not started', async () => {
await writeLocalEgressHandoffAsync(
{
url: 'https://egress.example.com',
token: 'token',
fingerprint: 'key=',
port: 8899,
platform: 'android',
},
handoffPath
);
await expect(resolveAndroidEmulatorLocalEgressAsync({ handoffPath })).rejects.toThrow(
'the Android emulator fence was not started'
);
});
});
@@ -124,8 +124,8 @@ describe(uploadArgentArtifactAsync, () => {
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { openStream }) => {
await readStreamAsync(openStream());
.mockImplementationOnce(async (_ctx, { stream }) => {
await readStreamAsync(stream);
});
await uploadArgentArtifactAsync(ctx, {
@@ -151,7 +151,7 @@ describe(uploadArgentArtifactAsync, () => {
filename: 'report.json',
kind: undefined,
size: data.length,
openStream: expect.any(Function),
stream: expect.anything(),
});
});
@@ -168,8 +168,8 @@ describe(uploadArgentArtifactAsync, () => {
.mockResolvedValueOnce(new Response(Readable.from([Buffer.from('artifact-data')])));
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { openStream }) => {
await readStreamAsync(openStream());
.mockImplementationOnce(async (_ctx, { stream }) => {
await readStreamAsync(stream);
});
await uploadArgentArtifactAsync(ctx, {
@@ -345,8 +345,8 @@ describe(pollArgentArtifactsForUploadAsync, () => {
});
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementation(async (_ctx, { openStream }) => {
await readStreamAsync(openStream());
.mockImplementation(async (_ctx, { stream }) => {
await readStreamAsync(stream);
});
const pollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
@@ -371,7 +371,7 @@ describe(pollArgentArtifactsForUploadAsync, () => {
name: 'b.json (artifact-b)',
filename: 'b.json',
size: 'artifact-b-data'.length,
openStream: expect.any(Function),
stream: expect.anything(),
});
});
@@ -49,7 +49,7 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
kind: 'agent-device-test-report',
metadata: { firstFrameRecordAt: 'test-time' },
size: reportedSize,
openStream: () => stream,
stream,
});
expect(mutation).toHaveBeenCalledWith(
@@ -63,8 +63,7 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
metadata: { firstFrameRecordAt: 'test-time' },
size: reportedSize,
},
}),
{ fetch: expect.any(Function) }
})
);
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
'https://uploads.expo.test/artifact',
@@ -74,52 +73,4 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
})
);
});
it('forwards cancellation through upload allocation and skips PUT when allocation aborts', async () => {
const controller = new AbortController();
const mutation = jest.fn().mockReturnValue({
toPromise: async () => {
controller.abort();
return {
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: {
url: 'https://uploads.expo.test/artifact',
headers: {},
},
},
},
},
};
},
});
const ctx = { graphqlClient: { mutation } } as unknown as CustomBuildContext;
await expect(
uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId: 'session',
artifactId: 'id',
name: 'Logs',
filename: 'logs.ndjson',
kind: 'simulator-log',
size: 0,
openStream: () => Readable.from([]),
signal: controller.signal,
})
).rejects.toThrow();
expect(fetch).not.toHaveBeenCalled();
const allocationFetch = mutation.mock.calls[0][2].fetch;
const mockFetch = jest.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('aborted'));
try {
await expect(allocationFetch('https://api.expo.test/graphql', {})).rejects.toThrow('aborted');
expect(mockFetch).toHaveBeenCalledWith(
'https://api.expo.test/graphql',
expect.objectContaining({
signal: expect.objectContaining({ aborted: true }),
})
);
} finally {
mockFetch.mockRestore();
}
});
});
@@ -1,400 +0,0 @@
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'
);
});
});
@@ -1,200 +0,0 @@
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.',
});
});
@@ -1,47 +0,0 @@
import { screenshotArtifactDetails } from '../deviceRunSessionScreenshotNames';
const timestamp = '2026-09-24T08-45-59-123Z';
const name = 'Screenshot 2026-09-24 08:45:59 UTC';
const gitCommitHash = 'a1b2c3d' + '0'.repeat(33);
const session = {
platform: 'IOS' as const,
app: { slug: 'workflow-project' },
build: { id: 'installed-build', gitCommitHash, app: { slug: 'my-app' } },
};
it('omits the commit for archive and empty sessions', () => {
expect(screenshotArtifactDetails(timestamp, { ...session, build: null })).toEqual({
name,
filename: `workflow-project-ios-${timestamp}.png`,
metadata: { __eas_type: 'screenshot', appSlug: 'workflow-project', platform: 'ios' },
});
});
it.each([null, 'not-a-commit'])('omits a missing or invalid build commit: %s', gitCommitHash => {
const result = screenshotArtifactDetails(timestamp, {
...session,
build: { ...session.build, gitCommitHash },
});
expect(result.filename).toBe(`my-app-ios-${timestamp}.png`);
expect(result.metadata.gitCommitHash).toBeUndefined();
});
it('sanitizes and bounds the app slug for filesystem-safe names', () => {
const result = screenshotArtifactDetails(timestamp, {
...session,
build: { ...session.build, app: { slug: '../../My App/' + 'x'.repeat(100) } },
});
expect(result.filename).toMatch(/^My-App-x+-ios-a1b2c3d-/);
expect(result.filename).not.toMatch(/[/\\]/);
expect(result.filename.length).toBeLessThan(150);
});
it('accepts a full SHA-256 commit hash', () => {
const gitCommitHash = 'b'.repeat(64);
const result = screenshotArtifactDetails(timestamp, {
...session,
build: { ...session.build, gitCommitHash },
});
expect(result.filename).toBe(`my-app-ios-bbbbbbb-${timestamp}.png`);
expect(result.metadata.gitCommitHash).toBe(gitCommitHash);
});
@@ -1,397 +0,0 @@
import type { bunyan } from '@expo/logger';
import fs from 'node:fs';
import { randomBytes } from 'node:crypto';
import { lstat, mkdtemp, readdir, rename, rm, symlink, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import {
startDeviceRunSessionScreenshotsAsync,
uploadDeviceRunSessionScreenshotsAsync,
} from '../deviceRunSessionScreenshots';
jest.mock('../deviceRunSessionArtifacts');
jest.mock('../../../sentry');
const ctx = {} as CustomBuildContext;
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const options = { deviceRunSessionId: 'session-id', logger };
let directory: string;
let uploaded: Buffer[];
let failedUploads: Map<string, { attempts: number; lastError: Error }>;
beforeEach(async () => {
jest.clearAllMocks();
directory = await mkdtemp(path.join(os.tmpdir(), 'screenshots-test-'));
uploaded = [];
failedUploads = new Map();
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 () => {
await rm(directory, { recursive: true, force: true });
});
const flush = () =>
uploadDeviceRunSessionScreenshotsAsync(ctx, {
...options,
directory,
failedUploads,
signal: new AbortController().signal,
});
it('uploads completed captures with their bytes and stable IDs, ignoring partial files and symlinks', async () => {
const filename = 'screenshot-2026-09-24T08-45-59-123Z-ffffffffffff.png';
// Sorts before the real file, so following the link would upload it before the target is gone.
const link = 'screenshot-2026-09-24T08-45-59-123Z-000000000000.png';
await writeFile(path.join(directory, filename), 'png-content');
await writeFile(path.join(directory, `${filename}.tmp`), 'partial');
await symlink(path.join(directory, filename), path.join(directory, link));
await flush();
await flush();
expect(uploaded).toEqual([Buffer.from('png-content')]);
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledTimes(1);
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
deviceRunSessionId: 'session-id',
artifactId: filename.slice(0, -4),
name: 'Screenshot 2026-09-24 08:45:59 UTC',
filename: 'screenshot-2026-09-24T08-45-59-123Z.png',
kind: 'screenshot',
size: 11,
metadata: { __eas_type: 'screenshot' },
})
);
expect(await readdir(directory)).not.toContain(filename);
expect((await lstat(path.join(directory, link))).isSymbolicLink()).toBe(true);
});
it('uploads captures that never failed first, oldest first, before retrying failed ones', async () => {
const failed = 'screenshot-2026-09-24T08-45-00-000Z-aaaaaaaaaaaa.png';
const newer = 'screenshot-2026-09-24T08-45-59-123Z-bbbbbbbbbbbb.png';
const older = 'screenshot-2026-09-24T08-45-30-000Z-cccccccccccc.png';
for (const name of [newer, failed, older]) {
await writeFile(path.join(directory, name), name);
}
failedUploads.set(failed, { attempts: 1, lastError: new Error('stalled') });
await flush();
expect(
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mock.calls.map(([, { artifactId }]) => artifactId)
).toEqual([older, newer, failed].map(name => name.slice(0, -4)));
});
it('keeps failed uploads and retries the same artifact ID, logging the failed attempt count', async () => {
const filename = `screenshot-2026-09-24T08-45-59-123Z-${randomBytes(6).toString('hex')}.png`;
await writeFile(path.join(directory, filename), 'png');
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementationOnce(async () => {
throw new Error('offline');
});
await flush();
expect(await readdir(directory)).toContain(filename);
await flush();
expect(uploaded).toEqual([Buffer.from('png')]);
expect(await readdir(directory)).toEqual([]);
expect(
jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls.map(([, args]) => args.artifactId)
).toEqual([filename.slice(0, -4), filename.slice(0, -4)]);
expect(logger.info).toHaveBeenCalledWith(
expect.anything(),
expect.stringMatching(
/^Uploaded preview screenshot screenshot-2026-09-24T08-45-59-123Z\.png \(3 B\) in \d+ ms after 1 failed attempt\.$/
)
);
expect(failedUploads.size).toBe(0);
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('uploads atomic captures immediately, including a capture arriving during another upload', async () => {
jest.useFakeTimers();
const collector = await startDeviceRunSessionScreenshotsAsync(ctx, options);
let releaseFirstUpload!: () => void;
const firstUploadGate = new Promise<void>(resolve => {
releaseFirstUpload = resolve;
});
let signalFirstUpload!: () => void;
const firstUploadStarted = new Promise<void>(resolve => {
signalFirstUpload = resolve;
});
let signalSecondUpload!: () => void;
const secondUploadStarted = new Promise<void>(resolve => {
signalSecondUpload = resolve;
});
const uploadNormally = jest.mocked(uploadDeviceRunSessionArtifactAsync).getMockImplementation();
if (!uploadNormally) {
throw new Error('Expected the default upload implementation.');
}
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (...args) => {
signalFirstUpload();
await firstUploadGate;
await uploadNormally(...args);
})
.mockImplementationOnce(async (...args) => {
signalSecondUpload();
await uploadNormally(...args);
});
const firstFile = path.join(
collector.directory,
'screenshot-2026-09-24T08-45-59-123Z-aaaaaaaaaaaa.png'
);
const secondFile = path.join(
collector.directory,
'screenshot-2026-09-24T08-45-59-124Z-bbbbbbbbbbbb.png'
);
try {
await writeFile(`${firstFile}.tmp`, 'first-capture');
await rename(`${firstFile}.tmp`, firstFile);
await firstUploadStarted;
await writeFile(`${secondFile}.tmp`, 'second-capture');
await rename(`${secondFile}.tmp`, secondFile);
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledTimes(1);
releaseFirstUpload();
await secondUploadStarted;
await collector.finishAsync(true);
expect(uploaded).toEqual([Buffer.from('first-capture'), Buffer.from('second-capture')]);
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledTimes(2);
} finally {
releaseFirstUpload();
jest.useRealTimers();
await collector.finishAsync(true);
await rm(collector.directory, { recursive: true, force: true });
}
});
it('uses periodic scans when watching is unavailable and does not overlap uploads', async () => {
jest.useFakeTimers();
const watchSpy = jest.spyOn(fs, 'watch').mockImplementation(() => {
throw new Error('Watching unavailable');
});
const collector = await startDeviceRunSessionScreenshotsAsync(ctx, options);
let releaseUpload!: () => void;
const uploadGate = new Promise<void>(resolve => {
releaseUpload = resolve;
});
let signalUploadStarted!: () => void;
const uploadStarted = new Promise<void>(resolve => {
signalUploadStarted = resolve;
});
try {
await writeFile(
path.join(collector.directory, 'screenshot-2026-09-24T08-45-59-123Z-aaaaaaaaaaaa.png'),
'live-capture'
);
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementationOnce(async () => {
signalUploadStarted();
await uploadGate;
});
jest.advanceTimersByTime(30_000);
await uploadStarted;
jest.advanceTimersByTime(60_000);
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledTimes(1);
releaseUpload();
await collector.finishAsync(true);
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledTimes(1);
} finally {
releaseUpload();
watchSpy.mockRestore();
jest.useRealTimers();
await collector.finishAsync(true);
await rm(collector.directory, { recursive: true, force: true });
}
});
it('warns for the first three attempts and every tenth after, without reporting to Sentry', async () => {
const filename = `screenshot-2026-09-24T08-45-59-123Z-${randomBytes(6).toString('hex')}.png`;
await writeFile(path.join(directory, filename), 'png');
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockRejectedValue(new Error('offline'));
for (let i = 0; i < 12; i++) {
await flush();
}
expect(jest.mocked(logger.warn).mock.calls.map(([fields]) => fields.attempt)).toEqual([
1, 2, 3, 10,
]);
expect(logger.warn).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ file: path.join(directory, filename), attempt: 1, size: 3 }),
'Could not upload preview screenshot (attempt 1). Keeping it for retry.'
);
expect(logger.warn).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ attempt: 2 }),
'Could not upload preview screenshot (attempt 2). Keeping it for retry.'
);
expect(failedUploads.get(filename)).toEqual({
attempts: 12,
lastError: expect.objectContaining({ message: 'offline' }),
});
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('removes an empty directory on finish and returns the same task when finish repeats', async () => {
const collector = await startDeviceRunSessionScreenshotsAsync(ctx, options);
const finish = collector.finishAsync(true);
expect(collector.finishAsync(true)).toBe(finish);
await finish;
await expect(readdir(collector.directory)).rejects.toMatchObject({ code: 'ENOENT' });
expect(logger.info).toHaveBeenCalledWith('Uploaded 0 preview screenshots during the session.');
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('keeps the directory and warns when the session host is still running', async () => {
const collector = await startDeviceRunSessionScreenshotsAsync(ctx, options);
try {
await collector.finishAsync(false);
expect(await readdir(collector.directory)).toEqual([]);
expect(logger.warn).toHaveBeenCalledWith(
{ directory: collector.directory },
'The session host is still running, so preview screenshots it saves from now on are not uploaded.'
);
expect(Sentry.capture).not.toHaveBeenCalled();
} finally {
await rm(collector.directory, { recursive: true, force: true });
}
});
it('warns with the files not uploaded and their last error, and reports them to Sentry once', async () => {
const collector = await startDeviceRunSessionScreenshotsAsync(ctx, options);
const filename = 'screenshot-2026-09-24T08-45-59-123Z-aaaaaaaaaaaa.png';
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockRejectedValue(new Error('offline'));
try {
await writeFile(path.join(collector.directory, filename), 'png');
await collector.finishAsync(true);
const notUploaded = [{ name: filename, attempts: expect.any(Number), lastError: 'offline' }];
expect(logger.warn).toHaveBeenCalledWith(
{ directory: collector.directory, files: notUploaded },
'1 preview screenshot was not uploaded.'
);
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
'Preview screenshots were not uploaded',
expect.objectContaining({
message: '1 preview screenshot was not uploaded.',
}),
{ extras: { files: notUploaded } }
);
} finally {
await rm(collector.directory, { recursive: true, force: true });
}
});
it('reports a screenshot as not uploaded when the shutdown deadline cuts off its upload', async () => {
jest.useFakeTimers();
const collector = await startDeviceRunSessionScreenshotsAsync(ctx, options);
const filename = 'screenshot-2026-09-24T08-45-59-123Z-aaaaaaaaaaaa.png';
const file = path.join(collector.directory, filename);
let signalUploadStarted!: () => void;
const uploadStarted = new Promise<void>(resolve => {
signalUploadStarted = resolve;
});
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_ctx, { signal }) => {
signalUploadStarted();
await new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () => reject(signal.reason));
});
});
try {
await writeFile(file, 'png');
const finish = collector.finishAsync(true);
await uploadStarted;
jest.advanceTimersByTime(30_000);
await finish;
expect(logger.warn).toHaveBeenCalledWith(
{ directory: collector.directory },
'Preview screenshot flush timed out after 30 s.'
);
expect(logger.warn).toHaveBeenCalledWith(
{ file, size: 3 },
'Shutdown deadline reached before this preview screenshot uploaded. Keeping the file.'
);
expect(logger.warn).toHaveBeenCalledWith(
{ directory: collector.directory, files: [{ name: filename }] },
'1 preview screenshot was not uploaded.'
);
expect(logger.info).toHaveBeenCalledWith('Uploaded 0 preview screenshots during the session.');
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(await readdir(collector.directory)).toEqual([filename]);
} finally {
jest.useRealTimers();
await rm(collector.directory, { recursive: true, force: true });
}
});
it('reports a failure record once and deletes it while uploading captures from the same scan', async () => {
const png = 'screenshot-2026-09-24T08-45-59-123Z-aaaaaaaaaaaa.png';
const failed = 'screenshot-2026-09-24T08-45-59-124Z-bbbbbbbbbbbb.png';
const record = { file: failed, error: 'ENOSPC: no space left', at: '2026-09-24T08:45:59.124Z' };
await writeFile(path.join(directory, png), 'png');
await writeFile(
path.join(directory, 'screenshot-2026-09-24T08-45-59-124Z-bbbbbbbbbbbb.failed.json'),
JSON.stringify(record)
);
expect(await flush()).toEqual({ uploaded: 1, saveFailures: 1 });
expect(await flush()).toEqual({ uploaded: 0, saveFailures: 0 });
expect(uploaded).toEqual([Buffer.from('png')]);
expect(await readdir(directory)).toEqual([]);
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
record,
'The session host could not save a preview screenshot.'
);
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
'The session host could not save a preview screenshot',
expect.objectContaining({ message: 'ENOSPC: no space left' }),
{ extras: { file: failed, at: record.at } }
);
});
it('warns about a malformed failure record, deletes it, and does not report it to Sentry', async () => {
const file = path.join(directory, 'screenshot-2026-09-24T08-45-59-124Z-bbbbbbbbbbbb.failed.json');
await writeFile(file, JSON.stringify({ file: 'screenshot.png' }));
await flush();
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.anything(), file },
'Could not read a preview screenshot failure record.'
);
expect(await readdir(directory)).toEqual([]);
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('handles a failure record as soon as it appears and counts it on finish', async () => {
jest.useFakeTimers();
const collector = await startDeviceRunSessionScreenshotsAsync(ctx, options);
let signalReported!: () => void;
const reported = new Promise<void>(resolve => {
signalReported = resolve;
});
jest.mocked(Sentry.capture).mockImplementationOnce(() => signalReported());
const record = path.join(
collector.directory,
'screenshot-2026-09-24T08-45-59-124Z-bbbbbbbbbbbb.failed.json'
);
try {
await writeFile(`${record}.tmp`, JSON.stringify({ file: 'x.png', error: 'EIO', at: 'now' }));
await rename(`${record}.tmp`, record);
await reported;
await collector.finishAsync(true);
expect(logger.info).toHaveBeenCalledWith(
'Uploaded 0 preview screenshots during the session; the session host could not save 1.'
);
} finally {
jest.useRealTimers();
await collector.finishAsync(true);
await rm(collector.directory, { recursive: true, force: true });
}
});
@@ -1,265 +0,0 @@
import type { bunyan } from '@expo/logger';
import { Client, fetchExchange } from '@urql/core';
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
import { tmpdir } from 'node:os';
import path from 'node:path';
import type { CustomBuildContext } from '../../../customBuildContext';
import {
loadScreenshotSessionAsync,
screenshotArtifactDetails,
} from '../deviceRunSessionScreenshotNames';
import { uploadDeviceRunSessionScreenshotsAsync } from '../deviceRunSessionScreenshots';
jest.unmock('node-fetch');
jest.mock('../../../sentry');
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(r => {
resolve = r;
});
return { promise, resolve };
}
let handle: (request: IncomingMessage, response: ServerResponse) => void;
let server: ReturnType<typeof createServer>;
let ctx: CustomBuildContext;
let url: string;
let directory: string;
let creates: number;
let authorization: string | undefined;
let stallCreation: boolean;
let creationStarted: ReturnType<typeof deferred<IncomingMessage>>;
let creationBodies: string[];
const installedCommit = 'a1b2c3d' + '0'.repeat(33);
let sessionLookupFails: boolean;
let sessionLookups: number;
let uploadQuery: string;
beforeEach(async () => {
creates = 0;
sessionLookups = 0;
sessionLookupFails = false;
uploadQuery = '';
authorization = undefined;
stallCreation = false;
creationBodies = [];
creationStarted = deferred();
directory = await mkdtemp(path.join(tmpdir(), 'recording-upload-http-'));
server = createServer((request, response) => {
if (request.url?.startsWith('/graphql')) {
authorization = request.headers.authorization;
request.setEncoding('utf8');
let body = '';
if (request.method === 'GET') {
const requestUrl = new URL(request.url, 'http://localhost');
body = JSON.stringify({ query: requestUrl.searchParams.get('query') });
}
request.on('data', chunk => (body += chunk));
request.on('end', () => {
const operation = JSON.parse(body);
if (operation.query.includes('query ScreenshotSession')) {
sessionLookups++;
response.setHeader('Content-Type', 'application/json');
if (sessionLookupFails) {
response.end(JSON.stringify({ errors: [{ message: 'Lookup unavailable' }] }));
} else {
response.end(
JSON.stringify({
data: {
deviceRunSessions: {
byId: {
platform: 'IOS',
app: { slug: 'session-project' },
build: {
id: 'installed-build',
gitCommitHash: installedCommit,
app: { slug: 'my-app' },
},
},
},
},
})
);
}
return;
}
creates++;
creationBodies.push(body);
creationStarted.resolve(request);
if (stallCreation) {
return;
}
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify({
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: { url: `${url}/artifact${uploadQuery}`, 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('publishes a manual preview PNG through the artifact GraphQL and HTTP upload path', async () => {
const payload = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aF9sAAAAASUVORK5CYII=',
'base64'
);
const filename = 'screenshot-2026-09-24T08-45-59-123Z-a1b2c3d4e5f6.png';
await writeFile(path.join(directory, filename), payload);
const bodies: Buffer[] = [];
handle = (request, response) => {
const chunks: Buffer[] = [];
request.on('data', chunk => chunks.push(Buffer.from(chunk)));
request.on('end', () => {
bodies.push(Buffer.concat(chunks));
response.end();
});
};
const session = loadScreenshotSessionAsync(ctx, 'drs-id', {
warn: jest.fn(),
} as unknown as bunyan);
await uploadDeviceRunSessionScreenshotsAsync(ctx, {
session,
directory,
deviceRunSessionId: 'drs-id',
failedUploads: new Map(),
logger: { info: jest.fn(), warn: jest.fn() } as unknown as bunyan,
signal: new AbortController().signal,
});
expect(bodies).toEqual([payload]);
expect(creates).toBe(1);
expect(sessionLookups).toBe(1);
expect(authorization).toBe('Bearer test-auth');
expect(JSON.parse(creationBodies[0]).variables).toEqual(
expect.objectContaining({
deviceRunSessionId: 'drs-id',
input: expect.objectContaining({
name: 'Screenshot 2026-09-24 08:45:59 UTC',
filename: 'my-app-ios-a1b2c3d-2026-09-24T08-45-59-123Z.png',
kind: 'screenshot',
size: payload.length,
metadata: {
__eas_type: 'screenshot',
appSlug: 'my-app',
platform: 'ios',
buildId: 'installed-build',
gitCommitHash: installedCommit,
},
}),
})
);
await expect(stat(path.join(directory, filename))).rejects.toMatchObject({ code: 'ENOENT' });
});
it('rereads the PNG when the artifact PUT is retried', async () => {
const filename = 'screenshot-2026-09-24T08-45-59-123Z-a1b2c3d4e5f6.png';
await writeFile(path.join(directory, filename), 'retried-capture');
const bodies: string[] = [];
handle = (request, response) => {
let body = '';
request.setEncoding('utf8');
request.on('data', chunk => (body += chunk));
request.on('end', () => {
bodies.push(body);
response.statusCode = bodies.length === 1 ? 503 : 200;
response.end();
});
};
const uploaded = await uploadDeviceRunSessionScreenshotsAsync(ctx, {
directory,
deviceRunSessionId: 'drs-id',
failedUploads: new Map(),
logger: { info: jest.fn(), warn: jest.fn() } as unknown as bunyan,
signal: new AbortController().signal,
});
expect(uploaded).toEqual({ uploaded: 1, saveFailures: 0 });
expect(creates).toBe(1);
expect(bodies).toEqual(['retried-capture', 'retried-capture']);
await expect(stat(path.join(directory, filename))).rejects.toMatchObject({ code: 'ENOENT' });
});
it('aborts a stalled GraphQL upload request and keeps the PNG', async () => {
stallCreation = true;
const filename = 'screenshot-2026-09-24T08-45-59-123Z-a1b2c3d4e5f6.png';
await writeFile(path.join(directory, filename), 'png');
const controller = new AbortController();
const upload = uploadDeviceRunSessionScreenshotsAsync(ctx, {
directory,
deviceRunSessionId: 'drs-id',
failedUploads: new Map(),
logger: { info: jest.fn(), warn: jest.fn() } as unknown as bunyan,
signal: controller.signal,
});
await creationStarted.promise;
controller.abort();
await upload;
expect((await stat(path.join(directory, filename))).size).toBe(3);
});
it('keeps the signed upload URL out of the error when the upload connection drops', async () => {
uploadQuery = '?X-Goog-Signature=secret-signature';
handle = request => request.socket.destroy();
const filename = 'screenshot-2026-09-24T08-45-59-123Z-a1b2c3d4e5f6.png';
await writeFile(path.join(directory, filename), 'png');
const failedUploads = new Map<string, { attempts: number; lastError: Error }>();
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
await uploadDeviceRunSessionScreenshotsAsync(ctx, {
directory,
deviceRunSessionId: 'drs-id',
failedUploads,
logger,
signal: new AbortController().signal,
});
const error = failedUploads.get(filename)?.lastError;
expect(error?.message).toBe(
`Failed to upload device run session artifact ${filename.slice(0, -4)}: ECONNRESET.`
);
expect(error?.cause).toBeUndefined();
expect(JSON.stringify(jest.mocked(logger.warn).mock.calls)).not.toContain('secret-signature');
expect(JSON.stringify(jest.mocked(logger.warn).mock.calls)).not.toContain('/artifact');
});
it('falls back without using the workflow commit when the session lookup fails', async () => {
sessionLookupFails = true;
ctx.metadata = { appName: 'wrong-app', gitCommitHash: 'f'.repeat(40) };
const logger = { warn: jest.fn() } as unknown as bunyan;
const session = await loadScreenshotSessionAsync(ctx, 'drs-id', logger);
expect(session).toBeNull();
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ deviceRunSessionId: 'drs-id' }),
'Could not load screenshot session details; using capture timestamps.'
);
expect(screenshotArtifactDetails('2026-09-24T08-45-59-123Z', session)).toEqual({
name: 'Screenshot 2026-09-24 08:45:59 UTC',
filename: 'screenshot-2026-09-24T08-45-59-123Z.png',
metadata: { __eas_type: 'screenshot' },
});
});
@@ -1,80 +0,0 @@
import {
DeviceRunSessionTimeoutError,
withDeviceRunSessionTimeoutAsync,
} from '../deviceRunSessionTimeout';
afterEach(() => jest.useRealTimers());
it('aborts a stalled operation when its deadline expires', async () => {
jest.useFakeTimers();
let signal: AbortSignal | undefined;
const operation = withDeviceRunSessionTimeoutAsync(
{ name: 'test operation', timeoutMs: 100 },
async value => {
signal = value;
return await new Promise<void>(() => {});
}
);
const rejected = expect(operation).rejects.toThrow('test operation timed out after 100ms');
const typed = expect(operation).rejects.toBeInstanceOf(DeviceRunSessionTimeoutError);
await jest.advanceTimersByTimeAsync(100);
await rejected;
await typed;
expect(signal?.aborted).toBe(true);
expect(jest.getTimerCount()).toBe(0);
});
it('does not start an operation with an already aborted parent', async () => {
const operation = jest.fn();
await expect(
withDeviceRunSessionTimeoutAsync(
{
name: 'test',
timeoutMs: 100,
signal: AbortSignal.abort(new Error('parent ended')),
},
operation
)
).rejects.toThrow('parent ended');
expect(operation).not.toHaveBeenCalled();
});
it('clears the deadline when an operation succeeds', async () => {
jest.useFakeTimers();
await expect(
withDeviceRunSessionTimeoutAsync({ name: 'test', timeoutMs: 100 }, async () => 42)
).resolves.toBe(42);
expect(jest.getTimerCount()).toBe(0);
});
it('ignores a deadline reset that arrives after the operation settled', async () => {
jest.useFakeTimers();
let resetDeadline!: () => void;
await expect(
withDeviceRunSessionTimeoutAsync({ name: 'test', timeoutMs: 100 }, async (_signal, reset) => {
resetDeadline = reset;
return 42;
})
).resolves.toBe(42);
resetDeadline();
expect(jest.getTimerCount()).toBe(0);
});
it('keeps an operation alive while it reports progress', async () => {
jest.useFakeTimers();
const operation = withDeviceRunSessionTimeoutAsync(
{ name: 'test operation', timeoutMs: 100 },
async (signal, resetDeadline) => {
await new Promise<void>(resolve => setTimeout(resolve, 80));
resetDeadline();
await new Promise<void>(resolve => setTimeout(resolve, 80));
resetDeadline();
await new Promise<void>(resolve => setTimeout(resolve, 80));
return signal.aborted;
}
);
const settled = expect(operation).resolves.toBe(false);
await jest.advanceTimersByTimeAsync(240);
await settled;
expect(jest.getTimerCount()).toBe(0);
});
@@ -1,333 +0,0 @@
import type { bunyan } from '@expo/logger';
import type { BuildStepEnv } from '@expo/steps';
import { Client, fetchExchange } from '@urql/core';
import { createReadStream } from 'node:fs';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
import type { Socket } from 'node:net';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { Readable } from 'node:stream';
import { setTimeout as delay } from 'node:timers/promises';
import type { CustomBuildContext } from '../../../customBuildContext';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import { findUnlistedDeviceScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
jest.unmock('node-fetch');
jest.unmock('node:fs');
jest.unmock('node:fs/promises');
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(r => {
resolve = r;
});
return { promise, resolve };
}
const metadata = {
deviceRunSessionId: 'drs-id',
artifactId: 'recording-id',
name: 'Recording',
filename: 'recording.mp4',
kind: 'screen-recording',
size: 16,
};
let handle: (request: IncomingMessage, response: ServerResponse) => void;
let server: ReturnType<typeof createServer>;
let ctx: CustomBuildContext;
let url: string;
let directory: string;
let creates: number;
let authorization: string | undefined;
let stallCreation: boolean;
let creationStarted: ReturnType<typeof deferred<IncomingMessage>>;
beforeEach(async () => {
creates = 0;
authorization = undefined;
stallCreation = false;
creationStarted = deferred();
directory = await mkdtemp(path.join(tmpdir(), 'recording-upload-http-'));
server = createServer((request, response) => {
if (request.url === '/graphql') {
creates++;
authorization = request.headers.authorization;
creationStarted.resolve(request);
request.resume();
request.on('end', () => {
if (stallCreation) {
return;
}
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('replays the full file after a transient PUT failure without recreating the upload session', async () => {
const bodies: string[] = [];
handle = (request, response) => {
const chunks: string[] = [];
request.setEncoding('utf8');
request.on('data', chunk => chunks.push(chunk));
request.on('end', () => {
bodies.push(chunks.join(''));
response.statusCode = bodies.length === 1 ? 503 : 200;
response.end();
});
};
const file = path.join(directory, 'recording.mp4');
const payload = '*'.repeat(128 * 1024);
await writeFile(file, payload);
const streams: Readable[] = [];
const open = () => {
const stream = createReadStream(file);
streams.push(stream);
return stream;
};
await uploadDeviceRunSessionArtifactAsync(ctx, {
...metadata,
size: payload.length,
openStream: open,
attempts: 3,
});
expect(creates).toBe(1);
expect(authorization).toBe('Bearer test-auth');
expect(bodies).toEqual([payload, payload]);
expect(streams).toHaveLength(2);
expect(streams.every(stream => stream.destroyed)).toBe(true);
});
it('keeps a slow PUT alive while its body is still flowing', async () => {
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
let received = 0;
let onChunk = () => {};
handle = (request, response) => {
request.on('data', chunk => {
received += chunk.length;
onChunk();
});
request.on('end', () => {
response.statusCode = 200;
response.end();
});
};
const waitForBytes = (total: number) =>
new Promise<void>(resolve => {
onChunk = () => {
if (received >= total) {
resolve();
}
};
onChunk();
});
const stream = new Readable({ read() {} });
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
...metadata,
size: 3,
openStream: () => stream,
});
const settled = expect(upload).resolves.toBeUndefined();
stream.push('a');
await waitForBytes(1);
await jest.advanceTimersByTimeAsync(60_000);
stream.push('b');
await waitForBytes(2);
await jest.advanceTimersByTimeAsync(60_000);
stream.push('c');
stream.push(null);
await settled;
expect(received).toBe(3);
jest.useRealTimers();
});
it('retries a PUT that stalled past the idle deadline with a fresh stream', async () => {
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
let puts = 0;
const firstBodyRead = deferred<void>();
handle = (request, response) => {
puts++;
request.resume();
request.on('end', () => {
if (puts === 1) {
firstBodyRead.resolve();
return;
}
response.statusCode = 200;
response.end();
});
};
const streams: Readable[] = [];
const open = () => {
const stream = Readable.from('recording');
streams.push(stream);
return stream;
};
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
...metadata,
size: 9,
openStream: open,
attempts: 3,
});
const settled = expect(upload).resolves.toBeUndefined();
await firstBodyRead.promise;
await jest.advanceTimersByTimeAsync(90_000);
await jest.advanceTimersByTimeAsync(500);
await settled;
expect(puts).toBe(2);
expect(streams).toHaveLength(2);
expect(streams.every(stream => stream.destroyed)).toBe(true);
jest.useRealTimers();
});
it('cancels an active PUT socket and destroys its source without retrying', async () => {
const started = deferred<void>();
const closed = deferred<void>();
let puts = 0;
handle = request => {
puts++;
request.socket.once('close', () => closed.resolve());
request.on('data', () => started.resolve());
};
const controller = new AbortController();
const stream = new Readable({ read() {} });
stream.push(Buffer.alloc(1024));
const openStream = jest.fn(() => stream);
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
...metadata,
openStream,
attempts: 3,
signal: controller.signal,
});
const rejected = expect(upload).rejects.toThrow('test cancellation');
await started.promise;
controller.abort(new Error('test cancellation'));
await rejected;
await closed.promise;
expect(stream.destroyed).toBe(true);
expect(openStream).toHaveBeenCalledTimes(1);
expect(puts).toBe(1);
});
it.each([200, 400, 503])(
'closes a stalled HTTP %s response before leaving or retrying the PUT',
async status => {
const sockets = new Set<Socket>();
let puts = 0;
let maxOpenSockets = 0;
handle = (request, response) => {
puts++;
sockets.add(request.socket);
maxOpenSockets = Math.max(maxOpenSockets, sockets.size);
request.socket.once('close', () => sockets.delete(request.socket));
request.resume();
request.on('end', () => {
response.writeHead(status, { 'Content-Length': '1000' });
response.flushHeaders();
response.write('partial response');
});
};
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
...metadata,
openStream: () => Readable.from('recording'),
attempts: 3,
});
if (status === 200) {
await expect(upload).resolves.toBeUndefined();
} else {
await expect(upload).rejects.toThrow(`HTTP ${status}`);
}
for (let i = 0; i < 100 && sockets.size > 0; i++) {
await delay(10);
}
expect(creates).toBe(1);
expect(puts).toBe(status === 503 ? 3 : 1);
expect(maxOpenSockets).toBe(1);
expect(sockets.size).toBe(0);
}
);
it('cancels urql session creation with auth intact and never starts a PUT', async () => {
stallCreation = true;
const puts = jest.fn();
handle = puts;
const controller = new AbortController();
const openStream = jest.fn(() => Readable.from('recording'));
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
...metadata,
openStream,
signal: controller.signal,
});
const rejected = expect(upload).rejects.toThrow('test cancellation');
const request = await creationStarted.promise;
const closed = new Promise<void>(resolve => request.socket.once('close', resolve));
controller.abort(new Error('test cancellation'));
await rejected;
await closed;
expect(authorization).toBe('Bearer test-auth');
expect(creates).toBe(1);
expect(puts).not.toHaveBeenCalled();
expect(openStream).not.toHaveBeenCalled();
});
it('says ffprobe is missing instead of calling an unlisted recording undecodable', async () => {
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const session = path.join(directory, 'session');
await mkdir(session);
await writeFile(path.join(session, 'recording.mp4.partial'), new Uint8Array(28));
await writeFile(
path.join(session, 'session.json'),
JSON.stringify({
udid: 'emulator-5554',
deviceName: 'Pixel',
runtimeDisplayName: 'Android 16',
status: 'recording',
recording: 'recording.mp4.partial',
})
);
const recordings = await findUnlistedDeviceScreenRecordingsAsync({
root: directory,
env: { ...process.env, PATH: path.join(directory, 'no-tools') } as BuildStepEnv,
logger,
});
expect(recordings).toEqual([]);
expect(logger.warn).toHaveBeenCalledWith(
'ffprobe is not available; cannot recover the unlisted recording session/recording.mp4.partial.'
);
});
@@ -1,576 +0,0 @@
import type { bunyan } from '@expo/logger';
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import * as ngrok from '@ngrok/ngrok';
import { access, mkdir, readdir, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import {
findUnlistedDeviceScreenRecordingsAsync,
uploadDeviceRunSessionScreenRecordingsAsync,
} from '../deviceRunSessionScreenRecordings';
import {
ensureMacosPreviewEncoderInstalledAsync,
startDeviceRunSessionPreview,
} from '../deviceRunSessionPreview';
import { startDeviceSessionHostAsync } from '../deviceSessionHost';
import { ensureFfmpegInstalledOnceAsync, spawnDetached } from '../remoteDeviceRunSession';
jest.mock('@ngrok/ngrok');
jest.mock('../deviceRunSessionArtifacts');
jest.mock('../../../sentry');
jest.mock('../serveSimMetricsRecorder', () => ({
readServeSimServersAsync: jest
.fn()
.mockResolvedValue([{ udid: 'emulator-5554', token: 'preview-token' }]),
}));
jest.mock('../../../utils/turtleFetch');
jest.mock('../remoteDeviceRunSession', () => ({
...jest.requireActual('../remoteDeviceRunSession'),
ensureFfmpegInstalledOnceAsync: jest.fn(),
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(),
uploadDeviceRunSessionScreenRecordingsAsync: jest.fn(),
}));
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(r => {
resolve = r;
});
return { promise, resolve };
}
const env = {
DEVICE_RUN_SESSION_ID: 'drs-id',
NGROK_AUTHTOKEN: 'token',
} as BuildStepEnv;
const ctx = {} as CustomBuildContext;
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const stopServer = jest.fn();
const closeTunnel = jest.fn();
const directories: string[] = [];
const baseDomain = 'preview.example.test';
async function startHostAsync() {
return await startDeviceSessionHostAsync(ctx, {
runtimePlatform: BuildRuntimePlatform.LINUX,
env,
logger,
timeoutMs: 10_000,
});
}
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);
jest.mocked(findUnlistedDeviceScreenRecordingsAsync).mockReset().mockResolvedValue([]);
jest.mocked(spawnDetached).mockImplementation(options => {
const flag = options.args.indexOf('--android-recording-directory');
if (flag >= 0) {
directories.push(options.args[flag + 1]);
}
return {
pid: undefined,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: stopServer,
};
});
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://public.example.test',
close: closeTunnel,
} as never);
jest.mocked(turtleFetch).mockImplementation(
async () =>
({
ok: true,
json: async () => ({ status: 'ready', device: 'emulator-5554' }),
}) as Awaited<ReturnType<typeof turtleFetch>>
);
});
afterEach(async () => {
jest.useRealTimers();
await Promise.all(
directories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))
);
});
it('records by default with no preview and finalizes before process stop, uploading exactly once', async () => {
const host = await startHostAsync();
expect(ngrok.forward).not.toHaveBeenCalled();
const directory = directories[0];
const recordings = [
{
udid: 'emulator-5554',
deviceName: 'Pixel',
runtimeDisplayName: 'Android 16',
directory: path.join(directory, 'session'),
},
];
await writeFile(path.join(directory, 'recordings.json'), JSON.stringify(recordings));
await mkdir(recordings[0].directory);
const started = deferred<void>();
const response = deferred<Awaited<ReturnType<typeof turtleFetch>>>();
jest.mocked(turtleFetch).mockImplementationOnce(async () => {
started.resolve();
return await response.promise;
});
const finishing = host.finishAsync();
expect(host.finishAsync()).toBe(finishing);
await started.promise;
expect(stopServer).not.toHaveBeenCalled();
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
const token =
jest.mocked(spawnDetached).mock.calls[0][0].env.EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN;
expect(token).toMatch(/^[a-f0-9]{64}$/);
expect(turtleFetch).toHaveBeenLastCalledWith(
expect.stringMatching(/\/_eas\/android-recording\/stop$/),
'POST',
{
headers: { Authorization: `Bearer ${token}` },
retries: 0,
shouldThrowOnNotOk: false,
signal: expect.any(AbortSignal),
}
);
response.resolve({ ok: true } as Awaited<ReturnType<typeof turtleFetch>>);
await finishing;
await host.finishAsync();
expect(stopServer).toHaveBeenCalledTimes(1);
expect(uploadDeviceRunSessionScreenRecordingsAsync).toHaveBeenCalledTimes(1);
expect(uploadDeviceRunSessionScreenRecordingsAsync).toHaveBeenCalledWith(ctx, {
logger,
deviceRunSessionId: 'drs-id',
recordings,
});
expect(stopServer.mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mock.invocationCallOrder[0]
);
await expect(host.openPreviewAsync({ baseDomain })).rejects.toThrow(
'after session host finalization'
);
});
it('coalesces preview opens and allows close/reopen without ending capture', async () => {
const host = await startHostAsync();
const opening = host.openPreviewAsync({ baseDomain });
expect(host.openPreviewAsync({ baseDomain })).toBe(opening);
const first = await opening;
const closing = first.closeAsync();
expect(first.closeAsync()).toBe(closing);
await closing;
const secondClose = jest.fn().mockResolvedValue(undefined);
jest.mocked(ngrok.forward).mockResolvedValueOnce({
url: () => 'https://replacement.example.test',
close: secondClose,
} as never);
const second = await host.openPreviewAsync({ baseDomain });
await first.closeAsync();
expect(await host.openPreviewAsync({ baseDomain })).toBe(second);
expect(secondClose).not.toHaveBeenCalled();
expect(spawnDetached).toHaveBeenCalledTimes(1);
expect(stopServer).not.toHaveBeenCalled();
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
expect(jest.mocked(turtleFetch).mock.calls.some(([, method]) => method === 'POST')).toBe(false);
await host.finishAsync();
expect(closeTunnel).toHaveBeenCalledTimes(1);
expect(secondClose).toHaveBeenCalledTimes(1);
expect(stopServer).toHaveBeenCalledTimes(1);
});
it('retries a failed tunnel without restarting the host', async () => {
const host = await startHostAsync();
jest.mocked(ngrok.forward).mockRejectedValueOnce(new Error('tunnel unavailable'));
await expect(host.openPreviewAsync({ baseDomain })).rejects.toThrow('tunnel unavailable');
expect(stopServer).not.toHaveBeenCalled();
await host.openPreviewAsync({ baseDomain });
expect(spawnDetached).toHaveBeenCalledTimes(1);
await host.finishAsync();
expect(closeTunnel).toHaveBeenCalledTimes(1);
});
it('drains an in-flight tunnel when finishing and rejects new opens', async () => {
const host = await startHostAsync();
const tunnel = deferred<Awaited<ReturnType<typeof ngrok.forward>>>();
jest.mocked(ngrok.forward).mockReturnValueOnce(tunnel.promise);
const opening = host.openPreviewAsync({ baseDomain });
const finishing = host.finishAsync();
await expect(host.openPreviewAsync({ baseDomain })).rejects.toThrow(
'after session host finalization'
);
tunnel.resolve({ url: () => 'https://late.example.test', close: closeTunnel } as never);
await expect(opening).rejects.toThrow('finalized while the preview was opening');
await finishing;
expect(closeTunnel).toHaveBeenCalledTimes(1);
expect(stopServer).toHaveBeenCalledTimes(1);
});
it('keeps cleanup and upload best-effort when tunnel close and finalization fail', async () => {
jest.mocked(spawnDetached).mockImplementationOnce(options => {
directories.push(options.args[options.args.indexOf('--android-recording-directory') + 1]);
return {
pid: undefined,
getOutput: () => '[serve-emu] emulator-5554 capture error: scrcpy exited with code 255',
getExitError: () => undefined,
stopAsync: stopServer,
};
});
const host = await startHostAsync();
await writeFile(path.join(directories[0], 'recordings.json'), '[]');
await host.openPreviewAsync({ baseDomain });
closeTunnel.mockRejectedValueOnce(new Error('tunnel close failure'));
jest.mocked(turtleFetch).mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => 'Error: scrcpy exited with code 255',
} as Awaited<ReturnType<typeof turtleFetch>>);
await host.finishAsync();
expect(stopServer).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{
err: expect.objectContaining({
message: expect.stringContaining('HTTP 500: Error: scrcpy exited with code 255'),
}),
},
'Could not finalize Android recording before shutdown.'
);
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not finalize Android recording before shutdown',
expect.any(Error),
{ level: 'warning' }
);
expect(uploadDeviceRunSessionScreenRecordingsAsync).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ hostOutput: '[serve-emu] emulator-5554 capture error: scrcpy exited with code 255' },
'Session host output around the recording failure.'
);
});
it('logs the reason and skips upload and output dump when the Hub never recorded', async () => {
jest.mocked(spawnDetached).mockImplementationOnce(options => {
directories.push(options.args[options.args.indexOf('--android-recording-directory') + 1]);
return {
pid: undefined,
getOutput: () => '[serve-emu] Android recording skipped',
getExitError: () => undefined,
stopAsync: stopServer,
};
});
const host = await startHostAsync();
jest.mocked(turtleFetch).mockResolvedValueOnce({
ok: false,
status: 409,
text: async () => 'Android recording requires exactly one booted emulator; found 0.',
} as Awaited<ReturnType<typeof turtleFetch>>);
await host.finishAsync();
expect(stopServer).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
'Android recording was not captured: Android recording requires exactly one booted emulator; found 0.'
);
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalledWith(
expect.anything(),
'Session host output around the recording failure.'
);
});
it('rolls back failed host startup without replacing the original error', async () => {
stopServer.mockRejectedValueOnce(new Error('stop failure'));
await expect(
startDeviceSessionHostAsync(ctx, {
runtimePlatform: BuildRuntimePlatform.LINUX,
env,
logger,
timeoutMs: 0,
})
).rejects.toThrow('Timed out waiting');
expect(stopServer).toHaveBeenCalledTimes(1);
const directory =
jest.mocked(spawnDetached).mock.calls[0][0].env.EXPO_DEVICE_HUB_SCREENSHOT_DIRECTORY;
if (!directory) {
throw new Error('Missing screenshot artifact directory');
}
// The host could not be stopped, so it may still write captures there.
await expect(access(directory)).resolves.toBeUndefined();
expect(ngrok.forward).not.toHaveBeenCalled();
expect(jest.mocked(turtleFetch).mock.calls.some(([, method]) => method === 'POST')).toBe(false);
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
const hostStillRunning =
'The session host is still running, so preview screenshots it saves from now on are not uploaded.';
expect(logger.warn).toHaveBeenCalledWith({ directory }, hostStillRunning);
const recordingWarnings = jest
.mocked(logger.warn)
.mock.calls.map(([, message]) => message)
.filter(message => message !== hostStillRunning);
expect(recordingWarnings).not.toContainEqual(expect.stringMatching(/finalize|upload/));
});
async function writeRecordingDescriptorAsync(directory: string) {
const child = path.join(directory, 'session');
await mkdir(child);
await writeFile(
path.join(directory, 'recordings.json'),
JSON.stringify([
{
udid: 'emulator-5554',
deviceName: 'Pixel',
runtimeDisplayName: 'Android',
directory: child,
},
])
);
}
it.each([true, false])(
'removes its recording root only when every upload succeeds (%s)',
async uploaded => {
const host = await startHostAsync();
const directory = directories[0];
await writeRecordingDescriptorAsync(directory);
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockResolvedValueOnce(uploaded);
await host.finishAsync();
if (uploaded) {
await expect(access(directory)).rejects.toMatchObject({ code: 'ENOENT' });
} else {
await expect(access(directory)).resolves.toBeUndefined();
}
}
);
it('finishes despite a stalled tunnel close', async () => {
const host = await startHostAsync();
await host.openPreviewAsync({ baseDomain });
const closing = deferred<void>();
closeTunnel.mockReturnValueOnce(closing.promise);
jest.useFakeTimers();
const finishing = host.finishAsync();
await jest.advanceTimersByTimeAsync(5_000);
await finishing;
expect(stopServer).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('deadline'));
closing.resolve();
});
it('aborts a stalled finalization request before stopping the host', async () => {
const host = await startHostAsync();
const response = deferred<Awaited<ReturnType<typeof turtleFetch>>>();
jest.mocked(turtleFetch).mockReturnValueOnce(response.promise);
jest.useFakeTimers();
const finishing = host.finishAsync();
await jest.advanceTimersByTimeAsync(60_000);
await finishing;
const options = jest.mocked(turtleFetch).mock.calls.at(-1)?.[2];
expect(options?.signal?.aborted).toBe(true);
expect(stopServer).toHaveBeenCalledTimes(1);
response.resolve({ ok: true } as Awaited<ReturnType<typeof turtleFetch>>);
});
it('uploads the partial recording a killed host left behind and then removes the root', async () => {
const host = await startHostAsync();
const directory = directories[0];
const child = path.join(directory, 'session');
await mkdir(child);
await writeFile(path.join(directory, 'recordings.json'), '[]');
const partial = [
{ udid: 'emulator-5554', deviceName: 'Pixel', runtimeDisplayName: 'Android', directory: child },
];
jest.mocked(findUnlistedDeviceScreenRecordingsAsync).mockResolvedValueOnce(partial);
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockResolvedValueOnce(true);
await host.finishAsync();
expect(findUnlistedDeviceScreenRecordingsAsync).toHaveBeenCalledWith({
root: directory,
env,
logger,
});
expect(uploadDeviceRunSessionScreenRecordingsAsync).toHaveBeenCalledWith(ctx, {
logger,
deviceRunSessionId: 'drs-id',
recordings: partial,
});
await expect(access(directory)).rejects.toMatchObject({ code: 'ENOENT' });
});
it('rejects descriptors outside its recording root', async () => {
const host = await startHostAsync();
const directory = directories[0];
await writeFile(
path.join(directory, 'recordings.json'),
JSON.stringify([
{
udid: 'emulator-5554',
deviceName: 'Pixel',
runtimeDisplayName: 'Android',
directory: path.dirname(directory),
},
])
);
await host.finishAsync();
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
await expect(access(directory)).resolves.toBeUndefined();
});
it('retains recordings without uploading if the host cannot be stopped', async () => {
const host = await startHostAsync();
const directory = directories[0];
await writeRecordingDescriptorAsync(directory);
stopServer.mockRejectedValueOnce(new Error('process stop failed'));
await host.finishAsync();
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
await expect(access(directory)).resolves.toBeUndefined();
});
it('closes a listener that arrives after finish has already timed out waiting for it', async () => {
const host = await startHostAsync();
const tunnel = deferred<Awaited<ReturnType<typeof ngrok.forward>>>();
jest.mocked(ngrok.forward).mockReturnValueOnce(tunnel.promise);
const opening = host.openPreviewAsync({ baseDomain });
const rejected = expect(opening).rejects.toThrow('finalized while the preview was opening');
jest.useFakeTimers();
const finishing = host.finishAsync();
await jest.advanceTimersByTimeAsync(5_000);
await finishing;
expect(stopServer).toHaveBeenCalledTimes(1);
tunnel.resolve({ url: () => 'https://late.example.test', close: closeTunnel } as never);
await rejected;
expect(closeTunnel).toHaveBeenCalledTimes(1);
});
it('leaves iOS recording to its existing build steps', async () => {
const host = await startDeviceSessionHostAsync(ctx, {
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger,
timeoutMs: 10_000,
});
const options = jest.mocked(spawnDetached).mock.calls[0][0];
expect(options.args).not.toContain('--android-recording-directory');
expect(options.env.EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN).toBeUndefined();
await host.finishAsync();
expect(jest.mocked(turtleFetch).mock.calls.some(([, method]) => method === 'POST')).toBe(false);
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
expect(stopServer).toHaveBeenCalledTimes(1);
});
it.each([BuildRuntimePlatform.LINUX, BuildRuntimePlatform.DARWIN])(
'uploads screenshots and flushes them exactly once when the %s host finishes',
async runtimePlatform => {
const uploads: Buffer[] = [];
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementation(async (_ctx, { openStream }) => {
const chunks: Buffer[] = [];
for await (const chunk of openStream()) {
chunks.push(Buffer.from(chunk));
}
uploads.push(Buffer.concat(chunks));
});
const host = await startDeviceSessionHostAsync(ctx, {
runtimePlatform,
env,
logger,
timeoutMs: 10_000,
});
await host.openPreviewAsync({ baseDomain });
const directory =
jest.mocked(spawnDetached).mock.calls[0][0].env.EXPO_DEVICE_HUB_SCREENSHOT_DIRECTORY;
if (!directory) {
throw new Error('Missing screenshot artifact directory');
}
await writeFile(
path.join(directory, 'screenshot-2026-09-24T08-45-59-123Z-a1b2c3d4e5f6.png'),
'manual-capture'
);
let directoryWhenStopping: string[] | undefined;
stopServer.mockImplementationOnce(async () => {
directoryWhenStopping = await readdir(directory);
});
const finishing = host.finishAsync();
expect(host.finishAsync()).toBe(finishing);
await finishing;
expect(uploads).toEqual([Buffer.from('manual-capture')]);
expect(stopServer).toHaveBeenCalledTimes(1);
expect(closeTunnel).toHaveBeenCalledTimes(1);
// The collector finishes, and removes its directory, only after the host has stopped.
expect(directoryWhenStopping).toBeDefined();
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();
});
@@ -1,3 +0,0 @@
Robin, sparrow, leiothrix and chat
Zażółć gęślą jaźń
€ 🐦
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Loaded 100 of 287 files, more files were not shown because too many files have changed in this diff. Show more