mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
114
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
102f06cc50 | ||
|
|
f96c266645 | ||
|
|
5739556d67 | ||
|
|
821dcd1a96 | ||
|
|
3b59a744da | ||
|
|
73edec4d68 | ||
|
|
13fe586f3f | ||
|
|
c3413315d1 | ||
|
|
6c256fd9d2 | ||
|
|
6683492955 | ||
|
|
964b4ed93f | ||
|
|
34e8b45bad | ||
|
|
d1de958201 | ||
|
|
2b672fc07f | ||
|
|
8453efcfb6 | ||
|
|
0456e8eea6 | ||
|
|
0ec9a81552 | ||
|
|
3b4fedeee5 | ||
|
|
570617f467 | ||
|
|
8262aa34c6 | ||
|
|
1f72736126 | ||
|
|
3328b60f85 | ||
|
|
4abb8601d5 | ||
|
|
9a881d8366 | ||
|
|
657034a1a6 | ||
|
|
bb98f5beef | ||
|
|
4aca8b1cc4 | ||
|
|
800a0e0893 | ||
|
|
2911b04ea1 | ||
|
|
50b381abe0 | ||
|
|
c8696f6012 | ||
|
|
45d795e076 | ||
|
|
8e98a915b9 | ||
|
|
73c65d3edb | ||
|
|
01e3be71aa | ||
|
|
991bbeae8c | ||
|
|
6c342abb97 | ||
|
|
1016aa35f3 | ||
|
|
ceb8f846c2 | ||
|
|
53cb3a7263 | ||
|
|
09188ab271 | ||
|
|
c4ffc1605e | ||
|
|
d90f022966 | ||
|
|
8b8362e4ae | ||
|
|
b5ac163b37 | ||
|
|
92c8944cda | ||
|
|
96581b8094 | ||
|
|
e229f5378f | ||
|
|
19657cc7ff | ||
|
|
be7acb7ab5 | ||
|
|
3c302a055d | ||
|
|
449bd4981e | ||
|
|
4fd36987ba | ||
|
|
01c1ffcaf4 | ||
|
|
b10cc829bd | ||
|
|
3d8ed4c81f | ||
|
|
a344c0e1e8 | ||
|
|
f80f548e2f | ||
|
|
c72ae9f9f6 | ||
|
|
bf0420cd29 | ||
|
|
738ccb1ff6 | ||
|
|
c487bcf1df | ||
|
|
bcaaaddab8 | ||
|
|
4e432fa537 | ||
|
|
817d06e66e | ||
|
|
76a9d17ea7 | ||
|
|
8ce87bfa56 | ||
|
|
2b7e8454e3 | ||
|
|
962a2a733e | ||
|
|
9e13c1e820 | ||
|
|
cadb7b2d1e | ||
|
|
718c82d8e1 | ||
|
|
5d31ecc2bc | ||
|
|
890286a08d | ||
|
|
33ff59a75e | ||
|
|
c2bbba6e78 | ||
|
|
86b85e7d8d | ||
|
|
03a7d3a6ee | ||
|
|
a21db49184 | ||
|
|
6dd6da083d | ||
|
|
656921d776 | ||
|
|
fabde0c2bb | ||
|
|
7680f33879 | ||
|
|
199a996b38 | ||
|
|
4728c64d3f | ||
|
|
0fdf96e795 | ||
|
|
04f3bee0c4 | ||
|
|
140cfe445a | ||
|
|
da55f26cac | ||
|
|
e24130c6e0 | ||
|
|
2a0fe12836 | ||
|
|
4c153da14d | ||
|
|
81166df987 | ||
|
|
ef189b00be | ||
|
|
27e56afd12 | ||
|
|
1c342621c0 | ||
|
|
9b7098cb58 | ||
|
|
9b247a6197 | ||
|
|
862d0a8f09 | ||
|
|
417996ade0 | ||
|
|
1c6161432a | ||
|
|
3f667ae4a8 | ||
|
|
4fb6de564c | ||
|
|
975693e96e | ||
|
|
b84233bf07 | ||
|
|
6dec2f9a13 | ||
|
|
c15e184bb8 | ||
|
|
89740dbbfd | ||
|
|
390a165c57 | ||
|
|
f7fb43b974 | ||
|
|
a32080fadf | ||
|
|
55017fbf70 | ||
|
|
7fd8a3a75b | ||
|
|
0f38c2ad82 |
No files matched your search
@@ -0,0 +1,43 @@
|
||||
name: Test recording recovery
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: &recording-paths
|
||||
- packages/build-tools/src/steps/utils/deviceRunSessionScreenRecordings.ts
|
||||
- packages/build-tools/src/steps/utils/deviceRunSessionArtifacts.ts
|
||||
- packages/build-tools/src/steps/utils/__tests__/*.test.ffmpeg.ts
|
||||
- packages/build-tools/jest/ffmpeg-config.ts
|
||||
- .github/workflows/test-recording-recovery.yml
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
paths: *recording-paths
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: test-recording-recovery-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
name: Test recording recovery with ffmpeg
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup tools
|
||||
uses: ./.github/actions/setup-mise
|
||||
- name: Install ffmpeg
|
||||
# The recording recovery test cuts and decodes real MP4 files with ffmpeg and ffprobe.
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Build build-tools dependencies
|
||||
# tsc emits the dist files that the test imports from workspace packages such as @expo/logger.
|
||||
run: yarn run -T lerna run typecheck --scope @expo/build-tools --include-dependencies
|
||||
- name: Run recording recovery test
|
||||
working-directory: packages/build-tools
|
||||
run: yarn jest-ffmpeg
|
||||
@@ -15,9 +15,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: ['20.18.3', '22', '24']
|
||||
node: ['22', '24']
|
||||
include:
|
||||
- node: '20.18.3'
|
||||
- node: '22'
|
||||
coverage: true
|
||||
name: Test with Node ${{ matrix.node }}
|
||||
steps:
|
||||
|
||||
+124
-1
@@ -8,10 +8,127 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--platform` to `eas workflow:insights:maestro` and list Maestro runs and flows per platform. ([#4613](https://github.com/expo/eas-cli/pull/4613) by [@hSATAC](https://github.com/hSATAC))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Allow more time for iOS screen recordings to finalize during session shutdown. ([#4626](https://github.com/expo/eas-cli/pull/4626) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Fix the `channel:protect` and `channel:unprotect` descriptions to include Release Managers and Owners, not only admins. ([#4616](https://github.com/expo/eas-cli/pull/4616) by [@byronkarlen](https://github.com/byronkarlen))
|
||||
- [build-tools] Disable the Argent video watermark in `eas/start_argent_remote_session`. ([#4574](https://github.com/expo/eas-cli/pull/4574) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Download the upterm binary through the build cache proxy. ([#4442](https://github.com/expo/eas-cli/pull/4442) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
## [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))
|
||||
- [build-tools] Open Android URLs with a VIEW intent instead of pinning them to the launch activity. ([#4559](https://github.com/expo/eas-cli/pull/4559) by [@kudo](https://github.com/kudo))
|
||||
|
||||
### 🧹 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
|
||||
@@ -19,6 +136,7 @@ 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))
|
||||
@@ -27,16 +145,20 @@ 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. ([#XXXX](https://github.com/expo/eas-cli/pull/XXXX) 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. ([#4400](https://github.com/expo/eas-cli/pull/4400) by [@wschurman](https://github.com/wschurman))
|
||||
- [build-tools] Use the website origin for simulator preview CORS and framing, and allow deploy previews and local website dev servers on staging. ([#4437](https://github.com/expo/eas-cli/pull/4437) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Report the web preview as `webPreviewUrl` and `webPreviewToken` in addition to the legacy serve-sim keys. ([#4467](https://github.com/expo/eas-cli/pull/4467) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [24.7.0](https://github.com/expo/eas-cli/releases/tag/v24.7.0) - 2026-09-16
|
||||
|
||||
@@ -148,6 +270,7 @@ 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
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ Each package has independent TypeScript compilation:
|
||||
|
||||
TypeScript configs:
|
||||
|
||||
- `tsconfig.json` - Base configuration (extends @tsconfig/node20)
|
||||
- `tsconfig.json` - Base configuration (extends @tsconfig/node22)
|
||||
- `tsconfig.build.json` - Production builds
|
||||
- `tsconfig.allowUnused.json` - Development with relaxed rules
|
||||
|
||||
|
||||
+1
-1
@@ -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-07-15
|
||||
Change Date: 2029-10-01
|
||||
|
||||
Change License: MIT
|
||||
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "24.8.0",
|
||||
"PRODUCTION": "24.8.0"
|
||||
"STAGING": "24.13.0",
|
||||
"PRODUCTION": "24.13.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "24.8.0",
|
||||
"version": "24.14.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -6,4 +6,4 @@ MISE_NODE_COREPACK = "true"
|
||||
actionlint = "1.7.12"
|
||||
gcloud = "570.0.0"
|
||||
jq = "1.8.1"
|
||||
node = "20.19.4"
|
||||
node = "22.23.3"
|
||||
+1
-1
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.11",
|
||||
"@types/node": "20.19.43",
|
||||
"@types/node": "22.20.5",
|
||||
"eslint-plugin-async-protect": "3.1.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-watch-typeahead": "2.2.2",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
bin/record-sim
|
||||
|
||||
.secrets*
|
||||
!.secrets.example
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Config } from '@jest/types';
|
||||
|
||||
import baseConfig from '../../../jest/jest.shared.config';
|
||||
|
||||
const config: Config.InitialOptions = {
|
||||
...baseConfig,
|
||||
rootDir: '../src',
|
||||
testMatch: ['**/__tests__/*.test.ffmpeg.ts'],
|
||||
clearMocks: true,
|
||||
setupFilesAfterEnv: ['<rootDir>/../jest/setup-tests.ts'],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "24.8.0",
|
||||
"version": "24.14.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -23,7 +23,6 @@
|
||||
"watch": "tsc --watch --preserveWatchOutput",
|
||||
"prebuild": "yarn gql",
|
||||
"build": "tsc",
|
||||
"build:record-sim": "mkdir -p bin && record_sim_bin_path=$(swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build --show-bin-path) && swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build && cp \"$record_sim_bin_path/record-sim\" bin/record-sim && chmod +x bin/record-sim",
|
||||
"build:egress-guard": "resources/egress-guard/build.sh",
|
||||
"test:egress-guard": "resources/egress-guard/tests/run-policy-tests.sh && resources/egress-guard/tests/run-guard-tests.sh",
|
||||
"typecheck": "tsc",
|
||||
@@ -31,6 +30,7 @@
|
||||
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
|
||||
"jest-unit": "jest --config jest/unit-config.ts",
|
||||
"jest-integration": "jest --config jest/integration-config.ts",
|
||||
"jest-ffmpeg": "jest --config jest/ffmpeg-config.ts",
|
||||
"jest-unit-watch": "jest --config jest/unit-config.ts --watch",
|
||||
"jest-integration-watch": "jest --config jest/integration-config.ts --watch",
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\"",
|
||||
@@ -41,17 +41,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "24.8.0",
|
||||
"@expo/eas-build-job": "24.8.0",
|
||||
"@expo/downloader": "24.13.0",
|
||||
"@expo/eas-build-job": "24.13.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "24.8.0",
|
||||
"@expo/logger": "24.13.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "24.8.0",
|
||||
"@expo/template-file": "24.8.0",
|
||||
"@expo/turtle-spawn": "24.8.0",
|
||||
"@expo/steps": "24.13.0",
|
||||
"@expo/template-file": "24.13.0",
|
||||
"@expo/turtle-spawn": "24.13.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
@@ -88,7 +88,7 @@
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.4",
|
||||
"@types/node": "20.19.43",
|
||||
"@types/node": "22.20.5",
|
||||
"@types/node-fetch": "^2.6.11",
|
||||
"@types/node-forge": "^1.3.11",
|
||||
"@types/plist": "^3.0.5",
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
.build/
|
||||
.swiftpm/
|
||||
DerivedData/
|
||||
.DS_Store
|
||||
@@ -1,28 +0,0 @@
|
||||
// swift-tools-version: 5.9
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "record-sim",
|
||||
platforms: [
|
||||
.macOS(.v14),
|
||||
],
|
||||
products: [
|
||||
.library(name: "RecordSim", targets: ["RecordSim"]),
|
||||
.executable(name: "record-sim", targets: ["record-sim"]),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "RecordSim",
|
||||
linkerSettings: [
|
||||
.linkedFramework("AVFoundation"),
|
||||
.linkedFramework("CoreGraphics"),
|
||||
.linkedFramework("CoreMedia"),
|
||||
.linkedFramework("CoreVideo"),
|
||||
.linkedFramework("IOSurface"),
|
||||
.linkedFramework("UniformTypeIdentifiers"),
|
||||
]
|
||||
),
|
||||
.executableTarget(name: "record-sim", dependencies: ["RecordSim"]),
|
||||
]
|
||||
)
|
||||
@@ -1,79 +0,0 @@
|
||||
# RecordSim
|
||||
|
||||
Small Swift library for non-exclusive iOS Simulator screen recording.
|
||||
|
||||
It implements one recording path:
|
||||
|
||||
1. Listen to SimRenderServer framebuffer callbacks.
|
||||
2. Copy the live `IOSurface` immediately into an owned `CVPixelBuffer`.
|
||||
3. Feed one continuous `AVAssetWriter`.
|
||||
4. Emit Apple HLS/CMAF-style fragmented MP4 segments.
|
||||
|
||||
The output is suitable for uploading while recording. `session.json` contains
|
||||
the ordered segment metadata needed to build a playlist later.
|
||||
|
||||
## Output
|
||||
|
||||
```text
|
||||
session/
|
||||
init.mp4
|
||||
session.json
|
||||
segments/
|
||||
segment-000000.m4s
|
||||
segment-000001.m4s
|
||||
```
|
||||
|
||||
`init.mp4` is the fMP4 initialization segment. Individual `.m4s` files are not
|
||||
standalone MP4 files. `session.json` stores the wall-clock timestamp for the
|
||||
first video frame plus the metadata needed to build a playlist: HLS version,
|
||||
target duration, media sequence, `initSegment`, and the ordered `segments` array
|
||||
with file path and duration for each media segment.
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
swift run record-sim \
|
||||
--udid <BOOTED_SIMULATOR_UDID> \
|
||||
--output /tmp/sim-recording \
|
||||
--segment-duration 120 \
|
||||
--fps 30 \
|
||||
--bitrate 30000000 \
|
||||
--codec h264
|
||||
```
|
||||
|
||||
For sparse 2h sessions, `--segment-duration 120` is a reasonable default: about
|
||||
60 media objects plus the init segment. The recorder appends a single duplicate
|
||||
hold frame near each segment boundary when the simulator is idle, so long idle
|
||||
periods still produce uploadable segments without encoding idle frames at 60fps.
|
||||
Use `--segment-duration 0` to write one continuous `recording.mp4` instead of
|
||||
`init.mp4` plus media segments.
|
||||
|
||||
Frame timestamps use monotonic host time, not wall-clock time. The recorder also
|
||||
probes the framebuffer seed once per second; if the surface changes without
|
||||
callbacks for 5 seconds, it captures that frame and rewires the private callback
|
||||
registration.
|
||||
|
||||
## Library
|
||||
|
||||
```swift
|
||||
let recorder = SimulatorRecorder(
|
||||
configuration: SimulatorRecordingConfiguration(
|
||||
deviceUDID: udid,
|
||||
outputDirectory: sessionDirectory,
|
||||
segmentDuration: 120
|
||||
)
|
||||
)
|
||||
|
||||
recorder.onSegment = { segment in
|
||||
// Enqueue upload of segment.url.
|
||||
}
|
||||
|
||||
try recorder.start()
|
||||
try recorder.waitUntilFirstFrame()
|
||||
// ...
|
||||
let manifest = try recorder.stop()
|
||||
```
|
||||
|
||||
`session.json` contains the precise wall-clock time for video PTS zero. In
|
||||
segmented mode it also contains the init segment and ordered media segment
|
||||
file/duration entries; in single-file mode it points at `recording.mp4`.
|
||||
-192
@@ -1,192 +0,0 @@
|
||||
import Foundation
|
||||
import IOSurface
|
||||
import ObjectiveC
|
||||
|
||||
final class FramebufferDisplaySource {
|
||||
struct SurfaceSnapshot {
|
||||
let surface: IOSurface
|
||||
let width: Int
|
||||
let height: Int
|
||||
let seed: UInt32
|
||||
}
|
||||
|
||||
private let deviceUDID: String
|
||||
private let callbackQueue: DispatchQueue
|
||||
private let onFrame: () -> Void
|
||||
private let onSurfaceChange: () -> Void
|
||||
private var descriptors: [NSObject] = []
|
||||
private var callbackUUIDs: [ObjectIdentifier: NSUUID] = [:]
|
||||
private var retainedBlocks: [AnyObject] = []
|
||||
private var ioClient: NSObject?
|
||||
|
||||
init(
|
||||
deviceUDID: String,
|
||||
callbackQueue: DispatchQueue,
|
||||
onFrame: @escaping () -> Void,
|
||||
onSurfaceChange: @escaping () -> Void
|
||||
) {
|
||||
self.deviceUDID = deviceUDID
|
||||
self.callbackQueue = callbackQueue
|
||||
self.onFrame = onFrame
|
||||
self.onSurfaceChange = onSurfaceChange
|
||||
}
|
||||
|
||||
func start() throws {
|
||||
try PrivateSimulatorFrameworks.load()
|
||||
guard let device = SimulatorDeviceLookup.find(udid: deviceUDID) else {
|
||||
throw RecorderError.make(1, "Simulator \(deviceUDID) not found")
|
||||
}
|
||||
let state = device.value(forKey: "stateString") as? String ?? "unknown"
|
||||
guard state == "Booted" else {
|
||||
throw RecorderError.make(2, "Simulator \(deviceUDID) is not booted (state: \(state))")
|
||||
}
|
||||
guard
|
||||
let io = device.perform(NSSelectorFromString("io"))?.takeUnretainedValue() as? NSObject
|
||||
else {
|
||||
throw RecorderError.make(3, "Failed to get simulator IO client")
|
||||
}
|
||||
ioClient = io
|
||||
try wireUpFramebuffer()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
unregisterCallbacks()
|
||||
descriptors.removeAll()
|
||||
retainedBlocks.removeAll()
|
||||
ioClient = nil
|
||||
}
|
||||
|
||||
func surfaceSnapshot() -> SurfaceSnapshot? {
|
||||
let surfaceSelector = NSSelectorFromString("framebufferSurface")
|
||||
var bestSnapshot: SurfaceSnapshot?
|
||||
var bestArea = 0
|
||||
for descriptor in descriptors {
|
||||
guard let surfaceObject = descriptor.perform(surfaceSelector)?.takeUnretainedValue()
|
||||
else {
|
||||
continue
|
||||
}
|
||||
let surface = unsafeBitCast(surfaceObject, to: IOSurface.self)
|
||||
let width = IOSurfaceGetWidth(surface)
|
||||
let height = IOSurfaceGetHeight(surface)
|
||||
let area = width * height
|
||||
if area > bestArea {
|
||||
bestSnapshot = SurfaceSnapshot(
|
||||
surface: surface,
|
||||
width: width,
|
||||
height: height,
|
||||
seed: IOSurfaceGetSeed(surface)
|
||||
)
|
||||
bestArea = area
|
||||
}
|
||||
}
|
||||
return bestSnapshot
|
||||
}
|
||||
|
||||
func rewireFramebuffer() throws {
|
||||
try wireUpFramebuffer()
|
||||
}
|
||||
|
||||
private func wireUpFramebuffer() throws {
|
||||
guard let io = ioClient else {
|
||||
throw RecorderError.make(3, "No simulator IO client")
|
||||
}
|
||||
io.perform(NSSelectorFromString("updateIOPorts"))
|
||||
let nextDescriptors = try findFramebufferDescriptors(io: io)
|
||||
unregisterCallbacks()
|
||||
descriptors = nextDescriptors
|
||||
retainedBlocks.removeAll()
|
||||
do {
|
||||
for descriptor in descriptors {
|
||||
try registerCallbacks(descriptor: descriptor)
|
||||
}
|
||||
} catch {
|
||||
unregisterCallbacks()
|
||||
descriptors.removeAll()
|
||||
retainedBlocks.removeAll()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func findFramebufferDescriptors(io: NSObject) throws -> [NSObject] {
|
||||
guard let ports = io.value(forKey: "deviceIOPorts") as? [NSObject] else {
|
||||
throw RecorderError.make(4, "Failed to read simulator IO ports")
|
||||
}
|
||||
let portIdentifierSelector = NSSelectorFromString("portIdentifier")
|
||||
let descriptorSelector = NSSelectorFromString("descriptor")
|
||||
let surfaceSelector = NSSelectorFromString("framebufferSurface")
|
||||
|
||||
var candidates: [NSObject] = []
|
||||
for port in ports {
|
||||
guard port.responds(to: portIdentifierSelector),
|
||||
let portIdentifier = port.perform(portIdentifierSelector)?.takeUnretainedValue(),
|
||||
"\(portIdentifier)" == "com.apple.framebuffer.display",
|
||||
port.responds(to: descriptorSelector),
|
||||
let descriptor = port.perform(descriptorSelector)?.takeUnretainedValue()
|
||||
as? NSObject,
|
||||
descriptor.responds(to: surfaceSelector)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
candidates.append(descriptor)
|
||||
}
|
||||
if candidates.isEmpty {
|
||||
throw RecorderError.make(5, "No framebuffer display descriptor found")
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
private func registerCallbacks(descriptor: NSObject) throws {
|
||||
let selector = NSSelectorFromString(
|
||||
"registerScreenCallbacksWithUUID:callbackQueue:frameCallback:surfacesChangedCallback:propertiesChangedCallback:"
|
||||
)
|
||||
guard descriptor.responds(to: selector) else {
|
||||
throw RecorderError.make(6, "Framebuffer descriptor does not support screen callbacks")
|
||||
}
|
||||
guard let msgSendPointer = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "objc_msgSend")
|
||||
else {
|
||||
throw RecorderError.make(7, "objc_msgSend not found")
|
||||
}
|
||||
typealias MsgSend =
|
||||
@convention(c) (
|
||||
AnyObject, Selector, AnyObject, AnyObject, AnyObject, AnyObject, AnyObject
|
||||
) -> Void
|
||||
let msgSend = unsafeBitCast(msgSendPointer, to: MsgSend.self)
|
||||
|
||||
let uuid = NSUUID()
|
||||
callbackUUIDs[ObjectIdentifier(descriptor)] = uuid
|
||||
|
||||
let frameCallback: @convention(block) () -> Void = { [weak self] in
|
||||
self?.onFrame()
|
||||
}
|
||||
let surfacesCallback: @convention(block) (AnyObject?, AnyObject?) -> Void = {
|
||||
[weak self] _, _ in
|
||||
self?.onSurfaceChange()
|
||||
}
|
||||
let propertiesCallback: @convention(block) () -> Void = {}
|
||||
retainedBlocks.append(frameCallback as AnyObject)
|
||||
retainedBlocks.append(surfacesCallback as AnyObject)
|
||||
retainedBlocks.append(propertiesCallback as AnyObject)
|
||||
|
||||
msgSend(
|
||||
descriptor,
|
||||
selector,
|
||||
uuid,
|
||||
callbackQueue as AnyObject,
|
||||
frameCallback as AnyObject,
|
||||
surfacesCallback as AnyObject,
|
||||
propertiesCallback as AnyObject
|
||||
)
|
||||
}
|
||||
|
||||
private func unregisterCallbacks() {
|
||||
let selector = NSSelectorFromString("unregisterScreenCallbacksWithUUID:")
|
||||
for descriptor in descriptors {
|
||||
if let uuid = callbackUUIDs[ObjectIdentifier(descriptor)],
|
||||
descriptor.responds(to: selector)
|
||||
{
|
||||
descriptor.perform(selector, with: uuid)
|
||||
}
|
||||
}
|
||||
callbackUUIDs.removeAll()
|
||||
}
|
||||
}
|
||||
-96
@@ -1,96 +0,0 @@
|
||||
import Foundation
|
||||
import ObjectiveC
|
||||
|
||||
enum XcodeDeveloperDirectory {
|
||||
static let current: String = {
|
||||
if let developerDir = ProcessInfo.processInfo.environment["DEVELOPER_DIR"],
|
||||
!developerDir.isEmpty
|
||||
{
|
||||
return developerDir
|
||||
}
|
||||
|
||||
let fallback = "/Applications/Xcode.app/Contents/Developer"
|
||||
let pipe = Pipe()
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/xcode-select")
|
||||
process.arguments = ["-p"]
|
||||
process.standardOutput = pipe
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
process.waitUntilExit()
|
||||
return String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? fallback
|
||||
}()
|
||||
}
|
||||
|
||||
enum PrivateSimulatorFrameworks {
|
||||
static func load() throws {
|
||||
try loadResult.get()
|
||||
}
|
||||
|
||||
private static let loadResult: Result<Void, Error> = {
|
||||
let developerDir = XcodeDeveloperDirectory.current
|
||||
let candidates = [
|
||||
"/Library/Developer/PrivateFrameworks/CoreSimulator.framework/CoreSimulator",
|
||||
"\(developerDir)/Library/PrivateFrameworks/CoreSimulator.framework/CoreSimulator",
|
||||
"\(developerDir)/../SharedFrameworks/SimulatorKit.framework/SimulatorKit",
|
||||
"\(developerDir)/Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit",
|
||||
]
|
||||
var failures: [String] = []
|
||||
for path in candidates {
|
||||
if dlopen(path, RTLD_NOW) == nil {
|
||||
let message = dlerror().map { String(cString: $0) } ?? "unknown error"
|
||||
failures.append("\(path): \(message)")
|
||||
}
|
||||
}
|
||||
guard NSClassFromString("SimServiceContext") != nil else {
|
||||
let tried = candidates.joined(separator: ", ")
|
||||
let errors = failures.isEmpty ? "no dlopen errors" : failures.joined(separator: "; ")
|
||||
return .failure(
|
||||
RecorderError.make(
|
||||
8,
|
||||
"Failed to load CoreSimulator/SimulatorKit (tried: \(tried); errors: \(errors))"
|
||||
)
|
||||
)
|
||||
}
|
||||
return .success(())
|
||||
}()
|
||||
}
|
||||
|
||||
enum SimulatorDeviceLookup {
|
||||
private static let context: NSObject? = {
|
||||
guard let contextClass = NSClassFromString("SimServiceContext") as? NSObject.Type else {
|
||||
return nil
|
||||
}
|
||||
let sharedSelector = NSSelectorFromString("sharedServiceContextForDeveloperDir:error:")
|
||||
return contextClass.perform(
|
||||
sharedSelector, with: XcodeDeveloperDirectory.current, with: nil)?
|
||||
.takeUnretainedValue() as? NSObject
|
||||
}()
|
||||
|
||||
static func find(udid: String) -> NSObject? {
|
||||
guard let context else {
|
||||
return nil
|
||||
}
|
||||
let deviceSetSelector = NSSelectorFromString("defaultDeviceSetWithError:")
|
||||
guard
|
||||
let deviceSet = context.perform(deviceSetSelector, with: nil)?
|
||||
.takeUnretainedValue() as? NSObject
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
guard let devices = deviceSet.value(forKey: "devices") as? [NSObject] else {
|
||||
return nil
|
||||
}
|
||||
return devices.first {
|
||||
($0.value(forKey: "UDID") as? NSUUID)?.uuidString == udid
|
||||
}
|
||||
}
|
||||
|
||||
static func stateString(udid: String) -> String? {
|
||||
find(udid: udid)?.value(forKey: "stateString") as? String
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public enum RecorderCodec: String, Codable, Sendable {
|
||||
case h264
|
||||
case hevc
|
||||
}
|
||||
|
||||
public struct SimulatorRecordingConfiguration: Sendable {
|
||||
public var deviceUDID: String
|
||||
public var outputDirectory: URL
|
||||
public var fps: Int
|
||||
public var bitrate: Int
|
||||
public var codec: RecorderCodec
|
||||
public var segmentDuration: TimeInterval
|
||||
public var maxPendingFrames: Int
|
||||
public var overwrite: Bool
|
||||
|
||||
public init(
|
||||
deviceUDID: String,
|
||||
outputDirectory: URL,
|
||||
fps: Int = 30,
|
||||
bitrate: Int = 30_000_000,
|
||||
codec: RecorderCodec = .h264,
|
||||
segmentDuration: TimeInterval = 120,
|
||||
maxPendingFrames: Int = 8,
|
||||
overwrite: Bool = true
|
||||
) {
|
||||
self.deviceUDID = deviceUDID
|
||||
self.outputDirectory = outputDirectory
|
||||
self.fps = fps
|
||||
self.bitrate = bitrate
|
||||
self.codec = codec
|
||||
self.segmentDuration = segmentDuration
|
||||
self.maxPendingFrames = maxPendingFrames
|
||||
self.overwrite = overwrite
|
||||
}
|
||||
}
|
||||
|
||||
public enum SegmentKind: String, Codable, Sendable {
|
||||
case initialization
|
||||
case media
|
||||
}
|
||||
|
||||
public struct SegmentOutput: Sendable {
|
||||
public let kind: SegmentKind
|
||||
public let relativePath: String
|
||||
public let url: URL
|
||||
public let byteCount: Int
|
||||
public let durationSeconds: Double?
|
||||
}
|
||||
|
||||
public struct MediaSegmentRecord: Codable, Sendable {
|
||||
public let file: String
|
||||
public let durationSeconds: Double
|
||||
}
|
||||
|
||||
public struct FirstFrameWallClock: Codable, Sendable {
|
||||
public let unixMs: Int64
|
||||
public let iso8601: String
|
||||
}
|
||||
|
||||
public enum RecordingFinalizationStage: Sendable {
|
||||
case captureStopped
|
||||
case videoSaved
|
||||
}
|
||||
|
||||
public struct RecordingManifest: Codable, Sendable {
|
||||
public let firstFrameWallClock: FirstFrameWallClock
|
||||
public let width: Int
|
||||
public let height: Int
|
||||
public let hlsVersion: Int?
|
||||
public let hlsTargetDurationSeconds: Int?
|
||||
public let hlsMediaSequence: Int?
|
||||
public let recording: String?
|
||||
public let initSegment: String?
|
||||
public let segments: [MediaSegmentRecord]
|
||||
}
|
||||
-140
@@ -1,140 +0,0 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
final class RecordingOutputWriter: NSObject, AVAssetWriterDelegate {
|
||||
private let rootDirectory: URL
|
||||
private let segmentsDirectory: URL
|
||||
let singleRecordingURL: URL
|
||||
private let stateQueue = DispatchQueue(label: "record-sim.segment-writer")
|
||||
private let notificationQueue = DispatchQueue(label: "record-sim.segment-notifications")
|
||||
private let onSegment: ((SegmentOutput) -> Void)?
|
||||
private var initSegmentPath: String?
|
||||
private var segments: [MediaSegmentRecord] = []
|
||||
private var nextMediaSegmentIndex = 0
|
||||
private var firstError: Error?
|
||||
|
||||
init(rootDirectory: URL, onSegment: ((SegmentOutput) -> Void)?) {
|
||||
self.rootDirectory = rootDirectory
|
||||
self.segmentsDirectory = rootDirectory.appendingPathComponent("segments", isDirectory: true)
|
||||
self.singleRecordingURL = rootDirectory.appendingPathComponent("recording.mp4")
|
||||
self.onSegment = onSegment
|
||||
super.init()
|
||||
}
|
||||
|
||||
func prepare(overwrite: Bool, segmented: Bool) throws {
|
||||
if overwrite {
|
||||
try? FileManager.default.removeItem(at: rootDirectory)
|
||||
}
|
||||
try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true)
|
||||
if segmented {
|
||||
try FileManager.default.createDirectory(at: segmentsDirectory, withIntermediateDirectories: true)
|
||||
}
|
||||
}
|
||||
|
||||
var error: Error? {
|
||||
stateQueue.sync { firstError }
|
||||
}
|
||||
|
||||
func assetWriter(
|
||||
_ writer: AVAssetWriter,
|
||||
didOutputSegmentData segmentData: Data,
|
||||
segmentType: AVAssetSegmentType,
|
||||
segmentReport: AVAssetSegmentReport?
|
||||
) {
|
||||
var notification: SegmentOutput?
|
||||
|
||||
stateQueue.sync {
|
||||
if firstError != nil {
|
||||
return
|
||||
}
|
||||
do {
|
||||
switch segmentType {
|
||||
case .initialization:
|
||||
let relativePath = "init.mp4"
|
||||
let url = rootDirectory.appendingPathComponent(relativePath)
|
||||
try segmentData.write(to: url, options: .atomic)
|
||||
initSegmentPath = relativePath
|
||||
notification = SegmentOutput(
|
||||
kind: .initialization,
|
||||
relativePath: relativePath,
|
||||
url: url,
|
||||
byteCount: segmentData.count,
|
||||
durationSeconds: nil
|
||||
)
|
||||
case .separable:
|
||||
let index = nextMediaSegmentIndex
|
||||
let relativePath = String(format: "segments/segment-%06d.m4s", index)
|
||||
let url = rootDirectory.appendingPathComponent(relativePath)
|
||||
try segmentData.write(to: url, options: .atomic)
|
||||
|
||||
let videoReport = segmentReport?.trackReports.first(where: { $0.mediaType == .video })
|
||||
let duration = videoReport.flatMap { timeSeconds($0.duration) } ?? 0
|
||||
let record = MediaSegmentRecord(
|
||||
file: relativePath,
|
||||
durationSeconds: duration
|
||||
)
|
||||
nextMediaSegmentIndex += 1
|
||||
segments.append(record)
|
||||
notification = SegmentOutput(
|
||||
kind: .media,
|
||||
relativePath: relativePath,
|
||||
url: url,
|
||||
byteCount: segmentData.count,
|
||||
durationSeconds: duration
|
||||
)
|
||||
default:
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
firstError = error
|
||||
}
|
||||
}
|
||||
|
||||
if let notification, let onSegment {
|
||||
notificationQueue.async {
|
||||
onSegment(notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func writeManifest(
|
||||
configuration: SimulatorRecordingConfiguration,
|
||||
firstFrameWallClock: Date,
|
||||
width: Int,
|
||||
height: Int
|
||||
) throws -> RecordingManifest {
|
||||
try stateQueue.sync {
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
let segmented = configuration.segmentDuration > 0
|
||||
if segmented, initSegmentPath == nil {
|
||||
throw RecorderError.make(50, "Missing initialization segment")
|
||||
}
|
||||
if segmented, segments.isEmpty {
|
||||
throw RecorderError.make(51, "No media segments were written")
|
||||
}
|
||||
let targetDuration = segmented
|
||||
? max(1, Int(ceil(segments.map(\.durationSeconds).max() ?? configuration.segmentDuration)))
|
||||
: nil
|
||||
let manifest = RecordingManifest(
|
||||
firstFrameWallClock: FirstFrameWallClock(
|
||||
unixMs: unixMs(firstFrameWallClock),
|
||||
iso8601: iso8601(firstFrameWallClock)
|
||||
),
|
||||
width: width,
|
||||
height: height,
|
||||
hlsVersion: segmented ? 7 : nil,
|
||||
hlsTargetDurationSeconds: targetDuration,
|
||||
hlsMediaSequence: segmented ? 0 : nil,
|
||||
recording: segmented ? nil : "recording.mp4",
|
||||
initSegment: segmented ? initSegmentPath : nil,
|
||||
segments: segments
|
||||
)
|
||||
let data = try JSONEncoder.prettySorted.encode(manifest)
|
||||
try data.write(to: rootDirectory.appendingPathComponent("session.json"), options: .atomic)
|
||||
return manifest
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,644 +0,0 @@
|
||||
import AVFoundation
|
||||
import CoreVideo
|
||||
import Foundation
|
||||
import IOSurface
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
public final class SimulatorRecorder {
|
||||
public var onSegment: ((SegmentOutput) -> Void)?
|
||||
public var onSimulatorStopped: ((String) -> Void)?
|
||||
public var onFinalizationStage: ((RecordingFinalizationStage) -> Void)?
|
||||
|
||||
private static let callbackStalenessTimeout: TimeInterval = 5
|
||||
private static let firstFrameRewireInterval: TimeInterval = 1
|
||||
private let configuration: SimulatorRecordingConfiguration
|
||||
private let callbackQueue = DispatchQueue(
|
||||
label: "record-sim.frame-callbacks", qos: .userInteractive)
|
||||
private let writerQueue = DispatchQueue(label: "record-sim.asset-writer", qos: .userInitiated)
|
||||
private let eventQueue = DispatchQueue(label: "record-sim.events", qos: .utility)
|
||||
private let pendingLock = NSLock()
|
||||
private var pendingFrames = 0
|
||||
private var displaySource: FramebufferDisplaySource?
|
||||
private var outputWriter: RecordingOutputWriter?
|
||||
private var writer: AVAssetWriter?
|
||||
private var input: AVAssetWriterInput?
|
||||
private var adaptor: AVAssetWriterInputPixelBufferAdaptor?
|
||||
private var boundaryTimer: DispatchSourceTimer?
|
||||
private var monotonicClock = MonotonicClock()
|
||||
private var firstAcceptedCaptureTime: CMTime?
|
||||
private var firstAcceptedWallClock: Date?
|
||||
private var recordingWidth: Int?
|
||||
private var recordingHeight: Int?
|
||||
private var lastPTS: CMTime?
|
||||
private var lastSeed: UInt32?
|
||||
private var lastFrameCallbackElapsed: TimeInterval?
|
||||
private var lastFirstFrameRewireElapsed: TimeInterval = 0
|
||||
private var lastAppendedPixelBuffer: CVPixelBuffer?
|
||||
private var nextBoundaryElapsed: TimeInterval = 0
|
||||
private var simulatorStoppedReason: String?
|
||||
private var stopped = false
|
||||
private var firstFrameReady = false
|
||||
private let firstFrameSemaphore = DispatchSemaphore(value: 0)
|
||||
private var firstError: Error?
|
||||
|
||||
public init(configuration: SimulatorRecordingConfiguration) {
|
||||
self.configuration = configuration
|
||||
}
|
||||
|
||||
public func start() throws {
|
||||
try validateConfiguration()
|
||||
|
||||
let outputWriter = RecordingOutputWriter(
|
||||
rootDirectory: configuration.outputDirectory,
|
||||
onSegment: { [weak self] segment in
|
||||
self?.onSegment?(segment)
|
||||
})
|
||||
try outputWriter.prepare(
|
||||
overwrite: configuration.overwrite, segmented: configuration.segmentDuration > 0)
|
||||
self.outputWriter = outputWriter
|
||||
|
||||
monotonicClock = MonotonicClock()
|
||||
nextBoundaryElapsed = configuration.segmentDuration
|
||||
lastFirstFrameRewireElapsed = 0
|
||||
stopped = false
|
||||
|
||||
let source = FramebufferDisplaySource(
|
||||
deviceUDID: configuration.deviceUDID,
|
||||
callbackQueue: callbackQueue,
|
||||
onFrame: { [weak self] in
|
||||
self?.captureFrame(force: false, reason: .callback)
|
||||
},
|
||||
onSurfaceChange: { [weak self] in
|
||||
self?.captureFrame(force: true, reason: .surfaceChange)
|
||||
}
|
||||
)
|
||||
try source.start()
|
||||
displaySource = source
|
||||
|
||||
startBoundaryTimer()
|
||||
}
|
||||
|
||||
public func waitUntilFirstFrame(timeoutSeconds: TimeInterval = 15) throws {
|
||||
let alreadyReady = try writerQueue.sync {
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
return firstFrameReady
|
||||
}
|
||||
if alreadyReady {
|
||||
return
|
||||
}
|
||||
if firstFrameSemaphore.wait(timeout: .now() + timeoutSeconds) == .timedOut {
|
||||
throw RecorderError.make(20, "Timed out waiting for first frame")
|
||||
}
|
||||
try writerQueue.sync {
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
if !firstFrameReady {
|
||||
throw RecorderError.make(21, "Recorder stopped before first frame")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func simulatorStopReason() -> String? {
|
||||
callbackQueue.sync {
|
||||
simulatorStoppedReason
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func stop() throws -> RecordingManifest {
|
||||
callbackQueue.sync {
|
||||
stopped = true
|
||||
boundaryTimer?.cancel()
|
||||
boundaryTimer = nil
|
||||
displaySource?.stop()
|
||||
displaySource = nil
|
||||
}
|
||||
onFinalizationStage?(.captureStopped)
|
||||
|
||||
return try writerQueue.sync {
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
guard let writer, let input, let outputWriter else {
|
||||
let error = RecorderError.make(22, "No frames were captured")
|
||||
firstError = error
|
||||
signalFirstFrameReadyIfNeeded()
|
||||
throw error
|
||||
}
|
||||
try appendTailFrameIfNeeded(finalPTS: monotonicClock.elapsedTime())
|
||||
input.markAsFinished()
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
writer.finishWriting {
|
||||
semaphore.signal()
|
||||
}
|
||||
if semaphore.wait(timeout: .now() + 60) == .timedOut {
|
||||
throw RecorderError.make(23, "Timed out finishing AVAssetWriter")
|
||||
}
|
||||
if writer.status == .failed {
|
||||
throw writer.error ?? RecorderError.make(24, "AVAssetWriter failed")
|
||||
}
|
||||
if let error = outputWriter.error {
|
||||
throw error
|
||||
}
|
||||
onFinalizationStage?(.videoSaved)
|
||||
guard let firstAcceptedWallClock else {
|
||||
throw RecorderError.make(25, "Missing first frame wall-clock timestamp")
|
||||
}
|
||||
guard let recordingWidth, let recordingHeight else {
|
||||
throw RecorderError.make(27, "Missing recording dimensions")
|
||||
}
|
||||
return try outputWriter.writeManifest(
|
||||
configuration: configuration,
|
||||
firstFrameWallClock: firstAcceptedWallClock,
|
||||
width: recordingWidth,
|
||||
height: recordingHeight
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private enum CaptureReason {
|
||||
case callback
|
||||
case surfaceChange
|
||||
case boundary
|
||||
case healthProbe
|
||||
}
|
||||
|
||||
private func validateConfiguration() throws {
|
||||
guard !configuration.deviceUDID.isEmpty else {
|
||||
throw RecorderError.make(30, "deviceUDID must not be empty")
|
||||
}
|
||||
guard configuration.fps > 0, configuration.fps <= 120 else {
|
||||
throw RecorderError.make(31, "fps must be between 1 and 120")
|
||||
}
|
||||
guard configuration.bitrate > 0 else {
|
||||
throw RecorderError.make(32, "bitrate must be positive")
|
||||
}
|
||||
guard configuration.segmentDuration >= 0 else {
|
||||
throw RecorderError.make(33, "segmentDuration must be non-negative")
|
||||
}
|
||||
guard configuration.maxPendingFrames > 0 else {
|
||||
throw RecorderError.make(34, "maxPendingFrames must be positive")
|
||||
}
|
||||
}
|
||||
|
||||
private func startBoundaryTimer() {
|
||||
let timer = DispatchSource.makeTimerSource(queue: callbackQueue)
|
||||
timer.schedule(deadline: .now() + 1, repeating: 1, leeway: .milliseconds(100))
|
||||
timer.setEventHandler { [weak self] in
|
||||
self?.probeSimulatorState()
|
||||
self?.probeFramebufferHealth()
|
||||
self?.appendBoundaryFrameIfNeeded()
|
||||
}
|
||||
timer.resume()
|
||||
boundaryTimer = timer
|
||||
}
|
||||
|
||||
private func appendBoundaryFrameIfNeeded() {
|
||||
if stopped {
|
||||
return
|
||||
}
|
||||
guard configuration.segmentDuration > 0 else {
|
||||
return
|
||||
}
|
||||
writerQueue.sync {
|
||||
guard firstAcceptedCaptureTime != nil else {
|
||||
return
|
||||
}
|
||||
let currentPTSSeconds = CMTimeGetSeconds(
|
||||
normalizedPresentationTime(for: monotonicClock.elapsedTime()))
|
||||
guard currentPTSSeconds >= nextBoundaryElapsed else {
|
||||
return
|
||||
}
|
||||
let boundary = nextBoundaryElapsed
|
||||
guard let lastPTS else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
if CMTimeGetSeconds(lastPTS) < boundary {
|
||||
let boundaryPTS = CMTime(seconds: boundary, preferredTimescale: 1_000_000_000)
|
||||
guard try appendHeldFrame(at: boundaryPTS) else {
|
||||
return
|
||||
}
|
||||
}
|
||||
nextBoundaryElapsed = boundary + configuration.segmentDuration
|
||||
} catch {
|
||||
firstError = error
|
||||
signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func captureFrame(force: Bool, reason: CaptureReason) {
|
||||
if stopped {
|
||||
return
|
||||
}
|
||||
if reason == .callback {
|
||||
lastFrameCallbackElapsed = monotonicClock.elapsedSeconds()
|
||||
}
|
||||
guard let snapshot = displaySource?.surfaceSnapshot() else {
|
||||
return
|
||||
}
|
||||
let surface = snapshot.surface
|
||||
let surfaceWidth = snapshot.width
|
||||
let surfaceHeight = snapshot.height
|
||||
guard surfaceWidth > 0, surfaceHeight > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
let seed = snapshot.seed
|
||||
let matchesLastAppendedSeed = writerQueue.sync { lastSeed == seed }
|
||||
if !force, matchesLastAppendedSeed {
|
||||
return
|
||||
}
|
||||
|
||||
guard reservePendingFrame() else {
|
||||
return
|
||||
}
|
||||
|
||||
let capturedAt = monotonicClock.elapsedTime()
|
||||
let capturedAtWallClock = Date()
|
||||
do {
|
||||
let pixelBuffer = try copySurfaceToOwnedPixelBuffer(
|
||||
surface,
|
||||
width: surfaceWidth,
|
||||
height: surfaceHeight
|
||||
)
|
||||
appendOwned(
|
||||
pixelBuffer: pixelBuffer,
|
||||
seed: seed,
|
||||
capturedAt: capturedAt,
|
||||
capturedAtWallClock: capturedAtWallClock
|
||||
)
|
||||
} catch {
|
||||
releasePendingFrame()
|
||||
writerQueue.async {
|
||||
self.firstError = error
|
||||
self.signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func probeFramebufferHealth() {
|
||||
guard !stopped, let displaySource else {
|
||||
return
|
||||
}
|
||||
let elapsed = monotonicClock.elapsedSeconds()
|
||||
if !isFirstFrameReady(),
|
||||
elapsed - lastFirstFrameRewireElapsed >= Self.firstFrameRewireInterval
|
||||
{
|
||||
lastFirstFrameRewireElapsed = elapsed
|
||||
rewireFramebuffer()
|
||||
captureFrame(force: true, reason: .healthProbe)
|
||||
return
|
||||
}
|
||||
|
||||
guard let snapshot = displaySource.surfaceSnapshot(),
|
||||
snapshot.width > 0,
|
||||
snapshot.height > 0
|
||||
else {
|
||||
rewireFramebuffer()
|
||||
return
|
||||
}
|
||||
|
||||
let observedSeed = snapshot.seed
|
||||
let lastAppendedSeed = writerQueue.sync { lastSeed }
|
||||
guard let lastAppendedSeed, observedSeed != lastAppendedSeed else {
|
||||
return
|
||||
}
|
||||
|
||||
let lastCallback = lastFrameCallbackElapsed ?? 0
|
||||
if elapsed - lastCallback >= Self.callbackStalenessTimeout {
|
||||
captureFrame(force: false, reason: .healthProbe)
|
||||
rewireFramebuffer()
|
||||
}
|
||||
}
|
||||
|
||||
private func probeSimulatorState() {
|
||||
guard !stopped, simulatorStoppedReason == nil else {
|
||||
return
|
||||
}
|
||||
let state = SimulatorDeviceLookup.stateString(udid: configuration.deviceUDID) ?? "missing"
|
||||
guard state != "Booted" else {
|
||||
return
|
||||
}
|
||||
simulatorStoppedReason = state
|
||||
let error = RecorderError.make(26, "Simulator stopped before first frame (state: \(state))")
|
||||
writerQueue.async {
|
||||
if self.firstError == nil, !self.firstFrameReady {
|
||||
self.firstError = error
|
||||
self.signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
}
|
||||
let onSimulatorStopped = onSimulatorStopped
|
||||
eventQueue.async {
|
||||
onSimulatorStopped?(state)
|
||||
}
|
||||
}
|
||||
|
||||
private func rewireFramebuffer() {
|
||||
guard let displaySource else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try displaySource.rewireFramebuffer()
|
||||
} catch {
|
||||
writerQueue.async {
|
||||
self.firstError = error
|
||||
self.signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reservePendingFrame() -> Bool {
|
||||
pendingLock.lock()
|
||||
defer { pendingLock.unlock() }
|
||||
if pendingFrames >= configuration.maxPendingFrames {
|
||||
return false
|
||||
}
|
||||
pendingFrames += 1
|
||||
return true
|
||||
}
|
||||
|
||||
private func releasePendingFrame() {
|
||||
pendingLock.lock()
|
||||
pendingFrames = max(0, pendingFrames - 1)
|
||||
pendingLock.unlock()
|
||||
}
|
||||
|
||||
private func copySurfaceToOwnedPixelBuffer(
|
||||
_ surface: IOSurface,
|
||||
width: Int,
|
||||
height: Int
|
||||
) throws -> CVPixelBuffer {
|
||||
let pool = try writerQueue.sync {
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
if writer == nil {
|
||||
try startWriter(width: width, height: height)
|
||||
}
|
||||
guard let pool = adaptor?.pixelBufferPool else {
|
||||
throw RecorderError.make(40, "AVAssetWriter pixel buffer pool is unavailable")
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
var output: CVPixelBuffer?
|
||||
let status = CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &output)
|
||||
guard status == kCVReturnSuccess, let destination = output else {
|
||||
throw RecorderError.make(41, "Failed to allocate pixel buffer from pool: \(status)")
|
||||
}
|
||||
|
||||
let destinationLockStatus = CVPixelBufferLockBaseAddress(destination, [])
|
||||
guard destinationLockStatus == kCVReturnSuccess else {
|
||||
throw RecorderError.make(50, "Failed to lock pixel buffer: \(destinationLockStatus)")
|
||||
}
|
||||
let surfaceLockStatus = IOSurfaceLock(surface, .readOnly, nil)
|
||||
guard surfaceLockStatus == KERN_SUCCESS else {
|
||||
CVPixelBufferUnlockBaseAddress(destination, [])
|
||||
throw RecorderError.make(49, "Failed to lock IOSurface: \(surfaceLockStatus)")
|
||||
}
|
||||
defer {
|
||||
IOSurfaceUnlock(surface, .readOnly, nil)
|
||||
CVPixelBufferUnlockBaseAddress(destination, [])
|
||||
}
|
||||
|
||||
guard let destinationAddress = CVPixelBufferGetBaseAddress(destination) else {
|
||||
throw RecorderError.make(42, "Pixel buffer has no base address")
|
||||
}
|
||||
let sourceAddress = IOSurfaceGetBaseAddress(surface)
|
||||
let sourceStride = IOSurfaceGetBytesPerRow(surface)
|
||||
let destinationStride = CVPixelBufferGetBytesPerRow(destination)
|
||||
let copyWidth = min(width, IOSurfaceGetWidth(surface), CVPixelBufferGetWidth(destination))
|
||||
let copyHeight = min(
|
||||
height, IOSurfaceGetHeight(surface), CVPixelBufferGetHeight(destination))
|
||||
let copyBytes = min(copyWidth * 4, sourceStride, destinationStride)
|
||||
for row in 0..<copyHeight {
|
||||
memcpy(
|
||||
destinationAddress.advanced(by: row * destinationStride),
|
||||
sourceAddress.advanced(by: row * sourceStride),
|
||||
copyBytes
|
||||
)
|
||||
}
|
||||
return destination
|
||||
}
|
||||
|
||||
private func appendOwned(
|
||||
pixelBuffer: CVPixelBuffer,
|
||||
seed: UInt32,
|
||||
capturedAt: CMTime,
|
||||
capturedAtWallClock: Date
|
||||
) {
|
||||
writerQueue.async {
|
||||
defer { self.releasePendingFrame() }
|
||||
if self.firstError != nil {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try self.appendOnWriterQueue(
|
||||
pixelBuffer: pixelBuffer,
|
||||
seed: seed,
|
||||
capturedAt: capturedAt,
|
||||
capturedAtWallClock: capturedAtWallClock
|
||||
)
|
||||
} catch {
|
||||
self.firstError = error
|
||||
self.signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func appendOnWriterQueue(
|
||||
pixelBuffer: CVPixelBuffer,
|
||||
seed: UInt32,
|
||||
capturedAt: CMTime,
|
||||
capturedAtWallClock: Date
|
||||
) throws {
|
||||
guard let writer, let input, let adaptor else {
|
||||
throw RecorderError.make(43, "AVAssetWriter is not initialized")
|
||||
}
|
||||
let isFirstFrame = firstAcceptedCaptureTime == nil
|
||||
let pts = isFirstFrame ? .zero : normalizedPresentationTime(for: capturedAt)
|
||||
if let lastPTS, CMTimeCompare(pts, lastPTS) <= 0 {
|
||||
return
|
||||
}
|
||||
guard try appendPendingBoundaryFrames(before: pts) else {
|
||||
return
|
||||
}
|
||||
guard input.isReadyForMoreMediaData else {
|
||||
return
|
||||
}
|
||||
if !adaptor.append(pixelBuffer, withPresentationTime: pts) {
|
||||
throw writer.error ?? RecorderError.make(44, "Failed to append pixel buffer")
|
||||
}
|
||||
if isFirstFrame {
|
||||
firstAcceptedCaptureTime = capturedAt
|
||||
firstAcceptedWallClock = capturedAtWallClock
|
||||
}
|
||||
lastPTS = pts
|
||||
lastSeed = seed
|
||||
lastAppendedPixelBuffer = pixelBuffer
|
||||
signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
|
||||
private func appendTailFrameIfNeeded(finalPTS: CMTime) throws {
|
||||
let deadline = Date().addingTimeInterval(5)
|
||||
while true {
|
||||
if try appendTailFrame(finalPTS: finalPTS) {
|
||||
return
|
||||
}
|
||||
if Date() >= deadline {
|
||||
throw RecorderError.make(45, "Timed out waiting to append tail frame")
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.05)
|
||||
}
|
||||
}
|
||||
|
||||
private func appendTailFrame(finalPTS: CMTime) throws -> Bool {
|
||||
let pts = normalizedPresentationTime(for: finalPTS)
|
||||
return try appendHeldFrame(at: pts)
|
||||
}
|
||||
|
||||
private func appendPendingBoundaryFrames(before pts: CMTime) throws -> Bool {
|
||||
guard configuration.segmentDuration > 0,
|
||||
firstAcceptedCaptureTime != nil
|
||||
else {
|
||||
return true
|
||||
}
|
||||
|
||||
while CMTimeGetSeconds(pts) > nextBoundaryElapsed {
|
||||
if let lastPTS, CMTimeGetSeconds(lastPTS) >= nextBoundaryElapsed {
|
||||
nextBoundaryElapsed += configuration.segmentDuration
|
||||
continue
|
||||
}
|
||||
|
||||
let boundaryPTS = CMTime(
|
||||
seconds: nextBoundaryElapsed, preferredTimescale: 1_000_000_000)
|
||||
guard try appendHeldFrame(at: boundaryPTS) else {
|
||||
return false
|
||||
}
|
||||
nextBoundaryElapsed += configuration.segmentDuration
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func appendHeldFrame(at pts: CMTime) throws -> Bool {
|
||||
guard let input,
|
||||
let adaptor,
|
||||
let lastPTS,
|
||||
let lastAppendedPixelBuffer
|
||||
else {
|
||||
return true
|
||||
}
|
||||
if CMTimeCompare(pts, lastPTS) <= 0 {
|
||||
return true
|
||||
}
|
||||
guard input.isReadyForMoreMediaData else {
|
||||
return false
|
||||
}
|
||||
if !adaptor.append(lastAppendedPixelBuffer, withPresentationTime: pts) {
|
||||
throw writer?.error ?? RecorderError.make(45, "Failed to append held frame")
|
||||
}
|
||||
self.lastPTS = pts
|
||||
return true
|
||||
}
|
||||
|
||||
private func normalizedPresentationTime(for capturedAt: CMTime) -> CMTime {
|
||||
let start = firstAcceptedCaptureTime ?? capturedAt
|
||||
return CMTimeSubtract(capturedAt, start)
|
||||
}
|
||||
|
||||
private func startWriter(width: Int, height: Int) throws {
|
||||
guard let outputWriter else {
|
||||
throw RecorderError.make(46, "Missing output writer")
|
||||
}
|
||||
|
||||
let writer: AVAssetWriter
|
||||
if configuration.segmentDuration > 0 {
|
||||
writer = AVAssetWriter(contentType: .mpeg4Movie)
|
||||
writer.delegate = outputWriter
|
||||
writer.outputFileTypeProfile = .mpeg4AppleHLS
|
||||
writer.preferredOutputSegmentInterval = CMTime(
|
||||
seconds: configuration.segmentDuration,
|
||||
preferredTimescale: 600
|
||||
)
|
||||
} else {
|
||||
writer = try AVAssetWriter(outputURL: outputWriter.singleRecordingURL, fileType: .mp4)
|
||||
}
|
||||
writer.initialSegmentStartTime = .zero
|
||||
|
||||
let input = AVAssetWriterInput(
|
||||
mediaType: .video,
|
||||
outputSettings: assetWriterSettings(width: width, height: height)
|
||||
)
|
||||
input.expectsMediaDataInRealTime = true
|
||||
|
||||
let attributes: [String: Any] = [
|
||||
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
|
||||
kCVPixelBufferWidthKey as String: width,
|
||||
kCVPixelBufferHeightKey as String: height,
|
||||
kCVPixelBufferIOSurfacePropertiesKey as String: [:],
|
||||
]
|
||||
let adaptor = AVAssetWriterInputPixelBufferAdaptor(
|
||||
assetWriterInput: input,
|
||||
sourcePixelBufferAttributes: attributes
|
||||
)
|
||||
|
||||
guard writer.canAdd(input) else {
|
||||
throw RecorderError.make(47, "AVAssetWriter cannot add video input")
|
||||
}
|
||||
writer.add(input)
|
||||
guard writer.startWriting() else {
|
||||
throw writer.error ?? RecorderError.make(48, "AVAssetWriter failed to start")
|
||||
}
|
||||
writer.startSession(atSourceTime: .zero)
|
||||
|
||||
self.writer = writer
|
||||
self.input = input
|
||||
self.adaptor = adaptor
|
||||
recordingWidth = width
|
||||
recordingHeight = height
|
||||
}
|
||||
|
||||
private func assetWriterSettings(width: Int, height: Int) -> [String: Any] {
|
||||
let codecType: AVVideoCodecType = configuration.codec == .hevc ? .hevc : .h264
|
||||
var compression: [String: Any] = [
|
||||
AVVideoAverageBitRateKey: configuration.bitrate,
|
||||
AVVideoExpectedSourceFrameRateKey: configuration.fps,
|
||||
AVVideoAllowFrameReorderingKey: true,
|
||||
]
|
||||
if configuration.segmentDuration > 0 {
|
||||
compression[AVVideoMaxKeyFrameIntervalKey] = max(
|
||||
1,
|
||||
Int((configuration.segmentDuration * Double(configuration.fps)).rounded())
|
||||
)
|
||||
}
|
||||
if configuration.codec == .h264 {
|
||||
compression[AVVideoProfileLevelKey] = AVVideoProfileLevelH264HighAutoLevel
|
||||
}
|
||||
return [
|
||||
AVVideoCodecKey: codecType,
|
||||
AVVideoWidthKey: width,
|
||||
AVVideoHeightKey: height,
|
||||
AVVideoCompressionPropertiesKey: compression,
|
||||
]
|
||||
}
|
||||
|
||||
private func signalFirstFrameReadyIfNeeded() {
|
||||
if firstFrameReady {
|
||||
return
|
||||
}
|
||||
firstFrameReady = true
|
||||
firstFrameSemaphore.signal()
|
||||
}
|
||||
|
||||
private func isFirstFrameReady() -> Bool {
|
||||
writerQueue.sync {
|
||||
firstFrameReady
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
|
||||
enum RecorderError {
|
||||
static func make(_ code: Int, _ message: String) -> NSError {
|
||||
NSError(
|
||||
domain: "RecordSim",
|
||||
code: code,
|
||||
userInfo: [NSLocalizedDescriptionKey: message]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension JSONEncoder {
|
||||
static var prettySorted: JSONEncoder {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
return encoder
|
||||
}
|
||||
}
|
||||
|
||||
func timeSeconds(_ time: CMTime) -> Double? {
|
||||
guard time.isValid, !time.isIndefinite, !time.isNegativeInfinity, !time.isPositiveInfinity else {
|
||||
return nil
|
||||
}
|
||||
return CMTimeGetSeconds(time)
|
||||
}
|
||||
|
||||
func iso8601(_ date: Date) -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
func unixMs(_ date: Date) -> Int64 {
|
||||
Int64((date.timeIntervalSince1970 * 1000).rounded())
|
||||
}
|
||||
|
||||
struct MonotonicClock {
|
||||
private static let timescale: CMTimeScale = 1_000_000_000
|
||||
|
||||
let startedAtUptimeNanoseconds: UInt64
|
||||
|
||||
init() {
|
||||
startedAtUptimeNanoseconds = DispatchTime.now().uptimeNanoseconds
|
||||
}
|
||||
|
||||
func elapsedSeconds() -> TimeInterval {
|
||||
TimeInterval(elapsedNanoseconds()) / TimeInterval(Self.timescale)
|
||||
}
|
||||
|
||||
func elapsedTime() -> CMTime {
|
||||
CMTime(value: CMTimeValue(elapsedNanoseconds()), timescale: Self.timescale)
|
||||
}
|
||||
|
||||
private func elapsedNanoseconds() -> UInt64 {
|
||||
DispatchTime.now().uptimeNanoseconds - startedAtUptimeNanoseconds
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import Foundation
|
||||
import RecordSim
|
||||
|
||||
struct CLIOptions {
|
||||
var udid: String?
|
||||
var output: String?
|
||||
var segmentDuration: TimeInterval = 120
|
||||
var fps = 30
|
||||
var bitrate = 30_000_000
|
||||
var codec: RecorderCodec = .h264
|
||||
}
|
||||
|
||||
private var requestedStopSignal: CInt = 0
|
||||
private var simulatorStopped: CInt = 0
|
||||
|
||||
private func handleStopSignal(_ signal: CInt) {
|
||||
requestedStopSignal = signal
|
||||
}
|
||||
|
||||
private func isStopRequested() -> Bool {
|
||||
requestedStopSignal != 0 || simulatorStopped != 0
|
||||
}
|
||||
|
||||
func usage() -> String {
|
||||
"""
|
||||
Usage:
|
||||
record-sim --udid <UDID> --output <session-dir> [options]
|
||||
|
||||
Options:
|
||||
--segment-duration <seconds> Target fMP4 media segment duration. Use 0 for one MP4. Default: 120
|
||||
--fps <fps> Expected source frame rate. Default: 30
|
||||
--bitrate <bits/sec> AVAssetWriter average bitrate. Default: 30000000
|
||||
--codec <h264|hevc> Video codec. Default: h264
|
||||
-h, --help Show this help
|
||||
"""
|
||||
}
|
||||
|
||||
func parseOptions(_ args: [String]) throws -> CLIOptions {
|
||||
var options = CLIOptions()
|
||||
var i = 0
|
||||
|
||||
func next(_ flag: String) throws -> String {
|
||||
i += 1
|
||||
guard i < args.count else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing value for \(flag)"])
|
||||
}
|
||||
return args[i]
|
||||
}
|
||||
|
||||
while i < args.count {
|
||||
let arg = args[i]
|
||||
switch arg {
|
||||
case "--udid", "-u":
|
||||
options.udid = try next(arg)
|
||||
case "--output", "--output-dir", "--out", "-o":
|
||||
options.output = try next(arg)
|
||||
case "--segment-duration":
|
||||
options.segmentDuration = Double(try next(arg)) ?? -1
|
||||
case "--fps":
|
||||
options.fps = Int(try next(arg)) ?? 0
|
||||
case "--bitrate":
|
||||
options.bitrate = Int(try next(arg)) ?? 0
|
||||
case "--codec":
|
||||
let raw = try next(arg)
|
||||
guard let codec = RecorderCodec(rawValue: raw) else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--codec must be h264 or hevc"])
|
||||
}
|
||||
options.codec = codec
|
||||
case "--help", "-h":
|
||||
print(usage())
|
||||
exit(0)
|
||||
default:
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Unknown argument: \(arg)"])
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
|
||||
guard options.udid != nil else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing required --udid"])
|
||||
}
|
||||
guard options.output != nil else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing required --output"])
|
||||
}
|
||||
guard options.segmentDuration >= 0 else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--segment-duration must be non-negative"])
|
||||
}
|
||||
guard options.fps > 0, options.fps <= 120 else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--fps must be between 1 and 120"])
|
||||
}
|
||||
guard options.bitrate > 0 else {
|
||||
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--bitrate must be positive"])
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
do {
|
||||
signal(SIGINT, handleStopSignal)
|
||||
signal(SIGTERM, handleStopSignal)
|
||||
|
||||
let options = try parseOptions(Array(CommandLine.arguments.dropFirst()))
|
||||
let configuration = SimulatorRecordingConfiguration(
|
||||
deviceUDID: options.udid!,
|
||||
outputDirectory: URL(fileURLWithPath: options.output!),
|
||||
fps: options.fps,
|
||||
bitrate: options.bitrate,
|
||||
codec: options.codec,
|
||||
segmentDuration: options.segmentDuration
|
||||
)
|
||||
|
||||
let recorder = SimulatorRecorder(configuration: configuration)
|
||||
recorder.onSimulatorStopped = { state in
|
||||
simulatorStopped = 1
|
||||
fputs("[record-sim] simulator stopped state=\(state); finalizing recording\n", stderr)
|
||||
}
|
||||
recorder.onFinalizationStage = { stage in
|
||||
switch stage {
|
||||
case .captureStopped:
|
||||
fputs("[record-sim] screen capture stopped; saving final video\n", stderr)
|
||||
case .videoSaved:
|
||||
fputs("[record-sim] final video saved; writing recording details\n", stderr)
|
||||
}
|
||||
}
|
||||
recorder.onSegment = { segment in
|
||||
switch segment.kind {
|
||||
case .initialization:
|
||||
fputs("[record-sim] init \(segment.relativePath) bytes=\(segment.byteCount)\n", stderr)
|
||||
case .media:
|
||||
let duration = segment.durationSeconds ?? 0
|
||||
fputs(String(format: "[record-sim] segment %@ duration=%.3fs bytes=%d\n", segment.relativePath, duration, segment.byteCount), stderr)
|
||||
}
|
||||
}
|
||||
|
||||
try recorder.start()
|
||||
try recorder.waitUntilFirstFrame()
|
||||
fputs("[record-sim] recording -> \(options.output!)\n", stderr)
|
||||
|
||||
while !isStopRequested() {
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
}
|
||||
|
||||
if requestedStopSignal != 0 {
|
||||
let signalName = switch requestedStopSignal {
|
||||
case SIGINT: "SIGINT"
|
||||
case SIGTERM: "SIGTERM"
|
||||
default: "signal \(requestedStopSignal)"
|
||||
}
|
||||
fputs("[record-sim] received \(signalName); finishing recording\n", stderr)
|
||||
}
|
||||
let manifest = try recorder.stop()
|
||||
if let recording = manifest.recording {
|
||||
fputs("[record-sim] done recording=\(recording)\n", stderr)
|
||||
} else {
|
||||
fputs("[record-sim] done segments=\(manifest.segments.count)\n", stderr)
|
||||
}
|
||||
fputs("[record-sim] manifest \(URL(fileURLWithPath: options.output!).appendingPathComponent("session.json").path)\n", stderr)
|
||||
} catch {
|
||||
fputs("error: \(error.localizedDescription)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Android } from '@expo/eas-build-job';
|
||||
|
||||
export function resolveArtifactPath(job: Android.Job): string {
|
||||
return job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}';
|
||||
}
|
||||
@@ -60,7 +60,8 @@ jest.mock('../../utils/expoUpdates', () => ({
|
||||
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync: jest.fn(async () => null),
|
||||
}));
|
||||
jest.mock('../../utils/expoUpdatesEmbedded', () => ({
|
||||
uploadEmbeddedBundleAsync: jest.fn(),
|
||||
...jest.requireActual('../../utils/expoUpdatesEmbedded'),
|
||||
uploadEmbeddedBundleAsync: jest.fn(async () => ({ status: 'uploaded' })),
|
||||
}));
|
||||
jest.mock('../../utils/hooks', () => ({
|
||||
Hook: {
|
||||
@@ -317,36 +318,108 @@ describe(androidBuilder, () => {
|
||||
expect(runBuilderWithHooksAsync).toHaveBeenCalledWith(ctx, expect.any(Function));
|
||||
});
|
||||
|
||||
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(),
|
||||
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' })
|
||||
);
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
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' });
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
|
||||
});
|
||||
await androidBuilder(ctx);
|
||||
|
||||
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(),
|
||||
expect(datadogDistributionMock).toHaveBeenCalledWith(
|
||||
'eas.build.phase_duration',
|
||||
expect.any(Number),
|
||||
expect.objectContaining({
|
||||
build_phase: BuildPhase.UPLOAD_EMBEDDED_BUNDLE.toLowerCase(),
|
||||
result: phaseResult,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
it('skips by default on SDK 57 and earlier', async () => {
|
||||
const ctx = createContext({ sdkVersion: '57.0.0' });
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
|
||||
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 })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
runGradleCommand,
|
||||
} from '../android/gradle';
|
||||
import { formatGradleProfileReport, parseGradleProfile } from '../android/gradleProfile';
|
||||
import { resolveArtifactPath } from '../android/resolve';
|
||||
import { Sentry } from '../sentry';
|
||||
import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
|
||||
import { prebuildAsync } from '../common/prebuild';
|
||||
@@ -32,7 +33,10 @@ import {
|
||||
configureExpoUpdatesIfInstalledAsync,
|
||||
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
|
||||
} from '../utils/expoUpdates';
|
||||
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
|
||||
import {
|
||||
shouldUploadEmbeddedBundle,
|
||||
uploadEmbeddedBundleAsync,
|
||||
} from '../utils/expoUpdatesEmbedded';
|
||||
import { Hook, runHookIfPresent } from '../utils/hooks';
|
||||
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
|
||||
|
||||
@@ -217,15 +221,26 @@ async function buildInnerAsync(
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
|
||||
await uploadApplicationArchive(ctx, {
|
||||
patternOrPath: ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}',
|
||||
patternOrPath: resolveArtifactPath(ctx.job),
|
||||
rootDir: ctx.getReactNativeProjectDirectory(),
|
||||
logger: ctx.logger,
|
||||
});
|
||||
});
|
||||
|
||||
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
|
||||
if (shouldUploadEmbeddedBundle(ctx)) {
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
const { status } = await uploadEmbeddedBundleAsync({
|
||||
job: ctx.job,
|
||||
env: ctx.env,
|
||||
logger: ctx.logger,
|
||||
projectDir: ctx.getReactNativeProjectDirectory(),
|
||||
appConfig: await ctx.appConfig,
|
||||
});
|
||||
if (status === 'skipped') {
|
||||
ctx.markBuildPhaseSkipped();
|
||||
} else if (status === 'failed') {
|
||||
ctx.markBuildPhaseHasWarnings();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,10 @@ import {
|
||||
configureExpoUpdatesIfInstalledAsync,
|
||||
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
|
||||
} from '../utils/expoUpdates';
|
||||
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
|
||||
import {
|
||||
shouldUploadEmbeddedBundle,
|
||||
uploadEmbeddedBundleAsync,
|
||||
} from '../utils/expoUpdatesEmbedded';
|
||||
import { Hook, runHookIfPresent } from '../utils/hooks';
|
||||
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
|
||||
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
|
||||
@@ -234,15 +237,26 @@ async function buildInnerAsync(
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
|
||||
await uploadApplicationArchive(ctx, {
|
||||
patternOrPath: resolveArtifactPath(ctx),
|
||||
patternOrPath: resolveArtifactPath(ctx.job),
|
||||
rootDir: ctx.getReactNativeProjectDirectory(),
|
||||
logger: ctx.logger,
|
||||
});
|
||||
});
|
||||
|
||||
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
|
||||
if (shouldUploadEmbeddedBundle(ctx)) {
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
const { status } = await uploadEmbeddedBundleAsync({
|
||||
job: ctx.job,
|
||||
env: ctx.env,
|
||||
logger: ctx.logger,
|
||||
projectDir: ctx.getReactNativeProjectDirectory(),
|
||||
appConfig: await ctx.appConfig,
|
||||
});
|
||||
if (status === 'skipped') {
|
||||
ctx.markBuildPhaseSkipped();
|
||||
} else if (status === 'failed') {
|
||||
ctx.markBuildPhaseHasWarnings();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -63,12 +63,12 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
public readonly logger: bunyan;
|
||||
public readonly graphqlClient: Client;
|
||||
public readonly runtimeApi: BuilderRuntimeApi;
|
||||
public readonly expoApiV2BaseUrl?: string;
|
||||
public readonly mcpServerUrl?: string;
|
||||
public job: TJob;
|
||||
public metadata?: Metadata;
|
||||
|
||||
private _env: Env;
|
||||
private readonly expoApiV2BaseUrl?: string;
|
||||
private readonly pendingMetricUploads: Promise<void>[] = [];
|
||||
|
||||
constructor(buildCtx: BuildContext<TJob>) {
|
||||
|
||||
@@ -15,10 +15,10 @@ export function resolveScheme(ctx: BuildContext<Ios.Job>): string {
|
||||
return schemes[0];
|
||||
}
|
||||
|
||||
export function resolveArtifactPath(ctx: BuildContext<Ios.Job>): string {
|
||||
if (ctx.job.applicationArchivePath) {
|
||||
return ctx.job.applicationArchivePath;
|
||||
} else if (ctx.job.simulator) {
|
||||
export function resolveArtifactPath(job: Ios.Job): string {
|
||||
if (job.applicationArchivePath) {
|
||||
return job.applicationArchivePath;
|
||||
} else if (job.simulator) {
|
||||
return 'ios/build/Build/Products/*simulator/*.app';
|
||||
} else {
|
||||
return 'ios/build/*.ipa';
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getEasFunctions } from '../easFunctions';
|
||||
jest.mock('fs');
|
||||
|
||||
describe('hook anchor self-declarations', () => {
|
||||
it('exactly the four v0 functions declare anchors, with these bindings', () => {
|
||||
it('declares the expected hook anchors', () => {
|
||||
const declarations: Record<string, HookAnchorId> = {};
|
||||
for (const buildFunction of getEasFunctions(createCustomBuildContextMock())) {
|
||||
if (buildFunction.__hookId !== undefined) {
|
||||
@@ -24,6 +24,7 @@ describe('hook anchor self-declarations', () => {
|
||||
'eas/checkout': 'checkout',
|
||||
'eas/install_node_modules': 'install_node_modules',
|
||||
'eas/upload_to_asc': 'submit',
|
||||
'eas/submit_to_google_play': 'submit',
|
||||
'eas/maestro_tests': 'maestro_tests',
|
||||
});
|
||||
});
|
||||
@@ -87,6 +88,17 @@ describe('hook anchor self-declarations', () => {
|
||||
bundle_short_version: '1.0.0',
|
||||
},
|
||||
],
|
||||
[
|
||||
'eas/submit_to_google_play',
|
||||
'before_submit',
|
||||
'after_submit',
|
||||
{
|
||||
artifact_path: 'app.aab',
|
||||
artifact_type: 'aab',
|
||||
package_name: 'com.example.app',
|
||||
service_account_key_path: 'key.json',
|
||||
},
|
||||
],
|
||||
[
|
||||
'eas/maestro_tests',
|
||||
'before_maestro_tests',
|
||||
|
||||
@@ -10,6 +10,7 @@ import { configureIosVersionFunction } from './functions/configureIosVersion';
|
||||
import { createSubmissionEntityFunction } from './functions/createSubmissionEntity';
|
||||
import { createDownloadArtifactFunction } from './functions/downloadArtifact';
|
||||
import { createDownloadBuildFunction } from './functions/downloadBuild';
|
||||
import { createDownloadSubmissionArchiveFunction } from './functions/downloadSubmissionArchive';
|
||||
import { createEasDeployBuildFunction } from './functions/deploy';
|
||||
import { createEasExportBuildFunction } from './functions/export';
|
||||
import { eagerBundleBuildFunction } from './functions/eagerBundle';
|
||||
@@ -19,6 +20,7 @@ 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';
|
||||
@@ -26,6 +28,8 @@ 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 { createSubmitToGooglePlayBuildFunction } from './functions/submitToGooglePlay';
|
||||
import { createReadPackageJsonBuildFunction } from './functions/readPackageJson';
|
||||
import { createRepackBuildFunction } from './functions/repack';
|
||||
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
|
||||
@@ -42,9 +46,10 @@ import { runGradleFunction } from './functions/runGradle';
|
||||
import { createMaestroTestsBuildFunction } from './functions/maestroTests';
|
||||
import { createSaveBuildCacheFunction } from './functions/saveBuildCache';
|
||||
import { createRolloutPosthogFlagFunction } from './functions/rolloutPosthogFlag';
|
||||
import { createRunAgentBuildFunction } from './functions/runAgent';
|
||||
import { createSaveCacheFunction } from './functions/saveCache';
|
||||
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
|
||||
import { createStartAgentDeviceSessionBuildFunction } from './functions/startAgentDeviceSession';
|
||||
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
|
||||
import { createStartArgentRemoteSessionBuildFunction } from './functions/startArgentRemoteSession';
|
||||
import { createStartAppiumRemoteSessionBuildFunction } from './functions/startAppiumRemoteSession';
|
||||
@@ -54,11 +59,15 @@ 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';
|
||||
@@ -77,7 +86,9 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createInstallNodeModulesBuildFunction(),
|
||||
createPrebuildBuildFunction(),
|
||||
createReadIpaInfoBuildFunction(),
|
||||
createReadAndroidAppInfoBuildFunction(),
|
||||
createDownloadBuildFunction(ctx),
|
||||
createDownloadSubmissionArchiveFunction(ctx),
|
||||
createInstallBuildFunction(),
|
||||
createLaunchApplicationFunction(),
|
||||
createEasExportBuildFunction(),
|
||||
@@ -99,7 +110,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
generateGymfileFromTemplateFunction(),
|
||||
runFastlaneFunction(),
|
||||
parseXcactivitylogFunction(),
|
||||
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
|
||||
createStartAgentDeviceSessionBuildFunction(ctx),
|
||||
createStartArgentRemoteSessionBuildFunction(ctx),
|
||||
createStartAppiumRemoteSessionBuildFunction(ctx),
|
||||
createStartAndroidEmulatorBuildFunction(),
|
||||
@@ -111,9 +122,13 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
|
||||
createStartWebPreviewRemoteSessionBuildFunction(ctx),
|
||||
createStartServeSimMetricsBuildFunction(),
|
||||
createStartServeSimLogsBuildFunction(),
|
||||
createCollectServeSimLogsBuildFunction(ctx),
|
||||
createStartSandboxBuildFunction(ctx),
|
||||
createRunAgentBuildFunction(ctx),
|
||||
createCollectServeSimMetricsBuildFunction(ctx),
|
||||
createInstallMaestroBuildFunction(),
|
||||
createInstallMitmproxyBuildFunction(),
|
||||
|
||||
createInstallPodsBuildFunction(),
|
||||
createSendSlackMessageFunction(),
|
||||
@@ -127,7 +142,9 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
calculateEASUpdateRuntimeVersionFunction(),
|
||||
|
||||
createSubmissionEntityFunction(),
|
||||
createSubmitToGooglePlayBuildFunction(),
|
||||
createUploadToAscBuildFunction(),
|
||||
createUpdateTestFlightMetadataBuildFunction(),
|
||||
|
||||
createReportMaestroTestResultsFunction(ctx),
|
||||
createMaestroTestsBuildFunction(ctx),
|
||||
@@ -137,6 +154,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
functions.push(
|
||||
...[
|
||||
createFindAndUploadBuildArtifactsBuildFunction(ctx),
|
||||
createUploadEmbeddedBundleBuildFunction(ctx),
|
||||
createResolveBuildConfigBuildFunction(ctx),
|
||||
createGetCredentialsForBuildTriggeredByGithubIntegration(ctx),
|
||||
]
|
||||
|
||||
@@ -219,4 +219,71 @@ describe(createEasBuildBuildFunctionGroup, () => {
|
||||
'Build function group is not supported in generic jobs.'
|
||||
);
|
||||
});
|
||||
|
||||
describe('embedded bundle upload', () => {
|
||||
function getStepNames(
|
||||
options: Parameters<typeof createMockBuildToolsContext>[0],
|
||||
{ sdkVersion, env = {} }: { sdkVersion?: string; env?: Record<string, string> }
|
||||
): string[] {
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(createMockBuildToolsContext(options));
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger: createMockLogger(),
|
||||
staticContextContent: { metadata: sdkVersion ? { sdkVersion } : null },
|
||||
});
|
||||
globalCtx.updateEnv(env);
|
||||
return functionGroup
|
||||
.createBuildStepsFromFunctionGroupCall(globalCtx)
|
||||
.map(step => step.displayName);
|
||||
}
|
||||
|
||||
it.each([
|
||||
['Android', { platform: Platform.ANDROID }],
|
||||
['Android with credentials', { platform: Platform.ANDROID, buildCredentials: { test: {} } }],
|
||||
['iOS with credentials', { platform: Platform.IOS, buildCredentials: { test: {} } }],
|
||||
])('uploads the embedded bundle after the build artifacts on SDK 58 (%s)', (_, options) => {
|
||||
const stepNames = getStepNames(options, { sdkVersion: '58.0.0' });
|
||||
|
||||
expect(stepNames.indexOf('Upload embedded bundle')).toBe(
|
||||
stepNames.indexOf('Find and upload build artifacts') + 1
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Android', { platform: Platform.ANDROID }],
|
||||
['Android with credentials', { platform: Platform.ANDROID, buildCredentials: { test: {} } }],
|
||||
['iOS with credentials', { platform: Platform.IOS, buildCredentials: { test: {} } }],
|
||||
])('passes ignore_error to the embedded bundle step (%s)', (_, options) => {
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(createMockBuildToolsContext(options));
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger: createMockLogger(),
|
||||
staticContextContent: { metadata: { sdkVersion: '58.0.0' } },
|
||||
});
|
||||
|
||||
const uploadStep = functionGroup
|
||||
.createBuildStepsFromFunctionGroupCall(globalCtx)
|
||||
.find(step => step.displayName === 'Upload embedded bundle');
|
||||
|
||||
expect(uploadStep).toBeDefined();
|
||||
expect(uploadStep!.inputs?.find(input => input.id === 'ignore_error')?.rawValue).toBe(true);
|
||||
});
|
||||
|
||||
it('does not upload the embedded bundle on SDK 57 without the opt-in', () => {
|
||||
expect(getStepNames({}, { sdkVersion: '57.0.0' })).not.toContain('Upload embedded bundle');
|
||||
});
|
||||
|
||||
it('uploads the embedded bundle on SDK 57 with the opt-in', () => {
|
||||
expect(
|
||||
getStepNames(
|
||||
{},
|
||||
{ sdkVersion: '57.0.0', env: { EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1' } }
|
||||
)
|
||||
).toContain('Upload embedded bundle');
|
||||
});
|
||||
|
||||
it('does not upload the embedded bundle for iOS simulator builds', () => {
|
||||
expect(
|
||||
getStepNames({ platform: Platform.IOS, simulator: true }, { sdkVersion: '58.0.0' })
|
||||
).not.toContain('Upload embedded bundle');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import path from 'path';
|
||||
|
||||
import { shouldUseEagerBundle } from '../../common/eagerBundle';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { shouldUploadEmbeddedBundle } from '../../utils/expoUpdatesEmbedded';
|
||||
import { calculateEASUpdateRuntimeVersionFunction } from '../functions/calculateEASUpdateRuntimeVersion';
|
||||
import { createCheckoutBuildFunction } from '../functions/checkout';
|
||||
import { configureAndroidVersionFunction } from '../functions/configureAndroidVersion';
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
import { runFastlaneFunction } from '../functions/runFastlane';
|
||||
import { runGradleFunction } from '../functions/runGradle';
|
||||
import { createSaveBuildCacheFunction } from '../functions/saveBuildCache';
|
||||
import { createUploadEmbeddedBundleBuildFunction } from '../functions/uploadEmbeddedBundle';
|
||||
import { createSetUpNpmrcBuildFunction } from '../functions/useNpmToken';
|
||||
|
||||
interface HelperFunctionsInput {
|
||||
@@ -303,6 +305,19 @@ function createStepsForIosBuildWithCredentials({
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
...(shouldUploadEmbeddedBundle({
|
||||
env: globalCtx.env,
|
||||
metadata: globalCtx.staticContext.metadata,
|
||||
})
|
||||
? [
|
||||
createUploadEmbeddedBundleBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: { ignore_error: true },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
@@ -390,6 +405,19 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
...(shouldUploadEmbeddedBundle({
|
||||
env: globalCtx.env,
|
||||
metadata: globalCtx.staticContext.metadata,
|
||||
})
|
||||
? [
|
||||
createUploadEmbeddedBundleBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: { ignore_error: true },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
@@ -483,6 +511,19 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
...(shouldUploadEmbeddedBundle({
|
||||
env: globalCtx.env,
|
||||
metadata: globalCtx.staticContext.metadata,
|
||||
})
|
||||
? [
|
||||
createUploadEmbeddedBundleBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: { ignore_error: true },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+47
@@ -0,0 +1,47 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { readAndroidArtifactInfoAsync } from '../../utils/android/appArtifact';
|
||||
import { createReadAndroidAppInfoBuildFunction } from '../readAndroidAppInfo';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
jest.unmock('@expo/spawn-async');
|
||||
|
||||
const fixturesDirectory = path.join(__dirname, 'fixtures/android-app-info');
|
||||
|
||||
it.each(['apk', 'aab'])('reads a real %s and publishes build step outputs', async type => {
|
||||
const step = createReadAndroidAppInfoBuildFunction().createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({
|
||||
projectTargetDirectory: fixturesDirectory,
|
||||
staticContextContent: { job: {} },
|
||||
}),
|
||||
{ callInputs: { artifact_path: `app.${type}` } }
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(step.outputById.artifact_type.value).toBe(type);
|
||||
expect(step.outputById.package_name.value).toBe('com.example.app');
|
||||
});
|
||||
|
||||
it('reports an invalid ZIP as a user error', async () => {
|
||||
await expect(readAndroidArtifactInfoAsync(__filename)).rejects.toMatchObject({
|
||||
constructor: UserError,
|
||||
errorCode: 'EAS_READ_ANDROID_APP_INFO_INVALID_ARTIFACT',
|
||||
cause: expect.any(Error),
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['apk', 'aab'])('retains the native tool error for an invalid %s manifest', async type => {
|
||||
await expect(
|
||||
readAndroidArtifactInfoAsync(path.join(fixturesDirectory, `invalid-manifest-${type}.zip`))
|
||||
).rejects.toMatchObject({
|
||||
constructor: UserError,
|
||||
errorCode: 'EAS_READ_ANDROID_APP_INFO_INVALID_MANIFEST',
|
||||
cause: expect.any(Error),
|
||||
});
|
||||
});
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
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,6 +11,7 @@ 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
|
||||
@@ -255,6 +256,144 @@ 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,
|
||||
@@ -424,6 +563,34 @@ 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({
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Client, CombinedError } from '@urql/core';
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
import fs from 'node:fs';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { createDownloadSubmissionArchiveFunction } from '../downloadSubmissionArchive';
|
||||
|
||||
const submissionId = 'aabbccdd-1234-4321-abcd-123456789012';
|
||||
const url = 'https://storage.example/app.ipa?signature=secret';
|
||||
|
||||
it('reports invalid submission ID and extensions together', async () => {
|
||||
const mutation = jest.fn();
|
||||
const step = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { submission_id: 'invalid', extensions: [123] },
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toMatchObject({
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({ path: ['submissionId'] }),
|
||||
expect.objectContaining({ path: ['extensions', 0] }),
|
||||
]),
|
||||
});
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requests a fresh URL on each execution, downloads without auth and does not log the URL', async () => {
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
submission: { generateSubmissionArchiveDownloadUrl: url },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
const logger = createMockLogger();
|
||||
const fn = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
url,
|
||||
body: Readable.from(Buffer.from('IPA bytes')),
|
||||
headers: { get: () => null },
|
||||
} as unknown as Response);
|
||||
const step = fn.createBuildStepFromFunctionCall(createGlobalContextMock({ logger }), {
|
||||
callInputs: { submission_id: submissionId },
|
||||
});
|
||||
await step.executeAsync();
|
||||
expect(await fs.promises.readFile(step.getOutputValueByName('artifact_path')!, 'utf8')).toBe(
|
||||
'IPA bytes'
|
||||
);
|
||||
}
|
||||
expect(mutation).toHaveBeenCalledTimes(2);
|
||||
expect(fetch).toHaveBeenCalledWith(url, { headers: undefined, signal: undefined });
|
||||
expect(JSON.stringify(jest.mocked(logger.info).mock.calls)).not.toContain(url);
|
||||
});
|
||||
|
||||
it('preserves the API error as the cause without downloading', async () => {
|
||||
const error = new CombinedError({ networkError: new Error('Connection reset') });
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({
|
||||
error,
|
||||
}),
|
||||
}));
|
||||
const step = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { submission_id: submissionId },
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toMatchObject({
|
||||
message: 'Could not request the submission archive. Try again later.',
|
||||
cause: error,
|
||||
});
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(mutation).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('retries a temporary URL request failure and then downloads the archive', async () => {
|
||||
const mutation = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
toPromise: async () => ({
|
||||
error: new CombinedError({ networkError: new Error('Connection reset') }),
|
||||
}),
|
||||
})
|
||||
.mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
data: { submission: { generateSubmissionArchiveDownloadUrl: url } },
|
||||
}),
|
||||
});
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
url,
|
||||
body: Readable.from(Buffer.from('IPA bytes')),
|
||||
headers: { get: () => null },
|
||||
} as unknown as Response);
|
||||
const step = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { submission_id: submissionId },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mutation).toHaveBeenCalledTimes(2);
|
||||
expect(await fs.promises.readFile(step.getOutputValueByName('artifact_path')!, 'utf8')).toBe(
|
||||
'IPA bytes'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not retry an access error', async () => {
|
||||
const error = new CombinedError({ graphQLErrors: [{ message: 'Not authorized' }] });
|
||||
const mutation = jest.fn(() => ({ toPromise: async () => ({ error }) }));
|
||||
const step = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { submission_id: submissionId },
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toMatchObject({ cause: error });
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not send another URL request after the step times out during retry backoff', async () => {
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({
|
||||
error: new CombinedError({ networkError: new Error('Connection reset') }),
|
||||
}),
|
||||
}));
|
||||
const step = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { submission_id: submissionId },
|
||||
timeoutMs: 100,
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow('timed out');
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the requested extension for an archive without a filename extension', async () => {
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
submission: { generateSubmissionArchiveDownloadUrl: 'https://storage.example/archive' },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
url: 'https://storage.example/archive',
|
||||
body: Readable.from(Buffer.from('Android archive bytes')),
|
||||
headers: { get: () => null },
|
||||
} as unknown as Response);
|
||||
const step = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { submission_id: submissionId, extensions: ['aab'] },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
const artifactPath = step.getOutputValueByName('artifact_path')!;
|
||||
expect(artifactPath).toMatch(/\.aab$/);
|
||||
expect(await fs.promises.readFile(artifactPath, 'utf8')).toBe('Android archive bytes');
|
||||
});
|
||||
|
||||
it('propagates the original download error', async () => {
|
||||
const error = new Error('Download failed');
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({
|
||||
data: { submission: { generateSubmissionArchiveDownloadUrl: url } },
|
||||
}),
|
||||
}));
|
||||
jest.mocked(fetch).mockRejectedValue(error);
|
||||
const step = createDownloadSubmissionArchiveFunction({
|
||||
graphqlClient: { mutation } as unknown as Client,
|
||||
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { submission_id: submissionId },
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toBe(error);
|
||||
});
|
||||
@@ -463,114 +463,127 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('installs the prebuilt WDA cache for each available iOS runtime', async () => {
|
||||
const homeDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'install-maestro-test-')
|
||||
);
|
||||
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'maestro-runner') {
|
||||
return { stdout: 'maestro-runner 1.2.3\n' };
|
||||
}
|
||||
if (command === 'xcrun') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
runtimes: [
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
|
||||
isAvailable: true,
|
||||
version: '18.3.1',
|
||||
},
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-0',
|
||||
isAvailable: true,
|
||||
version: '26.0',
|
||||
},
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-17-5',
|
||||
isAvailable: false,
|
||||
version: '17.5',
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (command === 'xcodebuild') {
|
||||
return { stdout: 'Xcode 26.0\nBuild version 17A324\n' };
|
||||
}
|
||||
if (command === 'tar') {
|
||||
const maestroRunnerHome = args[args.indexOf('-C') + 1];
|
||||
const productsDirectory = path.join(
|
||||
maestroRunnerHome,
|
||||
'cache',
|
||||
'wda-builds',
|
||||
'generic',
|
||||
'DerivedData',
|
||||
'Build',
|
||||
'Products'
|
||||
);
|
||||
await fs.promises.mkdir(productsDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(productsDirectory, 'WebDriverAgentRunner.xctestrun'),
|
||||
'cached'
|
||||
);
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as any);
|
||||
|
||||
try {
|
||||
await writeInstalledWdaVersion(homeDirectory, '11.1.3');
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com',
|
||||
HOME: homeDirectory,
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenCalledWith(
|
||||
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.0-wda-11.1.3.tar.gz',
|
||||
expect.stringMatching(/install_maestro_runner_wda_cache.*\/wda-cache\.tar\.gz$/),
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
it.each(['1.1.27', '1.1.28', '1.2.3'])(
|
||||
'prepares the WDA cache only for legacy Runner versions (%s)',
|
||||
async version => {
|
||||
const homeDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'install-maestro-test-')
|
||||
);
|
||||
for (const runtimeVersion of ['18.3', '26.0']) {
|
||||
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'maestro-runner') {
|
||||
return { stdout: `maestro-runner ${version}\n` };
|
||||
}
|
||||
if (command === 'xcrun') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
runtimes: [
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
|
||||
isAvailable: true,
|
||||
version: '18.3.1',
|
||||
},
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-0',
|
||||
isAvailable: true,
|
||||
version: '26.0',
|
||||
},
|
||||
{
|
||||
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-17-5',
|
||||
isAvailable: false,
|
||||
version: '17.5',
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (command === 'xcodebuild') {
|
||||
return { stdout: 'Xcode 26.0\nBuild version 17A324\n' };
|
||||
}
|
||||
if (command === 'tar') {
|
||||
const maestroRunnerHome = args[args.indexOf('-C') + 1];
|
||||
const productsDirectory = path.join(
|
||||
maestroRunnerHome,
|
||||
'cache',
|
||||
'wda-builds',
|
||||
'generic',
|
||||
'DerivedData',
|
||||
'Build',
|
||||
'Products'
|
||||
);
|
||||
await fs.promises.mkdir(productsDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(productsDirectory, 'WebDriverAgentRunner.xctestrun'),
|
||||
'cached'
|
||||
);
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as any);
|
||||
|
||||
try {
|
||||
await writeInstalledWdaVersion(homeDirectory, '11.1.3');
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com',
|
||||
HOME: homeDirectory,
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe(version);
|
||||
if (version !== '1.1.27') {
|
||||
expect(mockedDownloadFile).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn.mock.calls.map(([command]) => command)).not.toContain('xcrun');
|
||||
await expect(
|
||||
fs.promises.access(path.join(homeDirectory, '.maestro-runner', 'cache'))
|
||||
).rejects.toThrow();
|
||||
return;
|
||||
}
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenCalledWith(
|
||||
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.0-wda-11.1.3.tar.gz',
|
||||
expect.stringMatching(/install_maestro_runner_wda_cache.*\/wda-cache\.tar\.gz$/),
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
);
|
||||
for (const runtimeVersion of ['18.3', '26.0']) {
|
||||
await expect(
|
||||
fs.promises.readFile(
|
||||
path.join(
|
||||
homeDirectory,
|
||||
'.maestro-runner',
|
||||
'cache',
|
||||
'wda-builds',
|
||||
`sim-ios${runtimeVersion}-iphone`,
|
||||
'DerivedData',
|
||||
'Build',
|
||||
'Products',
|
||||
'WebDriverAgentRunner.xctestrun'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
).resolves.toBe('cached');
|
||||
}
|
||||
await expect(
|
||||
fs.promises.readFile(
|
||||
path.join(
|
||||
homeDirectory,
|
||||
'.maestro-runner',
|
||||
'cache',
|
||||
'wda-builds',
|
||||
`sim-ios${runtimeVersion}-iphone`,
|
||||
'DerivedData',
|
||||
'Build',
|
||||
'Products',
|
||||
'WebDriverAgentRunner.xctestrun'
|
||||
),
|
||||
'utf8'
|
||||
fs.promises.access(
|
||||
path.join(homeDirectory, '.maestro-runner', 'cache', 'wda-builds', 'sim-ios17.5-iphone')
|
||||
)
|
||||
).resolves.toBe('cached');
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
|
||||
}
|
||||
await expect(
|
||||
fs.promises.access(
|
||||
path.join(homeDirectory, '.maestro-runner', 'cache', 'wda-builds', 'sim-ios17.5-iphone')
|
||||
)
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
it('does not download the WDA cache when the installed WDA version is unknown', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => ({
|
||||
stdout:
|
||||
command === 'xcodebuild' ? 'Xcode 26.5\nBuild version 17F90\n' : 'maestro-runner 1.2.3\n',
|
||||
command === 'xcodebuild' ? 'Xcode 26.5\nBuild version 17F90\n' : 'maestro-runner 1.1.27\n',
|
||||
})) as any);
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
@@ -593,7 +606,7 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
switch (command) {
|
||||
case 'maestro-runner':
|
||||
return { stdout: 'maestro-runner 1.2.3\n' };
|
||||
return { stdout: 'maestro-runner 1.1.27\n' };
|
||||
case 'xcodebuild':
|
||||
return { stdout: 'Xcode 26.5\nBuild version 17F90\n' };
|
||||
case 'xcrun':
|
||||
@@ -633,7 +646,7 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).resolves.toBeUndefined();
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.2.3');
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.1.27');
|
||||
expect(mockedDownloadFile.mock.calls.map(([url]) => url)).toEqual([
|
||||
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.5-wda-11.1.3.tar.gz',
|
||||
'https://storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.5-wda-11.1.3.tar.gz',
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -179,17 +179,7 @@ describe(launchApplicationAsync, () => {
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'-a',
|
||||
'android.intent.action.VIEW',
|
||||
'-d',
|
||||
'exp://example.test',
|
||||
'-n',
|
||||
'host.exp.exponent/host.exp.exponent.MainActivity',
|
||||
],
|
||||
['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', 'exp://example.test'],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(logger.info).toHaveBeenNthCalledWith(
|
||||
@@ -242,17 +232,7 @@ describe(launchApplicationAsync, () => {
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'-a',
|
||||
'android.intent.action.VIEW',
|
||||
'-d',
|
||||
'exp://example.test',
|
||||
'-n',
|
||||
'com.example.app/com.example.app.MainActivity',
|
||||
],
|
||||
['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', 'exp://example.test'],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -271,20 +271,124 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
expect(mockedSpawn.mock.calls[0][1]).toContain('--parallel=2');
|
||||
});
|
||||
|
||||
it('rejects a non-junit output_format with maestro-runner', async () => {
|
||||
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: ['flows/a.yaml'],
|
||||
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 () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
output_format: 'csv',
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
'maestro-runner only supports the "junit" output_format'
|
||||
'maestro-runner supports "junit", "html", and "allure" output_format values'
|
||||
);
|
||||
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();
|
||||
@@ -966,7 +1070,7 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('uses $HOME/.maestro/tests output path for non-junit formats (e.g. html)', async () => {
|
||||
it('uses $HOME/.maestro/tests output path and uploads the Maestro CLI HTML report', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
@@ -977,11 +1081,119 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
await step.executeAsync();
|
||||
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests so the
|
||||
// whole-directory upload picks it up.
|
||||
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests.
|
||||
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 () => {
|
||||
@@ -1216,28 +1428,31 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
);
|
||||
});
|
||||
|
||||
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' }],
|
||||
});
|
||||
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' }],
|
||||
});
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
platform: 'ios',
|
||||
output_format: 'junit',
|
||||
backend: 'maestro-runner',
|
||||
});
|
||||
await step.executeAsync();
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
platform: 'ios',
|
||||
...(format === undefined ? {} : { output_format: format }),
|
||||
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());
|
||||
@@ -1301,14 +1516,15 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(30);
|
||||
});
|
||||
|
||||
it('does not harvest or upload screenshots when output_format is not junit', async () => {
|
||||
it('does not harvest or upload screenshots for Maestro CLI HTML', 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).not.toHaveBeenCalled();
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
|
||||
expect(mockUploadArtifact.mock.calls[0][0].artifact.name).toBe('Maestro HTML Report');
|
||||
});
|
||||
|
||||
it('uploads only the final attempt for a pure-failure flow, but every attempt for a flaky flow', async () => {
|
||||
|
||||
+31
-14
@@ -7,6 +7,7 @@ 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,
|
||||
@@ -14,11 +15,10 @@ import {
|
||||
stopLocalEgressResourcesAsync,
|
||||
} from '../../utils/localEgress';
|
||||
import {
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
|
||||
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
|
||||
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
|
||||
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
|
||||
@@ -27,7 +27,14 @@ 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() }),
|
||||
@@ -58,12 +65,15 @@ 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() } as unknown as bunyan;
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
child: jest.fn().mockReturnThis(),
|
||||
} 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 };
|
||||
@@ -74,7 +84,7 @@ const fields = {
|
||||
egressPort: handoff.port,
|
||||
};
|
||||
const controllers = [
|
||||
['agent-device', createStartAgentDeviceRemoteSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
|
||||
['agent-device', createStartAgentDeviceSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
|
||||
['Appium', createStartAppiumRemoteSessionBuildFunction, 'appiumUrl'],
|
||||
['Argent', createStartArgentRemoteSessionBuildFunction, 'toolsUrl'],
|
||||
['web preview', createStartWebPreviewRemoteSessionBuildFunction, 'previewUrl'],
|
||||
@@ -82,16 +92,17 @@ 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: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
// Inputs a step does not get default to undefined, like in a real step call.
|
||||
inputs: new Proxy({} as Record<string, { value: unknown }>, {
|
||||
get: (target, id: string) => target[id] ?? { value: undefined },
|
||||
}),
|
||||
outputs: {},
|
||||
env,
|
||||
signal,
|
||||
@@ -119,11 +130,15 @@ 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);
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
openPreview.mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://preview.test',
|
||||
previewToken: 'preview-secret',
|
||||
stopAsync: stopPreview,
|
||||
closeAsync: jest.fn(),
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: openPreview,
|
||||
finishAsync: stopPreview,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
|
||||
@@ -169,12 +184,14 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(stopEgress).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(['startup', 'report', 'wait', 'teardown'])(
|
||||
it.each(['startup', 'preview', 'report', 'wait', 'teardown'])(
|
||||
'releases egress after %s fails',
|
||||
async phase => {
|
||||
const error = new Error(`${phase} failed`);
|
||||
if (phase === 'startup') {
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockRejectedValue(error);
|
||||
jest.mocked(startDeviceSessionHostAsync).mockRejectedValue(error);
|
||||
} else if (phase === 'preview') {
|
||||
openPreview.mockRejectedValue(error);
|
||||
} else if (phase === 'report') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValue(error);
|
||||
} else if (phase === 'wait') {
|
||||
@@ -186,7 +203,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 === 'report' ? 0 : 1
|
||||
phase === 'startup' || phase === 'preview' || phase === 'report' ? 0 : 1
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import turtleSpawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { EventEmitter, once } from 'node:events';
|
||||
import path from 'node:path';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import * as agentRun from '../../utils/agentRun';
|
||||
import { leaseAgentRunProviderCredentialsAsync } from '../../utils/agentRunLease';
|
||||
import { createRunAgentBuildFunction } from '../runAgent';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('node:child_process', () => ({
|
||||
...jest.requireActual('node:child_process'),
|
||||
spawn: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/agentRunLease');
|
||||
|
||||
const installMock = jest.mocked(turtleSpawn);
|
||||
const spawnMock = jest.mocked(spawn);
|
||||
const leaseMock = jest.mocked(leaseAgentRunProviderCredentialsAsync);
|
||||
|
||||
const ctx = {
|
||||
expoApiV2BaseUrl: 'https://staging-api.expo.test/v2/',
|
||||
mcpServerUrl: 'wss://staging-mcp.expo.test',
|
||||
} as any;
|
||||
const inputs = {
|
||||
agent_kind: { value: 'claude-code' },
|
||||
agent_run_id: { value: '0199b0a0-1111-7222-8333-444455556666' },
|
||||
max_duration_seconds: { value: 1800 },
|
||||
prompt: { value: 'Fix the failing test.' },
|
||||
};
|
||||
const env = {
|
||||
PATH: '/usr/local/bin:/usr/bin',
|
||||
HOME: '/home/expo',
|
||||
EXPO_TOKEN: 'expo-token',
|
||||
__API_SERVER_URL: 'https://staging-api.expo.test',
|
||||
EAS_BUILD_ID: 'build-id',
|
||||
NODE_OPTIONS: '--max-old-space-size=3072',
|
||||
USER_DEFINED_SECRET: 'secret',
|
||||
};
|
||||
|
||||
async function runAsync(
|
||||
overrides: {
|
||||
ctx?: any;
|
||||
inputs?: Record<string, { value: unknown }>;
|
||||
env?: Record<string, string>;
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
const fn = createRunAgentBuildFunction(overrides.ctx ?? ctx);
|
||||
await fn.fn!({ logger: createMockLogger() } as any, {
|
||||
inputs: overrides.inputs ?? inputs,
|
||||
outputs: {},
|
||||
env: overrides.env ?? env,
|
||||
});
|
||||
}
|
||||
|
||||
function mockSuccessfulRun(): { mcpConfig?: any } {
|
||||
const captured: { mcpConfig?: any } = {};
|
||||
leaseMock.mockResolvedValue({ provider: 'anthropic', accessToken: 'anthropic-access-token' });
|
||||
installMock.mockResolvedValue({} as any);
|
||||
spawnMock.mockImplementation(((_command: string, _args: string[], spawnOptions: any) => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdin: { on: jest.fn(), end: jest.fn() },
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
});
|
||||
captured.mcpConfig = JSON.parse(
|
||||
vol.readFileSync(path.join(spawnOptions.env.HOME, 'mcp.json'), 'utf8') as string
|
||||
);
|
||||
process.nextTick(() => {
|
||||
child.emit('exit', 0, null);
|
||||
child.stdout.end();
|
||||
child.stderr.end();
|
||||
void Promise.all([once(child.stdout, 'end'), once(child.stderr, 'end')]).then(() =>
|
||||
child.emit('close')
|
||||
);
|
||||
});
|
||||
return child;
|
||||
}) as any);
|
||||
return captured;
|
||||
}
|
||||
|
||||
describe(createRunAgentBuildFunction, () => {
|
||||
it('is the eas/run_agent function', () => {
|
||||
expect(createRunAgentBuildFunction(ctx).getFullId()).toBe('eas/run_agent');
|
||||
});
|
||||
|
||||
it('passes none of the job environment to npm or the agent', async () => {
|
||||
mockSuccessfulRun();
|
||||
|
||||
await runAsync();
|
||||
|
||||
expect(leaseMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
expoApiV2BaseUrl: 'https://staging-api.expo.test/v2/',
|
||||
expoToken: 'expo-token',
|
||||
agentRunId: '0199b0a0-1111-7222-8333-444455556666',
|
||||
})
|
||||
);
|
||||
const npmEnv = installMock.mock.calls[0][2]!.env!;
|
||||
const agentEnv = spawnMock.mock.calls[0][2].env!;
|
||||
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'LANG', 'PATH', 'TMPDIR']);
|
||||
expect(Object.keys(agentEnv).sort()).toEqual([
|
||||
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
|
||||
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
'HOME',
|
||||
'LANG',
|
||||
'PATH',
|
||||
'TMPDIR',
|
||||
]);
|
||||
expect(agentEnv.PATH).toBe('/usr/local/bin:/usr/bin');
|
||||
expect(agentEnv.HOME).not.toBe('/home/expo');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['wss://staging-mcp.expo.test', 'https://staging-mcp.expo.test/mcp'],
|
||||
['ws://localhost:8787', 'http://localhost:8787/mcp'],
|
||||
])('points the agent at the HTTP endpoint of %s', async (mcpServerUrl, mcpUrl) => {
|
||||
const captured = mockSuccessfulRun();
|
||||
|
||||
await runAsync({ ctx: { ...ctx, mcpServerUrl } });
|
||||
|
||||
expect(captured.mcpConfig.mcpServers.expo.url).toBe(mcpUrl);
|
||||
});
|
||||
|
||||
it('stops the run at max_duration_seconds and reports a timeout', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
|
||||
const runAgentSpy = jest.spyOn(agentRun, 'runAgentAsync').mockImplementation(
|
||||
({ signal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
signal!.addEventListener('abort', () => reject(signal!.reason));
|
||||
})
|
||||
);
|
||||
try {
|
||||
const run = runAsync({ inputs: { ...inputs, max_duration_seconds: { value: 60 } } });
|
||||
const rejection = expect(run).rejects.toMatchObject({
|
||||
errorCode: 'EAS_RUN_AGENT_TIMEOUT',
|
||||
message: expect.stringContaining('60 seconds'),
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(59_000);
|
||||
expect(runAgentSpy.mock.calls[0][0].signal!.aborted).toBe(false);
|
||||
await jest.advanceTimersByTimeAsync(1_000);
|
||||
await rejection;
|
||||
} finally {
|
||||
runAgentSpy.mockRestore();
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an unknown agent_kind', { inputs: { ...inputs, agent_kind: { value: 'gemini' } } }],
|
||||
['a malformed agent_run_id', { inputs: { ...inputs, agent_run_id: { value: 'not-a-uuid' } } }],
|
||||
['an empty agent_run_id', { inputs: { ...inputs, agent_run_id: { value: '' } } }],
|
||||
['a zero duration', { inputs: { ...inputs, max_duration_seconds: { value: 0 } } }],
|
||||
['a negative duration', { inputs: { ...inputs, max_duration_seconds: { value: -300 } } }],
|
||||
['a fractional duration', { inputs: { ...inputs, max_duration_seconds: { value: 12.5 } } }],
|
||||
['an empty prompt', { inputs: { ...inputs, prompt: { value: '' } } }],
|
||||
['no EXPO_TOKEN', { env: { ...env, EXPO_TOKEN: '' } }],
|
||||
['no PATH', { env: { ...env, PATH: '' } }],
|
||||
['no Expo API URL', { ctx: { ...ctx, expoApiV2BaseUrl: undefined } }],
|
||||
['no MCP server URL', { ctx: { ...ctx, mcpServerUrl: undefined } }],
|
||||
])('throws a system error for %s', async (_name, overrides) => {
|
||||
await expect(runAsync(overrides)).rejects.toBeInstanceOf(SystemError);
|
||||
|
||||
expect(leaseMock).not.toHaveBeenCalled();
|
||||
expect(installMock).not.toHaveBeenCalled();
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports every invalid input at once', async () => {
|
||||
await expect(
|
||||
runAsync({
|
||||
inputs: {
|
||||
...inputs,
|
||||
agent_run_id: { value: 'not-a-uuid' },
|
||||
max_duration_seconds: { value: 0 },
|
||||
},
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'The "agent_run_id" input must be the UUID of an agent run, but received "not-a-uuid". ' +
|
||||
'The "max_duration_seconds" input must be a positive whole number of seconds, but received "0".'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
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)
|
||||
);
|
||||
});
|
||||
+234
-60
@@ -1,26 +1,26 @@
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForFileAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
|
||||
import { type StartupTasks, createStartupTasks } from '../../utils/startupTasks';
|
||||
import { runAgentDeviceRemoteSessionAsync } from '../startAgentDeviceRemoteSession';
|
||||
|
||||
// The daemon entry path and the state directory are resolved from the home directory when
|
||||
// the module loads, so point it at a temp home we can populate.
|
||||
@@ -40,6 +40,7 @@ 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(),
|
||||
@@ -47,7 +48,6 @@ 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,28 +66,34 @@ const mockTunnelStopAsync = jest.fn();
|
||||
const mockDaemonStopAsync = jest.fn();
|
||||
const mockEventCollectionStopAsync = jest.fn();
|
||||
|
||||
type CaptureInputs = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['capture'];
|
||||
const NO_CAPTURE: CaptureInputs = { networkCapture: false, networkCaptureFields: [] };
|
||||
|
||||
/** Runs the session for a device that is already booted, with the app (if any) launched. */
|
||||
async function runAsync(
|
||||
logger: { info: jest.Mock; warn: jest.Mock },
|
||||
logger: { info: jest.Mock; warn: jest.Mock; child: jest.Mock },
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
launchInputs: Record<string, { value: unknown }> = {}
|
||||
capture: CaptureInputs = NO_CAPTURE
|
||||
): Promise<void> {
|
||||
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
|
||||
await buildFunction.fn!(
|
||||
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
max_duration_seconds: { value: undefined },
|
||||
...launchInputs,
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env: {},
|
||||
logger: logger as never,
|
||||
runtimePlatform,
|
||||
sessionEnv: {
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
ngrokTunnelDomain: 'tunnel.example.com',
|
||||
ngrokAuthtoken: 'ngrok-token',
|
||||
},
|
||||
packageVersion: undefined,
|
||||
maxIdleTimeMinutes: undefined,
|
||||
maxDurationSeconds: undefined,
|
||||
capture,
|
||||
tasks: createStartupTasks(logger as never),
|
||||
device: { booted: Promise.resolve(), ready: Promise.resolve() },
|
||||
});
|
||||
}
|
||||
|
||||
describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () => {
|
||||
describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
@@ -104,6 +110,7 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 4242,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: mockDaemonStopAsync,
|
||||
});
|
||||
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 5678, token: 'daemon-token' });
|
||||
@@ -112,10 +119,13 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
|
||||
subdomainId: 'agent-device-abc',
|
||||
stopAsync: mockTunnelStopAsync,
|
||||
});
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
stopAsync: mockPreviewStopAsync,
|
||||
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(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
@@ -129,12 +139,11 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
|
||||
});
|
||||
|
||||
it('reports the preview URL and tears every resource down', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.LINUX);
|
||||
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
|
||||
);
|
||||
@@ -154,47 +163,212 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
it('hands network capture to serve-sim', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] },
|
||||
open_url: { value: 'exp://127.0.0.1:8081' },
|
||||
networkCapture: true,
|
||||
networkCaptureFields: ['header'],
|
||||
});
|
||||
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
expect(startDeviceSessionHostAsync).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',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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
|
||||
);
|
||||
});
|
||||
|
||||
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'));
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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,
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
})
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'serve-sim will launch host.exp.Exponent with arguments ' +
|
||||
'["-EXDevMenuIsOnboardingFinished","1"], then open exp://127.0.0.1:8081.'
|
||||
);
|
||||
});
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
it('fails before starting the daemon when a launch is asked for on Android', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
async function flushAsync(): Promise<void> {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
await expect(
|
||||
runAsync(logger, BuildRuntimePlatform.LINUX, {
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
})
|
||||
).rejects.toThrow('runs on linux');
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
});
|
||||
it('starts the 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();
|
||||
|
||||
it('declares the launch inputs', () => {
|
||||
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
|
||||
const globalCtx = createGlobalContextMock();
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(1);
|
||||
expect(startNgrokTunnelAsync).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+43
-2
@@ -75,6 +75,7 @@ describe(startAgentDeviceDaemonAsync, () => {
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 1234,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync,
|
||||
});
|
||||
jest.mocked(spawn).mockImplementation((async (
|
||||
@@ -108,7 +109,12 @@ 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' }),
|
||||
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',
|
||||
}),
|
||||
});
|
||||
|
||||
await handle.stopAsync();
|
||||
@@ -129,6 +135,36 @@ 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') {
|
||||
@@ -165,7 +201,12 @@ describe(startAgentDeviceDaemonAsync, () => {
|
||||
command: 'bun',
|
||||
args: ['run', 'src/daemon.ts'],
|
||||
cwd: '/tmp/agent-device-src',
|
||||
env: expect.objectContaining({ AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' }),
|
||||
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',
|
||||
}),
|
||||
});
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
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,6 +8,7 @@ import {
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../../utils/AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../../../utils/retry';
|
||||
import { resolveAndroidEmulatorLocalEgressAsync } from '../../utils/androidLocalEgress';
|
||||
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
@@ -19,6 +20,10 @@ 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',
|
||||
@@ -35,6 +40,7 @@ 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();
|
||||
@@ -65,6 +71,7 @@ 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;
|
||||
@@ -83,6 +90,82 @@ 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({
|
||||
|
||||
+180
-9
@@ -6,6 +6,7 @@ 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';
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
@@ -42,15 +42,17 @@ 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(),
|
||||
@@ -86,6 +88,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 4242,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: jest.fn(),
|
||||
});
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
|
||||
@@ -93,10 +96,13 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
subdomainId: 'argent-abc',
|
||||
stopAsync: mockTunnelStopAsync,
|
||||
});
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
stopAsync: mockPreviewStopAsync,
|
||||
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(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
@@ -113,7 +119,76 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
await fs.promises.rm(TEST_HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('enables the event log flag, shares one path, and starts/stops the collector', async () => {
|
||||
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, disables the video watermark, shares one path, and starts/stops the collector', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
|
||||
|
||||
@@ -156,6 +231,20 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
jest.mocked(spawnDetached).mock.invocationCallOrder[0]
|
||||
);
|
||||
|
||||
// (2b) The video watermark flag is disabled, before the tool-server is launched.
|
||||
const disableWatermarkIndex = spawnCalls.findIndex(
|
||||
([command, args]) =>
|
||||
command === 'bun' &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === 'x' &&
|
||||
args.includes('disable') &&
|
||||
args.includes('video-watermark')
|
||||
);
|
||||
expect(disableWatermarkIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(jest.mocked(spawn).mock.invocationCallOrder[disableWatermarkIndex]).toBeLessThan(
|
||||
jest.mocked(spawnDetached).mock.invocationCallOrder[0]
|
||||
);
|
||||
|
||||
// (3) The tool-server and the collector are pinned to the exact same event log path.
|
||||
const serverEnv = jest.mocked(spawnDetached).mock.calls[0][0].env;
|
||||
expect(serverEnv.ARGENT_EVENT_LOG).toBe(EXPECTED_EVENT_LOG_PATH);
|
||||
@@ -177,7 +266,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
|
||||
);
|
||||
@@ -192,6 +281,43 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('continues the session when disabling the video watermark fails', async () => {
|
||||
const error = new Error('bun x failed');
|
||||
jest
|
||||
.mocked(spawn)
|
||||
.mockImplementation(
|
||||
(_command, args) =>
|
||||
(args.includes('video-watermark')
|
||||
? Promise.reject(error)
|
||||
: Promise.resolve(undefined)) as never
|
||||
);
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: error },
|
||||
expect.stringContaining('Could not disable the Argent video watermark')
|
||||
);
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
@@ -216,7 +342,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
} as never
|
||||
);
|
||||
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
@@ -266,4 +392,49 @@ 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,6 +94,59 @@ 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,7 +1,8 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type BuildStepContext } from '@expo/steps';
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { startAndroidLocalEgressAsync } from '../../utils/androidLocalEgress';
|
||||
import {
|
||||
LOCAL_EGRESS_HANDOFF_PATH,
|
||||
configureSystemProxyAsync,
|
||||
@@ -20,6 +21,9 @@ 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),
|
||||
}));
|
||||
@@ -31,7 +35,12 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const server = { pid: 12345, getOutput: () => '', stopAsync: jest.fn() };
|
||||
const server = {
|
||||
pid: 12345,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: jest.fn(),
|
||||
};
|
||||
const tunnel = {
|
||||
url: 'https://egress.example.com',
|
||||
subdomainId: 'egress-id',
|
||||
@@ -47,6 +56,82 @@ 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();
|
||||
|
||||
+68
-16
@@ -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,10 +56,13 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('device-run-session-id');
|
||||
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
stopAsync,
|
||||
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(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
@@ -67,11 +70,14 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
});
|
||||
|
||||
it('reports the session token when serve-sim minted one', async () => {
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
previewToken: 'tok-1',
|
||||
stopAsync,
|
||||
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,
|
||||
});
|
||||
|
||||
await runAsync(BuildRuntimePlatform.DARWIN);
|
||||
@@ -79,14 +85,32 @@ 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],
|
||||
@@ -94,9 +118,8 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
await runAsync(runtimePlatform);
|
||||
|
||||
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(selectsXcode ? 1 : 0);
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(ctx, {
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: 'tunnel.example.com',
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 60_000,
|
||||
@@ -104,11 +127,14 @@ 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',
|
||||
},
|
||||
@@ -134,19 +160,21 @@ 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 web preview and announces them', async () => {
|
||||
it('hands the launch inputs to the session host 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(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
@@ -160,12 +188,36 @@ 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(startDeviceWebPreviewWithTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import nock from 'nock';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import {
|
||||
api,
|
||||
editPath,
|
||||
mockToken,
|
||||
packageName,
|
||||
serviceAccount,
|
||||
} from '../../utils/android/__tests__/googlePlayTestUtils';
|
||||
import { createSubmitToGooglePlayBuildFunction } from '../submitToGooglePlay';
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
let directory: string;
|
||||
let keyPath: string;
|
||||
let artifactPath: string;
|
||||
const session = '/upload/test-session';
|
||||
const createPath = `/androidpublisher/v3/applications/${packageName}/edits`;
|
||||
beforeEach(async () => {
|
||||
nock.disableNetConnect();
|
||||
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'play-submit-test-'));
|
||||
keyPath = path.join(directory, 'key.json');
|
||||
artifactPath = path.join(directory, 'app.aab');
|
||||
await fs.writeFile(artifactPath, 'binary');
|
||||
await fs.writeFile(
|
||||
keyPath,
|
||||
JSON.stringify({
|
||||
...serviceAccount,
|
||||
type: 'service_account',
|
||||
private_key: serviceAccount.private_key.export({ format: 'pem', type: 'pkcs8' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
afterEach(async () => {
|
||||
const pending = nock.pendingMocks();
|
||||
nock.cleanAll();
|
||||
nock.enableNetConnect();
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
expect(pending).toEqual([]);
|
||||
});
|
||||
function submission(extra: Record<string, unknown> = {}, signal?: AbortSignal) {
|
||||
const fn = createSubmitToGooglePlayBuildFunction();
|
||||
const step = fn.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
|
||||
callInputs: {
|
||||
artifact_path: artifactPath,
|
||||
artifact_type: 'aab',
|
||||
package_name: packageName,
|
||||
service_account_key_path: keyPath,
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
const setters = step.outputs.map(output => jest.spyOn(output, 'set'));
|
||||
return {
|
||||
setters,
|
||||
step,
|
||||
run: async () =>
|
||||
await fn.fn!(step.ctx, {
|
||||
inputs: Object.fromEntries(
|
||||
step.inputs!.map(input => [input.id, { value: input.rawValue }])
|
||||
),
|
||||
outputs: Object.fromEntries(step.outputs.map(output => [output.id, output])),
|
||||
env: {},
|
||||
signal,
|
||||
}),
|
||||
};
|
||||
}
|
||||
function upload() {
|
||||
mockToken();
|
||||
api().post(createPath).reply(200, { id: 'edit' });
|
||||
api()
|
||||
.post(`/upload${editPath}/bundles`)
|
||||
.query({ uploadType: 'resumable' })
|
||||
.reply(200, '', { Location: `https://androidpublisher.googleapis.com${session}` });
|
||||
nock('https://androidpublisher.googleapis.com').put(session).reply(200, { versionCode: 145 });
|
||||
}
|
||||
it.each([
|
||||
['completed', undefined, { status: 'completed' }],
|
||||
['completed', 0, 'invalid'],
|
||||
['completed', 0.25, { status: 'inProgress', userFraction: 0.25 }],
|
||||
['completed', 1, { status: 'completed' }],
|
||||
['draft', undefined, { status: 'draft' }],
|
||||
['draft', 0, 'invalid'],
|
||||
['draft', 0.25, 'invalid'],
|
||||
['draft', 1, 'invalid'],
|
||||
['halted', undefined, 'invalid'],
|
||||
['halted', 0, 'invalid'],
|
||||
['halted', 0.25, 'invalid'],
|
||||
['halted', 1, 'invalid'],
|
||||
['inProgress', undefined, 'invalid'],
|
||||
['inProgress', 0, 'invalid'],
|
||||
['inProgress', 0.25, { status: 'inProgress', userFraction: 0.25 }],
|
||||
['inProgress', 1, { status: 'completed' }],
|
||||
] as const)('validates and submits %s with rollout %s', async (status, rollout, expected) => {
|
||||
const task = submission({
|
||||
release_status: status,
|
||||
...(rollout !== undefined ? { rollout } : {}),
|
||||
});
|
||||
if (expected === 'invalid') {
|
||||
await expect(task.run()).rejects.toThrow(status === 'halted' ? /release_status/ : /rollout/);
|
||||
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
|
||||
return;
|
||||
}
|
||||
upload();
|
||||
api()
|
||||
.put(`${editPath}/tracks/internal`, {
|
||||
track: 'internal',
|
||||
releases: [{ ...expected, versionCodes: ['145'] }],
|
||||
})
|
||||
.reply(200, { track: 'internal' });
|
||||
api()
|
||||
.post(`${editPath}:commit`)
|
||||
.query({})
|
||||
.reply(() => {
|
||||
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
|
||||
return [200, { id: 'edit' }];
|
||||
});
|
||||
await task.run();
|
||||
expect(task.setters.map(set => set.mock.calls)).toEqual([[['145']]]);
|
||||
});
|
||||
it('runs the step with a string rollout and validates its required output', async () => {
|
||||
upload();
|
||||
api()
|
||||
.put(`${editPath}/tracks/internal`, {
|
||||
track: 'internal',
|
||||
releases: [{ status: 'inProgress', userFraction: 0.25, versionCodes: ['145'] }],
|
||||
})
|
||||
.reply(200, { track: 'internal' });
|
||||
api().post(`${editPath}:commit`).query({}).reply(200, { id: 'edit' });
|
||||
const { step } = submission({ rollout: '0.25' });
|
||||
expect(step.__hookId).toBe('submit');
|
||||
await step.executeAsync();
|
||||
expect(step.outputs[0].value).toBe('145');
|
||||
});
|
||||
it.each(['upload', 'track', 'abort'] as const)(
|
||||
'deletes the edit after %s failure and leaves outputs unset',
|
||||
async failure => {
|
||||
const controller = new AbortController();
|
||||
const task = submission({}, controller.signal);
|
||||
mockToken();
|
||||
api().post(createPath).reply(200, { id: 'edit' });
|
||||
api()
|
||||
.post(`/upload${editPath}/bundles`)
|
||||
.query({ uploadType: 'resumable' })
|
||||
.reply(() => {
|
||||
if (failure === 'abort') {
|
||||
controller.abort(new Error('step timed out'));
|
||||
}
|
||||
return failure === 'upload'
|
||||
? [400, { error: { message: 'rejected binary' } }]
|
||||
: [200, '', { Location: `https://androidpublisher.googleapis.com${session}` }];
|
||||
});
|
||||
if (failure === 'track') {
|
||||
nock('https://androidpublisher.googleapis.com').put(session).reply(200, { versionCode: 145 });
|
||||
api()
|
||||
.put(`${editPath}/tracks/internal`)
|
||||
.reply(400, { error: { message: 'unknown track' } });
|
||||
}
|
||||
api().delete(editPath).reply(204);
|
||||
await expect(task.run()).rejects.toThrow();
|
||||
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
|
||||
}
|
||||
);
|
||||
it('preserves the original failure when cleanup fails', async () => {
|
||||
upload();
|
||||
api()
|
||||
.put(`${editPath}/tracks/internal`)
|
||||
.reply(400, { error: { message: 'unknown track' } });
|
||||
api().delete(editPath).reply(500);
|
||||
await expect(submission().run()).rejects.toMatchObject({
|
||||
cause: expect.objectContaining({ apiMessage: 'unknown track' }),
|
||||
});
|
||||
});
|
||||
it('does not delete the edit or publish outputs when commit outcome is unknown', async () => {
|
||||
upload();
|
||||
api().put(`${editPath}/tracks/internal`).reply(200, { track: 'internal' });
|
||||
api().post(`${editPath}:commit`).query(true).reply(503);
|
||||
let deleted = false;
|
||||
api()
|
||||
.delete(editPath)
|
||||
.optionally()
|
||||
.reply(() => {
|
||||
deleted = true;
|
||||
return [204];
|
||||
});
|
||||
const task = submission();
|
||||
await expect(task.run()).rejects.toBeInstanceOf(SystemError);
|
||||
expect(deleted).toBe(false);
|
||||
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
|
||||
});
|
||||
it('reports a missing service-account file with its path', async () => {
|
||||
const missing = path.join(directory, 'missing.json');
|
||||
await expect(submission({ service_account_key_path: missing }).run()).rejects.toThrow(
|
||||
`Service-account key file not found at ${missing}`
|
||||
);
|
||||
});
|
||||
it('does not expose invalid credential contents', async () => {
|
||||
await fs.writeFile(keyPath, 'PRIVATE_SECRET_INVALID_JSON');
|
||||
const error = await submission()
|
||||
.run()
|
||||
.catch(error => error);
|
||||
expect(error).toBeInstanceOf(UserError);
|
||||
expect(JSON.stringify(error) + String(error)).not.toContain('PRIVATE_SECRET');
|
||||
});
|
||||
|
||||
it('preserves the cause when the service-account file cannot be read', async () => {
|
||||
const cause = Object.assign(new Error('permission denied'), { code: 'EACCES' });
|
||||
const read = jest.spyOn(fs, 'readFile').mockRejectedValueOnce(cause);
|
||||
try {
|
||||
await expect(submission().run()).rejects.toMatchObject({ cause });
|
||||
} finally {
|
||||
read.mockRestore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,621 @@
|
||||
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'),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
+24
-10
@@ -20,7 +20,8 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockReset()
|
||||
.mockImplementation(async (_ctx, { stream }) => {
|
||||
.mockImplementation(async (_ctx, { openStream }) => {
|
||||
const stream = openStream();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stream.once('end', resolve);
|
||||
stream.once('error', reject);
|
||||
@@ -29,7 +30,20 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads simulator device metadata', async () => {
|
||||
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 }) => {
|
||||
const recordingDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'ios-recording-test-'));
|
||||
const recordingPath = path.join(recordingDirectory, 'recording.mp4');
|
||||
await fs.writeFile(recordingPath, 'recording');
|
||||
@@ -45,7 +59,7 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
|
||||
try {
|
||||
const globalContext = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
runtimePlatform: platform,
|
||||
});
|
||||
const buildStep = createUploadDeviceRunSessionScreenRecordingsBuildFunction(
|
||||
{} as CustomBuildContext
|
||||
@@ -54,9 +68,9 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
callInputs: {
|
||||
recordings_json: [
|
||||
{
|
||||
udid: SIMULATOR_UDID,
|
||||
deviceName: 'iPhone 16',
|
||||
runtimeDisplayName: 'iOS 18.6',
|
||||
udid,
|
||||
deviceName: name,
|
||||
runtimeDisplayName: runtime,
|
||||
directory: recordingDirectory,
|
||||
},
|
||||
],
|
||||
@@ -68,13 +82,13 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
name: 'iPhone 16 screen recording (01234567-…, started at Jul 10, 2026, 10:00:00.000 UTC)',
|
||||
name: `${name} screen recording (${udid.slice(0, 8)}-…, started at Jul 10, 2026, 10:00:00.000 UTC)`,
|
||||
metadata: {
|
||||
__eas_type: 'screen-recording',
|
||||
__eas_screen_recording: '1',
|
||||
udid: SIMULATOR_UDID,
|
||||
deviceName: 'iPhone 16',
|
||||
runtimeDisplayName: 'iOS 18.6',
|
||||
udid,
|
||||
deviceName: name,
|
||||
runtimeDisplayName: runtime,
|
||||
firstFrameAt: '2026-07-10T10:00:00.000Z',
|
||||
width: 1179,
|
||||
height: 2556,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { ProjectConfig } from '@expo/config';
|
||||
import { BuildJob } from '@expo/eas-build-job';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createTestAndroidJob } from '../../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../../context';
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { readAppConfig } from '../../../utils/appConfig';
|
||||
import { uploadEmbeddedBundleAsync } from '../../../utils/expoUpdatesEmbedded';
|
||||
import { createUploadEmbeddedBundleBuildFunction } from '../uploadEmbeddedBundle';
|
||||
|
||||
jest.mock('../../../utils/appConfig');
|
||||
jest.mock('../../../utils/expoUpdatesEmbedded');
|
||||
|
||||
function mockAppConfig(updatesUrl: string | undefined): void {
|
||||
jest.mocked(readAppConfig).mockResolvedValue({
|
||||
exp: { name: 'my-app', slug: 'my-app', updates: updatesUrl ? { url: updatesUrl } : undefined },
|
||||
} as ProjectConfig);
|
||||
}
|
||||
|
||||
function createCustomContext(job: BuildJob): CustomBuildContext<BuildJob> {
|
||||
return new CustomBuildContext(
|
||||
new BuildContext(job, {
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test' },
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
uploadArtifact: jest.fn(),
|
||||
workingdir: '',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
describe(createUploadEmbeddedBundleBuildFunction, () => {
|
||||
const job = createTestAndroidJob({});
|
||||
const customContext = createCustomContext(job);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('uploads the embedded bundle from the working directory', async () => {
|
||||
mockAppConfig('https://u.expo.dev/project-id');
|
||||
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValue({ status: 'uploaded' });
|
||||
const globalContext = createGlobalContextMock({});
|
||||
globalContext.updateEnv({ EAS_BUILD_ID: 'build-123' });
|
||||
const buildStep =
|
||||
createUploadEmbeddedBundleBuildFunction(customContext).createBuildStepFromFunctionCall(
|
||||
globalContext
|
||||
);
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
job,
|
||||
env: expect.objectContaining({ EAS_BUILD_ID: 'build-123' }),
|
||||
projectDir: globalContext.defaultWorkingDirectory,
|
||||
appConfig: expect.objectContaining({ updates: { url: 'https://u.expo.dev/project-id' } }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('logs when the embedded bundle upload is skipped', async () => {
|
||||
mockAppConfig('https://u.expo.dev/project-id');
|
||||
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValue({ status: 'skipped' });
|
||||
const buildStep = createUploadEmbeddedBundleBuildFunction(
|
||||
customContext
|
||||
).createBuildStepFromFunctionCall(createGlobalContextMock({}));
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.ctx.logger.info).toHaveBeenCalledWith('Skipping embedded bundle upload.');
|
||||
});
|
||||
|
||||
it('fails the step when the upload fails', async () => {
|
||||
mockAppConfig('https://u.expo.dev/project-id');
|
||||
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValue({ status: 'failed' });
|
||||
const buildStep = createUploadEmbeddedBundleBuildFunction(
|
||||
customContext
|
||||
).createBuildStepFromFunctionCall(createGlobalContextMock({}));
|
||||
|
||||
await expect(buildStep.executeAsync()).rejects.toThrow('Failed to upload embedded bundle.');
|
||||
});
|
||||
|
||||
it('does not fail the step when the upload fails and ignore_error is set', async () => {
|
||||
mockAppConfig('https://u.expo.dev/project-id');
|
||||
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValue({ status: 'failed' });
|
||||
const buildStep = createUploadEmbeddedBundleBuildFunction(
|
||||
customContext
|
||||
).createBuildStepFromFunctionCall(createGlobalContextMock({}), {
|
||||
callInputs: { ignore_error: true },
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.ctx.logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Failed to upload embedded bundle. Ignoring error.'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails the step when the app config cannot be read', async () => {
|
||||
jest.mocked(readAppConfig).mockRejectedValue(new Error('Invalid app config'));
|
||||
const buildStep = createUploadEmbeddedBundleBuildFunction(
|
||||
customContext
|
||||
).createBuildStepFromFunctionCall(createGlobalContextMock({}));
|
||||
|
||||
await expect(buildStep.executeAsync()).rejects.toThrow('Invalid app config');
|
||||
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fail the step when the app config cannot be read and ignore_error is set', async () => {
|
||||
jest.mocked(readAppConfig).mockRejectedValue(new Error('Invalid app config'));
|
||||
const buildStep = createUploadEmbeddedBundleBuildFunction(
|
||||
customContext
|
||||
).createBuildStepFromFunctionCall(createGlobalContextMock({}), {
|
||||
callInputs: { ignore_error: true },
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.ctx.logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Failed to upload embedded bundle. Ignoring error.'
|
||||
);
|
||||
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
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,6 +25,7 @@ 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';
|
||||
|
||||
@@ -78,7 +79,7 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
required: true,
|
||||
}),
|
||||
],
|
||||
fn: async (stepsCtx, { inputs, outputs }) => {
|
||||
fn: async (stepsCtx, { inputs, outputs, signal }) => {
|
||||
const { logger } = stepsCtx;
|
||||
|
||||
const extensions = z.array(z.string()).parse(inputs.extensions.value);
|
||||
@@ -119,6 +120,7 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
robotAccessToken: stepsCtx.global.staticContext.job.secrets?.robotAccessToken ?? null,
|
||||
extensions,
|
||||
signal,
|
||||
});
|
||||
|
||||
outputs.artifact_path.set(artifactPath);
|
||||
@@ -129,11 +131,17 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
async function fetchApplicationArchiveUrlAsync({
|
||||
buildId,
|
||||
graphqlClient,
|
||||
signal,
|
||||
}: {
|
||||
buildId: string;
|
||||
graphqlClient: Client;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<string> {
|
||||
const result = await graphqlClient.query(BUILD_BY_ID_QUERY, { buildId }).toPromise();
|
||||
signal?.throwIfAborted();
|
||||
const result = await graphqlClient
|
||||
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
|
||||
.toPromise();
|
||||
signal?.throwIfAborted();
|
||||
|
||||
if (result.error) {
|
||||
const { error } = result;
|
||||
@@ -161,9 +169,11 @@ 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 } = params;
|
||||
const { logger, graphqlClient, robotAccessToken, extensions, signal } = params;
|
||||
|
||||
let downloadUrl: string;
|
||||
let headers: { Authorization: string } | undefined;
|
||||
@@ -178,7 +188,7 @@ export async function downloadBuildAsync(
|
||||
headers = undefined;
|
||||
} else if (params.buildId) {
|
||||
const buildId = z.string().uuid().parse(params.buildId);
|
||||
downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient });
|
||||
downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient, signal });
|
||||
headers = robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined;
|
||||
} else {
|
||||
throw new UserError(
|
||||
@@ -191,7 +201,8 @@ export async function downloadBuildAsync(
|
||||
path.join(os.tmpdir(), 'download_build-downloaded-')
|
||||
);
|
||||
|
||||
const response = await retryOnDNSFailure(fetch)(downloadUrl, { headers });
|
||||
signal?.throwIfAborted();
|
||||
const response = await retryOnDNSFailure(fetch)(downloadUrl, { headers, signal });
|
||||
|
||||
if (!response.ok) {
|
||||
const textResult = await asyncResult(response.text());
|
||||
@@ -201,7 +212,9 @@ 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);
|
||||
|
||||
@@ -217,10 +230,15 @@ 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,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import { graphql } from 'gql.tada';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { downloadBuildAsync } from './downloadBuild';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import { promiseRetryWithCondition } from '../../utils/promiseRetryWithCondition';
|
||||
|
||||
const CREATE_ARCHIVE_DOWNLOAD_URL = graphql(`
|
||||
mutation GenerateSubmissionArchiveDownloadUrl($submissionId: ID!) {
|
||||
submission {
|
||||
generateSubmissionArchiveDownloadUrl(submissionId: $submissionId)
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export function createDownloadSubmissionArchiveFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'download_submission_archive',
|
||||
name: 'Download submission archive',
|
||||
__metricsId: 'eas/download_submission_archive',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'submission_id',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'extensions',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
defaultValue: ['apk', 'aab', 'ipa'],
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'artifact_path',
|
||||
required: true,
|
||||
}),
|
||||
],
|
||||
fn: async (stepsCtx, { inputs, outputs, signal }) => {
|
||||
const { submissionId, extensions } = z
|
||||
.object({
|
||||
submissionId: z.string().uuid(),
|
||||
extensions: z.array(z.string()),
|
||||
})
|
||||
.parse({
|
||||
submissionId: inputs.submission_id.value,
|
||||
extensions: inputs.extensions.value,
|
||||
});
|
||||
|
||||
stepsCtx.logger.info(`Downloading archive for submission ${submissionId}...`);
|
||||
|
||||
const applicationArchiveUrl = await promiseRetryWithCondition(
|
||||
async () => {
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(CREATE_ARCHIVE_DOWNLOAD_URL, { submissionId }, graphqlAbortContext(signal))
|
||||
.toPromise();
|
||||
|
||||
signal?.throwIfAborted();
|
||||
|
||||
if (result.error) {
|
||||
throw result.error.networkError || result.error.response?.status >= 500
|
||||
? new SystemError('Could not request the submission archive. Try again later.', {
|
||||
cause: result.error,
|
||||
})
|
||||
: new UserError(
|
||||
'EAS_SUBMISSION_ARCHIVE_FETCH_FAILED',
|
||||
'Could not request the submission archive. Check your project access and the submission archive.',
|
||||
{ cause: result.error }
|
||||
);
|
||||
}
|
||||
|
||||
const url = result.data?.submission.generateSubmissionArchiveDownloadUrl;
|
||||
|
||||
if (!url) {
|
||||
throw new SystemError(
|
||||
'The server did not return a submission archive URL. Try again later.'
|
||||
);
|
||||
}
|
||||
return url;
|
||||
},
|
||||
error => !signal?.aborted && error instanceof SystemError,
|
||||
{ retries: 3, minTimeout: 1000, maxTimeout: 1000 },
|
||||
({ attemptNumber, maxAttemptsCount }) => {
|
||||
stepsCtx.logger.info(
|
||||
`Retrying submission archive URL request (${attemptNumber}/${maxAttemptsCount})...`
|
||||
);
|
||||
}
|
||||
)();
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: stepsCtx.logger,
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
applicationArchiveUrl,
|
||||
robotAccessToken: null,
|
||||
extensions,
|
||||
signal,
|
||||
});
|
||||
|
||||
outputs.artifact_path.set(artifactPath);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -199,10 +199,15 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
}
|
||||
|
||||
if (backend === 'maestro-runner' && global.runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await installMaestroRunnerWdaCache({
|
||||
logger,
|
||||
env,
|
||||
});
|
||||
// Runner 1.1.28 switched the default iOS driver to the bundled DeviceLab agent.
|
||||
// Use the detected version after installation, including when `latest` was requested.
|
||||
const installedRunnerVersion = semver.coerce(maestroVersionResult.value);
|
||||
if (!installedRunnerVersion || semver.lt(installedRunnerVersion, '1.1.28')) {
|
||||
await installMaestroRunnerWdaCache({
|
||||
logger,
|
||||
env,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`${backend} ${maestroVersionResult.value} is ready.`);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
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;
|
||||
}
|
||||
@@ -118,17 +118,7 @@ export async function launchApplicationAsync({
|
||||
logger.info(`Opening ${openUrl} in ${applicationIdentifier}.`);
|
||||
await spawn(
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'-a',
|
||||
'android.intent.action.VIEW',
|
||||
'-d',
|
||||
openUrl,
|
||||
'-n',
|
||||
`${applicationIdentifier}/${activityName}`,
|
||||
],
|
||||
['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
|
||||
{ env, logger }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,6 +107,8 @@ 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}`);
|
||||
@@ -213,23 +215,20 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
}
|
||||
const testsDirectory = path.join(home, '.maestro', 'tests');
|
||||
const junitReportDirectory = path.join(testsDirectory, 'junit-reports');
|
||||
const finalReportPath =
|
||||
outputFormat === 'junit'
|
||||
? path.join(testsDirectory, `${platform}-maestro-junit.xml`)
|
||||
: undefined;
|
||||
const junitFinalReportPath = path.join(testsDirectory, `${platform}-maestro-junit.xml`);
|
||||
|
||||
// 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 };
|
||||
|
||||
// Outputs are published BEFORE any throw below so downstream
|
||||
// `if: always()` upload steps still see populated values when this
|
||||
// step fails early.
|
||||
// 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.tests_directory.set(testsDirectory);
|
||||
outputs.junit_report_directory.set(junitReportDirectory);
|
||||
if (finalReportPath !== undefined) {
|
||||
outputs.final_report_path.set(finalReportPath);
|
||||
if (outputFormat === 'junit') {
|
||||
outputs.final_report_path.set(junitFinalReportPath);
|
||||
}
|
||||
|
||||
// Resolved after the output assignments above: an invalid backend input or
|
||||
@@ -239,6 +238,16 @@ 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,
|
||||
@@ -262,10 +271,14 @@ 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 && outputFormat !== 'junit') {
|
||||
if (
|
||||
backend === 'maestro-runner' &&
|
||||
outputFormat !== undefined &&
|
||||
!['junit', 'html', 'allure'].includes(outputFormat)
|
||||
) {
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_INVALID_INPUT',
|
||||
`maestro-runner only supports the "junit" output_format, but received "${outputFormat}".`
|
||||
`maestro-runner supports "junit", "html", and "allure" output_format values, but received "${outputFormat}".`
|
||||
);
|
||||
}
|
||||
const retryFailedOnly = inputs.retry_failed_only.value as boolean;
|
||||
@@ -337,17 +350,14 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
// maestro-runner writes its JUnit report and screenshot metadata to this directory.
|
||||
// maestro-runner writes all report formats and screenshot metadata here.
|
||||
const runnerOutputDirectory = path.join(
|
||||
testsDirectory,
|
||||
`${platform}-maestro-runner-attempt-${attempt}`
|
||||
);
|
||||
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 outputPath = mayHaveJUnitReports
|
||||
? path.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
|
||||
: maestroCliOutputPath;
|
||||
const { executable, args: maestroArgs } = buildMaestroArgs({
|
||||
backend,
|
||||
platform,
|
||||
@@ -371,6 +381,17 @@ 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();
|
||||
|
||||
@@ -412,9 +433,8 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
reportDirectories.push(runnerOutputDirectory);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
if (outputFormat === 'junit') {
|
||||
let screenshots: HarvestedScreenshot[];
|
||||
switch (backend) {
|
||||
@@ -504,14 +524,15 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
await sleepAsync(2000);
|
||||
}
|
||||
|
||||
// Smart merge first; on data errors (bad XML, missing input) fall back
|
||||
// to copy-latest so the caller still gets a single JUnit file.
|
||||
// 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.
|
||||
// Filesystem errors short-circuit straight to SystemError.
|
||||
if (finalReportPath !== undefined) {
|
||||
if (outputFormat === 'junit') {
|
||||
try {
|
||||
await mergeJUnitReports({
|
||||
sourceDir: junitReportDirectory,
|
||||
outputPath: finalReportPath,
|
||||
outputPath: junitFinalReportPath,
|
||||
});
|
||||
} catch (mergeErr: any) {
|
||||
if (isFilesystemError(mergeErr)) {
|
||||
@@ -521,7 +542,7 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
try {
|
||||
await copyLatestAttemptXml({
|
||||
sourceDir: junitReportDirectory,
|
||||
outputPath: finalReportPath,
|
||||
outputPath: junitFinalReportPath,
|
||||
});
|
||||
} catch (copyErr: any) {
|
||||
// Swallow: a copy failure here usually means maestro itself failed
|
||||
@@ -529,14 +550,13 @@ 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 ${finalReportPath}: ${copyErr?.message ?? copyErr}`
|
||||
`Failed to produce final_report_path at ${junitFinalReportPath}: ${copyErr?.message ?? copyErr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Upload before the failure verdict so fully-failed runs still get screenshots.
|
||||
if (outputFormat === 'junit') {
|
||||
await uploadFailureScreenshotsAsync({
|
||||
harvested,
|
||||
@@ -547,6 +567,81 @@ 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.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { readAndroidArtifactInfoAsync } from '../utils/android/appArtifact';
|
||||
|
||||
export function createReadAndroidAppInfoBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'read_android_app_info',
|
||||
name: 'Read Android app info',
|
||||
__metricsId: 'eas/read_android_app_info',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'artifact_path',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({ id: 'artifact_type', required: true }),
|
||||
BuildStepOutput.createProvider({ id: 'package_name', required: true }),
|
||||
],
|
||||
fn: async (ctx, { inputs, outputs, signal }) => {
|
||||
const artifactPath = path.resolve(
|
||||
ctx.workingDirectory,
|
||||
z.string().min(1).parse(inputs.artifact_path.value)
|
||||
);
|
||||
ctx.logger.info(`Reading Android artifact: ${artifactPath}.`);
|
||||
const artifact = await readAndroidArtifactInfoAsync(artifactPath, signal);
|
||||
ctx.logger.info(`Android artifact type: ${artifact.artifactType}.`);
|
||||
ctx.logger.info(`Android package name: ${artifact.packageName}.`);
|
||||
outputs.artifact_type.set(artifact.artifactType);
|
||||
outputs.package_name.set(artifact.packageName);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { AGENT_KINDS, runAgentAsync } from '../utils/agentRun';
|
||||
|
||||
export function createRunAgentBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'run_agent',
|
||||
name: 'Run agent',
|
||||
__metricsId: 'eas/run_agent',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.LINUX],
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'agent_kind',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'agent_run_id',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_duration_seconds',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'prompt',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, env, signal }) => {
|
||||
const inputErrors = {
|
||||
agentKind: `The "agent_kind" input must be one of ${AGENT_KINDS.join(', ')}, but received "${String(inputs.agent_kind.value)}".`,
|
||||
agentRunId: `The "agent_run_id" input must be the UUID of an agent run, but received "${String(inputs.agent_run_id.value)}".`,
|
||||
maxDurationSeconds: `The "max_duration_seconds" input must be a positive whole number of seconds, but received "${String(inputs.max_duration_seconds.value)}".`,
|
||||
prompt: 'The "prompt" input must be a non-empty string.',
|
||||
};
|
||||
const parsedInputs = z
|
||||
.object({
|
||||
agent_kind: z.enum(AGENT_KINDS, { error: inputErrors.agentKind }),
|
||||
agent_run_id: z.uuid({ error: inputErrors.agentRunId }),
|
||||
max_duration_seconds: z
|
||||
.number({ error: inputErrors.maxDurationSeconds })
|
||||
.int({ error: inputErrors.maxDurationSeconds })
|
||||
.positive({ error: inputErrors.maxDurationSeconds }),
|
||||
prompt: z.string({ error: inputErrors.prompt }).min(1, { error: inputErrors.prompt }),
|
||||
})
|
||||
.safeParse({
|
||||
agent_kind: inputs.agent_kind.value,
|
||||
agent_run_id: inputs.agent_run_id.value,
|
||||
max_duration_seconds: inputs.max_duration_seconds.value,
|
||||
prompt: inputs.prompt.value,
|
||||
});
|
||||
if (!parsedInputs.success) {
|
||||
throw createValidationError(parsedInputs.error);
|
||||
}
|
||||
const {
|
||||
agent_kind: agentKind,
|
||||
agent_run_id: agentRunId,
|
||||
max_duration_seconds: maxDurationSeconds,
|
||||
prompt,
|
||||
} = parsedInputs.data;
|
||||
const parsedEnv = z
|
||||
.object({
|
||||
EXPO_TOKEN: z
|
||||
.string({ error: 'EXPO_TOKEN is required to run an agent.' })
|
||||
.min(1, { error: 'EXPO_TOKEN is required to run an agent.' }),
|
||||
PATH: z
|
||||
.string({ error: 'PATH is required to run an agent.' })
|
||||
.min(1, { error: 'PATH is required to run an agent.' }),
|
||||
EAS_BUILD_NPM_CACHE_URL: z.string().optional(),
|
||||
})
|
||||
.safeParse(env);
|
||||
if (!parsedEnv.success) {
|
||||
throw createValidationError(parsedEnv.error);
|
||||
}
|
||||
const {
|
||||
EXPO_TOKEN: expoToken,
|
||||
PATH: pathEnv,
|
||||
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
|
||||
} = parsedEnv.data;
|
||||
if (!ctx.expoApiV2BaseUrl) {
|
||||
throw new SystemError('Expo API URL is required to run an agent.');
|
||||
}
|
||||
if (!ctx.mcpServerUrl) {
|
||||
throw new SystemError('MCP server URL is required to run an agent.');
|
||||
}
|
||||
// The worker config holds the MCP server's WebSocket URL; its HTTP endpoint is on the same host.
|
||||
const mcpUrl = new URL('/mcp', ctx.mcpServerUrl);
|
||||
mcpUrl.protocol = mcpUrl.protocol.replace(/^ws/, 'http');
|
||||
|
||||
const timeoutError = new UserError(
|
||||
'EAS_RUN_AGENT_TIMEOUT',
|
||||
`The agent did not finish within its limit of ${maxDurationSeconds} seconds, so it was stopped. Raise the agent's maximum duration or give it a smaller task, then start a new agent run.`
|
||||
);
|
||||
const timeoutController = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => timeoutController.abort(timeoutError),
|
||||
maxDurationSeconds * 1000
|
||||
);
|
||||
try {
|
||||
await runAgentAsync({
|
||||
agentKind,
|
||||
agentRunId,
|
||||
prompt,
|
||||
expoToken,
|
||||
expoApiV2BaseUrl: ctx.expoApiV2BaseUrl,
|
||||
mcpUrl: mcpUrl.toString(),
|
||||
pathEnv,
|
||||
npmCacheUrl,
|
||||
logger: stepCtx.logger,
|
||||
signal: signal
|
||||
? AbortSignal.any([signal, timeoutController.signal])
|
||||
: timeoutController.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createValidationError(error: z.ZodError): SystemError {
|
||||
return new SystemError(error.issues.map(issue => issue.message).join(' '));
|
||||
}
|
||||
@@ -1,22 +1,14 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
type BuildStepEnv,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import { type BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { uploadRemoteSessionConfigWithLocalEgressAsync } from '../utils/localEgressSession';
|
||||
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
PackageManager,
|
||||
@@ -25,18 +17,16 @@ 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,
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForFileAsync,
|
||||
@@ -51,174 +41,238 @@ 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 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);
|
||||
export type AgentDeviceRemoteSessionEnv = {
|
||||
deviceRunSessionId: string;
|
||||
ngrokTunnelDomain: string;
|
||||
ngrokAuthtoken: string;
|
||||
};
|
||||
|
||||
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}).`
|
||||
);
|
||||
/**
|
||||
* 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),
|
||||
};
|
||||
}
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
/**
|
||||
* 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}).`
|
||||
);
|
||||
|
||||
logger.info('Launching agent-device daemon.');
|
||||
const daemonProcess = await startAgentDeviceDaemonAsync({ packageVersion, env, logger });
|
||||
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;
|
||||
|
||||
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.`);
|
||||
// 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,
|
||||
});
|
||||
|
||||
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}.`);
|
||||
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.`);
|
||||
|
||||
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,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} finally {
|
||||
if (webPreview) {
|
||||
await webPreview.stopAsync();
|
||||
}
|
||||
await agentDeviceTunnel.stopAsync();
|
||||
if (eventCollection) {
|
||||
await stopAgentDeviceEventCollectionSafelyAsync({
|
||||
eventCollection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
await daemonProcess.stopAsync();
|
||||
}
|
||||
}),
|
||||
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;
|
||||
}
|
||||
);
|
||||
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,
|
||||
});
|
||||
|
||||
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,
|
||||
}
|
||||
: 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,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await startedDaemon.stopAsync();
|
||||
}
|
||||
})(),
|
||||
],
|
||||
['session host', sessionHost?.finishAsync()],
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -231,13 +285,14 @@ 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 });
|
||||
await spawn(add.command, add.args, { cwd: installDir, env, logger, signal });
|
||||
|
||||
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',
|
||||
@@ -253,6 +308,10 @@ 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(
|
||||
@@ -275,7 +334,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 });
|
||||
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger, signal });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,17 +364,19 @@ 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 });
|
||||
await cloneAgentDeviceAsync({ packageVersion, env, logger, signal });
|
||||
|
||||
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
|
||||
const install = resolvePackageInstall(packageManager, { production: true });
|
||||
@@ -324,8 +385,10 @@ 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({
|
||||
@@ -340,15 +403,18 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
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,8 +1,10 @@
|
||||
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,
|
||||
@@ -14,10 +16,12 @@ 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;
|
||||
@@ -80,140 +84,20 @@ 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 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,
|
||||
});
|
||||
},
|
||||
{
|
||||
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,
|
||||
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) {
|
||||
@@ -306,6 +190,167 @@ 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,5 +1,6 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
@@ -19,6 +20,11 @@ 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 {
|
||||
@@ -32,13 +38,14 @@ import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
|
||||
import {
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
@@ -63,6 +70,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
__metricsId: 'eas/start_appium_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -89,18 +97,30 @@ 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 });
|
||||
const { appiumHome, appiumBinPath, appiumEnv } = await installAppiumAsync({
|
||||
versionSpec,
|
||||
driverName: device.driverName,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
// Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the
|
||||
// simulator stream with it, and UiAutomator2 merges long recordings with it. The
|
||||
// macOS session image does not ship ffmpeg, so install it concurrently with Appium.
|
||||
// Both installs settle before the step continues or fails, so no install outlives it.
|
||||
const [appiumInstall] = await Promise.all([
|
||||
asyncResult(
|
||||
installAppiumAsync({ versionSpec, driverName: device.driverName, env, logger })
|
||||
),
|
||||
ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger }),
|
||||
]);
|
||||
const { appiumHome, appiumBinPath, appiumEnv } = appiumInstall.enforceValue();
|
||||
|
||||
const appiumProcess = spawnDetached({
|
||||
command: appiumBinPath,
|
||||
@@ -138,7 +158,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
logger,
|
||||
});
|
||||
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let sessionFailed = false;
|
||||
try {
|
||||
appiumTunnel = await startNgrokTunnelAsync({
|
||||
port: APPIUM_PORT,
|
||||
@@ -154,16 +175,18 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
sessionHost = await startDeviceSessionHostAsync(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,
|
||||
@@ -197,16 +220,29 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
sessionFailed = true;
|
||||
throw error;
|
||||
} finally {
|
||||
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 });
|
||||
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()],
|
||||
],
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -18,6 +18,11 @@ 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,
|
||||
@@ -32,13 +37,13 @@ import {
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
@@ -50,6 +55,9 @@ export const MIN_ARGENT_REMOTE_SESSION_VERSION = '0.16.0';
|
||||
const ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG = 'artifacts-list-endpoint';
|
||||
// Tells the tool-server to write its structured event log so we can collect session events.
|
||||
const ARGENT_EVENT_LOG_FLAG = 'tool-server-event-log';
|
||||
// On by default in Argent (since 0.17.0); we turn it off so recorded videos have no Argent
|
||||
// watermark. Older versions that don't know the flag treat `disable` as a no-op.
|
||||
const ARGENT_VIDEO_WATERMARK_FLAG = 'video-watermark';
|
||||
const ARGENT_STATE_DIR = path.join(os.homedir(), '.argent');
|
||||
// Pin the event log path explicitly and hand the same value to the tool-server (via
|
||||
// ARGENT_EVENT_LOG) and the collector, so an ambient ARGENT_EVENT_LOG or a future change to
|
||||
@@ -75,6 +83,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
__metricsId: 'eas/start_argent_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -115,6 +124,13 @@ 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}).`
|
||||
);
|
||||
@@ -150,6 +166,22 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
]);
|
||||
await spawn(enableEventLog.command, enableEventLog.args, { env, logger });
|
||||
|
||||
logger.info('Disabling the Argent video watermark flag.');
|
||||
const disableVideoWatermark = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'disable',
|
||||
ARGENT_VIDEO_WATERMARK_FLAG,
|
||||
]);
|
||||
// The watermark is cosmetic, so a failure here must not fail the session.
|
||||
try {
|
||||
await spawn(disableVideoWatermark.command, disableVideoWatermark.args, { env, logger });
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.'
|
||||
);
|
||||
}
|
||||
|
||||
const startServer = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
@@ -184,6 +216,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
stateDir: ARGENT_STATE_DIR,
|
||||
ancestorPid: argentServer.pid,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
getExitError: argentServer.getExitError,
|
||||
});
|
||||
toolServerPort = toolServerState.port;
|
||||
toolServerToken = toolServerState.token;
|
||||
@@ -216,7 +249,8 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
});
|
||||
|
||||
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let sessionFailed = false;
|
||||
try {
|
||||
toolsTunnel = await startNgrokTunnelAsync({
|
||||
port: toolServerPort,
|
||||
@@ -233,16 +267,18 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
sessionHost = await startDeviceSessionHostAsync(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}).`
|
||||
);
|
||||
@@ -276,23 +312,46 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
sessionFailed = true;
|
||||
throw error;
|
||||
} finally {
|
||||
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();
|
||||
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()],
|
||||
],
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -371,16 +430,26 @@ 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')
|
||||
@@ -391,7 +460,8 @@ export async function waitForArgentToolServerStateAsync({
|
||||
await fs.promises.readFile(path.join(stateDir, stateFileName), 'utf8')
|
||||
);
|
||||
if (await isProcessDescendantOfAsync(state.pid, ancestorPid)) {
|
||||
return state;
|
||||
matchingState = state;
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT' && !(err instanceof SystemError)) {
|
||||
@@ -402,6 +472,11 @@ 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,59 +47,18 @@ 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);
|
||||
|
||||
if (!originalDeviceIdentifier) {
|
||||
throw new Error('Could not find an iPhone among available simulator devices.');
|
||||
}
|
||||
|
||||
if (enableAccessibilitySettings) {
|
||||
await IosSimulatorUtils.enableAccessibilitySettingsAsync({
|
||||
deviceIdentifier: originalDeviceIdentifier,
|
||||
const { deviceIdentifier: originalDeviceIdentifier, displayName: formattedDevice } =
|
||||
await bootIosSimulatorAsync({
|
||||
deviceIdentifier: deviceIdentifierInput,
|
||||
enableAccessibilitySettings,
|
||||
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) {
|
||||
@@ -150,6 +109,67 @@ 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,6 +4,7 @@ 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,
|
||||
@@ -79,8 +80,9 @@ 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. The shared session cleanup releases the resources started here when
|
||||
* the session ends, with a job finalizer as a fallback.
|
||||
* 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.
|
||||
*/
|
||||
export function createStartLocalEgressBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -88,13 +90,15 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
|
||||
id: 'start_local_egress',
|
||||
name: 'Start local egress',
|
||||
__metricsId: 'eas/start_local_egress',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
fn: async ({ logger }, { env, signal }) => {
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX],
|
||||
fn: async ({ logger, global }, { env, signal }) => {
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
const isAndroid = global?.runtimePlatform === BuildRuntimePlatform.LINUX;
|
||||
let workDir: string | undefined;
|
||||
let server: DetachedProcessHandle | undefined;
|
||||
let tunnel: NgrokTunnelHandle | undefined;
|
||||
let android: { stopAsync: () => Promise<void> } | undefined;
|
||||
let finishSetup!: () => void;
|
||||
const setupFinished = new Promise<void>(resolve => {
|
||||
finishSetup = resolve;
|
||||
@@ -105,6 +109,7 @@ 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()),
|
||||
]);
|
||||
@@ -169,6 +174,35 @@ 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,6 +3,7 @@ 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';
|
||||
|
||||
@@ -51,6 +52,8 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
signal,
|
||||
workingDirectory: stepCtx.workingDirectory,
|
||||
env: commandEnv,
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
sandboxId,
|
||||
});
|
||||
try {
|
||||
await daemon.ready;
|
||||
@@ -76,19 +79,7 @@ export async function markSandboxReadyAsync(
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(
|
||||
MARK_SANDBOX_READY_MUTATION,
|
||||
{ sandboxId },
|
||||
signal
|
||||
? {
|
||||
fetch: (input, init) =>
|
||||
fetch(input, {
|
||||
...init,
|
||||
signal: init?.signal ? AbortSignal.any([signal, init.signal]) : signal,
|
||||
}),
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
.mutation(MARK_SANDBOX_READY_MUTATION, { sandboxId }, graphqlAbortContext(signal))
|
||||
.toPromise();
|
||||
signal?.throwIfAborted();
|
||||
if (result.error) {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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,6 +6,11 @@ import {
|
||||
} from '@expo/steps';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
parseNetworkCaptureInputs,
|
||||
} from '../utils/networkCaptureFields';
|
||||
import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
@@ -17,7 +22,6 @@ import {
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
@@ -33,6 +37,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
__metricsId: 'eas/start_serve_sim_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -58,6 +63,13 @@ 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);
|
||||
@@ -69,9 +81,8 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
|
||||
const webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
const sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
@@ -79,19 +90,28 @@ 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 ? { previewToken: webPreview.previewToken } : {}),
|
||||
...(webPreview.previewToken
|
||||
? { webPreviewToken: webPreview.previewToken, previewToken: webPreview.previewToken }
|
||||
: {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
@@ -104,7 +124,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
signal,
|
||||
});
|
||||
} finally {
|
||||
await webPreview.stopAsync();
|
||||
await sessionHost.finishAsync();
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { GooglePlayAuthUtils } from '../utils/android/GooglePlayAuthUtils';
|
||||
import { GooglePlayClient, ReleaseStatus, ReleaseStatusZ } from '../utils/android/GooglePlayClient';
|
||||
import {
|
||||
GooglePlayCommitOutcomeUnknownError,
|
||||
GooglePlayUtils,
|
||||
} from '../utils/android/GooglePlayUtils';
|
||||
import { AndroidArtifactType } from '../utils/android/appArtifact';
|
||||
|
||||
const InputsZ = z
|
||||
.object({
|
||||
artifact_path: z.string().min(1),
|
||||
artifact_type: z.enum(['apk', 'aab']),
|
||||
package_name: z.string().regex(/^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$/),
|
||||
service_account_key_path: z.string().min(1),
|
||||
track: z.string().trim().min(1).default('internal'),
|
||||
release_status: ReleaseStatusZ.exclude(['halted']).default('completed'),
|
||||
rollout: z.number().finite().gt(0).max(1).optional(),
|
||||
changelog: z.string().optional(),
|
||||
changes_not_sent_for_review: z.boolean().default(false),
|
||||
})
|
||||
.superRefine(({ release_status, rollout }, ctx) => {
|
||||
if (release_status === 'draft' && rollout !== undefined) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['rollout'],
|
||||
message: 'A draft release cannot have a rollout.',
|
||||
});
|
||||
}
|
||||
if (release_status === 'inProgress' && rollout === undefined) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['rollout'],
|
||||
message: 'An inProgress release requires a rollout greater than 0 and at most 1.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Does not inspect the binary. The caller must run eas/read_android_app_info and
|
||||
// compare its package_name with the intended application ID before calling this.
|
||||
export function createSubmitToGooglePlayBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'submit_to_google_play',
|
||||
name: 'Submit to Google Play',
|
||||
__metricsId: 'eas/submit_to_google_play',
|
||||
__hookId: 'submit',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'artifact_path',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'artifact_type',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_name',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'service_account_key_path',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'track',
|
||||
required: false,
|
||||
defaultValue: 'internal',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'release_status',
|
||||
required: false,
|
||||
defaultValue: 'completed',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'rollout',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'changelog',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'changes_not_sent_for_review',
|
||||
required: false,
|
||||
defaultValue: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
}),
|
||||
],
|
||||
outputProviders: [BuildStepOutput.createProvider({ id: 'version_code', required: true })],
|
||||
fn: async (ctx, { inputs, outputs, signal }) => {
|
||||
signal?.throwIfAborted();
|
||||
const parsed = InputsZ.safeParse(
|
||||
Object.fromEntries(Object.entries(inputs).map(([key, input]) => [key, input.value]))
|
||||
);
|
||||
if (!parsed.success) {
|
||||
throw new UserError(
|
||||
'EAS_GOOGLE_PLAY_INVALID_INPUT',
|
||||
'Invalid Google Play submission inputs:\n' + z.prettifyError(parsed.error)
|
||||
);
|
||||
}
|
||||
const submission = parsed.data;
|
||||
const { release_status: requested, rollout } = submission;
|
||||
// Match Fastlane: a partial rollout overrides the requested status to inProgress.
|
||||
const release: {
|
||||
status: ReleaseStatus;
|
||||
userFraction?: number;
|
||||
} =
|
||||
rollout !== undefined && rollout < 1
|
||||
? { status: 'inProgress', userFraction: rollout }
|
||||
: { status: requested === 'inProgress' ? 'completed' : requested };
|
||||
const credentials = await GooglePlayAuthUtils.loadGoogleServiceAccountAsync({
|
||||
keyPath: path.resolve(ctx.workingDirectory, submission.service_account_key_path),
|
||||
});
|
||||
const artifactPath = path.resolve(ctx.workingDirectory, submission.artifact_path);
|
||||
const client = new GooglePlayClient(credentials);
|
||||
const versionCode = await submitToGooglePlayAsync({
|
||||
artifactType: submission.artifact_type,
|
||||
client,
|
||||
submission: {
|
||||
package_name: submission.package_name,
|
||||
track: submission.track,
|
||||
release_status: submission.release_status,
|
||||
rollout: submission.rollout,
|
||||
changelog: submission.changelog,
|
||||
changes_not_sent_for_review: submission.changes_not_sent_for_review,
|
||||
artifact_path: artifactPath,
|
||||
},
|
||||
release,
|
||||
logger: ctx.logger,
|
||||
signal,
|
||||
});
|
||||
outputs.version_code.set(String(versionCode));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function submitToGooglePlayAsync({
|
||||
artifactType,
|
||||
client,
|
||||
submission,
|
||||
release,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
artifactType: AndroidArtifactType;
|
||||
client: GooglePlayClient;
|
||||
submission: {
|
||||
package_name: string;
|
||||
track: string;
|
||||
release_status: ReleaseStatus;
|
||||
rollout?: number;
|
||||
changelog?: string;
|
||||
changes_not_sent_for_review: boolean;
|
||||
artifact_path: string;
|
||||
};
|
||||
release: { status: ReleaseStatus; userFraction?: number };
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<number> {
|
||||
let editId: string | undefined;
|
||||
let committed = false;
|
||||
let commitOutcomeUnknown = false;
|
||||
try {
|
||||
const packageName = submission.package_name;
|
||||
logger.info(
|
||||
`Submitting ${packageName} (${artifactType}) to track ${JSON.stringify(submission.track)}.`
|
||||
);
|
||||
logger.info(
|
||||
`Requested status: ${submission.release_status}; rollout: ${submission.rollout ?? 'none'}. Effective status: ${release.status}; rollout: ${release.userFraction ?? 'none'}. changes_not_sent_for_review: ${submission.changes_not_sent_for_review}.`
|
||||
);
|
||||
if (submission.changelog) {
|
||||
logger.info(`Changelog (en-US): ${JSON.stringify(submission.changelog)}`);
|
||||
}
|
||||
editId = (await GooglePlayUtils.createEditAsync(client, { packageName, signal })).id;
|
||||
let lastPercent: number | undefined;
|
||||
const { versionCode } = await GooglePlayUtils.uploadApplicationAsync(client, {
|
||||
packageName,
|
||||
editId,
|
||||
artifactPath: submission.artifact_path,
|
||||
artifactType,
|
||||
signal,
|
||||
onProgress: (uploaded, total) => {
|
||||
const percent = Math.floor((uploaded / total) * 100);
|
||||
if (percent !== lastPercent) {
|
||||
logger.info(`Upload: ${percent}% (${uploaded}/${total} bytes).`);
|
||||
lastPercent = percent;
|
||||
}
|
||||
},
|
||||
});
|
||||
logger.info(`Uploaded version code: ${versionCode}.`);
|
||||
await GooglePlayUtils.updateTrackAsync(client, {
|
||||
packageName,
|
||||
editId,
|
||||
track: submission.track,
|
||||
release,
|
||||
versionCode,
|
||||
changelog: submission.changelog ? { locale: 'en-US', text: submission.changelog } : undefined,
|
||||
signal,
|
||||
});
|
||||
logger.info('Updated Google Play track ' + JSON.stringify(submission.track) + ' in the edit.');
|
||||
// Commit also validates the edit.
|
||||
await GooglePlayUtils.commitEditAsync(client, {
|
||||
packageName,
|
||||
editId,
|
||||
changesNotSentForReview: submission.changes_not_sent_for_review,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
committed = true;
|
||||
logger.info(
|
||||
`Submitted ${packageName}, version ${versionCode}, to ${JSON.stringify(submission.track)}.`
|
||||
);
|
||||
return versionCode;
|
||||
} catch (error) {
|
||||
commitOutcomeUnknown = error instanceof GooglePlayCommitOutcomeUnknownError;
|
||||
throw GooglePlayUtils.mapGooglePlayError(error);
|
||||
} finally {
|
||||
if (editId && !committed && !commitOutcomeUnknown) {
|
||||
try {
|
||||
await GooglePlayUtils.deleteEditAsync(client, {
|
||||
packageName: submission.package_name,
|
||||
editId,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
} catch {
|
||||
logger.warn(
|
||||
'Could not delete the Google Play edit after failure. The original submission error is preserved.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
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.')
|
||||
);
|
||||
}
|
||||
+11
-100
@@ -1,53 +1,17 @@
|
||||
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 { Sentry } from '../../sentry';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../utils/deviceRunSessionArtifacts';
|
||||
import {
|
||||
parseDeviceScreenRecordings,
|
||||
uploadDeviceRunSessionScreenRecordingsAsync,
|
||||
} from '../utils/deviceRunSessionScreenRecordings';
|
||||
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 {
|
||||
@@ -65,69 +29,16 @@ export function createUploadDeviceRunSessionScreenRecordingsBuildFunction(
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { env, inputs }) => {
|
||||
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;
|
||||
const recordings = parseDeviceScreenRecordings(inputs.recordings_json.value ?? []);
|
||||
if (recordings.length === 0) {
|
||||
logger.info('No iOS Simulator recordings found; skipping uploads.');
|
||||
logger.info('No device screen recordings found; skipping uploads.');
|
||||
return;
|
||||
}
|
||||
|
||||
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}.`
|
||||
);
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
await uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
|
||||
recordings,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { BuildJob, UserError } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { readAppConfig } from '../../utils/appConfig';
|
||||
import { uploadEmbeddedBundleAsync } from '../../utils/expoUpdatesEmbedded';
|
||||
|
||||
export function createUploadEmbeddedBundleBuildFunction(
|
||||
ctx: CustomBuildContext<BuildJob>
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'upload_embedded_bundle',
|
||||
name: 'Upload embedded bundle',
|
||||
__metricsId: 'eas/upload_embedded_bundle',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'ignore_error',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { env, inputs }) => {
|
||||
try {
|
||||
const appConfig = (
|
||||
await readAppConfig({
|
||||
projectDir: stepCtx.workingDirectory,
|
||||
env,
|
||||
logger: stepCtx.logger,
|
||||
sdkVersion: stepCtx.global.staticContext.metadata?.sdkVersion,
|
||||
})
|
||||
).exp;
|
||||
const { status } = await uploadEmbeddedBundleAsync({
|
||||
job: ctx.job,
|
||||
env,
|
||||
logger: stepCtx.logger,
|
||||
projectDir: stepCtx.workingDirectory,
|
||||
appConfig,
|
||||
});
|
||||
if (status === 'skipped') {
|
||||
stepCtx.logger.info('Skipping embedded bundle upload.');
|
||||
} else if (status === 'failed') {
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_EMBEDDED_BUNDLE_FAILED',
|
||||
'Failed to upload embedded bundle. See the warning above for details.'
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (inputs.ignore_error.value) {
|
||||
stepCtx.logger.warn({ err }, 'Failed to upload embedded bundle. Ignoring error.');
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,6 @@ 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';
|
||||
@@ -87,29 +86,8 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
throw new Error(`ASC API Key file not found: ${ascApiKeyPath}`);
|
||||
}
|
||||
|
||||
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 });
|
||||
const key = await AscApiUtils.loadApiKeyAsync({ keyPath: ascApiKeyPath });
|
||||
const client = new AscApiClient({ key, logger: stepsCtx.logger });
|
||||
|
||||
stepsCtx.logger.info(
|
||||
`Reading App information for Apple app identifier: ${appleAppIdentifier}...`
|
||||
@@ -177,21 +155,11 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
});
|
||||
|
||||
stepsCtx.logger.info('Committing upload...');
|
||||
await client.patchAsync(
|
||||
`/v1/buildUploadFiles/:id`,
|
||||
{
|
||||
data: {
|
||||
type: 'buildUploadFiles',
|
||||
id: buildFileResponse.data.id,
|
||||
attributes: {
|
||||
uploaded: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: buildFileResponse.data.id,
|
||||
}
|
||||
);
|
||||
await AscApiUtils.commitBuildUploadFileAsync({
|
||||
client,
|
||||
fileId: buildFileResponse.data.id,
|
||||
logger: stepsCtx.logger,
|
||||
});
|
||||
|
||||
stepsCtx.logger.info('Checking upload file status...');
|
||||
const waitingForFileStartedAt = Date.now();
|
||||
|
||||
@@ -10,12 +10,35 @@ import { setTimeout } from 'node:timers/promises';
|
||||
|
||||
import { Sentry } from '../../sentry';
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageExec,
|
||||
} from '../../utils/packageManager';
|
||||
import { isProcessGroupRunning, killProcessGroup } from '../../utils/processes';
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
|
||||
const IOS_SIMULATOR_RECORDING_POLL_INTERVAL_MS = 2_000;
|
||||
const RECORD_SIM_FINISH_TIMEOUT_MS = 70_000;
|
||||
const RECORD_SIM_FORCE_STOP_TIMEOUT_MS = 5_000;
|
||||
const RECORD_SIM_MAX_ATTEMPTS_PER_BOOT = 3;
|
||||
const RECORD_SIM_COMMAND = 'record-sim';
|
||||
// Once the poller stops, each recording stops in parallel within 60 + 430 + 5 + 310 = 805 s.
|
||||
const SERVE_SIM_START_TIMEOUT_MS = 60_000;
|
||||
// Match record-video's control request, recording lease, and finalization allowances.
|
||||
const SERVE_SIM_CONTROL_REQUEST_TIMEOUT_MS = 120_000;
|
||||
const SERVE_SIM_RECORDING_LEASE_MS = 180_000;
|
||||
const SERVE_SIM_FINALIZATION_TIMEOUT_MS = 120_000;
|
||||
const SERVE_SIM_SHUTDOWN_MARGIN_MS = 10_000;
|
||||
/** Give the session host time to finalize its active writer before SIGKILL. */
|
||||
export const SERVE_SIM_STOP_GRACE_PERIOD_MS =
|
||||
SERVE_SIM_FINALIZATION_TIMEOUT_MS + SERVE_SIM_SHUTDOWN_MARGIN_MS;
|
||||
// A recorder can exit before its lease expires and the host writes the manifest.
|
||||
const SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS =
|
||||
SERVE_SIM_RECORDING_LEASE_MS + SERVE_SIM_FINALIZATION_TIMEOUT_MS + SERVE_SIM_SHUTDOWN_MARGIN_MS;
|
||||
// An ambiguous stop can use a full control request before lease/finalization reconciliation.
|
||||
const SERVE_SIM_FINISH_TIMEOUT_MS =
|
||||
SERVE_SIM_CONTROL_REQUEST_TIMEOUT_MS + SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS;
|
||||
const SERVE_SIM_FORCE_STOP_TIMEOUT_MS = 5_000;
|
||||
const SERVE_SIM_RECORDING_STARTED = 'serve-sim:recording-started';
|
||||
// The serve-sim package the session host started; every recorder runs `record-video` from it.
|
||||
let serveSimPackageSpec: string | null = null;
|
||||
|
||||
type IosSimulatorRecording = {
|
||||
id: string;
|
||||
@@ -25,21 +48,27 @@ type IosSimulatorRecording = {
|
||||
outputDirectory: string;
|
||||
startedAt: Date;
|
||||
getOutput: () => string;
|
||||
hasStarted: () => boolean;
|
||||
};
|
||||
|
||||
type ActiveIosSimulatorRecording = IosSimulatorRecording & {
|
||||
recordingProcess: ChildProcess;
|
||||
completionPromise: Promise<void>;
|
||||
hasExited: () => boolean;
|
||||
};
|
||||
|
||||
type IosSimulatorRecordingSession = {
|
||||
env: Env;
|
||||
logger: bunyan;
|
||||
recordSimCommand: string;
|
||||
recordingsRootDirectory: string;
|
||||
activeRecordings: Map<IosSimulatorUuid, ActiveIosSimulatorRecording>;
|
||||
completedRecordings: IosSimulatorRecording[];
|
||||
recordingFailureCounts: Map<IosSimulatorUuid, number>;
|
||||
/** Failed attempts for the whole session; unlike recordingFailureCounts, never reset. */
|
||||
failedAttemptCounts: Map<IosSimulatorUuid, number>;
|
||||
recordingRetryAt: Map<IosSimulatorUuid, number>;
|
||||
serveBearerTokens: Map<IosSimulatorUuid, string>;
|
||||
completedServeBearerTokens: Map<IosSimulatorUuid, string>;
|
||||
pollingPromise: Promise<void>;
|
||||
abortController: AbortController;
|
||||
};
|
||||
@@ -47,31 +76,31 @@ type IosSimulatorRecordingSession = {
|
||||
let activeIosSimulatorRecordingSession: IosSimulatorRecordingSession | null = null;
|
||||
|
||||
export namespace IosSimulatorRecordingUtils {
|
||||
/** One session host serves every device of a job, so the first package wins. */
|
||||
export function useServeSimPackage(packageSpec: string): void {
|
||||
serveSimPackageSpec ??= packageSpec;
|
||||
}
|
||||
|
||||
export async function startAsync({ env, logger }: { env: Env; logger: bunyan }): Promise<void> {
|
||||
if (activeIosSimulatorRecordingSession) {
|
||||
logger.info('iOS Simulator screen recording polling is already running.');
|
||||
return;
|
||||
}
|
||||
|
||||
const recordSimCommand = await resolveRecordSimCommandAsync({ env });
|
||||
if (!recordSimCommand) {
|
||||
logger.warn(
|
||||
'record-sim binary is not available; iOS Simulator screen recordings are disabled.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const recordingsRootDirectory = await mkdtemp(
|
||||
path.join(os.tmpdir(), 'ios-simulator-recordings-')
|
||||
);
|
||||
const session: IosSimulatorRecordingSession = {
|
||||
env,
|
||||
logger,
|
||||
recordSimCommand,
|
||||
recordingsRootDirectory,
|
||||
activeRecordings: new Map(),
|
||||
completedRecordings: [],
|
||||
recordingFailureCounts: new Map(),
|
||||
failedAttemptCounts: new Map(),
|
||||
recordingRetryAt: new Map(),
|
||||
serveBearerTokens: new Map(),
|
||||
completedServeBearerTokens: new Map(),
|
||||
pollingPromise: Promise.resolve(),
|
||||
abortController: new AbortController(),
|
||||
};
|
||||
@@ -94,6 +123,7 @@ export namespace IosSimulatorRecordingUtils {
|
||||
}[]
|
||||
> {
|
||||
const session = activeIosSimulatorRecordingSession;
|
||||
serveSimPackageSpec = null;
|
||||
if (!session) {
|
||||
logger.info('No iOS Simulator screen recordings are running.');
|
||||
return [];
|
||||
@@ -105,41 +135,56 @@ export namespace IosSimulatorRecordingUtils {
|
||||
await Promise.all(
|
||||
[...session.activeRecordings.values()].map(async recording => {
|
||||
logger.info(`Stopping screen recording for ${recording.deviceName}.`);
|
||||
recording.recordingProcess.kill('SIGINT');
|
||||
const startState = await waitForRecorderStartAsync(recording, SERVE_SIM_START_TIMEOUT_MS);
|
||||
if (startState === 'exited') {
|
||||
await recording.completionPromise;
|
||||
return;
|
||||
}
|
||||
if (startState === 'timeout') {
|
||||
logger.warn(
|
||||
{ recorderOutput: recording.getOutput().trim() },
|
||||
`Screen recording for ${recording.deviceName} did not report its start within ${Math.round(
|
||||
SERVE_SIM_START_TIMEOUT_MS / 1_000
|
||||
)} seconds and will be stopped.`
|
||||
);
|
||||
}
|
||||
const finishTimeoutMs =
|
||||
startState === 'started' ? SERVE_SIM_FINISH_TIMEOUT_MS : SERVE_SIM_FORCE_STOP_TIMEOUT_MS;
|
||||
signalRecorder(recording.recordingProcess, 'SIGINT');
|
||||
const finished = await Promise.race([
|
||||
recording.completionPromise.then(() => true),
|
||||
setTimeout(RECORD_SIM_FINISH_TIMEOUT_MS, false, { ref: false }),
|
||||
setTimeout(finishTimeoutMs, false, { ref: false }),
|
||||
]);
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recordSimOutput = recording.getOutput().trim();
|
||||
const finishTimeoutSeconds = Math.round(RECORD_SIM_FINISH_TIMEOUT_MS / 1_000);
|
||||
const recorderOutput = recording.getOutput().trim();
|
||||
const finishTimeoutSeconds = Math.round(finishTimeoutMs / 1_000);
|
||||
const stopFailure =
|
||||
startState === 'started'
|
||||
? `did not finish within ${finishTimeoutSeconds} seconds`
|
||||
: `did not stop within ${finishTimeoutSeconds} seconds of SIGINT`;
|
||||
logger.warn(
|
||||
{ recordSimOutput },
|
||||
`Screen recording for ${recording.deviceName} did not finish within ${finishTimeoutSeconds} seconds and will be stopped.${
|
||||
recordSimOutput
|
||||
? `\nRecent recorder messages:\n${recordSimOutput}`
|
||||
{ recorderOutput },
|
||||
`Screen recording for ${recording.deviceName} ${stopFailure} and will be killed.${
|
||||
recorderOutput
|
||||
? `\nRecent recorder messages:\n${recorderOutput}`
|
||||
: '\nNo recorder messages were captured.'
|
||||
}`
|
||||
);
|
||||
recording.recordingProcess.kill('SIGKILL');
|
||||
signalRecorder(recording.recordingProcess, 'SIGKILL');
|
||||
const killed = await Promise.race([
|
||||
recording.completionPromise.then(() => true),
|
||||
setTimeout(RECORD_SIM_FORCE_STOP_TIMEOUT_MS, false, { ref: false }),
|
||||
setTimeout(SERVE_SIM_FORCE_STOP_TIMEOUT_MS, false, { ref: false }),
|
||||
]);
|
||||
if (!killed) {
|
||||
logger.warn(
|
||||
`iOS Simulator recording process for ${recording.deviceName} did not exit after SIGKILL.`
|
||||
);
|
||||
// Recorder output stays in the job log: it can contain the serve-sim bearer token.
|
||||
Sentry.capture(
|
||||
`iOS Simulator recording process for ${recording.deviceName} did not exit after SIGKILL.`,
|
||||
{
|
||||
extras: {
|
||||
output: recording.getOutput(),
|
||||
},
|
||||
}
|
||||
`iOS Simulator recording process for ${recording.deviceName} did not exit after SIGKILL.`
|
||||
);
|
||||
}
|
||||
})
|
||||
@@ -148,7 +193,31 @@ export namespace IosSimulatorRecordingUtils {
|
||||
const completedRecordings = [...session.completedRecordings].sort(
|
||||
(a, b) => a.startedAt.getTime() - b.startedAt.getTime()
|
||||
);
|
||||
return completedRecordings.map(recording => ({
|
||||
const recordingsWithManifests = await Promise.all(
|
||||
completedRecordings.map(async recording => {
|
||||
if (
|
||||
await waitForRecordingManifestAsync(
|
||||
recording.outputDirectory,
|
||||
SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS
|
||||
)
|
||||
) {
|
||||
return recording;
|
||||
}
|
||||
reportLostRecording(logger, recording.deviceName, recording.getOutput().trim());
|
||||
return null;
|
||||
})
|
||||
);
|
||||
const finalizedRecordings = recordingsWithManifests.filter(recording => recording !== null);
|
||||
const recordedUdids = new Set(finalizedRecordings.map(recording => recording.udid));
|
||||
for (const [failedUdid, attempts] of session.failedAttemptCounts) {
|
||||
if (!recordedUdids.has(failedUdid)) {
|
||||
Sentry.capture('iOS Simulator screen recording failed on every attempt', {
|
||||
level: 'warning',
|
||||
extras: { attempts },
|
||||
});
|
||||
}
|
||||
}
|
||||
return finalizedRecordings.map(recording => ({
|
||||
udid: recording.udid,
|
||||
deviceName: recording.deviceName,
|
||||
runtimeDisplayName: recording.runtimeDisplayName,
|
||||
@@ -174,18 +243,38 @@ async function pollIosSimulatorRecordingsAsync(
|
||||
break;
|
||||
}
|
||||
listDevicesErrorCount = 0;
|
||||
const readyServers = new Map(
|
||||
(await readServeSimServersAsync(SERVE_SIM_STATE_DIR)).flatMap(server =>
|
||||
server.token ? [[server.udid, server.token] as const] : []
|
||||
)
|
||||
);
|
||||
|
||||
const bootedUdids = new Set(bootedDevices.map(device => device.udid));
|
||||
for (const udid of session.recordingFailureCounts.keys()) {
|
||||
for (const udid of session.serveBearerTokens.keys()) {
|
||||
if (!bootedUdids.has(udid)) {
|
||||
session.recordingFailureCounts.delete(udid);
|
||||
session.recordingRetryAt.delete(udid);
|
||||
session.serveBearerTokens.delete(udid);
|
||||
session.completedServeBearerTokens.delete(udid);
|
||||
}
|
||||
}
|
||||
|
||||
for (const device of bootedDevices) {
|
||||
const token = readyServers.get(device.udid);
|
||||
const packageSpec = serveSimPackageSpec;
|
||||
if (!token || !packageSpec) {
|
||||
continue;
|
||||
}
|
||||
if (session.serveBearerTokens.get(device.udid) !== token) {
|
||||
session.serveBearerTokens.set(device.udid, token);
|
||||
session.recordingFailureCounts.delete(device.udid);
|
||||
session.recordingRetryAt.delete(device.udid);
|
||||
session.completedServeBearerTokens.delete(device.udid);
|
||||
}
|
||||
if (
|
||||
session.completedServeBearerTokens.get(device.udid) === token ||
|
||||
session.activeRecordings.has(device.udid) ||
|
||||
(session.recordingFailureCounts.get(device.udid) ?? 0) >= RECORD_SIM_MAX_ATTEMPTS_PER_BOOT
|
||||
Date.now() < (session.recordingRetryAt.get(device.udid) ?? 0)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -193,6 +282,7 @@ async function pollIosSimulatorRecordingsAsync(
|
||||
udid: device.udid,
|
||||
deviceName: device.name,
|
||||
runtimeDisplayName: device.runtimeDisplayName,
|
||||
packageSpec,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -227,50 +317,92 @@ async function startIosSimulatorRecordingAsync(
|
||||
udid,
|
||||
deviceName,
|
||||
runtimeDisplayName,
|
||||
packageSpec,
|
||||
}: {
|
||||
udid: IosSimulatorUuid;
|
||||
deviceName: string;
|
||||
runtimeDisplayName: string;
|
||||
packageSpec: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const startedAt = new Date();
|
||||
const serveBearerToken = session.serveBearerTokens.get(udid);
|
||||
const recordingId = randomUUID();
|
||||
const outputDirectory = path.join(session.recordingsRootDirectory, recordingId);
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
|
||||
session.logger.info(`Starting screen recording for ${deviceName}.`);
|
||||
const recorderExec = resolvePackageExec(
|
||||
resolveConfiguredPackageManager(session.env, PackageManager.NPM),
|
||||
[packageSpec, 'record-video']
|
||||
);
|
||||
const recordingSpawn = spawn(
|
||||
session.recordSimCommand,
|
||||
['--udid', udid, '--output', outputDirectory, '--segment-duration', '0'],
|
||||
recorderExec.command,
|
||||
[...recorderExec.args, '--udid', udid, '--output', outputDirectory],
|
||||
{
|
||||
env: session.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
}
|
||||
);
|
||||
const getOutput = captureProcessOutput(recordingSpawn.child);
|
||||
const completionPromise = recordingSpawn
|
||||
.then(() => undefined)
|
||||
.catch((err: unknown) => {
|
||||
session.recordingFailureCounts.set(udid, (session.recordingFailureCounts.get(udid) ?? 0) + 1);
|
||||
const { getOutput, hasStarted } = captureProcessOutput(recordingSpawn.child);
|
||||
const saveFinalizedRecordingAsync = async (): Promise<boolean> => {
|
||||
if (
|
||||
!(await waitForRecordingManifestAsync(outputDirectory, SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (serveBearerToken) {
|
||||
session.completedServeBearerTokens.set(udid, serveBearerToken);
|
||||
}
|
||||
session.completedRecordings.push({
|
||||
id: recordingId,
|
||||
udid,
|
||||
deviceName,
|
||||
runtimeDisplayName,
|
||||
outputDirectory,
|
||||
startedAt,
|
||||
getOutput,
|
||||
hasStarted,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
let processExited = false;
|
||||
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);
|
||||
processExited = true;
|
||||
// Host shutdown can end the recorder's lease after its video has been saved.
|
||||
if ((hasStarted() || !spawnError) && (await saveFinalizedRecordingAsync())) {
|
||||
return;
|
||||
}
|
||||
if (spawnError) {
|
||||
const err = spawnError;
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('iOS Simulator screen recording process failed', error);
|
||||
session.logger.warn(
|
||||
{ err: error, recordSimOutput: getOutput() },
|
||||
{ err: error, recorderOutput: getOutput() },
|
||||
`Screen recording process failed for ${deviceName}.`
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
session.activeRecordings.delete(udid);
|
||||
session.completedRecordings.push({
|
||||
id: recordingId,
|
||||
udid,
|
||||
deviceName,
|
||||
runtimeDisplayName,
|
||||
outputDirectory,
|
||||
startedAt,
|
||||
getOutput,
|
||||
});
|
||||
});
|
||||
}
|
||||
if (hasStarted()) {
|
||||
reportLostRecording(session.logger, deviceName, getOutput().trim());
|
||||
} else if (!spawnError) {
|
||||
session.logger.warn(
|
||||
{ recorderOutput: getOutput().trim() },
|
||||
`Screen recording for ${deviceName} exited before it started; it will be retried.`
|
||||
);
|
||||
}
|
||||
scheduleRecordingRetry(session, udid);
|
||||
})().finally(() => {
|
||||
session.activeRecordings.delete(udid);
|
||||
});
|
||||
|
||||
session.activeRecordings.set(udid, {
|
||||
id: recordingId,
|
||||
@@ -280,33 +412,109 @@ async function startIosSimulatorRecordingAsync(
|
||||
outputDirectory,
|
||||
recordingProcess: recordingSpawn.child,
|
||||
completionPromise,
|
||||
hasExited: () => processExited,
|
||||
startedAt,
|
||||
getOutput,
|
||||
hasStarted,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveRecordSimCommandAsync({ env }: { env: Env }): Promise<string | null> {
|
||||
try {
|
||||
await spawn('which', [RECORD_SIM_COMMAND], { env });
|
||||
return RECORD_SIM_COMMAND;
|
||||
} catch {}
|
||||
async function waitForRecordingManifestAsync(
|
||||
outputDirectory: string,
|
||||
timeoutMs: number
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await access(path.join(outputDirectory, 'session.json'));
|
||||
return true;
|
||||
} catch {
|
||||
await setTimeout(1_000);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const packagedRecordSimPath = path.join(__dirname, '..', '..', '..', 'bin', RECORD_SIM_COMMAND);
|
||||
try {
|
||||
await access(packagedRecordSimPath);
|
||||
return packagedRecordSimPath;
|
||||
} catch {
|
||||
return null;
|
||||
async function waitForRecorderStartAsync(
|
||||
recording: ActiveIosSimulatorRecording,
|
||||
timeoutMs: number
|
||||
): Promise<'started' | 'exited' | 'timeout'> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (recording.hasExited()) {
|
||||
return 'exited';
|
||||
}
|
||||
if (recording.hasStarted()) {
|
||||
return 'started';
|
||||
}
|
||||
const exited = await Promise.race([
|
||||
recording.completionPromise.then(() => true),
|
||||
setTimeout(100, false),
|
||||
]);
|
||||
if (exited) {
|
||||
return 'exited';
|
||||
}
|
||||
}
|
||||
return recording.hasExited() ? 'exited' : recording.hasStarted() ? 'started' : 'timeout';
|
||||
}
|
||||
|
||||
function reportLostRecording(logger: bunyan, deviceName: string, recorderOutput: string): void {
|
||||
logger.warn(
|
||||
{ recorderOutput },
|
||||
`Screen recording for ${deviceName} ended without a manifest; its footage cannot be uploaded.`
|
||||
);
|
||||
Sentry.capture('iOS Simulator screen recording ended without a manifest', {
|
||||
level: 'warning',
|
||||
extras: { deviceName },
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleRecordingRetry(
|
||||
session: IosSimulatorRecordingSession,
|
||||
udid: IosSimulatorUuid
|
||||
): void {
|
||||
session.failedAttemptCounts.set(udid, (session.failedAttemptCounts.get(udid) ?? 0) + 1);
|
||||
const failures = (session.recordingFailureCounts.get(udid) ?? 0) + 1;
|
||||
session.recordingFailureCounts.set(udid, failures);
|
||||
session.recordingRetryAt.set(
|
||||
udid,
|
||||
Date.now() + Math.min(300_000, 25_000 * 2 ** Math.min(failures - 1, 4))
|
||||
);
|
||||
}
|
||||
|
||||
function signalRecorder(child: ChildProcess, signal: NodeJS.Signals): void {
|
||||
// killProcessGroup skips a child without a pid.
|
||||
if (child.pid === undefined) {
|
||||
child.kill(signal);
|
||||
return;
|
||||
}
|
||||
killProcessGroup(child, signal);
|
||||
}
|
||||
|
||||
async function waitForRecordingProcessGroupExitAsync(child: ChildProcess): Promise<void> {
|
||||
if (child.pid === undefined) {
|
||||
return;
|
||||
}
|
||||
// Bounded by finishAsync's SIGKILL; unref'd so it cannot keep the worker alive.
|
||||
while (isProcessGroupRunning(child.pid)) {
|
||||
await setTimeout(100, undefined, { ref: false });
|
||||
}
|
||||
}
|
||||
|
||||
function captureProcessOutput(recordingProcess: ChildProcess): () => string {
|
||||
function captureProcessOutput(recordingProcess: ChildProcess): {
|
||||
getOutput: () => string;
|
||||
hasStarted: () => boolean;
|
||||
} {
|
||||
let output = '';
|
||||
let started = false;
|
||||
const appendChunk = (chunk: Buffer | string): void => {
|
||||
// Keep enough recorder stderr/stdout for diagnostics without retaining unbounded output.
|
||||
output = `${output}${chunk.toString()}`.slice(-16_384);
|
||||
output = `${output}${chunk.toString()}`;
|
||||
if (output.includes(SERVE_SIM_RECORDING_STARTED)) {
|
||||
started = true;
|
||||
}
|
||||
output = output.slice(-16_384);
|
||||
};
|
||||
recordingProcess.stdout?.on('data', appendChunk);
|
||||
recordingProcess.stderr?.on('data', appendChunk);
|
||||
return () => output;
|
||||
return { getOutput: () => output, hasStarted: () => started };
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
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 { writeFile } from 'node:fs/promises';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils';
|
||||
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../serveSimMetricsRecorder');
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('node:timers/promises', () => ({
|
||||
setTimeout: (ms: number, value?: unknown, options?: { signal?: AbortSignal }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const abort = (): void => {
|
||||
clearTimeout(timer);
|
||||
reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }));
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
options?.signal?.removeEventListener('abort', abort);
|
||||
resolve(value);
|
||||
}, ms);
|
||||
if (options?.signal?.aborted) {
|
||||
abort();
|
||||
} else {
|
||||
options?.signal?.addEventListener('abort', abort, { once: true });
|
||||
}
|
||||
}),
|
||||
}));
|
||||
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
} as unknown as bunyan;
|
||||
const udid = '06B546AC-7B06-4BED-83DB-83603E8EB537';
|
||||
let exit: () => void;
|
||||
let child: ChildProcess;
|
||||
let finishing: Promise<unknown> | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
|
||||
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)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
child = Object.assign(new EventEmitter(), {
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: jest.fn(),
|
||||
}) as unknown as ChildProcess;
|
||||
const done = new Promise<void>(resolve => {
|
||||
exit = resolve;
|
||||
});
|
||||
jest.mocked(spawn).mockReturnValue(Object.assign(done, { child }) as never);
|
||||
await IosSimulatorRecordingUtils.startAsync({ env: {} as Env, logger });
|
||||
for (let attempt = 0; attempt < 100 && jest.mocked(spawn).mock.calls.length === 0; attempt++) {
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
}
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
child.stderr?.emit('data', 'serve-sim:recording-started\n');
|
||||
});
|
||||
|
||||
async function saveManifest(): Promise<void> {
|
||||
const directory = jest.mocked(spawn).mock.calls[0][1][6];
|
||||
await writeFile(`${directory}/session.json`, JSON.stringify({ recording: 'recording.mp4' }));
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
if (jest.mocked(spawn).mock.calls.length) {
|
||||
await saveManifest();
|
||||
}
|
||||
exit();
|
||||
finishing ??= IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await jest.advanceTimersByTimeAsync(1_000);
|
||||
await finishing;
|
||||
finishing = undefined;
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
jest.mocked(spawn).mockReset();
|
||||
});
|
||||
|
||||
test('keeps the recorder alive through a 120-second stop request and 300-second reconciliation', async () => {
|
||||
finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGINT');
|
||||
|
||||
await jest.advanceTimersByTimeAsync(420_000);
|
||||
expect(child.kill).not.toHaveBeenCalledWith('SIGKILL');
|
||||
await saveManifest();
|
||||
exit();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(await finishing).toHaveLength(1);
|
||||
expect(child.kill).not.toHaveBeenCalledWith('SIGKILL');
|
||||
});
|
||||
|
||||
test('force-kills an unresponsive started recorder after the bounded shutdown allowance', async () => {
|
||||
jest.mocked(child.kill).mockImplementation(signal => {
|
||||
if (signal === 'SIGKILL') {
|
||||
exit();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await jest.advanceTimersByTimeAsync(429_999);
|
||||
expect(child.kill).not.toHaveBeenCalledWith('SIGKILL');
|
||||
await saveManifest();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
|
||||
expect(await finishing).toHaveLength(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.stringContaining('did not finish within 430 seconds')
|
||||
);
|
||||
});
|
||||
|
||||
test('retains a manifest saved after a 180-second lease and 120-second finalization', async () => {
|
||||
exit();
|
||||
finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await jest.advanceTimersByTimeAsync(299_999);
|
||||
await saveManifest();
|
||||
await jest.advanceTimersByTimeAsync(1_001);
|
||||
expect(await finishing).toHaveLength(1);
|
||||
expect(child.kill).not.toHaveBeenCalledWith('SIGKILL');
|
||||
});
|
||||
@@ -0,0 +1,539 @@
|
||||
import { type Env } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { type ChildProcess } from 'node:child_process';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { rm, writeFile } from 'node:fs/promises';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils';
|
||||
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../serveSimMetricsRecorder');
|
||||
jest.mock('../../../sentry');
|
||||
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
} as unknown as bunyan;
|
||||
const env = {} as Env;
|
||||
const udid = '06B546AC-7B06-4BED-83DB-83603E8EB537';
|
||||
|
||||
beforeEach(() => {
|
||||
IosSimulatorRecordingUtils.useServeSimPackage('@expo/serve-sim@next');
|
||||
jest
|
||||
.spyOn(IosSimulatorUtils, 'getAvailableDevicesAsync')
|
||||
.mockResolvedValue([{ udid, name: 'iPhone 17 Pro', runtimeDisplayName: 'iOS 26.4' } as never]);
|
||||
jest.mocked(readServeSimServersAsync).mockReset();
|
||||
jest.mocked(spawn).mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function waitForRecorderSpawnAsync(): Promise<void> {
|
||||
for (let attempt = 0; attempt < 50; attempt++) {
|
||||
if (jest.mocked(spawn).mock.calls.length > 0) {
|
||||
return;
|
||||
}
|
||||
await setTimeout(10);
|
||||
}
|
||||
throw new Error('serve-sim recorder did not start');
|
||||
}
|
||||
|
||||
// A recorder the test controls: `exit` ends it cleanly, `fail` ends it with an error.
|
||||
function mockRecorder({ kill }: { kill?: (signal: NodeJS.Signals) => void } = {}): {
|
||||
child: ChildProcess;
|
||||
exit: () => void;
|
||||
fail: (error: Error) => void;
|
||||
} {
|
||||
let exit: () => void = () => {};
|
||||
let fail: (error: Error) => void = () => {};
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: jest.fn((signal: NodeJS.Signals) => {
|
||||
kill?.(signal);
|
||||
return true;
|
||||
}),
|
||||
}) as unknown as ChildProcess;
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
exit = resolve;
|
||||
fail = reject;
|
||||
});
|
||||
jest.mocked(spawn).mockReturnValue(Object.assign(done, { child }) as never);
|
||||
return { child, exit: () => exit(), fail: error => fail(error) };
|
||||
}
|
||||
|
||||
function mockFailingRecorder(): void {
|
||||
jest.mocked(spawn).mockImplementation((_command, args) => {
|
||||
void writeFile(`${args[6]}/session.json`, JSON.stringify({ recording: 'recording.mp4' }));
|
||||
return Object.assign(Promise.reject(new Error('recorder exited')), {
|
||||
child: {
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: jest.fn(),
|
||||
},
|
||||
}) as never;
|
||||
});
|
||||
}
|
||||
|
||||
test('waits for a token-gated serve-sim session before starting a recorder', async () => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563' }]);
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
|
||||
expect(jest.mocked(spawn)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('starts serve-sim record-video and returns only a finalized recording', async () => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
let resolveProcess: () => void = () => {};
|
||||
const stderr = new PassThrough();
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdout: new PassThrough(),
|
||||
stderr,
|
||||
kill: jest.fn(),
|
||||
}) as unknown as ChildProcess;
|
||||
const processPromise = Object.assign(
|
||||
new Promise<void>(resolve => {
|
||||
resolveProcess = resolve;
|
||||
}),
|
||||
{
|
||||
child,
|
||||
}
|
||||
);
|
||||
jest.mocked(spawn).mockReturnValue(processPromise as never);
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
const [command, args, options] = jest.mocked(spawn).mock.calls[0];
|
||||
expect(command).toBe('npx');
|
||||
expect(args.slice(0, 5)).toEqual([
|
||||
'--yes',
|
||||
'@expo/serve-sim@next',
|
||||
'record-video',
|
||||
'--udid',
|
||||
udid,
|
||||
]);
|
||||
expect(args[5]).toBe('--output');
|
||||
expect(args).not.toContain('--segment-duration');
|
||||
expect(options?.detached).toBe(true);
|
||||
|
||||
const outputDirectory = args[6];
|
||||
jest.mocked(child.kill).mockImplementation(() => {
|
||||
resolveProcess();
|
||||
void setTimeout(20).then(() =>
|
||||
writeFile(`${outputDirectory}/session.json`, JSON.stringify({ recording: 'recording.mp4' }))
|
||||
);
|
||||
return true;
|
||||
});
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await setTimeout(20);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
stderr.write('serve-sim:recording-started\n');
|
||||
stderr.write('x'.repeat(17_000));
|
||||
const recordings = await finishing;
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGINT');
|
||||
expect(recordings).toEqual([
|
||||
{
|
||||
udid,
|
||||
deviceName: 'iPhone 17 Pro',
|
||||
runtimeDisplayName: 'iOS 26.4',
|
||||
directory: outputDirectory,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('reports a started recording that ends without a manifest to Sentry', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
let exitRecorder: () => void = () => {};
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: jest.fn(),
|
||||
}) as unknown as ChildProcess;
|
||||
jest.mocked(spawn).mockReturnValue(
|
||||
Object.assign(
|
||||
new Promise<void>(resolve => {
|
||||
exitRecorder = resolve;
|
||||
}),
|
||||
{ child }
|
||||
) as never
|
||||
);
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
child.stderr?.emit('data', 'serve-sim:recording-started\n');
|
||||
child.stderr?.emit('data', 'GET http://127.0.0.1:43563/record?token=session-token failed\n');
|
||||
exitRecorder();
|
||||
await setTimeout(50);
|
||||
currentTime += 311_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 += 311_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 += 311_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 += 311_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 += 311_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 += 311_000;
|
||||
await IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
});
|
||||
@@ -104,6 +104,39 @@ describe(listAgentDeviceArtifactsAsync, () => {
|
||||
})
|
||||
).rejects.toThrow('agent-device daemon does not expose artifact inventory.');
|
||||
});
|
||||
|
||||
it('keeps a screenshot display rotation and drops an unknown one', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
artifacts: [
|
||||
{
|
||||
id: 'rotated',
|
||||
artifactType: 'screenshot',
|
||||
filename: 'rotated.png',
|
||||
displayRotation: 'landscape-left',
|
||||
},
|
||||
{
|
||||
id: 'future',
|
||||
artifactType: 'screenshot',
|
||||
filename: 'future.png',
|
||||
displayRotation: 'sideways',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const artifacts = await listAgentDeviceArtifactsAsync({
|
||||
daemonUrl: 'http://127.0.0.1:1234',
|
||||
daemonToken: 'daemon-token',
|
||||
});
|
||||
|
||||
expect(artifacts.map(artifact => artifact.displayRotation)).toEqual([
|
||||
'landscape-left',
|
||||
undefined,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
@@ -120,8 +153,8 @@ describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
await uploadAgentDeviceArtifactAsync(ctx, {
|
||||
@@ -146,7 +179,7 @@ describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
filename: 'report.json',
|
||||
kind: 'agent-device-test-report',
|
||||
size: data.length,
|
||||
stream: expect.anything(),
|
||||
openStream: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,8 +191,8 @@ describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
await uploadAgentDeviceArtifactAsync(ctx, {
|
||||
@@ -181,6 +214,63 @@ describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads a screenshot display rotation as artifact metadata', async () => {
|
||||
const data = Buffer.from('png-data');
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
await uploadAgentDeviceArtifactAsync(ctx, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
daemonUrl: 'http://127.0.0.1:1234',
|
||||
daemonToken: 'daemon-token',
|
||||
logger: createLoggerMock(),
|
||||
artifact: {
|
||||
id: 'artifact-id',
|
||||
artifactType: 'screenshot',
|
||||
filename: 'shot.png',
|
||||
displayRotation: 'landscape-right',
|
||||
},
|
||||
});
|
||||
|
||||
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
kind: 'screenshot',
|
||||
metadata: { displayRotation: 'landscape-right' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads no metadata when the artifact has no display rotation', async () => {
|
||||
const data = Buffer.from('artifact-data');
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
await uploadAgentDeviceArtifactAsync(ctx, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
daemonUrl: 'http://127.0.0.1:1234',
|
||||
daemonToken: 'daemon-token',
|
||||
logger: createLoggerMock(),
|
||||
artifact: { id: 'artifact-id', artifactType: 'screenshot', filename: 'shot.png' },
|
||||
});
|
||||
|
||||
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls[0][1]).not.toHaveProperty(
|
||||
'metadata'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(pollAgentDeviceArtifactsForUploadAsync, () => {
|
||||
@@ -255,8 +345,8 @@ describe(pollAgentDeviceArtifactsForUploadAsync, () => {
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockRejectedValueOnce(new Error('upload failed'))
|
||||
.mockImplementationOnce(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import turtleSpawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { EventEmitter, once } from 'node:events';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { killProcessGroup } from '../../../utils/processes';
|
||||
import { runAgentAsync } from '../agentRun';
|
||||
import {
|
||||
type AgentRunProviderCredentials,
|
||||
leaseAgentRunProviderCredentialsAsync,
|
||||
} from '../agentRunLease';
|
||||
import { CLAUDE_CODE_CLI } from '../agents/claude';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('node:child_process', () => ({
|
||||
...jest.requireActual('node:child_process'),
|
||||
spawn: jest.fn(),
|
||||
}));
|
||||
jest.mock('../agentRunLease');
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../utils/processes', () => ({
|
||||
...jest.requireActual('../../../utils/processes'),
|
||||
killProcessGroup: jest.fn(),
|
||||
}));
|
||||
|
||||
const installMock = jest.mocked(turtleSpawn);
|
||||
const spawnMock = jest.mocked(spawn);
|
||||
const leaseMock = jest.mocked(leaseAgentRunProviderCredentialsAsync);
|
||||
const killProcessGroupMock = jest.mocked(killProcessGroup);
|
||||
|
||||
const agentRunId = '0199b0a0-1111-7222-8333-444455556666';
|
||||
const anthropicLease: AgentRunProviderCredentials = {
|
||||
provider: 'anthropic',
|
||||
accessToken: 'anthropic-access-token',
|
||||
};
|
||||
const openaiLease: AgentRunProviderCredentials = {
|
||||
provider: 'openai',
|
||||
idToken: 'openai-id-token',
|
||||
accessToken: 'openai-access-token',
|
||||
accountId: 'openai-account-id',
|
||||
};
|
||||
|
||||
interface AgentInvocation {
|
||||
command: string;
|
||||
args: string[];
|
||||
env: Record<string, string>;
|
||||
cwd: string;
|
||||
stdin: jest.Mock;
|
||||
child: EventEmitter & { stdout: PassThrough; stderr: PassThrough };
|
||||
/** The run removes its files when it ends, so call this while the agent is running. */
|
||||
readHomeFile(relativePath: string): { content: string; mode: number };
|
||||
exit(status: number | null, signal?: NodeJS.Signals, options?: { closeOutput: boolean }): void;
|
||||
closeOutput(): void;
|
||||
}
|
||||
|
||||
function mockSpawn({
|
||||
onAgent,
|
||||
onInstall,
|
||||
}: {
|
||||
onAgent: (invocation: AgentInvocation) => void;
|
||||
onInstall?: () => void;
|
||||
}): AgentInvocation[] {
|
||||
installMock.mockImplementation((() => {
|
||||
try {
|
||||
onInstall?.();
|
||||
return Promise.resolve({});
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}) as any);
|
||||
const invocations: AgentInvocation[] = [];
|
||||
spawnMock.mockImplementation(((command: string, args: string[], spawnOptions: any) => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
pid: 4242,
|
||||
stdin: { on: jest.fn(), end: jest.fn() },
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
});
|
||||
// Like a real child: `close` comes after everything it printed has been read.
|
||||
const closeOutput = (): void => {
|
||||
child.stdout.end();
|
||||
child.stderr.end();
|
||||
void Promise.all([once(child.stdout, 'end'), once(child.stderr, 'end')]).then(() =>
|
||||
child.emit('close')
|
||||
);
|
||||
};
|
||||
const invocation: AgentInvocation = {
|
||||
command,
|
||||
args,
|
||||
env: spawnOptions.env,
|
||||
cwd: spawnOptions.cwd,
|
||||
stdin: child.stdin.end,
|
||||
child,
|
||||
readHomeFile(relativePath) {
|
||||
const filePath = path.join(spawnOptions.env.HOME, relativePath);
|
||||
return {
|
||||
content: vol.readFileSync(filePath, 'utf8') as string,
|
||||
mode: vol.statSync(filePath).mode & 0o777,
|
||||
};
|
||||
},
|
||||
exit(status, signal, options = { closeOutput: true }) {
|
||||
process.nextTick(() => {
|
||||
child.emit('exit', status, signal ?? null);
|
||||
if (options.closeOutput) {
|
||||
closeOutput();
|
||||
}
|
||||
});
|
||||
},
|
||||
closeOutput,
|
||||
};
|
||||
invocations.push(invocation);
|
||||
onAgent(invocation);
|
||||
return child;
|
||||
}) as any);
|
||||
return invocations;
|
||||
}
|
||||
|
||||
function runDirectories(): string[] {
|
||||
return (vol.readdirSync(os.tmpdir()) as string[]).filter(name =>
|
||||
name.startsWith('eas-agent-run-')
|
||||
);
|
||||
}
|
||||
|
||||
function createOptions(
|
||||
overrides: Partial<Parameters<typeof runAgentAsync>[0]> = {}
|
||||
): Parameters<typeof runAgentAsync>[0] {
|
||||
return {
|
||||
agentKind: 'claude-code',
|
||||
agentRunId,
|
||||
prompt: 'Fix the failing test.',
|
||||
expoToken: 'expo-token',
|
||||
expoApiV2BaseUrl: 'https://staging-api.expo.test/v2/',
|
||||
mcpUrl: 'https://staging-mcp.expo.test/mcp',
|
||||
pathEnv: '/usr/local/bin:/usr/bin',
|
||||
npmCacheUrl: 'https://npm-cache.expo.test',
|
||||
logger: createMockLogger(),
|
||||
signal: new AbortController().signal,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe(runAgentAsync, () => {
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
expect(runDirectories()).toEqual([]);
|
||||
});
|
||||
|
||||
it('runs Claude Code with MCP as its only tool source and an allowlisted environment', async () => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const invocations = mockSpawn({
|
||||
onAgent: agent => {
|
||||
const mcpConfig = agent.readHomeFile('mcp.json');
|
||||
expect(JSON.parse(mcpConfig.content)).toEqual({
|
||||
mcpServers: {
|
||||
expo: {
|
||||
type: 'http',
|
||||
url: 'https://staging-mcp.expo.test/mcp',
|
||||
headers: {
|
||||
Authorization: 'Bearer expo-token',
|
||||
'X-Expo-Agent-Run-Id': agentRunId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mcpConfig.mode).toBe(0o600);
|
||||
agent.exit(0);
|
||||
},
|
||||
});
|
||||
|
||||
await runAgentAsync(createOptions());
|
||||
|
||||
expect(installMock.mock.calls[0].slice(0, 2)).toEqual([
|
||||
'npm',
|
||||
[
|
||||
'install',
|
||||
'--prefix',
|
||||
expect.stringMatching(/eas-agent-run-.*\/cli$/),
|
||||
'--no-save',
|
||||
'--package-lock=false',
|
||||
'--no-audit',
|
||||
'--no-fund',
|
||||
'--registry',
|
||||
'https://npm-cache.expo.test',
|
||||
'@anthropic-ai/[email protected]',
|
||||
],
|
||||
]);
|
||||
expect(invocations).toHaveLength(1);
|
||||
const [agent] = invocations;
|
||||
const runDirectory = path.dirname(agent.env.HOME);
|
||||
expect(path.dirname(runDirectory)).toBe(os.tmpdir());
|
||||
expect(agent.command).toBe(path.join(runDirectory, 'cli', 'node_modules', '.bin', 'claude'));
|
||||
expect(agent.args).toEqual([
|
||||
'-p',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--tools',
|
||||
'',
|
||||
'--allowedTools',
|
||||
'mcp__expo',
|
||||
'--mcp-config',
|
||||
path.join(agent.env.HOME, 'mcp.json'),
|
||||
'--strict-mcp-config',
|
||||
]);
|
||||
expect(agent.env).toEqual({
|
||||
PATH: '/usr/local/bin:/usr/bin',
|
||||
HOME: path.join(runDirectory, 'home'),
|
||||
TMPDIR: path.join(runDirectory, 'tmp'),
|
||||
LANG: 'C.UTF-8',
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'anthropic-access-token',
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
||||
});
|
||||
expect(agent.cwd).toBe(path.join(runDirectory, 'cwd'));
|
||||
expect(agent.stdin).toHaveBeenCalledWith('Fix the failing test.');
|
||||
});
|
||||
|
||||
it('writes the agent output as log lines with the leased credentials masked', async () => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const logger = createMockLogger();
|
||||
mockSpawn({
|
||||
onAgent: agent => {
|
||||
// One JSON event split across two chunks, in the middle of the token.
|
||||
agent.child.stdout.write(
|
||||
'{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"token: anthropic-acc'
|
||||
);
|
||||
agent.child.stdout.write('ess-token"}]}}\n');
|
||||
agent.child.stdout.write(
|
||||
'{"type":"assistant","message":{"content":[{"type":"text","text":"Done.\\nThe token was anthropic-access-token.\\n"}]}}\n'
|
||||
);
|
||||
agent.child.stdout.write(
|
||||
'{"type":"result","subtype":"error_during_execution","is_error":true,"errors":["boom"]}\n'
|
||||
);
|
||||
agent.child.stderr.write('warning: anthropic-access-token\n');
|
||||
agent.exit(0);
|
||||
},
|
||||
});
|
||||
|
||||
await runAgentAsync(createOptions({ logger }));
|
||||
|
||||
const child = jest.mocked(logger.child);
|
||||
expect(child.mock.calls).toEqual([[{ source: 'stdout' }], [{ source: 'stderr' }]]);
|
||||
const [stdoutLogger, stderrLogger] = child.mock.results.map(result => result.value);
|
||||
expect(stdoutLogger.info.mock.calls).toEqual([
|
||||
['Tool result: token: [redacted]'],
|
||||
['Agent: Done.\nThe token was [redacted].'],
|
||||
]);
|
||||
expect(stdoutLogger.error.mock.calls).toEqual([['Agent failed: boom']]);
|
||||
expect(stderrLogger.info.mock.calls).toEqual([['warning: [redacted]']]);
|
||||
});
|
||||
|
||||
it('reports a line the formatter cannot handle instead of ending the run', async () => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const logger = createMockLogger();
|
||||
const formatOutputLine = jest
|
||||
.spyOn(CLAUDE_CODE_CLI, 'formatOutputLine')
|
||||
.mockImplementationOnce(() => {
|
||||
throw new RangeError('Maximum call stack size exceeded');
|
||||
});
|
||||
mockSpawn({
|
||||
onAgent: agent => {
|
||||
agent.child.stdout.write(
|
||||
'{"type":"assistant","message":{"content":[{"type":"tool_use","name":"t","input":{}}]}}\n'
|
||||
);
|
||||
agent.child.stdout.write(
|
||||
'{"type":"assistant","message":{"content":[{"type":"text","text":"Still here."}]}}\n'
|
||||
);
|
||||
agent.exit(0);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await runAgentAsync(createOptions({ logger }));
|
||||
} finally {
|
||||
formatOutputLine.mockRestore();
|
||||
}
|
||||
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not format a line of the agent output',
|
||||
expect.any(RangeError)
|
||||
);
|
||||
const [stdoutLogger] = jest.mocked(logger.child).mock.results.map(result => result.value);
|
||||
expect(stdoutLogger.info.mock.calls).toEqual([['Agent: Still here.']]);
|
||||
});
|
||||
|
||||
it('runs Codex with its shell tool off and the credentials in a private home', async () => {
|
||||
leaseMock.mockResolvedValue(openaiLease);
|
||||
const invocations = mockSpawn({
|
||||
onAgent: agent => {
|
||||
const config = agent.readHomeFile('.codex/config.toml');
|
||||
expect(config.content).toContain(
|
||||
'[features]\nshell_tool = false\nview_image = false\nimage_generation = false\napps = false\nplugins = false\n'
|
||||
);
|
||||
expect(config.content).toContain('sandbox_mode = "read-only"');
|
||||
expect(config.content).toContain('[agents]\nenabled = false\n');
|
||||
expect(config.content).toContain(
|
||||
[
|
||||
'[mcp_servers.expo]',
|
||||
'url = "http://localhost:8787/mcp"',
|
||||
'default_tools_approval_mode = "approve"',
|
||||
'',
|
||||
'[mcp_servers.expo.http_headers]',
|
||||
'"Authorization" = "Bearer expo-token"',
|
||||
`"X-Expo-Agent-Run-Id" = "${agentRunId}"`,
|
||||
].join('\n')
|
||||
);
|
||||
expect(config.mode).toBe(0o600);
|
||||
const auth = agent.readHomeFile('.codex/auth.json');
|
||||
expect(JSON.parse(auth.content)).toEqual({
|
||||
auth_mode: 'chatgptAuthTokens',
|
||||
tokens: {
|
||||
id_token: 'openai-id-token',
|
||||
access_token: 'openai-access-token',
|
||||
refresh_token: '',
|
||||
account_id: 'openai-account-id',
|
||||
},
|
||||
last_refresh: expect.any(String),
|
||||
});
|
||||
expect(auth.mode).toBe(0o600);
|
||||
agent.exit(0);
|
||||
},
|
||||
});
|
||||
|
||||
await runAgentAsync(
|
||||
createOptions({
|
||||
agentKind: 'codex',
|
||||
mcpUrl: 'http://localhost:8787/mcp',
|
||||
npmCacheUrl: undefined,
|
||||
})
|
||||
);
|
||||
|
||||
const npmArgs = installMock.mock.calls[0][1]!;
|
||||
expect(npmArgs).not.toContain('--registry');
|
||||
expect(npmArgs.at(-1)).toBe('@openai/[email protected]');
|
||||
const [agent] = invocations;
|
||||
const runDirectory = path.dirname(agent.env.HOME);
|
||||
expect(agent.command).toBe(path.join(runDirectory, 'cli', 'node_modules', '.bin', 'codex'));
|
||||
expect(agent.args).toEqual(['exec', '--strict-config', '--json', '--skip-git-repo-check']);
|
||||
expect(agent.cwd).toBe(path.join(runDirectory, 'cwd'));
|
||||
expect(agent.env).toEqual({
|
||||
PATH: '/usr/local/bin:/usr/bin',
|
||||
HOME: path.join(runDirectory, 'home'),
|
||||
TMPDIR: path.join(runDirectory, 'tmp'),
|
||||
LANG: 'C.UTF-8',
|
||||
});
|
||||
expect(agent.stdin).toHaveBeenCalledWith('Fix the failing test.');
|
||||
});
|
||||
|
||||
it('escalates to SIGKILL when the agent ignores the stop', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const controller = new AbortController();
|
||||
let agentStarted!: () => void;
|
||||
const started = new Promise<void>(resolve => {
|
||||
agentStarted = resolve;
|
||||
});
|
||||
const invocations = mockSpawn({ onAgent: () => agentStarted() });
|
||||
|
||||
const run = runAgentAsync(createOptions({ signal: controller.signal }));
|
||||
const rejection = expect(run).rejects.toMatchObject({ name: 'AbortError' });
|
||||
await started;
|
||||
const [agent] = invocations;
|
||||
|
||||
controller.abort();
|
||||
expect(killProcessGroupMock.mock.calls).toEqual([[agent.child, 'SIGTERM']]);
|
||||
await jest.advanceTimersByTimeAsync(10_000);
|
||||
expect(killProcessGroupMock.mock.calls).toEqual([
|
||||
[agent.child, 'SIGTERM'],
|
||||
[agent.child, 'SIGKILL'],
|
||||
]);
|
||||
agent.exit(null, 'SIGKILL');
|
||||
|
||||
await rejection;
|
||||
expect(leaseMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ends what the agent left running and waits a bounded time for its output', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
let agentStarted!: () => void;
|
||||
const started = new Promise<void>(resolve => {
|
||||
agentStarted = resolve;
|
||||
});
|
||||
const invocations = mockSpawn({ onAgent: () => agentStarted() });
|
||||
|
||||
const run = runAgentAsync(createOptions());
|
||||
await started;
|
||||
invocations[0].exit(0, undefined, { closeOutput: false });
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
await expect(run).resolves.toBeUndefined();
|
||||
expect(killProcessGroupMock.mock.calls).toEqual([[invocations[0].child, 'SIGKILL']]);
|
||||
});
|
||||
|
||||
it('stops the agent when the step is aborted and fails with the abort reason', async () => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('the step was canceled');
|
||||
const invocations = mockSpawn({
|
||||
onAgent: agent => {
|
||||
queueMicrotask(() => {
|
||||
controller.abort(reason);
|
||||
agent.exit(null, 'SIGTERM');
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await expect(runAgentAsync(createOptions({ signal: controller.signal }))).rejects.toBe(reason);
|
||||
|
||||
expect(killProcessGroupMock.mock.calls).toEqual([
|
||||
[invocations[0].child, 'SIGTERM'],
|
||||
[invocations[0].child, 'SIGKILL'],
|
||||
]);
|
||||
expect(leaseMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resumes Claude Code with new credentials when the lease changed during the run', async () => {
|
||||
const renewedLease: AgentRunProviderCredentials = {
|
||||
...anthropicLease,
|
||||
accessToken: 'renewed-access-token',
|
||||
};
|
||||
leaseMock.mockResolvedValueOnce(anthropicLease).mockResolvedValueOnce(renewedLease);
|
||||
const logger = createMockLogger();
|
||||
const invocations = mockSpawn({
|
||||
onAgent: agent => {
|
||||
if (invocations.length === 2) {
|
||||
agent.child.stderr.write('tokens: anthropic-access-token renewed-access-token\n');
|
||||
}
|
||||
agent.exit(invocations.length === 1 ? 1 : 0);
|
||||
},
|
||||
});
|
||||
|
||||
await runAgentAsync(createOptions({ logger }));
|
||||
|
||||
expect(installMock).toHaveBeenCalledTimes(1);
|
||||
expect(invocations).toHaveLength(2);
|
||||
const [first, resumed] = invocations;
|
||||
expect(resumed.args).toEqual(['-p', '--continue', ...first.args.slice(1)]);
|
||||
expect(resumed.env.CLAUDE_CODE_OAUTH_TOKEN).toBe('renewed-access-token');
|
||||
expect(resumed.stdin).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^Your previous run of this task stopped.*\n\nFix the failing test\.$/s)
|
||||
);
|
||||
const child = jest.mocked(logger.child);
|
||||
expect(child.mock.calls).toEqual([
|
||||
[{ source: 'stdout' }],
|
||||
[{ source: 'stderr' }],
|
||||
[{ source: 'stdout' }],
|
||||
[{ source: 'stderr' }],
|
||||
]);
|
||||
const [, , , resumedStderrLogger] = child.mock.results.map(result => result.value);
|
||||
expect(resumedStderrLogger.info.mock.calls).toEqual([['tokens: [redacted] [redacted]']]);
|
||||
});
|
||||
|
||||
it('resumes Codex with new credentials when the lease changed during the run', async () => {
|
||||
const renewedLease: AgentRunProviderCredentials = {
|
||||
...openaiLease,
|
||||
accessToken: 'renewed-access-token',
|
||||
idToken: 'renewed-id-token',
|
||||
};
|
||||
leaseMock.mockResolvedValueOnce(openaiLease).mockResolvedValueOnce(renewedLease);
|
||||
const invocations = mockSpawn({
|
||||
onAgent: agent => {
|
||||
if (invocations.length === 1) {
|
||||
agent.exit(1);
|
||||
return;
|
||||
}
|
||||
expect(JSON.parse(agent.readHomeFile('.codex/auth.json').content).tokens).toMatchObject({
|
||||
id_token: 'renewed-id-token',
|
||||
access_token: 'renewed-access-token',
|
||||
});
|
||||
agent.exit(0);
|
||||
},
|
||||
});
|
||||
|
||||
await runAgentAsync(createOptions({ agentKind: 'codex' }));
|
||||
|
||||
expect(invocations).toHaveLength(2);
|
||||
expect(invocations[1].args).toEqual([
|
||||
'exec',
|
||||
'--strict-config',
|
||||
'--json',
|
||||
'--skip-git-repo-check',
|
||||
'resume',
|
||||
'--last',
|
||||
'-',
|
||||
]);
|
||||
expect(invocations[1].stdin).toHaveBeenCalledWith(
|
||||
expect.stringContaining('\n\nFix the failing test.')
|
||||
);
|
||||
expect(invocations[1].cwd).toBe(invocations[0].cwd);
|
||||
});
|
||||
|
||||
it('does not resume an agent when the step was aborted while its credentials were renewed', async () => {
|
||||
const controller = new AbortController();
|
||||
leaseMock.mockResolvedValueOnce(anthropicLease).mockImplementationOnce(async () => {
|
||||
controller.abort();
|
||||
return { ...anthropicLease, accessToken: 'renewed-access-token' };
|
||||
});
|
||||
const invocations = mockSpawn({ onAgent: agent => agent.exit(1) });
|
||||
|
||||
await expect(runAgentAsync(createOptions({ signal: controller.signal }))).rejects.toMatchObject(
|
||||
{ name: 'AbortError' }
|
||||
);
|
||||
|
||||
expect(invocations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[2, undefined, 'exited with code 2'],
|
||||
[null, 'SIGKILL' as const, 'was terminated by SIGKILL'],
|
||||
])(
|
||||
'fails when the agent fails and the lease is unchanged (status %p, signal %p)',
|
||||
async (status, signal, description) => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const invocations = mockSpawn({ onAgent: agent => agent.exit(status, signal) });
|
||||
|
||||
await expect(runAgentAsync(createOptions())).rejects.toMatchObject({
|
||||
errorCode: 'EAS_RUN_AGENT_FAILED',
|
||||
message: expect.stringContaining(description),
|
||||
});
|
||||
|
||||
expect(invocations).toHaveLength(1);
|
||||
expect(leaseMock).toHaveBeenCalledTimes(2);
|
||||
}
|
||||
);
|
||||
|
||||
it('fails with the lease error when the lease cannot be renewed after the agent failed', async () => {
|
||||
leaseMock
|
||||
.mockResolvedValueOnce(anthropicLease)
|
||||
.mockRejectedValueOnce(new Error('lease refused'));
|
||||
const invocations = mockSpawn({ onAgent: agent => agent.exit(1) });
|
||||
|
||||
await expect(runAgentAsync(createOptions())).rejects.toThrow('lease refused');
|
||||
|
||||
expect(invocations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports an agent CLI that cannot start', async () => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
mockSpawn({
|
||||
onAgent: agent => {
|
||||
process.nextTick(() => agent.child.emit('error', new Error('spawn claude ENOENT')));
|
||||
},
|
||||
});
|
||||
|
||||
await expect(runAgentAsync(createOptions())).rejects.toThrow(
|
||||
'Could not start the agent CLI on the worker: spawn claude ENOENT.'
|
||||
);
|
||||
|
||||
expect(leaseMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cleans up when the installation fails', async () => {
|
||||
leaseMock.mockResolvedValue(anthropicLease);
|
||||
const invocations = mockSpawn({
|
||||
onInstall: () => {
|
||||
throw new Error('npm ERR! network');
|
||||
},
|
||||
onAgent: agent => agent.exit(0),
|
||||
});
|
||||
|
||||
await expect(runAgentAsync(createOptions())).rejects.toBeInstanceOf(SystemError);
|
||||
|
||||
expect(invocations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not install or start anything when the lease is refused', async () => {
|
||||
leaseMock.mockRejectedValue(new Error('lease refused'));
|
||||
mockSpawn({ onAgent: agent => agent.exit(0) });
|
||||
|
||||
await expect(runAgentAsync(createOptions())).rejects.toThrow('lease refused');
|
||||
|
||||
expect(installMock).not.toHaveBeenCalled();
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses credentials of the wrong provider', async () => {
|
||||
leaseMock.mockResolvedValue(openaiLease);
|
||||
const invocations = mockSpawn({ onAgent: agent => agent.exit(0) });
|
||||
|
||||
await expect(runAgentAsync(createOptions({ agentKind: 'claude-code' }))).rejects.toBeInstanceOf(
|
||||
SystemError
|
||||
);
|
||||
|
||||
expect(invocations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import fetch, { type Response } from 'node-fetch';
|
||||
|
||||
import { leaseAgentRunProviderCredentialsAsync } from '../agentRunLease';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const agentRunId = '0199b0a0-1111-7222-8333-444455556666';
|
||||
const options = {
|
||||
expoApiV2BaseUrl: 'https://staging-api.expo.test/v2/',
|
||||
expoToken: 'expo-token',
|
||||
agentRunId,
|
||||
};
|
||||
|
||||
function createResponse(status: number, body: unknown): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => {
|
||||
if (body === undefined) {
|
||||
throw new Error('not JSON');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe(leaseAgentRunProviderCredentialsAsync, () => {
|
||||
it('leases Anthropic credentials with the job token', async () => {
|
||||
const lease = { provider: 'anthropic', accessToken: 'access-token' };
|
||||
fetchMock.mockResolvedValue(
|
||||
createResponse(200, { data: { ...lease, expiresAt: '2026-10-06T12:00:00.000Z' } })
|
||||
);
|
||||
|
||||
await expect(leaseAgentRunProviderCredentialsAsync(options)).resolves.toEqual(lease);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe(`https://staging-api.expo.test/v2/agent-runs/${agentRunId}/auth-lease`);
|
||||
expect(init).toMatchObject({
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer expo-token' },
|
||||
});
|
||||
});
|
||||
|
||||
it('leases OpenAI credentials', async () => {
|
||||
const lease = {
|
||||
provider: 'openai',
|
||||
idToken: 'id-token',
|
||||
accessToken: 'access-token',
|
||||
accountId: 'account-id',
|
||||
};
|
||||
fetchMock.mockResolvedValue(createResponse(200, { data: lease }));
|
||||
|
||||
await expect(leaseAgentRunProviderCredentialsAsync(options)).resolves.toEqual(lease);
|
||||
});
|
||||
|
||||
it('reports a 4xx with the message from the server and does not retry', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
createResponse(400, {
|
||||
errors: [
|
||||
{
|
||||
code: 'AGENT_RUN_PROVIDER_CONNECTION_DELETED',
|
||||
message: 'The provider connection for this agent run was deleted.',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const lease = leaseAgentRunProviderCredentialsAsync(options);
|
||||
|
||||
await expect(lease).rejects.toBeInstanceOf(UserError);
|
||||
await expect(lease).rejects.toMatchObject({
|
||||
errorCode: 'EAS_RUN_AGENT_CREDENTIALS_UNAVAILABLE',
|
||||
message: 'The provider connection for this agent run was deleted.',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports a 4xx without a readable body', async () => {
|
||||
fetchMock.mockResolvedValue(createResponse(401, undefined));
|
||||
|
||||
await expect(leaseAgentRunProviderCredentialsAsync(options)).rejects.toThrow(
|
||||
'Expo refused to issue provider credentials for this agent run (HTTP 401).'
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries server errors and network failures', async () => {
|
||||
jest.useFakeTimers();
|
||||
const lease = { provider: 'anthropic', accessToken: 'access-token' };
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponse(503, undefined))
|
||||
.mockRejectedValueOnce(new Error('socket hang up'))
|
||||
.mockResolvedValueOnce(createResponse(200, { data: lease }));
|
||||
|
||||
const assertion = expect(leaseAgentRunProviderCredentialsAsync(options)).resolves.toEqual(
|
||||
lease
|
||||
);
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('retries when the body of a successful response cannot be read', async () => {
|
||||
jest.useFakeTimers();
|
||||
const lease = { provider: 'anthropic', accessToken: 'access-token' };
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponse(200, undefined))
|
||||
.mockResolvedValueOnce(createResponse(200, { data: lease }));
|
||||
|
||||
const assertion = expect(leaseAgentRunProviderCredentialsAsync(options)).resolves.toEqual(
|
||||
lease
|
||||
);
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('gives up after three failed attempts', async () => {
|
||||
jest.useFakeTimers();
|
||||
fetchMock.mockResolvedValue(createResponse(500, undefined));
|
||||
|
||||
const assertion = expect(leaseAgentRunProviderCredentialsAsync(options)).rejects.toBeInstanceOf(
|
||||
SystemError
|
||||
);
|
||||
await jest.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('rejects a successful response that is not a lease', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
createResponse(200, { data: { provider: 'anthropic', refreshToken: 'nope' } })
|
||||
);
|
||||
|
||||
await expect(leaseAgentRunProviderCredentialsAsync(options)).rejects.toBeInstanceOf(
|
||||
SystemError
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
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, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
await uploadArgentArtifactAsync(ctx, {
|
||||
@@ -151,7 +151,7 @@ describe(uploadArgentArtifactAsync, () => {
|
||||
filename: 'report.json',
|
||||
kind: undefined,
|
||||
size: data.length,
|
||||
stream: expect.anything(),
|
||||
openStream: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,8 +168,8 @@ describe(uploadArgentArtifactAsync, () => {
|
||||
.mockResolvedValueOnce(new Response(Readable.from([Buffer.from('artifact-data')])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
await uploadArgentArtifactAsync(ctx, {
|
||||
@@ -345,8 +345,8 @@ describe(pollArgentArtifactsForUploadAsync, () => {
|
||||
});
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementation(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
.mockImplementation(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
const pollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
|
||||
@@ -371,7 +371,7 @@ describe(pollArgentArtifactsForUploadAsync, () => {
|
||||
name: 'b.json (artifact-b)',
|
||||
filename: 'b.json',
|
||||
size: 'artifact-b-data'.length,
|
||||
stream: expect.anything(),
|
||||
openStream: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
|
||||
kind: 'agent-device-test-report',
|
||||
metadata: { firstFrameRecordAt: 'test-time' },
|
||||
size: reportedSize,
|
||||
stream,
|
||||
openStream: () => stream,
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
@@ -63,7 +63,8 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
|
||||
metadata: { firstFrameRecordAt: 'test-time' },
|
||||
size: reportedSize,
|
||||
},
|
||||
})
|
||||
}),
|
||||
{ fetch: expect.any(Function) }
|
||||
);
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
'https://uploads.expo.test/artifact',
|
||||
@@ -73,4 +74,52 @@ 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
import { readFile, readdir, writeFile } from 'node:fs/promises';
|
||||
import { Readable } from 'node:stream';
|
||||
import os from 'node:os';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import {
|
||||
captureDeviceRunSessionPreviewAsync,
|
||||
ensureMacosPreviewEncoderInstalledAsync,
|
||||
startDeviceRunSessionPreview,
|
||||
} from '../deviceRunSessionPreview';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
describe(startDeviceRunSessionPreview, () => {
|
||||
const image = Buffer.from('webp-image');
|
||||
let captureAsync: jest.Mock<Promise<Buffer>, [AbortSignal]>;
|
||||
let mutation: jest.Mock;
|
||||
let logger: bunyan;
|
||||
let ctx: CustomBuildContext;
|
||||
let preview: ReturnType<typeof startDeviceRunSessionPreview> | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
captureAsync = jest.fn().mockResolvedValue(image);
|
||||
mutation = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createPreviewUploadSession: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/preview',
|
||||
headers: { 'content-type': 'image/webp' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
ctx = { graphqlClient: { mutation } } as unknown as CustomBuildContext;
|
||||
logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockReset()
|
||||
.mockResolvedValue(new Response('', { status: 200 }));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await preview?.stopAsync();
|
||||
preview = undefined;
|
||||
jest.useRealTimers();
|
||||
});
|
||||
const start = () => {
|
||||
preview = startDeviceRunSessionPreview({
|
||||
ctx,
|
||||
deviceRunSessionId: 'session-id',
|
||||
captureAsync,
|
||||
logger,
|
||||
});
|
||||
return preview;
|
||||
};
|
||||
|
||||
it('uploads immediately and reuses the URL every 60 seconds', async () => {
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://uploads.expo.test/preview',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: image,
|
||||
headers: { 'content-type': 'image/webp' },
|
||||
})
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(59_999);
|
||||
expect(captureAsync).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(captureAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
await preview!.stopAsync();
|
||||
await jest.advanceTimersByTimeAsync(120_000);
|
||||
expect(captureAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('retries capture errors without creating an empty artifact', async () => {
|
||||
captureAsync.mockRejectedValueOnce(new Error('device not ready'));
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries a failed upload with the same URL', async () => {
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 403 }));
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not log the signed upload URL when the upload request fails', async () => {
|
||||
const { FetchError } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValueOnce(
|
||||
new FetchError('network timeout at: https://uploads.expo.test/preview', 'request-timeout')
|
||||
);
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
const [fields] = jest.mocked(logger.warn).mock.calls[0] as unknown as [{ err: Error }];
|
||||
expect(fields.err.message).toBe(
|
||||
'Session preview upload request failed (name: FetchError, type: request-timeout).'
|
||||
);
|
||||
expect(JSON.stringify(jest.mocked(logger.warn).mock.calls)).not.toContain('uploads.expo.test');
|
||||
});
|
||||
|
||||
it('reuses the upload URL for the full session', async () => {
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(119 * 60_000);
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not overlap captures and aborts pending work when stopped', async () => {
|
||||
captureAsync.mockImplementation(
|
||||
signal =>
|
||||
new Promise((_, reject) => {
|
||||
signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
|
||||
})
|
||||
);
|
||||
const handle = start();
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(captureAsync).toHaveBeenCalledTimes(1);
|
||||
await handle.stopAsync();
|
||||
expect(captureAsync.mock.calls[0][0].aborted).toBe(true);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not upload a capture that finishes after shutdown', async () => {
|
||||
let finish!: (image: Buffer) => void;
|
||||
captureAsync.mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finish = resolve;
|
||||
})
|
||||
);
|
||||
const handle = start();
|
||||
const stopping = handle.stopAsync();
|
||||
finish(image);
|
||||
await stopping;
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('session preview with the real GraphQL client', () => {
|
||||
const uploadSession = {
|
||||
url: 'https://uploads.expo.test/preview',
|
||||
headers: { 'content-type': 'image/webp' },
|
||||
};
|
||||
let graphqlFetch: jest.SpyInstance;
|
||||
let preview: ReturnType<typeof startDeviceRunSessionPreview> | undefined;
|
||||
let logger: bunyan;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
graphqlFetch = jest.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new globalThis.Response(
|
||||
JSON.stringify({
|
||||
data: { deviceRunSession: { createPreviewUploadSession: { uploadSession } } },
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json' } }
|
||||
)
|
||||
);
|
||||
logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockReset()
|
||||
.mockResolvedValue(new Response('', { status: 200 }));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await preview?.stopAsync();
|
||||
preview = undefined;
|
||||
jest.restoreAllMocks();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const start = () => {
|
||||
const graphqlClient = new Client({
|
||||
url: 'https://api.expo.test/graphql',
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: { headers: { Authorization: 'Bearer worker-token' } },
|
||||
});
|
||||
preview = startDeviceRunSessionPreview({
|
||||
ctx: { graphqlClient } as CustomBuildContext,
|
||||
deviceRunSessionId: 'session-id',
|
||||
captureAsync: async () => Buffer.from('webp-image'),
|
||||
logger,
|
||||
});
|
||||
return preview;
|
||||
};
|
||||
|
||||
it('preserves the worker authentication when creating the upload session', async () => {
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
const [, options] = graphqlFetch.mock.calls[0];
|
||||
expect(new Headers(options.headers).get('authorization')).toBe('Bearer worker-token');
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(['shutdown', 'timeout'])('aborts a stalled GraphQL request on %s', async reason => {
|
||||
const timeout = new AbortController();
|
||||
jest.spyOn(AbortSignal, 'timeout').mockReturnValue(timeout.signal);
|
||||
let requestSignal: AbortSignal | undefined;
|
||||
let rejectRequest!: (error: Error) => void;
|
||||
graphqlFetch.mockImplementation(
|
||||
(_url, options) =>
|
||||
new Promise((_, reject) => {
|
||||
rejectRequest = reject;
|
||||
requestSignal = options.signal;
|
||||
requestSignal!.addEventListener('abort', () => reject(requestSignal!.reason), {
|
||||
once: true,
|
||||
});
|
||||
})
|
||||
);
|
||||
const handle = start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
let stopped = false;
|
||||
let stopping: Promise<void> | undefined;
|
||||
if (reason === 'shutdown') {
|
||||
stopping = handle.stopAsync().then(() => {
|
||||
stopped = true;
|
||||
});
|
||||
} else {
|
||||
timeout.abort(new Error('preview attempt timed out'));
|
||||
}
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
const aborted = requestSignal!.aborted;
|
||||
const stoppedAfterAbort = stopped;
|
||||
// Always release the fake transport so a regression cannot hang test cleanup.
|
||||
rejectRequest(new Error('test cleanup'));
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(aborted).toBe(true);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
if (stopping) {
|
||||
expect(stoppedAfterAbort).toBe(true);
|
||||
await stopping;
|
||||
} else {
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
graphqlFetch.mockResolvedValue(
|
||||
new globalThis.Response(
|
||||
JSON.stringify({
|
||||
data: { deviceRunSession: { createPreviewUploadSession: { uploadSession } } },
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json' } }
|
||||
)
|
||||
);
|
||||
jest.spyOn(AbortSignal, 'timeout').mockReturnValue(new AbortController().signal);
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(captureDeviceRunSessionPreviewAsync, () => {
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0xff, 0x00]);
|
||||
const webp = Buffer.from('encoded-webp');
|
||||
const resized = Buffer.from('resized-png');
|
||||
it.each([
|
||||
[BuildRuntimePlatform.DARWIN, 'selected-device'],
|
||||
[BuildRuntimePlatform.LINUX, 'selected-device'],
|
||||
[BuildRuntimePlatform.LINUX, 'no-device-id'],
|
||||
] as const)(
|
||||
'captures the selected device on %s and removes temporary files',
|
||||
async (runtimePlatform, device) => {
|
||||
jest.mocked(spawn).mockImplementation((command, args) => {
|
||||
if (command === 'adb' && args[0] === 'devices') {
|
||||
return Promise.resolve({
|
||||
stdout: 'List of devices attached\nselected-device\tdevice\noffline-device\toffline\n',
|
||||
}) as ReturnType<typeof spawn>;
|
||||
}
|
||||
if (command === 'adb') {
|
||||
expect(args).toEqual(['-s', 'selected-device', 'exec-out', 'screencap', '-p']);
|
||||
return Object.assign(Promise.resolve({ stdout: '', stderr: '' }), {
|
||||
child: { stdout: Readable.from([png]) },
|
||||
}) as ReturnType<typeof spawn>;
|
||||
}
|
||||
return (async () => {
|
||||
if (command === 'xcrun') {
|
||||
expect(args.slice(0, 4)).toEqual(['simctl', 'io', 'selected-device', 'screenshot']);
|
||||
await writeFile(args[4], Uint8Array.from(png));
|
||||
} else if (command === 'sips') {
|
||||
expect(args.slice(0, 2)).toEqual(['-Z', '320']);
|
||||
expect(await readFile(args[2])).toEqual(png);
|
||||
await writeFile(args[args.indexOf('--out') + 1], Uint8Array.from(resized));
|
||||
} else if (command === 'cwebp') {
|
||||
expect(await readFile(args[args.indexOf('-o') - 1])).toEqual(resized);
|
||||
await writeFile(args[args.indexOf('-o') + 1], Uint8Array.from(webp));
|
||||
} else {
|
||||
// Homebrew's FFmpeg cannot encode WebP, so macOS must not use it.
|
||||
expect(runtimePlatform).toBe(BuildRuntimePlatform.LINUX);
|
||||
expect(command).toBe('ffmpeg');
|
||||
expect(await readFile(args[args.indexOf('-i') + 1])).toEqual(png);
|
||||
expect(args).toContain('libwebp');
|
||||
await writeFile(args[args.length - 1], Uint8Array.from(webp));
|
||||
}
|
||||
return { stdout: '', stderr: '' };
|
||||
})() as ReturnType<typeof spawn>;
|
||||
});
|
||||
expect(
|
||||
await captureDeviceRunSessionPreviewAsync({
|
||||
runtimePlatform,
|
||||
device,
|
||||
env: {},
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
).toEqual(webp);
|
||||
expect(
|
||||
(await readdir(os.tmpdir())).filter(name => name.startsWith('session-preview-'))
|
||||
).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
it('skips ambiguous Android devices instead of capturing the wrong screen', async () => {
|
||||
jest
|
||||
.mocked(spawn)
|
||||
.mockResolvedValue({ stdout: 'device-one\tdevice\ndevice-two\tdevice\n' } as Awaited<
|
||||
ReturnType<typeof spawn>
|
||||
>);
|
||||
await expect(
|
||||
captureDeviceRunSessionPreviewAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
device: 'no-device-id',
|
||||
env: {},
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
).rejects.toThrow('Expected exactly one connected Android device');
|
||||
});
|
||||
|
||||
it('cleans up temporary files when capture fails', async () => {
|
||||
jest.mocked(spawn).mockRejectedValue(new Error('capture failed'));
|
||||
await expect(
|
||||
captureDeviceRunSessionPreviewAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
device: 'device',
|
||||
env: {},
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
).rejects.toThrow('capture failed');
|
||||
expect(
|
||||
(await readdir(os.tmpdir())).filter(name => name.startsWith('session-preview-'))
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(ensureMacosPreviewEncoderInstalledAsync, () => {
|
||||
const logger = { info: jest.fn() } as unknown as bunyan;
|
||||
|
||||
it.each([true, false])(
|
||||
'installs webp only when cwebp is missing (present: %s)',
|
||||
async present => {
|
||||
jest.mocked(spawn).mockReset();
|
||||
jest
|
||||
.mocked(spawn)
|
||||
.mockImplementation(
|
||||
command =>
|
||||
(command === 'cwebp' && !present
|
||||
? Promise.reject(new Error('command not found: cwebp'))
|
||||
: Promise.resolve({ stdout: '', stderr: '' })) as ReturnType<typeof spawn>
|
||||
);
|
||||
await ensureMacosPreviewEncoderInstalledAsync({ env: { HOME: '/Users/expo' }, logger });
|
||||
const brewCalls = jest.mocked(spawn).mock.calls.filter(([command]) => command === 'brew');
|
||||
if (present) {
|
||||
expect(brewCalls).toEqual([]);
|
||||
} else {
|
||||
expect(brewCalls).toEqual([
|
||||
[
|
||||
'brew',
|
||||
['install', 'webp'],
|
||||
{ env: { HOME: '/Users/expo', HOMEBREW_NO_AUTO_UPDATE: '1' }, logger },
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects when webp cannot be installed', async () => {
|
||||
jest.mocked(spawn).mockReset();
|
||||
jest.mocked(spawn).mockRejectedValue(new Error('brew failed'));
|
||||
await expect(ensureMacosPreviewEncoderInstalledAsync({ env: {}, logger })).rejects.toThrow(
|
||||
'brew failed'
|
||||
);
|
||||
});
|
||||
});
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import type { BuildStepEnv } from '@expo/steps';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { copyFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import {
|
||||
findUnlistedDeviceScreenRecordingsAsync,
|
||||
uploadDeviceRunSessionScreenRecordingsAsync,
|
||||
} from '../deviceRunSessionScreenRecordings';
|
||||
|
||||
// Runs with `yarn jest-ffmpeg`, which needs ffmpeg and ffprobe on PATH.
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
let handle: (request: IncomingMessage, response: ServerResponse) => void;
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let ctx: CustomBuildContext;
|
||||
let url: string;
|
||||
let directory: string;
|
||||
let creationBodies: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
creationBodies = [];
|
||||
directory = await mkdtemp(path.join(tmpdir(), 'recording-recovery-'));
|
||||
server = createServer((request, response) => {
|
||||
if (request.url === '/graphql') {
|
||||
request.setEncoding('utf8');
|
||||
let body = '';
|
||||
request.on('data', chunk => (body += chunk));
|
||||
request.on('end', () => {
|
||||
creationBodies.push(body);
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: { url: `${url}/artifact`, headers: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
} else {
|
||||
handle(request, response);
|
||||
}
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Missing test server address');
|
||||
}
|
||||
url = `http://127.0.0.1:${address.port}`;
|
||||
ctx = {
|
||||
graphqlClient: new Client({
|
||||
url: `${url}/graphql`,
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
|
||||
}),
|
||||
} as CustomBuildContext;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('recovers unlisted recordings from a killed host and flags the unfinished ones as partial', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const env = process.env as BuildStepEnv;
|
||||
const cut = path.join(directory, 'cut');
|
||||
const empty = path.join(directory, 'empty');
|
||||
const failed = path.join(directory, 'failed');
|
||||
const done = path.join(directory, 'done');
|
||||
await mkdir(cut);
|
||||
await mkdir(empty);
|
||||
await mkdir(failed);
|
||||
await mkdir(done);
|
||||
const manifest = {
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
status: 'recording',
|
||||
recording: 'recording.mp4.partial',
|
||||
firstFrameWallClock: { iso8601: '2026-07-10T10:00:00.000Z' },
|
||||
width: 128,
|
||||
height: 96,
|
||||
};
|
||||
execFileSync('ffmpeg', [
|
||||
'-v',
|
||||
'error',
|
||||
'-f',
|
||||
'lavfi',
|
||||
'-i',
|
||||
'color=c=red:s=128x96:r=1',
|
||||
'-t',
|
||||
'3',
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-g',
|
||||
'1',
|
||||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
'-movflags',
|
||||
'frag_keyframe+empty_moov',
|
||||
'-f',
|
||||
'mp4',
|
||||
path.join(cut, 'recording.mp4.partial'),
|
||||
]);
|
||||
await writeFile(path.join(cut, 'session.json'), JSON.stringify(manifest));
|
||||
await writeFile(path.join(empty, 'recording.mp4.partial'), new Uint8Array(28));
|
||||
await writeFile(path.join(empty, 'session.json'), JSON.stringify(manifest));
|
||||
await copyFile(
|
||||
path.join(cut, 'recording.mp4.partial'),
|
||||
path.join(failed, 'recording.mp4.partial')
|
||||
);
|
||||
await writeFile(
|
||||
path.join(failed, 'session.json'),
|
||||
JSON.stringify({
|
||||
...manifest,
|
||||
status: 'failed',
|
||||
error: 'Recording finalization exceeded 30000 ms.',
|
||||
})
|
||||
);
|
||||
|
||||
await copyFile(path.join(cut, 'recording.mp4.partial'), path.join(done, 'recording.mp4'));
|
||||
await writeFile(
|
||||
path.join(done, 'session.json'),
|
||||
JSON.stringify({ ...manifest, status: 'complete', recording: 'recording.mp4' })
|
||||
);
|
||||
|
||||
const recordings = await findUnlistedDeviceScreenRecordingsAsync({
|
||||
root: directory,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
expect(recordings).toEqual([
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
directory: cut,
|
||||
},
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
directory: done,
|
||||
},
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
directory: failed,
|
||||
},
|
||||
]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('empty/recording.mp4.partial'));
|
||||
|
||||
let putBytes = 0;
|
||||
handle = (request, response) => {
|
||||
request.on('data', chunk => (putBytes += chunk.length));
|
||||
request.on('end', () => {
|
||||
response.statusCode = 200;
|
||||
response.end();
|
||||
});
|
||||
};
|
||||
await expect(
|
||||
uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
recordings,
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
expect(putBytes).toBe(3 * (await stat(path.join(cut, 'recording.mp4.partial'))).size);
|
||||
const inputs = creationBodies
|
||||
.map(body => JSON.parse(body).variables.input)
|
||||
.sort((a, b) => a.filename.localeCompare(b.filename));
|
||||
expect(inputs.map(input => input.filename)).toEqual(['cut.mp4', 'done.mp4', 'failed.mp4']);
|
||||
expect(inputs.map(input => input.name.includes(', partial)'))).toEqual([true, false, true]);
|
||||
expect(inputs[1].metadata).not.toHaveProperty('partial');
|
||||
expect(inputs[0].metadata).toMatchObject({
|
||||
partial: true,
|
||||
partialReason: 'The Device Hub stopped before it could finalize the recording.',
|
||||
width: 128,
|
||||
height: 96,
|
||||
});
|
||||
expect(inputs[2].metadata).toMatchObject({
|
||||
partial: true,
|
||||
partialReason: 'Recording finalization exceeded 30000 ms.',
|
||||
});
|
||||
});
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
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 });
|
||||
}
|
||||
});
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
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' },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
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.'
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,576 @@
|
||||
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();
|
||||
});
|
||||
Loaded 100 of 307 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user