mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
@@ -26,6 +26,9 @@ jobs:
|
||||
uses: ./.github/actions/setup-mise
|
||||
env:
|
||||
MISE_NODE_VERSION: ${{ matrix.node }}
|
||||
- name: Install ffmpeg
|
||||
# build-tools recording-recovery tests cut and decode real MP4 files with ffmpeg and ffprobe.
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
env:
|
||||
|
||||
+57
-1
@@ -8,10 +8,61 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 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))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [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 +70,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 +79,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
|
||||
|
||||
|
||||
+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.10.0",
|
||||
"PRODUCTION": "24.10.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "24.8.0",
|
||||
"version": "24.11.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
bin/record-sim
|
||||
|
||||
.secrets*
|
||||
!.secrets.example
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "24.8.0",
|
||||
"version": "24.11.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",
|
||||
@@ -42,14 +41,14 @@
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "24.8.0",
|
||||
"@expo/eas-build-job": "24.8.0",
|
||||
"@expo/eas-build-job": "24.9.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "24.8.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "24.8.0",
|
||||
"@expo/steps": "24.9.0",
|
||||
"@expo/template-file": "24.8.0",
|
||||
"@expo/turtle-spawn": "24.8.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -60,6 +60,7 @@ jest.mock('../../utils/expoUpdates', () => ({
|
||||
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync: jest.fn(async () => null),
|
||||
}));
|
||||
jest.mock('../../utils/expoUpdatesEmbedded', () => ({
|
||||
...jest.requireActual('../../utils/expoUpdatesEmbedded'),
|
||||
uploadEmbeddedBundleAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/hooks', () => ({
|
||||
@@ -317,36 +318,82 @@ 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(ctx);
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
it('skips by default on SDK 57 and earlier', async () => {
|
||||
const ctx = createContext({ sdkVersion: '57.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(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
it('skips by default when the SDK version is unknown', async () => {
|
||||
const ctx = createContext({});
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
|
||||
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(ctx);
|
||||
});
|
||||
|
||||
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(ctx);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,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';
|
||||
|
||||
@@ -223,7 +226,7 @@ async function buildInnerAsync(
|
||||
});
|
||||
});
|
||||
|
||||
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
|
||||
if (shouldUploadEmbeddedBundle(ctx)) {
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
@@ -240,7 +243,7 @@ async function buildInnerAsync(
|
||||
});
|
||||
});
|
||||
|
||||
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
|
||||
if (shouldUploadEmbeddedBundle(ctx)) {
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
});
|
||||
|
||||
@@ -19,6 +19,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';
|
||||
@@ -44,7 +45,7 @@ import { createSaveBuildCacheFunction } from './functions/saveBuildCache';
|
||||
import { createRolloutPosthogFlagFunction } from './functions/rolloutPosthogFlag';
|
||||
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 +55,14 @@ 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 { createUploadToAscBuildFunction } from './functions/uploadToAsc';
|
||||
import { createUpdateTestFlightMetadataBuildFunction } from './functions/updateTestFlightMetadata';
|
||||
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
|
||||
import { createWaitForPosthogMetricFunction } from './functions/waitForPosthogMetric';
|
||||
import { createUploadPosthogSourcemapsFunction } from './functions/uploadPosthogSourcemaps';
|
||||
@@ -99,7 +103,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
generateGymfileFromTemplateFunction(),
|
||||
runFastlaneFunction(),
|
||||
parseXcactivitylogFunction(),
|
||||
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
|
||||
createStartAgentDeviceSessionBuildFunction(ctx),
|
||||
createStartArgentRemoteSessionBuildFunction(ctx),
|
||||
createStartAppiumRemoteSessionBuildFunction(ctx),
|
||||
createStartAndroidEmulatorBuildFunction(),
|
||||
@@ -111,9 +115,12 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
|
||||
createStartWebPreviewRemoteSessionBuildFunction(ctx),
|
||||
createStartServeSimMetricsBuildFunction(),
|
||||
createStartServeSimLogsBuildFunction(),
|
||||
createCollectServeSimLogsBuildFunction(ctx),
|
||||
createStartSandboxBuildFunction(ctx),
|
||||
createCollectServeSimMetricsBuildFunction(ctx),
|
||||
createInstallMaestroBuildFunction(),
|
||||
createInstallMitmproxyBuildFunction(),
|
||||
|
||||
createInstallPodsBuildFunction(),
|
||||
createSendSlackMessageFunction(),
|
||||
@@ -128,6 +135,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
|
||||
createSubmissionEntityFunction(),
|
||||
createUploadToAscBuildFunction(),
|
||||
createUpdateTestFlightMetadataBuildFunction(),
|
||||
|
||||
createReportMaestroTestResultsFunction(ctx),
|
||||
createMaestroTestsBuildFunction(ctx),
|
||||
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
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' }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,69 @@
|
||||
import { BuildRuntimePlatform, BuildStep } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
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);
|
||||
|
||||
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('does not install when mitmdump is already on PATH', async () => {
|
||||
mockedSpawn.mockResolvedValueOnce({} as never);
|
||||
|
||||
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('mitmdump', ['--version'], expect.anything());
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('warns instead of installing outside EAS Build VMs', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('not found'));
|
||||
|
||||
await createStep({}).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('installs the cask with Homebrew and leaves auto-update off', async () => {
|
||||
mockedSpawn
|
||||
.mockRejectedValueOnce(new Error('not found'))
|
||||
.mockResolvedValueOnce({} as never)
|
||||
.mockResolvedValueOnce({} as never);
|
||||
|
||||
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' }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not fail the job when mitmdump is still not runnable', async () => {
|
||||
mockedSpawn
|
||||
.mockRejectedValueOnce(new Error('not found'))
|
||||
.mockResolvedValueOnce({} as never)
|
||||
.mockRejectedValueOnce(new Error('not found'));
|
||||
|
||||
await expect(
|
||||
createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync()
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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,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({
|
||||
|
||||
+128
-8
@@ -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,6 +119,75 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
await fs.promises.rm(TEST_HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports an early exit with output before opening tunnels or publishing readiness', async () => {
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 4242,
|
||||
getOutput: () => 'could not bind server port',
|
||||
getExitError: () => new Error('process exited with code 1'),
|
||||
stopAsync: jest.fn(),
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
).rejects.toThrow(
|
||||
'Argent exited before becoming ready: process exited with code 1\nArgent tool-server output:\ncould not bind server port'
|
||||
);
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['preview', 'config', 'wait'])(
|
||||
'finishes the host and tools after %s fails',
|
||||
async phase => {
|
||||
const error = new Error(`${phase} failed`);
|
||||
if (phase === 'preview') {
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
|
||||
openPreviewAsync: jest.fn().mockRejectedValue(error),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
});
|
||||
} else if (phase === 'config') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
|
||||
} else {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
|
||||
}
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
).rejects.toBe(error);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(jest.mocked(spawnDetached).mock.results[0].value.stopAsync).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('enables the event log flag, shares one path, and starts/stops the collector', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
|
||||
@@ -177,7 +252,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 })
|
||||
);
|
||||
@@ -216,7 +291,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
} as never
|
||||
);
|
||||
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
@@ -266,4 +341,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,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'),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
+22
-9
@@ -29,7 +29,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 +58,7 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
|
||||
try {
|
||||
const globalContext = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
runtimePlatform: platform,
|
||||
});
|
||||
const buildStep = createUploadDeviceRunSessionScreenRecordingsBuildFunction(
|
||||
{} as CustomBuildContext
|
||||
@@ -54,9 +67,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 +81,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,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,65 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { BuildFunction, BuildRuntimePlatform, BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
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 }) => {
|
||||
if (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 isMitmproxyAvailableAsync(env))) {
|
||||
throw new Error('`brew install --cask mitmproxy` succeeded but mitmdump is not on PATH.');
|
||||
}
|
||||
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> {
|
||||
return (await asyncResult(spawn('mitmdump', ['--version'], { env }))).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,
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}: {
|
||||
|
||||
@@ -19,6 +19,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 +37,13 @@ import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
|
||||
import {
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
@@ -63,6 +68,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
__metricsId: 'eas/start_appium_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -89,6 +95,13 @@ 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(
|
||||
@@ -138,7 +151,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 +168,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 +213,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';
|
||||
@@ -75,6 +80,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
__metricsId: 'eas/start_argent_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -115,6 +121,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}).`
|
||||
);
|
||||
@@ -184,6 +197,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 +230,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 +248,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 +293,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 +411,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 +441,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 +453,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';
|
||||
|
||||
@@ -76,19 +77,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,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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,27 @@ import { setTimeout } from 'node:timers/promises';
|
||||
|
||||
import { Sentry } from '../../sentry';
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageExec,
|
||||
} from '../../utils/packageManager';
|
||||
import { isProcessGroupRunning, killProcessGroup } from '../../utils/processes';
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
|
||||
const IOS_SIMULATOR_RECORDING_POLL_INTERVAL_MS = 2_000;
|
||||
const RECORD_SIM_FINISH_TIMEOUT_MS = 70_000;
|
||||
const RECORD_SIM_FORCE_STOP_TIMEOUT_MS = 5_000;
|
||||
const RECORD_SIM_MAX_ATTEMPTS_PER_BOOT = 3;
|
||||
const RECORD_SIM_COMMAND = 'record-sim';
|
||||
// Once the poller stops, each recording stops in parallel within 60 + 130 + 5 + 30 = 225 s.
|
||||
const SERVE_SIM_START_TIMEOUT_MS = 60_000;
|
||||
/** serve-sim finalizes an active recording within this grace period after SIGTERM. */
|
||||
export const SERVE_SIM_STOP_GRACE_PERIOD_MS = 90_000;
|
||||
const SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS = 30_000;
|
||||
// serve-sim may be stopping at the same time: its grace period, the manifest wait, and 10 s.
|
||||
const SERVE_SIM_FINISH_TIMEOUT_MS =
|
||||
SERVE_SIM_STOP_GRACE_PERIOD_MS + SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS + 10_000;
|
||||
const SERVE_SIM_FORCE_STOP_TIMEOUT_MS = 5_000;
|
||||
const SERVE_SIM_RECORDING_STARTED = 'serve-sim:recording-started';
|
||||
// The serve-sim package the session host started; every recorder runs `record-video` from it.
|
||||
let serveSimPackageSpec: string | null = null;
|
||||
|
||||
type IosSimulatorRecording = {
|
||||
id: string;
|
||||
@@ -25,6 +40,7 @@ type IosSimulatorRecording = {
|
||||
outputDirectory: string;
|
||||
startedAt: Date;
|
||||
getOutput: () => string;
|
||||
hasStarted: () => boolean;
|
||||
};
|
||||
|
||||
type ActiveIosSimulatorRecording = IosSimulatorRecording & {
|
||||
@@ -35,11 +51,15 @@ type ActiveIosSimulatorRecording = IosSimulatorRecording & {
|
||||
type IosSimulatorRecordingSession = {
|
||||
env: Env;
|
||||
logger: bunyan;
|
||||
recordSimCommand: string;
|
||||
recordingsRootDirectory: string;
|
||||
activeRecordings: Map<IosSimulatorUuid, ActiveIosSimulatorRecording>;
|
||||
completedRecordings: IosSimulatorRecording[];
|
||||
recordingFailureCounts: Map<IosSimulatorUuid, number>;
|
||||
/** Failed attempts for the whole session; unlike recordingFailureCounts, never reset. */
|
||||
failedAttemptCounts: Map<IosSimulatorUuid, number>;
|
||||
recordingRetryAt: Map<IosSimulatorUuid, number>;
|
||||
serveBearerTokens: Map<IosSimulatorUuid, string>;
|
||||
completedServeBearerTokens: Map<IosSimulatorUuid, string>;
|
||||
pollingPromise: Promise<void>;
|
||||
abortController: AbortController;
|
||||
};
|
||||
@@ -47,31 +67,31 @@ type IosSimulatorRecordingSession = {
|
||||
let activeIosSimulatorRecordingSession: IosSimulatorRecordingSession | null = null;
|
||||
|
||||
export namespace IosSimulatorRecordingUtils {
|
||||
/** One session host serves every device of a job, so the first package wins. */
|
||||
export function useServeSimPackage(packageSpec: string): void {
|
||||
serveSimPackageSpec ??= packageSpec;
|
||||
}
|
||||
|
||||
export async function startAsync({ env, logger }: { env: Env; logger: bunyan }): Promise<void> {
|
||||
if (activeIosSimulatorRecordingSession) {
|
||||
logger.info('iOS Simulator screen recording polling is already running.');
|
||||
return;
|
||||
}
|
||||
|
||||
const recordSimCommand = await resolveRecordSimCommandAsync({ env });
|
||||
if (!recordSimCommand) {
|
||||
logger.warn(
|
||||
'record-sim binary is not available; iOS Simulator screen recordings are disabled.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const recordingsRootDirectory = await mkdtemp(
|
||||
path.join(os.tmpdir(), 'ios-simulator-recordings-')
|
||||
);
|
||||
const session: IosSimulatorRecordingSession = {
|
||||
env,
|
||||
logger,
|
||||
recordSimCommand,
|
||||
recordingsRootDirectory,
|
||||
activeRecordings: new Map(),
|
||||
completedRecordings: [],
|
||||
recordingFailureCounts: new Map(),
|
||||
failedAttemptCounts: new Map(),
|
||||
recordingRetryAt: new Map(),
|
||||
serveBearerTokens: new Map(),
|
||||
completedServeBearerTokens: new Map(),
|
||||
pollingPromise: Promise.resolve(),
|
||||
abortController: new AbortController(),
|
||||
};
|
||||
@@ -94,6 +114,7 @@ export namespace IosSimulatorRecordingUtils {
|
||||
}[]
|
||||
> {
|
||||
const session = activeIosSimulatorRecordingSession;
|
||||
serveSimPackageSpec = null;
|
||||
if (!session) {
|
||||
logger.info('No iOS Simulator screen recordings are running.');
|
||||
return [];
|
||||
@@ -105,41 +126,55 @@ export namespace IosSimulatorRecordingUtils {
|
||||
await Promise.all(
|
||||
[...session.activeRecordings.values()].map(async recording => {
|
||||
logger.info(`Stopping screen recording for ${recording.deviceName}.`);
|
||||
recording.recordingProcess.kill('SIGINT');
|
||||
const startState = await waitForRecorderStartAsync(recording, SERVE_SIM_START_TIMEOUT_MS);
|
||||
if (startState === 'exited') {
|
||||
return;
|
||||
}
|
||||
if (startState === 'timeout') {
|
||||
logger.warn(
|
||||
{ recorderOutput: recording.getOutput().trim() },
|
||||
`Screen recording for ${recording.deviceName} did not report its start within ${Math.round(
|
||||
SERVE_SIM_START_TIMEOUT_MS / 1_000
|
||||
)} seconds and will be stopped.`
|
||||
);
|
||||
}
|
||||
const finishTimeoutMs =
|
||||
startState === 'started' ? SERVE_SIM_FINISH_TIMEOUT_MS : SERVE_SIM_FORCE_STOP_TIMEOUT_MS;
|
||||
signalRecorder(recording.recordingProcess, 'SIGINT');
|
||||
const finished = await Promise.race([
|
||||
recording.completionPromise.then(() => true),
|
||||
setTimeout(RECORD_SIM_FINISH_TIMEOUT_MS, false, { ref: false }),
|
||||
setTimeout(finishTimeoutMs, false, { ref: false }),
|
||||
]);
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recordSimOutput = recording.getOutput().trim();
|
||||
const finishTimeoutSeconds = Math.round(RECORD_SIM_FINISH_TIMEOUT_MS / 1_000);
|
||||
const recorderOutput = recording.getOutput().trim();
|
||||
const finishTimeoutSeconds = Math.round(finishTimeoutMs / 1_000);
|
||||
const stopFailure =
|
||||
startState === 'started'
|
||||
? `did not finish within ${finishTimeoutSeconds} seconds`
|
||||
: `did not stop within ${finishTimeoutSeconds} seconds of SIGINT`;
|
||||
logger.warn(
|
||||
{ recordSimOutput },
|
||||
`Screen recording for ${recording.deviceName} did not finish within ${finishTimeoutSeconds} seconds and will be stopped.${
|
||||
recordSimOutput
|
||||
? `\nRecent recorder messages:\n${recordSimOutput}`
|
||||
{ recorderOutput },
|
||||
`Screen recording for ${recording.deviceName} ${stopFailure} and will be killed.${
|
||||
recorderOutput
|
||||
? `\nRecent recorder messages:\n${recorderOutput}`
|
||||
: '\nNo recorder messages were captured.'
|
||||
}`
|
||||
);
|
||||
recording.recordingProcess.kill('SIGKILL');
|
||||
signalRecorder(recording.recordingProcess, 'SIGKILL');
|
||||
const killed = await Promise.race([
|
||||
recording.completionPromise.then(() => true),
|
||||
setTimeout(RECORD_SIM_FORCE_STOP_TIMEOUT_MS, false, { ref: false }),
|
||||
setTimeout(SERVE_SIM_FORCE_STOP_TIMEOUT_MS, false, { ref: false }),
|
||||
]);
|
||||
if (!killed) {
|
||||
logger.warn(
|
||||
`iOS Simulator recording process for ${recording.deviceName} did not exit after SIGKILL.`
|
||||
);
|
||||
// Recorder output stays in the job log: it can contain the serve-sim bearer token.
|
||||
Sentry.capture(
|
||||
`iOS Simulator recording process for ${recording.deviceName} did not exit after SIGKILL.`,
|
||||
{
|
||||
extras: {
|
||||
output: recording.getOutput(),
|
||||
},
|
||||
}
|
||||
`iOS Simulator recording process for ${recording.deviceName} did not exit after SIGKILL.`
|
||||
);
|
||||
}
|
||||
})
|
||||
@@ -148,7 +183,31 @@ export namespace IosSimulatorRecordingUtils {
|
||||
const completedRecordings = [...session.completedRecordings].sort(
|
||||
(a, b) => a.startedAt.getTime() - b.startedAt.getTime()
|
||||
);
|
||||
return completedRecordings.map(recording => ({
|
||||
const recordingsWithManifests = await Promise.all(
|
||||
completedRecordings.map(async recording => {
|
||||
if (
|
||||
await waitForRecordingManifestAsync(
|
||||
recording.outputDirectory,
|
||||
SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS
|
||||
)
|
||||
) {
|
||||
return recording;
|
||||
}
|
||||
reportLostRecording(logger, recording.deviceName, recording.getOutput().trim());
|
||||
return null;
|
||||
})
|
||||
);
|
||||
const finalizedRecordings = recordingsWithManifests.filter(recording => recording !== null);
|
||||
const recordedUdids = new Set(finalizedRecordings.map(recording => recording.udid));
|
||||
for (const [failedUdid, attempts] of session.failedAttemptCounts) {
|
||||
if (!recordedUdids.has(failedUdid)) {
|
||||
Sentry.capture('iOS Simulator screen recording failed on every attempt', {
|
||||
level: 'warning',
|
||||
extras: { attempts },
|
||||
});
|
||||
}
|
||||
}
|
||||
return finalizedRecordings.map(recording => ({
|
||||
udid: recording.udid,
|
||||
deviceName: recording.deviceName,
|
||||
runtimeDisplayName: recording.runtimeDisplayName,
|
||||
@@ -174,18 +233,38 @@ async function pollIosSimulatorRecordingsAsync(
|
||||
break;
|
||||
}
|
||||
listDevicesErrorCount = 0;
|
||||
const readyServers = new Map(
|
||||
(await readServeSimServersAsync(SERVE_SIM_STATE_DIR)).flatMap(server =>
|
||||
server.token ? [[server.udid, server.token] as const] : []
|
||||
)
|
||||
);
|
||||
|
||||
const bootedUdids = new Set(bootedDevices.map(device => device.udid));
|
||||
for (const udid of session.recordingFailureCounts.keys()) {
|
||||
for (const udid of session.serveBearerTokens.keys()) {
|
||||
if (!bootedUdids.has(udid)) {
|
||||
session.recordingFailureCounts.delete(udid);
|
||||
session.recordingRetryAt.delete(udid);
|
||||
session.serveBearerTokens.delete(udid);
|
||||
session.completedServeBearerTokens.delete(udid);
|
||||
}
|
||||
}
|
||||
|
||||
for (const device of bootedDevices) {
|
||||
const token = readyServers.get(device.udid);
|
||||
const packageSpec = serveSimPackageSpec;
|
||||
if (!token || !packageSpec) {
|
||||
continue;
|
||||
}
|
||||
if (session.serveBearerTokens.get(device.udid) !== token) {
|
||||
session.serveBearerTokens.set(device.udid, token);
|
||||
session.recordingFailureCounts.delete(device.udid);
|
||||
session.recordingRetryAt.delete(device.udid);
|
||||
session.completedServeBearerTokens.delete(device.udid);
|
||||
}
|
||||
if (
|
||||
session.completedServeBearerTokens.get(device.udid) === token ||
|
||||
session.activeRecordings.has(device.udid) ||
|
||||
(session.recordingFailureCounts.get(device.udid) ?? 0) >= RECORD_SIM_MAX_ATTEMPTS_PER_BOOT
|
||||
Date.now() < (session.recordingRetryAt.get(device.udid) ?? 0)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -193,6 +272,7 @@ async function pollIosSimulatorRecordingsAsync(
|
||||
udid: device.udid,
|
||||
deviceName: device.name,
|
||||
runtimeDisplayName: device.runtimeDisplayName,
|
||||
packageSpec,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -227,50 +307,90 @@ async function startIosSimulatorRecordingAsync(
|
||||
udid,
|
||||
deviceName,
|
||||
runtimeDisplayName,
|
||||
packageSpec,
|
||||
}: {
|
||||
udid: IosSimulatorUuid;
|
||||
deviceName: string;
|
||||
runtimeDisplayName: string;
|
||||
packageSpec: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const startedAt = new Date();
|
||||
const serveBearerToken = session.serveBearerTokens.get(udid);
|
||||
const recordingId = randomUUID();
|
||||
const outputDirectory = path.join(session.recordingsRootDirectory, recordingId);
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
|
||||
session.logger.info(`Starting screen recording for ${deviceName}.`);
|
||||
const recorderExec = resolvePackageExec(
|
||||
resolveConfiguredPackageManager(session.env, PackageManager.NPM),
|
||||
[packageSpec, 'record-video']
|
||||
);
|
||||
const recordingSpawn = spawn(
|
||||
session.recordSimCommand,
|
||||
['--udid', udid, '--output', outputDirectory, '--segment-duration', '0'],
|
||||
recorderExec.command,
|
||||
[...recorderExec.args, '--udid', udid, '--output', outputDirectory],
|
||||
{
|
||||
env: session.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
}
|
||||
);
|
||||
const getOutput = captureProcessOutput(recordingSpawn.child);
|
||||
const completionPromise = recordingSpawn
|
||||
.then(() => undefined)
|
||||
.catch((err: unknown) => {
|
||||
session.recordingFailureCounts.set(udid, (session.recordingFailureCounts.get(udid) ?? 0) + 1);
|
||||
const { getOutput, hasStarted } = captureProcessOutput(recordingSpawn.child);
|
||||
const saveFinalizedRecordingAsync = async (): Promise<boolean> => {
|
||||
if (
|
||||
!(await waitForRecordingManifestAsync(outputDirectory, SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (serveBearerToken) {
|
||||
session.completedServeBearerTokens.set(udid, serveBearerToken);
|
||||
}
|
||||
session.completedRecordings.push({
|
||||
id: recordingId,
|
||||
udid,
|
||||
deviceName,
|
||||
runtimeDisplayName,
|
||||
outputDirectory,
|
||||
startedAt,
|
||||
getOutput,
|
||||
hasStarted,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
const completionPromise = (async () => {
|
||||
let spawnError: unknown;
|
||||
try {
|
||||
await recordingSpawn;
|
||||
} catch (err) {
|
||||
spawnError = err;
|
||||
}
|
||||
// A package-manager wrapper can exit while its record-video child still
|
||||
// runs in the detached group. Keep this device active until the group exits.
|
||||
await waitForRecordingProcessGroupExitAsync(recordingSpawn.child);
|
||||
// Host shutdown can end the recorder's lease after its video has been saved.
|
||||
if ((hasStarted() || !spawnError) && (await saveFinalizedRecordingAsync())) {
|
||||
return;
|
||||
}
|
||||
if (spawnError) {
|
||||
const err = spawnError;
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('iOS Simulator screen recording process failed', error);
|
||||
session.logger.warn(
|
||||
{ err: error, recordSimOutput: getOutput() },
|
||||
{ err: error, recorderOutput: getOutput() },
|
||||
`Screen recording process failed for ${deviceName}.`
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
session.activeRecordings.delete(udid);
|
||||
session.completedRecordings.push({
|
||||
id: recordingId,
|
||||
udid,
|
||||
deviceName,
|
||||
runtimeDisplayName,
|
||||
outputDirectory,
|
||||
startedAt,
|
||||
getOutput,
|
||||
});
|
||||
});
|
||||
}
|
||||
if (hasStarted()) {
|
||||
reportLostRecording(session.logger, deviceName, getOutput().trim());
|
||||
} else if (!spawnError) {
|
||||
session.logger.warn(
|
||||
{ recorderOutput: getOutput().trim() },
|
||||
`Screen recording for ${deviceName} exited before it started; it will be retried.`
|
||||
);
|
||||
}
|
||||
scheduleRecordingRetry(session, udid);
|
||||
})().finally(() => {
|
||||
session.activeRecordings.delete(udid);
|
||||
});
|
||||
|
||||
session.activeRecordings.set(udid, {
|
||||
id: recordingId,
|
||||
@@ -282,31 +402,103 @@ async function startIosSimulatorRecordingAsync(
|
||||
completionPromise,
|
||||
startedAt,
|
||||
getOutput,
|
||||
hasStarted,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveRecordSimCommandAsync({ env }: { env: Env }): Promise<string | null> {
|
||||
try {
|
||||
await spawn('which', [RECORD_SIM_COMMAND], { env });
|
||||
return RECORD_SIM_COMMAND;
|
||||
} catch {}
|
||||
async function waitForRecordingManifestAsync(
|
||||
outputDirectory: string,
|
||||
timeoutMs: number
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await access(path.join(outputDirectory, 'session.json'));
|
||||
return true;
|
||||
} catch {
|
||||
await setTimeout(1_000);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const packagedRecordSimPath = path.join(__dirname, '..', '..', '..', 'bin', RECORD_SIM_COMMAND);
|
||||
try {
|
||||
await access(packagedRecordSimPath);
|
||||
return packagedRecordSimPath;
|
||||
} catch {
|
||||
return null;
|
||||
async function waitForRecorderStartAsync(
|
||||
recording: ActiveIosSimulatorRecording,
|
||||
timeoutMs: number
|
||||
): Promise<'started' | 'exited' | 'timeout'> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (recording.hasStarted()) {
|
||||
return 'started';
|
||||
}
|
||||
const exited = await Promise.race([
|
||||
recording.completionPromise.then(() => true),
|
||||
setTimeout(100, false),
|
||||
]);
|
||||
if (exited) {
|
||||
return 'exited';
|
||||
}
|
||||
}
|
||||
return 'timeout';
|
||||
}
|
||||
|
||||
function reportLostRecording(logger: bunyan, deviceName: string, recorderOutput: string): void {
|
||||
logger.warn(
|
||||
{ recorderOutput },
|
||||
`Screen recording for ${deviceName} ended without a manifest; its footage cannot be uploaded.`
|
||||
);
|
||||
Sentry.capture('iOS Simulator screen recording ended without a manifest', {
|
||||
level: 'warning',
|
||||
extras: { deviceName },
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleRecordingRetry(
|
||||
session: IosSimulatorRecordingSession,
|
||||
udid: IosSimulatorUuid
|
||||
): void {
|
||||
session.failedAttemptCounts.set(udid, (session.failedAttemptCounts.get(udid) ?? 0) + 1);
|
||||
const failures = (session.recordingFailureCounts.get(udid) ?? 0) + 1;
|
||||
session.recordingFailureCounts.set(udid, failures);
|
||||
session.recordingRetryAt.set(
|
||||
udid,
|
||||
Date.now() + Math.min(300_000, 25_000 * 2 ** Math.min(failures - 1, 4))
|
||||
);
|
||||
}
|
||||
|
||||
function signalRecorder(child: ChildProcess, signal: NodeJS.Signals): void {
|
||||
// killProcessGroup skips a child without a pid.
|
||||
if (child.pid === undefined) {
|
||||
child.kill(signal);
|
||||
return;
|
||||
}
|
||||
killProcessGroup(child, signal);
|
||||
}
|
||||
|
||||
async function waitForRecordingProcessGroupExitAsync(child: ChildProcess): Promise<void> {
|
||||
if (child.pid === undefined) {
|
||||
return;
|
||||
}
|
||||
// Bounded by finishAsync's SIGKILL; unref'd so it cannot keep the worker alive.
|
||||
while (isProcessGroupRunning(child.pid)) {
|
||||
await setTimeout(100, undefined, { ref: false });
|
||||
}
|
||||
}
|
||||
|
||||
function captureProcessOutput(recordingProcess: ChildProcess): () => string {
|
||||
function captureProcessOutput(recordingProcess: ChildProcess): {
|
||||
getOutput: () => string;
|
||||
hasStarted: () => boolean;
|
||||
} {
|
||||
let output = '';
|
||||
let started = false;
|
||||
const appendChunk = (chunk: Buffer | string): void => {
|
||||
// Keep enough recorder stderr/stdout for diagnostics without retaining unbounded output.
|
||||
output = `${output}${chunk.toString()}`.slice(-16_384);
|
||||
output = `${output}${chunk.toString()}`;
|
||||
if (output.includes(SERVE_SIM_RECORDING_STARTED)) {
|
||||
started = true;
|
||||
}
|
||||
output = output.slice(-16_384);
|
||||
};
|
||||
recordingProcess.stdout?.on('data', appendChunk);
|
||||
recordingProcess.stderr?.on('data', appendChunk);
|
||||
return () => output;
|
||||
return { getOutput: () => output, hasStarted: () => started };
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
import { type Env } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { type ChildProcess } from 'node:child_process';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { rm, writeFile } from 'node:fs/promises';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils';
|
||||
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../serveSimMetricsRecorder');
|
||||
jest.mock('../../../sentry');
|
||||
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
} as unknown as bunyan;
|
||||
const env = {} as Env;
|
||||
const udid = '06B546AC-7B06-4BED-83DB-83603E8EB537';
|
||||
|
||||
beforeEach(() => {
|
||||
IosSimulatorRecordingUtils.useServeSimPackage('@expo/serve-sim@next');
|
||||
jest
|
||||
.spyOn(IosSimulatorUtils, 'getAvailableDevicesAsync')
|
||||
.mockResolvedValue([{ udid, name: 'iPhone 17 Pro', runtimeDisplayName: 'iOS 26.4' } as never]);
|
||||
jest.mocked(readServeSimServersAsync).mockReset();
|
||||
jest.mocked(spawn).mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function waitForRecorderSpawnAsync(): Promise<void> {
|
||||
for (let attempt = 0; attempt < 50; attempt++) {
|
||||
if (jest.mocked(spawn).mock.calls.length > 0) {
|
||||
return;
|
||||
}
|
||||
await setTimeout(10);
|
||||
}
|
||||
throw new Error('serve-sim recorder did not start');
|
||||
}
|
||||
|
||||
// A recorder the test controls: `exit` ends it cleanly, `fail` ends it with an error.
|
||||
function mockRecorder({ kill }: { kill?: (signal: NodeJS.Signals) => void } = {}): {
|
||||
child: ChildProcess;
|
||||
exit: () => void;
|
||||
fail: (error: Error) => void;
|
||||
} {
|
||||
let exit: () => void = () => {};
|
||||
let fail: (error: Error) => void = () => {};
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: jest.fn((signal: NodeJS.Signals) => {
|
||||
kill?.(signal);
|
||||
return true;
|
||||
}),
|
||||
}) as unknown as ChildProcess;
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
exit = resolve;
|
||||
fail = reject;
|
||||
});
|
||||
jest.mocked(spawn).mockReturnValue(Object.assign(done, { child }) as never);
|
||||
return { child, exit: () => exit(), fail: error => fail(error) };
|
||||
}
|
||||
|
||||
function mockFailingRecorder(): void {
|
||||
jest.mocked(spawn).mockImplementation((_command, args) => {
|
||||
void writeFile(`${args[6]}/session.json`, JSON.stringify({ recording: 'recording.mp4' }));
|
||||
return Object.assign(Promise.reject(new Error('recorder exited')), {
|
||||
child: {
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: jest.fn(),
|
||||
},
|
||||
}) as never;
|
||||
});
|
||||
}
|
||||
|
||||
test('waits for a token-gated serve-sim session before starting a recorder', async () => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563' }]);
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
|
||||
expect(jest.mocked(spawn)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('starts serve-sim record-video and returns only a finalized recording', async () => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
let resolveProcess: () => void = () => {};
|
||||
const stderr = new PassThrough();
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdout: new PassThrough(),
|
||||
stderr,
|
||||
kill: jest.fn(),
|
||||
}) as unknown as ChildProcess;
|
||||
const processPromise = Object.assign(
|
||||
new Promise<void>(resolve => {
|
||||
resolveProcess = resolve;
|
||||
}),
|
||||
{
|
||||
child,
|
||||
}
|
||||
);
|
||||
jest.mocked(spawn).mockReturnValue(processPromise as never);
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
const [command, args, options] = jest.mocked(spawn).mock.calls[0];
|
||||
expect(command).toBe('npx');
|
||||
expect(args.slice(0, 5)).toEqual([
|
||||
'--yes',
|
||||
'@expo/serve-sim@next',
|
||||
'record-video',
|
||||
'--udid',
|
||||
udid,
|
||||
]);
|
||||
expect(args[5]).toBe('--output');
|
||||
expect(args).not.toContain('--segment-duration');
|
||||
expect(options?.detached).toBe(true);
|
||||
|
||||
const outputDirectory = args[6];
|
||||
jest.mocked(child.kill).mockImplementation(() => {
|
||||
resolveProcess();
|
||||
void setTimeout(20).then(() =>
|
||||
writeFile(`${outputDirectory}/session.json`, JSON.stringify({ recording: 'recording.mp4' }))
|
||||
);
|
||||
return true;
|
||||
});
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await setTimeout(20);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
stderr.write('serve-sim:recording-started\n');
|
||||
stderr.write('x'.repeat(17_000));
|
||||
const recordings = await finishing;
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGINT');
|
||||
expect(recordings).toEqual([
|
||||
{
|
||||
udid,
|
||||
deviceName: 'iPhone 17 Pro',
|
||||
runtimeDisplayName: 'iOS 26.4',
|
||||
directory: outputDirectory,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('reports a started recording that ends without a manifest to Sentry', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
let exitRecorder: () => void = () => {};
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: jest.fn(),
|
||||
}) as unknown as ChildProcess;
|
||||
jest.mocked(spawn).mockReturnValue(
|
||||
Object.assign(
|
||||
new Promise<void>(resolve => {
|
||||
exitRecorder = resolve;
|
||||
}),
|
||||
{ child }
|
||||
) as never
|
||||
);
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
child.stderr?.emit('data', 'serve-sim:recording-started\n');
|
||||
child.stderr?.emit('data', 'GET http://127.0.0.1:43563/record?token=session-token failed\n');
|
||||
exitRecorder();
|
||||
await setTimeout(50);
|
||||
currentTime += 31_000;
|
||||
await setTimeout(1_100);
|
||||
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'iOS Simulator screen recording ended without a manifest',
|
||||
{ level: 'warning', extras: { deviceName: 'iPhone 17 Pro' } }
|
||||
);
|
||||
expect(JSON.stringify(jest.mocked(Sentry.capture).mock.calls)).not.toContain('session-token');
|
||||
});
|
||||
|
||||
test('says when a recorder never reported its start before it is stopped', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
let exitRecorder: () => void = () => {};
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: jest.fn(() => {
|
||||
exitRecorder();
|
||||
return true;
|
||||
}),
|
||||
}) as unknown as ChildProcess;
|
||||
jest.mocked(spawn).mockReturnValue(
|
||||
Object.assign(
|
||||
new Promise<void>(resolve => {
|
||||
exitRecorder = resolve;
|
||||
}),
|
||||
{ child }
|
||||
) as never
|
||||
);
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await setTimeout(50);
|
||||
currentTime += 61_000;
|
||||
await setTimeout(200);
|
||||
currentTime += 31_000;
|
||||
|
||||
expect(await finishing).toEqual([]);
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGINT');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'Screen recording for iPhone 17 Pro did not report its start within 60 seconds and will be stopped.'
|
||||
);
|
||||
});
|
||||
|
||||
test('retries after a failed recorder when the serve-sim session token changes', async () => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValueOnce([{ udid, url: 'http://127.0.0.1:43563', token: 'old-token' }])
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'new-token' }]);
|
||||
mockFailingRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
await setTimeout(2_100);
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(2);
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toEqual([]);
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'iOS Simulator screen recording failed on every attempt',
|
||||
expect.objectContaining({ level: 'warning', extras: { attempts: 2 } })
|
||||
);
|
||||
});
|
||||
|
||||
test('reports a device that failed and then shut down', async () => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
mockFailingRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockResolvedValue([]);
|
||||
await setTimeout(2_100);
|
||||
|
||||
expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toEqual([]);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'iOS Simulator screen recording failed on every attempt',
|
||||
expect.objectContaining({ extras: { attempts: 1 } })
|
||||
);
|
||||
});
|
||||
|
||||
test('retries after the server lease expires without a token change', async () => {
|
||||
const startTime = Date.now();
|
||||
let currentTime = startTime;
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
mockFailingRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
currentTime += 26_000;
|
||||
await setTimeout(2_100);
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('does not start a second recording for a server token after a late manifest', async () => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
jest.mocked(spawn).mockImplementation((_command, args) => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: jest.fn(),
|
||||
}) as unknown as ChildProcess;
|
||||
void setTimeout(20).then(() =>
|
||||
writeFile(`${args[6]}/session.json`, JSON.stringify({ recording: 'recording.mp4' }))
|
||||
);
|
||||
return Object.assign(Promise.resolve(), { child }) as never;
|
||||
});
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
await setTimeout(2_100);
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('keeps a recorder active after its package-manager wrapper exits', async () => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
let groupRunning = true;
|
||||
const originalKill = process.kill.bind(process);
|
||||
const signalGroup = jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (pid !== -4321) {
|
||||
return originalKill(pid, signal);
|
||||
}
|
||||
if (signal === 0) {
|
||||
if (groupRunning) {
|
||||
return true;
|
||||
}
|
||||
throw Object.assign(new Error('No such process group'), { code: 'ESRCH' });
|
||||
}
|
||||
if (signal === 'SIGINT') {
|
||||
void setTimeout(20).then(async () => {
|
||||
const args = jest.mocked(spawn).mock.calls[0][1];
|
||||
await writeFile(`${args[6]}/session.json`, JSON.stringify({ recording: 'recording.mp4' }));
|
||||
groupRunning = false;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
throw new Error(`Unexpected signal: ${signal}`);
|
||||
});
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
pid: 4321,
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: jest.fn(),
|
||||
}) as unknown as ChildProcess;
|
||||
jest.mocked(spawn).mockReturnValue(Object.assign(Promise.resolve(), { child }) as never);
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
child.stderr?.emit('data', 'serve-sim:recording-started\n');
|
||||
await setTimeout(2_100);
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
|
||||
const recordings = await IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
expect(signalGroup).toHaveBeenCalledWith(-4321, 'SIGINT');
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
expect(recordings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('does not signal a recorder that exits before it reports its start', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
const recorder = mockRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await setTimeout(50);
|
||||
recorder.exit();
|
||||
await setTimeout(50);
|
||||
// Past the manifest wait, so the recorder's completion settles.
|
||||
currentTime += 31_000;
|
||||
|
||||
expect(await finishing).toEqual([]);
|
||||
expect(recorder.child.kill).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'Screen recording for iPhone 17 Pro exited before it started; it will be retried.'
|
||||
);
|
||||
});
|
||||
|
||||
test('kills a recorder that ignores SIGINT and reports one that survives SIGKILL', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
const recorder = mockRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
recorder.child.stderr?.emit('data', 'GET /record?token=session-token\n');
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await setTimeout(50);
|
||||
currentTime += 61_000;
|
||||
|
||||
expect(await finishing).toEqual([]);
|
||||
expect(recorder.child.kill).toHaveBeenCalledWith('SIGINT');
|
||||
expect(recorder.child.kill).toHaveBeenCalledWith('SIGKILL');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.stringContaining(
|
||||
'Screen recording for iPhone 17 Pro did not stop within 5 seconds of SIGINT and will be killed.'
|
||||
)
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'iOS Simulator recording process for iPhone 17 Pro did not exit after SIGKILL.'
|
||||
);
|
||||
expect(JSON.stringify(jest.mocked(Sentry.capture).mock.calls)).not.toContain('session-token');
|
||||
}, 20_000);
|
||||
|
||||
test('reports a finalized recording whose manifest is gone at finish', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
const recorder = mockRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
const manifest = `${jest.mocked(spawn).mock.calls[0][1][6]}/session.json`;
|
||||
recorder.child.stderr?.emit('data', 'serve-sim:recording-started\n');
|
||||
await writeFile(manifest, JSON.stringify({ recording: 'recording.mp4' }));
|
||||
recorder.exit();
|
||||
await setTimeout(100);
|
||||
await rm(manifest);
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await setTimeout(50);
|
||||
currentTime += 31_000;
|
||||
|
||||
expect(await finishing).toEqual([]);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'iOS Simulator screen recording ended without a manifest',
|
||||
{ level: 'warning', extras: { deviceName: 'iPhone 17 Pro' } }
|
||||
);
|
||||
});
|
||||
|
||||
test.each([0, 20])(
|
||||
'keeps a started recording that exits with an error when its manifest appears after %i ms',
|
||||
async manifestDelayMs => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
const recorder = mockRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
const outputDirectory = jest.mocked(spawn).mock.calls[0][1][6];
|
||||
recorder.child.stderr?.emit('data', 'serve-sim:recording-started\n');
|
||||
const manifestWritten = setTimeout(manifestDelayMs).then(() =>
|
||||
writeFile(`${outputDirectory}/session.json`, JSON.stringify({ recording: 'recording.mp4' }))
|
||||
);
|
||||
if (manifestDelayMs === 0) {
|
||||
await manifestWritten;
|
||||
}
|
||||
recorder.fail(new Error('record-video exited with code 1'));
|
||||
|
||||
const recordings = await IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await manifestWritten;
|
||||
|
||||
expect(recordings).toEqual([
|
||||
{
|
||||
udid,
|
||||
deviceName: 'iPhone 17 Pro',
|
||||
runtimeDisplayName: 'iOS 26.4',
|
||||
directory: outputDirectory,
|
||||
},
|
||||
]);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
test('reports a started recorder that fails without a manifest', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
const recorder = mockRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
recorder.child.stderr?.emit('data', 'serve-sim:recording-started\n');
|
||||
recorder.fail(new Error('record-video exited with code 1'));
|
||||
await setTimeout(50);
|
||||
currentTime += 31_000;
|
||||
await setTimeout(1_100);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'Screen recording process failed for iPhone 17 Pro.'
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'iOS Simulator screen recording ended without a manifest',
|
||||
expect.objectContaining({ level: 'warning' })
|
||||
);
|
||||
});
|
||||
|
||||
test('records every booted device with the first serve-sim package', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
const secondUdid = '1C17CD51-5926-4838-951B-6D941C7BEDFF';
|
||||
IosSimulatorRecordingUtils.useServeSimPackage('@expo/serve-sim@later');
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([
|
||||
{ udid, name: 'iPhone 17 Pro', runtimeDisplayName: 'iOS 26.4' } as never,
|
||||
{ udid: secondUdid, name: 'iPhone 17', runtimeDisplayName: 'iOS 26.4' } as never,
|
||||
]);
|
||||
jest.mocked(readServeSimServersAsync).mockResolvedValue([
|
||||
{ udid, url: 'http://127.0.0.1:43563', token: 'first-token' },
|
||||
{ udid: secondUdid, url: 'http://127.0.0.1:43563', token: 'second-token' },
|
||||
]);
|
||||
const recorder = mockRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
for (let attempt = 0; attempt < 50 && jest.mocked(spawn).mock.calls.length < 2; attempt++) {
|
||||
await setTimeout(10);
|
||||
}
|
||||
|
||||
const calls = jest.mocked(spawn).mock.calls.map(([, args]) => args);
|
||||
expect(calls.map(args => args[4]).sort()).toEqual([udid, secondUdid].sort());
|
||||
expect(calls.every(args => args[1] === '@expo/serve-sim@next')).toBe(true);
|
||||
|
||||
recorder.exit();
|
||||
await setTimeout(50);
|
||||
currentTime += 31_000;
|
||||
await IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
});
|
||||
@@ -104,6 +104,39 @@ describe(listAgentDeviceArtifactsAsync, () => {
|
||||
})
|
||||
).rejects.toThrow('agent-device daemon does not expose artifact inventory.');
|
||||
});
|
||||
|
||||
it('keeps a screenshot display rotation and drops an unknown one', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
artifacts: [
|
||||
{
|
||||
id: 'rotated',
|
||||
artifactType: 'screenshot',
|
||||
filename: 'rotated.png',
|
||||
displayRotation: 'landscape-left',
|
||||
},
|
||||
{
|
||||
id: 'future',
|
||||
artifactType: 'screenshot',
|
||||
filename: 'future.png',
|
||||
displayRotation: 'sideways',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const artifacts = await listAgentDeviceArtifactsAsync({
|
||||
daemonUrl: 'http://127.0.0.1:1234',
|
||||
daemonToken: 'daemon-token',
|
||||
});
|
||||
|
||||
expect(artifacts.map(artifact => artifact.displayRotation)).toEqual([
|
||||
'landscape-left',
|
||||
undefined,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
@@ -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, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
});
|
||||
|
||||
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, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
});
|
||||
|
||||
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, () => {
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
stream: 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
+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,398 @@
|
||||
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, { stream }) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
uploaded.push(Buffer.concat(chunks));
|
||||
});
|
||||
});
|
||||
afterEach(async () => {
|
||||
for (const [, { stream }] of jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls) {
|
||||
stream.destroy();
|
||||
}
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
const flush = () =>
|
||||
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,463 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import type { BuildStepEnv } from '@expo/steps';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { copyFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { 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,
|
||||
uploadDeviceRunSessionScreenRecordingsAsync,
|
||||
} 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>>;
|
||||
let creationBodies: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
creates = 0;
|
||||
authorization = undefined;
|
||||
stallCreation = false;
|
||||
creationBodies = [];
|
||||
creationStarted = deferred();
|
||||
directory = await mkdtemp(path.join(tmpdir(), 'recording-upload-http-'));
|
||||
server = createServer((request, response) => {
|
||||
if (request.url === '/graphql') {
|
||||
creates++;
|
||||
authorization = request.headers.authorization;
|
||||
creationStarted.resolve(request);
|
||||
request.setEncoding('utf8');
|
||||
let body = '';
|
||||
request.on('data', chunk => (body += chunk));
|
||||
request.on('end', () => creationBodies.push(body));
|
||||
request.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,
|
||||
stream: open(),
|
||||
reopenStream: open,
|
||||
});
|
||||
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, 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,
|
||||
stream: open(),
|
||||
reopenStream: open,
|
||||
});
|
||||
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 reopenStream = jest.fn(() => Readable.from('unexpected retry'));
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
stream,
|
||||
reopenStream,
|
||||
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(reopenStream).not.toHaveBeenCalled();
|
||||
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,
|
||||
stream: Readable.from('recording'),
|
||||
reopenStream: () => Readable.from('recording'),
|
||||
});
|
||||
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 stream = Readable.from('recording');
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
stream,
|
||||
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(stream.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
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.'
|
||||
);
|
||||
});
|
||||
|
||||
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.',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,504 @@
|
||||
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 { startDeviceSessionHostAsync } from '../deviceSessionHost';
|
||||
import { 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('../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,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
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, { stream }) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
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' });
|
||||
}
|
||||
);
|
||||
@@ -454,6 +454,7 @@ describe(startChiselServerAsync, () => {
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 12345,
|
||||
getOutput: () => output,
|
||||
getExitError: () => undefined,
|
||||
stopAsync,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,6 +124,23 @@ describe(GuardEventRelay, () => {
|
||||
expect(relay.summary()).toEqual({ blocked: 3, logged: 0, distinct: 2, suppressed: 0 });
|
||||
});
|
||||
|
||||
it('omits the pid when the source has none and appends a note', () => {
|
||||
const logger = createLogger();
|
||||
const relay = new GuardEventRelay(logger);
|
||||
relay.handle({
|
||||
process: 'Android emulator',
|
||||
pid: null,
|
||||
function: 'udp',
|
||||
action: 'blocked',
|
||||
peer: '142.250.152.94:443',
|
||||
callers: [],
|
||||
note: 'QUIC',
|
||||
});
|
||||
expect(logger.lines.map(l => l.msg)).toEqual([
|
||||
'Local egress guard: refused udp from Android emulator to 142.250.152.94:443 (QUIC)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('words observed events differently and stops logging past the limit', () => {
|
||||
const logger = createLogger();
|
||||
const relay = new GuardEventRelay(logger, /* limit */ 1);
|
||||
|
||||
@@ -3,6 +3,9 @@ import { bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, BuildStepEnv, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
clearTimeout as clearTimeoutCallback,
|
||||
setTimeout as setTimeoutCallback,
|
||||
@@ -13,26 +16,36 @@ import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import {
|
||||
IosSimulatorRecordingUtils,
|
||||
SERVE_SIM_STOP_GRACE_PERIOD_MS,
|
||||
} from '../IosSimulatorRecordingUtils';
|
||||
import * as remoteDeviceRunSession from '../remoteDeviceRunSession';
|
||||
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
|
||||
import { sleepAsync } from '../../../utils/retry';
|
||||
import { uploadDeviceRunSessionScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
|
||||
import {
|
||||
createExpoDeviceHubArgs,
|
||||
createServeSimArgs,
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
fetchWebPreviewTurnArgsAsync,
|
||||
parseServeSimLaunchInputs,
|
||||
simulatorPreviewPageUrl,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startExpoDeviceHubWithTunnelAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
turnIceServersToWebPreviewArgs,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../remoteDeviceRunSession';
|
||||
|
||||
import {
|
||||
createExpoDeviceHubArgs,
|
||||
createServeSimArgs,
|
||||
simulatorPreviewPageUrl,
|
||||
startDeviceSessionHostAsync,
|
||||
waitForWebPreviewReadyAsync,
|
||||
websiteOrigin,
|
||||
websiteOriginServeSimArgs,
|
||||
} from '../remoteDeviceRunSession';
|
||||
} from '../deviceSessionHost';
|
||||
import { parseNetworkCaptureFieldsInput, parseNetworkCaptureInputs } from '../networkCaptureFields';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
jest.mock('node:timers');
|
||||
@@ -41,6 +54,10 @@ jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../../utils/retry', () => ({ sleepAsync: jest.fn() }));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../deviceRunSessionScreenRecordings', () => ({
|
||||
...jest.requireActual('../deviceRunSessionScreenRecordings'),
|
||||
uploadDeviceRunSessionScreenRecordingsAsync: jest.fn(),
|
||||
}));
|
||||
// Spyable so a test can stand in for the serve-sim state directory, which a local serve-sim owns.
|
||||
jest.mock('../serveSimMetricsRecorder', () => {
|
||||
const actual = jest.requireActual('../serveSimMetricsRecorder');
|
||||
@@ -56,6 +73,69 @@ function createLoggerMock(): bunyan {
|
||||
} as unknown as bunyan;
|
||||
}
|
||||
|
||||
describe(spawnDetached, () => {
|
||||
function mockProcess(promise: Promise<unknown>): void {
|
||||
jest
|
||||
.mocked(spawn)
|
||||
.mockReturnValue(
|
||||
Object.assign(promise, { child: { pid: 1234, unref: jest.fn(), once: jest.fn() } }) as never
|
||||
);
|
||||
}
|
||||
|
||||
it('observes exit without waiting for inherited output pipes to close', () => {
|
||||
const child = Object.assign(new EventEmitter(), { pid: 1234, unref: jest.fn() });
|
||||
jest.mocked(spawn).mockReturnValue(Object.assign(new Promise(() => {}), { child }) as never);
|
||||
const handle = spawnDetached({ command: 'server', args: [], env: {} });
|
||||
child.emit('exit', 1, null);
|
||||
expect(handle.getExitError()?.message).toContain('code 1');
|
||||
expect(child.listenerCount('exit')).toBe(0);
|
||||
});
|
||||
|
||||
it('does not report an exit while the process is running', () => {
|
||||
mockProcess(new Promise(() => {}));
|
||||
const handle = spawnDetached({ command: 'server', args: [], env: {} });
|
||||
expect(handle.getExitError()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reports a clean exit so startup does not keep waiting', async () => {
|
||||
const completion = Promise.resolve();
|
||||
mockProcess(completion);
|
||||
const handle = spawnDetached({ command: 'server', args: [], env: {} });
|
||||
await completion;
|
||||
expect(handle.getExitError()?.message).toContain('code 0');
|
||||
});
|
||||
|
||||
it.each([0, 1])('observes a real subprocess exiting with code %s', async exitCode => {
|
||||
const actualSpawn =
|
||||
jest.requireActual<typeof import('@expo/turtle-spawn')>('@expo/turtle-spawn').default;
|
||||
let completion: ReturnType<typeof actualSpawn>;
|
||||
jest.mocked(spawn).mockImplementationOnce((...args) => {
|
||||
completion = actualSpawn(...args);
|
||||
return completion;
|
||||
});
|
||||
const handle = spawnDetached({
|
||||
command: process.execPath,
|
||||
args: ['-e', `console.error('startup-marker'); process.exit(${exitCode});`],
|
||||
env: {},
|
||||
});
|
||||
await completion!.catch(() => {});
|
||||
expect(handle.getExitError()).toBeInstanceOf(Error);
|
||||
expect(handle.getOutput()).toContain('startup-marker');
|
||||
});
|
||||
|
||||
it.each(['spawn ENOENT', 'server exited with code 1', 'server terminated by SIGTERM'])(
|
||||
'preserves the process failure: %s',
|
||||
async message => {
|
||||
const error = new Error(message);
|
||||
const completion = Promise.reject(error);
|
||||
mockProcess(completion);
|
||||
const handle = spawnDetached({ command: 'server', args: [], env: {} });
|
||||
await completion.catch(() => {});
|
||||
expect(handle.getExitError()).toBe(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
function createCtxMock(): CustomBuildContext {
|
||||
return {
|
||||
env: {
|
||||
@@ -379,9 +459,94 @@ describe(createServeSimArgs, () => {
|
||||
expect(args.some(argument => argument.startsWith('--launch'))).toBe(false);
|
||||
expect(args).not.toContain('--open-url');
|
||||
});
|
||||
|
||||
it('omits --network-capture by default', () => {
|
||||
expect(createServeSimArgs({ port: 4321 })).not.toContain('--network-capture');
|
||||
expect(createServeSimArgs({ port: 4321, networkCapture: false })).not.toContain(
|
||||
'--network-capture'
|
||||
);
|
||||
});
|
||||
|
||||
it('appends --network-capture when enabled, which also covers an already booted simulator', () => {
|
||||
const args = createServeSimArgs({ port: 4321, networkCapture: true });
|
||||
expect(args).toContain('--network-capture');
|
||||
expect(args).not.toContain('--enable');
|
||||
});
|
||||
|
||||
it('repeats --network-capture-field once per requested field', () => {
|
||||
expect(
|
||||
createServeSimArgs({
|
||||
port: 4321,
|
||||
networkCapture: true,
|
||||
networkCaptureFields: ['header', 'query'],
|
||||
})
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
'--network-capture',
|
||||
'--network-capture-field',
|
||||
'header',
|
||||
'--network-capture-field',
|
||||
'query',
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps capture metadata-only when no field is requested', () => {
|
||||
expect(createServeSimArgs({ port: 4321, networkCapture: true })).not.toContain(
|
||||
'--network-capture-field'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not pass fields when capture itself is off', () => {
|
||||
expect(
|
||||
createServeSimArgs({ port: 4321, networkCapture: false, networkCaptureFields: ['header'] })
|
||||
).not.toContain('--network-capture-field');
|
||||
});
|
||||
|
||||
it('rejects network capture on a runtime that has no serve-sim', () => {
|
||||
expect(() =>
|
||||
parseNetworkCaptureInputs(
|
||||
{ networkCapture: true },
|
||||
{ runtimePlatform: BuildRuntimePlatform.LINUX }
|
||||
)
|
||||
).toThrow('this session runs on linux');
|
||||
expect(() =>
|
||||
parseNetworkCaptureInputs(
|
||||
{ networkCaptureFields: ['header'] },
|
||||
{ runtimePlatform: BuildRuntimePlatform.DARWIN }
|
||||
)
|
||||
).toThrow('needs "network_capture: true"');
|
||||
expect(parseNetworkCaptureInputs({}, { runtimePlatform: BuildRuntimePlatform.LINUX })).toEqual({
|
||||
networkCapture: false,
|
||||
networkCaptureFields: [],
|
||||
});
|
||||
expect(
|
||||
parseNetworkCaptureInputs(
|
||||
{ networkCapture: true, networkCaptureFields: ['header'] },
|
||||
{ runtimePlatform: BuildRuntimePlatform.DARWIN }
|
||||
)
|
||||
).toEqual({ networkCapture: true, networkCaptureFields: ['header'] });
|
||||
});
|
||||
|
||||
it('rejects a step input that is not an array of strings', () => {
|
||||
// A JSON step input is whatever the workflow author wrote, so the shape has to be checked.
|
||||
expect(() => parseNetworkCaptureFieldsInput('header,query')).toThrow(UserError);
|
||||
expect(() => parseNetworkCaptureFieldsInput('header,query')).toThrow(
|
||||
/must be an array of strings/
|
||||
);
|
||||
expect(() => parseNetworkCaptureFieldsInput([1, 2])).toThrow(UserError);
|
||||
expect(parseNetworkCaptureFieldsInput(undefined)).toEqual([]);
|
||||
expect(parseNetworkCaptureFieldsInput(['header'])).toEqual(['header']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(createExpoDeviceHubArgs, () => {
|
||||
it('opts in to recording only when a directory is provided', () => {
|
||||
expect(createExpoDeviceHubArgs({ port: 4321 })).not.toContain('--android-recording-directory');
|
||||
expect(
|
||||
createExpoDeviceHubArgs({ port: 4321, recordingDirectory: '/tmp/recordings' }).slice(-2)
|
||||
).toEqual(['--android-recording-directory', '/tmp/recordings']);
|
||||
});
|
||||
it('uses the latest Expo package and applies the EAS Android streaming policy', () => {
|
||||
expect(
|
||||
createExpoDeviceHubArgs({
|
||||
@@ -432,19 +597,26 @@ describe(websiteOriginServeSimArgs, () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('names staging, its deploy previews and each website dev port on staging', () => {
|
||||
it('names staging, its deploy previews and local website subdomains on staging', () => {
|
||||
const args = websiteOriginServeSimArgs({ EXPO_STAGING: '1' } as BuildStepEnv);
|
||||
expect(args.slice(0, 4)).toEqual([
|
||||
expect(args).toEqual([
|
||||
'--cors-origin',
|
||||
'https://staging.expo.dev',
|
||||
'--frame-ancestor',
|
||||
'https://staging.expo.dev',
|
||||
'--cors-origin',
|
||||
'https://*.expo.dev',
|
||||
'--frame-ancestor',
|
||||
'https://*.expo.dev',
|
||||
'--cors-origin',
|
||||
'https://expo.test',
|
||||
'--frame-ancestor',
|
||||
'https://expo.test',
|
||||
'--cors-origin',
|
||||
'https://*.expo.test',
|
||||
'--frame-ancestor',
|
||||
'https://*.expo.test',
|
||||
]);
|
||||
expect(args).toContain('https://*.expo.dev');
|
||||
expect(args).toContain('https://expo.test:13001');
|
||||
expect(args).toContain('https://expo.test:13215');
|
||||
expect(args).not.toContain('https://expo.test:13216');
|
||||
expect(args).not.toContain('https://expo.dev');
|
||||
});
|
||||
|
||||
it('names only https origins', () => {
|
||||
@@ -454,12 +626,18 @@ describe(websiteOriginServeSimArgs, () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('names the website dev ports on local, without the deploy-preview wildcard', () => {
|
||||
it('names local website subdomains without the deploy-preview wildcard on local', () => {
|
||||
for (const env of [{ EXPO_LOCAL: '1' }, { EXPO_LOCAL: '1', EXPO_STAGING: '1' }]) {
|
||||
const args = websiteOriginServeSimArgs(env as BuildStepEnv);
|
||||
expect(args).toContain('https://expo.test:13001');
|
||||
expect(args).not.toContain('https://*.expo.dev');
|
||||
expect(args.filter(value => value === 'https://expo.test')).toHaveLength(2);
|
||||
expect(websiteOriginServeSimArgs(env as BuildStepEnv)).toEqual([
|
||||
'--cors-origin',
|
||||
'https://expo.test',
|
||||
'--frame-ancestor',
|
||||
'https://expo.test',
|
||||
'--cors-origin',
|
||||
'https://*.expo.test',
|
||||
'--frame-ancestor',
|
||||
'https://*.expo.test',
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -493,6 +671,7 @@ describe(waitForWebPreviewReadyAsync, () => {
|
||||
expect.objectContaining({ retries: 0 })
|
||||
);
|
||||
expect(sleepAsync).toHaveBeenCalledTimes(1);
|
||||
expect(sleepAsync).toHaveBeenCalledWith(250);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -531,7 +710,57 @@ describe(startNgrokTunnelAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
describe('spawnDetached process group shutdown', () => {
|
||||
const env = {} as BuildStepEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
const spawned = Object.assign(Promise.resolve(undefined), {
|
||||
child: { pid: 4321, unref: jest.fn(), once: jest.fn() },
|
||||
});
|
||||
jest.mocked(spawn).mockReturnValue(spawned as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('waits for a detached child after its package-manager wrapper exits', async () => {
|
||||
let groupChecks = 0;
|
||||
const kill = jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (pid === -4321 && signal === 0) {
|
||||
groupChecks += 1;
|
||||
if (groupChecks < 4) {
|
||||
return true;
|
||||
}
|
||||
throw new Error('Process group exited');
|
||||
}
|
||||
if (pid === -4321 && signal === 'SIGTERM') {
|
||||
return true;
|
||||
}
|
||||
throw new Error(`Unexpected process signal: ${pid} ${signal}`);
|
||||
});
|
||||
|
||||
const detached = spawnDetached({ command: 'npx', args: [], env, stopGracePeriodMs: 90_000 });
|
||||
await detached.stopAsync();
|
||||
|
||||
expect(jest.mocked(sleepAsync)).toHaveBeenCalledWith(100);
|
||||
expect(kill).toHaveBeenCalledWith(-4321, 'SIGTERM');
|
||||
expect(kill).not.toHaveBeenCalledWith(-4321, 'SIGKILL');
|
||||
expect(kill).not.toHaveBeenCalledWith(4321, 0);
|
||||
});
|
||||
|
||||
it('kills a detached child that outlives the shutdown deadline', async () => {
|
||||
const kill = jest.spyOn(process, 'kill').mockReturnValue(true);
|
||||
|
||||
const detached = spawnDetached({ command: 'npx', args: [], env, stopGracePeriodMs: 0 });
|
||||
await detached.stopAsync();
|
||||
|
||||
expect(kill).toHaveBeenCalledWith(-4321, 'SIGTERM');
|
||||
expect(kill).toHaveBeenCalledWith(-4321, 'SIGKILL');
|
||||
});
|
||||
});
|
||||
|
||||
describe(startDeviceSessionHostAsync, () => {
|
||||
const baseDomain = 'eas-simulator.ngrok.dev';
|
||||
const turnArgs = [
|
||||
'--turn-url',
|
||||
@@ -548,6 +777,7 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
} as unknown as BuildStepEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockReset();
|
||||
jest.mocked(spawn).mockReset();
|
||||
jest.mocked(ngrok.forward).mockReset();
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
@@ -559,6 +789,7 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
child: {
|
||||
pid: undefined,
|
||||
unref: jest.fn(),
|
||||
once: jest.fn(),
|
||||
},
|
||||
});
|
||||
jest.mocked(spawn).mockReturnValue(spawnPromise as never);
|
||||
@@ -585,6 +816,25 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('gives serve-sim its recording grace period on shutdown', async () => {
|
||||
const spawnDetachedSpy = jest.spyOn(remoteDeviceRunSession, 'spawnDetached');
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(spawnDetachedSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ stopGracePeriodMs: SERVE_SIM_STOP_GRACE_PERIOD_MS })
|
||||
);
|
||||
await host.finishAsync();
|
||||
});
|
||||
|
||||
it('installs ffmpeg before starting expo-device-hub for Linux', async () => {
|
||||
const packageVersion = '1.2.3';
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
@@ -600,14 +850,14 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>)
|
||||
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
packageVersion,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
const spawnCalls = jest.mocked(spawn).mock.calls;
|
||||
expect(spawnCalls[0]).toEqual(['ffmpeg', ['-version'], { env }]);
|
||||
@@ -634,11 +884,17 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
expect(port).toBeGreaterThan(0);
|
||||
expect(command).toBe('npx');
|
||||
expect(args).toEqual(['--yes', ...createExpoDeviceHubArgs({ port, turnArgs, packageVersion })]);
|
||||
expect(args).toContain('--android-recording-directory');
|
||||
const recordingDirectory = args[args.indexOf('--android-recording-directory') + 1];
|
||||
expect(args).toEqual([
|
||||
'--yes',
|
||||
...createExpoDeviceHubArgs({ port, turnArgs, packageVersion, recordingDirectory }),
|
||||
]);
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(preview.apiUrl).toBe('https://android-preview.example.test');
|
||||
|
||||
await preview.stopAsync();
|
||||
await host.finishAsync();
|
||||
await fs.rm(recordingDirectory, { recursive: true, force: true });
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -648,13 +904,13 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
expect(preview.previewToken).toBe('tok-1');
|
||||
expect(preview.apiUrl).toBe('https://preview.example.test');
|
||||
@@ -677,13 +933,13 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env: { ...env, EXPO_STAGING: '1' },
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
expect(preview.previewPageUrl).toMatch(
|
||||
/^https:\/\/staging\.expo\.dev\/simulator-preview\/[a-f0-9]{32}$/
|
||||
@@ -702,9 +958,8 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
.mockResolvedValue([{ udid: 'device-id', url: 'http://127.0.0.1:1' }]);
|
||||
|
||||
await expect(
|
||||
startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
@@ -720,13 +975,13 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
expect(preview.previewToken).toBeUndefined();
|
||||
const [, args] = jest.mocked(spawn).mock.calls[0];
|
||||
@@ -741,14 +996,14 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
close,
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
packageVersion,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
@@ -767,37 +1022,12 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(preview.apiUrl).toBe('https://ios-preview.example.test');
|
||||
|
||||
await preview.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not install ffmpeg before starting expo-device-hub outside Linux', async () => {
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://android-preview.example.test',
|
||||
close,
|
||||
} as never);
|
||||
|
||||
const preview = await startExpoDeviceHubWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(jest.mocked(spawn).mock.calls[0][0]).toBe('npx');
|
||||
expect(jest.mocked(spawn)).not.toHaveBeenCalledWith(
|
||||
'ffmpeg',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
|
||||
await preview.stopAsync();
|
||||
await host.finishAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('launches serve-sim with bun x when EAS_OVERRIDE_PACKAGE_MANAGER is bun', async () => {
|
||||
const usePackage = jest.spyOn(IosSimulatorRecordingUtils, 'useServeSimPackage');
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://ios-preview.example.test',
|
||||
@@ -805,14 +1035,14 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
} as never);
|
||||
|
||||
const bunEnv = { ...env, EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' };
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env: bunEnv,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
packageVersion: '4.5.6',
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
@@ -827,8 +1057,9 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
packageVersion: '4.5.6',
|
||||
}),
|
||||
]);
|
||||
expect(usePackage).toHaveBeenCalledWith('@expo/[email protected]');
|
||||
|
||||
await preview.stopAsync();
|
||||
await host.finishAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -839,13 +1070,13 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
close,
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env: { ...env, EAS_FALLBACK_PACKAGE_MANAGER: 'bun' },
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
@@ -860,7 +1091,7 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
await preview.stopAsync();
|
||||
await host.finishAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -870,9 +1101,8 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
@@ -880,6 +1110,7 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
});
|
||||
await host.finishAsync();
|
||||
|
||||
const [, args] = jest.mocked(spawn).mock.calls[0];
|
||||
expect(args.slice(-8)).toEqual([
|
||||
@@ -896,9 +1127,8 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
|
||||
it('refuses to launch an application on Linux, where expo-device-hub cannot', async () => {
|
||||
await expect(
|
||||
startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { BuildStepEnv } from '@expo/steps';
|
||||
|
||||
import { isProcessRunning, spawnDetached } from '../remoteDeviceRunSession';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
|
||||
it('resolves stopAsync only after a process that ignores SIGTERM is gone', async () => {
|
||||
const handle = spawnDetached({
|
||||
command: 'sh',
|
||||
args: ['-c', 'trap "" TERM; echo ready; sleep 30 & wait'],
|
||||
env: process.env as BuildStepEnv,
|
||||
stopGracePeriodMs: 300,
|
||||
});
|
||||
const pid = handle.pid;
|
||||
if (pid === undefined) {
|
||||
throw new Error('spawn failed');
|
||||
}
|
||||
while (!handle.getOutput().includes('ready')) {
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
await handle.stopAsync();
|
||||
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(300);
|
||||
expect(isProcessRunning(pid)).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { Readable } from 'node:stream';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import { uploadServeSimLogsFileAsync } from '../serveSimLogsArtifacts';
|
||||
|
||||
jest.mock('../deviceRunSessionArtifacts');
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const filePath = path.join(os.tmpdir(), 'logs.ndjson');
|
||||
const args = { deviceRunSessionId: 'session', udid: 'A', filePath, logger };
|
||||
|
||||
it('uploads NDJSON with explicit user-app scope and destroys the stream afterward', async () => {
|
||||
await writeFile(filePath, '{"pid":42}\n');
|
||||
await uploadServeSimLogsFileAsync(ctx, args);
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
kind: 'simulator-log',
|
||||
filename: 'app-logs.ndjson',
|
||||
size: 11,
|
||||
metadata: expect.objectContaining({ scope: 'user-apps', udid: 'A' }),
|
||||
})
|
||||
);
|
||||
expect(
|
||||
(jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls[0][1].stream as Readable).destroyed
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('skips empty and missing files', async () => {
|
||||
await uploadServeSimLogsFileAsync(ctx, args);
|
||||
await writeFile(filePath, '');
|
||||
await uploadServeSimLogsFileAsync(ctx, args);
|
||||
expect(uploadDeviceRunSessionArtifactAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles a file read error while upload allocation is pending without an unhandled error', async () => {
|
||||
await writeFile(filePath, '{}\n');
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { stream, signal }) => {
|
||||
const aborted = new Promise<void>((_resolve, reject) => {
|
||||
signal!.addEventListener('abort', () => reject(signal!.reason), { once: true });
|
||||
});
|
||||
(stream as Readable).destroy(new Error('read failure'));
|
||||
await aborted;
|
||||
});
|
||||
await expect(uploadServeSimLogsFileAsync(ctx, args)).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
expect.stringContaining('Could not upload')
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { type Server, createServer } from 'node:http';
|
||||
import { type AddressInfo } from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { uploadServeSimLogsFileAsync } from '../serveSimLogsArtifacts';
|
||||
import { streamServeSimLogsToFileAsync } from '../serveSimLogsRecorder';
|
||||
|
||||
// This test exercises actual sockets, UTF-8 decoding, files, and PUT uploads.
|
||||
// Only WWW's allocation response is substituted; it does not verify cloud storage.
|
||||
jest.mock('../../../sentry');
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
jest.unmock('node-fetch');
|
||||
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
} as unknown as bunyan;
|
||||
|
||||
let directory: string;
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(path.join(os.tmpdir(), 'simulator-logs-http-test-'));
|
||||
server = createServer();
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close(error => (error ? reject(error) : resolve()))
|
||||
);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('streams selected-device logs into NDJSON and uploads the exact bytes as a session artifact', async () => {
|
||||
const record = JSON.stringify({ eventMessage: 'Hello 서울 🌲', process: 'MyApp' });
|
||||
const expected = Buffer.from(record + '\n');
|
||||
const envelope = JSON.stringify({ seq: 7, at: 1000, raw: record });
|
||||
const wire = Buffer.from(
|
||||
`: heartbeat\n\ndata: ${envelope}\n\ndata: invalid json\n\ndata: {"partial":`
|
||||
);
|
||||
const splitAt = wire.indexOf(Buffer.from('서울')) + 1;
|
||||
let streamRequest: { url?: string; authorization?: string } | undefined;
|
||||
let uploaded: Buffer | undefined;
|
||||
let uploadedLength: string | undefined;
|
||||
server.on('request', (request, response) => {
|
||||
if (request.method === 'GET') {
|
||||
streamRequest = { url: request.url, authorization: request.headers.authorization };
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'X-Serve-Sim-Log-Scope': 'user-apps',
|
||||
});
|
||||
response.write(wire.subarray(0, splitAt));
|
||||
setImmediate(() => response.end(wire.subarray(splitAt)));
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
request.on('data', chunk => chunks.push(Buffer.from(chunk)));
|
||||
request.on('end', () => {
|
||||
uploaded = Buffer.concat(chunks);
|
||||
uploadedLength = request.headers['content-length'];
|
||||
response.writeHead(200).end();
|
||||
});
|
||||
});
|
||||
const filePath = path.join(directory, 'simulator.ndjson');
|
||||
const result = await streamServeSimLogsToFileAsync({
|
||||
serveSimUrl: baseUrl,
|
||||
serveSimToken: 'local-test-token',
|
||||
serveSimDevice: 'device-B',
|
||||
filePath,
|
||||
signal: new AbortController().signal,
|
||||
logger,
|
||||
});
|
||||
expect(streamRequest).toEqual({
|
||||
url: '/logs?envelope=true&scope=user-apps&device=device-B',
|
||||
authorization: 'Bearer local-test-token',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
receivedData: true,
|
||||
limitReached: false,
|
||||
bytesWritten: expected.length,
|
||||
lastSequence: 7,
|
||||
});
|
||||
expect(await readFile(filePath)).toEqual(expected);
|
||||
|
||||
const mutation = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: {
|
||||
url: `${baseUrl}/upload`,
|
||||
headers: { 'Content-Length': String(expected.length) },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
await uploadServeSimLogsFileAsync(
|
||||
{ graphqlClient: { mutation } } as unknown as CustomBuildContext,
|
||||
{ deviceRunSessionId: 'session-test', udid: 'device-B', filePath, logger }
|
||||
);
|
||||
expect(mutation.mock.calls[0][1]).toEqual({
|
||||
deviceRunSessionId: 'session-test',
|
||||
input: {
|
||||
name: 'App logs (device-B)',
|
||||
filename: 'app-logs.ndjson',
|
||||
kind: 'simulator-log',
|
||||
metadata: {
|
||||
__eas_type: 'simulator-log',
|
||||
udid: 'device-B',
|
||||
scope: 'user-apps',
|
||||
source: 'serve-sim/logs',
|
||||
},
|
||||
size: expected.length,
|
||||
},
|
||||
});
|
||||
expect(uploaded).toEqual(expected);
|
||||
expect(uploadedLength).toBe(String(expected.length));
|
||||
// Only the invalid JSON record is reported; the partial trailing record is not a record yet.
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ malformedRecords: 1 },
|
||||
expect.stringContaining('not valid JSON')
|
||||
);
|
||||
});
|
||||
|
||||
it('resumes a buffered stream without appending already persisted sequence numbers', async () => {
|
||||
const urls: string[] = [];
|
||||
const first = JSON.stringify({ eventMessage: 'first' });
|
||||
const second = JSON.stringify({ eventMessage: 'second' });
|
||||
const frame = (seq: number, raw: string): string =>
|
||||
`data: ${JSON.stringify({ seq, at: seq * 1000, raw })}\n\n`;
|
||||
server.on('request', (request, response) => {
|
||||
urls.push(request.url!);
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'X-Serve-Sim-Log-Scope': 'user-apps',
|
||||
});
|
||||
// Deliberately resend seq=1 on reconnect to exercise the client's guard too.
|
||||
response.end(frame(1, first) + (urls.length > 1 ? frame(2, second) : ''));
|
||||
});
|
||||
const filePath = path.join(directory, 'resumed.ndjson');
|
||||
const options = {
|
||||
serveSimUrl: baseUrl,
|
||||
serveSimDevice: 'device-A',
|
||||
filePath,
|
||||
signal: new AbortController().signal,
|
||||
logger,
|
||||
};
|
||||
const initial = await streamServeSimLogsToFileAsync(options);
|
||||
const resumed = await streamServeSimLogsToFileAsync({
|
||||
...options,
|
||||
since: initial.lastSequence,
|
||||
});
|
||||
expect(initial.lastSequence).toBe(1);
|
||||
expect(resumed.lastSequence).toBe(2);
|
||||
expect(resumed.bytesWritten).toBe(Buffer.byteLength(second + '\n'));
|
||||
expect(urls).toEqual([
|
||||
'/logs?envelope=true&scope=user-apps&device=device-A',
|
||||
'/logs?envelope=true&scope=user-apps&since=1&device=device-A',
|
||||
]);
|
||||
expect(await readFile(filePath, 'utf8')).toBe(first + '\n' + second + '\n');
|
||||
});
|
||||
|
||||
it('aborts a real open HTTP stream without waiting for the server to finish', async () => {
|
||||
const controller = new AbortController();
|
||||
let sawRequest: () => void = () => {};
|
||||
const requestStarted = new Promise<void>(resolve => (sawRequest = resolve));
|
||||
server.on('request', (_request, response) => {
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'X-Serve-Sim-Log-Scope': 'user-apps',
|
||||
});
|
||||
response.write(': heartbeat\n\n');
|
||||
sawRequest();
|
||||
});
|
||||
const streaming = streamServeSimLogsToFileAsync({
|
||||
serveSimUrl: baseUrl,
|
||||
filePath: path.join(directory, 'aborted.ndjson'),
|
||||
signal: controller.signal,
|
||||
logger,
|
||||
});
|
||||
await requestStarted;
|
||||
controller.abort();
|
||||
await expect(streaming).resolves.toEqual({
|
||||
receivedData: false,
|
||||
limitReached: false,
|
||||
bytesWritten: 0,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fetch from 'node-fetch';
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { ServeSimLogsRecorder, streamServeSimLogsToFileAsync } from '../serveSimLogsRecorder';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
jest.mock('../../../sentry');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('node:fs/promises');
|
||||
const { Response: FetchResponse } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
// A compatible serve-sim explicitly acknowledges the requested log scope.
|
||||
class Response extends FetchResponse {
|
||||
constructor(...args: ConstructorParameters<typeof FetchResponse>) {
|
||||
super(...args);
|
||||
this.headers.set('x-serve-sim-log-scope', 'user-apps');
|
||||
}
|
||||
}
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
let directory: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.mocked(fetch).mockReset();
|
||||
directory = await mkdtemp(path.join(os.tmpdir(), 'logs-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const files = await ServeSimLogsRecorder.finishAsync({ logger });
|
||||
for (const file of files) {
|
||||
await rm(path.dirname(file.filePath), { recursive: true, force: true });
|
||||
}
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function record(options: Partial<Parameters<typeof streamServeSimLogsToFileAsync>[0]> = {}) {
|
||||
return streamServeSimLogsToFileAsync({
|
||||
serveSimUrl: 'http://localhost:1234',
|
||||
filePath: path.join(directory, 'logs.ndjson'),
|
||||
signal: new AbortController().signal,
|
||||
logger,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
it('preserves split UTF-8 and CRLF records, skips heartbeat and reports invalid JSON, and selects a device', async () => {
|
||||
const input = Buffer.from(
|
||||
':\r\n\r\ndata: {"message":"안녕"}\r\n\r\ndata: bad\n\ndata: {"pid":42}\n\n'
|
||||
);
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(new Response(Readable.from([...input].map(byte => Buffer.from([byte])))));
|
||||
const result = await record({ serveSimToken: 'secret', serveSimDevice: 'device two' });
|
||||
expect(result.receivedData).toBe(true);
|
||||
expect(await readFile(path.join(directory, 'logs.ndjson'), 'utf8')).toBe(
|
||||
'{"message":"안녕"}\n{"pid":42}\n'
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith('serve-sim sent malformed simulator log records', {
|
||||
extras: { malformedRecords: 1, serveSimDevice: 'device two' },
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'http://localhost:1234/logs?envelope=true&scope=user-apps&device=device+two',
|
||||
expect.objectContaining({ headers: { Authorization: 'Bearer secret' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('retains completed records when a connection errors', async () => {
|
||||
const stream = Readable.from(
|
||||
(async function* () {
|
||||
yield Buffer.from('data: {"pid":42}\n\n');
|
||||
await delay(10);
|
||||
throw new Error('disconnect');
|
||||
})()
|
||||
);
|
||||
jest.mocked(fetch).mockResolvedValue(new Response(stream));
|
||||
expect(await record()).toEqual({ receivedData: true, bytesWritten: 11, limitReached: false });
|
||||
expect(await readFile(path.join(directory, 'logs.ndjson'), 'utf8')).toBe('{"pid":42}\n');
|
||||
});
|
||||
|
||||
it.each([undefined, 'all'])('refuses an unconfirmed user-app scope (%s)', async scope => {
|
||||
const body = Readable.from(['data: {"eventMessage":"system noise"}\n\n']);
|
||||
const response = new FetchResponse(body);
|
||||
if (scope) {
|
||||
response.headers.set('x-serve-sim-log-scope', scope);
|
||||
}
|
||||
jest.mocked(fetch).mockResolvedValue(response);
|
||||
expect(await record()).toEqual({ receivedData: false, bytesWritten: 0, limitReached: false });
|
||||
expect(body.destroyed).toBe(true);
|
||||
await expect(readFile(path.join(directory, 'logs.ndjson'))).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
});
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('did not confirm user-app'));
|
||||
});
|
||||
|
||||
it('aborts a quiet live stream and drains preceding writes before returning', async () => {
|
||||
const controller = new AbortController();
|
||||
const stream = new Readable({ read() {} });
|
||||
stream.push('data: {"pid":42}\n\n');
|
||||
jest.mocked(fetch).mockResolvedValue(new Response(stream));
|
||||
const done = record({ signal: controller.signal });
|
||||
await delay(20);
|
||||
controller.abort();
|
||||
expect((await done).receivedData).toBe(true);
|
||||
expect(stream.destroyed).toBe(true);
|
||||
expect(await readFile(path.join(directory, 'logs.ndjson'), 'utf8')).toBe('{"pid":42}\n');
|
||||
});
|
||||
|
||||
it('enforces the byte limit without writing a truncated record', async () => {
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(new Response(Readable.from(['data: {"pid":42}\n\ndata: {"pid":43}\n\n'])));
|
||||
expect(await record({ maxBytes: 15 })).toEqual({
|
||||
receivedData: true,
|
||||
bytesWritten: 11,
|
||||
limitReached: true,
|
||||
});
|
||||
expect(await readFile(path.join(directory, 'logs.ndjson'), 'utf8')).toBe('{"pid":42}\n');
|
||||
});
|
||||
|
||||
it('drops an oversized partial SSE line and resumes at the next complete record', async () => {
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(
|
||||
new Response(
|
||||
Readable.from(['data: ' + 'x'.repeat(1024 * 1024 + 1), 'tail\n\ndata: {"pid":42}\n\n'])
|
||||
)
|
||||
);
|
||||
expect((await record()).bytesWritten).toBe(11);
|
||||
});
|
||||
|
||||
it('warns and resolves on HTTP and file failures', async () => {
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('missing', { status: 404 }));
|
||||
expect((await record()).receivedData).toBe(false);
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from(['data: {}\n\n'])));
|
||||
expect((await record({ filePath: '/missing-dir/log.ndjson' })).receivedData).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
async function register(): Promise<string> {
|
||||
const stateDir = path.join(directory, 'state');
|
||||
await mkdir(stateDir);
|
||||
await writeFile(
|
||||
path.join(stateDir, 'server-A.json'),
|
||||
JSON.stringify({ device: 'A', url: 'http://localhost:1234' })
|
||||
);
|
||||
return stateDir;
|
||||
}
|
||||
|
||||
it('caps bytes across reconnects and finalizes only once even concurrently', async () => {
|
||||
const stateDir = await register();
|
||||
let seq = 0;
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockImplementation(
|
||||
async () =>
|
||||
new Response(
|
||||
Readable.from([`data: ${JSON.stringify({ seq: ++seq, raw: '{"pid":42}' })}\n\n`])
|
||||
)
|
||||
);
|
||||
await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5, maxBytes: 22 });
|
||||
await delay(100);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
const [first, second] = await Promise.all([
|
||||
ServeSimLogsRecorder.finishAsync({ logger }),
|
||||
ServeSimLogsRecorder.finishAsync({ logger }),
|
||||
]);
|
||||
expect(first).toHaveLength(1);
|
||||
expect(second).toEqual([]);
|
||||
expect(await readFile(first[0].filePath, 'utf8')).toBe('{"pid":42}\n{"pid":42}\n');
|
||||
expect(await ServeSimLogsRecorder.finishAsync({ logger })).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns immediately before a server exists and discovers a later registration', async () => {
|
||||
const stateDir = path.join(directory, 'state');
|
||||
jest.mocked(fetch).mockImplementation(async () => new Response(Readable.from(['data: {}\n\n'])));
|
||||
await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5 });
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
await register();
|
||||
await delay(30);
|
||||
expect(await ServeSimLogsRecorder.finishAsync({ logger })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('stops discovery and active streams at the session time limit', async () => {
|
||||
const stateDir = await register();
|
||||
const stream = new Readable({ read() {} });
|
||||
stream.push('data: {}\n\n');
|
||||
jest.mocked(fetch).mockResolvedValue(new Response(stream));
|
||||
await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5, maxDurationMs: 30 });
|
||||
await delay(80);
|
||||
expect(stream.destroyed).toBe(true);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(await ServeSimLogsRecorder.finishAsync({ logger })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('stops retrying after ten consecutive failures', async () => {
|
||||
const stateDir = await register();
|
||||
jest.mocked(fetch).mockImplementation(async () => new Response('bad', { status: 503 }));
|
||||
await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5 });
|
||||
await delay(150);
|
||||
expect(fetch).toHaveBeenCalledTimes(10);
|
||||
expect(await ServeSimLogsRecorder.finishAsync({ logger })).toEqual([]);
|
||||
});
|
||||
|
||||
it('selects each registered device when two simulators share one server URL', async () => {
|
||||
const stateDir = await register();
|
||||
await writeFile(
|
||||
path.join(stateDir, 'server-B.json'),
|
||||
JSON.stringify({ device: 'B', url: 'http://localhost:1234' })
|
||||
);
|
||||
jest.mocked(fetch).mockImplementation(async () => new Response(Readable.from(['data: {}\n\n'])));
|
||||
await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5, maxBytes: 3 });
|
||||
await delay(50);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'http://localhost:1234/logs?envelope=true&scope=user-apps&device=A',
|
||||
expect.anything()
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'http://localhost:1234/logs?envelope=true&scope=user-apps&device=B',
|
||||
expect.anything()
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('persists the envelope cursor only for written records and drops replayed sequences', async () => {
|
||||
const frames = [1, 2, 3].map(
|
||||
seq => `data: ${JSON.stringify({ seq, raw: JSON.stringify({ pid: seq }) })}\n\n`
|
||||
);
|
||||
jest.mocked(fetch).mockResolvedValue(new Response(Readable.from(frames)));
|
||||
expect(await record({ since: 1, maxBytes: 10 })).toEqual({
|
||||
receivedData: true,
|
||||
bytesWritten: 10,
|
||||
limitReached: true,
|
||||
lastSequence: 2,
|
||||
});
|
||||
expect(await readFile(path.join(directory, 'logs.ndjson'), 'utf8')).toBe('{"pid":2}\n');
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'http://localhost:1234/logs?envelope=true&scope=user-apps&since=1',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('does not reconnect a legacy data stream without a replay cursor', async () => {
|
||||
const stateDir = await register();
|
||||
jest.mocked(fetch).mockImplementation(async () => new Response(Readable.from(['data: {}\n\n'])));
|
||||
await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5 });
|
||||
await delay(50);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('without a replay cursor'));
|
||||
});
|
||||
|
||||
it('resets the replay cursor when a replacement server changes its token', async () => {
|
||||
const stateDir = await register();
|
||||
let calls = 0;
|
||||
jest.mocked(fetch).mockImplementation(async (_url, options) => {
|
||||
calls++;
|
||||
if (calls === 1) {
|
||||
await writeFile(
|
||||
path.join(stateDir, 'server-A.json'),
|
||||
JSON.stringify({
|
||||
device: 'A',
|
||||
url: 'http://localhost:1234',
|
||||
token: 'new-token',
|
||||
})
|
||||
);
|
||||
}
|
||||
const seq = options?.headers ? 1 : 50;
|
||||
return new Response(Readable.from([`data: ${JSON.stringify({ seq, raw: '{}' })}\n\n`]));
|
||||
});
|
||||
await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5, maxBytes: 6 });
|
||||
await delay(50);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(String(jest.mocked(fetch).mock.calls[1][0])).not.toContain('since=');
|
||||
expect(jest.mocked(fetch).mock.calls[1][1]?.headers).toEqual({
|
||||
Authorization: 'Bearer new-token',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
|
||||
import { createStartupTasks } from '../startupTasks';
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
child: jest.fn().mockReturnThis(),
|
||||
} as unknown as bunyan;
|
||||
}
|
||||
|
||||
describe(createStartupTasks, () => {
|
||||
it('logs each task and sums them up in the order they finished', async () => {
|
||||
const logger = createLoggerMock();
|
||||
const tasks = createStartupTasks(logger);
|
||||
|
||||
let finishSlow!: () => void;
|
||||
const slow = tasks.run(
|
||||
'slow task',
|
||||
() =>
|
||||
new Promise<string>(resolve => {
|
||||
finishSlow = () => resolve('slow');
|
||||
})
|
||||
);
|
||||
const fast = tasks.run('fast task', async () => 'fast');
|
||||
|
||||
await expect(fast).resolves.toBe('fast');
|
||||
finishSlow();
|
||||
await expect(slow).resolves.toBe('slow');
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith('Starting slow task.');
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^Finished fast task in \d+\.\d s\.$/)
|
||||
);
|
||||
expect(tasks.summary()).toMatch(
|
||||
/^Startup took \d+\.\d s \(fast task \d+\.\d s, slow task \d+\.\d s\)\.$/
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the error for awaiters without an unhandled rejection', async () => {
|
||||
const logger = createLoggerMock();
|
||||
const tasks = createStartupTasks(logger);
|
||||
|
||||
const failing = tasks.run('failing task', async () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
// Nothing awaits the task yet. Jest fails the test on an unhandled rejection.
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
await expect(failing).rejects.toThrow('boom');
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringMatching(/^failing task failed after/));
|
||||
expect(tasks.summary()).toMatch(/\(\)\.$/);
|
||||
});
|
||||
|
||||
it('aborts the signal with the first failure and gives each task a child logger', async () => {
|
||||
const logger = createLoggerMock();
|
||||
const tasks = createStartupTasks(logger);
|
||||
|
||||
let taskLogger: unknown;
|
||||
await tasks.run('named task', async l => {
|
||||
taskLogger = l;
|
||||
});
|
||||
expect(logger.child).toHaveBeenCalledWith({ startupTask: 'named task' });
|
||||
expect(taskLogger).toBe(logger);
|
||||
expect(tasks.signal.aborted).toBe(false);
|
||||
|
||||
const first = new Error('first failure');
|
||||
await expect(tasks.run('first', async () => Promise.reject(first))).rejects.toBe(first);
|
||||
await expect(
|
||||
tasks.run('second', async () => Promise.reject(new Error('second failure')))
|
||||
).rejects.toThrow('second failure');
|
||||
expect(tasks.signal.aborted).toBe(true);
|
||||
expect(tasks.signal.reason).toBe(first);
|
||||
});
|
||||
|
||||
it('stops waiting on untilAborted when startup is aborted', async () => {
|
||||
const tasks = createStartupTasks(createLoggerMock());
|
||||
const never = new Promise<void>(() => {});
|
||||
const waiting = tasks.untilAborted(never);
|
||||
const failure = new Error('boot failed');
|
||||
|
||||
tasks.abort(failure);
|
||||
|
||||
await expect(waiting).rejects.toBe(failure);
|
||||
await expect(tasks.untilAborted(Promise.resolve('late'))).rejects.toBe(failure);
|
||||
await expect(
|
||||
createStartupTasks(createLoggerMock()).untilAborted(Promise.resolve('ok'))
|
||||
).resolves.toBe('ok');
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,11 @@ const AgentDeviceArtifactSchema = z.object({
|
||||
id: z.string(),
|
||||
artifactType: z.string().nullish(),
|
||||
filename: z.string(),
|
||||
// Display rotation of a screenshot. An unknown value is dropped so it cannot block the upload.
|
||||
displayRotation: z
|
||||
.enum(['portrait', 'portrait-upside-down', 'landscape-left', 'landscape-right'])
|
||||
.nullish()
|
||||
.catch(undefined),
|
||||
});
|
||||
const AgentDeviceArtifactsListResponseSchema = z.object({
|
||||
artifacts: z.array(AgentDeviceArtifactSchema),
|
||||
@@ -154,6 +159,9 @@ export async function uploadAgentDeviceArtifactAsync(
|
||||
name: `${artifact.filename} (${artifact.id})`,
|
||||
filename: artifact.filename,
|
||||
kind: artifact.artifactType ?? undefined,
|
||||
...(artifact.displayRotation
|
||||
? { metadata: { displayRotation: artifact.displayRotation } }
|
||||
: {}),
|
||||
size,
|
||||
stream: createReadStream(temporaryArtifactPath),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,860 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { type BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import readline from 'node:readline';
|
||||
|
||||
import {
|
||||
type AndroidDeviceSerialId,
|
||||
type AndroidEmulatorLaunchGate,
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
|
||||
import {
|
||||
LOCAL_EGRESS_HANDOFF_PATH,
|
||||
LOCAL_EGRESS_PROXY_HOST,
|
||||
readLocalEgressHandoffAsync,
|
||||
} from './localEgress';
|
||||
import {
|
||||
type GuardEvent,
|
||||
GuardEventRelay,
|
||||
registerLocalEgressGuardRelay,
|
||||
} from './localEgressGuard';
|
||||
|
||||
/**
|
||||
* Android emulator side of local egress, on Linux device hosts.
|
||||
*
|
||||
* - The emulator's `-http-proxy` points at a relay on LOCAL_EGRESS_RELAY_PORT.
|
||||
* The relay always listens, because qemu tests the proxy once at startup and
|
||||
* ignores it for the emulator's whole life if that test fails. It forwards
|
||||
* each connection to the chisel listener, which exists only while the EAS
|
||||
* CLI egress client is connected, and refuses the request otherwise.
|
||||
* - The guest's system proxy is the relay as well (10.0.2.2 is host loopback),
|
||||
* so proxy-aware clients send hostnames instead of IP addresses.
|
||||
* - Every emulator process runs in one systemd scope. An nftables table refuses
|
||||
* traffic from that scope that is not loopback TCP or DNS to
|
||||
* systemd-resolved: UDP, QUIC, ICMP and the emulator's
|
||||
* own direct connections. Kernel log lines from the fence become session log
|
||||
* lines through the guard relay.
|
||||
*
|
||||
* This keeps routing faithful; it is not a security boundary.
|
||||
*/
|
||||
|
||||
export const LOCAL_EGRESS_RELAY_PORT = 8898;
|
||||
export const ANDROID_EMULATOR_HOST_LOOPBACK_ALIAS = '10.0.2.2';
|
||||
/** TEST-NET-1; the relay answers CONNECTs to it itself, so it proves the proxy path is up. */
|
||||
export const LOCAL_EGRESS_READINESS_TARGET = { host: '192.0.2.1', port: 443 } as const;
|
||||
export const LOCAL_EGRESS_SESSION_UNIT = 'eas-egress-session';
|
||||
export const EGRESS_FENCE_TABLE = 'eas_fence';
|
||||
const FENCE_LOG_PREFIX = 'eas-egress';
|
||||
const FENCE_RELAY_KEY = 'android-emulator-fence';
|
||||
const FENCED_PROCESS_LABEL = 'Android emulator';
|
||||
const FENCED_PROCESS_PATTERN = /^(qemu-system|netsimd|crashpad_handle)/;
|
||||
const RELAY_MAX_HEAD_BYTES = 16 * 1024;
|
||||
const SCOPE_STARTUP_TIMEOUT_MS = 10_000;
|
||||
const ADB_REVERSE_INTERVAL_MS = 3_000;
|
||||
const GATE_SCRIPT = 'read -r _ || exit 1; exec "$@" < /dev/null';
|
||||
|
||||
const CONNECTION_ESTABLISHED = 'HTTP/1.1 200 Connection Established\r\n\r\n';
|
||||
const CLIENT_NOT_CONNECTED_BODY = 'The EAS CLI local egress client is not connected.\r\n';
|
||||
const CLIENT_NOT_CONNECTED =
|
||||
'HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Type: text/plain\r\n' +
|
||||
`Content-Length: ${Buffer.byteLength(CLIENT_NOT_CONNECTED_BODY)}\r\n\r\n${CLIENT_NOT_CONNECTED_BODY}`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Relay
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function parseConnectAuthority(head: string): { host: string; port: number } | null {
|
||||
const match = /^CONNECT\s+(\[[^\]]+\]|[^\s:]+):(\d{1,5})\s+HTTP\/1\.[01]\r?\n/i.exec(head);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return { host: match[1].replace(/^\[|\]$/g, '').toLowerCase(), port: Number(match[2]) };
|
||||
}
|
||||
|
||||
function isReadinessTarget({ host, port }: { host: string; port: number }): boolean {
|
||||
return host === LOCAL_EGRESS_READINESS_TARGET.host && port === LOCAL_EGRESS_READINESS_TARGET.port;
|
||||
}
|
||||
|
||||
/**
|
||||
* On the emulator's Wi-Fi path, a guest connection to the guest proxy
|
||||
* (10.0.2.2:<relay port>) is itself wrapped in `-http-proxy`'s CONNECT.
|
||||
*/
|
||||
function isRelayTarget({ host, port }: { host: string; port: number }, relayPort: number): boolean {
|
||||
return (
|
||||
port === relayPort &&
|
||||
(host === LOCAL_EGRESS_PROXY_HOST ||
|
||||
host === ANDROID_EMULATOR_HOST_LOOPBACK_ALIAS ||
|
||||
host === 'localhost')
|
||||
);
|
||||
}
|
||||
|
||||
export type LocalEgressRelay = {
|
||||
port: number;
|
||||
setLogger: (logger: bunyan) => void;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
export async function startLocalEgressRelayAsync({
|
||||
port = LOCAL_EGRESS_RELAY_PORT,
|
||||
upstreamPort,
|
||||
logger,
|
||||
}: {
|
||||
port?: number;
|
||||
upstreamPort: number;
|
||||
logger: bunyan;
|
||||
}): Promise<LocalEgressRelay> {
|
||||
let currentLogger = logger;
|
||||
let refusedWhileDetached = 0;
|
||||
const sockets = new Set<net.Socket>();
|
||||
const track = (socket: net.Socket): void => {
|
||||
sockets.add(socket);
|
||||
socket.on('error', () => socket.destroy());
|
||||
socket.once('close', () => sockets.delete(socket));
|
||||
};
|
||||
const endWith = (client: net.Socket, response: string): void => {
|
||||
client.end(response, () => client.destroy());
|
||||
};
|
||||
|
||||
const refuse = (client: net.Socket): void => {
|
||||
if (refusedWhileDetached === 0) {
|
||||
currentLogger.warn(
|
||||
'Local egress: the Android emulator made a request while the EAS CLI egress client is not ' +
|
||||
'connected, so it was refused. Requests from the emulator fail until the client connects.'
|
||||
);
|
||||
}
|
||||
refusedWhileDetached++;
|
||||
endWith(client, CLIENT_NOT_CONNECTED);
|
||||
};
|
||||
|
||||
const attached = (): void => {
|
||||
if (refusedWhileDetached > 0) {
|
||||
currentLogger.info(
|
||||
`Local egress: the EAS CLI egress client is connected. ${refusedWhileDetached} request(s) ` +
|
||||
'from the Android emulator were refused while it was not.'
|
||||
);
|
||||
refusedWhileDetached = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const forward = (client: net.Socket, buffered: Buffer): void => {
|
||||
const upstream = net.connect({ host: LOCAL_EGRESS_PROXY_HOST, port: upstreamPort });
|
||||
track(upstream);
|
||||
let connected = false;
|
||||
upstream.once('connect', () => {
|
||||
connected = true;
|
||||
attached();
|
||||
if (buffered.length > 0) {
|
||||
upstream.write(buffered);
|
||||
}
|
||||
client.pipe(upstream);
|
||||
upstream.pipe(client);
|
||||
client.resume();
|
||||
});
|
||||
upstream.once('error', () => {
|
||||
if (!connected && !client.destroyed) {
|
||||
refuse(client);
|
||||
}
|
||||
});
|
||||
upstream.once('close', () => {
|
||||
if (connected) {
|
||||
client.destroy();
|
||||
}
|
||||
});
|
||||
client.once('close', () => upstream.destroy());
|
||||
};
|
||||
|
||||
const route = (client: net.Socket, initial: Buffer): void => {
|
||||
let buffered = initial;
|
||||
const decide = (): void => {
|
||||
const headEnd = buffered.indexOf('\r\n\r\n');
|
||||
if (headEnd === -1 && buffered.length < RELAY_MAX_HEAD_BYTES) {
|
||||
return;
|
||||
}
|
||||
client.off('data', onData);
|
||||
client.pause();
|
||||
const target =
|
||||
headEnd === -1
|
||||
? null
|
||||
: parseConnectAuthority(buffered.subarray(0, headEnd + 2).toString('latin1'));
|
||||
if (target && isReadinessTarget(target)) {
|
||||
endWith(client, CONNECTION_ESTABLISHED);
|
||||
} else if (target && isRelayTarget(target, port)) {
|
||||
client.write(CONNECTION_ESTABLISHED);
|
||||
route(client, buffered.subarray(headEnd + 4));
|
||||
} else {
|
||||
forward(client, buffered);
|
||||
}
|
||||
};
|
||||
const onData = (chunk: Buffer): void => {
|
||||
buffered = Buffer.concat([buffered, chunk]);
|
||||
decide();
|
||||
};
|
||||
client.on('data', onData);
|
||||
client.resume();
|
||||
if (buffered.length > 0) {
|
||||
decide();
|
||||
}
|
||||
};
|
||||
|
||||
const server = net.createServer(client => {
|
||||
track(client);
|
||||
route(client, Buffer.alloc(0));
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(port, LOCAL_EGRESS_PROXY_HOST, () => {
|
||||
server.removeListener('error', reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
server.unref();
|
||||
|
||||
return {
|
||||
port,
|
||||
setLogger: nextLogger => {
|
||||
currentLogger = nextLogger;
|
||||
},
|
||||
stopAsync: async () => {
|
||||
await new Promise<void>(resolve => {
|
||||
server.close(() => resolve());
|
||||
for (const socket of sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildEgressFenceRuleset({ cgroupLevel }: { cgroupLevel: number }): string {
|
||||
return [
|
||||
`table inet ${EGRESS_FENCE_TABLE} {`,
|
||||
' set fenced { type cgroupsv2; }',
|
||||
' counter fence_tcp {}',
|
||||
' counter fence_udp {}',
|
||||
' counter fence_other {}',
|
||||
' chain out {',
|
||||
' type filter hook output priority filter; policy accept;',
|
||||
' ct state established,related accept',
|
||||
` socket cgroupv2 level ${cgroupLevel} @fenced jump fence`,
|
||||
' }',
|
||||
' chain fence {',
|
||||
// The emulator and netsimd talk to each other and to adb over loopback TCP;
|
||||
// guest TCP reaches loopback only through the proxy.
|
||||
' oif "lo" meta l4proto tcp accept',
|
||||
' oif "lo" ip daddr 127.0.0.53 udp dport 53 accept',
|
||||
` limit rate 50/second burst 200 packets log prefix "${FENCE_LOG_PREFIX} " level info`,
|
||||
' meta l4proto tcp counter name fence_tcp reject with tcp reset',
|
||||
' meta l4proto udp counter name fence_udp reject with icmpx admin-prohibited',
|
||||
' counter name fence_other reject with icmpx admin-prohibited',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function compressIpv6(address: string): string {
|
||||
try {
|
||||
return new URL(`http://[${address}]`).hostname.slice(1, -1);
|
||||
} catch {
|
||||
return address;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One kernel log line from the fence's `log` rule, for example
|
||||
* `eas-egress IN= OUT=ens3 SRC=10.0.0.2 DST=142.250.1.1 LEN=60 ... PROTO=TCP SPT=40000 DPT=443 ...`.
|
||||
* nft sees the emulator's sockets, not the guest app behind them, so events
|
||||
* carry no pid or callers.
|
||||
*/
|
||||
export function parseFenceLogLine(line: string): GuardEvent | null {
|
||||
const match = new RegExp(`${FENCE_LOG_PREFIX} (IN=.*)$`).exec(line);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const fields = new Map<string, string>();
|
||||
for (const token of match[1].split(/\s+/)) {
|
||||
const separator = token.indexOf('=');
|
||||
if (separator > 0) {
|
||||
fields.set(token.slice(0, separator), token.slice(separator + 1));
|
||||
}
|
||||
}
|
||||
const protocol = fields.get('PROTO')?.toLowerCase();
|
||||
const destination = fields.get('DST');
|
||||
if (!protocol || !destination) {
|
||||
return null;
|
||||
}
|
||||
const fn = protocol === 'icmpv6' ? 'icmp' : protocol;
|
||||
const host = net.isIPv6(destination) ? compressIpv6(destination) : destination;
|
||||
const destinationPort = fields.get('DPT');
|
||||
const peer = destinationPort
|
||||
? `${net.isIPv6(host) ? `[${host}]` : host}:${destinationPort}`
|
||||
: host;
|
||||
return {
|
||||
process: FENCED_PROCESS_LABEL,
|
||||
pid: null,
|
||||
function: fn,
|
||||
action: 'blocked',
|
||||
peer,
|
||||
callers: [],
|
||||
note: describeFenceEvent(fn, destinationPort),
|
||||
};
|
||||
}
|
||||
|
||||
function describeFenceEvent(fn: string, destinationPort: string | undefined): string | undefined {
|
||||
if (fn === 'tcp') {
|
||||
return 'a connection the emulator itself opened, such as its update check; app TCP goes through the proxy';
|
||||
}
|
||||
if (fn === 'udp' && destinationPort === '443') {
|
||||
return 'QUIC; clients usually retry over TCP through the proxy';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type FenceCounters = { tcp: number; udp: number; other: number };
|
||||
|
||||
/** `nft -j list counters table inet eas_fence` output. */
|
||||
export function parseFenceCounters(json: string): FenceCounters {
|
||||
const counters: FenceCounters = { tcp: 0, udp: 0, other: 0 };
|
||||
const parsed = JSON.parse(json) as {
|
||||
nftables?: { counter?: { name?: string; packets?: number } }[];
|
||||
};
|
||||
for (const entry of parsed.nftables ?? []) {
|
||||
const name = entry.counter?.name;
|
||||
const packets = entry.counter?.packets ?? 0;
|
||||
if (name === 'fence_tcp') {
|
||||
counters.tcp = packets;
|
||||
} else if (name === 'fence_udp') {
|
||||
counters.udp = packets;
|
||||
} else if (name === 'fence_other') {
|
||||
counters.other = packets;
|
||||
}
|
||||
}
|
||||
return counters;
|
||||
}
|
||||
|
||||
export function getCgroupLevel(cgroupPath: string): number {
|
||||
return cgroupPath.split('/').filter(Boolean).length;
|
||||
}
|
||||
|
||||
async function startSessionScopeAsync({ env }: { env: BuildStepEnv }): Promise<string> {
|
||||
const scope = spawn(
|
||||
'sudo',
|
||||
[
|
||||
'systemd-run',
|
||||
'--scope',
|
||||
'--quiet',
|
||||
`--unit=${LOCAL_EGRESS_SESSION_UNIT}`,
|
||||
'--',
|
||||
'sleep',
|
||||
'infinity',
|
||||
],
|
||||
{ env, detached: true, stdio: 'ignore' }
|
||||
);
|
||||
scope.child.unref();
|
||||
let exited = false;
|
||||
void asyncResult(scope).then(() => {
|
||||
exited = true;
|
||||
});
|
||||
|
||||
const deadline = Date.now() + SCOPE_STARTUP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const result = await asyncResult(
|
||||
spawn(
|
||||
'systemctl',
|
||||
['show', '-p', 'ControlGroup', '--value', `${LOCAL_EGRESS_SESSION_UNIT}.scope`],
|
||||
{ env, stdio: 'pipe' }
|
||||
)
|
||||
);
|
||||
const cgroupPath = result.ok ? result.value.stdout.trim().replace(/^\//, '') : '';
|
||||
if (cgroupPath) {
|
||||
return cgroupPath;
|
||||
}
|
||||
if (exited) {
|
||||
break;
|
||||
}
|
||||
await sleepAsync(200);
|
||||
}
|
||||
throw new SystemError(
|
||||
`Could not start the ${LOCAL_EGRESS_SESSION_UNIT} systemd scope for the Android emulator, so ` +
|
||||
'local egress cannot keep its traffic on the tunnel. Retry the session; if it keeps failing, ' +
|
||||
'please contact support.'
|
||||
);
|
||||
}
|
||||
|
||||
async function loadFenceAsync({
|
||||
cgroupPath,
|
||||
workDir,
|
||||
env,
|
||||
}: {
|
||||
cgroupPath: string;
|
||||
workDir: string;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<void> {
|
||||
const rulesetPath = path.join(workDir, 'fence.nft');
|
||||
await fs.promises.writeFile(
|
||||
rulesetPath,
|
||||
buildEgressFenceRuleset({ cgroupLevel: getCgroupLevel(cgroupPath) })
|
||||
);
|
||||
await asyncResult(
|
||||
spawn('sudo', ['nft', 'delete', 'table', 'inet', EGRESS_FENCE_TABLE], { env, stdio: 'pipe' })
|
||||
);
|
||||
try {
|
||||
await spawn('sudo', ['nft', '-f', rulesetPath], { env, stdio: 'pipe' });
|
||||
await spawn(
|
||||
'sudo',
|
||||
['nft', 'add', 'element', 'inet', EGRESS_FENCE_TABLE, 'fenced', `{ "${cgroupPath}" }`],
|
||||
{ env, stdio: 'pipe' }
|
||||
);
|
||||
} catch (err) {
|
||||
const failed = err as { stderr?: string };
|
||||
throw new SystemError(
|
||||
'Could not install the network fence for the Android emulator, so local egress cannot keep its ' +
|
||||
'traffic on the tunnel. Retry the session; if it keeps failing, please contact support.' +
|
||||
(failed.stderr ? ` nft output: ${failed.stderr.trim()}` : ''),
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function readFenceCountersAsync({ env }: { env: BuildStepEnv }): Promise<FenceCounters> {
|
||||
const result = await spawn(
|
||||
'sudo',
|
||||
['nft', '-j', 'list', 'counters', 'table', 'inet', EGRESS_FENCE_TABLE],
|
||||
{ env, stdio: 'pipe' }
|
||||
);
|
||||
return parseFenceCounters(result.stdout);
|
||||
}
|
||||
|
||||
function startFenceLogReader({
|
||||
env,
|
||||
sinceEpochSeconds,
|
||||
relay,
|
||||
getLogger,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
sinceEpochSeconds: number;
|
||||
relay: GuardEventRelay;
|
||||
getLogger: () => bunyan;
|
||||
}): { stopAsync: () => Promise<void> } {
|
||||
const reader = spawn(
|
||||
'sudo',
|
||||
['journalctl', '-k', '-f', '-o', 'cat', '--since', `@${sinceEpochSeconds}`],
|
||||
{ env, stdio: ['ignore', 'pipe', 'pipe'], ignoreStdio: true }
|
||||
);
|
||||
let stopping = false;
|
||||
const lines = reader.child.stdout
|
||||
? readline.createInterface({ input: reader.child.stdout }).on('line', line => {
|
||||
const event = parseFenceLogLine(line);
|
||||
if (event) {
|
||||
relay.handle(event);
|
||||
}
|
||||
})
|
||||
: undefined;
|
||||
const finished = asyncResult(reader).then(() => {
|
||||
if (!stopping) {
|
||||
getLogger().warn(
|
||||
'Local egress: stopped reading the kernel log, so further connections the Android emulator ' +
|
||||
'fence refuses are not listed here. They are still refused.'
|
||||
);
|
||||
}
|
||||
});
|
||||
reader.child.unref();
|
||||
for (const stream of [reader.child.stdout, reader.child.stderr]) {
|
||||
if (stream instanceof net.Socket) {
|
||||
stream.unref();
|
||||
}
|
||||
}
|
||||
return {
|
||||
stopAsync: async () => {
|
||||
stopping = true;
|
||||
// Kernel log lines arrive slightly after the packet; give the last ones a moment.
|
||||
await sleepAsync(500);
|
||||
reader.child.kill('SIGTERM');
|
||||
await finished;
|
||||
lines?.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Process coverage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type EmulatorProcess = { pid: number; comm: string; cgroup: string };
|
||||
|
||||
export async function listEmulatorProcessesAsync(procRoot = '/proc'): Promise<EmulatorProcess[]> {
|
||||
const entries = await fs.promises.readdir(procRoot);
|
||||
const processes: EmulatorProcess[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!/^\d+$/.test(entry)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const comm = (await fs.promises.readFile(path.join(procRoot, entry, 'comm'), 'utf8')).trim();
|
||||
if (!FENCED_PROCESS_PATTERN.test(comm)) {
|
||||
continue;
|
||||
}
|
||||
const cgroupFile = await fs.promises.readFile(path.join(procRoot, entry, 'cgroup'), 'utf8');
|
||||
const cgroup = /^0::(\S*)$/m.exec(cgroupFile)?.[1] ?? '';
|
||||
processes.push({ pid: Number(entry), comm, cgroup });
|
||||
} catch {
|
||||
// The process exited while it was being read.
|
||||
}
|
||||
}
|
||||
return processes;
|
||||
}
|
||||
|
||||
export function findProcessesOutsideScope(
|
||||
processes: readonly EmulatorProcess[],
|
||||
cgroupPath: string
|
||||
): EmulatorProcess[] {
|
||||
const scope = `/${cgroupPath}`;
|
||||
return processes.filter(({ cgroup }) => cgroup !== scope && !cgroup.startsWith(`${scope}/`));
|
||||
}
|
||||
|
||||
async function assertEmulatorProcessesFencedAsync({
|
||||
cgroupPath,
|
||||
when,
|
||||
}: {
|
||||
cgroupPath: string;
|
||||
when: string;
|
||||
}): Promise<void> {
|
||||
const outside = findProcessesOutsideScope(await listEmulatorProcessesAsync(), cgroupPath);
|
||||
if (outside.length === 0) {
|
||||
return;
|
||||
}
|
||||
throw new SystemError(
|
||||
`Android emulator processes are running outside the local egress fence ${when}: ${outside
|
||||
.map(({ comm, pid, cgroup }) => `${comm} (pid ${pid}, ${cgroup || 'unknown cgroup'})`)
|
||||
.join(
|
||||
', '
|
||||
)}. Their traffic would not use the tunnel, so the session was stopped. Retry the ` +
|
||||
'session; if it keeps failing, please contact support.'
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// adb reverse for --egress-allow localhost:<port>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Loopback ports `pid` listens on, from `ss -Hltnp`. */
|
||||
export function parseLoopbackListenerPorts(ssOutput: string, pid: number): number[] {
|
||||
const ports = new Set<number>();
|
||||
for (const line of ssOutput.split('\n')) {
|
||||
if (!line.includes(`pid=${pid},`)) {
|
||||
continue;
|
||||
}
|
||||
const match = /\s127\.0\.0\.1:(\d+)\s/.exec(line);
|
||||
if (match) {
|
||||
ports.add(Number(match[1]));
|
||||
}
|
||||
}
|
||||
return [...ports].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function parseAdbReversePorts(output: string): Set<number> {
|
||||
const ports = new Set<number>();
|
||||
for (const match of output.matchAll(/tcp:(\d+)\s+tcp:\d+/g)) {
|
||||
ports.add(Number(match[1]));
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
|
||||
/**
|
||||
* The EAS CLI forwards each `--egress-allow localhost:<port>` to the same
|
||||
* port on this host's loopback, where the guest cannot see it. `adb reverse`
|
||||
* makes guest `localhost:<port>` reach it. Reverse mappings are lost when an
|
||||
* emulator or the adb server restarts, so this runs on a timer.
|
||||
*/
|
||||
async function reconcileAdbReverseAsync({
|
||||
env,
|
||||
logger,
|
||||
chiselPid,
|
||||
tunnelPorts,
|
||||
reported,
|
||||
serialIds,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
chiselPid: number;
|
||||
/** chisel's own listeners: the proxy port and the control port ngrok forwards to. */
|
||||
tunnelPorts: readonly number[];
|
||||
reported: Set<string>;
|
||||
serialIds?: AndroidDeviceSerialId[];
|
||||
}): Promise<void> {
|
||||
const listeners = await spawn('ss', ['-Hltnp'], { env, stdio: 'pipe' });
|
||||
const ports = parseLoopbackListenerPorts(listeners.stdout, chiselPid).filter(
|
||||
port => !tunnelPorts.includes(port)
|
||||
);
|
||||
if (ports.length === 0) {
|
||||
return;
|
||||
}
|
||||
const serials =
|
||||
serialIds ??
|
||||
(await spawn('adb', ['devices'], { env, stdio: 'pipe' })).stdout
|
||||
.split('\n')
|
||||
.map(line => /^(emulator-\d+)\s+device\b/.exec(line)?.[1])
|
||||
.filter((serial): serial is AndroidDeviceSerialId => !!serial);
|
||||
for (const serial of serials) {
|
||||
const list = await asyncResult(
|
||||
spawn('adb', ['-s', serial, 'reverse', '--list'], { env, stdio: 'pipe' })
|
||||
);
|
||||
const existing = list.ok ? parseAdbReversePorts(list.value.stdout) : new Set<number>();
|
||||
for (const port of ports) {
|
||||
if (existing.has(port)) {
|
||||
continue;
|
||||
}
|
||||
const result = await asyncResult(
|
||||
spawn('adb', ['-s', serial, 'reverse', `tcp:${port}`, `tcp:${port}`], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
})
|
||||
);
|
||||
const key = `${serial}:${port}`;
|
||||
if (result.ok && !reported.has(key)) {
|
||||
reported.add(key);
|
||||
logger.info(
|
||||
`Local egress: localhost:${port} in ${serial} now reaches port ${port} on the EAS CLI machine.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type AndroidLocalEgressSession = {
|
||||
cgroupPath: string;
|
||||
env: BuildStepEnv;
|
||||
chiselPid: number;
|
||||
tunnelPorts: number[];
|
||||
relay: LocalEgressRelay;
|
||||
reportedReverse: Set<string>;
|
||||
reverseTimer?: NodeJS.Timeout;
|
||||
logger: bunyan;
|
||||
};
|
||||
|
||||
let activeSession: AndroidLocalEgressSession | undefined;
|
||||
|
||||
/**
|
||||
* Start the relay, the session scope and the fence, and begin reporting fence
|
||||
* events. Runs in `eas/start_local_egress`, before any emulator starts. The
|
||||
* fence and scope stay in place until the job ends: removing them while an
|
||||
* emulator still runs would let its traffic leave from this host.
|
||||
*/
|
||||
export async function startAndroidLocalEgressAsync({
|
||||
env,
|
||||
logger,
|
||||
workDir,
|
||||
chiselPid,
|
||||
proxyPort,
|
||||
controlPort,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
workDir: string;
|
||||
chiselPid: number;
|
||||
proxyPort: number;
|
||||
controlPort: number;
|
||||
}): Promise<{ stopAsync: () => Promise<void> }> {
|
||||
if (activeSession) {
|
||||
throw new SystemError('Android local egress is already started for this job.');
|
||||
}
|
||||
const relay = await startLocalEgressRelayAsync({ upstreamPort: proxyPort, logger });
|
||||
try {
|
||||
const cgroupPath = await startSessionScopeAsync({ env });
|
||||
const sinceEpochSeconds = Math.floor(Date.now() / 1000) - 1;
|
||||
await loadFenceAsync({ cgroupPath, workDir, env });
|
||||
|
||||
const guardRelay = new GuardEventRelay(logger);
|
||||
let guardLogger = logger;
|
||||
const reader = startFenceLogReader({
|
||||
env,
|
||||
sinceEpochSeconds,
|
||||
relay: guardRelay,
|
||||
getLogger: () => guardLogger,
|
||||
});
|
||||
registerLocalEgressGuardRelay(FENCE_RELAY_KEY, {
|
||||
source: reader,
|
||||
relay: guardRelay,
|
||||
onRebind: nextLogger => {
|
||||
guardLogger = nextLogger;
|
||||
relay.setLogger(nextLogger);
|
||||
},
|
||||
afterSummaryAsync: async () => {
|
||||
const { tcp, udp, other } = await readFenceCountersAsync({ env });
|
||||
guardLogger.info(
|
||||
`Local egress fence: refused ${tcp} TCP, ${udp} UDP and ${other} other packet(s) from the ` +
|
||||
'Android emulator in total, including retries and any not listed above.'
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const session: AndroidLocalEgressSession = {
|
||||
cgroupPath,
|
||||
env,
|
||||
chiselPid,
|
||||
tunnelPorts: [proxyPort, controlPort],
|
||||
relay,
|
||||
reportedReverse: new Set(),
|
||||
logger,
|
||||
};
|
||||
session.reverseTimer = setInterval(() => {
|
||||
void reconcileAdbReverseAsync({
|
||||
env,
|
||||
logger: guardLogger,
|
||||
chiselPid,
|
||||
tunnelPorts: session.tunnelPorts,
|
||||
reported: session.reportedReverse,
|
||||
}).catch(() => {});
|
||||
}, ADB_REVERSE_INTERVAL_MS);
|
||||
session.reverseTimer.unref();
|
||||
activeSession = session;
|
||||
logger.info(
|
||||
`Local egress: Android emulator processes run in ${cgroupPath}, and the network fence ` +
|
||||
'refuses traffic that cannot use the tunnel and reports it here.'
|
||||
);
|
||||
} catch (err) {
|
||||
await relay.stopAsync();
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
stopAsync: async () => {
|
||||
const session = activeSession;
|
||||
activeSession = undefined;
|
||||
if (session?.reverseTimer) {
|
||||
clearInterval(session.reverseTimer);
|
||||
}
|
||||
await relay.stopAsync();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type AndroidEmulatorLocalEgress = {
|
||||
launchGate: AndroidEmulatorLaunchGate;
|
||||
networkReadyTarget: { host: string; port: number };
|
||||
configureBootedEmulatorAsync: (args: {
|
||||
serialId: AndroidDeviceSerialId;
|
||||
logger: bunyan;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* What `eas/start_android_emulator` changes for a local egress session, or
|
||||
* null for every other session, which then starts the emulator exactly as
|
||||
* before.
|
||||
*/
|
||||
export async function resolveAndroidEmulatorLocalEgressAsync({
|
||||
handoffPath = LOCAL_EGRESS_HANDOFF_PATH,
|
||||
}: { handoffPath?: string } = {}): Promise<AndroidEmulatorLocalEgress | null> {
|
||||
const handoff = await readLocalEgressHandoffAsync(handoffPath);
|
||||
if (!handoff) {
|
||||
return null;
|
||||
}
|
||||
const session = activeSession;
|
||||
if (handoff.platform !== 'android' || !session) {
|
||||
throw new SystemError(
|
||||
'This session uses local egress, but the Android emulator fence was not started, so the ' +
|
||||
"emulator's traffic would not use the tunnel. Please contact support."
|
||||
);
|
||||
}
|
||||
const relayAddress = `${LOCAL_EGRESS_PROXY_HOST}:${session.relay.port}`;
|
||||
return {
|
||||
launchGate: {
|
||||
emulatorArgs: ['-http-proxy', relayAddress, '-no-metrics'],
|
||||
wrapperCommand: 'bash',
|
||||
wrapperArgs: ['-c', GATE_SCRIPT, 'eas-egress-gate'],
|
||||
admitAsync: async pid => {
|
||||
await assertEmulatorProcessesFencedAsync({
|
||||
cgroupPath: session.cgroupPath,
|
||||
when: 'before the emulator started',
|
||||
});
|
||||
await admitToScopeAsync({ pid, cgroupPath: session.cgroupPath, env: session.env });
|
||||
},
|
||||
},
|
||||
networkReadyTarget: LOCAL_EGRESS_READINESS_TARGET,
|
||||
configureBootedEmulatorAsync: async ({ serialId, logger }) => {
|
||||
await assertEmulatorProcessesFencedAsync({
|
||||
cgroupPath: session.cgroupPath,
|
||||
when: 'after boot',
|
||||
});
|
||||
const guestProxy = `${ANDROID_EMULATOR_HOST_LOOPBACK_ALIAS}:${session.relay.port}`;
|
||||
const proxyResult = await asyncResult(
|
||||
spawn(
|
||||
'adb',
|
||||
['-s', serialId, 'shell', 'settings', 'put', 'global', 'http_proxy', guestProxy],
|
||||
{
|
||||
env: session.env,
|
||||
stdio: 'pipe',
|
||||
}
|
||||
)
|
||||
);
|
||||
if (proxyResult.ok) {
|
||||
logger.info(
|
||||
`Local egress: the system proxy in ${serialId} is ${guestProxy}, so apps that honor it ` +
|
||||
"resolve hostnames on the EAS CLI machine. Other TCP still exits from that machine through the emulator's proxy."
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
{ err: proxyResult.reason },
|
||||
`Local egress: could not set the system proxy in ${serialId}. Traffic still exits from the ` +
|
||||
"EAS CLI machine through the emulator's proxy, but hostnames resolve on this worker."
|
||||
);
|
||||
}
|
||||
await reconcileAdbReverseAsync({
|
||||
env: session.env,
|
||||
logger,
|
||||
chiselPid: session.chiselPid,
|
||||
tunnelPorts: session.tunnelPorts,
|
||||
reported: session.reportedReverse,
|
||||
serialIds: [serialId],
|
||||
}).catch(err =>
|
||||
logger.warn(
|
||||
{ err },
|
||||
`Local egress: could not forward allowed localhost ports to ${serialId}.`
|
||||
)
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function admitToScopeAsync({
|
||||
pid,
|
||||
cgroupPath,
|
||||
env,
|
||||
}: {
|
||||
pid: number;
|
||||
cgroupPath: string;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<void> {
|
||||
const procsPath = `/sys/fs/cgroup/${cgroupPath}/cgroup.procs`;
|
||||
try {
|
||||
await spawn('sudo', ['sh', '-c', 'echo "$1" > "$2"', 'sh', String(pid), procsPath], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const cgroupFile = await fs.promises.readFile(`/proc/${pid}/cgroup`, 'utf8');
|
||||
if (!cgroupFile.includes(`0::/${cgroupPath}\n`)) {
|
||||
throw new Error(`/proc/${pid}/cgroup is ${cgroupFile.trim()}`);
|
||||
}
|
||||
} catch (err) {
|
||||
throw new SystemError(
|
||||
'Could not move the Android emulator into the local egress fence, so its traffic would not use ' +
|
||||
'the tunnel. Retry the session; if it keeps failing, please contact support.',
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { graphql } from 'gql.tada';
|
||||
import fetch, { Headers } from 'node-fetch';
|
||||
import type { Readable } from 'node:stream';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import {
|
||||
DeviceRunSessionTimeoutError,
|
||||
withDeviceRunSessionTimeoutAsync,
|
||||
} from './deviceRunSessionTimeout';
|
||||
|
||||
const CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
|
||||
mutation CreateDeviceRunSessionArtifactUploadSession(
|
||||
@@ -20,82 +27,178 @@ const CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
|
||||
}
|
||||
`);
|
||||
|
||||
type ArtifactMetadata = {
|
||||
deviceRunSessionId: string;
|
||||
artifactId: string;
|
||||
name: string;
|
||||
filename: string;
|
||||
kind: string | undefined;
|
||||
metadata?: Record<string, unknown>;
|
||||
size: number;
|
||||
};
|
||||
|
||||
type ArtifactSource = { stream: Readable; reopenStream?: () => Readable };
|
||||
type ArtifactUploadSession = Awaited<
|
||||
ReturnType<typeof createDeviceRunSessionArtifactUploadSessionAsync>
|
||||
>;
|
||||
|
||||
export async function uploadDeviceRunSessionArtifactAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
deviceRunSessionId,
|
||||
artifactId,
|
||||
name,
|
||||
filename,
|
||||
kind,
|
||||
metadata,
|
||||
size,
|
||||
stream,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
artifactId: string;
|
||||
name: string;
|
||||
filename: string;
|
||||
kind: string | undefined;
|
||||
metadata?: Record<string, unknown>;
|
||||
size: number;
|
||||
stream: NodeJS.ReadableStream;
|
||||
}
|
||||
options: ArtifactMetadata & ArtifactSource & { signal?: AbortSignal }
|
||||
): Promise<void> {
|
||||
const uploadSession = await createDeviceRunSessionArtifactUploadSessionAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId,
|
||||
name,
|
||||
filename,
|
||||
kind,
|
||||
metadata,
|
||||
size,
|
||||
});
|
||||
const response = await fetch(uploadSession.url, {
|
||||
method: 'PUT',
|
||||
headers: new Headers(uploadSession.headers as Record<string, string>),
|
||||
body: stream,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new SystemError(
|
||||
`Failed to upload device run session artifact ${artifactId}: ${response.status} ${response.statusText}`,
|
||||
{ cause: response }
|
||||
try {
|
||||
const uploadSession = await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Artifact upload session creation', timeoutMs: 15_000, signal: options.signal },
|
||||
async signal => await createDeviceRunSessionArtifactUploadSessionAsync(ctx, options, signal)
|
||||
);
|
||||
options.signal?.throwIfAborted();
|
||||
await putArtifactWithRetriesAsync({
|
||||
uploadSession,
|
||||
source: options,
|
||||
artifactId: options.artifactId,
|
||||
signal: options.signal,
|
||||
});
|
||||
} finally {
|
||||
// One-shot callers may have opened their stream before upload-session creation failed.
|
||||
options.stream.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async function putArtifactWithRetriesAsync({
|
||||
uploadSession,
|
||||
source,
|
||||
artifactId,
|
||||
signal,
|
||||
}: {
|
||||
uploadSession: ArtifactUploadSession;
|
||||
source: ArtifactSource;
|
||||
artifactId: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const attempts = source.reopenStream ? 3 : 1;
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
signal?.throwIfAborted();
|
||||
const stream = attempt > 0 && source.reopenStream ? source.reopenStream() : source.stream;
|
||||
try {
|
||||
// Upload time scales with file size, so the deadline bounds stalls, not the whole request.
|
||||
await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Artifact PUT', timeoutMs: 90_000, signal },
|
||||
async (putSignal, resetDeadline) =>
|
||||
await putArtifactAsync({
|
||||
uploadSession,
|
||||
stream,
|
||||
artifactId,
|
||||
signal: putSignal,
|
||||
onProgress: resetDeadline,
|
||||
})
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
const retryable =
|
||||
error instanceof ArtifactPutError
|
||||
? [408, 429, 500, 502, 503, 504].includes(error.status)
|
||||
: error instanceof DeviceRunSessionTimeoutError ||
|
||||
error instanceof ArtifactPutNetworkError;
|
||||
if (!retryable || attempt + 1 === attempts) {
|
||||
throw error;
|
||||
}
|
||||
await delay(500 * (attempt + 1), undefined, { signal });
|
||||
} finally {
|
||||
stream.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function putArtifactAsync({
|
||||
uploadSession,
|
||||
stream,
|
||||
artifactId,
|
||||
signal,
|
||||
onProgress,
|
||||
}: {
|
||||
uploadSession: ArtifactUploadSession;
|
||||
stream: Readable;
|
||||
artifactId: string;
|
||||
signal: AbortSignal;
|
||||
onProgress: () => void;
|
||||
}): Promise<void> {
|
||||
const requestController = new AbortController();
|
||||
try {
|
||||
const responding = fetch(uploadSession.url, {
|
||||
method: 'PUT',
|
||||
headers: new Headers(uploadSession.headers as Record<string, string>),
|
||||
body: stream,
|
||||
signal: AbortSignal.any([signal, requestController.signal]),
|
||||
});
|
||||
// fetch has piped the body already, so this listener observes flow without starting it.
|
||||
stream.on('data', onProgress);
|
||||
let response;
|
||||
try {
|
||||
response = await responding;
|
||||
} catch (error) {
|
||||
throw error instanceof Error && error.name === 'FetchError'
|
||||
? new ArtifactPutNetworkError(artifactId, error)
|
||||
: error;
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
if (!response.ok) {
|
||||
throw new ArtifactPutError(artifactId, response);
|
||||
}
|
||||
} finally {
|
||||
// node-fetch destroys the request body on abort, and its response stream is a PassThrough
|
||||
// whose destruction leaves the socket open, so the request itself is aborted here.
|
||||
requestController.abort();
|
||||
}
|
||||
}
|
||||
|
||||
class ArtifactPutError extends SystemError {
|
||||
readonly status: number;
|
||||
|
||||
constructor(artifactId: string, response: { status: number; statusText: string }) {
|
||||
super(
|
||||
`Failed to upload device run session artifact ${artifactId}: HTTP ${response.status} ${response.statusText}.`
|
||||
);
|
||||
this.status = response.status;
|
||||
}
|
||||
}
|
||||
|
||||
// node-fetch names the request URL in its message, and here that URL is signed. The message keeps
|
||||
// only the error code, and the original is not kept as the cause because loggers and Sentry print causes.
|
||||
class ArtifactPutNetworkError extends SystemError {
|
||||
readonly code: string | undefined;
|
||||
|
||||
constructor(artifactId: string, error: Error & { code?: unknown }) {
|
||||
const code = typeof error.code === 'string' ? error.code : undefined;
|
||||
super(
|
||||
`Failed to upload device run session artifact ${artifactId}: ${code ?? 'network error'}.`
|
||||
);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
async function createDeviceRunSessionArtifactUploadSessionAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
deviceRunSessionId,
|
||||
artifactId,
|
||||
name,
|
||||
filename,
|
||||
kind,
|
||||
metadata,
|
||||
size,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
artifactId: string;
|
||||
name: string;
|
||||
filename: string;
|
||||
kind: string | undefined;
|
||||
metadata?: Record<string, unknown>;
|
||||
size: number;
|
||||
}
|
||||
{ deviceRunSessionId, artifactId, name, filename, kind, metadata, size }: ArtifactMetadata,
|
||||
signal: AbortSignal
|
||||
) {
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION, {
|
||||
deviceRunSessionId,
|
||||
input: {
|
||||
name,
|
||||
filename,
|
||||
...(kind !== undefined ? { kind } : {}),
|
||||
...(metadata !== undefined ? { metadata } : {}),
|
||||
size,
|
||||
.mutation(
|
||||
CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION,
|
||||
{
|
||||
deviceRunSessionId,
|
||||
input: {
|
||||
name,
|
||||
filename,
|
||||
...(kind !== undefined ? { kind } : {}),
|
||||
...(metadata !== undefined ? { metadata } : {}),
|
||||
size,
|
||||
},
|
||||
},
|
||||
})
|
||||
graphqlAbortContext(signal)
|
||||
)
|
||||
.toPromise();
|
||||
signal.throwIfAborted();
|
||||
if (result.error) {
|
||||
throw new SystemError(
|
||||
`Failed to create upload session for device run session artifact ${artifactId}: ${result.error.message}`,
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import type { BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import 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 './deviceRunSessionArtifacts';
|
||||
|
||||
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(),
|
||||
status: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const UnlistedRecordingManifestSchema = z.object({
|
||||
udid: z.string(),
|
||||
deviceName: z.string(),
|
||||
runtimeDisplayName: z.string(),
|
||||
status: z.enum(['recording', 'failed', 'complete']),
|
||||
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 parseDeviceScreenRecordings(input: unknown): z.infer<typeof RecordingsSchema> {
|
||||
const result = RecordingsSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
throw new SystemError('Invalid device screen recordings input.', {
|
||||
cause: result.error,
|
||||
});
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Hub that was killed never lists its recording, even one it had already completed at the
|
||||
* duration limit. session.json is at "recording" or "failed" with a fragmented .partial that plays
|
||||
* up to its last keyframe, or at "complete" with the finished file. Return the ones ffprobe can read.
|
||||
*/
|
||||
export async function findUnlistedDeviceScreenRecordingsAsync({
|
||||
root,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
root: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<z.infer<typeof RecordingsSchema>> {
|
||||
const found: z.infer<typeof RecordingsSchema> = [];
|
||||
for (const entry of await readdir(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const directory = path.join(root, entry.name);
|
||||
const manifest = UnlistedRecordingManifestSchema.safeParse(
|
||||
await readFile(path.join(directory, 'session.json'), 'utf-8')
|
||||
.then(text => JSON.parse(text))
|
||||
.catch(() => null)
|
||||
);
|
||||
if (!manifest.success || path.basename(manifest.data.recording) !== manifest.data.recording) {
|
||||
continue;
|
||||
}
|
||||
const file = path.join(directory, manifest.data.recording);
|
||||
const name = path.join(entry.name, manifest.data.recording);
|
||||
let duration = 0;
|
||||
try {
|
||||
const probe = await spawn(
|
||||
'ffprobe',
|
||||
['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', file],
|
||||
{ env, stdio: 'pipe' }
|
||||
);
|
||||
duration = Number(probe.stdout.trim());
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.warn(`ffprobe is not available; cannot recover the unlisted recording ${name}.`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!(duration > 0)) {
|
||||
logger.warn(`Unlisted recording ${name} does not decode; skipping it.`);
|
||||
continue;
|
||||
}
|
||||
const { udid, deviceName, runtimeDisplayName } = manifest.data;
|
||||
found.push({ udid, deviceName, runtimeDisplayName, directory });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
export async function uploadDeviceRunSessionScreenRecordingsAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
logger,
|
||||
deviceRunSessionId,
|
||||
recordings,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
deviceRunSessionId: string;
|
||||
recordings: z.infer<typeof RecordingsSchema>;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
if (recordings.length === 0) {
|
||||
logger.info('No device screen recordings found; skipping uploads.');
|
||||
return true;
|
||||
}
|
||||
|
||||
const limit = limitFactory(5);
|
||||
const uploaded = 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'))
|
||||
);
|
||||
if (path.basename(metadata.recording) !== metadata.recording) {
|
||||
throw new Error('Recording filename must not contain a directory.');
|
||||
}
|
||||
const startedAt = recordingStartTimeFormatter.format(
|
||||
new Date(metadata.firstFrameWallClock.iso8601)
|
||||
);
|
||||
const partial = metadata.status !== undefined && metadata.status !== 'complete';
|
||||
const shortUdid = `${recording.udid.slice(0, 8)}-…`;
|
||||
const displayName = `${recording.deviceName} screen recording (${shortUdid}, started at ${startedAt}${partial ? ', partial' : ''})`;
|
||||
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,
|
||||
...(partial
|
||||
? {
|
||||
partial: true,
|
||||
partialReason:
|
||||
metadata.error ??
|
||||
'The Device Hub stopped before it could finalize the recording.',
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
size,
|
||||
stream: createReadStream(recordingPath),
|
||||
reopenStream: () => createReadStream(recordingPath),
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not upload device screen recording', error);
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
`Could not upload screen recording for ${recording.deviceName}.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
return uploaded.every(Boolean);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { graphql } from 'gql.tada';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { CustomBuildContext } from '../../customBuildContext';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
|
||||
const SHORT_COMMIT_HASH_LENGTH = 7;
|
||||
const MAX_APP_SLUG_LENGTH = 80;
|
||||
const SESSION_LOOKUP_TIMEOUT_MS = 5_000;
|
||||
// Runs of characters that are not letters, digits, dots, underscores, or hyphens; each run becomes one hyphen.
|
||||
const FILENAME_UNSAFE_RUN = /[^a-zA-Z0-9._-]+/g;
|
||||
// Dots and hyphens at either end of the slug, dropped so the name cannot start with a hidden-file dot.
|
||||
const LEADING_OR_TRAILING_DOTS_AND_HYPHENS = /^[.-]+|[.-]+$/g;
|
||||
// A full git commit hash: 40 hex characters for SHA-1 or 64 for SHA-256.
|
||||
const FULL_GIT_COMMIT_HASH = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i;
|
||||
|
||||
const SCREENSHOT_SESSION_QUERY = graphql(`
|
||||
query ScreenshotSession($deviceRunSessionId: ID!) {
|
||||
deviceRunSessions {
|
||||
byId(deviceRunSessionId: $deviceRunSessionId) {
|
||||
platform
|
||||
app { slug }
|
||||
build {
|
||||
id
|
||||
gitCommitHash
|
||||
app { slug }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const AppSchema = z.object({ slug: z.string() });
|
||||
const ScreenshotSessionSchema = z.object({
|
||||
platform: z.enum(['IOS', 'ANDROID']),
|
||||
app: AppSchema,
|
||||
build: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
gitCommitHash: z.string().nullable(),
|
||||
app: AppSchema,
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export type ScreenshotSession = z.infer<typeof ScreenshotSessionSchema>;
|
||||
|
||||
export async function loadScreenshotSessionAsync(
|
||||
ctx: CustomBuildContext,
|
||||
deviceRunSessionId: string,
|
||||
logger: bunyan
|
||||
): Promise<ScreenshotSession | null> {
|
||||
try {
|
||||
const result = await ctx.graphqlClient
|
||||
.query(
|
||||
SCREENSHOT_SESSION_QUERY,
|
||||
{ deviceRunSessionId },
|
||||
graphqlAbortContext(AbortSignal.timeout(SESSION_LOOKUP_TIMEOUT_MS))
|
||||
)
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
return ScreenshotSessionSchema.parse(result.data?.deviceRunSessions.byId);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, deviceRunSessionId },
|
||||
'Could not load screenshot session details; using capture timestamps.'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function screenshotArtifactDetails(
|
||||
timestamp: string,
|
||||
session: ScreenshotSession | null
|
||||
): { name: string; filename: string; metadata: Record<string, string> } {
|
||||
const name = screenshotArtifactName(timestamp);
|
||||
if (!session) {
|
||||
return {
|
||||
name,
|
||||
filename: `screenshot-${timestamp}.png`,
|
||||
metadata: { __eas_type: 'screenshot' },
|
||||
};
|
||||
}
|
||||
|
||||
const app = session.build?.app ?? session.app;
|
||||
const appSlug =
|
||||
app.slug
|
||||
.replace(FILENAME_UNSAFE_RUN, '-')
|
||||
.replace(LEADING_OR_TRAILING_DOTS_AND_HYPHENS, '')
|
||||
.slice(0, MAX_APP_SLUG_LENGTH) || 'app';
|
||||
const platform = session.platform.toLowerCase();
|
||||
const filenameParts = [appSlug, platform];
|
||||
const metadata: Record<string, string> = {
|
||||
__eas_type: 'screenshot',
|
||||
appSlug: app.slug,
|
||||
platform,
|
||||
};
|
||||
|
||||
if (session.build) {
|
||||
metadata.buildId = session.build.id;
|
||||
const commitHash = session.build.gitCommitHash;
|
||||
const hasValidCommitHash = commitHash !== null && FULL_GIT_COMMIT_HASH.test(commitHash);
|
||||
if (hasValidCommitHash) {
|
||||
filenameParts.push(commitHash.slice(0, SHORT_COMMIT_HASH_LENGTH).toLowerCase());
|
||||
metadata.gitCommitHash = commitHash;
|
||||
}
|
||||
}
|
||||
filenameParts.push(timestamp);
|
||||
return { name, filename: `${filenameParts.join('-')}.png`, metadata };
|
||||
}
|
||||
|
||||
function screenshotArtifactName(timestamp: string): string {
|
||||
// The caller already matched the timestamp against the filename pattern, so this split cannot fail.
|
||||
const [date, time] = timestamp.split('T');
|
||||
const [hours, minutes, seconds] = time.split('-');
|
||||
return `Screenshot ${date} ${hours}:${minutes}:${seconds} UTC`;
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { type FSWatcher, createReadStream, watch } from 'node:fs';
|
||||
import { mkdtemp, readFile, readdir, rm, rmdir, stat } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
|
||||
import {
|
||||
type ScreenshotSession,
|
||||
loadScreenshotSessionAsync,
|
||||
screenshotArtifactDetails,
|
||||
} from './deviceRunSessionScreenshotNames';
|
||||
|
||||
// The device-hub repository writes these files from
|
||||
// packages/serve-sim/packages/serve-sim/src/screenshot-artifacts.ts and
|
||||
// packages/serve-emu/packages/serve-emu/src/screenshot-artifacts.ts.
|
||||
// When a capture cannot be saved, the same files write a failure record in its place.
|
||||
// These patterns, the record schema and the producer code must change together.
|
||||
const SCREENSHOT_FILENAME_PATTERN =
|
||||
/^screenshot-(?<timestamp>\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)-[a-f0-9]{12}\.png$/;
|
||||
const SCREENSHOT_FAILURE_RECORD_PATTERN =
|
||||
/^screenshot-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-[a-f0-9]{12}\.failed\.json$/;
|
||||
const ScreenshotFailureRecordSchema = z.object({
|
||||
file: z.string(),
|
||||
error: z.string(),
|
||||
at: z.string(),
|
||||
});
|
||||
|
||||
async function reportScreenshotFailureRecordAsync(file: string, logger: bunyan): Promise<void> {
|
||||
try {
|
||||
const record = ScreenshotFailureRecordSchema.parse(JSON.parse(await readFile(file, 'utf8')));
|
||||
logger.warn(
|
||||
{ file: record.file, error: record.error, at: record.at },
|
||||
'The session host could not save a preview screenshot.'
|
||||
);
|
||||
Sentry.capture(
|
||||
'The session host could not save a preview screenshot',
|
||||
new Error(record.error),
|
||||
{
|
||||
extras: { file: record.file, at: record.at },
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn({ err, file }, 'Could not read a preview screenshot failure record.');
|
||||
}
|
||||
await rm(file, { force: true });
|
||||
}
|
||||
|
||||
type FailedUploads = Map<string, { attempts: number; lastError: Error }>;
|
||||
|
||||
type ScreenshotEntry = { kind: 'failureRecord' } | { kind: 'screenshot'; timestamp: string };
|
||||
|
||||
function classifyScreenshotEntry(name: string): ScreenshotEntry | null {
|
||||
if (SCREENSHOT_FAILURE_RECORD_PATTERN.test(name)) {
|
||||
return { kind: 'failureRecord' };
|
||||
}
|
||||
const timestamp = SCREENSHOT_FILENAME_PATTERN.exec(name)?.groups?.timestamp;
|
||||
return timestamp ? { kind: 'screenshot', timestamp } : null;
|
||||
}
|
||||
|
||||
async function uploadScreenshotAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
file,
|
||||
name,
|
||||
timestamp,
|
||||
size,
|
||||
deviceRunSessionId,
|
||||
session,
|
||||
signal,
|
||||
failedUploads,
|
||||
logger,
|
||||
}: {
|
||||
file: string;
|
||||
name: string;
|
||||
timestamp: string;
|
||||
size: number;
|
||||
deviceRunSessionId: string;
|
||||
session: Promise<ScreenshotSession | null>;
|
||||
signal: AbortSignal;
|
||||
failedUploads: FailedUploads;
|
||||
logger: bunyan;
|
||||
}
|
||||
): Promise<void> {
|
||||
const artifactDetails = screenshotArtifactDetails(timestamp, await session);
|
||||
const startedAt = Date.now();
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId: name.slice(0, -4),
|
||||
name: artifactDetails.name,
|
||||
filename: artifactDetails.filename,
|
||||
metadata: artifactDetails.metadata,
|
||||
kind: 'screenshot',
|
||||
size,
|
||||
stream: createReadStream(file),
|
||||
reopenStream: () => createReadStream(file),
|
||||
signal,
|
||||
});
|
||||
await rm(file);
|
||||
const previousFailures = failedUploads.get(name)?.attempts ?? 0;
|
||||
failedUploads.delete(name);
|
||||
// Each earlier failed attempt may have left an artifact row without a file on the server.
|
||||
// The API deletes such rows when the job run finishes (expo/universe#31539).
|
||||
const retryNote =
|
||||
previousFailures > 0
|
||||
? ` after ${previousFailures} failed attempt${previousFailures === 1 ? '' : 's'}`
|
||||
: '';
|
||||
logger.info(
|
||||
{ file },
|
||||
`Uploaded preview screenshot ${artifactDetails.filename} (${formatBytes(size)}) in ${Date.now() - startedAt} ms${retryNote}.`
|
||||
);
|
||||
}
|
||||
|
||||
function recordUploadFailure(
|
||||
err: unknown,
|
||||
{
|
||||
file,
|
||||
name,
|
||||
size,
|
||||
signal,
|
||||
failedUploads,
|
||||
logger,
|
||||
}: {
|
||||
file: string;
|
||||
name: string;
|
||||
size: number | undefined;
|
||||
signal: AbortSignal;
|
||||
failedUploads: FailedUploads;
|
||||
logger: bunyan;
|
||||
}
|
||||
): 'aborted' | 'recorded' {
|
||||
if (signal.aborted) {
|
||||
logger.warn(
|
||||
{ file, size },
|
||||
'Shutdown deadline reached before this preview screenshot uploaded. Keeping the file.'
|
||||
);
|
||||
return 'aborted';
|
||||
}
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
const attempt = (failedUploads.get(name)?.attempts ?? 0) + 1;
|
||||
failedUploads.set(name, { attempts: attempt, lastError: error });
|
||||
// A sustained outage would otherwise warn for every waiting file on each 30 s scan.
|
||||
if (attempt <= 3 || attempt % 10 === 0) {
|
||||
logger.warn(
|
||||
{ err: error, file, attempt, size },
|
||||
`Could not upload preview screenshot (attempt ${attempt}). Keeping it for retry.`
|
||||
);
|
||||
}
|
||||
return 'recorded';
|
||||
}
|
||||
|
||||
/**
|
||||
* Only atomically completed PNGs are eligible; failed uploads stay on disk for retry.
|
||||
* Failure records are reported once and deleted.
|
||||
*/
|
||||
export async function uploadDeviceRunSessionScreenshotsAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
directory,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
signal,
|
||||
failedUploads,
|
||||
session = Promise.resolve(null),
|
||||
}: {
|
||||
directory: string;
|
||||
deviceRunSessionId: string;
|
||||
logger: bunyan;
|
||||
signal: AbortSignal;
|
||||
failedUploads: FailedUploads;
|
||||
session?: Promise<ScreenshotSession | null>;
|
||||
}
|
||||
): Promise<{ uploaded: number; saveFailures: number }> {
|
||||
let uploadedCount = 0;
|
||||
let saveFailures = 0;
|
||||
// Names start with the capture time. Captures that never failed go first, oldest first, so a
|
||||
// capture that keeps stalling cannot hold back newer ones or use up the shutdown budget.
|
||||
const entries = (await readdir(directory, { withFileTypes: true })).sort(
|
||||
(a, b) =>
|
||||
Number(failedUploads.has(a.name)) - Number(failedUploads.has(b.name)) ||
|
||||
(a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
|
||||
);
|
||||
for (const entry of entries) {
|
||||
if (signal.aborted) {
|
||||
break;
|
||||
}
|
||||
const classified = entry.isFile() ? classifyScreenshotEntry(entry.name) : null;
|
||||
if (!classified) {
|
||||
continue;
|
||||
}
|
||||
const file = path.join(directory, entry.name);
|
||||
if (classified.kind === 'failureRecord') {
|
||||
await reportScreenshotFailureRecordAsync(file, logger);
|
||||
saveFailures++;
|
||||
continue;
|
||||
}
|
||||
let size: number | undefined;
|
||||
try {
|
||||
size = (await stat(file)).size;
|
||||
await uploadScreenshotAsync(ctx, {
|
||||
file,
|
||||
name: entry.name,
|
||||
timestamp: classified.timestamp,
|
||||
size,
|
||||
deviceRunSessionId,
|
||||
session,
|
||||
signal,
|
||||
failedUploads,
|
||||
logger,
|
||||
});
|
||||
uploadedCount++;
|
||||
} catch (err) {
|
||||
if (size === undefined && (err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
continue;
|
||||
}
|
||||
const outcome = recordUploadFailure(err, {
|
||||
file,
|
||||
name: entry.name,
|
||||
size,
|
||||
signal,
|
||||
failedUploads,
|
||||
logger,
|
||||
});
|
||||
if (outcome === 'aborted') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { uploaded: uploadedCount, saveFailures };
|
||||
}
|
||||
|
||||
function watchScreenshotDirectory(
|
||||
directory: string,
|
||||
onChange: () => void,
|
||||
logger: bunyan
|
||||
): () => void {
|
||||
let watcher: FSWatcher | undefined;
|
||||
try {
|
||||
watcher = watch(directory, { persistent: false }, (_event, filename) => {
|
||||
if (
|
||||
filename === null ||
|
||||
SCREENSHOT_FILENAME_PATTERN.test(filename) ||
|
||||
SCREENSHOT_FAILURE_RECORD_PATTERN.test(filename)
|
||||
) {
|
||||
onChange();
|
||||
}
|
||||
});
|
||||
watcher.on('error', err => {
|
||||
logger.warn({ err, directory }, 'Screenshot watcher failed; using periodic scans.');
|
||||
watcher?.close();
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn({ err, directory }, 'Could not watch screenshots; using periodic scans.');
|
||||
}
|
||||
return () => watcher?.close();
|
||||
}
|
||||
|
||||
// rmdir refuses a non-empty directory, so a file still there at the end is reported as not uploaded,
|
||||
// never deleted.
|
||||
async function removeEmptyDirectoryAsync(directory: string): Promise<boolean> {
|
||||
try {
|
||||
await rmdir(directory);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOTEMPTY') {
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function reportNotUploadedScreenshotsAsync({
|
||||
directory,
|
||||
hostStopped,
|
||||
failedUploads,
|
||||
logger,
|
||||
}: {
|
||||
directory: string;
|
||||
hostStopped: boolean;
|
||||
failedUploads: FailedUploads;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
if (hostStopped && (await removeEmptyDirectoryAsync(directory))) {
|
||||
return;
|
||||
}
|
||||
const files = await readdir(directory);
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
const notUploaded = files.map(name => {
|
||||
const failure = failedUploads.get(name);
|
||||
return failure
|
||||
? { name, attempts: failure.attempts, lastError: failure.lastError.message }
|
||||
: { name };
|
||||
});
|
||||
const message = `${files.length} preview screenshot${files.length === 1 ? ' was' : 's were'} not uploaded.`;
|
||||
logger.warn({ directory, files: notUploaded }, message);
|
||||
Sentry.capture('Preview screenshots were not uploaded', new Error(message), {
|
||||
extras: { files: notUploaded },
|
||||
});
|
||||
}
|
||||
|
||||
async function finishCollectionAsync({
|
||||
directory,
|
||||
hostStopped,
|
||||
logger,
|
||||
stopWatching,
|
||||
timer,
|
||||
controller,
|
||||
drainAsync,
|
||||
totals,
|
||||
failedUploads,
|
||||
}: {
|
||||
directory: string;
|
||||
hostStopped: boolean;
|
||||
logger: bunyan;
|
||||
stopWatching: () => void;
|
||||
timer: NodeJS.Timeout;
|
||||
controller: AbortController;
|
||||
drainAsync: () => Promise<void>;
|
||||
totals: { uploaded: number; saveFailures: number };
|
||||
failedUploads: FailedUploads;
|
||||
}): Promise<void> {
|
||||
stopWatching();
|
||||
clearInterval(timer);
|
||||
// This shutdown budget wins over the uploader's own 90 s stall deadline, so a slow upload
|
||||
// at shutdown is reported as not uploaded rather than delaying teardown.
|
||||
const deadline = setTimeout(() => controller.abort(), 30_000);
|
||||
try {
|
||||
await drainAsync();
|
||||
if (controller.signal.aborted) {
|
||||
logger.warn({ directory }, 'Preview screenshot flush timed out after 30 s.');
|
||||
}
|
||||
const saveFailureNote =
|
||||
totals.saveFailures > 0 ? `; the session host could not save ${totals.saveFailures}` : '';
|
||||
logger.info(
|
||||
`Uploaded ${totals.uploaded} preview screenshots during the session${saveFailureNote}.`
|
||||
);
|
||||
if (!hostStopped) {
|
||||
logger.warn(
|
||||
{ directory },
|
||||
'The session host is still running, so preview screenshots it saves from now on are not uploaded.'
|
||||
);
|
||||
}
|
||||
await reportNotUploadedScreenshotsAsync({ directory, hostStopped, failedUploads, logger });
|
||||
} catch (err) {
|
||||
logger.warn({ err, directory }, 'Could not finish preview screenshot uploads.');
|
||||
} finally {
|
||||
clearTimeout(deadline);
|
||||
}
|
||||
}
|
||||
|
||||
export async function startDeviceRunSessionScreenshotsAsync(
|
||||
ctx: CustomBuildContext,
|
||||
options: { deviceRunSessionId: string; logger: bunyan }
|
||||
): Promise<{ directory: string; finishAsync: (hostStopped: boolean) => Promise<void> }> {
|
||||
const session = loadScreenshotSessionAsync(ctx, options.deviceRunSessionId, options.logger);
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), 'device-session-screenshots-'));
|
||||
const failedUploads: FailedUploads = new Map();
|
||||
const totals = { uploaded: 0, saveFailures: 0 };
|
||||
const controller = new AbortController();
|
||||
let pending: Promise<void> | null = null;
|
||||
let scanRequested = false;
|
||||
|
||||
const collectRequestedScreenshotsAsync = async (): Promise<void> => {
|
||||
while (scanRequested && !controller.signal.aborted) {
|
||||
scanRequested = false;
|
||||
try {
|
||||
const { uploaded, saveFailures } = await uploadDeviceRunSessionScreenshotsAsync(ctx, {
|
||||
...options,
|
||||
session,
|
||||
directory,
|
||||
failedUploads,
|
||||
signal: controller.signal,
|
||||
});
|
||||
totals.uploaded += uploaded;
|
||||
totals.saveFailures += saveFailures;
|
||||
} catch (err) {
|
||||
options.logger.warn({ err, directory }, 'Could not collect preview screenshots.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const flush = (): Promise<void> => {
|
||||
scanRequested = true;
|
||||
if (pending) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
pending = collectRequestedScreenshotsAsync().finally(() => {
|
||||
pending = null;
|
||||
});
|
||||
return pending;
|
||||
};
|
||||
|
||||
const stopWatching = watchScreenshotDirectory(directory, () => void flush(), options.logger);
|
||||
const timer = setInterval(() => {
|
||||
void flush();
|
||||
}, 30_000);
|
||||
timer.unref();
|
||||
let finishTask: Promise<void> | null = null;
|
||||
return {
|
||||
directory,
|
||||
finishAsync(hostStopped) {
|
||||
finishTask ??= finishCollectionAsync({
|
||||
directory,
|
||||
hostStopped,
|
||||
logger: options.logger,
|
||||
stopWatching,
|
||||
timer,
|
||||
controller,
|
||||
drainAsync: async () => {
|
||||
await pending;
|
||||
await flush();
|
||||
},
|
||||
totals,
|
||||
failedUploads,
|
||||
});
|
||||
return finishTask;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export class DeviceRunSessionTimeoutError extends Error {
|
||||
constructor(name: string, timeoutMs: number) {
|
||||
super(`${name} timed out after ${timeoutMs}ms.`);
|
||||
this.name = 'DeviceRunSessionTimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds waiting and signals cancellation. Operations must check the signal before later effects.
|
||||
* Calling resetDeadline restarts the timer, so the bound applies to stalls instead of total time.
|
||||
*/
|
||||
export async function withDeviceRunSessionTimeoutAsync<T>(
|
||||
{ name, timeoutMs, signal: parent }: { name: string; timeoutMs: number; signal?: AbortSignal },
|
||||
operation: (signal: AbortSignal, resetDeadline: () => void) => Promise<T>
|
||||
): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const signal = parent ? AbortSignal.any([parent, controller.signal]) : controller.signal;
|
||||
signal.throwIfAborted();
|
||||
let rejectAbort: (reason: unknown) => void = () => {};
|
||||
const aborted = new Promise<never>((_resolve, reject) => {
|
||||
rejectAbort = reject;
|
||||
});
|
||||
const onAbort = () => rejectAbort(signal.reason);
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
const expire = () => controller.abort(new DeviceRunSessionTimeoutError(name, timeoutMs));
|
||||
let timer = setTimeout(expire, timeoutMs);
|
||||
let settled = false;
|
||||
const resetDeadline = () => {
|
||||
clearTimeout(timer);
|
||||
if (!settled && !signal.aborted) {
|
||||
timer = setTimeout(expire, timeoutMs);
|
||||
}
|
||||
};
|
||||
try {
|
||||
const result = await Promise.race([operation(signal, resetDeadline), aborted]);
|
||||
signal.throwIfAborted();
|
||||
return result;
|
||||
} finally {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageExec,
|
||||
} from '../../utils/packageManager';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import {
|
||||
findUnlistedDeviceScreenRecordingsAsync,
|
||||
parseDeviceScreenRecordings,
|
||||
uploadDeviceRunSessionScreenRecordingsAsync,
|
||||
} from './deviceRunSessionScreenRecordings';
|
||||
import { startDeviceRunSessionScreenshotsAsync } from './deviceRunSessionScreenshots';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
type ServeSimLaunchOptions,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
fetchWebPreviewTurnArgsAsync,
|
||||
findAvailablePortAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
isProcessRunning,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
} from './remoteDeviceRunSession';
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
import {
|
||||
IosSimulatorRecordingUtils,
|
||||
SERVE_SIM_STOP_GRACE_PERIOD_MS,
|
||||
} from './IosSimulatorRecordingUtils';
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
|
||||
const WEB_PREVIEW_HOST = '127.0.0.1';
|
||||
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
|
||||
const SERVE_SIM_MAX_DIMENSION = '1600';
|
||||
const SERVE_SIM_MJPEG_QUALITY = '0.55';
|
||||
const SERVE_SIM_VIDEO_BITRATE = '10000000';
|
||||
const SERVE_SIM_VIDEO_FPS = '60';
|
||||
const EXPO_DEVICE_HUB_PACKAGE_NAME = 'expo-device-hub';
|
||||
const EXPO_DEVICE_HUB_MAX_DIMENSION = '960';
|
||||
const EXPO_DEVICE_HUB_VIDEO_BITRATE = '6000000';
|
||||
const EXPO_DEVICE_HUB_VIDEO_FPS = '60';
|
||||
// On SIGTERM the Hub finalizes the recording itself, with this deadline before it force-exits,
|
||||
// so a stop request that failed here still gets one more chance to write the MP4.
|
||||
const EXPO_DEVICE_HUB_SIGTERM_FINALIZE_DEADLINE_MS = 60_000;
|
||||
const EXPO_DEVICE_HUB_EXIT_LEEWAY_MS = 10_000;
|
||||
const RECORDING_STOP_GRACE_PERIOD_MS =
|
||||
EXPO_DEVICE_HUB_SIGTERM_FINALIZE_DEADLINE_MS + EXPO_DEVICE_HUB_EXIT_LEEWAY_MS;
|
||||
const HOST_OUTPUT_TAIL_CHARS = 8_000;
|
||||
const WEB_PREVIEW_READY_POLL_INTERVAL_MS = 250;
|
||||
|
||||
export function websiteOrigin(env: BuildStepEnv): string {
|
||||
return env.EXPO_LOCAL
|
||||
? 'https://expo.test'
|
||||
: env.EXPO_STAGING
|
||||
? 'https://staging.expo.dev'
|
||||
: 'https://expo.dev';
|
||||
}
|
||||
|
||||
export function simulatorPreviewPageUrl(env: BuildStepEnv, subdomainId: string): string {
|
||||
return new URL(`/simulator-preview/${subdomainId}`, websiteOrigin(env)).toString();
|
||||
}
|
||||
|
||||
// Local website hosts are shared by local and staging simulator sessions.
|
||||
const WEBSITE_DEV_ORIGINS = ['https://expo.test', 'https://*.expo.test'];
|
||||
|
||||
export function websiteOriginServeSimArgs(env: BuildStepEnv): string[] {
|
||||
const origins = new Set([websiteOrigin(env)]);
|
||||
if (!env.EXPO_LOCAL && env.EXPO_STAGING) {
|
||||
origins.add('https://*.expo.dev');
|
||||
}
|
||||
if (env.EXPO_LOCAL || env.EXPO_STAGING) {
|
||||
for (const origin of WEBSITE_DEV_ORIGINS) {
|
||||
origins.add(origin);
|
||||
}
|
||||
}
|
||||
return [...origins].flatMap(origin => ['--cors-origin', origin, '--frame-ancestor', origin]);
|
||||
}
|
||||
|
||||
function createServeSimPackageSpec(packageVersion: string | undefined): string {
|
||||
return `${SERVE_SIM_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
|
||||
}
|
||||
|
||||
function createExpoDeviceHubPackageSpec(packageVersion: string | undefined): string {
|
||||
return `${EXPO_DEVICE_HUB_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
|
||||
}
|
||||
|
||||
export function createServeSimArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
websiteArgs = [],
|
||||
shareUrl,
|
||||
packageVersion,
|
||||
launchAppIdentifier,
|
||||
launchArgs = [],
|
||||
openUrl,
|
||||
networkCapture = false,
|
||||
networkCaptureFields = [],
|
||||
}: {
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
websiteArgs?: string[];
|
||||
shareUrl?: string;
|
||||
packageVersion?: string;
|
||||
networkCapture?: boolean;
|
||||
networkCaptureFields?: string[];
|
||||
} & ServeSimLaunchOptions): string[] {
|
||||
return [
|
||||
createServeSimPackageSpec(packageVersion),
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
WEB_PREVIEW_HOST,
|
||||
'--require-token',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'h264',
|
||||
'--max-dimension',
|
||||
SERVE_SIM_MAX_DIMENSION,
|
||||
'--mjpeg-quality',
|
||||
SERVE_SIM_MJPEG_QUALITY,
|
||||
'--video-bitrate',
|
||||
SERVE_SIM_VIDEO_BITRATE,
|
||||
'--video-fps',
|
||||
SERVE_SIM_VIDEO_FPS,
|
||||
...turnArgs,
|
||||
...websiteArgs,
|
||||
...(shareUrl ? ['--share-url', shareUrl] : []),
|
||||
...(launchAppIdentifier ? ['--launch-app-identifier', launchAppIdentifier] : []),
|
||||
...launchArgs.flatMap(argument => ['--launch-arg', argument]),
|
||||
...(openUrl ? ['--open-url', openUrl] : []),
|
||||
// `--network-capture` also covers an already booted simulator. Fields are repeated, not
|
||||
// comma-joined, so serve-sim's error names the bad value.
|
||||
...(networkCapture
|
||||
? [
|
||||
'--network-capture',
|
||||
...networkCaptureFields.flatMap(field => ['--network-capture-field', field]),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
export function createExpoDeviceHubArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
packageVersion,
|
||||
recordingDirectory,
|
||||
}: {
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
packageVersion?: string;
|
||||
recordingDirectory?: string;
|
||||
}): string[] {
|
||||
return [
|
||||
createExpoDeviceHubPackageSpec(packageVersion),
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
WEB_PREVIEW_HOST,
|
||||
'--platform',
|
||||
'android',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'h264',
|
||||
'--webrtc-ice-policy',
|
||||
'all',
|
||||
'--max-dimension',
|
||||
EXPO_DEVICE_HUB_MAX_DIMENSION,
|
||||
'--video-bitrate',
|
||||
EXPO_DEVICE_HUB_VIDEO_BITRATE,
|
||||
'--video-fps',
|
||||
EXPO_DEVICE_HUB_VIDEO_FPS,
|
||||
'--hide-sidebar',
|
||||
'--hide-boot-device',
|
||||
...(recordingDirectory ? ['--android-recording-directory', recordingDirectory] : []),
|
||||
...turnArgs,
|
||||
];
|
||||
}
|
||||
|
||||
const WebPreviewReadyResponseSchema = z.object({
|
||||
status: z.literal('ready'),
|
||||
device: z.string(),
|
||||
});
|
||||
|
||||
export async function waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
}: {
|
||||
previewServer: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
|
||||
serverName: string;
|
||||
port: number;
|
||||
timeoutMs: number;
|
||||
}): Promise<string> {
|
||||
const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
if (previewServer.pid !== undefined && !isProcessRunning(previewServer.pid)) {
|
||||
throw new SystemError(
|
||||
`${serverName} exited before becoming ready. Last output:\n${
|
||||
previewServer.getOutput() || '<empty>'
|
||||
}`
|
||||
);
|
||||
}
|
||||
try {
|
||||
const response = await turtleFetch(readyUrl, 'GET', {
|
||||
retries: 0,
|
||||
timeout: 2_000,
|
||||
});
|
||||
const ready = WebPreviewReadyResponseSchema.parse(await response.json());
|
||||
return ready.device;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await sleepAsync(WEB_PREVIEW_READY_POLL_INTERVAL_MS);
|
||||
}
|
||||
throw new SystemError(
|
||||
`Timed out waiting for ${serverName} readiness at ${readyUrl}${
|
||||
lastError instanceof Error ? `: ${lastError.message}` : ''
|
||||
}. Last output:\n${previewServer.getOutput() || '<empty>'}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function readServeSimPreviewTokenAsync(
|
||||
udid: string,
|
||||
stateDir: string = SERVE_SIM_STATE_DIR
|
||||
): Promise<string | undefined> {
|
||||
const servers = await readServeSimServersAsync(stateDir);
|
||||
return servers.find(server => server.udid === udid)?.token;
|
||||
}
|
||||
|
||||
export type DeviceWebPreview = {
|
||||
previewPageUrl: string;
|
||||
apiUrl: string;
|
||||
previewToken?: string;
|
||||
/** Closes this tunnel only. The session host and recording remain running. */
|
||||
closeAsync(): Promise<void>;
|
||||
};
|
||||
|
||||
export type DeviceSessionHost = {
|
||||
openPreviewAsync(options: { baseDomain: string }): Promise<DeviceWebPreview>;
|
||||
/**
|
||||
* Terminal and idempotent. Never rejects: a failed finalization, host stop or upload is logged
|
||||
* and reported, so callers need no error handling around it.
|
||||
*/
|
||||
finishAsync(): Promise<void>;
|
||||
};
|
||||
|
||||
type AndroidSessionRecording = {
|
||||
deviceRunSessionId: string;
|
||||
directory: string;
|
||||
controlToken: string;
|
||||
env: BuildStepEnv;
|
||||
};
|
||||
|
||||
export async function startDeviceSessionHostAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
packageVersion,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
networkCapture = false,
|
||||
networkCaptureFields = [],
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
networkCapture?: boolean;
|
||||
networkCaptureFields?: string[];
|
||||
} & ServeSimLaunchOptions
|
||||
): Promise<DeviceSessionHost> {
|
||||
const isAndroid = runtimePlatform === BuildRuntimePlatform.LINUX;
|
||||
// Unreachable from the step functions, which reject a non-Darwin launch while parsing.
|
||||
// Kept because this function is exported and expo-device-hub cannot launch.
|
||||
if (isAndroid && networkCapture) {
|
||||
throw new UserError(
|
||||
'EAS_NETWORK_CAPTURE_INVALID_INPUT',
|
||||
`Cannot record network traffic: capture runs through serve-sim on an iOS simulator, and this session runs expo-device-hub on ${runtimePlatform}.`
|
||||
);
|
||||
}
|
||||
if (isAndroid && launchAppIdentifier) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
`Cannot launch ${launchAppIdentifier}: an application launch runs through serve-sim on an iOS simulator, and this session runs expo-device-hub on ${runtimePlatform}.`
|
||||
);
|
||||
}
|
||||
if (isAndroid) {
|
||||
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
|
||||
}
|
||||
const recording: AndroidSessionRecording | null = isAndroid
|
||||
? {
|
||||
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
|
||||
directory: await fs.promises.mkdtemp(path.join(os.tmpdir(), 'android-session-recordings-')),
|
||||
controlToken: randomBytes(32).toString('hex'),
|
||||
env,
|
||||
}
|
||||
: null;
|
||||
const subdomainId = randomBytes(16).toString('hex');
|
||||
const previewPageUrl = simulatorPreviewPageUrl(env, subdomainId);
|
||||
const port = await findAvailablePortAsync();
|
||||
const serverName = isAndroid ? 'expo-device-hub' : 'serve-sim';
|
||||
const packageSpec = isAndroid
|
||||
? createExpoDeviceHubPackageSpec(packageVersion)
|
||||
: createServeSimPackageSpec(packageVersion);
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
|
||||
const previewExec = resolvePackageExec(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
isAndroid
|
||||
? createExpoDeviceHubArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
packageVersion,
|
||||
recordingDirectory: recording?.directory,
|
||||
})
|
||||
: createServeSimArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
packageVersion,
|
||||
websiteArgs: websiteOriginServeSimArgs(env),
|
||||
shareUrl: previewPageUrl,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
})
|
||||
);
|
||||
logger.info(
|
||||
`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port} via ${previewExec.command}.`
|
||||
);
|
||||
const screenshots = await startDeviceRunSessionScreenshotsAsync(ctx, {
|
||||
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
|
||||
logger,
|
||||
});
|
||||
let previewServer: DetachedProcessHandle;
|
||||
try {
|
||||
previewServer = spawnDetached({
|
||||
command: previewExec.command,
|
||||
args: previewExec.args,
|
||||
env: {
|
||||
...env,
|
||||
EXPO_DEVICE_HUB_SCREENSHOT_DIRECTORY: screenshots.directory,
|
||||
...(recording ? { EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN: recording.controlToken } : {}),
|
||||
},
|
||||
// Only an Android host records through expo-device-hub; serve-sim records on iOS.
|
||||
stopGracePeriodMs: recording
|
||||
? RECORDING_STOP_GRACE_PERIOD_MS
|
||||
: SERVE_SIM_STOP_GRACE_PERIOD_MS,
|
||||
});
|
||||
} catch (error) {
|
||||
// Nothing was spawned, so nothing can still write into the directory.
|
||||
await screenshots.finishAsync(true);
|
||||
throw error;
|
||||
}
|
||||
|
||||
let previewToken: string | undefined;
|
||||
let previewTask: Promise<DeviceWebPreview> | null = null;
|
||||
let finishTask: Promise<void> | null = null;
|
||||
let hostReady = false;
|
||||
|
||||
const host: DeviceSessionHost = {
|
||||
openPreviewAsync({ baseDomain }) {
|
||||
if (finishTask) {
|
||||
return Promise.reject(
|
||||
new SystemError('Cannot open a preview after session host finalization.')
|
||||
);
|
||||
}
|
||||
if (previewTask) {
|
||||
return previewTask;
|
||||
}
|
||||
const opening = (async (): Promise<DeviceWebPreview> => {
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix: 'web-preview',
|
||||
subdomainId,
|
||||
baseDomain,
|
||||
authtoken: getNgrokAuthtokenOrThrow(env),
|
||||
logger,
|
||||
});
|
||||
if (finishTask) {
|
||||
await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Late preview tunnel close', timeoutMs: 5_000 },
|
||||
async () => await tunnel.stopAsync()
|
||||
).catch(err => logger.warn({ err }, 'Could not close a late preview tunnel.'));
|
||||
throw new SystemError('Session host finalized while the preview was opening.');
|
||||
}
|
||||
let closeTask: Promise<void> | null = null;
|
||||
return {
|
||||
previewPageUrl,
|
||||
apiUrl: tunnel.url,
|
||||
previewToken,
|
||||
closeAsync() {
|
||||
return (closeTask ??= (async () => {
|
||||
try {
|
||||
await tunnel.stopAsync();
|
||||
} finally {
|
||||
if (previewTask === opening) {
|
||||
previewTask = null;
|
||||
}
|
||||
}
|
||||
})());
|
||||
},
|
||||
};
|
||||
})();
|
||||
previewTask = opening;
|
||||
// A failed tunnel may be retried without restarting capture.
|
||||
void opening.catch(() => {
|
||||
if (previewTask === opening) {
|
||||
previewTask = null;
|
||||
}
|
||||
});
|
||||
return opening;
|
||||
},
|
||||
finishAsync() {
|
||||
return (finishTask ??= finishDeviceSessionHostAsync(ctx, {
|
||||
previewTask,
|
||||
previewServer,
|
||||
screenshots,
|
||||
serverName,
|
||||
port,
|
||||
// A host that never answered /readyz has nothing to finalize or upload.
|
||||
recording: hostReady ? recording : null,
|
||||
logger,
|
||||
}));
|
||||
},
|
||||
};
|
||||
try {
|
||||
logger.info(`Waiting for ${serverName} to become ready.`);
|
||||
const device = await waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
});
|
||||
hostReady = true;
|
||||
if (!isAndroid) {
|
||||
previewToken = await readServeSimPreviewTokenAsync(device);
|
||||
if (!previewToken) {
|
||||
throw new SystemError(
|
||||
`serve-sim became ready but wrote no session token for device ${device}. The preview is ` +
|
||||
'on a public tunnel and would be reachable without one, so the session cannot continue. ' +
|
||||
'This usually means the state file was not written as expected; retry the session, and ' +
|
||||
'report it if it repeats.'
|
||||
);
|
||||
}
|
||||
IosSimulatorRecordingUtils.useServeSimPackage(packageSpec);
|
||||
}
|
||||
return host;
|
||||
} catch (error) {
|
||||
await host.finishAsync();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function finishDeviceSessionHostAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
previewTask,
|
||||
previewServer,
|
||||
screenshots,
|
||||
serverName,
|
||||
port,
|
||||
recording,
|
||||
logger,
|
||||
}: {
|
||||
previewTask: Promise<DeviceWebPreview> | null;
|
||||
previewServer: DetachedProcessHandle;
|
||||
screenshots: { finishAsync(hostStopped: boolean): Promise<void> };
|
||||
serverName: string;
|
||||
port: number;
|
||||
recording: AndroidSessionRecording | null;
|
||||
logger: bunyan;
|
||||
}
|
||||
): Promise<void> {
|
||||
// Native ngrok operations have no scoped cancellation. Retire a late listener too.
|
||||
const retirePreview = withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Preview tunnel retirement', timeoutMs: 5_000 },
|
||||
async () => {
|
||||
// A preview that failed to open, or was closed by its opener after finish began, has no tunnel left.
|
||||
const preview = await previewTask?.catch(() => null);
|
||||
await preview?.closeAsync();
|
||||
}
|
||||
).catch(err => {
|
||||
logger.warn({ err }, `Could not close the ${serverName} preview tunnel within its deadline.`);
|
||||
});
|
||||
let finalization: AndroidRecordingFinalization | null = null;
|
||||
if (recording) {
|
||||
// stopAsync signals the whole process group, including capture's encoder.
|
||||
// Finalize the MP4 first; the token protects this route on the preview server.
|
||||
finalization = await finalizeAndroidRecordingAsync({
|
||||
port,
|
||||
controlToken: recording.controlToken,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
let hostStopped = false;
|
||||
try {
|
||||
await previewServer.stopAsync();
|
||||
if (previewServer.pid !== undefined && isProcessRunning(previewServer.pid)) {
|
||||
throw new Error('Session host is still running after shutdown.');
|
||||
}
|
||||
hostStopped = true;
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `Could not stop the ${serverName} session host.`);
|
||||
}
|
||||
await retirePreview;
|
||||
await screenshots.finishAsync(hostStopped);
|
||||
// A Hub that never recorded has logged its reason and left nothing to upload.
|
||||
const captured = finalization !== 'not-recording';
|
||||
let uploaded = false;
|
||||
if (recording && captured && hostStopped) {
|
||||
uploaded = await uploadFinishedAndroidRecordingAsync(ctx, { recording, logger });
|
||||
}
|
||||
if (recording && captured && (finalization === 'failed' || !uploaded)) {
|
||||
// The Hub reports capture failures on stderr; the stop route answers with only a summary.
|
||||
logger.warn(
|
||||
{ hostOutput: previewServer.getOutput().slice(-HOST_OUTPUT_TAIL_CHARS) || '<empty>' },
|
||||
'Session host output around the recording failure.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type AndroidRecordingFinalization = 'finalized' | 'not-recording' | 'failed';
|
||||
|
||||
async function finalizeAndroidRecordingAsync({
|
||||
port,
|
||||
controlToken,
|
||||
logger,
|
||||
}: {
|
||||
port: number;
|
||||
controlToken: string;
|
||||
logger: bunyan;
|
||||
}): Promise<AndroidRecordingFinalization> {
|
||||
try {
|
||||
const response = await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Android recording finalization', timeoutMs: 60_000 },
|
||||
async signal =>
|
||||
await turtleFetch(
|
||||
`http://${WEB_PREVIEW_HOST}:${port}/_eas/android-recording/stop`,
|
||||
'POST',
|
||||
{
|
||||
headers: { Authorization: `Bearer ${controlToken}` },
|
||||
retries: 0,
|
||||
shouldThrowOnNotOk: false,
|
||||
signal,
|
||||
}
|
||||
)
|
||||
);
|
||||
if (response.status === 409) {
|
||||
// The Hub answers 409 with the reason nothing was recorded, such as the emulator count.
|
||||
logger.warn(`Android recording was not captured: ${await response.text()}`);
|
||||
return 'not-recording';
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new SystemError(
|
||||
`Android recording finalization returned HTTP ${response.status}: ${await response.text()}`
|
||||
);
|
||||
}
|
||||
return 'finalized';
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not finalize Android recording before shutdown', error, {
|
||||
level: 'warning',
|
||||
});
|
||||
logger.warn({ err: error }, 'Could not finalize Android recording before shutdown.');
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFinishedAndroidRecordingAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{ recording, logger }: { recording: AndroidSessionRecording; logger: bunyan }
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
let recordings = parseDeviceScreenRecordings(
|
||||
JSON.parse(
|
||||
await fs.promises.readFile(path.join(recording.directory, 'recordings.json'), 'utf8')
|
||||
)
|
||||
);
|
||||
if (recordings.length === 0) {
|
||||
// recordings.json is written at finish; a killed Hub leaves what it had on disk unlisted.
|
||||
recordings = await findUnlistedDeviceScreenRecordingsAsync({
|
||||
root: recording.directory,
|
||||
env: recording.env,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
for (const item of recordings) {
|
||||
if (
|
||||
path.dirname(path.resolve(item.directory)) !== recording.directory ||
|
||||
(await fs.promises.lstat(item.directory)).isSymbolicLink()
|
||||
) {
|
||||
throw new Error('Recording directory is not an owned session child.');
|
||||
}
|
||||
}
|
||||
const uploaded = await uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: recording.deviceRunSessionId,
|
||||
recordings,
|
||||
});
|
||||
if (recordings.length > 0 && uploaded) {
|
||||
await fs.promises.rm(recording.directory, { recursive: true });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not upload the Android session recording', error, { level: 'warning' });
|
||||
logger.warn(
|
||||
{ err: error, recordingDirectory: recording.directory },
|
||||
'Could not upload the Android session recording.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import * as jose from 'jose';
|
||||
import fetch from 'node-fetch';
|
||||
import { ZodError, z } from 'zod';
|
||||
|
||||
import { isConnectFailure, isConnectionInterruptedError } from '../../../utils/networkErrors';
|
||||
import { promiseRetryWithCondition } from '../../../utils/promiseRetryWithCondition';
|
||||
|
||||
const TOKEN_LIFETIME_SECONDS = 20 /* minutes */ * 60 /* seconds */;
|
||||
const TOKEN_REFRESH_MARGIN_SECONDS = 60 /* seconds */;
|
||||
|
||||
type ApiSchema = {
|
||||
[Path in string]: {
|
||||
path?: z.ZodType<Record<string, string>>;
|
||||
@@ -14,7 +22,7 @@ type ApiSchema = {
|
||||
const AscErrorResponseSchema = z.object({
|
||||
errors: z
|
||||
.array(
|
||||
z.object({
|
||||
z.looseObject({
|
||||
id: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
code: z.string().optional(),
|
||||
@@ -27,6 +35,83 @@ const AscErrorResponseSchema = z.object({
|
||||
});
|
||||
|
||||
const GetApi = {
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/get-v1-builds-_id_-buildbetadetail
|
||||
'/v1/builds/:id/buildBetaDetail': {
|
||||
path: z.object({ id: z.string() }),
|
||||
request: z.object({}),
|
||||
response: z.object({
|
||||
data: z.object({
|
||||
id: z.string(),
|
||||
attributes: z.object({
|
||||
internalBuildState: z.enum([
|
||||
'PROCESSING',
|
||||
'PROCESSING_EXCEPTION',
|
||||
'MISSING_EXPORT_COMPLIANCE',
|
||||
'READY_FOR_BETA_TESTING',
|
||||
'IN_BETA_TESTING',
|
||||
'EXPIRED',
|
||||
'IN_EXPORT_COMPLIANCE_REVIEW',
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/get-v1-builds-_id_-app
|
||||
'/v1/builds/:id/app': {
|
||||
path: z.object({ id: z.string() }),
|
||||
request: z.object({}),
|
||||
response: z.object({
|
||||
data: z.object({
|
||||
id: z.string(),
|
||||
attributes: z.object({ primaryLocale: z.string().optional() }).optional(),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/get-v1-builds-_id_-betabuildlocalizations
|
||||
'/v1/builds/:id/betaBuildLocalizations': {
|
||||
path: z.object({ id: z.string() }),
|
||||
request: z.object({ limit: z.number().int().max(200) }),
|
||||
response: z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
attributes: z.object({ locale: z.string().optional() }).optional(),
|
||||
})
|
||||
),
|
||||
links: z.object({ next: z.string().nullish() }).optional(),
|
||||
}),
|
||||
},
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/get-v1-betagroups
|
||||
'/v1/betaGroups': {
|
||||
path: z.object({}),
|
||||
request: z.union([
|
||||
z.object({
|
||||
'filter[app]': z.string(),
|
||||
'filter[builds]': z.never().optional(),
|
||||
limit: z.number().int().max(200),
|
||||
}),
|
||||
z.object({
|
||||
'filter[app]': z.never().optional(),
|
||||
'filter[builds]': z.string(),
|
||||
limit: z.number().int().max(200),
|
||||
}),
|
||||
]),
|
||||
response: z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
attributes: z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
isInternalGroup: z.boolean().optional(),
|
||||
hasAccessToAllBuilds: z.boolean().nullish(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
),
|
||||
links: z.object({ next: z.string().nullish() }).optional(),
|
||||
}),
|
||||
},
|
||||
'/v1/apps': {
|
||||
path: z.object({}),
|
||||
request: z.object({
|
||||
@@ -119,12 +204,40 @@ const GetApi = {
|
||||
warnings: z.array(z.object({ code: z.string(), description: z.string() })).optional(),
|
||||
}),
|
||||
}),
|
||||
relationships: z
|
||||
.object({
|
||||
build: z.object({
|
||||
data: z.object({ type: z.literal('builds'), id: z.string() }).nullable(),
|
||||
}),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
} satisfies ApiSchema;
|
||||
|
||||
const PostApi = {
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/post-v1-builds-_id_-relationships-betagroups
|
||||
'/v1/builds/:id/relationships/betaGroups': {
|
||||
path: z.object({ id: z.string() }),
|
||||
request: z.object({
|
||||
data: z.array(z.object({ type: z.literal('betaGroups'), id: z.string() })),
|
||||
}),
|
||||
response: z.undefined(),
|
||||
},
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/post-v1-betabuildlocalizations
|
||||
'/v1/betaBuildLocalizations': {
|
||||
request: z.object({
|
||||
data: z.object({
|
||||
type: z.literal('betaBuildLocalizations'),
|
||||
attributes: z.object({ locale: z.string(), whatsNew: z.string().nullish() }),
|
||||
relationships: z.object({
|
||||
build: z.object({ data: z.object({ type: z.literal('builds'), id: z.string() }) }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
response: z.object({ data: z.object({ id: z.string() }) }),
|
||||
},
|
||||
'/v1/buildUploads': {
|
||||
request: z.object({
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/builduploadcreaterequest/data-data.dictionary
|
||||
@@ -223,6 +336,18 @@ const PostApi = {
|
||||
} satisfies ApiSchema;
|
||||
|
||||
const PatchApi = {
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/patch-v1-betabuildlocalizations-_id_
|
||||
'/v1/betaBuildLocalizations/:id': {
|
||||
path: z.object({ id: z.string() }),
|
||||
request: z.object({
|
||||
data: z.object({
|
||||
type: z.literal('betaBuildLocalizations'),
|
||||
id: z.string(),
|
||||
attributes: z.object({ whatsNew: z.string().nullish() }).optional(),
|
||||
}),
|
||||
}),
|
||||
response: z.object({ data: z.object({ id: z.string() }) }),
|
||||
},
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/patch-v1-builduploadfiles-_id_
|
||||
'/v1/buildUploadFiles/:id': {
|
||||
path: z.object({
|
||||
@@ -287,23 +412,65 @@ export class AscApiRequestError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
public readonly responseJson: z.output<typeof AscErrorResponseSchema>,
|
||||
public readonly responseJson: z.output<typeof AscErrorResponseSchema>['errors'][number],
|
||||
options?: { cause?: unknown }
|
||||
) {
|
||||
super(message, { cause: options?.cause });
|
||||
}
|
||||
|
||||
get code(): string | undefined {
|
||||
return this.responseJson.code;
|
||||
}
|
||||
}
|
||||
|
||||
export type AscApiKey = {
|
||||
keyId: string;
|
||||
issuerId?: string | null;
|
||||
privateKey: jose.KeyLike;
|
||||
};
|
||||
|
||||
export class AscApiClient {
|
||||
private readonly baseUrl = 'https://api.appstoreconnect.apple.com';
|
||||
private readonly token: string;
|
||||
private readonly key: AscApiKey;
|
||||
private cachedToken?: { value: Promise<string>; expiresAt: number };
|
||||
private readonly logger?: bunyan;
|
||||
|
||||
constructor({ token, logger }: { token: string; logger?: bunyan }) {
|
||||
this.token = token;
|
||||
constructor({ key, logger }: { key: AscApiKey; logger?: bunyan }) {
|
||||
this.key = key;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
private async getTokenAsync(): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
// Reuse valid tokens, but leave time for a request to reach Apple before expiry.
|
||||
if (!this.cachedToken || this.cachedToken.expiresAt <= now + TOKEN_REFRESH_MARGIN_SECONDS) {
|
||||
const expiresAt = now + TOKEN_LIFETIME_SECONDS;
|
||||
this.cachedToken = { value: this.signTokenAsync(expiresAt), expiresAt };
|
||||
}
|
||||
const cachedToken = this.cachedToken;
|
||||
try {
|
||||
return await cachedToken.value;
|
||||
} catch (error) {
|
||||
if (this.cachedToken === cachedToken) {
|
||||
this.cachedToken = undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async signTokenAsync(expiresAt: number): Promise<string> {
|
||||
const jwt = new jose.SignJWT({})
|
||||
.setProtectedHeader({ alg: 'ES256', kid: this.key.keyId })
|
||||
.setAudience('appstoreconnect-v1')
|
||||
.setExpirationTime(expiresAt);
|
||||
if (this.key.issuerId) {
|
||||
jwt.setIssuer(this.key.issuerId);
|
||||
} else {
|
||||
jwt.setSubject('user');
|
||||
}
|
||||
return await jwt.sign(this.key.privateKey);
|
||||
}
|
||||
|
||||
public async getAsync<TPath extends keyof typeof GetApi>(
|
||||
path: TPath,
|
||||
body: z.input<(typeof GetApi)[TPath]['request']>,
|
||||
@@ -334,14 +501,40 @@ export class AscApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
public async getNextPageAsync<TPath extends keyof typeof GetApi>(
|
||||
path: TPath,
|
||||
next: string
|
||||
): Promise<z.output<(typeof GetApi)[TPath]['response']>> {
|
||||
const nextUrl = new URL(next, this.baseUrl);
|
||||
if (nextUrl.origin !== this.baseUrl) {
|
||||
throw new Error('App Store Connect returned a pagination link on another host.');
|
||||
}
|
||||
return await this.sendRequestAsync({
|
||||
method: 'GET',
|
||||
path: nextUrl.toString(),
|
||||
body: {},
|
||||
requestSchema: z.object({}),
|
||||
responseSchema: GetApi[path].response,
|
||||
});
|
||||
}
|
||||
|
||||
public async postAsync<TPath extends keyof typeof PostApi>(
|
||||
path: TPath,
|
||||
body: z.input<(typeof PostApi)[TPath]['request']>
|
||||
body: z.input<(typeof PostApi)[TPath]['request']>,
|
||||
// Require path params if the API schema defines them.
|
||||
...params: (typeof PostApi)[TPath] extends { path: infer TSchema extends z.ZodType }
|
||||
? [params: z.input<TSchema>]
|
||||
: []
|
||||
): Promise<z.output<(typeof PostApi)[TPath]['response']>> {
|
||||
const schema = PostApi[path];
|
||||
let effectivePath: string = path;
|
||||
const pathParams = 'path' in schema ? schema.path.parse(params[0]) : {};
|
||||
for (const [key, value] of Object.entries(pathParams)) {
|
||||
effectivePath = effectivePath.replaceAll(`:${key}`, encodeURIComponent(String(value)));
|
||||
}
|
||||
return await this.sendRequestAsync({
|
||||
method: 'POST',
|
||||
path,
|
||||
path: effectivePath,
|
||||
body,
|
||||
requestSchema: schema.request,
|
||||
responseSchema: schema.response,
|
||||
@@ -369,7 +562,22 @@ export class AscApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
private async sendRequestAsync({
|
||||
private async sendRequestAsync(
|
||||
args: Parameters<AscApiClient['sendRequestOnceAsync']>[0]
|
||||
): Promise<any> {
|
||||
return await promiseRetryWithCondition(
|
||||
() => this.sendRequestOnceAsync(args),
|
||||
error =>
|
||||
isConnectFailure(error) || (args.method === 'GET' && isConnectionInterruptedError(error)),
|
||||
{ retries: 3, factor: 2, minTimeout: 100 },
|
||||
({ attemptNumber, maxAttemptsCount, error }) =>
|
||||
this.logger?.warn(
|
||||
`Retrying Apple request (${args.method} ${args.path}, attempt ${attemptNumber}/${maxAttemptsCount}): ${String(error)}`
|
||||
)
|
||||
)();
|
||||
}
|
||||
|
||||
private async sendRequestOnceAsync({
|
||||
path,
|
||||
method,
|
||||
body,
|
||||
@@ -397,7 +605,7 @@ export class AscApiClient {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
Authorization: `Bearer ${await this.getTokenAsync()}`,
|
||||
},
|
||||
body: method === 'GET' ? undefined : JSON.stringify(parsedBody.value),
|
||||
});
|
||||
@@ -408,10 +616,31 @@ export class AscApiClient {
|
||||
(async () => AscErrorResponseSchema.parse(JSON.parse(text)))()
|
||||
);
|
||||
if (parsedAscErrorResponse.ok) {
|
||||
throw new AscApiRequestError(
|
||||
`Unexpected response (${response.status}) from App Store Connect: ${text}`,
|
||||
response.status,
|
||||
parsedAscErrorResponse.value,
|
||||
const errors = parsedAscErrorResponse.value.errors.map(ascError => {
|
||||
if (ascError.code === 'FORBIDDEN.REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED') {
|
||||
return new UserError(
|
||||
'EAS_ASC_REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED',
|
||||
'A required Apple agreement is missing or has expired. ' +
|
||||
"Ask your Apple Developer team's Account Holder to review and accept the required agreements " +
|
||||
'in App Store Connect: https://appstoreconnect.apple.com/business. ' +
|
||||
'If prompted, also accept the Apple Developer Program License Agreement at https://developer.apple.com/account. ' +
|
||||
'Then try again.',
|
||||
{ cause: ascError }
|
||||
);
|
||||
}
|
||||
return new AscApiRequestError(
|
||||
`Unexpected response (${response.status}) from App Store Connect: ${JSON.stringify(ascError)}`,
|
||||
response.status,
|
||||
ascError,
|
||||
{ cause: response }
|
||||
);
|
||||
});
|
||||
if (errors.length === 1) {
|
||||
throw errors[0];
|
||||
}
|
||||
throw new AggregateError(
|
||||
errors,
|
||||
`App Store Connect returned multiple errors:\n${errors.map(error => error.message).join('\n')}`,
|
||||
{ cause: response }
|
||||
);
|
||||
}
|
||||
@@ -421,6 +650,9 @@ export class AscApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return responseSchema.parse(undefined);
|
||||
}
|
||||
const text = await response.text();
|
||||
const parsedJson = await asyncResult((async () => JSON.parse(text))());
|
||||
if (!parsedJson.ok) {
|
||||
|
||||
@@ -1,14 +1,144 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import fs from 'fs-extra';
|
||||
import * as jose from 'jose';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isConnectionInterruptedError } from '../../../utils/networkErrors';
|
||||
import { promiseRetryWithCondition } from '../../../utils/promiseRetryWithCondition';
|
||||
|
||||
import {
|
||||
AscApiClient,
|
||||
AscApiClientGetApi,
|
||||
AscApiClientPostApi,
|
||||
AscApiKey,
|
||||
AscApiRequestError,
|
||||
AscPlatform,
|
||||
} from './AscApiClient';
|
||||
|
||||
export namespace AscApiUtils {
|
||||
export async function commitBuildUploadFileAsync({
|
||||
client,
|
||||
fileId,
|
||||
logger,
|
||||
}: {
|
||||
client: AscApiClient;
|
||||
fileId: string;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await promiseRetryWithCondition(
|
||||
async () => {
|
||||
const { data } = await client.getAsync(
|
||||
'/v1/buildUploadFiles/:id',
|
||||
{ 'fields[buildUploadFiles]': ['assetDeliveryState'] },
|
||||
{ id: fileId }
|
||||
);
|
||||
if (['COMPLETE', 'FAILED'].includes(data.attributes.assetDeliveryState.state)) {
|
||||
return;
|
||||
}
|
||||
await client.patchAsync(
|
||||
'/v1/buildUploadFiles/:id',
|
||||
{ data: { type: 'buildUploadFiles', id: fileId, attributes: { uploaded: true } } },
|
||||
{ id: fileId }
|
||||
);
|
||||
},
|
||||
error => isConnectionInterruptedError(error),
|
||||
{ retries: 3, factor: 1, minTimeout: 2000 },
|
||||
({ attemptNumber, maxAttemptsCount, error }) =>
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
`Checking upload file before commit attempt ${attemptNumber}/${maxAttemptsCount}.`
|
||||
)
|
||||
)();
|
||||
} catch (error) {
|
||||
if (!isConnectionInterruptedError(error)) {
|
||||
throw error;
|
||||
}
|
||||
// Apple may have accepted the final commit even though its response was lost.
|
||||
try {
|
||||
const { data } = await client.getAsync(
|
||||
'/v1/buildUploadFiles/:id',
|
||||
{ 'fields[buildUploadFiles]': ['assetDeliveryState'] },
|
||||
{ id: fileId }
|
||||
);
|
||||
if (data.attributes.assetDeliveryState.state === 'COMPLETE') {
|
||||
logger.info(`Upload file ${fileId}: COMPLETE confirmed after the final commit error.`);
|
||||
return;
|
||||
}
|
||||
} catch (readError) {
|
||||
logger.warn(
|
||||
{ err: readError },
|
||||
`Could not check upload file ${fileId} after the final commit error.`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllBetaBuildLocalizationsAsync({
|
||||
client,
|
||||
buildId,
|
||||
}: {
|
||||
client: AscApiClient;
|
||||
buildId: string;
|
||||
}) {
|
||||
let response = await client.getAsync(
|
||||
'/v1/builds/:id/betaBuildLocalizations',
|
||||
{ limit: 200 },
|
||||
{ id: buildId }
|
||||
);
|
||||
const localizations = [...response.data];
|
||||
for (let page = 1; response.links?.next; page++) {
|
||||
if (page === 20) {
|
||||
throw new SystemError(
|
||||
'We only support TestFlight localization lists with up to 20 pages (4,000 localizations). Contact Expo support if you need a larger localization list.'
|
||||
);
|
||||
}
|
||||
response = await client.getNextPageAsync(
|
||||
'/v1/builds/:id/betaBuildLocalizations',
|
||||
response.links.next
|
||||
);
|
||||
localizations.push(...response.data);
|
||||
}
|
||||
return localizations;
|
||||
}
|
||||
|
||||
export async function getAllBetaGroupsAsync({
|
||||
client,
|
||||
appId,
|
||||
buildId,
|
||||
}: {
|
||||
client: AscApiClient;
|
||||
} & ({ appId: string; buildId?: never } | { appId?: never; buildId: string })) {
|
||||
let response = await client.getAsync('/v1/betaGroups', {
|
||||
...(buildId !== undefined ? { 'filter[builds]': buildId } : { 'filter[app]': appId }),
|
||||
limit: 200,
|
||||
});
|
||||
const groups = [...response.data];
|
||||
for (let page = 1; response.links?.next; page++) {
|
||||
if (page === 20) {
|
||||
throw new SystemError(
|
||||
'We only support TestFlight group lists with up to 20 pages (4,000 groups). Contact Expo support if you need a larger group list.'
|
||||
);
|
||||
}
|
||||
response = await client.getNextPageAsync('/v1/betaGroups', response.links.next);
|
||||
groups.push(...response.data);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export async function loadApiKeyAsync({ keyPath }: { keyPath: string }): Promise<AscApiKey> {
|
||||
const keyJson = z
|
||||
.object({ issuer_id: z.string().nullish(), key_id: z.string(), key: z.string() })
|
||||
.parse(await fs.readJson(keyPath));
|
||||
return {
|
||||
keyId: keyJson.key_id,
|
||||
issuerId: keyJson.issuer_id,
|
||||
privateKey: await jose.importPKCS8(keyJson.key, 'ES256'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a bundle's `DTPlatformName` (from its Info.plist) to the App Store
|
||||
* Connect platform used for a build upload. Unknown or missing values fall
|
||||
@@ -57,12 +187,13 @@ export namespace AscApiUtils {
|
||||
{ id: appleAppIdentifier }
|
||||
);
|
||||
} catch (error) {
|
||||
const notFoundErrors =
|
||||
error instanceof AscApiRequestError && error.status === 404
|
||||
? error.responseJson.errors
|
||||
: [];
|
||||
const errors = error instanceof AggregateError ? error.errors : [error];
|
||||
const isAppNotFoundError =
|
||||
notFoundErrors.length > 0 && notFoundErrors.every(item => item.code === 'NOT_FOUND');
|
||||
errors.length > 0 &&
|
||||
errors.every(
|
||||
item =>
|
||||
item instanceof AscApiRequestError && item.status === 404 && item.code === 'NOT_FOUND'
|
||||
);
|
||||
if (!isAppNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
@@ -124,13 +255,15 @@ export namespace AscApiUtils {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const errors =
|
||||
error instanceof AscApiRequestError && error.status === 409
|
||||
? error.responseJson.errors
|
||||
: [];
|
||||
const errors = error instanceof AggregateError ? error.errors : [error];
|
||||
const isDuplicateVersionError =
|
||||
errors.length > 0 &&
|
||||
errors.every(item => item.code === 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE');
|
||||
errors.every(
|
||||
item =>
|
||||
item instanceof AscApiRequestError &&
|
||||
item.status === 409 &&
|
||||
item.code === 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE'
|
||||
);
|
||||
|
||||
if (isDuplicateVersionError) {
|
||||
throw new UserError(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import * as jose from 'jose';
|
||||
import nock from 'nock';
|
||||
|
||||
import { AscApiClient, AscApiRequestError } from '../AscApiClient';
|
||||
@@ -6,21 +8,83 @@ import { AscApiClient, AscApiRequestError } from '../AscApiClient';
|
||||
jest.unmock('node-fetch');
|
||||
|
||||
describe(AscApiClient, () => {
|
||||
const token = 'test-token';
|
||||
const client = new AscApiClient({ token });
|
||||
let signingKey: jose.KeyLike;
|
||||
let client: AscApiClient;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
const { privateKey } = await jose.generateKeyPair('ES256');
|
||||
signingKey = privateKey;
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
client = new AscApiClient({ key: { keyId: 'TESTKEY', privateKey: signingKey } });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.enableNetConnect();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it.each([undefined, 'test-issuer'])(
|
||||
'reuses tokens and refreshes before expiry (issuer: %s)',
|
||||
async issuerId => {
|
||||
const { privateKey, publicKey } = await jose.generateKeyPair('ES256');
|
||||
const startTime = Date.now();
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'setTimeout', 'setInterval'] });
|
||||
jest.setSystemTime(startTime);
|
||||
const refreshingClient = new AscApiClient({
|
||||
key: { keyId: 'TESTKEY', issuerId, privateKey },
|
||||
});
|
||||
const responseFixture = require('./fixtures/buildUploads/get-buildUploads-200.json');
|
||||
const tokens: string[] = [];
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.get('/v1/buildUploads/upload-id')
|
||||
.query(true)
|
||||
.times(5)
|
||||
.reply(function () {
|
||||
tokens.push(String(this.req.headers.authorization).replace(/^Bearer /, ''));
|
||||
return [200, responseFixture];
|
||||
});
|
||||
|
||||
const requestAsync = async (): Promise<unknown> =>
|
||||
await refreshingClient.getAsync(
|
||||
'/v1/buildUploads/:id',
|
||||
{ 'fields[buildUploads]': ['build', 'state'], include: ['build'] },
|
||||
{ id: 'upload-id' }
|
||||
);
|
||||
await Promise.all([requestAsync(), requestAsync()]);
|
||||
jest.setSystemTime(startTime + 18 * 60 * 1000);
|
||||
await requestAsync();
|
||||
expect(new Set(tokens).size).toBe(1);
|
||||
|
||||
jest.setSystemTime(startTime + 19 * 60 * 1000);
|
||||
await requestAsync();
|
||||
expect(tokens[3]).not.toBe(tokens[0]);
|
||||
jest.setSystemTime(startTime + 21 * 60 * 1000);
|
||||
await requestAsync();
|
||||
expect(tokens[4]).toBe(tokens[3]);
|
||||
|
||||
const verificationOptions = {
|
||||
currentDate: new Date(Date.now()),
|
||||
audience: 'appstoreconnect-v1',
|
||||
};
|
||||
await expect(jose.jwtVerify(tokens[0], publicKey, verificationOptions)).rejects.toThrow(
|
||||
'"exp" claim timestamp check failed'
|
||||
);
|
||||
await expect(
|
||||
jose.jwtVerify(tokens[4], publicKey, verificationOptions)
|
||||
).resolves.toMatchObject({
|
||||
payload: issuerId ? { iss: issuerId } : { sub: 'user' },
|
||||
});
|
||||
expect(scope.isDone()).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it('fetches app info', async () => {
|
||||
const appId = '1491144534';
|
||||
const responseFixture = require('./fixtures/apps/get-apps-200.json');
|
||||
@@ -49,6 +113,45 @@ describe(AscApiClient, () => {
|
||||
expect(scope.isDone()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('accepts null or missing hasAccessToAllBuilds for TestFlight groups', async () => {
|
||||
nock('https://api.appstoreconnect.apple.com')
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, {
|
||||
data: [
|
||||
{
|
||||
type: 'betaGroups',
|
||||
id: 'external',
|
||||
attributes: {
|
||||
name: 'test external',
|
||||
isInternalGroup: false,
|
||||
hasAccessToAllBuilds: null,
|
||||
publicLinkEnabled: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'betaGroups',
|
||||
id: 'internal',
|
||||
attributes: { name: 'Internal', isInternalGroup: true },
|
||||
},
|
||||
],
|
||||
links: { self: 'https://api.appstoreconnect.apple.com/v1/betaGroups' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.getAsync('/v1/betaGroups', { 'filter[app]': 'app', limit: 200 })
|
||||
).resolves.toEqual({
|
||||
data: [
|
||||
{
|
||||
id: 'external',
|
||||
attributes: { name: 'test external', isInternalGroup: false, hasAccessToAllBuilds: null },
|
||||
},
|
||||
{ id: 'internal', attributes: { name: 'Internal', isInternalGroup: true } },
|
||||
],
|
||||
links: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('creates build upload', async () => {
|
||||
const buildUploadId = 'fdf9c476-aaa4-4ead-b91c-6e3cc3a47805';
|
||||
const responseFixture = require('./fixtures/buildUploads/post-buildUploads-200.json');
|
||||
@@ -102,6 +205,13 @@ describe(AscApiClient, () => {
|
||||
expect(scope.isDone()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('requires path parameters for a POST route that has them', async () => {
|
||||
await expect(
|
||||
// @ts-expect-error The build ID is required by this route.
|
||||
client.postAsync('/v1/builds/:id/relationships/betaGroups', { data: [] })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('creates build upload file', async () => {
|
||||
const buildUploadId = 'fdf9c476-aaa4-4ead-b91c-6e3cc3a47805';
|
||||
const fileId = '5b110930-f947-4998-a129-5926ffcedde5';
|
||||
@@ -245,6 +355,7 @@ describe(AscApiClient, () => {
|
||||
attributes: {
|
||||
state: responseFixture.data.attributes.state,
|
||||
},
|
||||
relationships: { build: { data: null } },
|
||||
},
|
||||
});
|
||||
expect(scope.isDone()).toBeTruthy();
|
||||
@@ -281,6 +392,25 @@ describe(AscApiClient, () => {
|
||||
expect(scope.isDone()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('explains how to resolve a missing agreement', async () => {
|
||||
nock('https://api.appstoreconnect.apple.com')
|
||||
.get('/v1/apps/6817395749')
|
||||
.query({ 'fields[apps]': 'bundleId,name' })
|
||||
.reply(403, {
|
||||
errors: [{ code: 'FORBIDDEN.REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED' }],
|
||||
});
|
||||
|
||||
const request = client.getAsync(
|
||||
'/v1/apps/:id',
|
||||
{ 'fields[apps]': ['bundleId', 'name'] },
|
||||
{ id: '6817395749' }
|
||||
);
|
||||
await expect(request).rejects.toBeInstanceOf(UserError);
|
||||
await expect(request).rejects.toThrow(
|
||||
/Account Holder.*https:\/\/appstoreconnect.apple.com\/business/
|
||||
);
|
||||
});
|
||||
|
||||
it('throws AscApiRequestError for structured ASC error payload', async () => {
|
||||
const appId = '1491144534';
|
||||
const responseFixture = {
|
||||
@@ -291,6 +421,7 @@ describe(AscApiClient, () => {
|
||||
title:
|
||||
'The provided entity includes an attribute with a value that has already been used',
|
||||
detail: 'The bundle version must be higher than the previously uploaded version.',
|
||||
links: { see: '/business' },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -302,20 +433,50 @@ describe(AscApiClient, () => {
|
||||
|
||||
await expect(
|
||||
client.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: appId })
|
||||
).rejects.toBeInstanceOf(AscApiRequestError);
|
||||
).rejects.toMatchObject({
|
||||
message: `Unexpected response (409) from App Store Connect: ${JSON.stringify(responseFixture.errors[0])}`,
|
||||
status: 409,
|
||||
code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE',
|
||||
responseJson: responseFixture.errors[0],
|
||||
});
|
||||
});
|
||||
|
||||
it('aggregates agreement and generic errors without losing either', async () => {
|
||||
const forbidden = { code: 'FORBIDDEN', detail: 'Access denied.' };
|
||||
nock('https://api.appstoreconnect.apple.com')
|
||||
.get('/v1/apps/6817395749')
|
||||
.query({ 'fields[apps]': 'bundleId,name' })
|
||||
.reply(403, {
|
||||
errors: [{ code: 'FORBIDDEN.REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED' }, forbidden],
|
||||
});
|
||||
|
||||
const request = client.getAsync(
|
||||
'/v1/apps/:id',
|
||||
{ 'fields[apps]': ['bundleId', 'name'] },
|
||||
{ id: '6817395749' }
|
||||
);
|
||||
await expect(request).rejects.toBeInstanceOf(AggregateError);
|
||||
await expect(request).rejects.toHaveProperty('errors', [
|
||||
expect.any(UserError),
|
||||
expect.any(AscApiRequestError),
|
||||
]);
|
||||
await expect(request).rejects.toThrow(/Account Holder/);
|
||||
await expect(request).rejects.toThrow(/Access denied/);
|
||||
await expect(request).rejects.toHaveProperty('errors.1.responseJson', forbidden);
|
||||
});
|
||||
|
||||
it('throws regular Error for non-structured ASC error payload', async () => {
|
||||
const appId = '1491144534';
|
||||
|
||||
nock('https://api.appstoreconnect.apple.com')
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.get(`/v1/apps/${appId}`)
|
||||
.query({ 'fields[apps]': 'bundleId,name' })
|
||||
.reply(503, 'Service unavailable');
|
||||
|
||||
await expect(
|
||||
client.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: appId })
|
||||
).rejects.not.toBeInstanceOf(AscApiRequestError);
|
||||
).rejects.toThrow('Unexpected response (503)');
|
||||
expect(scope.isDone()).toBe(true);
|
||||
});
|
||||
|
||||
it('throws controlled error when success response body is not valid JSON', async () => {
|
||||
@@ -330,4 +491,108 @@ describe(AscApiClient, () => {
|
||||
client.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: appId })
|
||||
).rejects.toThrow('Malformed JSON response from App Store Connect (200): not-json');
|
||||
});
|
||||
|
||||
it.each(['ECONNRESET', 'EAI_AGAIN'])('recovers a status read after %s', async code => {
|
||||
const fixture = require('./fixtures/buildUploadFiles/get-buildUploadFiles-200.json');
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.get('/v1/buildUploadFiles/file')
|
||||
.query(true)
|
||||
.replyWithError({ code, message: 'Temporary network failure' })
|
||||
.get('/v1/buildUploadFiles/file')
|
||||
.query(true)
|
||||
.reply(200, fixture);
|
||||
await expect(
|
||||
client.getAsync(
|
||||
'/v1/buildUploadFiles/:id',
|
||||
{ 'fields[buildUploadFiles]': ['assetDeliveryState'] },
|
||||
{ id: 'file' }
|
||||
)
|
||||
).resolves.toMatchObject({ data: { id: fixture.data.id } });
|
||||
expect(scope.isDone()).toBe(true);
|
||||
});
|
||||
|
||||
it.each([401, 422, 429, 503])('does not retry permanent HTTP %s errors', async status => {
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.get('/v1/apps/app')
|
||||
.query(true)
|
||||
.reply(status, { errors: [{ status: String(status) }] });
|
||||
await expect(
|
||||
client.getAsync(
|
||||
'/v1/apps/:id',
|
||||
{
|
||||
'fields[apps]': ['bundleId', 'name'],
|
||||
},
|
||||
{ id: 'app' }
|
||||
)
|
||||
).rejects.toBeInstanceOf(AscApiRequestError);
|
||||
expect(scope.isDone()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not retry creating a localization after a lost response', async () => {
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.post('/v1/betaBuildLocalizations')
|
||||
.replyWithError({ code: 'ECONNRESET', message: 'Lost response' });
|
||||
await expect(
|
||||
client.postAsync('/v1/betaBuildLocalizations', {
|
||||
data: {
|
||||
type: 'betaBuildLocalizations',
|
||||
attributes: { locale: 'en-US', whatsNew: 'Hello' },
|
||||
relationships: { build: { data: { type: 'builds', id: 'build' } } },
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'ECONNRESET' });
|
||||
expect(scope.isDone()).toBe(true);
|
||||
});
|
||||
|
||||
it('retries connection establishment failure before a creation request', async () => {
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.post('/v1/betaBuildLocalizations')
|
||||
.replyWithError({ code: 'EAI_AGAIN', message: 'DNS failure' })
|
||||
.post('/v1/betaBuildLocalizations')
|
||||
.reply(201, { data: { id: 'localization' } });
|
||||
await expect(
|
||||
client.postAsync('/v1/betaBuildLocalizations', {
|
||||
data: {
|
||||
type: 'betaBuildLocalizations',
|
||||
attributes: { locale: 'en-US', whatsNew: 'Hello' },
|
||||
relationships: { build: { data: { type: 'builds', id: 'build' } } },
|
||||
},
|
||||
})
|
||||
).resolves.toEqual({ data: { id: 'localization' } });
|
||||
expect(scope.isDone()).toBe(true);
|
||||
});
|
||||
|
||||
it('stops after three retries of an interrupted read', async () => {
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.get('/v1/apps/app')
|
||||
.query(true)
|
||||
.times(4)
|
||||
.replyWithError({ code: 'ECONNRESET', message: 'Connection reset' });
|
||||
await expect(
|
||||
client.getAsync(
|
||||
'/v1/apps/:id',
|
||||
{
|
||||
'fields[apps]': ['bundleId', 'name'],
|
||||
},
|
||||
{ id: 'app' }
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'ECONNRESET' });
|
||||
expect(scope.isDone()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not repeat an interrupted changelog update', async () => {
|
||||
const body = {
|
||||
data: { type: 'betaBuildLocalizations', id: 'locale', attributes: { whatsNew: 'Hello' } },
|
||||
} as const;
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.patch('/v1/betaBuildLocalizations/locale', body)
|
||||
.replyWithError({ code: 'ECONNRESET', message: 'Response lost' })
|
||||
.patch('/v1/betaBuildLocalizations/locale', body)
|
||||
.optionally()
|
||||
.reply(200, { data: { id: 'locale' } });
|
||||
await expect(
|
||||
client.patchAsync('/v1/betaBuildLocalizations/:id', body, { id: 'locale' })
|
||||
).rejects.toMatchObject({ code: 'ECONNRESET' });
|
||||
expect(scope.isDone()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,55 @@
|
||||
import { AscApiRequestError } from '../AscApiClient';
|
||||
import { createLogger } from '@expo/logger';
|
||||
import fs from 'fs-extra';
|
||||
import * as jose from 'jose';
|
||||
import nock from 'nock';
|
||||
|
||||
import { AscApiClient, AscApiRequestError } from '../AscApiClient';
|
||||
import { AscApiUtils } from '../AscApiUtils';
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
|
||||
describe('AscApiUtils', () => {
|
||||
describe('loadApiKeyAsync', () => {
|
||||
beforeAll(() => nock.disableNetConnect());
|
||||
afterAll(() => nock.enableNetConnect());
|
||||
afterEach(() => nock.cleanAll());
|
||||
|
||||
it.each([undefined, 'test-issuer'])(
|
||||
'loads a key that authenticates ASC requests (issuer: %s)',
|
||||
async issuerId => {
|
||||
const { privateKey, publicKey } = await jose.generateKeyPair('ES256');
|
||||
const keyPath = '/asc-api-key.json';
|
||||
await fs.writeJson(keyPath, {
|
||||
key_id: 'TESTKEY',
|
||||
issuer_id: issuerId,
|
||||
key: await jose.exportPKCS8(privateKey),
|
||||
});
|
||||
const client = new AscApiClient({ key: await AscApiUtils.loadApiKeyAsync({ keyPath }) });
|
||||
let token = '';
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.get('/v1/apps/app')
|
||||
.query(true)
|
||||
.reply(function () {
|
||||
token = String(this.req.headers.authorization).replace(/^Bearer /, '');
|
||||
return [200, require('./fixtures/apps/get-apps-200.json')];
|
||||
});
|
||||
|
||||
await client.getAsync(
|
||||
'/v1/apps/:id',
|
||||
{ 'fields[apps]': ['bundleId', 'name'] },
|
||||
{ id: 'app' }
|
||||
);
|
||||
await expect(
|
||||
jose.jwtVerify(token, publicKey, { audience: 'appstoreconnect-v1' })
|
||||
).resolves.toMatchObject({
|
||||
protectedHeader: { kid: 'TESTKEY', alg: 'ES256' },
|
||||
payload: issuerId ? { iss: issuerId } : { sub: 'user' },
|
||||
});
|
||||
expect(scope.isDone()).toBe(true);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('getAppInfoAsync', () => {
|
||||
it('returns app info when lookup succeeds', async () => {
|
||||
const response = {
|
||||
@@ -24,48 +72,53 @@ describe('AscApiUtils', () => {
|
||||
).resolves.toEqual(response);
|
||||
});
|
||||
|
||||
it('throws UserError with visible apps when app id is not found', async () => {
|
||||
const notFoundPayload = {
|
||||
errors: [
|
||||
{
|
||||
status: '404',
|
||||
code: 'NOT_FOUND',
|
||||
detail: "There is no resource of type 'apps' with id '1234567890'",
|
||||
},
|
||||
],
|
||||
};
|
||||
const notFoundError = new AscApiRequestError(
|
||||
'Unexpected response (404) from App Store Connect',
|
||||
404,
|
||||
notFoundPayload
|
||||
);
|
||||
const client = {
|
||||
getAsync: jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(notFoundError)
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
type: 'apps',
|
||||
id: '1111111111',
|
||||
attributes: { name: 'Visible App', bundleId: 'com.visible.app' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
it.each([false, true])(
|
||||
'throws UserError with visible apps (aggregate: %s)',
|
||||
async aggregate => {
|
||||
const notFoundPayload = {
|
||||
errors: [
|
||||
{
|
||||
status: '404',
|
||||
code: 'NOT_FOUND',
|
||||
detail: "There is no resource of type 'apps' with id '1234567890'",
|
||||
},
|
||||
],
|
||||
};
|
||||
const notFoundError = new AscApiRequestError(
|
||||
'Unexpected response (404) from App Store Connect',
|
||||
404,
|
||||
notFoundPayload.errors[0]
|
||||
);
|
||||
const client = {
|
||||
getAsync: jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
aggregate ? new AggregateError([notFoundError, notFoundError]) : notFoundError
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
type: 'apps',
|
||||
id: '1111111111',
|
||||
attributes: { name: 'Visible App', bundleId: 'com.visible.app' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(
|
||||
AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier: '1234567890' })
|
||||
).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
errorCode: 'EAS_UPLOAD_TO_ASC_APP_NOT_FOUND',
|
||||
docsUrl: 'https://expo.fyi/asc-app-id',
|
||||
message: expect.stringMatching(
|
||||
/App Store Connect app for application identifier 1234567890 was not found[\s\S]*- Visible App \(com\.visible\.app\) \(ID: 1111111111\)/
|
||||
),
|
||||
})
|
||||
);
|
||||
});
|
||||
await expect(
|
||||
AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier: '1234567890' })
|
||||
).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
errorCode: 'EAS_UPLOAD_TO_ASC_APP_NOT_FOUND',
|
||||
docsUrl: 'https://expo.fyi/asc-app-id',
|
||||
message: expect.stringMatching(
|
||||
/App Store Connect app for application identifier 1234567890 was not found[\s\S]*- Visible App \(com\.visible\.app\) \(ID: 1111111111\)/
|
||||
),
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('rethrows original not-found error when app-list lookup fails', async () => {
|
||||
const notFoundPayload = {
|
||||
@@ -79,7 +132,7 @@ describe('AscApiUtils', () => {
|
||||
const notFoundError = new AscApiRequestError(
|
||||
'Unexpected response (404) from App Store Connect',
|
||||
404,
|
||||
notFoundPayload
|
||||
notFoundPayload.errors[0]
|
||||
);
|
||||
|
||||
const listingError = new Error('listing failed');
|
||||
@@ -122,7 +175,7 @@ describe('AscApiUtils', () => {
|
||||
});
|
||||
|
||||
describe('createBuildUploadAsync', () => {
|
||||
it('throws UserError when ASC duplicate version error is returned', async () => {
|
||||
it.each([false, true])('explains duplicate versions (aggregate: %s)', async aggregate => {
|
||||
const payload = {
|
||||
errors: [
|
||||
{
|
||||
@@ -135,11 +188,15 @@ describe('AscApiUtils', () => {
|
||||
const duplicateError = new AscApiRequestError(
|
||||
'Unexpected response (409) from App Store Connect',
|
||||
409,
|
||||
payload
|
||||
payload.errors[0]
|
||||
);
|
||||
|
||||
const client = {
|
||||
postAsync: jest.fn().mockRejectedValue(duplicateError),
|
||||
postAsync: jest
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
aggregate ? new AggregateError([duplicateError, duplicateError]) : duplicateError
|
||||
),
|
||||
};
|
||||
|
||||
await expect(
|
||||
@@ -211,7 +268,7 @@ describe('AscApiUtils', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('rethrows when error payload includes mixed error codes', async () => {
|
||||
it.each([false, true])('rethrows unrelated errors (aggregate: %s)', async aggregate => {
|
||||
const payload = {
|
||||
errors: [
|
||||
{
|
||||
@@ -224,11 +281,8 @@ describe('AscApiUtils', () => {
|
||||
},
|
||||
],
|
||||
};
|
||||
const mixedError = new AscApiRequestError(
|
||||
'Unexpected response (409) from App Store Connect',
|
||||
409,
|
||||
payload
|
||||
);
|
||||
const errors = payload.errors.map(error => new AscApiRequestError('API error', 409, error));
|
||||
const mixedError = aggregate ? new AggregateError(errors) : errors[1];
|
||||
const client = {
|
||||
postAsync: jest.fn().mockRejectedValue(mixedError),
|
||||
};
|
||||
@@ -268,3 +322,112 @@ describe('AscApiUtils', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('commitBuildUploadFileAsync', () => {
|
||||
const logger = createLogger({ name: 'test' });
|
||||
const fileResponse = (state: string) => ({
|
||||
data: {
|
||||
type: 'buildUploadFiles',
|
||||
id: 'file',
|
||||
attributes: { assetDeliveryState: { state } },
|
||||
},
|
||||
});
|
||||
let client: AscApiClient;
|
||||
beforeAll(async () => {
|
||||
const { privateKey } = await jose.generateKeyPair('ES256');
|
||||
client = new AscApiClient({ key: { keyId: 'TESTKEY', privateKey } });
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
afterAll(() => nock.enableNetConnect());
|
||||
afterEach(() => {
|
||||
try {
|
||||
expect(nock.pendingMocks()).toEqual([]);
|
||||
} finally {
|
||||
nock.cleanAll();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['COMPLETE', 'FAILED'])('does not commit a terminal file: %s', async state => {
|
||||
nock('https://api.appstoreconnect.apple.com')
|
||||
.get('/v1/buildUploadFiles/file')
|
||||
.query(true)
|
||||
.reply(200, fileResponse(state));
|
||||
await expect(
|
||||
AscApiUtils.commitBuildUploadFileAsync({ client, fileId: 'file', logger })
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(['COMPLETE', 'AWAITING_UPLOAD'])(
|
||||
'reads the file before retrying an interrupted commit: %s',
|
||||
async state => {
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.get('/v1/buildUploadFiles/file')
|
||||
.query(true)
|
||||
.reply(200, fileResponse('AWAITING_UPLOAD'))
|
||||
.patch('/v1/buildUploadFiles/file')
|
||||
.replyWithError({ code: 'ECONNRESET', message: 'Lost response' })
|
||||
.get('/v1/buildUploadFiles/file')
|
||||
.query(true)
|
||||
.reply(200, fileResponse(state));
|
||||
if (state === 'AWAITING_UPLOAD') {
|
||||
scope.patch('/v1/buildUploadFiles/file').reply(200, fileResponse('COMPLETE'));
|
||||
}
|
||||
await expect(
|
||||
AscApiUtils.commitBuildUploadFileAsync({ client, fileId: 'file', logger })
|
||||
).resolves.toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
[401, 'NOT_AUTHORIZED'],
|
||||
[403, 'FORBIDDEN'],
|
||||
[409, 'STATE_ERROR.INVALID_STATE'],
|
||||
])('preserves Apple errors without retrying: %s %s', async (status, code) => {
|
||||
nock('https://api.appstoreconnect.apple.com')
|
||||
.get('/v1/buildUploadFiles/file')
|
||||
.query(true)
|
||||
.reply(200, fileResponse('UPLOAD_COMPLETE'))
|
||||
.patch('/v1/buildUploadFiles/file')
|
||||
.reply(status, { errors: [{ code }] });
|
||||
await expect(
|
||||
AscApiUtils.commitBuildUploadFileAsync({ client, fileId: 'file', logger })
|
||||
).rejects.toMatchObject({ status, code });
|
||||
});
|
||||
|
||||
it('does not commit when the initial state read fails', async () => {
|
||||
nock('https://api.appstoreconnect.apple.com')
|
||||
.get('/v1/buildUploadFiles/file')
|
||||
.query(true)
|
||||
.reply(503, 'Unavailable');
|
||||
await expect(
|
||||
AscApiUtils.commitBuildUploadFileAsync({ client, fileId: 'file', logger })
|
||||
).rejects.toThrow('503');
|
||||
});
|
||||
|
||||
it.each(['COMPLETE', 'AWAITING_UPLOAD', 'UPLOAD_COMPLETE', 'FAILED', 'read-error'])(
|
||||
'checks the file after the final interrupted commit: %s',
|
||||
async finalState => {
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.get('/v1/buildUploadFiles/file')
|
||||
.query(true)
|
||||
.times(4)
|
||||
.reply(200, fileResponse('AWAITING_UPLOAD'))
|
||||
.patch('/v1/buildUploadFiles/file')
|
||||
.times(4)
|
||||
.replyWithError({ code: 'ECONNRESET', message: 'Lost response' });
|
||||
const finalRead = scope.get('/v1/buildUploadFiles/file').query(true);
|
||||
if (finalState === 'read-error') {
|
||||
finalRead.reply(503, 'Unavailable');
|
||||
} else {
|
||||
finalRead.reply(200, fileResponse(finalState));
|
||||
}
|
||||
const result = AscApiUtils.commitBuildUploadFileAsync({ client, fileId: 'file', logger });
|
||||
if (finalState === 'COMPLETE') {
|
||||
await expect(result).resolves.toBeUndefined();
|
||||
} else {
|
||||
await expect(result).rejects.toMatchObject({ code: 'ECONNRESET' });
|
||||
}
|
||||
},
|
||||
15_000
|
||||
);
|
||||
});
|
||||
@@ -56,6 +56,8 @@ const EGRESS_MONITOR_INTERVAL_MS = 2_000;
|
||||
const EGRESS_ESCAPE_SCAN_INTERVAL_MS = 5_000;
|
||||
const EGRESS_ESCAPE_LOG_LIMIT = 50;
|
||||
|
||||
export type LocalEgressPlatform = 'ios' | 'android';
|
||||
|
||||
export type LocalEgressHandoff = {
|
||||
/** Public URL of the reverse tunnel server, reachable through ngrok. */
|
||||
url: string;
|
||||
@@ -65,6 +67,8 @@ export type LocalEgressHandoff = {
|
||||
fingerprint: string;
|
||||
/** Loopback port on this host that the client must serve. */
|
||||
port: number;
|
||||
/** Missing means `ios`. */
|
||||
platform?: LocalEgressPlatform;
|
||||
};
|
||||
|
||||
export function getChiselAssetName({
|
||||
@@ -448,7 +452,8 @@ export async function readLocalEgressHandoffAsync(
|
||||
typeof parsed.url !== 'string' ||
|
||||
typeof parsed.token !== 'string' ||
|
||||
typeof parsed.fingerprint !== 'string' ||
|
||||
typeof parsed.port !== 'number'
|
||||
typeof parsed.port !== 'number' ||
|
||||
(parsed.platform !== undefined && parsed.platform !== 'ios' && parsed.platform !== 'android')
|
||||
) {
|
||||
throw new SystemError(`Local egress handoff at ${handoffPath} is malformed.`);
|
||||
}
|
||||
@@ -457,6 +462,7 @@ export async function readLocalEgressHandoffAsync(
|
||||
token: parsed.token,
|
||||
fingerprint: parsed.fingerprint,
|
||||
port: parsed.port,
|
||||
...(parsed.platform ? { platform: parsed.platform } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -729,15 +735,18 @@ export async function monitorLocalEgressAsync({
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
scanSimulatorConnections = true,
|
||||
}: {
|
||||
port: number;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal: AbortSignal;
|
||||
/** The scan follows launchd_sim descendants, so it only applies to iOS Simulators. */
|
||||
scanSimulatorConnections?: boolean;
|
||||
}): Promise<void> {
|
||||
let connected = false;
|
||||
let lastEscapeScanAt = 0;
|
||||
let escapeScanBroken = false;
|
||||
let escapeScanBroken = !scanSimulatorConnections;
|
||||
const reportedEscapes = new Set<string>();
|
||||
const lifetimeSignal = activeLocalEgressResources?.controller.signal;
|
||||
try {
|
||||
|
||||
@@ -54,12 +54,14 @@ export function buildGuardLaunchdEnvironment({
|
||||
|
||||
export type GuardEvent = {
|
||||
process: string;
|
||||
pid: number;
|
||||
/** Null when the event source cannot attribute the attempt to a process id. */
|
||||
pid: number | null;
|
||||
function: string;
|
||||
action: 'blocked' | 'logged';
|
||||
peer: string;
|
||||
/** Image names above the interposer, innermost first. */
|
||||
callers: string[];
|
||||
note?: string;
|
||||
};
|
||||
|
||||
/** One tab-separated line written by the guard; see policy.h for the format. */
|
||||
@@ -95,7 +97,6 @@ async function resolvePackagedFileAsync(binDir: string, file: string): Promise<s
|
||||
}
|
||||
}
|
||||
|
||||
/** The packaged library, next to the compiled package like record-sim. */
|
||||
export async function resolveEgressGuardLibraryAsync(
|
||||
binDir: string = PACKAGED_BIN_DIR
|
||||
): Promise<string | null> {
|
||||
@@ -168,9 +169,11 @@ export class GuardEventRelay {
|
||||
return;
|
||||
}
|
||||
const verb = event.action === 'blocked' ? 'refused' : 'observed';
|
||||
const pid = event.pid === null ? '' : ` (pid ${event.pid})`;
|
||||
const callers = event.callers.length ? `; callers: ${event.callers.join(', ')}` : '';
|
||||
const note = event.note ? ` (${event.note})` : '';
|
||||
this.logger.info(
|
||||
`Local egress guard: ${verb} ${event.function} from ${event.process} (pid ${event.pid}) to ${event.peer}${callers}`
|
||||
`Local egress guard: ${verb} ${event.function} from ${event.process}${pid} to ${event.peer}${note}${callers}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -314,9 +317,26 @@ export async function resolveLocalEgressBootEnvironmentAsync({
|
||||
};
|
||||
}
|
||||
|
||||
type ActiveRelay = { tailer: GuardLogTailer; relay: GuardEventRelay };
|
||||
type ActiveRelay = {
|
||||
source: { stopAsync: () => Promise<void> };
|
||||
relay: GuardEventRelay;
|
||||
onRebind?: (logger: bunyan) => void;
|
||||
afterSummaryAsync?: () => Promise<void>;
|
||||
};
|
||||
const activeRelays = new Map<string, ActiveRelay>();
|
||||
|
||||
/**
|
||||
* Report events from another source, such as the Android emulator's packet
|
||||
* fence, through the same session log lines, step rebinding and summary as the
|
||||
* Simulator guard.
|
||||
*/
|
||||
export function registerLocalEgressGuardRelay(key: string, entry: ActiveRelay): void {
|
||||
if (activeRelays.has(key)) {
|
||||
throw new SystemError(`A local egress guard relay is already registered for ${key}.`);
|
||||
}
|
||||
activeRelays.set(key, entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the guard into a simulator when a local egress session is active,
|
||||
* and start relaying its events into the session log. Returns false when
|
||||
@@ -416,7 +436,7 @@ export async function installLocalEgressGuardAsync({
|
||||
},
|
||||
});
|
||||
tailer.start();
|
||||
activeRelays.set(logPath, { tailer, relay });
|
||||
activeRelays.set(logPath, { source: tailer, relay });
|
||||
}
|
||||
|
||||
logger.info(
|
||||
@@ -676,8 +696,9 @@ export async function verifyLocalEgressGuardAsync({
|
||||
* finished.
|
||||
*/
|
||||
export function rebindLocalEgressGuardRelays(logger: bunyan): void {
|
||||
for (const { relay } of activeRelays.values()) {
|
||||
for (const { relay, onRebind } of activeRelays.values()) {
|
||||
relay.setLogger(logger);
|
||||
onRebind?.(logger);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -685,9 +706,9 @@ export function rebindLocalEgressGuardRelays(logger: bunyan): void {
|
||||
export async function stopLocalEgressGuardRelaysAsync(logger: bunyan): Promise<void> {
|
||||
const relays = [...activeRelays.values()];
|
||||
activeRelays.clear();
|
||||
for (const { tailer, relay } of relays) {
|
||||
for (const { source, relay, afterSummaryAsync } of relays) {
|
||||
try {
|
||||
await tailer.stopAsync();
|
||||
await source.stopAsync();
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
@@ -695,5 +716,8 @@ export async function stopLocalEgressGuardRelaysAsync(logger: bunyan): Promise<v
|
||||
);
|
||||
}
|
||||
relay.logSummary();
|
||||
await afterSummaryAsync?.().catch(err =>
|
||||
logger.warn({ err }, 'Local egress guard: could not report the final totals.')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ export async function uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
logger: options.logger,
|
||||
signal: signal ?? new AbortController().signal,
|
||||
...(localEgress.platform === 'android' ? { scanSimulatorConnections: false } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { BuildRuntimePlatform, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
|
||||
/** The inputs that turn on network capture for a simulator session. */
|
||||
export function createNetworkCaptureInputProviders(): ReturnType<
|
||||
typeof BuildStepInput.createProvider
|
||||
>[] {
|
||||
return [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'network_capture',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'network_capture_fields',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/** A JSON step input is whatever the workflow author wrote, so its shape has to be checked. */
|
||||
export function parseNetworkCaptureFieldsInput(value: unknown): string[] {
|
||||
if (value === undefined) {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(value) || !value.every(field => typeof field === 'string')) {
|
||||
throw new UserError(
|
||||
'EAS_NETWORK_CAPTURE_INVALID_INPUT',
|
||||
'Input "network_capture_fields" must be an array of strings.'
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
}: { networkCapture?: unknown; networkCaptureFields?: unknown },
|
||||
{ runtimePlatform }: { runtimePlatform: BuildRuntimePlatform }
|
||||
): { networkCapture: boolean; networkCaptureFields: string[] } {
|
||||
const fields = parseNetworkCaptureFieldsInput(networkCaptureFields);
|
||||
if (fields.length > 0 && networkCapture !== true) {
|
||||
throw new UserError(
|
||||
'EAS_NETWORK_CAPTURE_INVALID_INPUT',
|
||||
'Input "network_capture_fields" narrows what a recording keeps, so it needs "network_capture: true". Turn capture on, or drop "network_capture_fields".'
|
||||
);
|
||||
}
|
||||
if (networkCapture === true && runtimePlatform !== BuildRuntimePlatform.DARWIN) {
|
||||
throw new UserError(
|
||||
'EAS_NETWORK_CAPTURE_INVALID_INPUT',
|
||||
`Input "network_capture" records traffic through serve-sim on an iOS simulator, and this session runs on ${runtimePlatform}. Run the session on an iOS simulator, or drop "network_capture".`
|
||||
);
|
||||
}
|
||||
return { networkCapture: networkCapture === true, networkCaptureFields: fields };
|
||||
}
|
||||
@@ -26,26 +26,11 @@ import {
|
||||
parseOpenUrlInput,
|
||||
} from '../functions/launchApplication';
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageExec,
|
||||
} from '../../utils/packageManager';
|
||||
import { isProcessGroupRunning } from '../../utils/processes';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
const WEB_PREVIEW_HOST = '127.0.0.1';
|
||||
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
|
||||
const SERVE_SIM_MAX_DIMENSION = '1600';
|
||||
const SERVE_SIM_MJPEG_QUALITY = '0.55';
|
||||
const SERVE_SIM_VIDEO_BITRATE = '10000000';
|
||||
const SERVE_SIM_VIDEO_FPS = '60';
|
||||
const EXPO_DEVICE_HUB_PACKAGE_NAME = 'expo-device-hub';
|
||||
const EXPO_DEVICE_HUB_MAX_DIMENSION = '960';
|
||||
const EXPO_DEVICE_HUB_VIDEO_BITRATE = '6000000';
|
||||
const EXPO_DEVICE_HUB_VIDEO_FPS = '60';
|
||||
|
||||
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
|
||||
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
|
||||
@@ -547,10 +532,11 @@ export type DetachedProcessHandle = {
|
||||
/** PID of the directly spawned process, if the OS assigned one. */
|
||||
pid: number | undefined;
|
||||
getOutput: () => string;
|
||||
getExitError: () => Error | undefined;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
export function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
@@ -559,8 +545,11 @@ function isProcessRunning(pid: number): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
async function stopDetachedProcessAsync(pid: number | undefined): Promise<void> {
|
||||
if (pid === undefined || !isProcessRunning(pid)) {
|
||||
async function stopDetachedProcessAsync(
|
||||
pid: number | undefined,
|
||||
gracePeriodMs = 5_000
|
||||
): Promise<void> {
|
||||
if (pid === undefined || !isProcessGroupRunning(pid)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -575,11 +564,11 @@ async function stopDetachedProcessAsync(pid: number | undefined): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (Date.now() < deadline && isProcessRunning(pid)) {
|
||||
const deadline = Date.now() + gracePeriodMs;
|
||||
while (Date.now() < deadline && isProcessGroupRunning(pid)) {
|
||||
await sleepAsync(100);
|
||||
}
|
||||
if (!isProcessRunning(pid)) {
|
||||
if (!isProcessGroupRunning(pid)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -589,6 +578,43 @@ async function stopDetachedProcessAsync(pid: number | undefined): Promise<void>
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {}
|
||||
}
|
||||
// kill(pid, 0) succeeds on the zombie until libuv reaps it on a later loop turn.
|
||||
const killDeadline = Date.now() + 5_000;
|
||||
while (Date.now() < killDeadline && isProcessGroupRunning(pid)) {
|
||||
await sleepAsync(100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs every named teardown to completion and logs each failure. The first failure is rethrown
|
||||
* only when the session body succeeded, so a teardown error cannot replace the error that ended it.
|
||||
*/
|
||||
export async function finishRemoteSessionAsync({
|
||||
teardown,
|
||||
sessionFailed,
|
||||
logger,
|
||||
}: {
|
||||
teardown: [name: string, task: Promise<unknown> | undefined][];
|
||||
sessionFailed: boolean;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const results = await Promise.allSettled(teardown.map(([, task]) => task));
|
||||
const failures = results.flatMap((result, index) =>
|
||||
result.status === 'rejected' ? [{ name: teardown[index][0], err: result.reason }] : []
|
||||
);
|
||||
for (const { name, err } of failures) {
|
||||
logger.warn({ err }, `Could not stop the ${name} during remote session teardown.`);
|
||||
if (sessionFailed) {
|
||||
// The session error is what the step reports, so a swallowed teardown failure goes to Sentry.
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture(`Could not stop the ${name} after the remote session failed`, error, {
|
||||
level: 'warning',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!sessionFailed && failures.length > 0) {
|
||||
throw failures[0].err;
|
||||
}
|
||||
}
|
||||
|
||||
export function spawnDetached({
|
||||
@@ -596,11 +622,13 @@ export function spawnDetached({
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
stopGracePeriodMs,
|
||||
}: {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
env: BuildStepEnv;
|
||||
stopGracePeriodMs?: number;
|
||||
}): DetachedProcessHandle {
|
||||
const promise = spawn(command, args, {
|
||||
cwd,
|
||||
@@ -608,9 +636,24 @@ export function spawnDetached({
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
});
|
||||
// We don't await the process — it should outlive this step. Failures show
|
||||
// up in the captured output; suppress unhandled rejections here.
|
||||
promise.catch(() => {});
|
||||
// Observe completion without rejecting in the background. Startup callers can
|
||||
// distinguish a dead process from one that is still preparing its state file.
|
||||
let exitError: Error | undefined;
|
||||
// The spawn promise waits for stdio to close. Descendants may keep those
|
||||
// pipes open after the launcher exits, so observe the exit itself as well.
|
||||
promise.child.once('exit', (code, signal) => {
|
||||
exitError = new Error(
|
||||
signal ? `Process exited with signal ${signal}.` : `Process exited with code ${code}.`
|
||||
);
|
||||
});
|
||||
void promise.then(
|
||||
() => {
|
||||
exitError ??= new Error('Process exited with code 0.');
|
||||
},
|
||||
error => {
|
||||
exitError ??= error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
);
|
||||
promise.child.unref();
|
||||
|
||||
let output = '';
|
||||
@@ -624,50 +667,11 @@ export function spawnDetached({
|
||||
return {
|
||||
pid,
|
||||
getOutput: () => output,
|
||||
stopAsync: async () => await stopDetachedProcessAsync(pid),
|
||||
getExitError: () => exitError,
|
||||
stopAsync: async () => await stopDetachedProcessAsync(pid, stopGracePeriodMs),
|
||||
};
|
||||
}
|
||||
|
||||
export function websiteOrigin(env: BuildStepEnv): string {
|
||||
return env.EXPO_LOCAL
|
||||
? 'https://expo.test'
|
||||
: env.EXPO_STAGING
|
||||
? 'https://staging.expo.dev'
|
||||
: 'https://expo.dev';
|
||||
}
|
||||
|
||||
export function simulatorPreviewPageUrl(env: BuildStepEnv, subdomainId: string): string {
|
||||
return new URL(`/simulator-preview/${subdomainId}`, websiteOrigin(env)).toString();
|
||||
}
|
||||
|
||||
// Website dev servers on expo.test use staging sessions, and CORS matches ports exactly.
|
||||
const WEBSITE_DEV_ORIGINS = [
|
||||
'https://expo.test',
|
||||
'https://expo.test:13001',
|
||||
...Array.from({ length: 16 }, (_, index) => `https://expo.test:${13200 + index}`),
|
||||
];
|
||||
|
||||
export function websiteOriginServeSimArgs(env: BuildStepEnv): string[] {
|
||||
const origins = new Set([websiteOrigin(env)]);
|
||||
if (!env.EXPO_LOCAL && env.EXPO_STAGING) {
|
||||
origins.add('https://*.expo.dev');
|
||||
}
|
||||
if (env.EXPO_LOCAL || env.EXPO_STAGING) {
|
||||
for (const origin of WEBSITE_DEV_ORIGINS) {
|
||||
origins.add(origin);
|
||||
}
|
||||
}
|
||||
return [...origins].flatMap(origin => ['--cors-origin', origin, '--frame-ancestor', origin]);
|
||||
}
|
||||
|
||||
function createServeSimPackageSpec(packageVersion: string | undefined): string {
|
||||
return `${SERVE_SIM_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
|
||||
}
|
||||
|
||||
function createExpoDeviceHubPackageSpec(packageVersion: string | undefined): string {
|
||||
return `${EXPO_DEVICE_HUB_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
|
||||
}
|
||||
|
||||
export interface ServeSimLaunchOptions {
|
||||
launchAppIdentifier?: string;
|
||||
launchArgs?: string[];
|
||||
@@ -742,91 +746,12 @@ export function describeServeSimLaunch({
|
||||
return `serve-sim will launch ${launchAppIdentifier}${withArguments}${thenOpen}.`;
|
||||
}
|
||||
|
||||
export function createServeSimArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
websiteArgs = [],
|
||||
shareUrl,
|
||||
packageVersion,
|
||||
launchAppIdentifier,
|
||||
launchArgs = [],
|
||||
openUrl,
|
||||
}: {
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
websiteArgs?: string[];
|
||||
shareUrl?: string;
|
||||
packageVersion?: string;
|
||||
} & ServeSimLaunchOptions): string[] {
|
||||
return [
|
||||
createServeSimPackageSpec(packageVersion),
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
WEB_PREVIEW_HOST,
|
||||
'--require-token',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'h264',
|
||||
'--max-dimension',
|
||||
SERVE_SIM_MAX_DIMENSION,
|
||||
'--mjpeg-quality',
|
||||
SERVE_SIM_MJPEG_QUALITY,
|
||||
'--video-bitrate',
|
||||
SERVE_SIM_VIDEO_BITRATE,
|
||||
'--video-fps',
|
||||
SERVE_SIM_VIDEO_FPS,
|
||||
...turnArgs,
|
||||
...websiteArgs,
|
||||
...(shareUrl ? ['--share-url', shareUrl] : []),
|
||||
...(launchAppIdentifier ? ['--launch-app-identifier', launchAppIdentifier] : []),
|
||||
...launchArgs.flatMap(argument => ['--launch-arg', argument]),
|
||||
...(openUrl ? ['--open-url', openUrl] : []),
|
||||
];
|
||||
}
|
||||
|
||||
export function createExpoDeviceHubArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
packageVersion,
|
||||
}: {
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
packageVersion?: string;
|
||||
}): string[] {
|
||||
return [
|
||||
createExpoDeviceHubPackageSpec(packageVersion),
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
WEB_PREVIEW_HOST,
|
||||
'--platform',
|
||||
'android',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'h264',
|
||||
'--webrtc-ice-policy',
|
||||
'all',
|
||||
'--max-dimension',
|
||||
EXPO_DEVICE_HUB_MAX_DIMENSION,
|
||||
'--video-bitrate',
|
||||
EXPO_DEVICE_HUB_VIDEO_BITRATE,
|
||||
'--video-fps',
|
||||
EXPO_DEVICE_HUB_VIDEO_FPS,
|
||||
'--hide-sidebar',
|
||||
'--hide-boot-device',
|
||||
...turnArgs,
|
||||
];
|
||||
}
|
||||
|
||||
export async function findAvailablePortAsync(): Promise<number> {
|
||||
const server = createServer();
|
||||
server.unref();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, WEB_PREVIEW_HOST, () => resolve());
|
||||
server.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
const address = server.address();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -838,270 +763,6 @@ export async function findAvailablePortAsync(): Promise<number> {
|
||||
return address.port;
|
||||
}
|
||||
|
||||
const WebPreviewReadyResponseSchema = z.object({
|
||||
status: z.literal('ready'),
|
||||
device: z.string(),
|
||||
});
|
||||
|
||||
export async function waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
}: {
|
||||
previewServer: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
|
||||
serverName: string;
|
||||
port: number;
|
||||
timeoutMs: number;
|
||||
}): Promise<string> {
|
||||
const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
if (previewServer.pid !== undefined && !isProcessRunning(previewServer.pid)) {
|
||||
throw new SystemError(
|
||||
`${serverName} exited before becoming ready. Last output:\n${
|
||||
previewServer.getOutput() || '<empty>'
|
||||
}`
|
||||
);
|
||||
}
|
||||
try {
|
||||
const response = await turtleFetch(readyUrl, 'GET', {
|
||||
retries: 0,
|
||||
timeout: 2_000,
|
||||
});
|
||||
const ready = WebPreviewReadyResponseSchema.parse(await response.json());
|
||||
return ready.device;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
throw new SystemError(
|
||||
`Timed out waiting for ${serverName} readiness at ${readyUrl}${
|
||||
lastError instanceof Error ? `: ${lastError.message}` : ''
|
||||
}. Last output:\n${previewServer.getOutput() || '<empty>'}`
|
||||
);
|
||||
}
|
||||
|
||||
export type DeviceWebPreviewHandle = {
|
||||
previewPageUrl: string;
|
||||
apiUrl: string;
|
||||
/** Session token gating the preview. Only serve-sim mints one. */
|
||||
previewToken?: string;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type ServeSimPreviewHandle = DeviceWebPreviewHandle;
|
||||
|
||||
async function startWebPreviewWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
serverName,
|
||||
packageSpec,
|
||||
createArgs,
|
||||
readPreviewTokenAsync,
|
||||
}: {
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
serverName: string;
|
||||
packageSpec: string;
|
||||
createArgs: (port: number, turnArgs: string[], previewPageUrl: string) => string[];
|
||||
readPreviewTokenAsync?: (device: string) => Promise<string>;
|
||||
}
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
const subdomainId = randomBytes(16).toString('hex');
|
||||
const previewPageUrl = simulatorPreviewPageUrl(env, subdomainId);
|
||||
const port = await findAvailablePortAsync();
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
|
||||
const previewExec = resolvePackageExec(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
createArgs(port, turnArgs, previewPageUrl)
|
||||
);
|
||||
logger.info(
|
||||
`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port} via ${previewExec.command}.`
|
||||
);
|
||||
const previewServer = spawnDetached({
|
||||
command: previewExec.command,
|
||||
args: previewExec.args,
|
||||
env,
|
||||
});
|
||||
|
||||
try {
|
||||
logger.info(`Waiting for ${serverName} to become ready.`);
|
||||
const device = await waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
});
|
||||
const previewToken = await readPreviewTokenAsync?.(device);
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix: 'web-preview',
|
||||
subdomainId,
|
||||
baseDomain,
|
||||
authtoken: getNgrokAuthtokenOrThrow(env),
|
||||
logger,
|
||||
});
|
||||
return {
|
||||
previewPageUrl,
|
||||
apiUrl: tunnel.url,
|
||||
previewToken,
|
||||
stopAsync: async () => {
|
||||
const results = await Promise.allSettled([tunnel.stopAsync(), previewServer.stopAsync()]);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
logger.warn({ err: result.reason }, `Could not stop a ${serverName} preview resource.`);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await previewServer.stopAsync();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readServeSimPreviewTokenAsync(
|
||||
udid: string,
|
||||
stateDir: string = SERVE_SIM_STATE_DIR
|
||||
): Promise<string | undefined> {
|
||||
const servers = await readServeSimServersAsync(stateDir);
|
||||
return servers.find(server => server.udid === udid)?.token;
|
||||
}
|
||||
|
||||
export async function startServeSimWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
packageVersion,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
}: {
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
} & ServeSimLaunchOptions
|
||||
): Promise<ServeSimPreviewHandle> {
|
||||
const websiteArgs = websiteOriginServeSimArgs(env);
|
||||
return await startWebPreviewWithTunnelAsync(ctx, {
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
serverName: 'serve-sim',
|
||||
packageSpec: createServeSimPackageSpec(packageVersion),
|
||||
createArgs: (port, turnArgs, previewPageUrl) =>
|
||||
createServeSimArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
websiteArgs,
|
||||
shareUrl: previewPageUrl,
|
||||
packageVersion,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
}),
|
||||
readPreviewTokenAsync: async device => {
|
||||
const previewToken = await readServeSimPreviewTokenAsync(device);
|
||||
if (!previewToken) {
|
||||
// A serve-sim that does not know --require-token fails earlier, in the readiness check, so
|
||||
// reaching here means it started and left no token in its state file.
|
||||
throw new SystemError(
|
||||
`serve-sim became ready but wrote no session token for device ${device}. The preview is ` +
|
||||
'on a public tunnel and would be reachable without one, so the session cannot continue. ' +
|
||||
'This usually means the state file was not written as expected; retry the session, and ' +
|
||||
'report it if it repeats.'
|
||||
);
|
||||
}
|
||||
return previewToken;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function startExpoDeviceHubWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
runtimePlatform,
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
packageVersion,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
}
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
if (runtimePlatform === BuildRuntimePlatform.LINUX) {
|
||||
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
|
||||
}
|
||||
return await startWebPreviewWithTunnelAsync(ctx, {
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
serverName: 'expo-device-hub',
|
||||
packageSpec: createExpoDeviceHubPackageSpec(packageVersion),
|
||||
createArgs: (port, turnArgs) => createExpoDeviceHubArgs({ port, turnArgs, packageVersion }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function startDeviceWebPreviewWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
runtimePlatform,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
...options
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
} & ServeSimLaunchOptions
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
switch (runtimePlatform) {
|
||||
case BuildRuntimePlatform.DARWIN:
|
||||
return await startServeSimWithTunnelAsync(ctx, {
|
||||
...options,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
});
|
||||
case BuildRuntimePlatform.LINUX:
|
||||
// Unreachable from the three step functions, which reject a non-Darwin launch while
|
||||
// parsing. Kept because this function is exported and expo-device-hub cannot launch.
|
||||
if (launchAppIdentifier) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
`Cannot launch ${launchAppIdentifier}: an application launch runs through serve-sim on an iOS simulator, and this session runs expo-device-hub on ${runtimePlatform}.`
|
||||
);
|
||||
}
|
||||
return await startExpoDeviceHubWithTunnelAsync(ctx, { ...options, runtimePlatform });
|
||||
}
|
||||
}
|
||||
|
||||
export type NgrokTunnelHandle = {
|
||||
url: string;
|
||||
subdomainId: string;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
|
||||
|
||||
export async function uploadServeSimLogsFileAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
deviceRunSessionId,
|
||||
udid,
|
||||
filePath,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
udid: string;
|
||||
filePath: string;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<void> {
|
||||
let stream: ReturnType<typeof createReadStream> | undefined;
|
||||
try {
|
||||
const { size } = await stat(filePath);
|
||||
if (size === 0) {
|
||||
return;
|
||||
}
|
||||
const fileController = new AbortController();
|
||||
stream = createReadStream(filePath);
|
||||
// A read error can occur while the upload session is still being allocated.
|
||||
stream.on('error', err => fileController.abort(err));
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId: `simulator-log-${udid}`,
|
||||
name: `App logs (${udid.slice(0, 8)})`,
|
||||
filename: 'app-logs.ndjson',
|
||||
kind: 'simulator-log',
|
||||
metadata: { __eas_type: 'simulator-log', udid, scope: 'user-apps', source: 'serve-sim/logs' },
|
||||
size,
|
||||
stream,
|
||||
signal: AbortSignal.any([fileController.signal, ...(signal ? [signal] : [])]),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
`Could not upload simulator logs for ${udid}; other artifacts will continue.`
|
||||
);
|
||||
} finally {
|
||||
stream?.destroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fetch from 'node-fetch';
|
||||
import { mkdtemp, open } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { type Readable } from 'node:stream';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Sentry } from '../../sentry';
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
|
||||
// Require user-app scope acknowledgement; replay cursors avoid duplicate records
|
||||
// on reconnect. Older servers that ignore the filter are not safe to collect from.
|
||||
const MAX_BYTES_PER_DEVICE = 20 * 1024 * 1024;
|
||||
const MAX_DURATION_MS = 30 * 60 * 1000;
|
||||
const MAX_LINE_LENGTH = 1024 * 1024;
|
||||
const MAX_CONSECUTIVE_FAILURES = 10;
|
||||
|
||||
const ServeSimLogEnvelopeSchema = z.object({
|
||||
seq: z.number().int().nonnegative(),
|
||||
raw: z.string(),
|
||||
});
|
||||
|
||||
type CollectedLog = { udid: string; filePath: string };
|
||||
type Session = {
|
||||
controller: AbortController;
|
||||
timer: NodeJS.Timeout;
|
||||
polling: Promise<void>;
|
||||
streams: Map<string, Promise<void>>;
|
||||
files: Map<string, CollectedLog>;
|
||||
finishing?: Promise<CollectedLog[]>;
|
||||
};
|
||||
let activeSession: Session | undefined;
|
||||
|
||||
export namespace ServeSimLogsRecorder {
|
||||
export async function startAsync({
|
||||
logger,
|
||||
stateDir = SERVE_SIM_STATE_DIR,
|
||||
pollIntervalMs = 2_000,
|
||||
maxBytes = MAX_BYTES_PER_DEVICE,
|
||||
maxDurationMs = MAX_DURATION_MS,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
stateDir?: string;
|
||||
pollIntervalMs?: number;
|
||||
maxBytes?: number;
|
||||
maxDurationMs?: number;
|
||||
}): Promise<void> {
|
||||
if (activeSession) {
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const session: Session = {
|
||||
controller,
|
||||
timer: setTimeout(() => controller.abort(), maxDurationMs),
|
||||
polling: Promise.resolve(),
|
||||
streams: new Map(),
|
||||
files: new Map(),
|
||||
};
|
||||
session.timer.unref();
|
||||
activeSession = session;
|
||||
// Reserve the session before asynchronous setup so concurrent starts are harmless.
|
||||
session.polling = (async () => {
|
||||
const outputDirectory = await mkdtemp(path.join(os.tmpdir(), 'serve-sim-logs-'));
|
||||
const failures = new Map<string, number>();
|
||||
const bytes = new Map<string, number>();
|
||||
const capped = new Set<string>();
|
||||
const cursors = new Map<string, { identity: string; sequence?: number }>();
|
||||
while (!controller.signal.aborted) {
|
||||
const servers = await readServeSimServersAsync(stateDir);
|
||||
const present = new Set(servers.map(server => server.udid));
|
||||
for (const udid of failures.keys()) {
|
||||
if (!present.has(udid)) {
|
||||
failures.delete(udid);
|
||||
cursors.delete(udid);
|
||||
}
|
||||
}
|
||||
for (const server of servers) {
|
||||
if (controller.signal.aborted) {
|
||||
break;
|
||||
}
|
||||
if (session.streams.has(server.udid) || capped.has(server.udid)) {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(outputDirectory, `${encodeURIComponent(server.udid)}.ndjson`);
|
||||
const identity = JSON.stringify([server.url, server.token]);
|
||||
let cursor = cursors.get(server.udid);
|
||||
if (cursor?.identity !== identity) {
|
||||
cursor = { identity };
|
||||
cursors.set(server.udid, cursor);
|
||||
failures.delete(server.udid);
|
||||
}
|
||||
if ((failures.get(server.udid) ?? 0) >= MAX_CONSECUTIVE_FAILURES) {
|
||||
continue;
|
||||
}
|
||||
failures.set(server.udid, (failures.get(server.udid) ?? 0) + 1);
|
||||
const done = streamServeSimLogsToFileAsync({
|
||||
serveSimUrl: server.url,
|
||||
serveSimToken: server.token,
|
||||
serveSimDevice: server.udid,
|
||||
since: cursor?.sequence,
|
||||
filePath,
|
||||
signal: controller.signal,
|
||||
logger,
|
||||
maxBytes: maxBytes - (bytes.get(server.udid) ?? 0),
|
||||
})
|
||||
.then(result => {
|
||||
bytes.set(server.udid, (bytes.get(server.udid) ?? 0) + result.bytesWritten);
|
||||
if (result.lastSequence !== undefined && cursors.get(server.udid) === cursor) {
|
||||
cursors.set(server.udid, { identity, sequence: result.lastSequence });
|
||||
}
|
||||
if (result.receivedData) {
|
||||
session.files.set(server.udid, { udid: server.udid, filePath });
|
||||
failures.set(server.udid, 0);
|
||||
if (result.lastSequence === undefined) {
|
||||
capped.add(server.udid);
|
||||
if (!controller.signal.aborted && !result.limitReached) {
|
||||
logger.warn(
|
||||
`Simulator log stream for ${server.udid} ended without a replay cursor; retaining collected logs without reconnecting.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result.limitReached) {
|
||||
capped.add(server.udid);
|
||||
logger.warn(`serve-sim simulator logs reached the size limit for ${server.udid}.`);
|
||||
}
|
||||
})
|
||||
.finally(() => session.streams.delete(server.udid));
|
||||
session.streams.set(server.udid, done);
|
||||
}
|
||||
await delay(pollIntervalMs, undefined, { signal: controller.signal }).catch(() => {});
|
||||
}
|
||||
})().catch(err => {
|
||||
logger.warn({ err }, 'Could not collect serve-sim simulator logs.');
|
||||
controller.abort();
|
||||
});
|
||||
logger.info('Started collecting user-app logs (up to 20 MiB per device and 30 minutes).');
|
||||
}
|
||||
|
||||
export async function finishAsync({ logger }: { logger: bunyan }): Promise<CollectedLog[]> {
|
||||
const session = activeSession;
|
||||
if (!session || session.finishing) {
|
||||
// Only the first finalizer receives files, preventing duplicate uploads.
|
||||
await session?.finishing;
|
||||
return [];
|
||||
}
|
||||
session.finishing = (async () => {
|
||||
clearTimeout(session.timer);
|
||||
session.controller.abort();
|
||||
await session.polling;
|
||||
await Promise.all(session.streams.values());
|
||||
logger.info('Finished collecting serve-sim simulator logs.');
|
||||
return [...session.files.values()];
|
||||
})();
|
||||
try {
|
||||
return await session.finishing;
|
||||
} finally {
|
||||
activeSession = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamServeSimLogsToFileAsync({
|
||||
serveSimUrl,
|
||||
serveSimToken,
|
||||
serveSimDevice,
|
||||
since,
|
||||
filePath,
|
||||
signal,
|
||||
logger,
|
||||
maxBytes = MAX_BYTES_PER_DEVICE,
|
||||
}: {
|
||||
serveSimUrl: string;
|
||||
serveSimToken?: string;
|
||||
serveSimDevice?: string;
|
||||
since?: number;
|
||||
filePath: string;
|
||||
signal: AbortSignal;
|
||||
logger: bunyan;
|
||||
maxBytes?: number;
|
||||
}): Promise<{
|
||||
receivedData: boolean;
|
||||
limitReached: boolean;
|
||||
bytesWritten: number;
|
||||
lastSequence?: number;
|
||||
}> {
|
||||
let bytesWritten = 0;
|
||||
let limitReached = maxBytes <= 0;
|
||||
let lastSequence = since;
|
||||
let malformedRecords = 0;
|
||||
let file: Awaited<ReturnType<typeof open>> | undefined;
|
||||
let body: NodeJS.ReadableStream | undefined;
|
||||
const closeBody = (): void => {
|
||||
(body as Readable | undefined)?.destroy?.();
|
||||
};
|
||||
try {
|
||||
if (signal.aborted || limitReached) {
|
||||
return { receivedData: false, limitReached, bytesWritten };
|
||||
}
|
||||
const url = new URL('/logs', serveSimUrl);
|
||||
url.searchParams.set('envelope', 'true');
|
||||
url.searchParams.set('scope', 'user-apps');
|
||||
if (since !== undefined) {
|
||||
url.searchParams.set('since', String(since));
|
||||
}
|
||||
if (serveSimDevice) {
|
||||
url.searchParams.set('device', serveSimDevice);
|
||||
}
|
||||
const response = await fetch(url.toString(), {
|
||||
signal,
|
||||
...(serveSimToken ? { headers: { Authorization: `Bearer ${serveSimToken}` } } : {}),
|
||||
});
|
||||
body = response.body;
|
||||
signal.addEventListener('abort', closeBody, { once: true });
|
||||
if (signal.aborted) {
|
||||
closeBody();
|
||||
return { receivedData: false, limitReached, bytesWritten };
|
||||
}
|
||||
if (!response.ok || !response.body) {
|
||||
logger.warn(`serve-sim /logs responded ${response.status}; simulator logs will be retried.`);
|
||||
return { receivedData: false, limitReached, bytesWritten };
|
||||
}
|
||||
// Older servers ignore unknown query parameters and would return system logs.
|
||||
if (response.headers.get('x-serve-sim-log-scope') !== 'user-apps') {
|
||||
logger.warn(
|
||||
'serve-sim did not confirm user-app log filtering. Skipping this stream; use a serve-sim version that supports /logs?scope=user-apps.'
|
||||
);
|
||||
return { receivedData: false, limitReached, bytesWritten };
|
||||
}
|
||||
file = await open(filePath, 'a');
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let droppingLine = false;
|
||||
stream: for await (const chunk of response.body) {
|
||||
buffer +=
|
||||
typeof chunk === 'string' ? chunk : decoder.decode(chunk as Buffer, { stream: true });
|
||||
let newline: number;
|
||||
while ((newline = buffer.indexOf('\n')) !== -1) {
|
||||
const line = buffer.slice(0, newline).replace(/\r$/, '');
|
||||
buffer = buffer.slice(newline + 1);
|
||||
const skip = droppingLine || line.length > MAX_LINE_LENGTH;
|
||||
droppingLine = false;
|
||||
if (skip || !line.startsWith('data:')) {
|
||||
continue;
|
||||
}
|
||||
let payload = line.slice(5).trim();
|
||||
let sequence: number | undefined;
|
||||
try {
|
||||
const envelope = ServeSimLogEnvelopeSchema.safeParse(JSON.parse(payload));
|
||||
if (envelope.success) {
|
||||
sequence = envelope.data.seq;
|
||||
if (lastSequence !== undefined && sequence <= lastSequence) {
|
||||
continue;
|
||||
}
|
||||
// Keep the downloaded artifact homogeneous across old and new servers.
|
||||
payload = JSON.stringify(JSON.parse(envelope.data.raw));
|
||||
}
|
||||
} catch {
|
||||
// Skip the record so the rest of the stream is kept; the count is reported below.
|
||||
malformedRecords += 1;
|
||||
continue;
|
||||
}
|
||||
const record = payload + '\n';
|
||||
const size = Buffer.byteLength(record);
|
||||
if (bytesWritten + size > maxBytes) {
|
||||
limitReached = true;
|
||||
break stream;
|
||||
}
|
||||
// Await writes to bound buffering, including during teardown; close waits for these writes.
|
||||
await file.writeFile(record);
|
||||
bytesWritten += size;
|
||||
if (sequence !== undefined) {
|
||||
lastSequence = sequence;
|
||||
}
|
||||
if (bytesWritten >= maxBytes) {
|
||||
limitReached = true;
|
||||
break stream;
|
||||
}
|
||||
}
|
||||
if (buffer.length > MAX_LINE_LENGTH) {
|
||||
buffer = '';
|
||||
droppingLine = true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!signal.aborted) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'serve-sim simulator log stream ended; collected logs will be retained.'
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (malformedRecords > 0) {
|
||||
Sentry.capture('serve-sim sent malformed simulator log records', {
|
||||
extras: { malformedRecords, serveSimDevice },
|
||||
});
|
||||
logger.warn(
|
||||
{ malformedRecords },
|
||||
'serve-sim sent simulator log records that are not valid JSON; they were skipped.'
|
||||
);
|
||||
}
|
||||
signal.removeEventListener('abort', closeBody);
|
||||
closeBody();
|
||||
await file?.close().catch(err => {
|
||||
logger.warn({ err }, 'Could not close the simulator log file.');
|
||||
});
|
||||
}
|
||||
return {
|
||||
receivedData: bytesWritten > 0,
|
||||
limitReached,
|
||||
bytesWritten,
|
||||
...(lastSequence !== undefined ? { lastSequence } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
|
||||
export type StartupTasks = {
|
||||
/**
|
||||
* Aborted when the first task fails, with that task's error as the reason. Long or
|
||||
* unbounded work (for example a download) passes it on, and tasks check it before
|
||||
* each next stage, so a failed startup stops quickly and does no more work.
|
||||
*/
|
||||
readonly signal: AbortSignal;
|
||||
/**
|
||||
* Starts `fn` now and returns its promise. Logs when it starts, when it finishes, and
|
||||
* how long it took, so the timing of each part stays visible when parts run in
|
||||
* parallel inside one step. `fn` gets a child logger with a `startupTask` field, so
|
||||
* the interleaved lines of parallel tasks can be told apart.
|
||||
*
|
||||
* If the task fails, startup is aborted. The returned promise is marked as handled: a
|
||||
* task that fails before anything awaits it must not crash the worker (it has no
|
||||
* unhandledRejection handler). Awaiting the promise still throws the error.
|
||||
*/
|
||||
run<T>(name: string, fn: (taskLogger: bunyan) => Promise<T>): Promise<T>;
|
||||
/**
|
||||
* Resolves like `promise`, or rejects with the abort reason as soon as startup is
|
||||
* aborted. For waiting on work that cannot be cancelled, such as a device boot. The
|
||||
* returned promise is marked as handled.
|
||||
*/
|
||||
untilAborted<T>(promise: Promise<T>): Promise<T>;
|
||||
/** Aborts startup, for a failure outside of `run`. The first reason wins. */
|
||||
abort(reason: unknown): void;
|
||||
/** One line with the duration of every finished task, in the order they finished. */
|
||||
summary(): string;
|
||||
};
|
||||
|
||||
export function createStartupTasks(logger: bunyan): StartupTasks {
|
||||
const startedAt = Date.now();
|
||||
const finished: { name: string; ms: number }[] = [];
|
||||
const controller = new AbortController();
|
||||
const abort = (reason: unknown): void => {
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort(reason);
|
||||
}
|
||||
};
|
||||
return {
|
||||
signal: controller.signal,
|
||||
run<T>(name: string, fn: (taskLogger: bunyan) => Promise<T>): Promise<T> {
|
||||
const taskStartedAt = Date.now();
|
||||
logger.info(`Starting ${name}.`);
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const result = await fn(logger.child({ startupTask: name }));
|
||||
const ms = Date.now() - taskStartedAt;
|
||||
finished.push({ name, ms });
|
||||
logger.info(`Finished ${name} in ${formatSeconds(ms)}.`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
logger.warn(`${name} failed after ${formatSeconds(Date.now() - taskStartedAt)}.`);
|
||||
abort(err);
|
||||
throw err;
|
||||
}
|
||||
})();
|
||||
promise.catch(() => {});
|
||||
return promise;
|
||||
},
|
||||
untilAborted<T>(promise: Promise<T>): Promise<T> {
|
||||
const { signal } = controller;
|
||||
const result = new Promise<T>((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(signal.reason);
|
||||
return;
|
||||
}
|
||||
const onAbort = (): void => reject(signal.reason);
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
promise.then(
|
||||
value => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
resolve(value);
|
||||
},
|
||||
err => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
reject(err);
|
||||
}
|
||||
);
|
||||
});
|
||||
result.catch(() => {});
|
||||
return result;
|
||||
},
|
||||
abort,
|
||||
summary(): string {
|
||||
const parts = finished.map(({ name, ms }) => `${name} ${formatSeconds(ms)}`);
|
||||
return `Startup took ${formatSeconds(Date.now() - startedAt)} (${parts.join(', ')}).`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatSeconds(ms: number): string {
|
||||
return `${(ms / 1000).toFixed(1)} s`;
|
||||
}
|
||||
@@ -21,6 +21,17 @@ export type AndroidVirtualDeviceName = string & z.BRAND<'AndroidVirtualDeviceNam
|
||||
export type AndroidDeviceName = string & z.BRAND<'AndroidDeviceName'>;
|
||||
export type AndroidDeviceSerialId = string & z.BRAND<'AndroidDeviceSerialId'>;
|
||||
|
||||
/**
|
||||
* Starts the emulator through a wrapper that waits on stdin, so the wrapper's
|
||||
* pid can be placed (for example, into a cgroup) before the emulator execs.
|
||||
*/
|
||||
export type AndroidEmulatorLaunchGate = {
|
||||
emulatorArgs: string[];
|
||||
wrapperCommand: string;
|
||||
wrapperArgs: string[];
|
||||
admitAsync: (pid: number) => Promise<void>;
|
||||
};
|
||||
|
||||
export namespace AndroidEmulatorUtils {
|
||||
const RETRY_INTERVAL_MS = 1_000;
|
||||
|
||||
@@ -312,10 +323,12 @@ export namespace AndroidEmulatorUtils {
|
||||
deviceName,
|
||||
env,
|
||||
logcatDirectory,
|
||||
launchGate,
|
||||
}: {
|
||||
deviceName: AndroidVirtualDeviceName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logcatDirectory: string;
|
||||
launchGate?: AndroidEmulatorLaunchGate;
|
||||
}): Promise<{
|
||||
emulatorPromise: SpawnPromise<SpawnResult>;
|
||||
serialId: AndroidDeviceSerialId;
|
||||
@@ -341,37 +354,47 @@ export namespace AndroidEmulatorUtils {
|
||||
});
|
||||
}
|
||||
|
||||
const emulatorPromise = spawn(
|
||||
`${process.env.ANDROID_HOME}/emulator/emulator`,
|
||||
[
|
||||
'-no-window',
|
||||
'-no-boot-anim',
|
||||
'-writable-system',
|
||||
'-noaudio',
|
||||
'-no-snapshot-save',
|
||||
'-logcat',
|
||||
'*:v',
|
||||
'-logcat-output',
|
||||
logcatOutputPath,
|
||||
'-avd',
|
||||
deviceName,
|
||||
'-accel',
|
||||
'on',
|
||||
...(typeof env.ANDROID_EMULATOR_EXTRA_ARGS === 'string'
|
||||
? env.ANDROID_EMULATOR_EXTRA_ARGS.split(' ')
|
||||
: []),
|
||||
],
|
||||
{
|
||||
detached: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
ignoreStdio: true,
|
||||
env: {
|
||||
...env,
|
||||
// We don't need to wait for emulator to exit gracefully.
|
||||
ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1',
|
||||
},
|
||||
}
|
||||
);
|
||||
const emulatorCommand = `${process.env.ANDROID_HOME}/emulator/emulator`;
|
||||
const emulatorArgs = [
|
||||
'-no-window',
|
||||
'-no-boot-anim',
|
||||
'-writable-system',
|
||||
'-noaudio',
|
||||
'-no-snapshot-save',
|
||||
'-logcat',
|
||||
'*:v',
|
||||
'-logcat-output',
|
||||
logcatOutputPath,
|
||||
'-avd',
|
||||
deviceName,
|
||||
'-accel',
|
||||
'on',
|
||||
...(typeof env.ANDROID_EMULATOR_EXTRA_ARGS === 'string'
|
||||
? env.ANDROID_EMULATOR_EXTRA_ARGS.split(' ')
|
||||
: []),
|
||||
];
|
||||
const emulatorEnv = {
|
||||
...env,
|
||||
// We don't need to wait for emulator to exit gracefully.
|
||||
ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1',
|
||||
};
|
||||
const emulatorPromise = launchGate
|
||||
? spawn(
|
||||
launchGate.wrapperCommand,
|
||||
[...launchGate.wrapperArgs, emulatorCommand, ...emulatorArgs, ...launchGate.emulatorArgs],
|
||||
{
|
||||
detached: true,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
ignoreStdio: true,
|
||||
env: emulatorEnv,
|
||||
}
|
||||
)
|
||||
: spawn(emulatorCommand, emulatorArgs, {
|
||||
detached: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
ignoreStdio: true,
|
||||
env: emulatorEnv,
|
||||
});
|
||||
const emulatorOutputStream = fs.createWriteStream(emulatorOutputPath, { flags: 'a' });
|
||||
let reportedEmulatorOutputError = false;
|
||||
emulatorOutputStream.on('error', err => {
|
||||
@@ -413,6 +436,16 @@ export namespace AndroidEmulatorUtils {
|
||||
if (!emulatorPromise.child.pid) {
|
||||
await emulatorPromise;
|
||||
}
|
||||
if (launchGate && emulatorPromise.child.pid) {
|
||||
try {
|
||||
await launchGate.admitAsync(emulatorPromise.child.pid);
|
||||
} catch (err) {
|
||||
emulatorPromise.child.kill('SIGKILL');
|
||||
await asyncResult(emulatorPromise);
|
||||
throw err;
|
||||
}
|
||||
emulatorPromise.child.stdin?.end('go\n');
|
||||
}
|
||||
emulatorPromise.child.unref();
|
||||
// The emulator is detached and managed through adb. Observe its process promise
|
||||
// immediately so expected exits during retry cleanup do not become unhandled rejections.
|
||||
@@ -445,11 +478,14 @@ export namespace AndroidEmulatorUtils {
|
||||
env,
|
||||
timeoutMs = 3 * 60 * 1_000,
|
||||
logger,
|
||||
networkReadyTarget,
|
||||
}: {
|
||||
serialId: AndroidDeviceSerialId;
|
||||
env: NodeJS.ProcessEnv;
|
||||
timeoutMs?: number;
|
||||
logger?: bunyan;
|
||||
/** Host and port the guest must reach; 1.1.1.1:443 unless set. */
|
||||
networkReadyTarget?: { host: string; port: number };
|
||||
}): Promise<void> {
|
||||
const retries = Math.max(0, Math.ceil(timeoutMs / RETRY_INTERVAL_MS) - 1);
|
||||
await retryAsync(
|
||||
@@ -464,7 +500,11 @@ export namespace AndroidEmulatorUtils {
|
||||
throw new Error(`Emulator (${serialId}) boot has not completed.`);
|
||||
}
|
||||
|
||||
const hasNetworkConnection = await hasNetworkConnectionAsync({ serialId, env });
|
||||
const hasNetworkConnection = await hasNetworkConnectionAsync({
|
||||
serialId,
|
||||
env,
|
||||
target: networkReadyTarget,
|
||||
});
|
||||
if (!hasNetworkConnection) {
|
||||
throw new Error(`Emulator (${serialId}) network is not ready.`);
|
||||
}
|
||||
@@ -514,9 +554,11 @@ export namespace AndroidEmulatorUtils {
|
||||
async function hasNetworkConnectionAsync({
|
||||
serialId,
|
||||
env,
|
||||
target = { host: '1.1.1.1', port: 443 },
|
||||
}: {
|
||||
serialId: AndroidDeviceSerialId;
|
||||
env: NodeJS.ProcessEnv;
|
||||
target?: { host: string; port: number };
|
||||
}): Promise<boolean> {
|
||||
const networkReadyCheckCommand = env.ANDROID_EMULATOR_NETWORK_READY_COMMAND?.trim();
|
||||
if (networkReadyCheckCommand) {
|
||||
@@ -529,7 +571,7 @@ export namespace AndroidEmulatorUtils {
|
||||
const netcatResult = await asyncResult(
|
||||
spawn(
|
||||
'adb',
|
||||
['-s', serialId, 'shell', 'nc', '-w', '1', '1.1.1.1', '443'],
|
||||
['-s', serialId, 'shell', 'nc', '-w', '1', target.host, String(target.port)],
|
||||
// Close stdin to make netcat exit cleanly on Android images that don't support `-z`.
|
||||
{ env, stdio: ['ignore', 'pipe', 'pipe'] }
|
||||
)
|
||||
|
||||
@@ -261,6 +261,150 @@ describe('AndroidEmulatorUtils', () => {
|
||||
await emulatorOutputStreamClosed;
|
||||
});
|
||||
|
||||
it('spawns the emulator directly with the same arguments when there is no launch gate', async () => {
|
||||
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'logcat-staging-'));
|
||||
temporaryDirectories.push(outputDir);
|
||||
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
|
||||
mockSuccessfulStart(deviceName);
|
||||
const env = { ANDROID_EMULATOR_EXTRA_ARGS: '-gpu swiftshader_indirect' };
|
||||
|
||||
const result = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env,
|
||||
logcatDirectory: outputDir,
|
||||
});
|
||||
|
||||
const emulatorCalls = mockedSpawn.mock.calls.filter(([command]) =>
|
||||
command.endsWith('/emulator/emulator')
|
||||
);
|
||||
expect(emulatorCalls).toEqual([
|
||||
[
|
||||
`${process.env.ANDROID_HOME}/emulator/emulator`,
|
||||
[
|
||||
'-no-window',
|
||||
'-no-boot-anim',
|
||||
'-writable-system',
|
||||
'-noaudio',
|
||||
'-no-snapshot-save',
|
||||
'-logcat',
|
||||
'*:v',
|
||||
'-logcat-output',
|
||||
result.logcatOutputPath,
|
||||
'-avd',
|
||||
deviceName,
|
||||
'-accel',
|
||||
'on',
|
||||
'-gpu',
|
||||
'swiftshader_indirect',
|
||||
],
|
||||
{
|
||||
detached: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
ignoreStdio: true,
|
||||
env: { ...env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(
|
||||
mockedSpawn.mock.calls.filter(([command]) =>
|
||||
['sudo', 'nft', 'systemd-run', 'bash'].includes(command)
|
||||
)
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('starts the emulator behind the launch gate and releases it once admitted', async () => {
|
||||
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'logcat-staging-'));
|
||||
temporaryDirectories.push(outputDir);
|
||||
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
|
||||
const { child } = mockSuccessfulStart(deviceName);
|
||||
const stdin = { end: jest.fn() };
|
||||
Object.assign(child, { stdin });
|
||||
const gatedSpawn = mockedSpawn.getMockImplementation()!;
|
||||
mockedSpawn.mockImplementation(((command: string, args: string[], options: object) =>
|
||||
command === 'bash'
|
||||
? gatedSpawn('/android/emulator/emulator', args, options as any)
|
||||
: gatedSpawn(command, args, options as any)) as any);
|
||||
const admitAsync = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const result = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env: {},
|
||||
logcatDirectory: outputDir,
|
||||
launchGate: {
|
||||
emulatorArgs: ['-http-proxy', '127.0.0.1:8898', '-no-metrics'],
|
||||
wrapperCommand: 'bash',
|
||||
wrapperArgs: ['-c', 'gate', 'eas-egress-gate'],
|
||||
admitAsync,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'bash',
|
||||
[
|
||||
'-c',
|
||||
'gate',
|
||||
'eas-egress-gate',
|
||||
`${process.env.ANDROID_HOME}/emulator/emulator`,
|
||||
'-no-window',
|
||||
'-no-boot-anim',
|
||||
'-writable-system',
|
||||
'-noaudio',
|
||||
'-no-snapshot-save',
|
||||
'-logcat',
|
||||
'*:v',
|
||||
'-logcat-output',
|
||||
result.logcatOutputPath,
|
||||
'-avd',
|
||||
deviceName,
|
||||
'-accel',
|
||||
'on',
|
||||
'-http-proxy',
|
||||
'127.0.0.1:8898',
|
||||
'-no-metrics',
|
||||
],
|
||||
expect.objectContaining({ detached: true, stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
);
|
||||
expect(admitAsync).toHaveBeenCalledWith(1234);
|
||||
expect(stdin.end).toHaveBeenCalledWith('go\n');
|
||||
expect(admitAsync.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
stdin.end.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('kills the gated wrapper and throws when admission fails', async () => {
|
||||
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'logcat-staging-'));
|
||||
temporaryDirectories.push(outputDir);
|
||||
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
|
||||
const { child } = mockSuccessfulStart(deviceName);
|
||||
const stdin = { end: jest.fn() };
|
||||
const kill = jest.fn();
|
||||
Object.assign(child, { stdin, kill });
|
||||
const gatedSpawn = mockedSpawn.getMockImplementation()!;
|
||||
mockedSpawn.mockImplementation(((command: string, args: string[], options: object) =>
|
||||
gatedSpawn(
|
||||
command === 'bash' ? '/android/emulator/emulator' : command,
|
||||
args,
|
||||
options as any
|
||||
)) as any);
|
||||
const admissionError = new Error('not fenced');
|
||||
|
||||
await expect(
|
||||
AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env: {},
|
||||
logcatDirectory: outputDir,
|
||||
launchGate: {
|
||||
emulatorArgs: [],
|
||||
wrapperCommand: 'bash',
|
||||
wrapperArgs: ['-c', 'gate', 'eas-egress-gate'],
|
||||
admitAsync: jest.fn().mockRejectedValue(admissionError),
|
||||
},
|
||||
})
|
||||
).rejects.toBe(admissionError);
|
||||
expect(kill).toHaveBeenCalledWith('SIGKILL');
|
||||
expect(stdin.end).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws a SystemError when the staging directory cannot be prepared', async () => {
|
||||
const outputDir = '/unwritable/logcat-staging';
|
||||
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
|
||||
@@ -339,6 +483,30 @@ describe('AndroidEmulatorUtils', () => {
|
||||
).rejects.toThrow('network is not ready');
|
||||
});
|
||||
|
||||
it('checks the given readiness target instead of 1.1.1.1:443', async () => {
|
||||
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (args[3] === 'getprop') {
|
||||
return { stdout: '1\n', stderr: '' } as any;
|
||||
}
|
||||
if (args[3] === 'nc' && args[6] === '192.0.2.1' && args[7] === '443') {
|
||||
return { stdout: '', stderr: '' } as any;
|
||||
}
|
||||
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
|
||||
}) as any);
|
||||
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
serialId: 'emulator-5554' as any,
|
||||
env: process.env,
|
||||
networkReadyTarget: { host: '192.0.2.1', port: 443 },
|
||||
});
|
||||
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith(
|
||||
'adb',
|
||||
expect.arrayContaining(['1.1.1.1']),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('uses overridden network readiness command when provided', async () => {
|
||||
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (args[3] === 'getprop') {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { graphqlAbortContext } from '../graphqlAbort';
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
});
|
||||
|
||||
it('returns nothing without a signal', () => {
|
||||
expect(graphqlAbortContext(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('aborts the request when either the caller or urql aborts', async () => {
|
||||
const seen: AbortSignal[] = [];
|
||||
globalThis.fetch = jest.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
seen.push(init!.signal!);
|
||||
return new Response('{}');
|
||||
}) as typeof fetch;
|
||||
const caller = new AbortController();
|
||||
const urql = new AbortController();
|
||||
const context = graphqlAbortContext(caller.signal)!;
|
||||
await context.fetch!('https://api.test/graphql', { method: 'POST', signal: urql.signal });
|
||||
await context.fetch!('https://api.test/graphql', { method: 'POST' });
|
||||
expect(seen).toHaveLength(2);
|
||||
expect(seen.every(signal => !signal.aborted)).toBe(true);
|
||||
urql.abort(new Error('urql gave up'));
|
||||
expect(seen[0].aborted).toBe(true);
|
||||
expect(seen[1].aborted).toBe(false);
|
||||
caller.abort(new Error('caller gave up'));
|
||||
expect(seen[1].aborted).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { isConnectFailure, isConnectionInterruptedError } from '../networkErrors';
|
||||
|
||||
it.each([
|
||||
'EAI_AGAIN',
|
||||
'ECONNREFUSED',
|
||||
'EHOSTDOWN',
|
||||
'EHOSTUNREACH',
|
||||
'ENETDOWN',
|
||||
'ENETUNREACH',
|
||||
'ENOTFOUND',
|
||||
])('recognizes wrapped %s as a connection establishment failure', code => {
|
||||
const error = new Error('Request failed', {
|
||||
cause: new TypeError('fetch failed', {
|
||||
cause: Object.assign(new Error('connect failed'), { code }),
|
||||
}),
|
||||
});
|
||||
expect(isConnectFailure(error)).toBe(true);
|
||||
expect(isConnectionInterruptedError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['ECONNRESET', 'UND_ERR_SOCKET'])(
|
||||
'requires caller-specific recovery for wrapped %s',
|
||||
code => {
|
||||
const error = new Error('Request failed', {
|
||||
cause: Object.assign(new Error('response lost'), { code }),
|
||||
});
|
||||
expect(isConnectFailure(error)).toBe(false);
|
||||
expect(isConnectionInterruptedError(error)).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['ETIMEDOUT', 'CERT_HAS_EXPIRED', undefined])('does not automatically retry %s', code => {
|
||||
const error = Object.assign(new Error('Request failed'), { code });
|
||||
expect(isConnectFailure(error)).toBe(false);
|
||||
expect(isConnectionInterruptedError(error)).toBe(false);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ChildProcess } from 'node:child_process';
|
||||
|
||||
import { isChildProcessAlive } from '../processes';
|
||||
import { isChildProcessAlive, isProcessGroupRunning } from '../processes';
|
||||
|
||||
function child(partial: {
|
||||
exitCode: number | null;
|
||||
@@ -41,3 +41,29 @@ describe(isChildProcessAlive, () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(isProcessGroupRunning, () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('signals the group, not the leader', () => {
|
||||
const kill = jest.spyOn(process, 'kill').mockReturnValue(true);
|
||||
expect(isProcessGroupRunning(4321)).toBe(true);
|
||||
expect(kill).toHaveBeenCalledWith(-4321, 0);
|
||||
});
|
||||
|
||||
it('is not running once the group is gone', () => {
|
||||
jest.spyOn(process, 'kill').mockImplementation(() => {
|
||||
throw Object.assign(new Error('No such process'), { code: 'ESRCH' });
|
||||
});
|
||||
expect(isProcessGroupRunning(4321)).toBe(false);
|
||||
});
|
||||
|
||||
it('is running when the group exists but cannot be signaled', () => {
|
||||
jest.spyOn(process, 'kill').mockImplementation(() => {
|
||||
throw Object.assign(new Error('Operation not permitted'), { code: 'EPERM' });
|
||||
});
|
||||
expect(isProcessGroupRunning(4321)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { promiseRetryWithCondition } from '../promiseRetryWithCondition';
|
||||
|
||||
it('reports the previous error and retry budget only when the next attempt starts', async () => {
|
||||
const errors = [new Error('First failure'), new Error('Second failure')];
|
||||
const fn = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(errors[0])
|
||||
.mockRejectedValueOnce(errors[1])
|
||||
.mockResolvedValue('done');
|
||||
const onRetry = jest.fn();
|
||||
await expect(
|
||||
promiseRetryWithCondition(fn, () => true, { retries: 2, minTimeout: 1 }, onRetry)()
|
||||
).resolves.toBe('done');
|
||||
expect(onRetry.mock.calls).toEqual([
|
||||
[{ attemptNumber: 2, maxAttemptsCount: 3, error: errors[0] }],
|
||||
[{ attemptNumber: 3, maxAttemptsCount: 3, error: errors[1] }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not report a retry after the final failure', async () => {
|
||||
const error = new Error('Persistent failure');
|
||||
const fn = jest.fn().mockRejectedValue(error);
|
||||
const onRetry = jest.fn();
|
||||
await expect(
|
||||
promiseRetryWithCondition(fn, () => true, { retries: 1, minTimeout: 1 }, onRetry)()
|
||||
).rejects.toBe(error);
|
||||
expect(onRetry.mock.calls).toEqual([[{ attemptNumber: 2, maxAttemptsCount: 2, error }]]);
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import StreamZip from 'node-stream-zip';
|
||||
import semver from 'semver';
|
||||
|
||||
import { findArtifacts } from './artifacts';
|
||||
import { runEasCliCommand } from './easCli';
|
||||
@@ -12,6 +13,32 @@ import { resolveArtifactPath } from '../ios/resolve';
|
||||
import { BuildContext } from '../context';
|
||||
import { isEASUpdateConfigured } from './expoUpdates';
|
||||
|
||||
function parseBooleanEnvVar(value: string | undefined): boolean | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
return value !== '0' && value !== 'false';
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploading the embedded bundle is enabled by default for projects on SDK 58 and later, and can be
|
||||
* disabled by setting EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE to "0".
|
||||
*
|
||||
* On SDK 57 and below the feature is still experimental and off by default. Projects opt in by
|
||||
* setting EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE to "1".
|
||||
*/
|
||||
export function shouldUploadEmbeddedBundle(ctx: BuildContext<BuildJob>): boolean {
|
||||
const explicitFlag =
|
||||
parseBooleanEnvVar(ctx.env.EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE) ??
|
||||
parseBooleanEnvVar(ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE);
|
||||
if (explicitFlag !== undefined) {
|
||||
return explicitFlag;
|
||||
}
|
||||
|
||||
const sdkVersion = ctx.metadata?.sdkVersion;
|
||||
return !!sdkVersion && semver.satisfies(sdkVersion, '>=58');
|
||||
}
|
||||
|
||||
export async function uploadEmbeddedBundleAsync(ctx: BuildContext<BuildJob>): Promise<void> {
|
||||
if (!(await isEASUpdateConfigured(ctx))) {
|
||||
ctx.markBuildPhaseSkipped();
|
||||
|
||||
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user