mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f95ca50d9 |
No files matched your search
@@ -19,9 +19,7 @@
|
||||
"@expo/downloader",
|
||||
"@expo/turtle-spawn",
|
||||
"@expo/template-file",
|
||||
"create-eas-build-function",
|
||||
"@expo/vm-tools",
|
||||
"@expo/simulator-launcher"
|
||||
"create-eas-build-function"
|
||||
]
|
||||
],
|
||||
"linked": [],
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Add experimentation client.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Add a Free plan build limit guidance experiment for coding agents to `eas build`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Add `eas simulator:simctl` to set the appearance of a remote iOS simulator session and restart its SpringBoard.
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 8 * * *' # 8:00 AM UTC time every day
|
||||
|
||||
|
||||
-207
@@ -2,213 +2,6 @@
|
||||
|
||||
This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
## [27.5.0](https://github.com/expo/eas-cli/releases/tag/v27.5.0) - 2026-11-05
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Import ordered action configurations with `eas simulator:control --import` and show progress for each action. ([#4686](https://github.com/expo/eas-cli/pull/4686) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Reuse simulator control connections with `--keep-alive` or `simulator:control:connection open`. Set an idle timeout, inspect the connection, or close it without stopping the simulator session. Keep-alive currently supports macOS and Linux. ([#4791](https://github.com/expo/eas-cli/pull/4791) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Start simulator sessions from saved settings and apply control actions after readiness. ([#4687](https://github.com/expo/eas-cli/pull/4687) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Run `eas simulator` commands also as `eas simulators`, `eas emulator` and `eas emulators`, for example `eas emulators:start` or `eas simulators:list`. These aliases are hidden from help. ([#4794](https://github.com/expo/eas-cli/pull/4794) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] List `eas workflow:ssh` in `eas --help`. ([#4793](https://github.com/expo/eas-cli/pull/4793) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Stop an agent-device session that is still starting up when it is stopped. ([#4666](https://github.com/expo/eas-cli/pull/4666) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Stop an Appium session that is still starting up when it is stopped. ([#4668](https://github.com/expo/eas-cli/pull/4668) by [@gwdp](https://github.com/gwdp))
|
||||
Report temporary-directory cleanup failures when Stop interrupts Appium installation.
|
||||
- [build-tools] Stop an Argent session that is still starting up when it is stopped. ([#4667](https://github.com/expo/eas-cli/pull/4667) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Find the personal account by its owner instead of assuming that its name equals the username. ([#3922](https://github.com/expo/eas-cli/pull/3922) by [@byronkarlen](https://github.com/byronkarlen))
|
||||
- [build-tools][worker] Use the sandbox worker token for sandbox jobs when www provides one, and keep Expo credentials out of the commands of those jobs. ([#4807](https://github.com/expo/eas-cli/pull/4807) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [eas-cli] Show the Expo Simulators hint after a successful build from November 12 instead of November 10. ([#4817](https://github.com/expo/eas-cli/pull/4817) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [build-tools] Stop a web-preview session that is still starting up when it is stopped. ([#4669](https://github.com/expo/eas-cli/pull/4669) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [build-tools] Extract the worker API client core from the device run session worker API client. ([#4806](https://github.com/expo/eas-cli/pull/4806) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
## [27.4.1](https://github.com/expo/eas-cli/releases/tag/v27.4.1) - 2026-11-04
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [simulator-launcher][build-tools] Wait for a starting warm Simulator launcher before claiming its session, instead of falling back to a cold start. ([#4810](https://github.com/expo/eas-cli/pull/4810) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [27.4.0](https://github.com/expo/eas-cli/releases/tag/v27.4.0) - 2026-11-03
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [simulator-launcher][build-tools] Add `eas/start_warm_appium_remote_session`, which runs the Appium session on the warm Simulator, or starts cold. An `appium` claim stops the unused tools first, so WDA does not share the Simulator with the agent-device runner. ([#4735](https://github.com/expo/eas-cli/pull/4735) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add warm iOS session steps (`eas/start_warm_serve_sim_remote_session`, `eas/start_warm_agent_device_session`, `eas/start_warm_argent_remote_session`). They run the existing session on the warm Simulator, or start cold. `eas/start_agent_device_session` also accepts `install_app_path` and `launch_app_identifier`. ([#4723](https://github.com/expo/eas-cli/pull/4723) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Update the app version and build number from the final application archive, so the build shows the values the native build resolved. ([#3499](https://github.com/expo/eas-cli/pull/3499) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Upload the final agent-device session artifacts before stopping the daemon, including sessions that reuse a warm Simulator. ([#4750](https://github.com/expo/eas-cli/pull/4750) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add internal helpers that claim the warm Simulator through the launcher and adopt it on the warm serve-sim. Skip the Expo Go install when the warm Simulator already has the same Expo Go version. ([#4724](https://github.com/expo/eas-cli/pull/4724) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Recover warm Simulator recordings when their MP4 exists before the shutdown manifest is ready. ([#4789](https://github.com/expo/eas-cli/pull/4789) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Show the Expo Simulators hint after a successful build from November 10 instead of November 5. ([#4801](https://github.com/expo/eas-cli/pull/4801) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [build-tools] Move iOS Simulator startup behind a shared session host contract. ([#4752](https://github.com/expo/eas-cli/pull/4752) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [27.3.0](https://github.com/expo/eas-cli/releases/tag/v27.3.0) - 2026-11-02
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-build-job] Add optional Observe error context to workflow jobs. ([#4787](https://github.com/expo/eas-cli/pull/4787) by [@AbbanMustafa](https://github.com/AbbanMustafa))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Bump `@expo/apple-utils` to `2.3.0` to fix iOS capability sync when App Store Connect rejects enabling, disabling or changing a capability. ([#4790](https://github.com/expo/eas-cli/pull/4790) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Keep already linked App Groups, Merchant IDs and iCloud containers when capability sync links another identifier. ([#4584](https://github.com/expo/eas-cli/pull/4584) by [@ceydaakin](https://github.com/ceydaakin))
|
||||
- [worker] Mask secrets in every record the worker process writes to its stdout: the build log records and the worker's own records. ([#4782](https://github.com/expo/eas-cli/pull/4782) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [worker][build-tools] Stop warmup in the background for jobs that do not use the warm Simulator, so job setup does not wait for it. Before build-tools boots or clones a Simulator, it waits until the launcher's state file records that cleanup finished, and fails if cleanup does not finish in time. ([#4721](https://github.com/expo/eas-cli/pull/4721) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Recover unhealthy device-session tunnels without changing their URLs and block further traffic when shutdown starts, even if ngrok cleanup times out. ([#4526](https://github.com/expo/eas-cli/pull/4526) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Stop sending the SSH host when registering an SSH session. ([#4784](https://github.com/expo/eas-cli/pull/4784) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
## [27.2.0](https://github.com/expo/eas-cli/releases/tag/v27.2.0) - 2026-10-29
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Use single-device previews when the session already has a valid device ID. ([#4733](https://github.com/expo/eas-cli/pull/4733) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add an experiment that suggests Expo Update to coding agents after a build when the Free plan account is within 5 builds of the platform limit. ([#4758](https://github.com/expo/eas-cli/pull/4758) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [eas-cli] Add `eas observe:traces` to list request trace spans (or a summary of span names and counts), `eas observe:trace` to display one trace as a span tree, and span rows in the `eas observe:session` timeline. ([#4772](https://github.com/expo/eas-cli/pull/4772) by [@kadikraman](https://github.com/kadikraman))
|
||||
- [simulator-launcher] Accept a comma-separated list of warm Simulators in `EXPO_SIMULATOR_DEVICE`; the first is the default. One serve-sim serves every listed device on one port. After the default device is ready, warmup preinstalls the latest stable Expo Go from one download and tries to disable apsd on the other devices, until a job claims one. A claim can select any listed device and reports its preinstalled Expo Go. ([#4764](https://github.com/expo/eas-cli/pull/4764) by [@gwdp](https://github.com/gwdp))
|
||||
- [simulator-launcher] Add `@expo/simulator-launcher`, which warms an iOS Simulator at VM boot and hands it to one EAS job (`start`, `claim`, `prepare-tools`, `stop`). ([#4722](https://github.com/expo/eas-cli/pull/4722) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Show a one-time Expo Simulators hint after a successful build. ([#4768](https://github.com/expo/eas-cli/pull/4768) by [@FiberJW](https://github.com/FiberJW))
|
||||
- [vm-tools] Add a macOS startup script loader that runs `/usr/local/share/vm-tools/startup.sh` once per VM boot. ([#4725](https://github.com/expo/eas-cli/pull/4725) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Explain missing background task identifiers reported by App Store Connect. ([#3465](https://github.com/expo/eas-cli/pull/3465) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Explain App Store Connect NOT_AUTHORIZED responses in the shared API client. ([#3462](https://github.com/expo/eas-cli/pull/3462) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Explain App Store Connect UNEXPECTED_ERROR responses in the shared API client. ([#3467](https://github.com/expo/eas-cli/pull/3467) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Explain missing app icons reported by App Store Connect, including codes 90022 and 90023, while preserving Apple’s validation details. ([#3466](https://github.com/expo/eas-cli/pull/3466) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] List failed Google Play commit attempts in order, including network failures, when the outcome is uncertain. ([#4660](https://github.com/expo/eas-cli/pull/4660) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [worker] Run package-manager version queries in parallel during worker preparation. ([#4710](https://github.com/expo/eas-cli/pull/4710) by [@gwdp](https://github.com/gwdp))
|
||||
- [worker] Start the Xcode version check in the background during worker tool preparation. Apply the Xcode 26.0 upload workaround when the query finishes, without delaying job startup. ([#4707](https://github.com/expo/eas-cli/pull/4707) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Fix a five-minute cleanup delay when a device session is canceled during startup and its screen recorder fails before connecting. ([#4761](https://github.com/expo/eas-cli/pull/4761) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Remove the Free plan build limit guidance experiment for coding agents from `eas build`. ([#4757](https://github.com/expo/eas-cli/pull/4757) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [worker] Allow jobs to skip installing the CocoaPods cache proxy with EAS_BUILD_DISABLE_COCOAPODS_CACHE_PLUGIN=1. ([#4709](https://github.com/expo/eas-cli/pull/4709) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Handle TURN requests for a stopped device session without reporting a warning. ([#4762](https://github.com/expo/eas-cli/pull/4762) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Preserve indented and incomplete Xcode compiler errors in formatted build logs, including the final diagnostic when logging stops. ([#4365](https://github.com/expo/eas-cli/pull/4365) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [simulator-launcher] Add the state, request command and service helpers for the warm Simulator launcher. The package stays private until #4722 adds its CLI entry point. ([#4766](https://github.com/expo/eas-cli/pull/4766) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [27.1.0](https://github.com/expo/eas-cli/releases/tag/v27.1.0) - 2026-10-28
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Display queued workflow runs: `workflow:run --wait` and `workflow:status` show a run waiting for its concurrency group and the run blocking it, and `workflow:cancel` includes queued runs in its selection list. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Upload the workflow files that `uses` jobs reference, including nested ones, when running a local workflow file with `eas workflow:run`. ([#4769](https://github.com/expo/eas-cli/pull/4769) by [@sswrk](https://github.com/sswrk))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Explain App Store Connect error 90189 as a duplicate build number. ([#3461](https://github.com/expo/eas-cli/pull/3461) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Fail the command when EAS CLI cannot pull environment variables from EAS servers, instead of continuing without them. ([#4774](https://github.com/expo/eas-cli/pull/4774) by [@wschurman](https://github.com/wschurman))
|
||||
- [eas-cli] Format the generated root changelog before committing a release. ([#4773](https://github.com/expo/eas-cli/pull/4773) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools][worker] Put the `NPM_TOKEN` registry credential in the builder's npm user config on EAS Build (`~/.npmrc`, or the file `NPM_CONFIG_USERCONFIG` names), where pnpm 10.34.2+ and 11.5.3+ still expand `${NPM_TOKEN}` (they ignore it in a project-level `.npmrc`). Explain what to change when the project's pnpm version needs a newer Node.js than the build runs, including when eas.json sets `node` or `pnpm`. ([#4729](https://github.com/expo/eas-cli/pull/4729) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [build-tools] Install `@expo/repack-app` for the `eas/repack` step with npm in a sandbox that sees only `PATH` and its own `HOME`, so the build machine's npm and Yarn configuration cannot break the install, fetch it through the EAS npm cache when the job uses one, and show the installer output in the build log. The step no longer looks the version up separately before installing. When the npm cache fails, the install retries against the public registry like the project install does; the `eas/run_agent` agent CLI install shares the same helper and gains the same retry. ([#4728](https://github.com/expo/eas-cli/pull/4728) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Honor the `--non-interactive` flag in `workflow:cancel`; it was parsed but never applied, so automation could hang on the interactive picker. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Stop reporting a finish time for workflow runs that have not finished: `workflow:runs` reports `finishedAt` only for runs in a final status. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
|
||||
|
||||
## [27.0.0](https://github.com/expo/eas-cli/releases/tag/v27.0.0) - 2026-10-27
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Write the reason a command failed to stderr, next to the `command failed.` line, instead of to stdout. A program that reads the reason from stdout must read stderr instead. ([#4592](https://github.com/expo/eas-cli/pull/4592) by [@vonovak](https://github.com/vonovak))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Send the `permissions.account.role` from the build profile to request a role for the build's `EXPO_TOKEN`. ([#4734](https://github.com/expo/eas-cli/pull/4734) by [@wschurman](https://github.com/wschurman))
|
||||
- [eas-cli] Add `simulator:logs` to read and follow logs from an iOS simulator session and download saved logs after it stops. ([#4545](https://github.com/expo/eas-cli/pull/4545) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Keep the launched app open when an Appium iOS session starts. ([#4699](https://github.com/expo/eas-cli/pull/4699) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Run Appium through the prepared Full Disk Access helper when available on macOS. ([#4622](https://github.com/expo/eas-cli/pull/4622) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Show permission guidance, including `permissions.account.role` in eas.json, when a build request fails with an authorization error. ([#4760](https://github.com/expo/eas-cli/pull/4760) by [@wschurman](https://github.com/wschurman))
|
||||
- [eas-cli] Print the notice about eas-cli in project dependencies only when stderr is a terminal, so programs that read stderr get the command's error. ([#4591](https://github.com/expo/eas-cli/pull/4591) by [@vonovak](https://github.com/vonovak))
|
||||
- [eas-cli] Show account roles and credential permission limits in `eas login` when you are already logged in. ([#4759](https://github.com/expo/eas-cli/pull/4759) by [@wschurman](https://github.com/wschurman))
|
||||
- [build-tools] Identify the build or application archive when a download fails, and explain when the archive is no longer available. ([#4703](https://github.com/expo/eas-cli/pull/4703) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Run apsd setup in the background without delaying iOS app launch or preview readiness. ([#4706](https://github.com/expo/eas-cli/pull/4706) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Fix simulator control action help routing and show help when a flag is incomplete. ([#4681](https://github.com/expo/eas-cli/pull/4681) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Require explicit `host:` simulator-host upload paths and report missing local files. ([#4683](https://github.com/expo/eas-cli/pull/4683) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
Missing local absolute paths now require the `host:` prefix. Actions that require upload IDs accept local files and reject host paths with an explanation.
|
||||
|
||||
- [build-tools] Handle a device session stopped during startup as normal cleanup instead of reporting a startup error. ([#4716](https://github.com/expo/eas-cli/pull/4716) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives. ([#4731](https://github.com/expo/eas-cli/pull/4731) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Run the GraphQL schema and generated code check on pull requests to any base branch, including stacked pull requests. ([#4763](https://github.com/expo/eas-cli/pull/4763) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
## [26.0.0](https://github.com/expo/eas-cli/releases/tag/v26.0.0) - 2026-10-26
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-build-job][build-tools] Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports. ([#4719](https://github.com/expo/eas-cli/pull/4719) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-build-job][build-tools] Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions. ([#4696](https://github.com/expo/eas-cli/pull/4696) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-json] Add `permissions.account.role` to build profiles to request a role for the build's `EXPO_TOKEN`. ([#4732](https://github.com/expo/eas-cli/pull/4732) by [@wschurman](https://github.com/wschurman))
|
||||
- [build-tools] Stop sandboxes after an optional maximum duration measured from readiness. ([#4702](https://github.com/expo/eas-cli/pull/4702) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Unify signed URL uploading to a single implementation and use it everywhere. ([#4712](https://github.com/expo/eas-cli/pull/4712) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Regenerate graphql code. ([#4692](https://github.com/expo/eas-cli/pull/4692) by [@wschurman](https://github.com/wschurman))
|
||||
- [build-tools] Default Android emulators to 4 GiB of RAM to reduce ADB disconnects during large transfers. Retain the 2 GiB default for API 34 and below outside interactive device sessions, and preserve explicit emulator configuration overrides. ([#4727](https://github.com/expo/eas-cli/pull/4727) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Retry transient failures when reporting that a device run session has started. ([#4680](https://github.com/expo/eas-cli/pull/4680) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log. ([#4690](https://github.com/expo/eas-cli/pull/4690) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Read the app config fewer times in `eas build` and other project commands. An iOS build now runs `expo config` 2 times instead of 6. ([`78ba760`](https://github.com/expo/eas-cli/commit/78ba76001b67c9971236af1a1d02e46c2f82d24a) by [@vonovak](https://github.com/vonovak))
|
||||
- [build-tools][steps] Stop printing workflow hook parsing messages in build logs: native builds showed them under an "Unknown build phase" group, and custom builds warned about unknown hook keys and hooks on steps that never run. ([#4717](https://github.com/expo/eas-cli/pull/4717) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [worker] Retry Node.js installation without the internal Node.js cache mirror when the first attempt fails, while preserving other configured mirrors. ([#3452](https://github.com/expo/eas-cli/pull/3452) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Share one GraphQL client between the project ID check and the command, so commands send one fewer app query. ([#4646](https://github.com/expo/eas-cli/pull/4646) by [@vonovak](https://github.com/vonovak))
|
||||
- [eas-cli] Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs. ([#4694](https://github.com/expo/eas-cli/pull/4694) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status. ([#4693](https://github.com/expo/eas-cli/pull/4693) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [worker] Mask secrets in every field of uploaded job log records, such as logged errors, not only in log messages. ([#4698](https://github.com/expo/eas-cli/pull/4698) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Make command descriptions in `eas --help` consistently lowercase without trailing periods. ([#4385](https://github.com/expo/eas-cli/pull/4385) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli][build-tools] Bump Node.js to 22.23.4 to fix the CVEs in the [October 2026 Node.js security releases](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases). ([#4714](https://github.com/expo/eas-cli/pull/4714) by [@ramonclaudio](https://github.com/ramonclaudio))
|
||||
|
||||
## [25.3.0](https://github.com/expo/eas-cli/releases/tag/v25.3.0) - 2026-10-20
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add a Free plan build limit checkout experiment to `eas build` that offers to open the Starter plan checkout. ([#4633](https://github.com/expo/eas-cli/pull/4633) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [build-tools] Let serve-sim boot, install and launch Argent iOS sessions. ([#4602](https://github.com/expo/eas-cli/pull/4602) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Let serve-sim boot, install and launch Appium iOS sessions. ([#4603](https://github.com/expo/eas-cli/pull/4603) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Upload the screen recording session manifest as a JSON artifact alongside each video. ([#4689](https://github.com/expo/eas-cli/pull/4689) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add a `--system-image` flag to `eas simulator:start` to select the Android SDK system image for the emulator. ([#4500](https://github.com/expo/eas-cli/pull/4500) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Quote Android launch arguments and URLs for the device shell in simulator sessions, so values with spaces or shell characters reach `am start` as one argument; pass raw values and remove any shell quoting added as a workaround. ([#4695](https://github.com/expo/eas-cli/pull/4695) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Wait up to five minutes for Google Play to finish processing an uploaded bundle before giving up on the edit commit. ([#4700](https://github.com/expo/eas-cli/pull/4700) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [build-tools] Reset the device run session idle timer on iOS Simulator preview input. ([#4691](https://github.com/expo/eas-cli/pull/4691) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Forward captured Simulator traffic through the local egress proxy. ([#4598](https://github.com/expo/eas-cli/pull/4598) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [25.2.0](https://github.com/expo/eas-cli/releases/tag/v25.2.0) - 2026-10-17
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add experimentation client. ([#4558](https://github.com/expo/eas-cli/pull/4558) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [eas-cli] Add a Free plan build limit guidance experiment for coding agents to `eas build`. ([#4631](https://github.com/expo/eas-cli/pull/4631) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [build-tools] Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard. ([#4544](https://github.com/expo/eas-cli/pull/4544) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `eas simulator:control` to run serve-sim actions, with file upload and download, on a remote iOS simulator session. ([#4641](https://github.com/expo/eas-cli/pull/4641) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Prepare authenticated simulator action transport and file transfers. ([#4642](https://github.com/expo/eas-cli/pull/4642) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [25.1.1](https://github.com/expo/eas-cli/releases/tag/v25.1.1) - 2026-10-16
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
@@ -215,7 +215,7 @@ if: ${{ steps.previous_step.outputs.success == 'true' && env.ENVIRONMENT == 'pro
|
||||
Artifacts tracked as `ArtifactToUpload`:
|
||||
|
||||
- Managed artifacts (APK, IPA, AAB) with specific handling
|
||||
- Workflow artifacts for any file
|
||||
- Generic artifacts for any file
|
||||
- Upload via `ctx.uploadArtifact()` or `upload-artifact` step
|
||||
|
||||
## Package Interdependencies
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "27.4.1",
|
||||
"PRODUCTION": "27.4.1"
|
||||
"STAGING": "25.1.1",
|
||||
"PRODUCTION": "25.1.1"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "27.5.0",
|
||||
"version": "25.1.1",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -6,4 +6,4 @@ MISE_NODE_COREPACK = "true"
|
||||
actionlint = "1.7.12"
|
||||
gcloud = "570.0.0"
|
||||
jq = "1.8.1"
|
||||
node = "22.23.4"
|
||||
node = "22.23.3"
|
||||
@@ -1,120 +1,5 @@
|
||||
# @expo/build-tools
|
||||
|
||||
## 27.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stop an agent-device session that is still starting up when it is stopped. ([#4666](https://github.com/expo/eas-cli/pull/4666) by [@gwdp](https://github.com/gwdp))
|
||||
- Stop an Appium session that is still starting up when it is stopped. ([#4668](https://github.com/expo/eas-cli/pull/4668) by [@gwdp](https://github.com/gwdp))
|
||||
Report temporary-directory cleanup failures when Stop interrupts Appium installation.
|
||||
- Stop an Argent session that is still starting up when it is stopped. ([#4667](https://github.com/expo/eas-cli/pull/4667) by [@gwdp](https://github.com/gwdp))
|
||||
- Use the sandbox worker token for sandbox jobs when www provides one, and keep Expo credentials out of the commands of those jobs. ([#4807](https://github.com/expo/eas-cli/pull/4807) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- Stop a web-preview session that is still starting up when it is stopped. ([#4669](https://github.com/expo/eas-cli/pull/4669) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 27.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Wait for a starting warm Simulator launcher before claiming its session, instead of falling back to a cold start. ([#4810](https://github.com/expo/eas-cli/pull/4810) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 27.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add `eas/start_warm_appium_remote_session`, which runs the Appium session on the warm Simulator, or starts cold. An `appium` claim stops the unused tools first, so WDA does not share the Simulator with the agent-device runner. ([#4735](https://github.com/expo/eas-cli/pull/4735) by [@gwdp](https://github.com/gwdp))
|
||||
- Add warm iOS session steps (`eas/start_warm_serve_sim_remote_session`, `eas/start_warm_agent_device_session`, `eas/start_warm_argent_remote_session`). They run the existing session on the warm Simulator, or start cold. `eas/start_agent_device_session` also accepts `install_app_path` and `launch_app_identifier`. ([#4723](https://github.com/expo/eas-cli/pull/4723) by [@gwdp](https://github.com/gwdp))
|
||||
- Update the app version and build number from the final application archive, so the build shows the values the native build resolved. ([#3499](https://github.com/expo/eas-cli/pull/3499) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Upload the final agent-device session artifacts before stopping the daemon, including sessions that reuse a warm Simulator. ([#4750](https://github.com/expo/eas-cli/pull/4750) by [@gwdp](https://github.com/gwdp))
|
||||
- Add internal helpers that claim the warm Simulator through the launcher and adopt it on the warm serve-sim. Skip the Expo Go install when the warm Simulator already has the same Expo Go version. ([#4724](https://github.com/expo/eas-cli/pull/4724) by [@gwdp](https://github.com/gwdp))
|
||||
- Recover warm Simulator recordings when their MP4 exists before the shutdown manifest is ready. ([#4789](https://github.com/expo/eas-cli/pull/4789) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 27.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stop warmup in the background for jobs that do not use the warm Simulator, so job setup does not wait for it. Before build-tools boots or clones a Simulator, it waits until the launcher's state file records that cleanup finished, and fails if cleanup does not finish in time. ([#4721](https://github.com/expo/eas-cli/pull/4721) by [@gwdp](https://github.com/gwdp))
|
||||
- Recover unhealthy device-session tunnels without changing their URLs and block further traffic when shutdown starts, even if ngrok cleanup times out. ([#4526](https://github.com/expo/eas-cli/pull/4526) by [@gwdp](https://github.com/gwdp))
|
||||
- Stop sending the SSH host when registering an SSH session. ([#4784](https://github.com/expo/eas-cli/pull/4784) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
## 27.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Use single-device previews when the session already has a valid device ID. ([#4733](https://github.com/expo/eas-cli/pull/4733) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Explain missing background task identifiers reported by App Store Connect. ([#3465](https://github.com/expo/eas-cli/pull/3465) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Explain App Store Connect NOT_AUTHORIZED responses in the shared API client. ([#3462](https://github.com/expo/eas-cli/pull/3462) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Explain App Store Connect UNEXPECTED_ERROR responses in the shared API client. ([#3467](https://github.com/expo/eas-cli/pull/3467) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Explain missing app icons reported by App Store Connect, including codes 90022 and 90023, while preserving Apple’s validation details. ([#3466](https://github.com/expo/eas-cli/pull/3466) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- List failed Google Play commit attempts in order, including network failures, when the outcome is uncertain. ([#4660](https://github.com/expo/eas-cli/pull/4660) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Fix a five-minute cleanup delay when a device session is canceled during startup and its screen recorder fails before connecting. ([#4761](https://github.com/expo/eas-cli/pull/4761) by [@gwdp](https://github.com/gwdp))
|
||||
- Handle TURN requests for a stopped device session without reporting a warning. ([#4762](https://github.com/expo/eas-cli/pull/4762) by [@gwdp](https://github.com/gwdp))
|
||||
- Preserve indented and incomplete Xcode compiler errors in formatted build logs, including the final diagnostic when logging stops. ([#4365](https://github.com/expo/eas-cli/pull/4365) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## 27.1.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Explain App Store Connect error 90189 as a duplicate build number. ([#3461](https://github.com/expo/eas-cli/pull/3461) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Put the `NPM_TOKEN` registry credential in the builder's npm user config on EAS Build (`~/.npmrc`, or the file `NPM_CONFIG_USERCONFIG` names), where pnpm 10.34.2+ and 11.5.3+ still expand `${NPM_TOKEN}` (they ignore it in a project-level `.npmrc`). Explain what to change when the project's pnpm version needs a newer Node.js than the build runs, including when eas.json sets `node` or `pnpm`. ([#4729](https://github.com/expo/eas-cli/pull/4729) by [@hSATAC](https://github.com/hSATAC))
|
||||
- Install `@expo/repack-app` for the `eas/repack` step with npm in a sandbox that sees only `PATH` and its own `HOME`, so the build machine's npm and Yarn configuration cannot break the install, fetch it through the EAS npm cache when the job uses one, and show the installer output in the build log. The step no longer looks the version up separately before installing. When the npm cache fails, the install retries against the public registry like the project install does; the `eas/run_agent` agent CLI install shares the same helper and gains the same retry. ([#4728](https://github.com/expo/eas-cli/pull/4728) by [@hSATAC](https://github.com/hSATAC))
|
||||
|
||||
## 27.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Keep the launched app open when an Appium iOS session starts. ([#4699](https://github.com/expo/eas-cli/pull/4699) by [@gwdp](https://github.com/gwdp))
|
||||
- Run Appium through the prepared Full Disk Access helper when available on macOS. ([#4622](https://github.com/expo/eas-cli/pull/4622) by [@gwdp](https://github.com/gwdp))
|
||||
- Identify the build or application archive when a download fails, and explain when the archive is no longer available. ([#4703](https://github.com/expo/eas-cli/pull/4703) by [@gwdp](https://github.com/gwdp))
|
||||
- Run apsd setup in the background without delaying iOS app launch or preview readiness. ([#4706](https://github.com/expo/eas-cli/pull/4706) by [@gwdp](https://github.com/gwdp))
|
||||
- Handle a device session stopped during startup as normal cleanup instead of reporting a startup error. ([#4716](https://github.com/expo/eas-cli/pull/4716) by [@gwdp](https://github.com/gwdp))
|
||||
- Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives. ([#4731](https://github.com/expo/eas-cli/pull/4731) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 26.0.0
|
||||
|
||||
### Major Changes
|
||||
|
||||
- Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports. ([#4719](https://github.com/expo/eas-cli/pull/4719) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Stop sandboxes after an optional maximum duration measured from readiness. ([#4702](https://github.com/expo/eas-cli/pull/4702) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Unify signed URL uploading to a single implementation and use it everywhere. ([#4712](https://github.com/expo/eas-cli/pull/4712) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Default Android emulators to 4 GiB of RAM to reduce ADB disconnects during large transfers. Retain the 2 GiB default for API 34 and below outside interactive device sessions, and preserve explicit emulator configuration overrides. ([#4727](https://github.com/expo/eas-cli/pull/4727) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions. ([#4696](https://github.com/expo/eas-cli/pull/4696) by [@brentvatne](https://github.com/brentvatne))
|
||||
- Retry transient failures when reporting that a device run session has started. ([#4680](https://github.com/expo/eas-cli/pull/4680) by [@gwdp](https://github.com/gwdp))
|
||||
- Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log. ([#4690](https://github.com/expo/eas-cli/pull/4690) by [@hSATAC](https://github.com/hSATAC))
|
||||
- Stop printing workflow hook parsing messages in build logs: native builds showed them under an "Unknown build phase" group, and custom builds warned about unknown hook keys and hooks on steps that never run. ([#4717](https://github.com/expo/eas-cli/pull/4717) by [@hSATAC](https://github.com/hSATAC))
|
||||
|
||||
## 25.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Let serve-sim boot, install and launch Argent iOS sessions. ([#4602](https://github.com/expo/eas-cli/pull/4602) by [@gwdp](https://github.com/gwdp))
|
||||
- Let serve-sim boot, install and launch Appium iOS sessions. ([#4603](https://github.com/expo/eas-cli/pull/4603) by [@gwdp](https://github.com/gwdp))
|
||||
- Upload the screen recording session manifest as a JSON artifact alongside each video. ([#4689](https://github.com/expo/eas-cli/pull/4689) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Quote Android launch arguments and URLs for the device shell in simulator sessions, so values with spaces or shell characters reach `am start` as one argument; pass raw values and remove any shell quoting added as a workaround. ([#4695](https://github.com/expo/eas-cli/pull/4695) by [@brentvatne](https://github.com/brentvatne))
|
||||
- Wait up to five minutes for Google Play to finish processing an uploaded bundle before giving up on the edit commit. ([#4700](https://github.com/expo/eas-cli/pull/4700) by [@hSATAC](https://github.com/hSATAC))
|
||||
- Reset the device run session idle timer on iOS Simulator preview input. ([#4691](https://github.com/expo/eas-cli/pull/4691) by [@gwdp](https://github.com/gwdp))
|
||||
- Forward captured Simulator traffic through the local egress proxy. ([#4598](https://github.com/expo/eas-cli/pull/4598) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 25.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard. ([#4544](https://github.com/expo/eas-cli/pull/4544) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 25.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "27.5.0",
|
||||
"version": "25.1.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -26,7 +26,7 @@
|
||||
"build:egress-guard": "resources/egress-guard/build.sh",
|
||||
"test:egress-guard": "resources/egress-guard/tests/run-policy-tests.sh && resources/egress-guard/tests/run-guard-tests.sh",
|
||||
"typecheck": "tsc",
|
||||
"generate-appium-commands": "mise exec [email protected]3.4 -- node scripts/generate-appium-commands.js",
|
||||
"generate-appium-commands": "mise exec [email protected]2.0 -- node scripts/generate-appium-commands.js",
|
||||
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
|
||||
"jest-unit": "jest --config jest/unit-config.ts",
|
||||
"jest-integration": "jest --config jest/integration-config.ts",
|
||||
@@ -42,18 +42,18 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "27.5.0",
|
||||
"@expo/eas-build-job": "27.5.0",
|
||||
"@expo/downloader": "25.1.1",
|
||||
"@expo/eas-build-job": "25.1.1",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "27.5.0",
|
||||
"@expo/logger": "25.1.1",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "27.5.0",
|
||||
"@expo/template-file": "27.5.0",
|
||||
"@expo/turtle-spawn": "27.5.0",
|
||||
"@expo/xcpretty": "^4.4.7",
|
||||
"@expo/steps": "25.1.1",
|
||||
"@expo/template-file": "25.1.1",
|
||||
"@expo/turtle-spawn": "25.1.1",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
"@urql/core": "^6.0.1",
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
BuildPhase,
|
||||
BuildPhaseResult,
|
||||
BuildTrigger,
|
||||
EnvironmentSecretType,
|
||||
Job,
|
||||
Metadata,
|
||||
Platform,
|
||||
@@ -32,34 +31,6 @@ describe('BuildContext', () => {
|
||||
(fs.readdir as unknown as jest.Mock).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('keeps the sandbox worker token out of the env and keeps the other secrets', () => {
|
||||
const ctx = new BuildContext(
|
||||
{
|
||||
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
|
||||
secrets: {
|
||||
environmentSecrets: [
|
||||
{
|
||||
name: 'EAS_SANDBOX_WORKER_TOKEN',
|
||||
value: 'sandbox-worker-token',
|
||||
type: EnvironmentSecretType.STRING,
|
||||
},
|
||||
{ name: 'KEEP', value: 'value', type: EnvironmentSecretType.STRING },
|
||||
],
|
||||
},
|
||||
} as Job,
|
||||
{
|
||||
env: { __API_SERVER_URL: 'https://api.expo.test' },
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
|
||||
expect(ctx.env.EAS_SANDBOX_WORKER_TOKEN).toBeUndefined();
|
||||
expect(ctx.env.KEEP).toBe('value');
|
||||
});
|
||||
|
||||
it('should merge secrets', async () => {
|
||||
const robotAccessToken = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/eas-environment-secrets/', { recursive: true });
|
||||
|
||||
@@ -361,67 +361,6 @@ describe('Datadog singleton', () => {
|
||||
expect(turtleFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('POSTs distribution metrics to the sandbox worker API with the sandbox worker token', () => {
|
||||
turtleFetchMock.mockResolvedValueOnce({} as Response);
|
||||
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
sandboxWorkerToken: 'sandbox-token',
|
||||
target: { kind: 'sandbox' },
|
||||
});
|
||||
|
||||
Datadog.distribution('eas.workflow.hook', 1, { hook: 'on_start' });
|
||||
|
||||
expect(turtleFetchMock).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/sandbox-worker/metrics',
|
||||
'POST',
|
||||
{
|
||||
json: {
|
||||
metrics: [
|
||||
{
|
||||
name: 'eas.workflow.hook',
|
||||
type: 'distribution',
|
||||
value: 1,
|
||||
tags: { hook: 'on_start', target: 'job_run' },
|
||||
},
|
||||
],
|
||||
},
|
||||
headers: { 'expo-sandbox-worker-token': 'sandbox-token' },
|
||||
retries: 2,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('labels metric upload failures for sandbox targets with the sandbox kind', async () => {
|
||||
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
sandboxWorkerToken: 'sandbox-token',
|
||||
target: { kind: 'sandbox' },
|
||||
});
|
||||
|
||||
Datadog.distribution('eas.workflow.hook', 1);
|
||||
await flushPromises();
|
||||
|
||||
expect(sentryCaptureMock).toHaveBeenCalledWith(
|
||||
'Failed to report turtle sandbox metric',
|
||||
expect.any(Error),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('does not report logs for sandbox targets', () => {
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
sandboxWorkerToken: 'sandbox-token',
|
||||
target: { kind: 'sandbox' },
|
||||
});
|
||||
|
||||
Datadog.log('Custom build saw user-provided function');
|
||||
|
||||
expect(turtleFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flushes pending metric uploads', async () => {
|
||||
let resolveUpload!: (response: Response) => void;
|
||||
turtleFetchMock.mockReturnValueOnce(
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { type watchDeviceRunSessionStartup } from '../../steps/utils/deviceRunSessionStartup';
|
||||
import { DeviceRunSessionStoppedError } from '../../steps/utils/remoteDeviceRunSession';
|
||||
|
||||
/**
|
||||
* A `watchDeviceRunSessionStartup` stand-in that never polls. A test calls `stop` on the
|
||||
* returned watch (`jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value`) to act like
|
||||
* a Stop request during startup.
|
||||
*/
|
||||
export function createStartupWatchMock({
|
||||
startup,
|
||||
}: Parameters<typeof watchDeviceRunSessionStartup>[0]): ReturnType<
|
||||
typeof watchDeviceRunSessionStartup
|
||||
> & { stop: () => void } {
|
||||
const stopped = new DeviceRunSessionStoppedError('Stopped during startup.');
|
||||
return {
|
||||
stopped,
|
||||
stop: () => startup.abort(stopped),
|
||||
isStopped: () => startup.signal.reason === stopped,
|
||||
completeAsync: jest.fn(async () => startup.signal.throwIfAborted()),
|
||||
finishAsync: jest.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
@@ -125,31 +125,6 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('explains that the pnpm version needs a newer Node.js than the build runs', async () => {
|
||||
const fakeError = new Error();
|
||||
const err = await resolveBuildPhaseErrorAsync(
|
||||
fakeError,
|
||||
[
|
||||
'Running "pnpm install --frozen-lockfile" in /home/expo/workingdir/build directory',
|
||||
'warn: This version of pnpm requires at least Node.js v22.13',
|
||||
'warn: The current version of Node.js is v20.19.4',
|
||||
'warn: Visit https://r.pnpm.io/comp to see the list of past pnpm versions with respective Node.js version support.',
|
||||
'Error [ERR_UNKNOWN_BUILTIN_MODULE]: No such built-in module: node:sqlite',
|
||||
],
|
||||
{
|
||||
job: { platform: Platform.ANDROID } as Job,
|
||||
phase: BuildPhase.INSTALL_DEPENDENCIES,
|
||||
env: {},
|
||||
},
|
||||
'/fake/path'
|
||||
);
|
||||
expect(err.errorCode).toBe('EAS_BUILD_PNPM_UNSUPPORTED_NODE_VERSION_ERROR');
|
||||
expect(err.message).toBe(
|
||||
'This build uses a pnpm version that requires Node.js 22.13.0 or newer, but runs Node.js 20.19.4. You can fix it by either:\n- setting "node" to "22.13.0" or a newer version in the build profile in eas.json\n- using a pnpm version that supports Node.js 20.19.4: change "packageManager" in package.json if your project sets it, and set "pnpm" in the build profile in eas.json'
|
||||
);
|
||||
expect(err.docsUrl).toBe('https://docs.expo.dev/eas/json/');
|
||||
});
|
||||
|
||||
it('detects xcode line error', async () => {
|
||||
vol.fromJSON({
|
||||
'/path/to/xcodelogs.log': originalFs.readFileSync(
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { errors } from '@expo/eas-build-job';
|
||||
import nullthrows from 'nullthrows';
|
||||
import semver from 'semver';
|
||||
|
||||
import { ErrorHandler } from './errors.types';
|
||||
|
||||
// example log: "warn: This version of pnpm requires at least Node.js v22.13" followed by "warn: The current version of Node.js is v20.19.4"
|
||||
const PNPM_UNSUPPORTED_NODE_VERSION_REGEXP =
|
||||
/This version of pnpm requires at least Node\.js v([\d.]+)\n.*The current version of Node\.js is v([\d.]+)/;
|
||||
|
||||
function createPnpmUnsupportedNodeVersionError(matchResult: RegExpMatchArray): errors.UserError {
|
||||
const [, requiredNodeVersion, currentNodeVersion] = matchResult;
|
||||
const minimumNodeVersion = nullthrows(semver.coerce(requiredNodeVersion)).version;
|
||||
return new errors.UserError(
|
||||
'EAS_BUILD_PNPM_UNSUPPORTED_NODE_VERSION_ERROR',
|
||||
`This build uses a pnpm version that requires Node.js ${minimumNodeVersion} or newer, but runs Node.js ${currentNodeVersion}. You can fix it by either:
|
||||
- setting "node" to "${minimumNodeVersion}" or a newer version in the build profile in eas.json
|
||||
- using a pnpm version that supports Node.js ${currentNodeVersion}: change "packageManager" in package.json if your project sets it, and set "pnpm" in the build profile in eas.json`,
|
||||
{ docsUrl: 'https://docs.expo.dev/eas/json/' }
|
||||
);
|
||||
}
|
||||
|
||||
export const pnpmUnsupportedNodeVersionErrorHandler: ErrorHandler<errors.UserError> = {
|
||||
regexp: PNPM_UNSUPPORTED_NODE_VERSION_REGEXP,
|
||||
createError: createPnpmUnsupportedNodeVersionError,
|
||||
};
|
||||
|
||||
export function detectPnpmUnsupportedNodeVersionError(
|
||||
output: string
|
||||
): errors.UserError | undefined {
|
||||
const match = output.match(PNPM_UNSUPPORTED_NODE_VERSION_REGEXP);
|
||||
return match ? createPnpmUnsupportedNodeVersionError(match) : undefined;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BuildMode, BuildPhase, Platform, Workflow, errors } from '@expo/eas-build-job';
|
||||
|
||||
import { ErrorHandler, XCODE_BUILD_PHASE } from './errors.types';
|
||||
import { pnpmUnsupportedNodeVersionErrorHandler } from './pnpmUnsupportedNodeVersionError';
|
||||
|
||||
import UserError = errors.UserError;
|
||||
|
||||
@@ -212,7 +211,6 @@ You are seeing this error because either:
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
pnpmUnsupportedNodeVersionErrorHandler,
|
||||
{
|
||||
platform: Platform.IOS,
|
||||
phase: XCODE_BUILD_PHASE,
|
||||
|
||||
@@ -53,7 +53,7 @@ jest.mock('../../steps/utils/android/gradleConfig', () => ({
|
||||
injectCredentialsGradleConfig: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/artifacts', () => ({
|
||||
uploadApplicationArchive: jest.fn(async () => []),
|
||||
uploadApplicationArchive: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/expoUpdates', () => ({
|
||||
configureExpoUpdatesIfInstalledAsync: jest.fn(),
|
||||
|
||||
@@ -17,10 +17,6 @@ import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
|
||||
import { prebuildAsync } from '../common/prebuild';
|
||||
import { JobHooksRef, setupAsync } from '../common/setup';
|
||||
import { Artifacts, BuildContext, SkipNativeBuildError } from '../context';
|
||||
import {
|
||||
extractAndroidVersionAsync,
|
||||
updateResolvedVersionAsync,
|
||||
} from '../steps/functions/updateResolvedVersion';
|
||||
import {
|
||||
cacheStatsAsync,
|
||||
restoreCcacheAsync,
|
||||
@@ -224,30 +220,11 @@ async function buildInnerAsync(
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
|
||||
const [archivePath] = await uploadApplicationArchive(ctx, {
|
||||
await uploadApplicationArchive(ctx, {
|
||||
patternOrPath: resolveArtifactPath(ctx.job),
|
||||
rootDir: ctx.getReactNativeProjectDirectory(),
|
||||
logger: ctx.logger,
|
||||
});
|
||||
const buildId = ctx.env.EAS_BUILD_ID;
|
||||
if (!archivePath || !buildId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { appVersion, appBuildVersion } = await extractAndroidVersionAsync(archivePath);
|
||||
if (appVersion || appBuildVersion) {
|
||||
await updateResolvedVersionAsync(ctx.graphqlClient, buildId, {
|
||||
appVersion,
|
||||
appBuildVersion,
|
||||
});
|
||||
ctx.logger.info(
|
||||
`Updated resolved version: ${appVersion ?? 'N/A'} (${appBuildVersion ?? 'N/A'})`
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
Sentry.capture('Failed to update resolved version', err);
|
||||
ctx.logger.warn({ err }, 'Failed to update resolved version (non-fatal)');
|
||||
}
|
||||
});
|
||||
|
||||
if (shouldUploadEmbeddedBundle(ctx)) {
|
||||
|
||||
@@ -18,10 +18,6 @@ import { downloadApplicationArchiveAsync } from '../ios/resign';
|
||||
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
|
||||
import { Sentry } from '../sentry';
|
||||
import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
|
||||
import {
|
||||
extractIosVersionAsync,
|
||||
updateResolvedVersionAsync,
|
||||
} from '../steps/functions/updateResolvedVersion';
|
||||
import {
|
||||
cacheStatsAsync,
|
||||
restoreCcacheAsync,
|
||||
@@ -240,30 +236,11 @@ async function buildInnerAsync(
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
|
||||
const [archivePath] = await uploadApplicationArchive(ctx, {
|
||||
await uploadApplicationArchive(ctx, {
|
||||
patternOrPath: resolveArtifactPath(ctx.job),
|
||||
rootDir: ctx.getReactNativeProjectDirectory(),
|
||||
logger: ctx.logger,
|
||||
});
|
||||
const buildId = ctx.env.EAS_BUILD_ID;
|
||||
if (!archivePath || !buildId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { appVersion, appBuildVersion } = await extractIosVersionAsync(archivePath);
|
||||
if (appVersion || appBuildVersion) {
|
||||
await updateResolvedVersionAsync(ctx.graphqlClient, buildId, {
|
||||
appVersion,
|
||||
appBuildVersion,
|
||||
});
|
||||
ctx.logger.info(
|
||||
`Updated resolved version: ${appVersion ?? 'N/A'} (${appBuildVersion ?? 'N/A'})`
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
Sentry.capture('Failed to update resolved version', err);
|
||||
ctx.logger.warn({ err }, 'Failed to update resolved version (non-fatal)');
|
||||
}
|
||||
});
|
||||
|
||||
if (shouldUploadEmbeddedBundle(ctx)) {
|
||||
|
||||
@@ -16,13 +16,11 @@ function createCtx(
|
||||
{ workingdir = '/tmp/wd' }: { workingdir?: string } = {}
|
||||
): {
|
||||
ctx: BuildContext<Ios.Job>;
|
||||
info: jest.Mock;
|
||||
warn: jest.Mock;
|
||||
} {
|
||||
const info = jest.fn();
|
||||
const warn = jest.fn();
|
||||
const logger = {
|
||||
info,
|
||||
info: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn,
|
||||
@@ -38,7 +36,7 @@ function createCtx(
|
||||
workingdir,
|
||||
}
|
||||
);
|
||||
return { ctx, info, warn };
|
||||
return { ctx, warn };
|
||||
}
|
||||
|
||||
// Project checkout lives at <workingdir>/build (job projectRootDirectory is '.').
|
||||
@@ -81,31 +79,22 @@ describe(parseJobHooksAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('writes nothing to the build log while parsing', async () => {
|
||||
const { ctx, info, warn } = createCtx({
|
||||
before_install_node_modules: [{ run: 'echo hi' }],
|
||||
before_submit: [{ run: 'echo hi' }],
|
||||
before_bogus: [{ run: 'echo hi' }],
|
||||
});
|
||||
await parseJobHooksAsync(ctx, INSTALL);
|
||||
expect(info).not.toHaveBeenCalled();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats an unknown hook key as inert', async () => {
|
||||
const { ctx } = createCtx({
|
||||
it('treats an unknown hook key as inert and warns', async () => {
|
||||
const { ctx, warn } = createCtx({
|
||||
before_bogus: [{ run: 'echo hi' }],
|
||||
});
|
||||
const parsed = await parseJobHooksAsync(ctx, INSTALL);
|
||||
expect(parsed!.hookEntriesByKey).toEqual({});
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('unknown hook key "before_bogus"'));
|
||||
});
|
||||
|
||||
it('shape-checks a registered-but-unwrapped key, then skips it', async () => {
|
||||
const { ctx } = createCtx({
|
||||
it('shape-checks a registered-but-unwrapped key, then warns and skips it', async () => {
|
||||
const { ctx, warn } = createCtx({
|
||||
before_submit: [{ run: 'echo hi' }],
|
||||
});
|
||||
const parsed = await parseJobHooksAsync(ctx, INSTALL);
|
||||
expect(parsed!.hookEntriesByKey.before_submit).toBeUndefined();
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hooks.before_submit'));
|
||||
});
|
||||
|
||||
it('shape-validates a registered-but-unwrapped key even though it is skipped', async () => {
|
||||
@@ -203,9 +192,10 @@ describe(parseJobHooksAsync, () => {
|
||||
});
|
||||
|
||||
it('never loads a composite function referenced under a registered-but-unwrapped key', async () => {
|
||||
const { ctx } = createCtx({ before_submit: [{ uses: './.eas/functions/missing' }] });
|
||||
const { ctx, warn } = createCtx({ before_submit: [{ uses: './.eas/functions/missing' }] });
|
||||
const parsed = await parseJobHooksAsync(ctx, INSTALL);
|
||||
expect(parsed!.hookEntriesByKey).toEqual({});
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hooks.before_submit'));
|
||||
});
|
||||
|
||||
it('wraps a cross-key duplicate step id in the aggregate error', async () => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
ArchiveSourceType,
|
||||
BuildMode,
|
||||
BuildTrigger,
|
||||
EnvironmentSecretType,
|
||||
ErrorCode,
|
||||
Job,
|
||||
Platform,
|
||||
@@ -13,7 +12,6 @@ import spawn from '@expo/turtle-spawn';
|
||||
import { randomBytes, randomUUID } from 'crypto';
|
||||
import { vol } from 'memfs';
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
import { Readable } from 'stream';
|
||||
import { setTimeout } from 'timers/promises';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
@@ -117,73 +115,6 @@ describe('projectSources', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('fetches a sandbox project source from the sandbox worker router with only the worker token', async () => {
|
||||
const gitCommitHash = randomBytes(20).toString('hex');
|
||||
const ctx = new BuildContext(
|
||||
{
|
||||
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash,
|
||||
},
|
||||
secrets: {
|
||||
robotAccessToken: 'sandbox-worker-token',
|
||||
environmentSecrets: [
|
||||
{
|
||||
name: 'EAS_SANDBOX_WORKER_TOKEN',
|
||||
type: EnvironmentSecretType.STRING,
|
||||
value: 'sandbox-worker-token',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as Job,
|
||||
{
|
||||
env: {
|
||||
__API_SERVER_URL: 'https://api.expo.dev',
|
||||
EAS_BUILD_ID: randomUUID(),
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
},
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
type: ArchiveSourceType.GIT,
|
||||
},
|
||||
}),
|
||||
}) as Response
|
||||
);
|
||||
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://api.expo.dev/v2/sandbox-worker/project-archive',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'expo-sandbox-worker-token': 'sandbox-worker-token' },
|
||||
})
|
||||
);
|
||||
expect(shallowCloneRepositoryAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
archiveSource: expect.objectContaining({
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['http', 'network', 'json', 'schema', 'missing-url', 'PATH', 'GCS', 'R2'])(
|
||||
'throws a system error if refresh fails (%s)',
|
||||
async failure => {
|
||||
@@ -459,13 +390,7 @@ describe('projectSources', () => {
|
||||
ctx.graphqlClient.mutation = mockGraphqlMutation as any;
|
||||
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
let uploadedBody = '';
|
||||
fetchMock.mockImplementation(async (_url, request) => {
|
||||
for await (const chunk of request!.body as Readable) {
|
||||
uploadedBody += chunk;
|
||||
}
|
||||
return { ok: true } as Response;
|
||||
});
|
||||
fetchMock.mockImplementation(async () => ({ ok: true }) as Response);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: ctx.job.projectArchive }),
|
||||
@@ -489,14 +414,10 @@ describe('projectSources', () => {
|
||||
'https://storage.example.com/upload',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'x-custom-header': 'value',
|
||||
'content-length': '63',
|
||||
'content-type': 'text/plain;charset=UTF-8',
|
||||
},
|
||||
headers: { 'x-custom-header': 'value' },
|
||||
body: '{"archiveContent":["project/README.md","project/package.json"]}',
|
||||
})
|
||||
);
|
||||
expect(uploadedBody).toBe('{"archiveContent":["project/README.md","project/package.json"]}');
|
||||
|
||||
// Verify build metadata was updated
|
||||
expect(mockGraphqlMutation).toHaveBeenNthCalledWith(
|
||||
|
||||
@@ -3,7 +3,6 @@ import { EventEmitter } from 'events';
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import { ExpoRunFormatter } from '@expo/xcpretty';
|
||||
import fg from 'fast-glob';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../sentry';
|
||||
@@ -60,47 +59,6 @@ describe(XcodeBuildLogger, () => {
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flushes the final incomplete compiler error when the log reader is interrupted', async () => {
|
||||
const { ExpoRunFormatter: RealFormatter } =
|
||||
jest.requireActual<typeof import('@expo/xcpretty')>('@expo/xcpretty');
|
||||
jest.mocked(ExpoRunFormatter.create).mockImplementation(RealFormatter.create);
|
||||
|
||||
let rejectReader!: (error: Error) => void;
|
||||
const spawnPromise = Object.assign(
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
rejectReader = reject;
|
||||
}),
|
||||
{
|
||||
child: {
|
||||
stdout,
|
||||
kill: () =>
|
||||
rejectReader(Object.assign(new Error('tail interrupted'), { signal: 'SIGINT' })),
|
||||
},
|
||||
}
|
||||
);
|
||||
jest.mocked(spawnAsync).mockReturnValue(spawnPromise as any);
|
||||
const diagnostics =
|
||||
'/project/First.swift:12:3: error: first compiler error\n' +
|
||||
' /project/Last.swift:20:4: error: final compiler error\n';
|
||||
vol.fromJSON({ '/logs/build.log': diagnostics });
|
||||
const logger = createMockLogger();
|
||||
const buildLogger = new XcodeBuildLogger(logger, '/project');
|
||||
await buildLogger.watchLogFiles('/logs');
|
||||
stdout.emit('data', diagnostics);
|
||||
|
||||
await buildLogger.flush();
|
||||
|
||||
const output = jest
|
||||
.mocked(logger.info)
|
||||
.mock.calls.map(([line]) => String(line))
|
||||
.join('\n');
|
||||
expect(output).toContain('first compiler error');
|
||||
expect(output).toContain('final compiler error');
|
||||
expect(output).toContain('Last.swift:20:4');
|
||||
expect(output).toContain('2 error(s)');
|
||||
expect(output.match(/final compiler error/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('falls back to raw logs when the formatter throws', async () => {
|
||||
const logger = createMockLogger();
|
||||
await startWatchingAsync(logger);
|
||||
|
||||
@@ -44,8 +44,9 @@ export async function runHookableBuildPhaseAsync<TJob extends BuildJob, T>({
|
||||
if (hooks) {
|
||||
try {
|
||||
await runHookEntriesAsync({ ctx, hooks, timing: 'after', anchor, anchorFailed: true });
|
||||
} catch {
|
||||
// The phase error wins.
|
||||
} catch (hookError) {
|
||||
// The phase error wins; still surface the after-hook failure in the log.
|
||||
ctx.logger.error({ err: hookError }, `after_${anchor} hook failed`);
|
||||
}
|
||||
}
|
||||
throw phaseError;
|
||||
|
||||
@@ -4,7 +4,6 @@ import path from 'path';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
import { Sentry } from '../sentry';
|
||||
import { NpmCacheRegistryInstallError, isNpmCacheInstallFailure } from '../utils/npmCacheRegistry';
|
||||
import { PackageManager, findPackagerRootDir } from '../utils/packageManager';
|
||||
import { isUsingModernYarnVersion } from '../utils/project';
|
||||
|
||||
@@ -197,6 +196,10 @@ export function resolvePackagerDir(ctx: BuildContext<Job>): string {
|
||||
return packagerRunDir;
|
||||
}
|
||||
|
||||
function isNpmCacheInstallFailure(err: unknown, npmCacheUrl: string): boolean {
|
||||
return getErrorOutput(err).includes(npmCacheUrl);
|
||||
}
|
||||
|
||||
function isNpmCacheRegistryErrorLine(line: string, npmCacheUrl: string): boolean {
|
||||
return (
|
||||
line.includes(npmCacheUrl) &&
|
||||
@@ -204,9 +207,24 @@ function isNpmCacheRegistryErrorLine(line: string, npmCacheUrl: string): boolean
|
||||
);
|
||||
}
|
||||
|
||||
function getErrorOutput(err: unknown): string {
|
||||
if (!(err instanceof Error)) {
|
||||
return '';
|
||||
}
|
||||
const { stdout, stderr } = err as Error & { stdout?: string; stderr?: string };
|
||||
return [stdout, stderr].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
class NpmCacheRegistryNonFatalError extends SystemError {
|
||||
override name = 'NpmCacheRegistryNonFatalError';
|
||||
constructor() {
|
||||
super('Non-fatal npm cache registry error during dependency install');
|
||||
}
|
||||
}
|
||||
|
||||
class NpmCacheRegistryInstallError extends SystemError {
|
||||
override name = 'NpmCacheRegistryInstallError';
|
||||
constructor(cause: unknown) {
|
||||
super('Failed to install dependencies using npm cache registry', { cause });
|
||||
}
|
||||
}
|
||||
@@ -53,17 +53,19 @@ export async function parseJobHooksAsync<TJob extends BuildJob>(
|
||||
const globalContext = new BuildStepGlobalContext(customBuildContext, false);
|
||||
// Hook steps run in the checked-out project directory, not the pre-checkout
|
||||
// target directory the global context defaults to.
|
||||
globalContext.markAsCheckedOut();
|
||||
globalContext.markAsCheckedOut(ctx.logger);
|
||||
|
||||
const externalFunctions = getEasFunctions(customBuildContext);
|
||||
const externalFunctionGroups = getEasFunctionGroups(customBuildContext);
|
||||
|
||||
// Shape-validate every REGISTERED key, reachable or not (steps-world parity:
|
||||
// a malformed `before_submit` fails a steps build, so it fails here too). An
|
||||
// unknown key is inert — a key newer than this worker must be ignored, never
|
||||
// fail the job.
|
||||
// a malformed `before_submit` fails a steps build, so it fails here too), and
|
||||
// warn on keys this build won't run. An unknown key is inert — a key newer
|
||||
// than this worker must be ignored, never fail the job.
|
||||
for (const [key, steps] of Object.entries(hooks)) {
|
||||
if (parseHookKey(key) === null) {
|
||||
const parsed = parseHookKey(key);
|
||||
if (parsed === null) {
|
||||
ctx.logger.warn(`Ignoring unknown hook key "${key}".`);
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(steps) && steps.length === 0) {
|
||||
@@ -79,6 +81,15 @@ export async function parseJobHooksAsync<TJob extends BuildJob>(
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
// Registered but not wrapped by this build: shape-checked above, then
|
||||
// skipped. Function existence is deliberately NOT checked (that happens only
|
||||
// during construction) — an unknown `uses:` under an unwrapped key warns
|
||||
// rather than failing the build.
|
||||
if (!wrappedAnchors.includes(parsed.anchorId)) {
|
||||
ctx.logger.warn(
|
||||
`Ignoring "hooks.${key}": this build does not run the "${parsed.anchorId}" step.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Construct entries for the wrapped anchors in EXECUTION order (per anchor,
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { ArchiveSourceType } from '@expo/eas-build-job';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ProjectArchiveSourceSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal(ArchiveSourceType.GIT),
|
||||
repositoryUrl: z.string().url(),
|
||||
gitRef: z.string().nullable(),
|
||||
gitCommitHash: z.string(),
|
||||
}),
|
||||
z.object({ type: z.literal(ArchiveSourceType.URL), url: z.string().url() }),
|
||||
z.object({ type: z.literal(ArchiveSourceType.NONE) }),
|
||||
]);
|
||||
|
||||
export type ProjectArchiveSource = z.infer<typeof ProjectArchiveSourceSchema>;
|
||||
@@ -5,15 +5,13 @@ import { asyncResult } from '@expo/results';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs/promises';
|
||||
import { graphql } from 'gql.tada';
|
||||
import fetch from 'node-fetch';
|
||||
import nullthrows from 'nullthrows';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { shallowCloneRepositoryAsync } from './git';
|
||||
import { type ProjectArchiveSource, ProjectArchiveSourceSchema } from './projectArchiveSource';
|
||||
import { BuildContext } from '../context';
|
||||
import { getSandboxWorkerApi } from '../steps/utils/sandboxWorkerApi';
|
||||
import { uploadContentWithSignedUrl } from '../storage/uploadWithSignedUrl';
|
||||
import { turtleFetch } from '../utils/turtleFetch';
|
||||
|
||||
export async function prepareProjectSourcesAsync<TJob extends Job>(
|
||||
@@ -210,9 +208,10 @@ async function uploadProjectMetadataAsync(
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
|
||||
await uploadContentWithSignedUrl({
|
||||
signedUrl: uploadSession,
|
||||
content: JSON.stringify({ archiveContent: sortedFiles }),
|
||||
await fetch(uploadSession.url, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ archiveContent: sortedFiles }),
|
||||
headers: uploadSession.headers,
|
||||
});
|
||||
|
||||
const updateMetadataResult = await ctx.graphqlClient
|
||||
@@ -247,18 +246,7 @@ async function uploadProjectMetadataAsync(
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProjectArchiveSourceAsync(
|
||||
ctx: BuildContext<Job>
|
||||
): Promise<ProjectArchiveSource> {
|
||||
const sandboxWorkerApi = getSandboxWorkerApi(ctx);
|
||||
if (sandboxWorkerApi) {
|
||||
return await sandboxWorkerApi.getProjectArchiveSourceAsync({
|
||||
timeoutMs: 20000,
|
||||
retries: 3,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>) {
|
||||
const taskId = nullthrows(ctx.env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const expoApiServerURL = nullthrows(ctx.env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
const robotAccessToken = nullthrows(
|
||||
@@ -296,7 +284,20 @@ async function fetchProjectArchiveSourceAsync(
|
||||
);
|
||||
}
|
||||
|
||||
const dataResult = z.object({ data: ProjectArchiveSourceSchema }).safeParse(jsonResult.value);
|
||||
const dataResult = z
|
||||
.object({
|
||||
data: z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal(ArchiveSourceType.GIT),
|
||||
repositoryUrl: z.string().url(),
|
||||
gitRef: z.string().nullable(),
|
||||
gitCommitHash: z.string(),
|
||||
}),
|
||||
z.object({ type: z.literal(ArchiveSourceType.URL), url: z.string().url() }),
|
||||
z.object({ type: z.literal(ArchiveSourceType.NONE) }),
|
||||
]),
|
||||
})
|
||||
.safeParse(jsonResult.value);
|
||||
if (!dataResult.success) {
|
||||
throw new Error(
|
||||
`Unexpected data from server (${response.status}): ${z.prettifyError(dataResult.error)}`
|
||||
|
||||
@@ -71,12 +71,7 @@ export async function setupAsync<TJob extends BuildJob>(
|
||||
}
|
||||
);
|
||||
|
||||
await setUpNpmrcAsync({
|
||||
projectTargetDirectory: ctx.buildDirectory,
|
||||
workingDirectory: ctx.getReactNativeProjectDirectory(),
|
||||
env: ctx.env,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
await setUpNpmrcAsync(ctx, ctx.logger);
|
||||
if (ctx.job.platform === Platform.IOS && ctx.env.EAS_BUILD_RUNNER === 'eas-build') {
|
||||
await deleteXcodeEnvLocalIfExistsAsync(ctx as BuildContext<Ios.Job>);
|
||||
}
|
||||
|
||||
@@ -80,12 +80,9 @@ export class XcodeBuildLogger {
|
||||
this.logger.info(data.toString());
|
||||
}
|
||||
});
|
||||
try {
|
||||
await this.logReaderPromise;
|
||||
} finally {
|
||||
// flush() interrupts tail, but the formatter may still hold a final compiler diagnostic.
|
||||
this.logger.info(formatter.getBuildSummary());
|
||||
}
|
||||
await this.logReaderPromise;
|
||||
|
||||
this.logger.info(formatter.getBuildSummary());
|
||||
} catch (err: any) {
|
||||
if (!this.flushing) {
|
||||
this.loggerError = err;
|
||||
|
||||
@@ -3,13 +3,14 @@ import {
|
||||
BuildPhase,
|
||||
BuildPhaseResult,
|
||||
Env,
|
||||
type EnvironmentSecret,
|
||||
EnvironmentSecretType,
|
||||
GenericArtifactType,
|
||||
Job,
|
||||
LogMarker,
|
||||
ManagedArtifactType,
|
||||
Metadata,
|
||||
errors,
|
||||
isGenericArtifact,
|
||||
} from '@expo/eas-build-job';
|
||||
import { BuildTrigger } from '@expo/eas-build-job/dist/common';
|
||||
import { bunyan } from '@expo/logger';
|
||||
@@ -19,7 +20,6 @@ import path from 'path';
|
||||
|
||||
import { resolveBuildPhaseErrorAsync } from './buildErrors/detectError';
|
||||
import { Datadog } from './datadog';
|
||||
import { SANDBOX_WORKER_TOKEN_ENV } from './steps/utils/sandboxWorkerApi';
|
||||
import { readAppConfig } from './utils/appConfig';
|
||||
import { createTemporaryEnvironmentSecretFile } from './utils/environmentSecrets';
|
||||
import { PackageManager, resolvePackageManager } from './utils/packageManager';
|
||||
@@ -42,7 +42,7 @@ export type ArtifactToUpload =
|
||||
paths: string[];
|
||||
}
|
||||
| {
|
||||
type: null;
|
||||
type: GenericArtifactType;
|
||||
name: string;
|
||||
paths: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
@@ -241,7 +241,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
logger: bunyan;
|
||||
}): Promise<{ artifactId: string | null }> {
|
||||
const result = await this._uploadArtifact({ artifact, logger });
|
||||
if (result.filename && artifact.type !== null) {
|
||||
if (result.filename && !isGenericArtifact(artifact)) {
|
||||
this.artifacts[artifact.type] = result.filename;
|
||||
}
|
||||
return { artifactId: result.artifactId ?? null };
|
||||
@@ -410,7 +410,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
const environmentSecretsDirectory = path.join(this.workingdir, 'eas-environment-secrets');
|
||||
|
||||
const environmentSecrets: Record<string, string> = {};
|
||||
for (const { name, type, value } of job.secrets.environmentSecrets.filter(isJobSecret)) {
|
||||
for (const { name, type, value } of job.secrets.environmentSecrets) {
|
||||
if (type === EnvironmentSecretType.STRING) {
|
||||
environmentSecrets[name] = value;
|
||||
} else {
|
||||
@@ -424,9 +424,3 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
return environmentSecrets;
|
||||
}
|
||||
}
|
||||
|
||||
const EXCLUDED_JOB_SECRET_NAMES: ReadonlySet<string> = new Set([SANDBOX_WORKER_TOKEN_ENV]);
|
||||
|
||||
function isJobSecret({ name }: EnvironmentSecret): boolean {
|
||||
return !EXCLUDED_JOB_SECRET_NAMES.has(name);
|
||||
}
|
||||
@@ -1,30 +1,23 @@
|
||||
import { Sentry } from './sentry';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from './steps/utils/deviceRunSessionWorkerApi';
|
||||
import { SANDBOX_WORKER_TOKEN_HEADER } from './steps/utils/sandboxWorkerApi';
|
||||
import { turtleFetch } from './utils/turtleFetch';
|
||||
|
||||
type MetricsTarget =
|
||||
| { kind: 'build'; turtleBuildId: string }
|
||||
| { kind: 'jobRun'; turtleJobRunId: string }
|
||||
// A device run session job with a session token. The token names the session and its job run.
|
||||
| { kind: 'deviceRunSession' }
|
||||
| { kind: 'sandbox' };
|
||||
| { kind: 'deviceRunSession' };
|
||||
|
||||
type DatadogSetupOptions =
|
||||
| {
|
||||
expoApiV2BaseUrl: string;
|
||||
robotAccessToken: string;
|
||||
target: Exclude<MetricsTarget, { kind: 'deviceRunSession' | 'sandbox' }>;
|
||||
target: Exclude<MetricsTarget, { kind: 'deviceRunSession' }>;
|
||||
}
|
||||
| {
|
||||
expoApiV2BaseUrl: string;
|
||||
deviceRunSessionToken: string;
|
||||
target: Extract<MetricsTarget, { kind: 'deviceRunSession' }>;
|
||||
}
|
||||
| {
|
||||
expoApiV2BaseUrl: string;
|
||||
sandboxWorkerToken: string;
|
||||
target: Extract<MetricsTarget, { kind: 'sandbox' }>;
|
||||
};
|
||||
|
||||
let setupOptions: DatadogSetupOptions | null = null;
|
||||
@@ -48,12 +41,6 @@ function getMetricsRequest(options: DatadogSetupOptions): {
|
||||
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: options.deviceRunSessionToken },
|
||||
};
|
||||
}
|
||||
if ('sandboxWorkerToken' in options) {
|
||||
return {
|
||||
path: 'sandbox-worker/metrics',
|
||||
headers: { [SANDBOX_WORKER_TOKEN_HEADER]: options.sandboxWorkerToken },
|
||||
};
|
||||
}
|
||||
const { target, robotAccessToken } = options;
|
||||
return {
|
||||
path:
|
||||
|
||||
@@ -3,16 +3,8 @@ import HttpLogStream from './logging/HttpLogStream';
|
||||
import RemoteLoggerStream from './logging/RemoteLoggerStream';
|
||||
|
||||
export { Builders, HttpLogStream, RemoteLoggerStream };
|
||||
export {
|
||||
SignedUrlUploadApiError,
|
||||
SignedUrlUploadNetworkError,
|
||||
uploadWithSignedUrl,
|
||||
} from './storage/uploadWithSignedUrl';
|
||||
export type {
|
||||
SignedUrl,
|
||||
UploadWithSignedUrlOptions,
|
||||
UploadWithSignedUrlParams,
|
||||
} from './storage/uploadWithSignedUrl';
|
||||
export { uploadWithSignedUrl } from './storage/uploadWithSignedUrl';
|
||||
export type { SignedUrl, UploadWithSignedUrlParams } from './storage/uploadWithSignedUrl';
|
||||
export { downloadCacheAsync } from './steps/functions/restoreCache';
|
||||
export { uploadCacheAsync } from './steps/functions/saveCache';
|
||||
|
||||
@@ -34,7 +26,6 @@ export { formatSecondsForLog } from './utils/formatDuration';
|
||||
export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
|
||||
|
||||
export { Hook, runHookIfPresent } from './utils/hooks';
|
||||
export { detectPnpmUnsupportedNodeVersionError } from './buildErrors/pnpmUnsupportedNodeVersionError';
|
||||
|
||||
export { parseGradleProfile, formatGradleProfileReport } from './android/gradleProfile';
|
||||
export type { GradleProfileTask } from './android/gradleProfile';
|
||||
@@ -43,6 +34,5 @@ export * from './generic';
|
||||
|
||||
export { Datadog } from './datadog';
|
||||
export { getDeviceRunSessionTokenFromEnvironmentSecrets } from './steps/utils/deviceRunSessionWorkerApi';
|
||||
export { getSandboxWorkerTokenFromEnvironmentSecrets } from './steps/utils/sandboxWorkerApi';
|
||||
export { Sentry } from './sentry';
|
||||
export * from './runtimeSettings';
|
||||
@@ -56,10 +56,10 @@ class RemoteLoggerStream extends Writable {
|
||||
return null;
|
||||
}
|
||||
|
||||
public async init(): Promise<void> {
|
||||
public async init(): Promise<string> {
|
||||
this.fileHandle = await fs.open(this.temporaryLogsPath, 'w+', 0o660);
|
||||
this.flushInterval = setInterval(() => this.flush(), this.options.uploadIntervalMs);
|
||||
await this.flush(true);
|
||||
return (await this.flush(true)) as string;
|
||||
}
|
||||
|
||||
public async cleanUp(): Promise<void> {
|
||||
@@ -121,18 +121,17 @@ class RemoteLoggerStream extends Writable {
|
||||
}
|
||||
}
|
||||
|
||||
private async flush(force = false): Promise<void> {
|
||||
private async flush(force = false): Promise<string | void> {
|
||||
if (force || this.hasChangesToUpload) {
|
||||
if (this.uploadingPromise) {
|
||||
await this.uploadingPromise;
|
||||
await this.flush(force);
|
||||
return;
|
||||
return await this.flush(force);
|
||||
}
|
||||
await this.flushInternal();
|
||||
return await this.flushInternal();
|
||||
}
|
||||
}
|
||||
|
||||
private async flushInternal(): Promise<void> {
|
||||
private async flushInternal(): Promise<string | void> {
|
||||
await this.safeWriteToFile();
|
||||
|
||||
let resolveFn: PromiseResolveFn;
|
||||
@@ -140,17 +139,21 @@ class RemoteLoggerStream extends Writable {
|
||||
this.uploadingPromise = new Promise(res => {
|
||||
resolveFn = res;
|
||||
});
|
||||
await this.upload()
|
||||
return await this.upload()
|
||||
.then(result => {
|
||||
return result;
|
||||
})
|
||||
.catch(err => {
|
||||
this.logger.error({ err }, 'Failed to upload logs file');
|
||||
})
|
||||
.then(() => {
|
||||
.then(result => {
|
||||
this.uploadingPromise = undefined;
|
||||
resolveFn();
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
private async upload(): Promise<void> {
|
||||
private async upload(): Promise<string | void> {
|
||||
if (!this.uploadMethod) {
|
||||
return;
|
||||
}
|
||||
@@ -162,7 +165,7 @@ class RemoteLoggerStream extends Writable {
|
||||
);
|
||||
};
|
||||
|
||||
await uploadWithSignedUrl({
|
||||
return await uploadWithSignedUrl({
|
||||
signedUrl: this.uploadMethod.signedUrl,
|
||||
srcGeneratorAsync,
|
||||
});
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { getEasFunctions } from '../easFunctions';
|
||||
|
||||
it('registers opt-in session functions with the existing inputs', () => {
|
||||
const functions = getEasFunctions({ hasBuildJob: () => false } as CustomBuildContext);
|
||||
for (const name of ['serve_sim_remote', 'agent_device', 'argent_remote', 'appium_remote']) {
|
||||
const existing = functions.find(fn => fn.id === `start_${name}_session`)!;
|
||||
const warm = functions.find(fn => fn.id === `start_warm_${name}_session`)!;
|
||||
expect(warm.getFullId()).toBe(`eas/start_warm_${name}_session`);
|
||||
const warmInputs = warm.inputProviders!.map(input => input(undefined as never, 'warm').id);
|
||||
const existingInputs = existing.inputProviders!.map(
|
||||
input => input(undefined as never, 'existing').id
|
||||
);
|
||||
expect(warmInputs).toEqual(existingInputs);
|
||||
}
|
||||
const ids = functions.map(fn => fn.getFullId());
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
@@ -33,7 +33,6 @@ import { createSubmitToGooglePlayBuildFunction } from './functions/submitToGoogl
|
||||
import { createReadPackageJsonBuildFunction } from './functions/readPackageJson';
|
||||
import { createRepackBuildFunction } from './functions/repack';
|
||||
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
|
||||
import { createUpdateResolvedVersionBuildFunction } from './functions/updateResolvedVersion';
|
||||
import { resolveAppleTeamIdFromCredentialsFunction } from './functions/resolveAppleTeamIdFromCredentials';
|
||||
import { createResolveBuildConfigBuildFunction } from './functions/resolveBuildConfig';
|
||||
import {
|
||||
@@ -112,11 +111,8 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
runFastlaneFunction(),
|
||||
parseXcactivitylogFunction(),
|
||||
createStartAgentDeviceSessionBuildFunction(ctx),
|
||||
createStartAgentDeviceSessionBuildFunction(ctx, { warm: true }),
|
||||
createStartArgentRemoteSessionBuildFunction(ctx),
|
||||
createStartArgentRemoteSessionBuildFunction(ctx, { warm: true }),
|
||||
createStartAppiumRemoteSessionBuildFunction(ctx),
|
||||
createStartAppiumRemoteSessionBuildFunction(ctx, { warm: true }),
|
||||
createStartAndroidEmulatorBuildFunction(),
|
||||
createStartCuttlefishDeviceBuildFunction(),
|
||||
createStartIosSimulatorBuildFunction(),
|
||||
@@ -125,7 +121,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createFinishIosSimulatorRecordingsBuildFunction(),
|
||||
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
|
||||
createStartWebPreviewRemoteSessionBuildFunction(ctx),
|
||||
createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true }),
|
||||
createStartServeSimMetricsBuildFunction(),
|
||||
createStartServeSimLogsBuildFunction(),
|
||||
createCollectServeSimLogsBuildFunction(ctx),
|
||||
@@ -159,7 +154,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
functions.push(
|
||||
...[
|
||||
createFindAndUploadBuildArtifactsBuildFunction(ctx),
|
||||
createUpdateResolvedVersionBuildFunction(ctx),
|
||||
createUploadEmbeddedBundleBuildFunction(ctx),
|
||||
createResolveBuildConfigBuildFunction(ctx),
|
||||
createGetCredentialsForBuildTriggeredByGithubIntegration(ctx),
|
||||
|
||||
@@ -130,35 +130,6 @@ describe(createEasBuildBuildFunctionGroup, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Android without credentials', { platform: Platform.ANDROID }],
|
||||
['Android with credentials', { platform: Platform.ANDROID, buildCredentials: { test: {} } }],
|
||||
['iOS simulator', { platform: Platform.IOS, simulator: true }],
|
||||
['iOS with credentials', { platform: Platform.IOS, buildCredentials: { test: {} } }],
|
||||
])('wires the uploaded archive path into the update step (%s)', (_label, overrides) => {
|
||||
const buildToolsContext = createMockBuildToolsContext(overrides);
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
|
||||
|
||||
const updateStep = steps.find(s => s.displayName === 'Update resolved version');
|
||||
const archivePathInput = updateStep?.inputs?.find(
|
||||
input => input.id === 'application_archive_path'
|
||||
);
|
||||
expect(updateStep).toBeDefined();
|
||||
expect(archivePathInput?.rawValue).toBe(
|
||||
'${ steps.find_and_upload_build_artifacts.application_archive_path }'
|
||||
);
|
||||
|
||||
// The step ID must be assigned explicitly: an auto-generated ID makes the input
|
||||
// interpolation throw "Step ... does not exist" before the function body runs.
|
||||
const uploadStepIndex = steps.findIndex(s => s.id === 'find_and_upload_build_artifacts');
|
||||
expect(uploadStepIndex).toBeGreaterThan(-1);
|
||||
expect(steps[uploadStepIndex].displayName).toBe('Find and upload build artifacts');
|
||||
expect(uploadStepIndex).toBeLessThan(steps.indexOf(updateStep!));
|
||||
});
|
||||
|
||||
it('composes working directory with installPods step-level ./ios dir (iOS)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
|
||||
@@ -19,7 +19,6 @@ import { configureIosCredentialsFunction } from '../functions/configureIosCreden
|
||||
import { configureIosVersionFunction } from '../functions/configureIosVersion';
|
||||
import { eagerBundleBuildFunction } from '../functions/eagerBundle';
|
||||
import { createFindAndUploadBuildArtifactsBuildFunction } from '../functions/findAndUploadBuildArtifacts';
|
||||
import { createUpdateResolvedVersionBuildFunction } from '../functions/updateResolvedVersion';
|
||||
import { generateGymfileFromTemplateFunction } from '../functions/generateGymfileFromTemplate';
|
||||
import { injectAndroidCredentialsFunction } from '../functions/injectAndroidCredentials';
|
||||
import { createInstallNodeModulesBuildFunction } from '../functions/installNodeModules';
|
||||
@@ -184,20 +183,7 @@ function createStepsForIosSimulatorBuild({
|
||||
runFastlane,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'find_and_upload_build_artifacts',
|
||||
workingDirectory,
|
||||
}),
|
||||
createUpdateResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
application_archive_path:
|
||||
'${ steps.find_and_upload_build_artifacts.application_archive_path }',
|
||||
},
|
||||
}
|
||||
),
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
@@ -318,10 +304,7 @@ function createStepsForIosBuildWithCredentials({
|
||||
runFastlane,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'find_and_upload_build_artifacts',
|
||||
workingDirectory,
|
||||
}),
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
...(shouldUploadEmbeddedBundle({
|
||||
env: globalCtx.env,
|
||||
metadata: globalCtx.staticContext.metadata,
|
||||
@@ -335,16 +318,6 @@ function createStepsForIosBuildWithCredentials({
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
createUpdateResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
application_archive_path:
|
||||
'${ steps.find_and_upload_build_artifacts.application_archive_path }',
|
||||
},
|
||||
}
|
||||
),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
@@ -431,10 +404,7 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
runGradle,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'find_and_upload_build_artifacts',
|
||||
workingDirectory,
|
||||
}),
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
...(shouldUploadEmbeddedBundle({
|
||||
env: globalCtx.env,
|
||||
metadata: globalCtx.staticContext.metadata,
|
||||
@@ -448,16 +418,6 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
createUpdateResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
application_archive_path:
|
||||
'${ steps.find_and_upload_build_artifacts.application_archive_path }',
|
||||
},
|
||||
}
|
||||
),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
@@ -550,10 +510,7 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
: []),
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'find_and_upload_build_artifacts',
|
||||
workingDirectory,
|
||||
}),
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
...(shouldUploadEmbeddedBundle({
|
||||
env: globalCtx.env,
|
||||
metadata: globalCtx.staticContext.metadata,
|
||||
@@ -567,16 +524,6 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
createUpdateResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
application_archive_path:
|
||||
'${ steps.find_and_upload_build_artifacts.application_archive_path }',
|
||||
},
|
||||
}
|
||||
),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
|
||||
@@ -48,10 +48,7 @@ describe(createCheckoutBuildFunction, () => {
|
||||
});
|
||||
|
||||
it('moves project sources to the target directory when no inputs are given', async () => {
|
||||
const logger = createMockLogger();
|
||||
jest.mocked(logger.child).mockReturnValue(logger);
|
||||
const { globalCtx, executeAsync } = createCheckoutStep({
|
||||
logger,
|
||||
projectArchive: GIT_PROJECT_ARCHIVE,
|
||||
});
|
||||
|
||||
@@ -59,9 +56,6 @@ describe(createCheckoutBuildFunction, () => {
|
||||
|
||||
expect(await vol.promises.readFile(`${projectTargetDirectory}/app.json`, 'utf8')).toBe('{}');
|
||||
expect(globalCtx.wasCheckedOut()).toBe(true);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Changing default working directory to')
|
||||
);
|
||||
expect(fetchAndCheckoutRefAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -102,11 +96,14 @@ describe(createCheckoutBuildFunction, () => {
|
||||
});
|
||||
|
||||
it('rejects the ref input when the project is already checked out', async () => {
|
||||
const logger = createMockLogger();
|
||||
jest.mocked(logger.child).mockReturnValue(logger);
|
||||
const { globalCtx, executeAsync } = createCheckoutStep({
|
||||
logger,
|
||||
projectArchive: GIT_PROJECT_ARCHIVE,
|
||||
callInputs: { ref: 'feature/add-icon' },
|
||||
});
|
||||
globalCtx.markAsCheckedOut();
|
||||
globalCtx.markAsCheckedOut(logger);
|
||||
|
||||
await expect(executeAsync()).rejects.toMatchObject({
|
||||
errorCode: 'EAS_CHECKOUT_REF_AFTER_CHECKOUT',
|
||||
|
||||
+15
-49
@@ -6,7 +6,6 @@ import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
|
||||
import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
@@ -45,26 +44,6 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
waitForFileAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
// Agent-device also polls for Stop while it starts up. That watch sets neither an idle timeout nor
|
||||
// a duration; keep it pending until startup retires it, and give each lifetime wait its own mock.
|
||||
const lifetimeWait = jest.fn<
|
||||
ReturnType<typeof waitForDeviceRunSessionStoppedAsync>,
|
||||
Parameters<typeof waitForDeviceRunSessionStoppedAsync>
|
||||
>();
|
||||
function routeStartupWatch(): void {
|
||||
lifetimeWait.mockReset();
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async args => {
|
||||
if ('idleTimeout' in args || 'maxDurationSeconds' in args) {
|
||||
return await lifetimeWait(args);
|
||||
}
|
||||
if (!args.signal?.aborted) {
|
||||
await new Promise<void>(resolve =>
|
||||
args.signal?.addEventListener('abort', () => resolve(), { once: true })
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const finishHost = jest.fn();
|
||||
const closePreview = jest.fn();
|
||||
const stopTool = jest.fn();
|
||||
@@ -75,8 +54,6 @@ const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnTh
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockResolvedValue();
|
||||
routeStartupWatch();
|
||||
// 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.
|
||||
@@ -102,11 +79,9 @@ beforeEach(() => {
|
||||
apiUrl: 'https://preview.example.test',
|
||||
closeAsync: closePreview,
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: openPreview,
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: finishHost,
|
||||
});
|
||||
jest
|
||||
.mocked(startDeviceSessionHostAsync)
|
||||
.mockResolvedValue({ openPreviewAsync: openPreview, finishAsync: finishHost });
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: undefined,
|
||||
getOutput: () => '',
|
||||
@@ -121,8 +96,8 @@ beforeEach(() => {
|
||||
const events = { stopAsync: stopEvents, getLastEventObservedAt: () => undefined };
|
||||
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue(events);
|
||||
jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue(events);
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
|
||||
lifetimeWait.mockResolvedValue(undefined);
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -168,11 +143,7 @@ describe.each([
|
||||
|
||||
it('holds the host until session completion', async () => {
|
||||
await runAsync();
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ preview: { baseDomain: 'example.test', authtoken: 'token' } })
|
||||
);
|
||||
expect(openPreview).toHaveBeenCalledWith();
|
||||
expect(openPreview).toHaveBeenCalledWith({ baseDomain: 'example.test', authtoken: 'token' });
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({
|
||||
@@ -184,7 +155,7 @@ describe.each([
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
expect(closePreview).not.toHaveBeenCalled();
|
||||
expect(finishHost.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
lifetimeWait.mock.invocationCallOrder[0]
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(stopTool).toHaveBeenCalledTimes(1);
|
||||
expect(stopEvents).toHaveBeenCalledTimes(1);
|
||||
@@ -329,7 +300,7 @@ describe.each([
|
||||
} else if (phase === 'config') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
|
||||
} else {
|
||||
lifetimeWait.mockRejectedValueOnce(error);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
|
||||
}
|
||||
await expect(runAsync()).rejects.toBe(error);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
@@ -377,10 +348,6 @@ describe('Appium ffmpeg setup', () => {
|
||||
}
|
||||
|
||||
function holdFfmpegInstall({ appiumError }: { appiumError?: Error } = {}) {
|
||||
let markStarted!: () => void;
|
||||
const started = new Promise<void>(resolve => {
|
||||
markStarted = resolve;
|
||||
});
|
||||
let finish!: (outcome: 'installed' | 'failed') => void;
|
||||
const install = new Promise((resolve, reject) => {
|
||||
finish = outcome =>
|
||||
@@ -391,26 +358,25 @@ describe('Appium ffmpeg setup', () => {
|
||||
return Promise.reject(new Error('ffmpeg missing'));
|
||||
}
|
||||
if (args.includes('install') && args.includes('ffmpeg')) {
|
||||
markStarted();
|
||||
return install;
|
||||
}
|
||||
return command === 'npm' && appiumError
|
||||
? Promise.reject(appiumError)
|
||||
: Promise.resolve({ stdout: '{}' });
|
||||
}) as never);
|
||||
return { finish, started };
|
||||
return finish;
|
||||
}
|
||||
|
||||
it.each(['installed', 'failed'] as const)(
|
||||
'starts Appium after the ffmpeg install settles (%s)',
|
||||
async outcome => {
|
||||
const { finish, started } = holdFfmpegInstall();
|
||||
const finishFfmpegInstall = holdFfmpegInstall();
|
||||
|
||||
const run = runAppiumAsync();
|
||||
await started;
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
|
||||
finish(outcome);
|
||||
finishFfmpegInstall(outcome);
|
||||
await run;
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
@@ -418,16 +384,16 @@ describe('Appium ffmpeg setup', () => {
|
||||
|
||||
it('waits for the ffmpeg install before failing on an Appium install error', async () => {
|
||||
const appiumError = new Error('npm failed');
|
||||
const { finish, started } = holdFfmpegInstall({ appiumError });
|
||||
const finishFfmpegInstall = holdFfmpegInstall({ appiumError });
|
||||
|
||||
let settled = false;
|
||||
const run = runAppiumAsync().finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
await started;
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(settled).toBe(false);
|
||||
|
||||
finish('installed');
|
||||
finishFfmpegInstall('installed');
|
||||
await expect(run).rejects.toBe(appiumError);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,6 @@ import * as tar from 'tar';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { Datadog } from '../../../datadog';
|
||||
import * as files from '../../../utils/files';
|
||||
import { createDownloadBuildFunction, downloadBuildAsync } from '../downloadBuild';
|
||||
|
||||
@@ -31,17 +30,11 @@ const APPLICATION_ARCHIVE_URL = `https://expo.dev/artifacts/eas/${randomUUID()}.
|
||||
function createMockGraphqlClient({
|
||||
applicationArchiveUrl,
|
||||
error,
|
||||
transientError,
|
||||
}: {
|
||||
applicationArchiveUrl?: string | null;
|
||||
error?: Error;
|
||||
transientError?: Error;
|
||||
}): Client {
|
||||
const toPromise = jest.fn();
|
||||
if (transientError) {
|
||||
toPromise.mockResolvedValueOnce({ error: transientError, data: undefined });
|
||||
}
|
||||
toPromise.mockResolvedValue(
|
||||
const toPromise = jest.fn().mockResolvedValue(
|
||||
error
|
||||
? { error, data: undefined }
|
||||
: {
|
||||
@@ -69,7 +62,7 @@ function createSuccessfulResponse({
|
||||
url,
|
||||
headers,
|
||||
}: {
|
||||
body: Buffer | Readable;
|
||||
body: Buffer;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}): Response {
|
||||
@@ -79,7 +72,7 @@ function createSuccessfulResponse({
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
body: body instanceof Readable ? body : Readable.from(body),
|
||||
body: Readable.from(body),
|
||||
url,
|
||||
headers: {
|
||||
get: (name: string): string | null => normalizedHeaders[name.toLowerCase()] ?? null,
|
||||
@@ -87,47 +80,6 @@ function createSuccessfulResponse({
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function createUndiciNetworkError(code: string): CombinedError {
|
||||
return new CombinedError({
|
||||
networkError: Object.assign(new TypeError('fetch failed'), {
|
||||
cause: Object.assign(new Error(`read ${code}`), { code }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const { FetchError: ActualFetchError } = jest.requireActual(
|
||||
'node-fetch'
|
||||
) as typeof import('node-fetch');
|
||||
|
||||
function createSocketFetchError(url: string, code: string): Error {
|
||||
return new ActualFetchError(`request to ${url} failed, reason: connect ${code}`, 'system', {
|
||||
code,
|
||||
} as any);
|
||||
}
|
||||
|
||||
function useFakeBackoffTimers(): void {
|
||||
// Streams and fs callbacks need the real nextTick and setImmediate; only the backoff timers are faked.
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
|
||||
}
|
||||
|
||||
const MAX_IO_TURNS_BEFORE_BACKOFF = 1000;
|
||||
|
||||
async function waitForBackoffTimerAsync(): Promise<void> {
|
||||
for (let turns = 0; jest.getTimerCount() === 0; turns++) {
|
||||
if (turns === MAX_IO_TURNS_BEFORE_BACKOFF) {
|
||||
throw new Error('No backoff timer was scheduled.');
|
||||
}
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
async function fireBackoffTimersAsync(count: number): Promise<void> {
|
||||
for (let i = 0; i < count; i++) {
|
||||
await waitForBackoffTimerAsync();
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
}
|
||||
}
|
||||
|
||||
async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
|
||||
const sourceDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'flat-app-source-'));
|
||||
const archivePath = path.join(os.tmpdir(), `${randomUUID()}.tar.gz`);
|
||||
@@ -156,65 +108,7 @@ async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('downloadBuild', () => {
|
||||
it.each([404, 410])('identifies the unavailable build on HTTP %s', async status => {
|
||||
const buildId = randomUUID();
|
||||
const { Response: ActualResponse } =
|
||||
jest.requireActual<typeof import('node-fetch')>('node-fetch');
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(new ActualResponse('<Error>NoSuchKey</Error>', { status }));
|
||||
|
||||
await expect(
|
||||
downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId,
|
||||
graphqlClient: createMockGraphqlClient({ applicationArchiveUrl: APPLICATION_ARCHIVE_URL }),
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
errorCode: 'EAS_DOWNLOAD_BUILD_ARCHIVE_UNAVAILABLE',
|
||||
message:
|
||||
`Failed to download the application archive for build ${buildId} (HTTP ${status}). ` +
|
||||
'The archive is unavailable. Check that the archive still exists and try again.',
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([404, 410, 500])(
|
||||
'identifies the application URL without credentials on HTTP %s',
|
||||
async status => {
|
||||
const { Response: ActualResponse } =
|
||||
jest.requireActual<typeof import('node-fetch')>('node-fetch');
|
||||
jest.mocked(fetch).mockResolvedValue(new ActualResponse('download failed', { status }));
|
||||
|
||||
const download = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl:
|
||||
'https://user:[email protected]/app.tar.gz?token=secret#fragment',
|
||||
graphqlClient: createMockGraphqlClient({}),
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
|
||||
const prefix = `Failed to download the archive at https://example.test/app.tar.gz (HTTP ${status})`;
|
||||
await expect(download).rejects.toThrow(
|
||||
status === 500
|
||||
? `${prefix}: download failed`
|
||||
: `${prefix}. The archive is unavailable. ` +
|
||||
'Check that the URL is correct and the archive still exists, then try again.'
|
||||
);
|
||||
await expect(download).rejects.not.toThrow(/password|secret|fragment/);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
@@ -571,13 +465,13 @@ describe('downloadBuild', () => {
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws SystemError with the cause code after retrying a failing build lookup', async () => {
|
||||
useFakeBackoffTimers();
|
||||
it('throws SystemError when GraphQL request fails with a network error', async () => {
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
error: createUndiciNetworkError('ECONNRESET'),
|
||||
error: new CombinedError({
|
||||
networkError: new Error('Network request failed'),
|
||||
}),
|
||||
});
|
||||
const datadogLog = jest.spyOn(Datadog, 'log');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
@@ -586,81 +480,19 @@ describe('downloadBuild', () => {
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await fireBackoffTimersAsync(5);
|
||||
|
||||
await rejection;
|
||||
await expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: `Could not fetch build ${buildId}: [Network] fetch failed (ECONNRESET)`,
|
||||
});
|
||||
expect(graphqlClient.query).toHaveBeenCalledTimes(6);
|
||||
expect(datadogLog).toHaveBeenCalledWith('download_build.build_lookup failed on attempt 6', {
|
||||
event: 'network_retry',
|
||||
operation: 'download_build.build_lookup',
|
||||
outcome: 'failed',
|
||||
attempts: '6',
|
||||
code: 'ECONNRESET',
|
||||
message: `Could not fetch build ${buildId}: [Network] Network request failed`,
|
||||
});
|
||||
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a network error', createUndiciNetworkError('ECONNRESET'), 'ECONNRESET'],
|
||||
[
|
||||
'a transient GraphQL error',
|
||||
new CombinedError({
|
||||
graphQLErrors: [
|
||||
{
|
||||
message: 'Too many requests',
|
||||
extensions: { isTransient: true, errorCode: 'TOO_MANY_REQUESTS' },
|
||||
},
|
||||
],
|
||||
response: { status: 200 },
|
||||
}),
|
||||
'TOO_MANY_REQUESTS',
|
||||
],
|
||||
])('downloads after a build lookup that failed once with %s', async (_, transientError, code) => {
|
||||
useFakeBackoffTimers();
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
transientError,
|
||||
});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
|
||||
);
|
||||
|
||||
const datadogLog = jest.spyOn(Datadog, 'log');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
await fireBackoffTimersAsync(1);
|
||||
|
||||
const { artifactPath } = await promise;
|
||||
expect(path.basename(artifactPath)).toBe('app.apk');
|
||||
expect(graphqlClient.query).toHaveBeenCalledTimes(2);
|
||||
expect(datadogLog).toHaveBeenCalledWith('download_build.build_lookup recovered on attempt 2', {
|
||||
event: 'network_retry',
|
||||
operation: 'download_build.build_lookup',
|
||||
outcome: 'recovered',
|
||||
attempts: '2',
|
||||
code,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws SystemError when GraphQL request returns 5xx', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
error: new CombinedError({
|
||||
graphQLErrors: [{ message: 'Internal Server Error' }],
|
||||
error: Object.assign(new Error('Internal Server Error'), {
|
||||
response: { status: 500 },
|
||||
}),
|
||||
});
|
||||
@@ -672,14 +504,12 @@ describe('downloadBuild', () => {
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await fireBackoffTimersAsync(5);
|
||||
|
||||
await rejection;
|
||||
await expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: `Could not fetch build ${buildId}: [GraphQL] Internal Server Error`,
|
||||
message: `Could not fetch build ${buildId}: Internal Server Error`,
|
||||
});
|
||||
expect(graphqlClient.query).toHaveBeenCalledTimes(6);
|
||||
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -704,193 +534,10 @@ describe('downloadBuild', () => {
|
||||
errorCode: 'EAS_DOWNLOAD_BUILD_FETCH_FAILED',
|
||||
message: `Could not fetch build ${buildId}: [GraphQL] Build not found`,
|
||||
});
|
||||
expect(graphqlClient.query).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('downloads after an archive request that failed once with a socket error', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValueOnce(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ECONNRESET'))
|
||||
.mockResolvedValueOnce(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
|
||||
);
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
await fireBackoffTimersAsync(1);
|
||||
|
||||
const { artifactPath } = await promise;
|
||||
expect(path.basename(artifactPath)).toBe('app.apk');
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws SystemError with the cause code after retrying a failing build archive request', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValue(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ETIMEDOUT'));
|
||||
const datadogLog = jest.spyOn(Datadog, 'log');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await fireBackoffTimersAsync(5);
|
||||
|
||||
await rejection;
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: `Could not download the application archive: request to ${APPLICATION_ARCHIVE_URL} failed, reason: connect ETIMEDOUT`,
|
||||
});
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
|
||||
expect(datadogLog).toHaveBeenCalledWith('download_build.archive_download failed on attempt 6', {
|
||||
event: 'network_retry',
|
||||
operation: 'download_build.archive_download',
|
||||
outcome: 'failed',
|
||||
attempts: '6',
|
||||
code: 'ETIMEDOUT',
|
||||
});
|
||||
});
|
||||
|
||||
function createBrokenBody(code: string): Readable {
|
||||
return Readable.from(
|
||||
(async function* () {
|
||||
yield Buffer.from('he');
|
||||
throw Object.assign(new Error(`body failed with ${code}`), { code });
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
it.each(['ERR_STREAM_PREMATURE_CLOSE', 'ECONNRESET'])(
|
||||
'downloads again when the archive body breaks off with %s after the headers arrived',
|
||||
async code => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
const brokenBody = createBrokenBody(code);
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(
|
||||
createSuccessfulResponse({ body: brokenBody, url: 'https://cdn.test/app.apk' })
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
|
||||
);
|
||||
const datadogLog = jest.spyOn(Datadog, 'log');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
await fireBackoffTimersAsync(1);
|
||||
|
||||
const { artifactPath } = await promise;
|
||||
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(2);
|
||||
expect(datadogLog).toHaveBeenCalledWith(
|
||||
'download_build.archive_download recovered on attempt 2',
|
||||
expect.objectContaining({ outcome: 'recovered', code })
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('throws SystemError when the build archive body keeps breaking off', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
jest.mocked(fetch).mockImplementation(async () =>
|
||||
createSuccessfulResponse({
|
||||
body: createBrokenBody('ECONNRESET'),
|
||||
url: 'https://cdn.test/app.apk',
|
||||
})
|
||||
);
|
||||
const datadogLog = jest.spyOn(Datadog, 'log');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await fireBackoffTimersAsync(5);
|
||||
|
||||
await rejection;
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: 'Could not download the application archive: body failed with ECONNRESET',
|
||||
});
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
|
||||
expect(datadogLog).toHaveBeenCalledWith(
|
||||
'download_build.archive_download failed on attempt 6',
|
||||
expect.objectContaining({ outcome: 'failed', code: 'ECONNRESET' })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not start another archive attempt after an abort during the backoff', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValueOnce(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ECONNRESET'));
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('step timed out');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
signal: controller.signal,
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBe(reason);
|
||||
await waitForBackoffTimerAsync();
|
||||
controller.abort(reason);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
await rejection;
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rethrows the raw error of a failing user-supplied archive URL after the retries', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValue(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ENOTFOUND'));
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBeInstanceOf(ActualFetchError);
|
||||
await fireBackoffTimersAsync(5);
|
||||
|
||||
await rejection;
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it('should throw an error if no matching files are found', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
@@ -958,21 +605,6 @@ describe('createDownloadBuildFunction', () => {
|
||||
};
|
||||
}
|
||||
|
||||
function createApplicationResponse(buildId: string): Response {
|
||||
return new ActualResponse(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
build: {
|
||||
id: buildId,
|
||||
gitCommitHash: null,
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
function createStep(ctx: any, callInputs: Record<string, unknown>) {
|
||||
return createDownloadBuildFunction(ctx).createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({
|
||||
@@ -988,7 +620,18 @@ describe('createDownloadBuildFunction', () => {
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest.mocked(fetch).mockImplementation(async url => {
|
||||
if (url === workerApiUrl) {
|
||||
return createApplicationResponse(buildId);
|
||||
return new ActualResponse(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
build: {
|
||||
id: buildId,
|
||||
gitCommitHash: null,
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
@@ -1018,33 +661,6 @@ describe('createDownloadBuildFunction', () => {
|
||||
expect(graphqlClient.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries the worker API lookup after a 5xx response', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(new ActualResponse('Service Unavailable', { status: 503 }))
|
||||
.mockResolvedValueOnce(createApplicationResponse(buildId))
|
||||
.mockResolvedValueOnce(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
|
||||
);
|
||||
|
||||
const execution = createStep(createSessionCtx(graphqlClient), {
|
||||
build_id: buildId,
|
||||
extensions: ['apk'],
|
||||
}).executeAsync();
|
||||
await fireBackoffTimersAsync(1);
|
||||
|
||||
await execution;
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(3);
|
||||
expect(jest.mocked(fetch).mock.calls.map(([url]) => url)).toEqual([
|
||||
workerApiUrl,
|
||||
workerApiUrl,
|
||||
APPLICATION_ARCHIVE_URL,
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not call the worker API for an application_archive_url input', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
|
||||
-18
@@ -51,24 +51,6 @@ describe(createFindAndUploadBuildArtifactsBuildFunction, () => {
|
||||
await expect(buildStep.executeAsync()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('sets application_archive_path output to first archive', async () => {
|
||||
const globalContext = createGlobalContextMock({});
|
||||
vol.fromJSON(
|
||||
{
|
||||
'ios/build/test.ipa': '',
|
||||
},
|
||||
globalContext.defaultWorkingDirectory
|
||||
);
|
||||
const buildStep = findAndUploadBuildArtifacts.createBuildStepFromFunctionCall(
|
||||
globalContext,
|
||||
{}
|
||||
);
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.outputById.application_archive_path.value).toMatch(/ios\/build\/test\.ipa$/);
|
||||
});
|
||||
|
||||
it('throws build artifacts error', async () => {
|
||||
const globalContext = createGlobalContextMock({});
|
||||
ctx.job.buildArtifactPaths = ['worker.log'];
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
@@ -202,263 +201,6 @@ describe(launchApplicationAsync, () => {
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('quotes Android launch arguments and URLs for the device shell', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'dev.example.app',
|
||||
activityName: 'dev.example.app.MainActivity',
|
||||
launchArgs: ['-d', 'euxy://tempo', '--es', 'probe', 'hello world', '--es', 'quote', "it's"],
|
||||
openUrl: 'exp://example.test/path?a=1&b=2',
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'-d',
|
||||
'euxy://tempo',
|
||||
'--es',
|
||||
'probe',
|
||||
"'hello world'",
|
||||
'--es',
|
||||
'quote',
|
||||
"'it'\\''s'",
|
||||
'-n',
|
||||
'dev.example.app/dev.example.app.MainActivity',
|
||||
],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'-a',
|
||||
'android.intent.action.VIEW',
|
||||
'-d',
|
||||
"'exp://example.test/path?a=1&b=2'",
|
||||
],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(logger.info).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
`Launching dev.example.app with arguments ${JSON.stringify(['-d', 'euxy://tempo', '--es', 'probe', 'hello world', '--es', 'quote', "it's"])}.`
|
||||
);
|
||||
});
|
||||
|
||||
it('passes iOS launch arguments with spaces unquoted', async () => {
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
launchArgs: ['-name', 'hello world'],
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'launch', 'booted', 'com.example.app', '-name', 'hello world'],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('round-trips quoted Android arguments through a POSIX shell', async () => {
|
||||
const component = 'dev.example.app/dev.example.app.MainActivity$Inner';
|
||||
const launchArgs = [
|
||||
'--es',
|
||||
'text',
|
||||
'hello world',
|
||||
'--es',
|
||||
'json',
|
||||
'{"a":1,"b":2}',
|
||||
'--es',
|
||||
'shell',
|
||||
'$(printf injected) `printf injected` $HOME ; & | < >',
|
||||
'--es',
|
||||
'glob',
|
||||
'*',
|
||||
'--es',
|
||||
'home',
|
||||
'~',
|
||||
'--esa',
|
||||
'list',
|
||||
'a\\,b,c',
|
||||
'--es',
|
||||
'whitespace',
|
||||
'one\ntwo\tthree',
|
||||
'--es',
|
||||
'unicode',
|
||||
'héllo 🌍',
|
||||
'--es',
|
||||
'empty',
|
||||
'',
|
||||
];
|
||||
const openUrl = 'exp://example.test/path?a=1&b=2#fragment';
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'dev.example.app',
|
||||
activityName: 'dev.example.app.MainActivity$Inner',
|
||||
launchArgs,
|
||||
openUrl,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
// adb joins the words after `shell` with spaces and the device shell parses the result.
|
||||
const parsedCommands = mockedSpawn.mock.calls.map(([, args]) => {
|
||||
const { stdout } = spawnSync(
|
||||
'/bin/sh',
|
||||
['-c', `am() { printf '%s\\0' "$@"; }; ${(args as string[]).slice(1).join(' ')}`],
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
return stdout.split('\0').slice(0, -1);
|
||||
});
|
||||
expect(parsedCommands).toEqual([
|
||||
['start', ...launchArgs, '-n', component],
|
||||
['start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([[['--es', 'key', '']], [['-e', 'key', '']], [['-d', '']]])(
|
||||
'passes empty Android option values as empty arguments (%j)',
|
||||
async launchArgs => {
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
...launchArgs.map(argument => (argument === '' ? "''" : argument)),
|
||||
'-n',
|
||||
'com.example.app/com.example.app.MainActivity',
|
||||
],
|
||||
expect.any(Object)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('explains an Android launch failure when a launch argument is empty', async () => {
|
||||
const launchError = new Error('adb shell am start exited with non-zero code: 1');
|
||||
mockedSpawn.mockRejectedValueOnce(launchError);
|
||||
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs: ['--ez', 'isTest', 'true', ''],
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
errorCode: 'EAS_LAUNCH_APPLICATION_FAILED',
|
||||
message: expect.stringContaining('empty argument at position 4'),
|
||||
cause: launchError,
|
||||
});
|
||||
});
|
||||
|
||||
it('rethrows an Android launch failure unchanged when no launch argument is empty', async () => {
|
||||
const launchError = new Error('adb shell am start exited with non-zero code: 1');
|
||||
mockedSpawn.mockRejectedValueOnce(launchError);
|
||||
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs: ['--ez', 'isTest', 'true'],
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toBe(launchError);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ launchArgs: ['--es', 'key', 'a\0b'] },
|
||||
{ launchArgs: [], openUrl: 'exp://example.test/a\0b' },
|
||||
])('rejects NUL characters on Android (%j)', async ({ launchArgs, openUrl }) => {
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs,
|
||||
openUrl,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith(
|
||||
'adb',
|
||||
expect.arrayContaining([expect.stringContaining('\0')]),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts an Android launch command of exactly 65,000 bytes', async () => {
|
||||
const fixedLength = 'am start --es key -n com.example.app/com.example.app.MainActivity'.length;
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs: ['--es', 'key', 'a'.repeat(65_000 - fixedLength)],
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects an Android URL command of 65,001 bytes', async () => {
|
||||
const fixedLength = 'am start -a android.intent.action.VIEW -d exp://example.test/'.length;
|
||||
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
openUrl: `exp://example.test/${'a'.repeat(65_001 - fixedLength)}`,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects an Android launch command that is too long after quoting', async () => {
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs: ['--es', 'quotes', "'".repeat(20_000)],
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('launches using application metadata passed to the build step', async () => {
|
||||
const launchApplication = createLaunchApplicationFunction();
|
||||
const buildStep = launchApplication.createBuildStepFromFunctionCall(
|
||||
@@ -495,36 +237,6 @@ describe(launchApplicationAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('quotes Android launch arguments passed to the build step', async () => {
|
||||
const launchApplication = createLaunchApplicationFunction();
|
||||
const buildStep = launchApplication.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
|
||||
{
|
||||
callInputs: {
|
||||
application_identifier: 'com.example.app',
|
||||
activity_name: 'com.example.app.MainActivity',
|
||||
launch_args: ['--es', 'probe', 'hello world'],
|
||||
},
|
||||
}
|
||||
);
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'--es',
|
||||
'probe',
|
||||
"'hello world'",
|
||||
'-n',
|
||||
'com.example.app/com.example.app.MainActivity',
|
||||
],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it.each([{}, ['valid', 42]])('rejects invalid launch arguments (%j)', async launchArgs => {
|
||||
const launchApplication = createLaunchApplicationFunction();
|
||||
const buildStep = launchApplication.createBuildStepFromFunctionCall(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
@@ -305,6 +305,7 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
expect(mockUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
type: GenericArtifactType.OTHER,
|
||||
name:
|
||||
format === 'html' ? 'Maestro Runner HTML Report' : 'Maestro Runner Allure Results',
|
||||
paths: [
|
||||
@@ -1090,6 +1091,7 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
expect(mockUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
type: GenericArtifactType.OTHER,
|
||||
name: 'Maestro HTML Report',
|
||||
paths: ['/home/expo/.maestro/tests/android-maestro-html.html'],
|
||||
}),
|
||||
@@ -1418,6 +1420,7 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
expect(mockUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
type: GenericArtifactType.OTHER,
|
||||
name: shot.displayName,
|
||||
metadata: shot.metadata,
|
||||
}),
|
||||
|
||||
+18
-53
@@ -7,7 +7,6 @@ import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
monitorLocalEgressAsync,
|
||||
@@ -83,26 +82,6 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
// Agent-device also polls for Stop while it starts up. That watch sets neither an idle timeout nor
|
||||
// a duration; keep it pending until startup retires it, and give each lifetime wait its own mock.
|
||||
const lifetimeWait = jest.fn<
|
||||
ReturnType<typeof waitForDeviceRunSessionStoppedAsync>,
|
||||
Parameters<typeof waitForDeviceRunSessionStoppedAsync>
|
||||
>();
|
||||
function routeStartupWatch(): void {
|
||||
lifetimeWait.mockReset();
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async args => {
|
||||
if ('idleTimeout' in args || 'maxDurationSeconds' in args) {
|
||||
return await lifetimeWait(args);
|
||||
}
|
||||
if (!args.signal?.aborted) {
|
||||
await new Promise<void>(resolve =>
|
||||
args.signal?.addEventListener('abort', () => resolve(), { once: true })
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
@@ -145,8 +124,6 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
}
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockResolvedValue();
|
||||
routeStartupWatch();
|
||||
lifetime = registerLocalEgressResources(stopEgress);
|
||||
stopEgress.mockResolvedValue(undefined);
|
||||
stopPreview.mockResolvedValue(undefined);
|
||||
@@ -185,11 +162,10 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: openPreview,
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopPreview,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
|
||||
lifetimeWait.mockImplementation(async () => {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
|
||||
expect(lifetime.aborted).toBe(false);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -199,30 +175,18 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('releases resources without monitoring when the session stopped during startup', async () => {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValueOnce('stopped');
|
||||
await run();
|
||||
expect(lifetimeWait).not.toHaveBeenCalled();
|
||||
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(stopPreview).toHaveBeenCalledTimes(1);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
expect(lifetime.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('reports credentials and starts monitoring before waiting, then releases resources', async () => {
|
||||
await run();
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ctx,
|
||||
deviceRunSessionId: 'session-id',
|
||||
logger,
|
||||
remoteConfig: expect.objectContaining({
|
||||
...fields,
|
||||
[controllerField]: expect.any(String),
|
||||
[controllerField === 'previewUrl' ? 'previewToken' : 'webPreviewToken']: 'preview-secret',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
deviceRunSessionId: 'session-id',
|
||||
logger,
|
||||
remoteConfig: expect.objectContaining({
|
||||
...fields,
|
||||
[controllerField]: expect.any(String),
|
||||
[controllerField === 'previewUrl' ? 'previewToken' : 'webPreviewToken']: 'preview-secret',
|
||||
}),
|
||||
});
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledWith({
|
||||
port: handoff.port,
|
||||
env,
|
||||
@@ -236,7 +200,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
stopEgress.mockClear();
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(null);
|
||||
lifetimeWait.mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
await run();
|
||||
const { remoteConfig } = jest.mocked(uploadRemoteSessionConfigAsync).mock.calls[0][0];
|
||||
expect(remoteConfig).toHaveProperty(controllerField);
|
||||
@@ -255,7 +219,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
} else if (phase === 'report') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValue(error);
|
||||
} else if (phase === 'wait') {
|
||||
lifetimeWait.mockRejectedValue(error);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValue(error);
|
||||
} else {
|
||||
stopPreview.mockRejectedValue(error);
|
||||
}
|
||||
@@ -269,10 +233,11 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
);
|
||||
it('passes cancellation to the monitor and releases the lifetime', async () => {
|
||||
const abort = new AbortController();
|
||||
lifetimeWait.mockImplementation(async () => abort.abort());
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => abort.abort());
|
||||
await run(abort.signal);
|
||||
// Agent-device hands the monitor its startup signal, which follows the step signal.
|
||||
expect(jest.mocked(monitorLocalEgressAsync).mock.calls[0][0].signal?.aborted).toBe(true);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signal: abort.signal })
|
||||
);
|
||||
expect(lifetime.aborted).toBe(true);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import turtleSpawnAsync from '@expo/turtle-spawn';
|
||||
import assert from 'node:assert';
|
||||
import fg from 'fast-glob';
|
||||
import { vol } from 'memfs';
|
||||
import path from 'node:path';
|
||||
import resolveFrom from 'resolve-from';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createTestAndroidJob, createTestIosJob } from '../../../__tests__/utils/job';
|
||||
@@ -19,100 +17,17 @@ import {
|
||||
} from '../repack';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('@expo/spawn-async');
|
||||
jest.mock('@expo/repack-app');
|
||||
jest.mock('resolve-from', () => jest.fn(() => '@expo/repack-app'));
|
||||
jest.mock('resolve-from', () => jest.fn((_, moduleName) => moduleName));
|
||||
jest.mock('../../utils/ios/credentials/manager');
|
||||
|
||||
describe(createRepackBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(turtleSpawnAsync).mockImplementation((async (_command: string, args: string[]) => {
|
||||
const sandbox = args[args.indexOf('--prefix') + 1];
|
||||
const packageName = args[args.length - 1].replace(/@[^@/]*$/, '');
|
||||
vol.fromJSON({
|
||||
[path.join(sandbox, 'node_modules', packageName, 'package.json')]: JSON.stringify({
|
||||
version: '2.0.0-next.1',
|
||||
}),
|
||||
});
|
||||
}) as unknown as typeof turtleSpawnAsync);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
vol.reset();
|
||||
});
|
||||
|
||||
it('installs the repack package with npm in a sandbox whose env is only PATH and HOME, through the npm cache registry', async () => {
|
||||
const globalCtx = createGlobalContextMock({
|
||||
staticContextContent: {
|
||||
job: {},
|
||||
},
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
PATH: '/usr/local/bin:/usr/bin',
|
||||
EAS_BUILD_NPM_CACHE_URL: 'https://npm-cache.expo.test',
|
||||
NPM_TOKEN: 'secret',
|
||||
});
|
||||
const repack = createRepackBuildFunction();
|
||||
const repackStep = repack.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: {
|
||||
platform: 'ios',
|
||||
source_app_path: '/path/to/source_app.ipa',
|
||||
repack_package: '@scope/fork',
|
||||
repack_version: 'next',
|
||||
},
|
||||
});
|
||||
const logInfo = jest.spyOn(repackStep.ctx.logger, 'info');
|
||||
|
||||
await repackStep.executeAsync();
|
||||
|
||||
const [installCall] = jest.mocked(turtleSpawnAsync).mock.calls;
|
||||
const npmEnv = installCall[2]?.env;
|
||||
assert(npmEnv);
|
||||
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'PATH']);
|
||||
expect(npmEnv.PATH).toMatch(/\/usr\/local\/bin:\/usr\/bin$/);
|
||||
expect(installCall).toEqual([
|
||||
'npm',
|
||||
[
|
||||
'install',
|
||||
'--prefix',
|
||||
npmEnv.HOME,
|
||||
'--no-save',
|
||||
'--package-lock=false',
|
||||
'--no-audit',
|
||||
'--no-fund',
|
||||
'--no-update-notifier',
|
||||
'--registry',
|
||||
'https://npm-cache.expo.test',
|
||||
'@scope/fork@next',
|
||||
],
|
||||
{ env: npmEnv, logger: repackStep.ctx.logger },
|
||||
]);
|
||||
expect(logInfo).toHaveBeenCalledWith('Using repack from: @scope/fork@next (2.0.0-next.1)');
|
||||
expect(resolveFrom).toHaveBeenCalledWith(npmEnv.HOME, '@scope/fork');
|
||||
});
|
||||
|
||||
it('installs the repack package from the default registry when the job has no npm cache', async () => {
|
||||
const globalCtx = createGlobalContextMock({
|
||||
staticContextContent: {
|
||||
job: {},
|
||||
},
|
||||
});
|
||||
globalCtx.updateEnv({ PATH: '/usr/local/bin:/usr/bin' });
|
||||
const repack = createRepackBuildFunction();
|
||||
const repackStep = repack.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: {
|
||||
platform: 'ios',
|
||||
source_app_path: '/path/to/source_app.ipa',
|
||||
},
|
||||
});
|
||||
|
||||
await repackStep.executeAsync();
|
||||
|
||||
const [installCall] = jest.mocked(turtleSpawnAsync).mock.calls;
|
||||
expect(installCall[1]).not.toContain('--registry');
|
||||
});
|
||||
|
||||
it('should set generated output path for successful repack', async () => {
|
||||
const repack = createRepackBuildFunction();
|
||||
const repackStep = repack.createBuildStepFromFunctionCall(
|
||||
|
||||
@@ -103,7 +103,7 @@ describe(createRunAgentBuildFunction, () => {
|
||||
);
|
||||
const npmEnv = installMock.mock.calls[0][2]!.env!;
|
||||
const agentEnv = spawnMock.mock.calls[0][2].env!;
|
||||
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'PATH']);
|
||||
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'LANG', 'PATH', 'TMPDIR']);
|
||||
expect(Object.keys(agentEnv).sort()).toEqual([
|
||||
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
|
||||
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
|
||||
+22
-359
@@ -1,18 +1,14 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fetch from 'node-fetch';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../../utils/deviceRunSessionArtifacts';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
DeviceRunSessionStoppedError,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
@@ -25,8 +21,6 @@ import {
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createProcessOutput } from '../../utils/processOutput';
|
||||
import { type StartupTasks, createStartupTasks } from '../../utils/startupTasks';
|
||||
import { createWarmDeviceSessionServer } from '../../utils/warmDeviceSessionHost';
|
||||
import { type WarmIosSession } from '../../utils/warmIosSession';
|
||||
import { runAgentDeviceRemoteSessionAsync } from '../startAgentDeviceRemoteSession';
|
||||
|
||||
// The daemon entry path and the state directory are resolved from the home directory when
|
||||
@@ -44,14 +38,10 @@ jest.mock('../../../sentry');
|
||||
jest.mock('../../utils/agentDeviceArtifacts', () => ({
|
||||
pollAgentDeviceArtifactsForUploadAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/deviceRunSessionArtifacts', () => ({
|
||||
uploadDeviceRunSessionArtifactAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/agentDeviceEvents', () => ({
|
||||
startAgentDeviceEventCollectionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/warmDeviceSessionHost');
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
getDeviceRunSessionIdOrThrow: jest.fn(),
|
||||
@@ -84,7 +74,9 @@ const NO_CAPTURE: CaptureInputs = { networkCapture: false, networkCaptureFields:
|
||||
async function runAsync(
|
||||
logger: { info: jest.Mock; warn: jest.Mock; child: jest.Mock },
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
options: Partial<Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]> = {}
|
||||
capture: CaptureInputs = NO_CAPTURE,
|
||||
disableShare = false,
|
||||
privatePreviewUrl?: string
|
||||
): Promise<void> {
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env: {},
|
||||
@@ -98,29 +90,18 @@ async function runAsync(
|
||||
packageVersion: undefined,
|
||||
maxIdleTimeMinutes: undefined,
|
||||
maxDurationSeconds: undefined,
|
||||
capture: NO_CAPTURE,
|
||||
tasks: createStartupTasks(logger as never, options.signal),
|
||||
capture,
|
||||
disableShare,
|
||||
privatePreviewUrl,
|
||||
tasks: createStartupTasks(logger as never),
|
||||
device:
|
||||
runtimePlatform === BuildRuntimePlatform.DARWIN
|
||||
? { iosSimulatorUdid: 'selected-udid', application: Promise.resolve() }
|
||||
: { booted: Promise.resolve(), ready: Promise.resolve() },
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
it('cleans up a stopped startup without starting live-session polling', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValueOnce('stopped');
|
||||
await runAsync(logger, BuildRuntimePlatform.DARWIN);
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
expect(pollAgentDeviceArtifactsForUploadAsync).not.toHaveBeenCalled();
|
||||
expect(startAgentDeviceEventCollectionAsync).not.toHaveBeenCalled();
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalled();
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalled();
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
@@ -152,7 +133,6 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
};
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async (_ctx, options) => {
|
||||
@@ -165,7 +145,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
}
|
||||
return host;
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
|
||||
await fs.promises.mkdir(path.dirname(DAEMON_ENTRY_PATH), { recursive: true });
|
||||
@@ -201,213 +181,6 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses the warm daemon after adoption and starts no daemon of its own', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const warmServer = { port: 3200 } as never;
|
||||
jest.mocked(createWarmDeviceSessionServer).mockReturnValue(warmServer);
|
||||
const warm = {
|
||||
agentDeviceStateDir: '/warm/agent',
|
||||
tools: { pid: 20, port: 4310, token: 'warm-token' },
|
||||
} as WarmIosSession;
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env: {},
|
||||
logger: logger as never,
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
sessionEnv: {
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
ngrokTunnelDomain: 'tunnel.example.com',
|
||||
ngrokAuthtoken: 'ngrok-token',
|
||||
},
|
||||
packageVersion: undefined,
|
||||
maxIdleTimeMinutes: undefined,
|
||||
maxDurationSeconds: undefined,
|
||||
capture: NO_CAPTURE,
|
||||
tasks: createStartupTasks(logger as never),
|
||||
warm,
|
||||
device: { iosSimulatorUdid: 'UDID', application: Promise.resolve() },
|
||||
});
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(ctx, expect.anything(), warmServer);
|
||||
expect(startNgrokTunnelAsync).toHaveBeenCalledWith(expect.objectContaining({ port: 4310 }));
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({ agentDeviceRemoteSessionToken: 'warm-token' }),
|
||||
})
|
||||
);
|
||||
expect(startAgentDeviceEventCollectionAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ stateDir: '/warm/agent' })
|
||||
);
|
||||
expect(mockEventCollectionStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'finishes the final artifact upload before stopping its owner (warm=%s)',
|
||||
async warm => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const { pollAgentDeviceArtifactsForUploadAsync: pollActual } = jest.requireActual<
|
||||
typeof import('../../utils/agentDeviceArtifacts')
|
||||
>('../../utils/agentDeviceArtifacts');
|
||||
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockImplementation(pollActual);
|
||||
const { Response } = jest.requireActual<typeof import('node-fetch')>('node-fetch');
|
||||
const artifacts: { id: string; filename: string }[] = [];
|
||||
let releaseUpload = () => {};
|
||||
const pendingUpload = new Promise<void>(resolve => {
|
||||
releaseUpload = resolve;
|
||||
});
|
||||
let markUploadStarted = () => {};
|
||||
const uploadStarted = new Promise<void>(resolve => {
|
||||
markUploadStarted = resolve;
|
||||
});
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementationOnce(async () => {
|
||||
markUploadStarted();
|
||||
await pendingUpload;
|
||||
});
|
||||
let inventories = 0;
|
||||
jest.mocked(fetch).mockImplementation(async url => {
|
||||
if (String(url).endsWith('/artifacts')) {
|
||||
inventories += 1;
|
||||
return new Response(JSON.stringify({ artifacts }));
|
||||
}
|
||||
return new Response(Readable.from([Buffer.from('screenshot')]));
|
||||
});
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
|
||||
while (inventories === 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
// The next periodic inventory is 5 seconds away, so only the final one can see this file.
|
||||
artifacts.push({ id: 'late', filename: 'late.png' });
|
||||
});
|
||||
|
||||
const running = runAsync(
|
||||
logger,
|
||||
warm ? BuildRuntimePlatform.DARWIN : BuildRuntimePlatform.LINUX,
|
||||
{
|
||||
warm: warm
|
||||
? ({
|
||||
agentDeviceStateDir: '/warm/agent',
|
||||
tools: { port: 4310, token: 'warm-token' },
|
||||
} as WarmIosSession)
|
||||
: undefined,
|
||||
}
|
||||
);
|
||||
try {
|
||||
await uploadStarted;
|
||||
expect(mockDaemonStopAsync).not.toHaveBeenCalled();
|
||||
if (warm) {
|
||||
expect(mockPreviewStopAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
} finally {
|
||||
releaseUpload();
|
||||
await running;
|
||||
}
|
||||
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ artifactId: 'late', filename: 'late.png' })
|
||||
);
|
||||
expect(inventories).toBe(2);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(warm ? 0 : 1);
|
||||
}
|
||||
);
|
||||
|
||||
it('stops the daemon when the final artifact drain fails', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const failure = new Error('drain failed');
|
||||
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockRejectedValue(failure);
|
||||
|
||||
await expect(runAsync(logger, BuildRuntimePlatform.LINUX)).rejects.toBe(failure);
|
||||
|
||||
expect(pollAgentDeviceArtifactsForUploadAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ stopPollingSignal: expect.any(AbortSignal) })
|
||||
);
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'stops the warm host after a failed drain (collector startup fails=%s)',
|
||||
async startupFails => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const drainFailure = new Error('drain failed');
|
||||
const startupFailure = new Error('event collector failed');
|
||||
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockRejectedValueOnce(drainFailure);
|
||||
if (startupFails) {
|
||||
jest.mocked(startAgentDeviceEventCollectionAsync).mockRejectedValueOnce(startupFailure);
|
||||
}
|
||||
await expect(
|
||||
runAsync(logger, BuildRuntimePlatform.DARWIN, {
|
||||
warm: {
|
||||
agentDeviceStateDir: '/warm/agent',
|
||||
tools: { port: 4310, token: 'warm-token' },
|
||||
} as WarmIosSession,
|
||||
})
|
||||
).rejects.toBe(startupFails ? startupFailure : drainFailure);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaemonStopAsync).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: drainFailure },
|
||||
'Could not stop the agent-device daemon during remote session teardown.'
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('cancels a warm upload and drains its poller before stopping the host', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const controller = new AbortController();
|
||||
const cancellation = new Error('job cancelled');
|
||||
const { pollAgentDeviceArtifactsForUploadAsync: pollActual } = jest.requireActual<
|
||||
typeof import('../../utils/agentDeviceArtifacts')
|
||||
>('../../utils/agentDeviceArtifacts');
|
||||
const pollingFinished = jest.fn();
|
||||
jest
|
||||
.mocked(pollAgentDeviceArtifactsForUploadAsync)
|
||||
.mockImplementation((...args) => pollActual(...args).finally(pollingFinished));
|
||||
const { Response } = jest.requireActual<typeof import('node-fetch')>('node-fetch');
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockImplementation(async url =>
|
||||
String(url).endsWith('/artifacts')
|
||||
? new Response(JSON.stringify({ artifacts: [{ id: 'active', filename: 'active.png' }] }))
|
||||
: new Response(Readable.from(['png']))
|
||||
);
|
||||
let markUploadStarted = () => {};
|
||||
const uploadStarted = new Promise<void>(resolve => {
|
||||
markUploadStarted = resolve;
|
||||
});
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { signal }) => {
|
||||
markUploadStarted();
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
});
|
||||
});
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementationOnce(async () => {
|
||||
await uploadStarted;
|
||||
controller.abort(cancellation);
|
||||
throw cancellation;
|
||||
});
|
||||
await expect(
|
||||
runAsync(logger, BuildRuntimePlatform.DARWIN, {
|
||||
signal: controller.signal,
|
||||
warm: {
|
||||
agentDeviceStateDir: '/warm/agent',
|
||||
tools: { port: 4310, token: 'warm-token' },
|
||||
} as WarmIosSession,
|
||||
})
|
||||
).rejects.toBe(cancellation);
|
||||
expect(pollingFinished).toHaveBeenCalledTimes(1);
|
||||
expect(pollingFinished.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockPreviewStopAsync.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaemonStopAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redacts learned daemon credentials in arbitrary output and retained diagnostics', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const token = 'daemon secret/+';
|
||||
@@ -429,26 +202,12 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(encodeURIComponent(token));
|
||||
});
|
||||
|
||||
it('resets the idle timer on preview input from the session host', async () => {
|
||||
const previewInputAt = new Date('2026-10-19T12:00:00Z');
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(ctx, {
|
||||
application: Promise.resolve(),
|
||||
} as never);
|
||||
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(previewInputAt);
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.LINUX, { maxIdleTimeMinutes: 5 });
|
||||
|
||||
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
|
||||
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(previewInputAt);
|
||||
});
|
||||
|
||||
it('hands network capture to serve-sim', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
|
||||
capture: { networkCapture: true, networkCaptureFields: ['header'] },
|
||||
networkCapture: true,
|
||||
networkCaptureFields: ['header'],
|
||||
});
|
||||
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
@@ -459,10 +218,13 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
|
||||
it('hands a private preview choice to the session host', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
|
||||
disableShare: true,
|
||||
privatePreviewUrl: 'https://expo.dev/private-preview',
|
||||
});
|
||||
await runAsync(
|
||||
logger,
|
||||
BuildRuntimePlatform.DARWIN,
|
||||
NO_CAPTURE,
|
||||
true,
|
||||
'https://expo.dev/private-preview'
|
||||
);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
@@ -558,8 +320,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
type Device = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['device'];
|
||||
function startSession(
|
||||
device: Device | ((tasks: StartupTasks) => Device),
|
||||
signal?: AbortSignal,
|
||||
onStarted?: () => Promise<void>
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
@@ -587,7 +348,6 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
capture: NO_CAPTURE,
|
||||
tasks,
|
||||
device: sessionDevice,
|
||||
onStarted,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -826,7 +586,10 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
ctx,
|
||||
{} as never
|
||||
);
|
||||
const preview = await host.openPreviewAsync();
|
||||
const preview = await host.openPreviewAsync({
|
||||
baseDomain: 'tunnel.example.com',
|
||||
authtoken: 'ngrok-token',
|
||||
});
|
||||
jest.mocked(host.openPreviewAsync).mockImplementation(async () => {
|
||||
await previewReady.promise;
|
||||
return preview;
|
||||
@@ -860,106 +623,6 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports late host cleanup failure after startup Stop', async () => {
|
||||
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
|
||||
const failed = new AggregateError([stopped, new Error('host cleanup failed')]);
|
||||
let entered!: () => void;
|
||||
const starting = new Promise<void>(resolve => {
|
||||
entered = resolve;
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
|
||||
entered();
|
||||
return await new Promise((_resolve, reject) => {
|
||||
options.signal!.addEventListener('abort', () => setImmediate(() => reject(failed)), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
let tasks!: StartupTasks;
|
||||
const running = startSession(startup => {
|
||||
tasks = startup;
|
||||
return { booted: Promise.resolve(), ready: Promise.resolve() };
|
||||
});
|
||||
running.catch(() => {});
|
||||
await starting;
|
||||
tasks.abort(stopped);
|
||||
await expect(running).rejects.toBe(failed);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drains resources and preserves the explicit startup Stop reason', async () => {
|
||||
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
|
||||
let tasks!: StartupTasks;
|
||||
const session = startSession(startup => {
|
||||
tasks = startup;
|
||||
return { booted: Promise.resolve(), ready: tasks.untilAborted(new Promise(() => {})) };
|
||||
});
|
||||
session.catch(() => {});
|
||||
await flushAsync();
|
||||
tasks.abort(stopped);
|
||||
await expect(session).rejects.toBe(stopped);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not hide a teardown failure after an explicit startup Stop', async () => {
|
||||
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
|
||||
const failed = new Error('daemon teardown failed');
|
||||
let tasks!: StartupTasks;
|
||||
const session = startSession(startup => {
|
||||
tasks = startup;
|
||||
return { booted: Promise.resolve(), ready: tasks.untilAborted(new Promise(() => {})) };
|
||||
});
|
||||
session.catch(() => {});
|
||||
await flushAsync();
|
||||
mockDaemonStopAsync.mockRejectedValueOnce(failed);
|
||||
tasks.abort(stopped);
|
||||
await expect(session).rejects.toBe(failed);
|
||||
});
|
||||
|
||||
it('retires startup monitoring after reporting and before lifetime waiting', async () => {
|
||||
const retired = deferred();
|
||||
const retiring = jest.fn(() => retired.promise);
|
||||
const session = startSession(
|
||||
{ booted: Promise.resolve(), ready: Promise.resolve() },
|
||||
undefined,
|
||||
retiring
|
||||
);
|
||||
await flushAsync();
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
|
||||
expect(retiring).toHaveBeenCalledTimes(1);
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
retired.resolve();
|
||||
await session;
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('aborts a pending ready report when startup is stopped', async () => {
|
||||
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
|
||||
let tasks!: StartupTasks;
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockImplementation(
|
||||
async ({ signal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
signal!.addEventListener('abort', () => reject(new Error('request aborted')), {
|
||||
once: true,
|
||||
});
|
||||
})
|
||||
);
|
||||
const session = startSession(startup => {
|
||||
tasks = startup;
|
||||
return { booted: Promise.resolve(), ready: Promise.resolve() };
|
||||
});
|
||||
session.catch(() => {});
|
||||
await flushAsync();
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signal: tasks.signal })
|
||||
);
|
||||
tasks.abort(stopped);
|
||||
await expect(session).rejects.toBe(stopped);
|
||||
expect(startAgentDeviceEventCollectionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts the Android host before application readiness and waits before reporting ready', async () => {
|
||||
const ready = deferred();
|
||||
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
|
||||
|
||||
+218
-412
@@ -4,16 +4,14 @@ import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { DeviceRunSessionWorkerApi } from '../../utils/deviceRunSessionWorkerApi';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { stopLocalEgressResourcesAsync } from '../../utils/localEgress';
|
||||
import {
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync,
|
||||
startLocalEgressGuardRelayAsync,
|
||||
} from '../../utils/localEgressGuard';
|
||||
import {
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
|
||||
import { downloadBuildAsync } from '../downloadBuild';
|
||||
import { installBuildAsync } from '../installBuild';
|
||||
import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact';
|
||||
@@ -22,7 +20,6 @@ import {
|
||||
getAgentDeviceRemoteSessionEnvOrThrow,
|
||||
runAgentDeviceRemoteSessionAsync,
|
||||
} from '../startAgentDeviceRemoteSession';
|
||||
import { runOnWarmIosSimulatorAsync } from '../../utils/warmDeviceSessionHost';
|
||||
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
|
||||
import { startAndroidEmulatorAsync } from '../startAndroidEmulator';
|
||||
import { bootIosSimulatorAsync } from '../startIosSimulator';
|
||||
@@ -39,14 +36,12 @@ jest.mock('../../../utils/IosSimulatorUtils');
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../startIosSimulator', () => ({
|
||||
...jest.requireActual('../startIosSimulator'),
|
||||
bootIosSimulatorAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../startAndroidEmulator', () => ({ startAndroidEmulatorAsync: jest.fn() }));
|
||||
jest.mock('../../utils/warmDeviceSessionHost');
|
||||
jest.mock('../downloadBuild', () => ({ downloadBuildAsync: jest.fn() }));
|
||||
jest.mock('../installBuild', () => ({ installBuildAsync: jest.fn() }));
|
||||
jest.mock('../../utils/iosAppArtifact', () => ({
|
||||
@@ -107,10 +102,9 @@ function runStep(
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
inputValues: Record<string, unknown> = {},
|
||||
signal?: AbortSignal,
|
||||
stepCtx: CustomBuildContext = ctx,
|
||||
warm = false
|
||||
stepCtx: CustomBuildContext = ctx
|
||||
): Promise<void> {
|
||||
const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx, { warm });
|
||||
const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx);
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const inputs = Object.fromEntries(
|
||||
[
|
||||
@@ -119,10 +113,8 @@ function runStep(
|
||||
'lcd_width',
|
||||
'lcd_height',
|
||||
'lcd_density',
|
||||
'install_app_path',
|
||||
'build_id',
|
||||
'application_archive_url',
|
||||
'launch_app_identifier',
|
||||
'launch_args',
|
||||
'open_url',
|
||||
'network_capture',
|
||||
@@ -153,25 +145,6 @@ function sessionDevice(): Device {
|
||||
}
|
||||
|
||||
describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
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 };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
deviceLaunch = undefined;
|
||||
jest.clearAllMocks();
|
||||
@@ -184,13 +157,6 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ name: 'iPhone 17', udid: 'SELECTED-UDID' }] as never);
|
||||
jest.mocked(getAgentDeviceRemoteSessionEnvOrThrow).mockReturnValue(sessionEnv);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async ({ signal }) => {
|
||||
if (!signal?.aborted) {
|
||||
await new Promise<void>(resolve =>
|
||||
signal?.addEventListener('abort', () => resolve(), { once: true })
|
||||
);
|
||||
}
|
||||
});
|
||||
jest
|
||||
.mocked(runAgentDeviceRemoteSessionAsync)
|
||||
.mockImplementation(async (_ctx, { device, tasks }) => {
|
||||
@@ -206,6 +172,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
emulatorPromise: Promise.resolve(),
|
||||
shouldAdjustAnimationScale: true,
|
||||
});
|
||||
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/App.app' });
|
||||
jest
|
||||
.mocked(installBuildAsync)
|
||||
.mockResolvedValue({ applicationIdentifier: 'dev.example.app', activityName: '.Main' });
|
||||
@@ -213,43 +180,6 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
jest.mocked(readIosApplicationIdentifierAsync).mockResolvedValue('dev.example.app');
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'observes Stop during warm claim and preserves cleanup failure=%s',
|
||||
async failCleanup => {
|
||||
const stopped = deferred();
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise);
|
||||
let entered!: () => void;
|
||||
const claiming = new Promise<void>(resolve => {
|
||||
entered = resolve;
|
||||
});
|
||||
const cleanup = new Error('warm cleanup failed');
|
||||
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async claim => {
|
||||
entered();
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
claim.signal!.addEventListener(
|
||||
'abort',
|
||||
() =>
|
||||
reject(
|
||||
failCleanup
|
||||
? new AggregateError([claim.signal!.reason, cleanup])
|
||||
: claim.signal!.reason
|
||||
),
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
const running = runStep(BuildRuntimePlatform.DARWIN, {}, undefined, ctx, true);
|
||||
running.catch(() => {});
|
||||
await claiming;
|
||||
stopped.resolve();
|
||||
if (failCleanup) {
|
||||
await expect(running).rejects.toMatchObject({ errors: expect.arrayContaining([cleanup]) });
|
||||
} else {
|
||||
await expect(running).resolves.toBeUndefined();
|
||||
}
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
it('hands guarded iOS boot to serve-sim and starts reporting before host startup', async () => {
|
||||
const bootEnv = {
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
@@ -258,7 +188,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
};
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(bootEnv);
|
||||
await runStep(BuildRuntimePlatform.DARWIN, {
|
||||
install_app_path: '/tmp/App.app',
|
||||
build_id: 'build-id',
|
||||
launch_args: ['-flag'],
|
||||
open_url: 'exp://example.test',
|
||||
});
|
||||
@@ -272,7 +202,14 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
|
||||
expect(downloadBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
buildId: 'build-id',
|
||||
extensions: ['app'],
|
||||
graphqlClient,
|
||||
robotAccessToken: 'robot-token',
|
||||
})
|
||||
);
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(1);
|
||||
const device = sessionDevice();
|
||||
expect(device).toEqual({
|
||||
@@ -316,48 +253,63 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('fails before app installation or host startup when guarded device shutdown fails', async () => {
|
||||
it('downloads build_id through the device run session worker API when the job has a session token', async () => {
|
||||
const sessionCtx = {
|
||||
graphqlClient,
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
} as unknown as CustomBuildContext;
|
||||
|
||||
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' }, undefined, sessionCtx);
|
||||
|
||||
expect(downloadBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
buildId: 'build-id',
|
||||
deviceRunSessionWorkerApi: expect.any(DeviceRunSessionWorkerApi),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not use the worker API without a session token or for an archive URL', async () => {
|
||||
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' });
|
||||
expect(downloadBuildAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ buildId: 'build-id', deviceRunSessionWorkerApi: null })
|
||||
);
|
||||
|
||||
const sessionCtx = {
|
||||
graphqlClient,
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
} as unknown as CustomBuildContext;
|
||||
await runStep(
|
||||
BuildRuntimePlatform.LINUX,
|
||||
{ application_archive_url: 'https://example.test/app.tar.gz' },
|
||||
undefined,
|
||||
sessionCtx
|
||||
);
|
||||
expect(downloadBuildAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
applicationArchiveUrl: 'https://example.test/app.tar.gz',
|
||||
deviceRunSessionWorkerApi: null,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('fails before download or host startup when guarded device shutdown fails', async () => {
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
});
|
||||
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Booted' } as never);
|
||||
jest.mocked(spawn).mockRejectedValue(new Error('shutdown failed'));
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app' })
|
||||
).rejects.toThrow('shutdown failed');
|
||||
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('treats Stop during guarded device shutdown as normal cancellation', async () => {
|
||||
const stopped = deferred();
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise);
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
});
|
||||
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Booted' } as never);
|
||||
jest.mocked(spawn).mockImplementation(
|
||||
(_command, _args, options) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
options!.signal!.addEventListener('abort', () => reject(new Error('shutdown aborted')), {
|
||||
once: true,
|
||||
});
|
||||
}) as ReturnType<typeof spawn>
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'shutdown failed'
|
||||
);
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' });
|
||||
step.catch(() => {});
|
||||
await flushAsync();
|
||||
expect(spawn).toHaveBeenCalled();
|
||||
stopped.resolve();
|
||||
await expect(step).resolves.toBeUndefined();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
expect(startLocalEgressGuardRelayAsync).not.toHaveBeenCalled();
|
||||
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('provides Simulator preparation and local app startup options to the remote session', async () => {
|
||||
it('provides Simulator preparation and downloaded startup options to the remote session', async () => {
|
||||
const hostReady = deferred();
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
@@ -372,7 +324,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
});
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, {
|
||||
device_identifier: 'iPhone 17',
|
||||
install_app_path: '/tmp/App.app',
|
||||
build_id: 'build-id',
|
||||
launch_args: ['-flag'],
|
||||
open_url: 'exp://example.test',
|
||||
});
|
||||
@@ -382,7 +334,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
env: {},
|
||||
});
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
|
||||
expect(downloadBuildAsync).toHaveBeenCalledTimes(1);
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
|
||||
artifactPath: '/tmp/App.app',
|
||||
env: {},
|
||||
@@ -400,15 +352,15 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('fails a guarded session before app installation or host startup if its boot files are missing', async () => {
|
||||
it('fails a guarded session before download or host startup if its boot files are missing', async () => {
|
||||
jest
|
||||
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
|
||||
.mockRejectedValue(new Error('missing bin/egress-guard-check'));
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app' })
|
||||
).rejects.toThrow('missing bin/egress-guard-check');
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'missing bin/egress-guard-check'
|
||||
);
|
||||
expect(startLocalEgressGuardRelayAsync).not.toHaveBeenCalled();
|
||||
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
@@ -423,18 +375,35 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
jest
|
||||
.mocked(runAgentDeviceRemoteSessionAsync)
|
||||
.mockRejectedValueOnce(new Error('guard check failed'));
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app' })
|
||||
).rejects.toThrow('guard check failed');
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'guard check failed'
|
||||
);
|
||||
expect(startLocalEgressGuardRelayAsync).toHaveBeenCalledTimes(1);
|
||||
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits for the download before handing the app to the host', async () => {
|
||||
const download = deferred<{ artifactPath: string }>();
|
||||
jest.mocked(downloadBuildAsync).mockReturnValue(download.promise);
|
||||
|
||||
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(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes the selected Simulator to the host even when there is no app', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN);
|
||||
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
expect(sessionDevice()).toEqual({
|
||||
@@ -451,7 +420,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
lcd_width: 720,
|
||||
lcd_height: 1600,
|
||||
lcd_density: 300,
|
||||
install_app_path: '/tmp/App.app',
|
||||
build_id: 'build-id',
|
||||
});
|
||||
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
@@ -464,113 +433,108 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
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 () => {
|
||||
jest.mocked(downloadBuildAsync).mockRejectedValue(new Error('download failed'));
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'download failed'
|
||||
);
|
||||
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 }) => {
|
||||
const ready = runDevicePreparationAsync(device);
|
||||
await beforeFailure?.();
|
||||
const daemon = tasks.run('agent-device daemon', async () => {
|
||||
throw new Error('daemon failed');
|
||||
});
|
||||
try {
|
||||
await Promise.all([daemon, ready]);
|
||||
} finally {
|
||||
await Promise.allSettled([daemon, ready]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('stops the download and waits for it when Android boot fails', async () => {
|
||||
const download = mockStalledDownload();
|
||||
jest.mocked(startAndroidEmulatorAsync).mockRejectedValue(new Error('boot failed'));
|
||||
|
||||
await expect(runStep(BuildRuntimePlatform.LINUX, { 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('drains the download when host startup fails before app preparation', async () => {
|
||||
const download = mockStalledDownload();
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mockRejectedValue(new Error('host failed'));
|
||||
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'host failed'
|
||||
);
|
||||
expect(download.aborted()).toBe(true);
|
||||
expect(download.settled()).toBe(true);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not start work for an already cancelled step', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error('cancelled'));
|
||||
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app' }, controller.signal)
|
||||
runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal)
|
||||
).rejects.toThrow('cancelled');
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
|
||||
'observes Stop during a held download on %s and drains startup normally',
|
||||
async runtimePlatform => {
|
||||
const download = mockStalledDownload();
|
||||
const stopped = deferred();
|
||||
const controller = new AbortController();
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise);
|
||||
const step = runStep(runtimePlatform, { build_id: 'build-id' }, controller.signal);
|
||||
step.catch(() => {});
|
||||
try {
|
||||
await flushAsync();
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deviceRunSessionId: sessionEnv.deviceRunSessionId,
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
);
|
||||
stopped.resolve();
|
||||
await expect(step).resolves.toBeUndefined();
|
||||
expect(download.aborted()).toBe(true);
|
||||
expect(download.settled()).toBe(true);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
expect(readIosApplicationIdentifierAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
controller.abort(new Error('test cleanup'));
|
||||
stopped.resolve();
|
||||
await step.catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('waits for an in-flight Android boot after Stop without installing or launching', async () => {
|
||||
const boot = deferred<Awaited<ReturnType<typeof startAndroidEmulatorAsync>>>();
|
||||
const stopped = deferred();
|
||||
jest.mocked(startAndroidEmulatorAsync).mockReturnValue(boot.promise);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise);
|
||||
let settled = false;
|
||||
const step = runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' });
|
||||
void step
|
||||
.finally(() => {
|
||||
settled = true;
|
||||
})
|
||||
.catch(() => {});
|
||||
try {
|
||||
await flushAsync();
|
||||
expect(startAndroidEmulatorAsync).toHaveBeenCalled();
|
||||
stopped.resolve();
|
||||
await flushAsync();
|
||||
expect(settled).toBe(false);
|
||||
} finally {
|
||||
boot.resolve({} as Awaited<ReturnType<typeof startAndroidEmulatorAsync>>);
|
||||
stopped.resolve();
|
||||
await step;
|
||||
}
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retires the startup watch without treating its abort as user Stop', async () => {
|
||||
jest
|
||||
.mocked(runAgentDeviceRemoteSessionAsync)
|
||||
.mockImplementation(async (_ctx, { onStarted, tasks }) => {
|
||||
await onStarted!();
|
||||
expect(tasks.signal.aborted).toBe(false);
|
||||
});
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN)).resolves.toBeUndefined();
|
||||
const options = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
expect(options.maxDurationSeconds).toBeUndefined();
|
||||
expect(options.idleTimeout).toBeUndefined();
|
||||
expect(options.signal!.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves a download failure that wins before Stop', async () => {
|
||||
const failed = new Error('download failed first');
|
||||
const stopped = deferred();
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise);
|
||||
jest.mocked(downloadBuildAsync).mockRejectedValue(failed);
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' });
|
||||
step.catch(() => {});
|
||||
await flushAsync();
|
||||
stopped.resolve();
|
||||
await expect(step).rejects.toBe(failed);
|
||||
});
|
||||
|
||||
it('fails when the startup status watch rejects', async () => {
|
||||
const failed = new Error('session errored');
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValue(failed);
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN)).rejects.toBe(failed);
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -589,15 +553,11 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not start device or app work after cancellation during Xcode selection', async () => {
|
||||
it('does not start device or download work after cancellation during Xcode selection', async () => {
|
||||
const controller = new AbortController();
|
||||
const selected = deferred();
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockReturnValue(selected.promise);
|
||||
const step = runStep(
|
||||
BuildRuntimePlatform.DARWIN,
|
||||
{ install_app_path: '/tmp/App.app' },
|
||||
controller.signal
|
||||
);
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal);
|
||||
const failure = expect(step).rejects.toThrow('cancelled');
|
||||
await flushAsync();
|
||||
controller.abort(new Error('cancelled'));
|
||||
@@ -606,7 +566,42 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
await failure;
|
||||
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drains app preparation when the daemon fails', async () => {
|
||||
const metadata = deferred<string>();
|
||||
const metadataStarted = deferred();
|
||||
jest.mocked(readIosApplicationIdentifierAsync).mockImplementation(() => {
|
||||
metadataStarted.resolve();
|
||||
return metadata.promise;
|
||||
});
|
||||
mockSessionWithFailingDaemon(async () => {
|
||||
await metadataStarted.promise;
|
||||
setImmediate(() => metadata.resolve('dev.example.app'));
|
||||
});
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'daemon failed'
|
||||
);
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
expect(deviceLaunch).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects conflicting application inputs before it boots anything', async () => {
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -622,10 +617,8 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
'lcd_width',
|
||||
'lcd_height',
|
||||
'lcd_density',
|
||||
'install_app_path',
|
||||
'build_id',
|
||||
'application_archive_url',
|
||||
'launch_app_identifier',
|
||||
'launch_args',
|
||||
'open_url',
|
||||
'network_capture',
|
||||
@@ -685,39 +678,18 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
['NUL URL', { open_url: 'example:\u0000screen' }],
|
||||
['URL length', { open_url: `example://${'a'.repeat(8192)}` }],
|
||||
])('rejects iOS launch %s before startup work', async (_name, inputs) => {
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, {
|
||||
install_app_path: '/tmp/App.app',
|
||||
...inputs,
|
||||
});
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id', ...inputs });
|
||||
await expect(step).rejects.toBeInstanceOf(UserError);
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['NUL argument', { launch_args: ['a\u0000b'] }],
|
||||
['command length', { launch_args: ['--es', 'quotes', "'".repeat(20_000)] }],
|
||||
['NUL URL', { open_url: 'example:\u0000screen' }],
|
||||
])('rejects Android launch %s before startup work', async (_name, inputs) => {
|
||||
const step = runStep(BuildRuntimePlatform.LINUX, {
|
||||
install_app_path: '/tmp/App.app',
|
||||
...inputs,
|
||||
});
|
||||
await expect(step).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
|
||||
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
|
||||
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps Android launch inputs on its existing direct path', async () => {
|
||||
const launchArgs = ['a'.repeat(9000)];
|
||||
await runStep(BuildRuntimePlatform.LINUX, {
|
||||
install_app_path: '/tmp/App.app',
|
||||
launch_args: launchArgs,
|
||||
});
|
||||
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', launch_args: launchArgs });
|
||||
expect(launchApplicationAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, launchArgs })
|
||||
);
|
||||
@@ -744,170 +716,4 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
it('requires an application for launch options before starting the device', async () => {
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { open_url: 'fixture://route' })
|
||||
).rejects.toThrow('pass install_app_path or launch_app_identifier');
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('launches an installed iOS application without reading an app artifact', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN, {
|
||||
launch_app_identifier: 'dev.installed.app',
|
||||
launch_args: ['--test'],
|
||||
open_url: 'fixture://route',
|
||||
});
|
||||
expect(readIosApplicationIdentifierAsync).not.toHaveBeenCalled();
|
||||
expect(deviceLaunch).toEqual({
|
||||
installAppPath: undefined,
|
||||
launchAppIdentifier: 'dev.installed.app',
|
||||
launchArgs: ['--test'],
|
||||
openUrl: 'fixture://route',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps explicit application identifier launch iOS-only', async () => {
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.LINUX, { launch_app_identifier: 'dev.app' })
|
||||
).rejects.toThrow('Pass install_app_path for Android');
|
||||
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still downloads a build_id app and launches it on iOS', async () => {
|
||||
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/Downloaded.app' });
|
||||
await runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id', launch_args: ['-flag'] });
|
||||
expect(downloadBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ buildId: 'build-id', extensions: ['app'] })
|
||||
);
|
||||
expect(deviceLaunch).toEqual({
|
||||
installAppPath: '/tmp/Downloaded.app',
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
launchArgs: ['-flag'],
|
||||
openUrl: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('still downloads an application_archive_url app and installs it on Android', async () => {
|
||||
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/Downloaded.apk' });
|
||||
await runStep(BuildRuntimePlatform.LINUX, {
|
||||
application_archive_url: 'https://example.test/app.tar.gz',
|
||||
});
|
||||
expect(downloadBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
applicationArchiveUrl: 'https://example.test/app.tar.gz',
|
||||
extensions: ['apk'],
|
||||
})
|
||||
);
|
||||
expect(installBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ artifactPath: '/tmp/Downloaded.apk' })
|
||||
);
|
||||
expect(launchApplicationAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('runs the warm step on the warm Simulator for a local app only', async () => {
|
||||
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation((_claim, run) => run());
|
||||
const runWarm = (inputs: Record<string, unknown>) =>
|
||||
runStep(BuildRuntimePlatform.DARWIN, inputs, undefined, ctx, true);
|
||||
await runWarm({ install_app_path: '/tmp/App.app', device_identifier: 'iPhone 17' });
|
||||
expect(runOnWarmIosSimulatorAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ engine: 'agent-device', requestedDevice: 'iPhone 17' }),
|
||||
expect.any(Function)
|
||||
);
|
||||
jest.mocked(runOnWarmIosSimulatorAsync).mockClear();
|
||||
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/Downloaded.app' });
|
||||
await runWarm({ build_id: 'build-id' });
|
||||
expect(runOnWarmIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('accepts only one application source', async () => {
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app', build_id: 'id' })
|
||||
).rejects.toThrow('Pass only one of install_app_path, build_id or application_archive_url.');
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits for Android boot before installing the supplied local APK', async () => {
|
||||
const boot = deferred();
|
||||
jest.mocked(startAndroidEmulatorAsync).mockReturnValue(boot.promise as never);
|
||||
const step = runStep(BuildRuntimePlatform.LINUX, { install_app_path: '/tmp/App.apk' });
|
||||
await flushAsync();
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
boot.resolve();
|
||||
await step;
|
||||
expect(installBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ artifactPath: '/tmp/App.apk' })
|
||||
);
|
||||
expect(launchApplicationAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('drains an in-flight Android install on cancellation and never launches it', async () => {
|
||||
const controller = new AbortController();
|
||||
const installation = deferred<{ applicationIdentifier: string; activityName: string }>();
|
||||
jest.mocked(installBuildAsync).mockReturnValue(installation.promise);
|
||||
const step = runStep(
|
||||
BuildRuntimePlatform.LINUX,
|
||||
{ install_app_path: '/tmp/App.apk' },
|
||||
controller.signal
|
||||
);
|
||||
let settled = false;
|
||||
void step.then(
|
||||
() => (settled = true),
|
||||
() => (settled = true)
|
||||
);
|
||||
const failure = expect(step).rejects.toThrow('cancelled');
|
||||
await flushAsync();
|
||||
expect(installBuildAsync).toHaveBeenCalledTimes(1);
|
||||
controller.abort(new Error('cancelled'));
|
||||
await flushAsync();
|
||||
expect(settled).toBe(false);
|
||||
installation.resolve({ applicationIdentifier: 'dev.app', activityName: '.Main' });
|
||||
await failure;
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drains an in-flight Android install when session startup fails', async () => {
|
||||
const entered = deferred();
|
||||
const installation = deferred<{ applicationIdentifier: string; activityName: string }>();
|
||||
jest.mocked(installBuildAsync).mockImplementation(() => {
|
||||
entered.resolve();
|
||||
return installation.promise;
|
||||
});
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async () => {
|
||||
await entered.promise;
|
||||
throw new Error('session startup failed');
|
||||
});
|
||||
const step = runStep(BuildRuntimePlatform.LINUX, { install_app_path: '/tmp/App.apk' });
|
||||
let settled = false;
|
||||
void step.then(
|
||||
() => (settled = true),
|
||||
() => (settled = true)
|
||||
);
|
||||
const failure = expect(step).rejects.toThrow('session startup failed');
|
||||
await entered.promise;
|
||||
await flushAsync();
|
||||
expect(settled).toBe(false);
|
||||
installation.resolve({ applicationIdentifier: 'dev.app', activityName: '.Main' });
|
||||
await failure;
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drains iOS metadata reading on cancellation without starting the session', async () => {
|
||||
const controller = new AbortController();
|
||||
const metadata = deferred<string>();
|
||||
jest.mocked(readIosApplicationIdentifierAsync).mockReturnValue(metadata.promise);
|
||||
const step = runStep(
|
||||
BuildRuntimePlatform.DARWIN,
|
||||
{ install_app_path: '/tmp/App.app' },
|
||||
controller.signal
|
||||
);
|
||||
const failure = expect(step).rejects.toThrow('cancelled');
|
||||
await flushAsync();
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
|
||||
controller.abort(new Error('cancelled'));
|
||||
metadata.resolve('dev.app');
|
||||
await failure;
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+12
-1133
File diff suppressed because it is too large.
Load diff
+3
-764
@@ -7,9 +7,8 @@ import path from 'node:path';
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import { isProcessDescendantOfAsync, killProcessGroup } from '../../../utils/processes';
|
||||
import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
|
||||
import { startArgentEventCollectionAsync } from '../../utils/argentEvents';
|
||||
import { createProcessOutput } from '../../utils/processOutput';
|
||||
@@ -24,15 +23,7 @@ import {
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import {
|
||||
createWarmDeviceSessionServer,
|
||||
runOnWarmIosSimulatorAsync,
|
||||
} from '../../utils/warmDeviceSessionHost';
|
||||
import { type WarmIosSession } from '../../utils/warmIosSession';
|
||||
import { watchDeviceRunSessionStartup } from '../../utils/deviceRunSessionStartup';
|
||||
import { createStartupWatchMock } from '../../../__tests__/utils/startupWatch';
|
||||
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
|
||||
import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact';
|
||||
|
||||
// Redirect ~/.argent (where the tool-server writes its state file and event log) to a temp
|
||||
// home so waitForArgentToolServerStateAsync — which lives in the module under test and cannot
|
||||
@@ -47,33 +38,13 @@ jest.mock('node:os', () => {
|
||||
});
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../utils/processes', () => ({
|
||||
isProcessDescendantOfAsync: jest.fn(),
|
||||
killProcessGroup: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../utils/processes', () => ({ isProcessDescendantOfAsync: jest.fn() }));
|
||||
jest.mock('../../utils/argentArtifacts', () => ({ pollArgentArtifactsForUploadAsync: jest.fn() }));
|
||||
jest.mock('../../utils/argentEvents', () => ({
|
||||
...jest.requireActual('../../utils/argentEvents'),
|
||||
startArgentEventCollectionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/warmDeviceSessionHost');
|
||||
jest.mock('../../utils/iosAppArtifact', () => ({
|
||||
...jest.requireActual('../../utils/iosAppArtifact'),
|
||||
readIosApplicationIdentifierAsync: jest.fn().mockResolvedValue('dev.example.app'),
|
||||
}));
|
||||
jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
IosSimulatorUtils: {
|
||||
resolveUdidAsync: jest.fn().mockResolvedValue('selected-ios-udid'),
|
||||
getAvailableDevicesAsync: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ name: 'iPhone 17', udid: 'selected-ios-udid' }]),
|
||||
},
|
||||
}));
|
||||
jest.mock('../../utils/localEgressGuard', () => ({
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
@@ -88,9 +59,6 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/deviceRunSessionStartup', () => ({
|
||||
watchDeviceRunSessionStartup: jest.fn(),
|
||||
}));
|
||||
|
||||
const TEST_HOME = path.join(os.tmpdir(), 'eas-argent-orchestration-home');
|
||||
const ARGENT_STATE_DIR = path.join(TEST_HOME, '.argent');
|
||||
@@ -136,12 +104,10 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock);
|
||||
|
||||
// The (real) tool-server state wait reads this file from the redirected ~/.argent.
|
||||
await fs.promises.mkdir(ARGENT_STATE_DIR, { recursive: true });
|
||||
@@ -155,363 +121,6 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
await fs.promises.rm(TEST_HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[BuildRuntimePlatform.DARWIN, false],
|
||||
[BuildRuntimePlatform.LINUX, false],
|
||||
[BuildRuntimePlatform.DARWIN, true],
|
||||
])(
|
||||
'handles Stop during %s setup, keeping teardown failure=%s visible',
|
||||
async (runtimePlatform, teardownFails) => {
|
||||
let rejectSetup!: (error: Error) => void;
|
||||
const setup = Object.assign(
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
rejectSetup = reject;
|
||||
}),
|
||||
{ child: { pid: 12345 } }
|
||||
);
|
||||
jest.mocked(spawn).mockReturnValueOnce(setup as unknown as ReturnType<typeof spawn>);
|
||||
jest
|
||||
.mocked(killProcessGroup)
|
||||
.mockImplementationOnce(() => rejectSetup(new Error('setup killed')));
|
||||
const teardownError = new Error('host teardown failed');
|
||||
if (teardownFails) {
|
||||
mockPreviewStopAsync.mockRejectedValueOnce(teardownError);
|
||||
}
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
const running = Promise.resolve(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
boot_simulator: {
|
||||
value: runtimePlatform === BuildRuntimePlatform.DARWIN ? true : undefined,
|
||||
},
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
);
|
||||
running.catch(() => {});
|
||||
try {
|
||||
for (let i = 0; i < 20 && !jest.mocked(spawn).mock.calls.length; i++) {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
|
||||
watch.stop();
|
||||
if (teardownFails) {
|
||||
await expect(running).rejects.toBe(teardownError);
|
||||
} else {
|
||||
await expect(running).resolves.toBeUndefined();
|
||||
}
|
||||
expect(killProcessGroup).toHaveBeenCalledTimes(1);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
expect(watch.finishAsync).toHaveBeenCalled();
|
||||
} finally {
|
||||
rejectSetup(new Error('test cleanup'));
|
||||
await running.catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('does not allocate Argent or a host after parent cancellation during Xcode selection', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
const controller = new AbortController();
|
||||
const failure = new Error('parent step cancelled');
|
||||
jest
|
||||
.mocked(selectXcodeDeveloperDirectoryAsync)
|
||||
.mockImplementationOnce(async () => controller.abort(failure));
|
||||
await expect(
|
||||
createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
signal: controller.signal,
|
||||
} as never
|
||||
)
|
||||
).rejects.toBe(failure);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves an aggregate host startup failure', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const failure = new AggregateError([
|
||||
new Error('host failed'),
|
||||
new Error('host cleanup failed'),
|
||||
]);
|
||||
jest.mocked(startDeviceSessionHostAsync).mockRejectedValueOnce(failure);
|
||||
await expect(
|
||||
createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
boot_simulator: { value: true },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
).rejects.toBe(failure);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports late host cleanup failure after startup Stop', async () => {
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
let entered!: () => void;
|
||||
const starting = new Promise<void>(resolve => {
|
||||
entered = resolve;
|
||||
});
|
||||
let failed!: AggregateError;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
|
||||
entered();
|
||||
return await new Promise((_resolve, reject) => {
|
||||
options.signal!.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
failed = new AggregateError([options.signal!.reason, new Error('host cleanup failed')]);
|
||||
setImmediate(() => reject(failed));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
const running = createStartArgentRemoteSessionBuildFunction(ctx).fn!(
|
||||
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
|
||||
{
|
||||
inputs: {
|
||||
boot_simulator: { value: true },
|
||||
device_identifier: { value: 'selected-ios-udid' },
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
) as Promise<void>;
|
||||
running.catch(() => {});
|
||||
await starting;
|
||||
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
|
||||
const failure = await running.then(
|
||||
() => undefined,
|
||||
error => error
|
||||
);
|
||||
expect(failure).toBe(failed);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps watching startup when warm adoption retries cold', async () => {
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const adoption = new Error('warm adoption failed');
|
||||
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async (_claim, run) => {
|
||||
try {
|
||||
return await run({
|
||||
device: { udid: 'selected-ios-udid', name: 'iPhone 17' },
|
||||
} as WarmIosSession);
|
||||
} catch (error) {
|
||||
if (error !== adoption) {
|
||||
throw error;
|
||||
}
|
||||
return await run(undefined);
|
||||
}
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
|
||||
options.onStartupError?.(adoption);
|
||||
throw adoption;
|
||||
});
|
||||
await expect(
|
||||
Promise.resolve(
|
||||
createStartArgentRemoteSessionBuildFunction(ctx, { warm: true }).fn!(
|
||||
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
|
||||
{
|
||||
inputs: {
|
||||
boot_simulator: { value: true },
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
)
|
||||
).resolves.toBeUndefined();
|
||||
expect(watchDeviceRunSessionStartup).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'observes Stop during warm claim and preserves cleanup failure=%s',
|
||||
async failCleanup => {
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
let entered!: () => void;
|
||||
const claiming = new Promise<void>(resolve => {
|
||||
entered = resolve;
|
||||
});
|
||||
const cleanup = new Error('warm cleanup failed');
|
||||
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async claim => {
|
||||
entered();
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
claim.signal!.addEventListener(
|
||||
'abort',
|
||||
() =>
|
||||
reject(
|
||||
failCleanup
|
||||
? new AggregateError([claim.signal!.reason, cleanup])
|
||||
: claim.signal!.reason
|
||||
),
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
const running = createStartArgentRemoteSessionBuildFunction(ctx, { warm: true }).fn!(
|
||||
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
) as Promise<void>;
|
||||
running.catch(() => {});
|
||||
await claiming;
|
||||
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
|
||||
if (failCleanup) {
|
||||
await expect(running).rejects.toMatchObject({ errors: expect.arrayContaining([cleanup]) });
|
||||
} else {
|
||||
await expect(running).resolves.toBeUndefined();
|
||||
}
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
it('does not open the preview after Stop while the tools tunnel is opening', async () => {
|
||||
let tunnelEntered!: () => void;
|
||||
const entered = new Promise<void>(resolve => {
|
||||
tunnelEntered = resolve;
|
||||
});
|
||||
let finishTunnel!: () => void;
|
||||
const pendingTunnel = new Promise<void>(resolve => {
|
||||
finishTunnel = resolve;
|
||||
});
|
||||
jest.mocked(startNgrokTunnelAsync).mockImplementationOnce(async () => {
|
||||
tunnelEntered();
|
||||
await pendingTunnel;
|
||||
return {
|
||||
url: 'https://argent.example.com',
|
||||
subdomainId: 'argent',
|
||||
stopAsync: mockTunnelStopAsync,
|
||||
};
|
||||
});
|
||||
const openPreviewAsync = jest.fn();
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
|
||||
openPreviewAsync,
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
});
|
||||
const running = Promise.resolve(
|
||||
createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
boot_simulator: { value: true },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
);
|
||||
running.catch(() => {});
|
||||
try {
|
||||
await entered;
|
||||
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
|
||||
} finally {
|
||||
finishTunnel();
|
||||
}
|
||||
await expect(running).resolves.toBeUndefined();
|
||||
expect(openPreviewAsync).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
|
||||
'preserves an earlier prepared %s host failure when Stop arrives during cleanup',
|
||||
async runtimePlatform => {
|
||||
const failure = new Error('Host became ready without a session token');
|
||||
let cleanupEntered!: () => void;
|
||||
const entered = new Promise<void>(resolve => {
|
||||
cleanupEntered = resolve;
|
||||
});
|
||||
let finishCleanup!: () => void;
|
||||
const cleanup = new Promise<void>(resolve => {
|
||||
finishCleanup = resolve;
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
|
||||
options.onStartupError?.(failure);
|
||||
cleanupEntered();
|
||||
await cleanup;
|
||||
throw failure;
|
||||
});
|
||||
const running = Promise.resolve(
|
||||
createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
);
|
||||
running.catch(() => {});
|
||||
try {
|
||||
await entered;
|
||||
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
|
||||
} finally {
|
||||
finishCleanup();
|
||||
}
|
||||
await expect(running).rejects.toBe(failure);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('reports an early exit with output and stops Argent before opening tunnels', async () => {
|
||||
const stopError = new Error('drain timed out');
|
||||
const stopServer = jest.fn().mockRejectedValue(stopError);
|
||||
@@ -608,7 +217,6 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
if (phase === 'preview') {
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
|
||||
openPreviewAsync: jest.fn().mockRejectedValue(error),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
});
|
||||
} else if (phase === 'config') {
|
||||
@@ -899,35 +507,6 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resets the idle timer on preview input from the session host', async () => {
|
||||
const previewInputAt = new Date('2026-10-19T12:00:00Z');
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
{} as never,
|
||||
{} as never
|
||||
);
|
||||
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(previewInputAt);
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: 5 },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
|
||||
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
|
||||
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(previewInputAt);
|
||||
});
|
||||
|
||||
it('redacts startup credentials and registers the state token for later output', async () => {
|
||||
const token = 'tool/server "secret"';
|
||||
await fs.promises.writeFile(
|
||||
@@ -974,72 +553,6 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
expect(JSON.stringify(logger.info.mock.calls)).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('uses the warm Argent server and drains its artifacts before stopping the host', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const warmServer = { port: 3200 } as never;
|
||||
const warm = {
|
||||
device: { udid: 'WARM-UDID', name: 'iPhone 17' },
|
||||
argentEventLogPath: '/warm/argent-events',
|
||||
tools: { pid: 30, port: 3201, token: 'warm-token' },
|
||||
} as WarmIosSession;
|
||||
jest.mocked(createWarmDeviceSessionServer).mockReturnValue(warmServer);
|
||||
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation((_claim, run) => run(warm));
|
||||
let releaseArtifacts = () => {};
|
||||
const artifactsDrained = new Promise<void>(resolve => {
|
||||
releaseArtifacts = resolve;
|
||||
});
|
||||
let pollingStarted = () => {};
|
||||
const polling = new Promise<void>(resolve => {
|
||||
pollingStarted = resolve;
|
||||
});
|
||||
jest.mocked(pollArgentArtifactsForUploadAsync).mockImplementationOnce(async () => {
|
||||
pollingStarted();
|
||||
await artifactsDrained;
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx, { warm: true });
|
||||
expect(buildFunction.id).toBe('start_warm_argent_remote_session');
|
||||
if (!buildFunction.fn) {
|
||||
throw new Error('Expected the Argent session function.');
|
||||
}
|
||||
const running = buildFunction.fn(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
max_duration_seconds: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
try {
|
||||
await polling;
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalled();
|
||||
expect(mockPreviewStopAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
releaseArtifacts();
|
||||
await running;
|
||||
}
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(ctx, expect.anything(), warmServer);
|
||||
expect(startNgrokTunnelAsync).toHaveBeenCalledWith(expect.objectContaining({ port: 3201 }));
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({ toolsAuthToken: 'warm-token' }),
|
||||
})
|
||||
);
|
||||
expect(startArgentEventCollectionAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ eventLogPath: '/warm/argent-events' })
|
||||
);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
@@ -1079,280 +592,6 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('boots and prepares the downloaded app while Argent installation is pending, and closes the host if installation fails', async () => {
|
||||
let rejectInstall!: (error: Error) => void;
|
||||
let enteredInstall!: () => void;
|
||||
const installEntered = new Promise<void>(resolve => {
|
||||
enteredInstall = resolve;
|
||||
});
|
||||
jest.mocked(spawn).mockImplementationOnce(() => {
|
||||
enteredInstall();
|
||||
return new Promise((_resolve, reject) => {
|
||||
rejectInstall = reject;
|
||||
}) as never;
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
const running = buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
install_app_path: { value: '/tmp/App.app' },
|
||||
launch_args: { value: ['--literal'] },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
await installEntered;
|
||||
expect(
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
|
||||
).toBeLessThan(jest.mocked(IosSimulatorUtils.resolveUdidAsync).mock.invocationCallOrder[0]);
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
|
||||
artifactPath: '/tmp/App.app',
|
||||
env: {},
|
||||
});
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
iosSimulatorUdid: 'SELECTED-IOS-UDID',
|
||||
installAppPath: '/tmp/App.app',
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
launchArgs: ['--literal'],
|
||||
})
|
||||
);
|
||||
const error = new Error('Argent install failed');
|
||||
rejectInstall(error);
|
||||
await expect(running).rejects.toBe(error);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['artifacts-list-endpoint', 'tool-server-event-log', 'video-watermark'])(
|
||||
'stops and drains the pending %s command when the concurrent host fails',
|
||||
async flag => {
|
||||
const hostError = new Error('serve-sim startup failed');
|
||||
const commandError = new Error('Argent command exited after cancellation');
|
||||
let failHost!: (error: Error) => void;
|
||||
let finishHostCleanup!: () => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(
|
||||
async (_ctx, options) =>
|
||||
await new Promise((_resolve, reject) => {
|
||||
failHost = error => {
|
||||
options.onStartupError?.(error);
|
||||
finishHostCleanup = () => reject(error);
|
||||
};
|
||||
})
|
||||
);
|
||||
let failCommand!: (error: Error) => void;
|
||||
let commandStarted!: () => void;
|
||||
const commandEntered = new Promise<void>(resolve => {
|
||||
commandStarted = resolve;
|
||||
});
|
||||
const child = { pid: 4242, kill: jest.fn() };
|
||||
jest.mocked(spawn).mockImplementation((_command, args) => {
|
||||
if (!args.includes(flag)) {
|
||||
return Promise.resolve(undefined) as never;
|
||||
}
|
||||
const pending = new Promise((_resolve, reject) => {
|
||||
failCommand = reject;
|
||||
});
|
||||
commandStarted();
|
||||
return Object.assign(pending, { child }) as never;
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
const running = Promise.resolve(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
boot_simulator: { value: true },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
);
|
||||
let settled = false;
|
||||
void running
|
||||
.finally(() => {
|
||||
settled = true;
|
||||
})
|
||||
.catch(() => {});
|
||||
try {
|
||||
await commandEntered;
|
||||
failHost(hostError);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(killProcessGroup).toHaveBeenCalledWith(child, 'SIGKILL');
|
||||
expect(settled).toBe(false);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
failCommand(commandError);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(settled).toBe(false);
|
||||
finishHostCleanup();
|
||||
await expect(running).rejects.toBe(hostError);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
failCommand?.(commandError);
|
||||
finishHostCleanup?.();
|
||||
await running.catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('interrupts tool-server state polling and retains the original host error', async () => {
|
||||
await fs.promises.rm(path.join(ARGENT_STATE_DIR, 'tool-server-orchestration.json'));
|
||||
const hostError = new Error('serve-sim startup failed');
|
||||
let failHost!: (error: Error) => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise((_resolve, reject) => {
|
||||
failHost = reject;
|
||||
})
|
||||
);
|
||||
let pollingStarted!: () => void;
|
||||
const pollingEntered = new Promise<void>(resolve => {
|
||||
pollingStarted = resolve;
|
||||
});
|
||||
const getExitError = jest.fn<Error | undefined, []>(() => {
|
||||
pollingStarted();
|
||||
return undefined;
|
||||
});
|
||||
const stopServer = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(spawnDetached).mockReturnValueOnce({
|
||||
pid: 4242,
|
||||
getOutput: () => 'later controller diagnostic',
|
||||
getExitError,
|
||||
stopAsync: stopServer,
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
const running = Promise.resolve(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
boot_simulator: { value: true },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
);
|
||||
void running.catch(() => {});
|
||||
try {
|
||||
await pollingEntered;
|
||||
failHost(hostError);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const options = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
|
||||
expect(options.signal?.aborted).toBe(true);
|
||||
getExitError.mockReturnValue(new Error('later Argent exit'));
|
||||
await expect(running).rejects.toBe(hostError);
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
getExitError.mockReturnValue(new Error('stop pending state poll'));
|
||||
await running.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps Argent setup concurrent but waits for the iOS host before opening tools', async () => {
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
{} as CustomBuildContext,
|
||||
{} as never
|
||||
);
|
||||
let releaseHost!: () => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
releaseHost = () => resolve(host);
|
||||
})
|
||||
);
|
||||
let stateReady!: () => void;
|
||||
const stateRead = new Promise<void>(resolve => {
|
||||
stateReady = resolve;
|
||||
});
|
||||
jest.mocked(isProcessDescendantOfAsync).mockImplementationOnce(async () => {
|
||||
stateReady();
|
||||
return true;
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
const running = Promise.resolve(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
boot_simulator: { value: true },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
);
|
||||
try {
|
||||
await stateRead;
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(1);
|
||||
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
releaseHost();
|
||||
await running;
|
||||
}
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
|
||||
'makes %s prepared setup commands cancellable',
|
||||
async runtimePlatform => {
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
expect(spawn).toHaveBeenCalledTimes(3);
|
||||
for (const [, , options] of jest.mocked(spawn).mock.calls) {
|
||||
expect(options).toHaveProperty('detached', true);
|
||||
expect(options).not.toHaveProperty('signal');
|
||||
}
|
||||
expect(killProcessGroup).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('fails before starting anything when a launch is asked for on Android', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
@@ -5,12 +6,79 @@ import path from 'node:path';
|
||||
|
||||
import { Sentry } from '../../../sentry';
|
||||
import {
|
||||
MIN_ARGENT_REMOTE_SESSION_VERSION,
|
||||
stopArgentEventCollectionSafelyAsync,
|
||||
waitForArgentToolServerStateAsync,
|
||||
warnIfArgentPackageVersionCannotBeVerified,
|
||||
} from '../startArgentRemoteSession';
|
||||
|
||||
jest.mock('../../../sentry');
|
||||
|
||||
describe(warnIfArgentPackageVersionCannotBeVerified, () => {
|
||||
const warn = jest.fn();
|
||||
const logger = { warn } as unknown as bunyan;
|
||||
const supportedVersion = '999.0.0';
|
||||
const oldVersion = '0.0.0';
|
||||
|
||||
beforeEach(() => {
|
||||
warn.mockClear();
|
||||
});
|
||||
|
||||
it('allows the default latest package version', () => {
|
||||
expect(() =>
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: undefined, logger })
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: 'latest', logger })
|
||||
).not.toThrow();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows exact supported semver versions', () => {
|
||||
expect(() =>
|
||||
warnIfArgentPackageVersionCannotBeVerified({
|
||||
packageVersion: MIN_ARGENT_REMOTE_SESSION_VERSION,
|
||||
logger,
|
||||
})
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: supportedVersion, logger })
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
warnIfArgentPackageVersionCannotBeVerified({
|
||||
packageVersion: `v${supportedVersion}`,
|
||||
logger,
|
||||
})
|
||||
).not.toThrow();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects exact semver versions that are too old', () => {
|
||||
expect(() =>
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: oldVersion, logger })
|
||||
).toThrow(SystemError);
|
||||
expect(() =>
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: oldVersion, logger })
|
||||
).toThrow(`Use "latest" or pass an exact version >= ${MIN_ARGENT_REMOTE_SESSION_VERSION}`);
|
||||
});
|
||||
|
||||
it('warns for package tags or ranges that cannot be verified', () => {
|
||||
expect(() =>
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: 'next', logger })
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
warnIfArgentPackageVersionCannotBeVerified({
|
||||
packageVersion: `^${MIN_ARGENT_REMOTE_SESSION_VERSION}`,
|
||||
logger,
|
||||
})
|
||||
).not.toThrow();
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Continuing and letting the package manager resolve it.')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(waitForArgentToolServerStateAsync, () => {
|
||||
let stateDir: string;
|
||||
|
||||
|
||||
@@ -1,268 +1,159 @@
|
||||
import { EnvironmentSecretType, SystemError, UserError } from '@expo/eas-build-job';
|
||||
import fetch from 'node-fetch';
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
|
||||
import * as sandboxDaemon from '../../utils/sandboxDaemon';
|
||||
import { SandboxWorkerApi } from '../../utils/sandboxWorkerApi';
|
||||
import { createStartSandboxBuildFunction } from '../startSandbox';
|
||||
|
||||
const { AbortError, Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
const context = {
|
||||
job: {
|
||||
secrets: {
|
||||
environmentSecrets: [
|
||||
{
|
||||
name: 'EAS_SANDBOX_WORKER_TOKEN',
|
||||
type: EnvironmentSecretType.STRING,
|
||||
value: 'worker-token',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
};
|
||||
const inputs = { sandbox_id: { value: 'sandbox-id' } };
|
||||
import { createStartSandboxBuildFunction, markSandboxReadyAsync } from '../startSandbox';
|
||||
|
||||
describe('sandbox build functions', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ completion: 'duration', duration: 0.02 },
|
||||
{ completion: 'cancellation', duration: 60 },
|
||||
{ completion: 'duration', duration: 0.0001 },
|
||||
])(
|
||||
'stops after $completion with $duration seconds only after readiness',
|
||||
async ({ completion, duration }) => {
|
||||
const controller = new AbortController();
|
||||
let ready!: () => void;
|
||||
const stopAsync = jest.fn(async () => {});
|
||||
jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockResolvedValue({
|
||||
ready: new Promise<void>(resolve => {
|
||||
ready = resolve;
|
||||
}),
|
||||
stopAsync,
|
||||
});
|
||||
const markReady = jest
|
||||
.spyOn(SandboxWorkerApi.prototype, 'markReadyAsync')
|
||||
.mockResolvedValue(undefined);
|
||||
const logger = { info: jest.fn(), child: jest.fn() };
|
||||
logger.child.mockReturnValue(logger);
|
||||
const fn = createStartSandboxBuildFunction(context as any);
|
||||
const pending = fn.fn!({ logger } as any, {
|
||||
inputs: { ...inputs, max_duration_seconds: { value: duration } },
|
||||
outputs: {},
|
||||
env: {},
|
||||
signal: controller.signal,
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 40));
|
||||
expect(markReady).not.toHaveBeenCalled();
|
||||
expect(stopAsync).not.toHaveBeenCalled();
|
||||
ready();
|
||||
if (completion === 'cancellation') {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(stopAsync).not.toHaveBeenCalled();
|
||||
controller.abort();
|
||||
}
|
||||
await pending;
|
||||
expect(markReady).toHaveBeenCalledTimes(1);
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
const timeoutMessage = `Sandbox reached its maximum runtime of ${duration} seconds. Stopping.`;
|
||||
if (completion === 'duration') {
|
||||
expect(logger.info).toHaveBeenCalledWith(timeoutMessage);
|
||||
} else {
|
||||
expect(logger.info).not.toHaveBeenCalledWith(timeoutMessage);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it.each([0, -1, Infinity, NaN])(
|
||||
'rejects invalid maximum duration %s before starting the daemon',
|
||||
async duration => {
|
||||
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync');
|
||||
const fn = createStartSandboxBuildFunction(context as any);
|
||||
const result = fn.fn!({} as any, {
|
||||
inputs: { ...inputs, max_duration_seconds: { value: duration } },
|
||||
outputs: {},
|
||||
env: {},
|
||||
});
|
||||
await expect(result).rejects.toBeInstanceOf(UserError);
|
||||
await expect(result).rejects.toThrow(
|
||||
`Invalid sandbox maximum duration: ${duration}. Provide a finite number greater than zero, or omit max_duration_seconds.`
|
||||
);
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('connects with the worker token from the job secrets, which the step env does not have', async () => {
|
||||
const env = { PREPARED_BY_EARLIER_STEP: 'value' };
|
||||
const startupError = new Error('stop after checking options');
|
||||
const start = jest
|
||||
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
|
||||
.mockRejectedValue(startupError);
|
||||
const fn = createStartSandboxBuildFunction(context as any);
|
||||
|
||||
await expect(fn.fn!({ logger: {} } as any, { inputs, outputs: {}, env })).rejects.toBe(
|
||||
startupError
|
||||
);
|
||||
expect(start.mock.calls[0][0]).toMatchObject({ credential: 'worker-token', env });
|
||||
});
|
||||
|
||||
it('does not mark the sandbox ready if startup was canceled', async () => {
|
||||
it('aborts an active ready request and keeps its authentication headers', async () => {
|
||||
const controller = new AbortController();
|
||||
const stopAsync = jest.fn(async () => {});
|
||||
jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockImplementation(async options => {
|
||||
expect(options.signal).toBe(controller.signal);
|
||||
controller.abort();
|
||||
return { ready: Promise.resolve(), stopAsync };
|
||||
});
|
||||
const fn = createStartSandboxBuildFunction(context as any);
|
||||
|
||||
await expect(
|
||||
fn.fn!({ logger: {} } as any, { inputs, outputs: {}, env: {}, signal: controller.signal })
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('marks the sandbox ready with only the worker token and aborts the request when canceled', async () => {
|
||||
const controller = new AbortController();
|
||||
const stopAsync = jest.fn(async () => {});
|
||||
jest
|
||||
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
|
||||
.mockResolvedValue({ ready: Promise.resolve(), stopAsync });
|
||||
let requestStarted!: () => void;
|
||||
const started = new Promise<void>(resolve => {
|
||||
requestStarted = resolve;
|
||||
});
|
||||
jest.mocked(fetch).mockImplementationOnce(async (url, init) => {
|
||||
expect(url).toBe('https://api.expo.test/v2/sandbox-worker/ready');
|
||||
expect(init?.headers).toEqual({ 'expo-sandbox-worker-token': 'worker-token' });
|
||||
return await new Promise((_resolve, reject) => {
|
||||
init!.signal!.addEventListener('abort', () => reject(new AbortError('Aborted')));
|
||||
const fetchMock = jest.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => {
|
||||
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer test-token');
|
||||
return await new Promise<Response>((_resolve, reject) => {
|
||||
init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason), { once: true });
|
||||
requestStarted();
|
||||
});
|
||||
});
|
||||
const fn = createStartSandboxBuildFunction(context as any);
|
||||
const pending = fn.fn!({ logger: {} } as any, {
|
||||
inputs,
|
||||
outputs: {},
|
||||
env: {},
|
||||
signal: controller.signal,
|
||||
const graphqlClient = new Client({
|
||||
url: 'https://api.expo.test/graphql',
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: { headers: { Authorization: 'Bearer test-token' } },
|
||||
});
|
||||
const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' });
|
||||
|
||||
await started;
|
||||
controller.abort();
|
||||
await rejected;
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
try {
|
||||
const pending = markSandboxReadyAsync(
|
||||
{ graphqlClient } as any,
|
||||
'sandbox-id',
|
||||
controller.signal
|
||||
);
|
||||
const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' });
|
||||
await started;
|
||||
controller.abort();
|
||||
await rejected;
|
||||
} finally {
|
||||
fetchMock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('throws a system error and stops the daemon when readiness fails', async () => {
|
||||
it('does not mark the sandbox ready if startup was canceled', async () => {
|
||||
const controller = new AbortController();
|
||||
const stopAsync = jest.fn(async () => {});
|
||||
jest
|
||||
const start = jest
|
||||
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
|
||||
.mockResolvedValue({ ready: Promise.resolve(), stopAsync });
|
||||
jest.mocked(fetch).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ errors: [{ message: 'Sandbox has stopped.' }] }), {
|
||||
status: 400,
|
||||
})
|
||||
);
|
||||
const fn = createStartSandboxBuildFunction(context as any);
|
||||
|
||||
const result = fn.fn!({ logger: {} } as any, { inputs, outputs: {}, env: {} });
|
||||
|
||||
await expect(result).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(result).rejects.toMatchObject({
|
||||
message: 'Failed to mark sandbox sandbox-id as ready.',
|
||||
cause: expect.objectContaining({ status: 400 }),
|
||||
});
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
.mockImplementation(async options => {
|
||||
expect(options.signal).toBe(controller.signal);
|
||||
controller.abort();
|
||||
return { ready: Promise.resolve(), stopAsync };
|
||||
});
|
||||
const mutation = jest.fn();
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
graphqlClient: { mutation },
|
||||
} as any);
|
||||
try {
|
||||
await expect(
|
||||
fn.fn!({ logger: {} } as any, {
|
||||
inputs: { sandbox_id: { value: 'sandbox-id' } },
|
||||
outputs: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
start.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to the legacy token and keeps the Expo token for commands without a worker token', async () => {
|
||||
it('excludes the MCP token from the command environment', async () => {
|
||||
const env = {
|
||||
PREPARED_BY_EARLIER_STEP: 'value',
|
||||
EXPO_TOKEN: 'expo-token',
|
||||
__EAS_SANDBOX_MCP_TOKEN: 'token',
|
||||
};
|
||||
const startupError = new Error('stop after checking options');
|
||||
const start = jest
|
||||
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
|
||||
.mockRejectedValue(startupError);
|
||||
const fn = createStartSandboxBuildFunction({ ...context, job: {} } as any);
|
||||
|
||||
await expect(
|
||||
fn.fn!({ logger: {} } as any, {
|
||||
inputs,
|
||||
outputs: {},
|
||||
env: {
|
||||
PREPARED_BY_EARLIER_STEP: 'value',
|
||||
EXPO_TOKEN: 'expo-token',
|
||||
__EAS_SANDBOX_MCP_TOKEN: 'legacy-token',
|
||||
},
|
||||
})
|
||||
).rejects.toBe(startupError);
|
||||
expect(start.mock.calls[0][0]).toMatchObject({ credential: 'legacy-token' });
|
||||
expect(start.mock.calls[0][0].env).toEqual({
|
||||
PREPARED_BY_EARLIER_STEP: 'value',
|
||||
EXPO_TOKEN: 'expo-token',
|
||||
});
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
} as any);
|
||||
try {
|
||||
await expect(
|
||||
fn.fn!({ logger: {} } as any, {
|
||||
inputs: { sandbox_id: { value: 'sandbox-id' } },
|
||||
outputs: {},
|
||||
env,
|
||||
})
|
||||
).rejects.toBe(startupError);
|
||||
expect(start.mock.calls[0][0]).toMatchObject({ credential: 'token' });
|
||||
expect(start.mock.calls[0][0].env).toEqual({
|
||||
PREPARED_BY_EARLIER_STEP: 'value',
|
||||
EXPO_TOKEN: 'expo-token',
|
||||
});
|
||||
expect(env.__EAS_SANDBOX_MCP_TOKEN).toBe('token');
|
||||
} finally {
|
||||
start.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('marks the sandbox ready over GraphQL without a worker token', async () => {
|
||||
const stopAsync = jest.fn(async () => {});
|
||||
jest
|
||||
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
|
||||
.mockResolvedValue({ ready: Promise.resolve(), stopAsync });
|
||||
it('throws a system error when the sandbox token is missing', async () => {
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
job: {},
|
||||
env: {},
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
fn.fn!({} as any, {
|
||||
inputs: { sandbox_id: { value: 'sandbox-id' } },
|
||||
outputs: {},
|
||||
env: {},
|
||||
})
|
||||
).rejects.toBeInstanceOf(SystemError);
|
||||
});
|
||||
|
||||
it('throws a system error when the MCP server URL is missing', async () => {
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
job: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'sandbox-token' },
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
fn.fn!({} as any, {
|
||||
inputs: { sandbox_id: { value: 'sandbox-id' } },
|
||||
outputs: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'sandbox-token' },
|
||||
})
|
||||
).rejects.toThrow('MCP server URL is required to start the sandbox daemon.');
|
||||
});
|
||||
|
||||
it('marks the sandbox as ready', async () => {
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({ data: { sandbox: { markSandboxReady: { id: 'sandbox-id' } } } }),
|
||||
}));
|
||||
const logger = { info: jest.fn(), child: jest.fn() };
|
||||
logger.child.mockReturnValue(logger);
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
...context,
|
||||
job: {},
|
||||
graphqlClient: { mutation },
|
||||
} as any);
|
||||
|
||||
await fn.fn!({ logger } as any, {
|
||||
inputs: { ...inputs, max_duration_seconds: { value: 0.0001 } },
|
||||
outputs: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'legacy-token' },
|
||||
});
|
||||
await markSandboxReadyAsync({ graphqlClient: { mutation } } as any, 'sandbox-id');
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sandboxId: 'sandbox-id' },
|
||||
undefined
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
missing: 'a sandbox worker token or a legacy token',
|
||||
overrides: { job: {} },
|
||||
message: 'This sandbox job has neither EAS_SANDBOX_WORKER_TOKEN nor __EAS_SANDBOX_MCP_TOKEN.',
|
||||
},
|
||||
{
|
||||
missing: 'the MCP server URL',
|
||||
overrides: { mcpServerUrl: undefined },
|
||||
message: 'MCP server URL is required to start the sandbox daemon.',
|
||||
},
|
||||
{
|
||||
missing: 'the API server URL',
|
||||
overrides: { expoApiV2BaseUrl: undefined },
|
||||
message: 'The API server URL is not set',
|
||||
},
|
||||
])('throws a system error without $missing', async ({ overrides, message }) => {
|
||||
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync');
|
||||
const fn = createStartSandboxBuildFunction({ ...context, ...overrides } as any);
|
||||
it('throws a system error when marking the sandbox as ready fails', async () => {
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({ error: new Error('request failed') }),
|
||||
}));
|
||||
|
||||
const result = fn.fn!({} as any, { inputs, outputs: {}, env: {} });
|
||||
|
||||
await expect(result).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(result).rejects.toThrow(message);
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
markSandboxReadyAsync({ graphqlClient: { mutation } } as any, 'sandbox-id')
|
||||
).rejects.toThrow('Failed to mark sandbox sandbox-id as ready.');
|
||||
});
|
||||
});
|
||||
+7
-291
@@ -12,17 +12,9 @@ import {
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import {
|
||||
createWarmDeviceSessionServer,
|
||||
runOnWarmIosSimulatorAsync,
|
||||
} from '../../utils/warmDeviceSessionHost';
|
||||
import { type WarmIosSession } from '../../utils/warmIosSession';
|
||||
import { watchDeviceRunSessionStartup } from '../../utils/deviceRunSessionStartup';
|
||||
import { createStartupWatchMock } from '../../../__tests__/utils/startupWatch';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
|
||||
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/warmDeviceSessionHost');
|
||||
jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
IosSimulatorUtils: {
|
||||
resolveUdidAsync: jest.fn().mockResolvedValue('selected-ios-udid'),
|
||||
@@ -47,9 +39,6 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/deviceRunSessionStartup', () => ({
|
||||
watchDeviceRunSessionStartup: jest.fn(),
|
||||
}));
|
||||
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const env = { NGROK_AUTHTOKEN: 'ngrok-token', PATH: '/usr/bin' } as BuildStepEnv;
|
||||
@@ -59,8 +48,7 @@ const stopAsync = jest.fn();
|
||||
|
||||
async function runAsync(
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
launchInputs: Record<string, { value: unknown }> = {},
|
||||
signal?: AbortSignal
|
||||
launchInputs: Record<string, { value: unknown }> = {}
|
||||
): Promise<void> {
|
||||
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx);
|
||||
await buildFunction.fn!(
|
||||
@@ -75,7 +63,6 @@ async function runAsync(
|
||||
...launchInputs,
|
||||
},
|
||||
outputs: {},
|
||||
signal,
|
||||
env,
|
||||
} as never
|
||||
);
|
||||
@@ -94,279 +81,13 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock);
|
||||
stopAsync.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('does not start a host when the parent aborts during Xcode selection', async () => {
|
||||
const controller = new AbortController();
|
||||
const failure = new Error('parent step cancelled');
|
||||
jest
|
||||
.mocked(selectXcodeDeveloperDirectoryAsync)
|
||||
.mockImplementationOnce(async () => controller.abort(failure));
|
||||
await expect(runAsync(BuildRuntimePlatform.DARWIN, {}, controller.signal)).rejects.toBe(
|
||||
failure
|
||||
);
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
expect(watchDeviceRunSessionStartup).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.finishAsync
|
||||
).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps watching startup when warm adoption retries cold', async () => {
|
||||
const adoption = new Error('warm adoption failed');
|
||||
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async (_claim, run) => {
|
||||
try {
|
||||
return await run({
|
||||
device: { udid: 'selected-ios-udid', name: 'iPhone 17' },
|
||||
} as WarmIosSession);
|
||||
} catch (error) {
|
||||
if (error !== adoption) {
|
||||
throw error;
|
||||
}
|
||||
return await run(undefined);
|
||||
}
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
|
||||
options.onStartupError?.(adoption);
|
||||
throw adoption;
|
||||
});
|
||||
await expect(
|
||||
Promise.resolve(
|
||||
createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true }).fn!(
|
||||
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
|
||||
{
|
||||
inputs: {
|
||||
boot_simulator: { value: true },
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env,
|
||||
} as never
|
||||
)
|
||||
)
|
||||
).resolves.toBeUndefined();
|
||||
expect(watchDeviceRunSessionStartup).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'observes Stop during warm claim and preserves cleanup failure=%s',
|
||||
async failCleanup => {
|
||||
let entered!: () => void;
|
||||
const claiming = new Promise<void>(resolve => {
|
||||
entered = resolve;
|
||||
});
|
||||
const cleanup = new Error('warm cleanup failed');
|
||||
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async claim => {
|
||||
entered();
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
claim.signal!.addEventListener(
|
||||
'abort',
|
||||
() =>
|
||||
reject(
|
||||
failCleanup
|
||||
? new AggregateError([claim.signal!.reason, cleanup])
|
||||
: claim.signal!.reason
|
||||
),
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
const running = createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true }).fn!(
|
||||
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env,
|
||||
} as never
|
||||
) as Promise<void>;
|
||||
running.catch(() => {});
|
||||
await claiming;
|
||||
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
|
||||
if (failCleanup) {
|
||||
await expect(running).rejects.toMatchObject({ errors: expect.arrayContaining([cleanup]) });
|
||||
} else {
|
||||
await expect(running).resolves.toBeUndefined();
|
||||
}
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
it('adopts the claimed warm Simulator when the warm step runs', async () => {
|
||||
const warmServer = { port: 3200 } as never;
|
||||
const warm = { device: { udid: 'SELECTED-IOS-UDID', name: 'iPhone 17' } } as WarmIosSession;
|
||||
jest.mocked(createWarmDeviceSessionServer).mockReturnValue(warmServer);
|
||||
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation((_claim, run) => run(warm));
|
||||
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true });
|
||||
expect(buildFunction.id).toBe('start_warm_serve_sim_remote_session');
|
||||
await buildFunction.fn!(
|
||||
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
|
||||
{ inputs: { device_identifier: { value: 'iPhone 17' } }, outputs: {}, env } as never
|
||||
);
|
||||
expect(runOnWarmIosSimulatorAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ engine: 'serve-sim', requestedDevice: 'iPhone 17' }),
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ iosSimulatorUdid: 'SELECTED-IOS-UDID' }),
|
||||
warmServer
|
||||
);
|
||||
});
|
||||
|
||||
it('uses preview activity to reset the configured idle timeout', async () => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, { max_idle_time_minutes: { value: 5 } });
|
||||
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
|
||||
expect(idleTimeout?.getLastEventObservedAt()).toBeUndefined();
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
|
||||
const observedAt = new Date();
|
||||
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(observedAt);
|
||||
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(observedAt);
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
|
||||
'leaves the idle timer disabled when omitted on %s',
|
||||
async runtimePlatform => {
|
||||
await runAsync(runtimePlatform);
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ idleTimeout: undefined, maxDurationSeconds: 120 })
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('leaves the idle timer disabled for zero', async () => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, {
|
||||
max_idle_time_minutes: { value: 0 },
|
||||
});
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ idleTimeout: undefined, maxDurationSeconds: 120 })
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an Android idle timeout before starting the host', async () => {
|
||||
await expect(
|
||||
runAsync(BuildRuntimePlatform.LINUX, {
|
||||
max_idle_time_minutes: { value: 5 },
|
||||
})
|
||||
).rejects.toThrow('Omit max_idle_time_minutes for Android sessions.');
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
|
||||
'handles Stop while the %s host starts',
|
||||
async runtimePlatform => {
|
||||
let hostEntered!: () => void;
|
||||
const entered = new Promise<void>(resolve => {
|
||||
hostEntered = resolve;
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce((_ctx, { signal }) => {
|
||||
hostEntered();
|
||||
return new Promise((_resolve, reject) =>
|
||||
signal!.addEventListener('abort', () => reject(new Error('host aborted')), { once: true })
|
||||
);
|
||||
});
|
||||
const running = runAsync(runtimePlatform);
|
||||
running.catch(() => {});
|
||||
await entered;
|
||||
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
|
||||
watch.stop();
|
||||
await expect(running).resolves.toBeUndefined();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
expect(watch.finishAsync).toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
|
||||
'preserves an earlier %s host failure when Stop arrives during cleanup',
|
||||
async runtimePlatform => {
|
||||
const failure = new Error('Host became ready without a session token');
|
||||
let cleanupEntered!: () => void;
|
||||
const entered = new Promise<void>(resolve => {
|
||||
cleanupEntered = resolve;
|
||||
});
|
||||
let finishCleanup!: () => void;
|
||||
const cleanup = new Promise<void>(resolve => {
|
||||
finishCleanup = resolve;
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
|
||||
options.onStartupError?.(failure);
|
||||
cleanupEntered();
|
||||
await cleanup;
|
||||
throw failure;
|
||||
});
|
||||
const running = runAsync(runtimePlatform);
|
||||
running.catch(() => {});
|
||||
try {
|
||||
await entered;
|
||||
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
|
||||
} finally {
|
||||
finishCleanup();
|
||||
}
|
||||
await expect(running).rejects.toBe(failure);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps a teardown failure visible after Stop during preview setup', async () => {
|
||||
let previewEntered!: () => void;
|
||||
let finishPreview!: (value: {
|
||||
previewPageUrl: string;
|
||||
apiUrl: string;
|
||||
closeAsync: () => Promise<void>;
|
||||
}) => void;
|
||||
const entered = new Promise<void>(resolve => {
|
||||
previewEntered = resolve;
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
|
||||
openPreviewAsync: () => {
|
||||
previewEntered();
|
||||
return new Promise(resolve => {
|
||||
finishPreview = resolve;
|
||||
});
|
||||
},
|
||||
finishAsync: stopAsync,
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
});
|
||||
const failed = new Error('host teardown failed');
|
||||
stopAsync.mockRejectedValueOnce(failed);
|
||||
const running = runAsync(BuildRuntimePlatform.LINUX);
|
||||
running.catch(() => {});
|
||||
await entered;
|
||||
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
|
||||
watch.stop();
|
||||
finishPreview({
|
||||
previewPageUrl: 'https://preview.test',
|
||||
apiUrl: 'https://server.test',
|
||||
closeAsync: async () => {},
|
||||
});
|
||||
await expect(running).rejects.toBe(failed);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retires the startup watcher before lifetime waiting', async () => {
|
||||
await runAsync(BuildRuntimePlatform.LINUX);
|
||||
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
|
||||
expect(watch.completeAsync.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('reports the session token when serve-sim minted one', async () => {
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
@@ -376,7 +97,6 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
previewToken: 'tok-1',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
|
||||
@@ -404,7 +124,6 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
|
||||
@@ -432,7 +151,6 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, { public_preview: { value: false } });
|
||||
@@ -450,7 +168,6 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
if (phase === 'preview') {
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
|
||||
openPreviewAsync: jest.fn().mockRejectedValue(error),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
} else if (phase === 'config') {
|
||||
@@ -483,9 +200,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
env: childEnv,
|
||||
logger,
|
||||
timeoutMs: 60_000,
|
||||
preview: { baseDomain: 'tunnel.example.com', authtoken: 'ngrok-token' },
|
||||
signal: expect.any(AbortSignal),
|
||||
onStartupError: expect.any(Function),
|
||||
signal: undefined,
|
||||
...(selectsXcode
|
||||
? {
|
||||
iosSimulatorUdid: 'SELECTED-IOS-UDID',
|
||||
@@ -504,7 +219,10 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
networkCaptureFields: [],
|
||||
});
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
|
||||
expect(host.openPreviewAsync).toHaveBeenCalledWith();
|
||||
expect(host.openPreviewAsync).toHaveBeenCalledWith({
|
||||
baseDomain: 'tunnel.example.com',
|
||||
authtoken: 'ngrok-token',
|
||||
});
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
@@ -514,7 +232,6 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
previewApiUrl: 'https://web-preview.example.test',
|
||||
},
|
||||
logger,
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
@@ -546,7 +263,6 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
'frame_ancestors',
|
||||
'cors_origins',
|
||||
'package_version',
|
||||
'max_idle_time_minutes',
|
||||
'max_duration_seconds',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -198,10 +198,7 @@ it('does not delete the edit or publish outputs when commit outcome is unknown',
|
||||
return [204];
|
||||
});
|
||||
const task = submission();
|
||||
const error = await task.run().catch(error => error);
|
||||
expect(error).toBeInstanceOf(SystemError);
|
||||
expect((error as Error).message).toContain('HTTP 404');
|
||||
expect((error as Error).message).toContain('Could not confirm whether Google Play saved');
|
||||
await expect(task.run()).rejects.toBeInstanceOf(SystemError);
|
||||
expect(deleted).toBe(false);
|
||||
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1,407 +0,0 @@
|
||||
import { BuildStep, spawnAsync } from '@expo/steps';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
jest.mock('@expo/steps', () => ({
|
||||
...jest.requireActual('@expo/steps'),
|
||||
spawnAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawnAsync = jest.mocked(spawnAsync);
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createTestAndroidJob } from '../../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../../context';
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { parseInfoPlistBuffer } from '../readIpaInfo';
|
||||
import {
|
||||
createUpdateResolvedVersionBuildFunction,
|
||||
extractAndroidVersionAsync,
|
||||
extractIosVersionAsync,
|
||||
parseAaptOutput,
|
||||
parseManifestXml,
|
||||
} from '../updateResolvedVersion';
|
||||
|
||||
describe(parseAaptOutput, () => {
|
||||
it('extracts versionName and versionCode from aapt2 output', () => {
|
||||
const output = `package: name='com.example.app' versionCode='42' versionName='2.5.0' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34' compileSdkVersionCodename='14'
|
||||
sdkVersion:'21'
|
||||
targetSdkVersion:'34'
|
||||
uses-permission: name='android.permission.INTERNET'`;
|
||||
|
||||
expect(parseAaptOutput(output)).toEqual({
|
||||
appVersion: '2.5.0',
|
||||
appBuildVersion: '42',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles output with no version info', () => {
|
||||
expect(parseAaptOutput('some unrelated output')).toEqual({
|
||||
appVersion: undefined,
|
||||
appBuildVersion: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles versionName with prerelease suffix', () => {
|
||||
const output = `package: name='com.example' versionCode='1' versionName='1.0.0-beta.1'`;
|
||||
|
||||
expect(parseAaptOutput(output)).toEqual({
|
||||
appVersion: '1.0.0-beta.1',
|
||||
appBuildVersion: '1',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps an unescaped apostrophe in versionName', () => {
|
||||
const output = `package: name='com.example' versionCode='1' versionName='1.0-O'Brien' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34'
|
||||
sdkVersion:'21'`;
|
||||
|
||||
expect(parseAaptOutput(output)).toEqual({
|
||||
appVersion: "1.0-O'Brien",
|
||||
appBuildVersion: '1',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a versionName that contains a quote and an equals sign', () => {
|
||||
const output = `package: name='com.example' versionCode='1' versionName='1.0' build=beta'
|
||||
sdkVersion:'21'`;
|
||||
|
||||
expect(parseAaptOutput(output)).toEqual({
|
||||
appVersion: "1.0' build=beta",
|
||||
appBuildVersion: '1',
|
||||
});
|
||||
});
|
||||
|
||||
it('stops versionName at the split field of a split APK', () => {
|
||||
const output = `package: name='com.example' versionCode='42' versionName='2.5.0' split='feature' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34'
|
||||
sdkVersion:'21'`;
|
||||
|
||||
expect(parseAaptOutput(output)).toEqual({
|
||||
appVersion: '2.5.0',
|
||||
appBuildVersion: '42',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an empty versionName as missing', () => {
|
||||
const output = `package: name='com.example' versionCode='42' versionName='' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34'
|
||||
sdkVersion:'21'`;
|
||||
|
||||
expect(parseAaptOutput(output)).toEqual({
|
||||
appVersion: undefined,
|
||||
appBuildVersion: '42',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseManifestXml, () => {
|
||||
it('extracts versionName and versionCode from bundletool manifest XML', () => {
|
||||
const xml = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:versionCode="10"
|
||||
android:versionName="3.1.0"
|
||||
package="com.example.app">
|
||||
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
|
||||
</manifest>`;
|
||||
|
||||
expect(parseManifestXml(xml)).toEqual({
|
||||
appVersion: '3.1.0',
|
||||
appBuildVersion: '10',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles XML with no version attributes', () => {
|
||||
const xml = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.example.app">
|
||||
</manifest>`;
|
||||
|
||||
expect(parseManifestXml(xml)).toEqual({
|
||||
appVersion: undefined,
|
||||
appBuildVersion: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('simulator .app Info.plist parsing', () => {
|
||||
const FIXTURES_DIR = path.join(__dirname, 'fixtures');
|
||||
const SIMULATOR_APP_DIR = path.join(FIXTURES_DIR, 'TestSimulator.app');
|
||||
const INFO_PLIST_PATH = path.join(SIMULATOR_APP_DIR, 'Info.plist');
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create a minimal XML Info.plist fixture
|
||||
await fs.promises.mkdir(SIMULATOR_APP_DIR, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
INFO_PLIST_PATH,
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.example.test</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>4.2.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>99</string>
|
||||
</dict>
|
||||
</plist>`
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.promises.rm(SIMULATOR_APP_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reads version from a .app Info.plist', async () => {
|
||||
const buffer = await fs.promises.readFile(INFO_PLIST_PATH);
|
||||
const infoPlist = parseInfoPlistBuffer(buffer);
|
||||
|
||||
expect(infoPlist.CFBundleShortVersionString).toBe('4.2.0');
|
||||
expect(infoPlist.CFBundleVersion).toBe('99');
|
||||
});
|
||||
});
|
||||
|
||||
describe(extractIosVersionAsync, () => {
|
||||
const FIXTURES_DIR = path.join(__dirname, 'fixtures');
|
||||
const SIMULATOR_APP_DIR = path.join(FIXTURES_DIR, 'ExtractVersion.app');
|
||||
const INFO_PLIST_PATH = path.join(SIMULATOR_APP_DIR, 'Info.plist');
|
||||
const IPA_FIXTURE_PATH = path.join(FIXTURES_DIR, 'SmallestAppExample.ipa');
|
||||
|
||||
beforeAll(async () => {
|
||||
await fs.promises.mkdir(SIMULATOR_APP_DIR, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
INFO_PLIST_PATH,
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.0.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>55</string>
|
||||
</dict>
|
||||
</plist>`
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.promises.rm(SIMULATOR_APP_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('extracts version from a .app directory', async () => {
|
||||
const result = await extractIosVersionAsync(SIMULATOR_APP_DIR);
|
||||
|
||||
expect(result).toEqual({
|
||||
appVersion: '2.0.1',
|
||||
appBuildVersion: '55',
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts version from an .ipa file', async () => {
|
||||
const result = await extractIosVersionAsync(IPA_FIXTURE_PATH);
|
||||
|
||||
expect(result).toEqual({
|
||||
appVersion: '1.0',
|
||||
appBuildVersion: '1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(extractAndroidVersionAsync, () => {
|
||||
afterEach(() => {
|
||||
mockedSpawnAsync.mockReset();
|
||||
});
|
||||
|
||||
it('extracts version from an .apk via aapt2', async () => {
|
||||
mockedSpawnAsync.mockResolvedValue({
|
||||
stdout: `package: name='com.example.app' versionCode='42' versionName='2.5.0'`,
|
||||
stderr: '',
|
||||
} as any);
|
||||
|
||||
const result = await extractAndroidVersionAsync('/path/to/app.apk');
|
||||
|
||||
expect(mockedSpawnAsync).toHaveBeenCalledWith(
|
||||
'aapt2',
|
||||
['dump', 'badging', '/path/to/app.apk'],
|
||||
{
|
||||
stdio: 'pipe',
|
||||
}
|
||||
);
|
||||
expect(result).toEqual({
|
||||
appVersion: '2.5.0',
|
||||
appBuildVersion: '42',
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts version from an .aab via bundletool', async () => {
|
||||
mockedSpawnAsync.mockResolvedValue({
|
||||
stdout: `<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:versionCode="10"
|
||||
android:versionName="3.1.0"
|
||||
package="com.example.app">
|
||||
</manifest>`,
|
||||
stderr: '',
|
||||
} as any);
|
||||
|
||||
const result = await extractAndroidVersionAsync('/path/to/app.aab');
|
||||
|
||||
expect(mockedSpawnAsync).toHaveBeenCalledWith(
|
||||
'bundletool',
|
||||
['dump', 'manifest', '--bundle', '/path/to/app.aab'],
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
expect(result).toEqual({
|
||||
appVersion: '3.1.0',
|
||||
appBuildVersion: '10',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty object for unknown extension', async () => {
|
||||
const result = await extractAndroidVersionAsync('/path/to/archive.zip');
|
||||
|
||||
expect(mockedSpawnAsync).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe(createUpdateResolvedVersionBuildFunction, () => {
|
||||
afterEach(() => {
|
||||
mockedSpawnAsync.mockReset();
|
||||
});
|
||||
|
||||
function createStep({
|
||||
env,
|
||||
archivePath,
|
||||
workingDirectory,
|
||||
mutationResult = { data: {}, error: undefined },
|
||||
}: {
|
||||
env: Record<string, string>;
|
||||
archivePath?: string;
|
||||
workingDirectory?: string;
|
||||
mutationResult?: { data?: unknown; error?: Error };
|
||||
}): { step: BuildStep; mutation: jest.Mock } {
|
||||
const mutation = jest.fn(() => ({ toPromise: async () => mutationResult }));
|
||||
const ctx = new BuildContext(createTestAndroidJob({}), {
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test', ...env },
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
uploadArtifact: jest.fn(),
|
||||
workingdir: '',
|
||||
});
|
||||
ctx.graphqlClient.mutation = mutation as any;
|
||||
const step = createUpdateResolvedVersionBuildFunction(
|
||||
new CustomBuildContext(ctx)
|
||||
).createBuildStepFromFunctionCall(createGlobalContextMock({}), {
|
||||
callInputs: archivePath ? { application_archive_path: archivePath } : {},
|
||||
workingDirectory,
|
||||
});
|
||||
return { step, mutation };
|
||||
}
|
||||
|
||||
it('resolves a relative archive path against the step working directory', async () => {
|
||||
mockedSpawnAsync.mockResolvedValue({
|
||||
stdout: `package: name='com.example.app' versionCode='42' versionName='2.5.0'`,
|
||||
stderr: '',
|
||||
} as any);
|
||||
const { step, mutation } = createStep({
|
||||
env: { EAS_BUILD_ID: 'build-id' },
|
||||
archivePath: 'android/app/build/outputs/apk/release/app-release.apk',
|
||||
workingDirectory: './my-app',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
const expectedPath = path.join(
|
||||
step.ctx.workingDirectory,
|
||||
'android/app/build/outputs/apk/release/app-release.apk'
|
||||
);
|
||||
expect(path.isAbsolute(expectedPath)).toBe(true);
|
||||
expect(mockedSpawnAsync).toHaveBeenCalledWith('aapt2', ['dump', 'badging', expectedPath], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('updates the resolved versions for the build', async () => {
|
||||
mockedSpawnAsync.mockResolvedValue({
|
||||
stdout: `package: name='com.example.app' versionCode='42' versionName='2.5.0'`,
|
||||
stderr: '',
|
||||
} as any);
|
||||
const { step, mutation } = createStep({
|
||||
env: { EAS_BUILD_ID: 'build-id' },
|
||||
archivePath: '/path/to/app.apk',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(mutation.mock.calls[0][1]).toEqual({
|
||||
buildId: 'build-id',
|
||||
appVersion: '2.5.0',
|
||||
appBuildVersion: '42',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits a missing value instead of sending null', async () => {
|
||||
mockedSpawnAsync.mockResolvedValue({
|
||||
stdout: `package: name='com.example.app' versionCode='42'`,
|
||||
stderr: '',
|
||||
} as any);
|
||||
const { step, mutation } = createStep({
|
||||
env: { EAS_BUILD_ID: 'build-id' },
|
||||
archivePath: '/path/to/app.apk',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
const variables = mutation.mock.calls[0][1];
|
||||
expect(variables.appBuildVersion).toBe('42');
|
||||
expect(variables.appVersion).toBeUndefined();
|
||||
expect(variables.appVersion).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does nothing without a build ID', async () => {
|
||||
const { step, mutation } = createStep({ env: {}, archivePath: '/path/to/app.apk' });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawnAsync).not.toHaveBeenCalled();
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing without an archive path', async () => {
|
||||
const { step, mutation } = createStep({ env: { EAS_BUILD_ID: 'build-id' } });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawnAsync).not.toHaveBeenCalled();
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fail the step when extraction or the mutation fails', async () => {
|
||||
mockedSpawnAsync.mockRejectedValueOnce(new Error('aapt2: command not found'));
|
||||
const failingExtraction = createStep({
|
||||
env: { EAS_BUILD_ID: 'build-id' },
|
||||
archivePath: '/path/to/app.apk',
|
||||
});
|
||||
await expect(failingExtraction.step.executeAsync()).resolves.not.toThrow();
|
||||
expect(failingExtraction.mutation).not.toHaveBeenCalled();
|
||||
|
||||
mockedSpawnAsync.mockResolvedValue({
|
||||
stdout: `package: name='com.example.app' versionCode='42' versionName='2.5.0'`,
|
||||
stderr: '',
|
||||
} as any);
|
||||
const failingMutation = createStep({
|
||||
env: { EAS_BUILD_ID: 'build-id' },
|
||||
archivePath: '/path/to/app.apk',
|
||||
mutationResult: { error: new Error('GraphQL error') },
|
||||
});
|
||||
await expect(failingMutation.step.executeAsync()).resolves.not.toThrow();
|
||||
expect(failingMutation.mutation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -121,32 +121,26 @@ describe(createUploadArtifactBuildFunction, () => {
|
||||
expect(typeInput.isRawValueOneOfAllowedValues()).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['other', 'android-apk', 'android-aab', 'ios-simulator-app', 'ios-ipa'])(
|
||||
'uploads %s as a workflow artifact with metadata',
|
||||
async type => {
|
||||
contextUploadArtifact.mockClear();
|
||||
const metadata = { kind: 'screen-recording', startedAtMs: '123', device: 'emulator-5554' };
|
||||
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({}),
|
||||
{
|
||||
callInputs: {
|
||||
type,
|
||||
key: 'Screen Recording',
|
||||
path: '/',
|
||||
metadata,
|
||||
},
|
||||
}
|
||||
);
|
||||
it('passes the metadata input through to runtimeApi.uploadArtifact', async () => {
|
||||
contextUploadArtifact.mockClear();
|
||||
const metadata = { kind: 'screen-recording', startedAtMs: '123', device: 'emulator-5554' };
|
||||
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
|
||||
callInputs: {
|
||||
// Generic ('other') artifact: only generic artifacts persist metadata in the worker
|
||||
// (the managed-type upload paths drop it), so that's the type metadata is meant for.
|
||||
type: 'other',
|
||||
key: 'Screen Recording',
|
||||
path: '/',
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(contextUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({ type: null, metadata }),
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
expect(contextUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ artifact: expect.objectContaining({ metadata }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not attach metadata to managed artifact types (the worker drops it)', async () => {
|
||||
contextUploadArtifact.mockClear();
|
||||
|
||||
+2
-11
@@ -140,12 +140,9 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
const uploads = jest
|
||||
const names = jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mock.calls.map(([, options]) => options);
|
||||
const names = uploads
|
||||
.filter(options => options.kind === 'screen-recording')
|
||||
.map(options => options.name);
|
||||
.mock.calls.map(([, options]) => options.name);
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining([
|
||||
'iPhone 16 screen recording (01234567-…, started at Jul 10, 2026, 10:00:00.000 UTC)',
|
||||
@@ -154,12 +151,6 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
])
|
||||
);
|
||||
expect(new Set(names).size).toBe(3);
|
||||
const metadata = uploads.filter(options => options.kind === 'screen-recording-metadata');
|
||||
expect(metadata.map(options => options.name)).toEqual(
|
||||
expect.arrayContaining(names.map(name => `${name} metadata`))
|
||||
);
|
||||
expect(metadata).toHaveLength(3);
|
||||
expect(uploads).toHaveLength(6);
|
||||
} finally {
|
||||
await Promise.all(
|
||||
recordingDirectories.map(directory => fs.rm(directory, { recursive: true, force: true }))
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { AscApiClient } from '../../utils/ios/AscApiClient';
|
||||
import { AscApiUtils } from '../../utils/ios/AscApiUtils';
|
||||
import {
|
||||
createUploadToAscBuildFunction,
|
||||
isClosedVersionTrainError,
|
||||
isInvalidBundleIdentifierError,
|
||||
isMissingAppIconsError,
|
||||
isMissingBackgroundTaskIdentifiersError,
|
||||
isMissingPurposeStringError,
|
||||
isRedundantBinaryUploadError,
|
||||
isSdkVersionIssueError,
|
||||
parseMissingUsageDescriptionKeys,
|
||||
} from '../uploadToAsc';
|
||||
@@ -55,20 +45,6 @@ describe(isInvalidBundleIdentifierError, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(isRedundantBinaryUploadError, () => {
|
||||
it('returns true when all errors are redundant-binary-upload codes', () => {
|
||||
expect(isRedundantBinaryUploadError([{ code: '90189' }, { code: '90189' }])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when any other error code is present', () => {
|
||||
expect(isRedundantBinaryUploadError([{ code: '90189' }, { code: '90062' }])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when there are no errors', () => {
|
||||
expect(isRedundantBinaryUploadError([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(isMissingPurposeStringError, () => {
|
||||
it('returns true when all errors are missing-purpose-string codes', () => {
|
||||
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90683' }])).toBe(true);
|
||||
@@ -97,41 +73,6 @@ describe(isSdkVersionIssueError, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(isMissingBackgroundTaskIdentifiersError, () => {
|
||||
it('returns true when all errors are missing BG task identifiers codes', () => {
|
||||
expect(isMissingBackgroundTaskIdentifiersError([{ code: '90771' }, { code: '90771' }])).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false when any other error code is present', () => {
|
||||
expect(isMissingBackgroundTaskIdentifiersError([{ code: '90771' }, { code: '90062' }])).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false when there are no errors', () => {
|
||||
expect(isMissingBackgroundTaskIdentifiersError([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(isMissingAppIconsError, () => {
|
||||
it.each([['90391', '90713', '90713'], ['90713', '90022', '90023'], ['90022'], ['90023']])(
|
||||
'recognizes missing-icon validation codes %j',
|
||||
(...codes) => {
|
||||
expect(isMissingAppIconsError(codes.map(code => ({ code })))).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it('returns false when any other error code is present', () => {
|
||||
expect(isMissingAppIconsError([{ code: '90391' }, { code: '90771' }])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when there are no errors', () => {
|
||||
expect(isMissingAppIconsError([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseMissingUsageDescriptionKeys, () => {
|
||||
it('extracts missing UsageDescription keys from ASC messages', () => {
|
||||
expect(
|
||||
@@ -169,176 +110,3 @@ describe(parseMissingUsageDescriptionKeys, () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(createUploadToAscBuildFunction, () => {
|
||||
it('keeps Apple duplicate-build details in the public error and the full state as its cause', async () => {
|
||||
const state = {
|
||||
state: 'FAILED',
|
||||
errors: [
|
||||
{
|
||||
code: '90189',
|
||||
description: 'Redundant Binary Upload. Build number 1 was already uploaded.',
|
||||
},
|
||||
],
|
||||
warnings: [{ code: 'WARNING', description: 'Apple warning.' }],
|
||||
infos: [{ code: 'INFO', description: 'Apple information.' }],
|
||||
};
|
||||
await fs.writeFile('/app.ipa', 'ipa');
|
||||
await fs.writeFile('/key.json', '{}');
|
||||
jest.spyOn(AscApiUtils, 'loadApiKeyAsync').mockResolvedValue({} as never);
|
||||
jest.spyOn(AscApiUtils, 'getAppInfoAsync').mockResolvedValue({
|
||||
data: { attributes: { name: 'App', bundleId: 'com.example.app' } },
|
||||
} as never);
|
||||
jest.spyOn(AscApiUtils, 'createBuildUploadAsync').mockResolvedValue({
|
||||
data: { id: 'upload-id' },
|
||||
} as never);
|
||||
jest.spyOn(AscApiUtils, 'commitBuildUploadFileAsync').mockResolvedValue(undefined as never);
|
||||
jest.spyOn(AscApiClient.prototype, 'postAsync').mockResolvedValue({
|
||||
data: { id: 'file-id', attributes: { uploadOperations: [] } },
|
||||
} as never);
|
||||
jest
|
||||
.spyOn(AscApiClient.prototype, 'getAsync')
|
||||
.mockResolvedValueOnce({
|
||||
data: { attributes: { assetDeliveryState: { state: 'COMPLETE' } } },
|
||||
} as never)
|
||||
.mockResolvedValueOnce({ data: { attributes: { state } } } as never);
|
||||
try {
|
||||
const step = createUploadToAscBuildFunction().createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ staticContextContent: { job: {} } }),
|
||||
{
|
||||
callInputs: {
|
||||
ipa_path: '/app.ipa',
|
||||
asc_api_key_path: '/key.json',
|
||||
apple_app_identifier: 'app-id',
|
||||
bundle_version: '1',
|
||||
bundle_short_version: '1.0',
|
||||
wait_for_processing: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
const error = await step.executeAsync().catch(error => error);
|
||||
expect(error).toBeInstanceOf(UserError);
|
||||
expect(error.errorCode).toBe('EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE');
|
||||
expect(error.cause).toEqual(state);
|
||||
expect(error.toExternalExpoError().message).toContain(
|
||||
'Redundant Binary Upload. Build number 1 was already uploaded. (90189)'
|
||||
);
|
||||
} finally {
|
||||
jest.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(createUploadToAscBuildFunction, () => {
|
||||
it('keeps Apple background-task details in the public error and the full state as its cause', async () => {
|
||||
const state = {
|
||||
state: 'FAILED',
|
||||
errors: [
|
||||
{
|
||||
code: '90771',
|
||||
description:
|
||||
'Missing BGTaskSchedulerPermittedIdentifiers while UIBackgroundModes includes processing.',
|
||||
},
|
||||
],
|
||||
warnings: [{ code: 'WARNING', description: 'Apple warning.' }],
|
||||
infos: [{ code: 'INFO', description: 'Apple information.' }],
|
||||
};
|
||||
await fs.writeFile('/app.ipa', 'ipa');
|
||||
await fs.writeFile('/key.json', '{}');
|
||||
jest.spyOn(AscApiUtils, 'loadApiKeyAsync').mockResolvedValue({} as never);
|
||||
jest.spyOn(AscApiUtils, 'getAppInfoAsync').mockResolvedValue({
|
||||
data: { attributes: { name: 'App', bundleId: 'com.example.app' } },
|
||||
} as never);
|
||||
jest.spyOn(AscApiUtils, 'createBuildUploadAsync').mockResolvedValue({
|
||||
data: { id: 'upload-id' },
|
||||
} as never);
|
||||
jest.spyOn(AscApiUtils, 'commitBuildUploadFileAsync').mockResolvedValue(undefined as never);
|
||||
jest.spyOn(AscApiClient.prototype, 'postAsync').mockResolvedValue({
|
||||
data: { id: 'file-id', attributes: { uploadOperations: [] } },
|
||||
} as never);
|
||||
jest
|
||||
.spyOn(AscApiClient.prototype, 'getAsync')
|
||||
.mockResolvedValueOnce({
|
||||
data: { attributes: { assetDeliveryState: { state: 'COMPLETE' } } },
|
||||
} as never)
|
||||
.mockResolvedValueOnce({ data: { attributes: { state } } } as never);
|
||||
try {
|
||||
const step = createUploadToAscBuildFunction().createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ staticContextContent: { job: {} } }),
|
||||
{
|
||||
callInputs: {
|
||||
ipa_path: '/app.ipa',
|
||||
asc_api_key_path: '/key.json',
|
||||
apple_app_identifier: 'app-id',
|
||||
bundle_version: '1',
|
||||
bundle_short_version: '1.0',
|
||||
wait_for_processing: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
const error = await step.executeAsync().catch(error => error);
|
||||
expect(error).toBeInstanceOf(UserError);
|
||||
expect(error.errorCode).toBe('EAS_UPLOAD_TO_ASC_MISSING_BG_TASK_IDENTIFIERS');
|
||||
expect(error.cause).toEqual(state);
|
||||
expect(error.toExternalExpoError().message).toContain(
|
||||
'Missing BGTaskSchedulerPermittedIdentifiers while UIBackgroundModes includes processing. (90771)'
|
||||
);
|
||||
} finally {
|
||||
jest.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(createUploadToAscBuildFunction, () => {
|
||||
it('keeps Apple validation details in the public error and the full state as its cause', async () => {
|
||||
const state = {
|
||||
state: 'FAILED',
|
||||
errors: [{ code: '90713', description: 'Missing CFBundleIconName in Watch.app.' }],
|
||||
warnings: [{ code: 'WARNING', description: 'Apple warning.' }],
|
||||
infos: [{ code: 'INFO', description: 'Apple information.' }],
|
||||
};
|
||||
await fs.writeFile('/app.ipa', 'ipa');
|
||||
await fs.writeFile('/key.json', '{}');
|
||||
jest.spyOn(AscApiUtils, 'loadApiKeyAsync').mockResolvedValue({} as never);
|
||||
jest.spyOn(AscApiUtils, 'getAppInfoAsync').mockResolvedValue({
|
||||
data: { attributes: { name: 'App', bundleId: 'com.example.app' } },
|
||||
} as never);
|
||||
jest.spyOn(AscApiUtils, 'createBuildUploadAsync').mockResolvedValue({
|
||||
data: { id: 'upload-id' },
|
||||
} as never);
|
||||
jest.spyOn(AscApiUtils, 'commitBuildUploadFileAsync').mockResolvedValue(undefined as never);
|
||||
jest.spyOn(AscApiClient.prototype, 'postAsync').mockResolvedValue({
|
||||
data: { id: 'file-id', attributes: { uploadOperations: [] } },
|
||||
} as never);
|
||||
jest
|
||||
.spyOn(AscApiClient.prototype, 'getAsync')
|
||||
.mockResolvedValueOnce({
|
||||
data: { attributes: { assetDeliveryState: { state: 'COMPLETE' } } },
|
||||
} as never)
|
||||
.mockResolvedValueOnce({ data: { attributes: { state } } } as never);
|
||||
try {
|
||||
const step = createUploadToAscBuildFunction().createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ staticContextContent: { job: {} } }),
|
||||
{
|
||||
callInputs: {
|
||||
ipa_path: '/app.ipa',
|
||||
asc_api_key_path: '/key.json',
|
||||
apple_app_identifier: 'app-id',
|
||||
bundle_version: '1',
|
||||
bundle_short_version: '1.0',
|
||||
wait_for_processing: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
const error = await step.executeAsync().catch(error => error);
|
||||
expect(error).toBeInstanceOf(UserError);
|
||||
expect(error.errorCode).toBe('EAS_UPLOAD_TO_ASC_MISSING_APP_ICONS');
|
||||
expect(error.cause).toEqual(state);
|
||||
expect(error.toExternalExpoError().message).toContain(
|
||||
'Missing CFBundleIconName in Watch.app. (90713)'
|
||||
);
|
||||
} finally {
|
||||
jest.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { NpmrcAuthTokenLine } from '../../../templates/npmrc';
|
||||
import { createSetUpNpmrcBuildFunction } from '../useNpmToken';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
|
||||
const projectTargetDirectory = '/workingdir/project';
|
||||
const homeDirectory = '/home/expo';
|
||||
|
||||
describe(createSetUpNpmrcBuildFunction, () => {
|
||||
beforeEach(async () => {
|
||||
vol.reset();
|
||||
await vol.promises.mkdir(projectTargetDirectory, { recursive: true });
|
||||
await vol.promises.mkdir(homeDirectory, { recursive: true });
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: `${homeDirectory}/.npmrc\n` } as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('adds the NPM_TOKEN placeholder from the step env to ~/.npmrc on EAS Build', async () => {
|
||||
const globalCtx = createGlobalContextMock({ projectTargetDirectory });
|
||||
globalCtx.updateEnv({ NPM_TOKEN: 'npm_secret_token', EAS_BUILD_RUNNER: 'eas-build' });
|
||||
const step = createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: {},
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(await vol.promises.readFile(`${homeDirectory}/.npmrc`, 'utf8')).toContain(
|
||||
NpmrcAuthTokenLine
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -58,11 +58,7 @@ export function createCheckoutBuildFunction(): BuildFunction {
|
||||
});
|
||||
}
|
||||
|
||||
const previousWorkingDirectory = stepsCtx.global.defaultWorkingDirectory;
|
||||
stepsCtx.global.markAsCheckedOut();
|
||||
stepsCtx.logger.info(
|
||||
`Changing default working directory to ${stepsCtx.global.defaultWorkingDirectory} (was ${previousWorkingDirectory})`
|
||||
);
|
||||
stepsCtx.global.markAsCheckedOut(stepsCtx.logger);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import { Client, CombinedError } from '@urql/core';
|
||||
import { Client } from '@urql/core';
|
||||
import contentDisposition from 'content-disposition';
|
||||
import { glob } from 'fast-glob';
|
||||
import { graphql } from 'gql.tada';
|
||||
@@ -26,18 +26,12 @@ import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import {
|
||||
describeError,
|
||||
isTransferFailure,
|
||||
isTransientGraphqlError,
|
||||
} from '../../utils/networkErrors';
|
||||
import { retryOnNetworkFailureAsync } from '../../utils/retryOnNetworkFailure';
|
||||
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
|
||||
import { pluralize } from '../../utils/strings';
|
||||
import {
|
||||
type DeviceRunSessionWorkerApi,
|
||||
getDeviceRunSessionWorkerApi,
|
||||
} from '../utils/deviceRunSessionWorkerApi';
|
||||
import { WorkerApiError } from '../utils/workerApi';
|
||||
|
||||
const streamPipeline = promisify(stream.pipeline);
|
||||
|
||||
@@ -158,34 +152,21 @@ async function fetchApplicationArchiveUrlAsync({
|
||||
signal,
|
||||
});
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = await retryOnNetworkFailureAsync(
|
||||
async () => {
|
||||
const queryResult = await graphqlClient
|
||||
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
|
||||
.toPromise();
|
||||
if (queryResult.error) {
|
||||
throw queryResult.error;
|
||||
}
|
||||
return queryResult.data;
|
||||
},
|
||||
{
|
||||
operation: 'download_build.build_lookup',
|
||||
signal,
|
||||
isRetryable: error => error instanceof CombinedError && isTransientGraphqlError(error),
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
const message = `Could not fetch build ${buildId}: ${describeError(error)}`;
|
||||
throw error instanceof CombinedError && !isTransientGraphqlError(error)
|
||||
? new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error })
|
||||
: new SystemError(message, { cause: error });
|
||||
}
|
||||
const result = await graphqlClient
|
||||
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
|
||||
.toPromise();
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const applicationArchiveUrl = data?.builds.byId?.artifacts?.applicationArchiveUrl;
|
||||
if (result.error) {
|
||||
const { error } = result;
|
||||
const message = `Could not fetch build ${buildId}: ${error.message}`;
|
||||
|
||||
throw error.networkError || result.error.response?.status >= 500
|
||||
? new SystemError(message, { cause: error })
|
||||
: new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error });
|
||||
}
|
||||
|
||||
const applicationArchiveUrl = result.data?.builds.byId?.artifacts?.applicationArchiveUrl;
|
||||
if (!applicationArchiveUrl) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
|
||||
@@ -214,18 +195,13 @@ async function fetchDeviceRunSessionApplicationArchiveUrlAsync({
|
||||
}): Promise<string> {
|
||||
let application;
|
||||
try {
|
||||
application = await retryOnNetworkFailureAsync(
|
||||
() => deviceRunSessionWorkerApi.getApplicationAsync({ signal }),
|
||||
{
|
||||
operation: 'download_build.session_build_lookup',
|
||||
signal,
|
||||
isRetryable: error => error instanceof WorkerApiError && error.isTransient,
|
||||
}
|
||||
);
|
||||
application = await deviceRunSessionWorkerApi.getApplicationAsync({ signal });
|
||||
} catch (err) {
|
||||
signal?.throwIfAborted();
|
||||
throw new SystemError(
|
||||
`Could not fetch build ${buildId} for this device run session: ${describeError(err)}`,
|
||||
`Could not fetch build ${buildId} for this device run session: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
@@ -305,48 +281,18 @@ export async function downloadBuildAsync(
|
||||
);
|
||||
|
||||
signal?.throwIfAborted();
|
||||
let archivePath: string;
|
||||
try {
|
||||
archivePath = await retryOnNetworkFailureAsync(
|
||||
async () => {
|
||||
const response = await fetch(downloadUrl, { headers, signal });
|
||||
if (!response.ok) {
|
||||
const textResult = await asyncResult(response.text());
|
||||
const archiveUrl = new URL(downloadUrl);
|
||||
const source = params.buildId
|
||||
? `application archive for build ${params.buildId}`
|
||||
: `archive at ${archiveUrl.origin}${archiveUrl.pathname}`;
|
||||
if (response.status === 404 || response.status === 410) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_ARCHIVE_UNAVAILABLE',
|
||||
`Failed to download the ${source} (HTTP ${response.status}). ` +
|
||||
'The archive is unavailable. ' +
|
||||
(params.buildId
|
||||
? 'Check that the archive still exists and try again.'
|
||||
: 'Check that the URL is correct and the archive still exists, then try again.')
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to download the ${source} (HTTP ${response.status}): ${textResult.value}`
|
||||
);
|
||||
}
|
||||
const archiveFilename = resolveArchiveFilename({ response, extensions });
|
||||
const attemptArchivePath = path.join(downloadDestinationDirectory, archiveFilename);
|
||||
// On abort, node-fetch emits an error on the body stream, which fails the pipeline.
|
||||
await streamPipeline(response.body, fs.createWriteStream(attemptArchivePath));
|
||||
return attemptArchivePath;
|
||||
},
|
||||
{ operation: 'download_build.archive_download', signal, isRetryable: isTransferFailure }
|
||||
);
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
if (params.buildId && isTransferFailure(error)) {
|
||||
throw new SystemError(`Could not download the application archive: ${describeError(error)}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
const response = await retryOnDNSFailure(fetch)(downloadUrl, { headers, signal });
|
||||
|
||||
if (!response.ok) {
|
||||
const textResult = await asyncResult(response.text());
|
||||
throw new Error(`Unexpected response from server (${response.status}): ${textResult.value}`);
|
||||
}
|
||||
|
||||
const archiveFilename = resolveArchiveFilename({ response, extensions });
|
||||
const archivePath = path.join(downloadDestinationDirectory, archiveFilename);
|
||||
|
||||
// On abort, node-fetch emits an error on the body stream, which fails the pipeline.
|
||||
await streamPipeline(response.body, fs.createWriteStream(archivePath));
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const { size } = await fs.promises.stat(archivePath);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BuildJob, Ios, ManagedArtifactType, Platform } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildStepContext, BuildStepOutput } from '@expo/steps';
|
||||
import { BuildFunction, BuildStepContext } from '@expo/steps';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { findXcodeBuildLogsPathAsync } from '../../ios/xcodeBuildLogs';
|
||||
@@ -13,13 +13,7 @@ export function createFindAndUploadBuildArtifactsBuildFunction(
|
||||
id: 'find_and_upload_build_artifacts',
|
||||
name: 'Find and upload build artifacts',
|
||||
__metricsId: 'eas/find_and_upload_build_artifacts',
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'application_archive_path',
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { outputs }) => {
|
||||
fn: async stepCtx => {
|
||||
// We want each upload to print logs on its own
|
||||
// and we don't want to interleave logs from different uploads
|
||||
// so we execute uploads consecutively.
|
||||
@@ -30,10 +24,7 @@ export function createFindAndUploadBuildArtifactsBuildFunction(
|
||||
let firstError: any = null;
|
||||
|
||||
try {
|
||||
const [firstArchivePath] = await uploadApplicationArchivesAsync({ ctx, stepCtx });
|
||||
if (firstArchivePath) {
|
||||
outputs.application_archive_path.set(firstArchivePath);
|
||||
}
|
||||
await uploadApplicationArchivesAsync({ ctx, stepCtx });
|
||||
} catch (err: unknown) {
|
||||
stepCtx.logger.error({ err }, `Failed to upload application archives.`);
|
||||
firstError ||= err;
|
||||
@@ -77,7 +68,7 @@ async function uploadApplicationArchivesAsync({
|
||||
}: {
|
||||
ctx: CustomBuildContext<BuildJob>;
|
||||
stepCtx: BuildStepContext;
|
||||
}): Promise<string[]> {
|
||||
}): Promise<void> {
|
||||
const applicationArchivePatternOrPath =
|
||||
ctx.job.platform === Platform.ANDROID
|
||||
? (ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}')
|
||||
@@ -108,8 +99,6 @@ async function uploadApplicationArchivesAsync({
|
||||
logger,
|
||||
});
|
||||
logger.info('Done.');
|
||||
|
||||
return applicationArchives;
|
||||
}
|
||||
|
||||
async function uploadBuildArtifacts({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserError, quoteAndroidShellCommand } from '@expo/eas-build-job';
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
@@ -106,40 +106,24 @@ export async function launchApplicationAsync({
|
||||
logApplicationLaunch(logger, applicationIdentifier, launchArgs);
|
||||
// Android does not support process arguments like iOS. Pass raw `am start` Intent
|
||||
// arguments instead, such as `--es key value` or `--ez key true`.
|
||||
try {
|
||||
await spawnAndroidShellCommandAsync(
|
||||
['am', 'start', ...launchArgs, '-n', `${applicationIdentifier}/${activityName}`],
|
||||
{ env, logger }
|
||||
);
|
||||
} catch (err) {
|
||||
// An empty argument that is not an option value reaches `am start` as an empty package, and
|
||||
// `am start` then fails to resolve the Intent without saying why.
|
||||
const emptyArgumentIndex = launchArgs.indexOf('');
|
||||
if (emptyArgumentIndex === -1 || err instanceof UserError) {
|
||||
throw err;
|
||||
await spawn(
|
||||
'adb',
|
||||
['shell', 'am', 'start', ...launchArgs, '-n', `${applicationIdentifier}/${activityName}`],
|
||||
{
|
||||
env,
|
||||
logger,
|
||||
}
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_FAILED',
|
||||
`Launching ${applicationIdentifier} failed. Input "launch_args" has an empty argument at position ${emptyArgumentIndex + 1}, which Android passes to "am start" as an empty argument. Remove it unless it is an option value, such as "--es", "key", "".`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
);
|
||||
if (openUrl) {
|
||||
logger.info(`Opening ${openUrl} in ${applicationIdentifier}.`);
|
||||
await spawnAndroidShellCommandAsync(
|
||||
['am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
|
||||
await spawn(
|
||||
'adb',
|
||||
['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
|
||||
{ env, logger }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function spawnAndroidShellCommandAsync(
|
||||
command: string[],
|
||||
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
|
||||
): Promise<void> {
|
||||
await spawn('adb', ['shell', ...quoteAndroidShellCommand(command)], { env, logger });
|
||||
}
|
||||
|
||||
async function preapproveIosUrlSchemeAsync({
|
||||
applicationIdentifier,
|
||||
openUrl,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
@@ -631,7 +631,7 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
try {
|
||||
await ctx.runtimeApi.uploadArtifact({
|
||||
artifact: {
|
||||
type: null,
|
||||
type: GenericArtifactType.OTHER,
|
||||
name: selectedReport.name,
|
||||
paths: [selectedReport.artifactPath],
|
||||
},
|
||||
@@ -738,7 +738,7 @@ async function uploadFailureScreenshotsAsync({
|
||||
await fs.copyFile(shot.fileAbsPath, safePath);
|
||||
await ctx.runtimeApi.uploadArtifact({
|
||||
artifact: {
|
||||
type: null,
|
||||
type: GenericArtifactType.OTHER,
|
||||
name: shot.displayName,
|
||||
paths: [safePath],
|
||||
metadata: shot.metadata,
|
||||
|
||||
@@ -117,7 +117,7 @@ export async function readIpaInfoAsync(ipaPath: string): Promise<IpaInfo> {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseInfoPlistBuffer(data: Buffer): Record<string, unknown> {
|
||||
function parseInfoPlistBuffer(data: Buffer): Record<string, unknown> {
|
||||
const isBinaryPlist = data.subarray(0, 8).toString('ascii') === 'bplist00';
|
||||
if (isBinaryPlist) {
|
||||
const parsedBinaryPlists = bplistParser.parseBuffer(data);
|
||||
|
||||
@@ -23,7 +23,7 @@ import resolveFrom from 'resolve-from';
|
||||
|
||||
import { COMMON_FASTLANE_ENV } from '../../common/fastlane';
|
||||
import IosCredentialsManager from '../utils/ios/credentials/manager';
|
||||
import { installIsolatedNpmPackageAsync } from '../../utils/installIsolatedNpmPackageAsync';
|
||||
import { resolvePackageVersionAsync } from '../../utils/packageManager';
|
||||
|
||||
export function createRepackBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -121,12 +121,18 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
: undefined;
|
||||
const jsBundleOnly = (inputs.js_bundle_only.value as boolean | undefined) ?? false;
|
||||
|
||||
const resolvedRepackVersion =
|
||||
(await resolvePackageVersionAsync({
|
||||
logger: stepsCtx.logger,
|
||||
packageName: inputs.repack_package.value as string,
|
||||
distTag: inputs.repack_version.value as string,
|
||||
})) ?? (inputs.repack_version.value as string);
|
||||
stepsCtx.logger.info(
|
||||
`Using repack from: ${inputs.repack_package.value}@${inputs.repack_version.value} (${resolvedRepackVersion})`
|
||||
);
|
||||
const repackApp = await installAndImportRepackAsync({
|
||||
packageName: inputs.repack_package.value as string,
|
||||
versionSpec: inputs.repack_version.value as string,
|
||||
pathEnv: env.PATH,
|
||||
npmCacheUrl: env.EAS_BUILD_NPM_CACHE_URL,
|
||||
logger: stepsCtx.logger,
|
||||
version: resolvedRepackVersion,
|
||||
});
|
||||
const { repackAppIosAsync, repackAppAndroidAsync } = repackApp;
|
||||
|
||||
@@ -220,28 +226,16 @@ function createOutputPath({
|
||||
*/
|
||||
async function installAndImportRepackAsync({
|
||||
packageName,
|
||||
versionSpec,
|
||||
pathEnv,
|
||||
npmCacheUrl,
|
||||
logger,
|
||||
version,
|
||||
}: {
|
||||
packageName: string;
|
||||
versionSpec: string;
|
||||
pathEnv: string | undefined;
|
||||
npmCacheUrl: string | undefined;
|
||||
logger: bunyan;
|
||||
version: string;
|
||||
}): Promise<typeof import('@expo/repack-app')> {
|
||||
const sandbox = await fs.promises.mkdtemp(path.join(os.tmpdir(), `repack-package-root-`));
|
||||
await installIsolatedNpmPackageAsync({
|
||||
packageSpec: `${packageName}@${versionSpec}`,
|
||||
directory: sandbox,
|
||||
npmCacheUrl,
|
||||
pathEnv,
|
||||
logger,
|
||||
await spawnAsync('yarn', ['add', `${packageName}@${version}`], {
|
||||
stdio: 'inherit',
|
||||
cwd: sandbox,
|
||||
});
|
||||
const packageJsonPath = path.join(sandbox, 'node_modules', packageName, 'package.json');
|
||||
const { version } = JSON.parse(await fs.promises.readFile(packageJsonPath, 'utf8'));
|
||||
logger.info(`Using repack from: ${packageName}@${versionSpec} (${version})`);
|
||||
return require(resolveFrom(sandbox, packageName));
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import path from 'path';
|
||||
import * as tar from 'tar';
|
||||
import z from 'zod';
|
||||
|
||||
import { uploadWithSignedUrl } from '../../storage/uploadWithSignedUrl';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
|
||||
import { getCacheVersion } from '../utils/cache';
|
||||
@@ -162,13 +161,16 @@ export async function uploadCacheAsync({
|
||||
|
||||
logger.info(`Uploading cache...`);
|
||||
|
||||
await uploadWithSignedUrl(
|
||||
{
|
||||
signedUrl: { url, headers },
|
||||
srcGeneratorAsync: async () => fs.createReadStream(archivePath),
|
||||
},
|
||||
{ retries: 3, retryIntervalMs: 100 }
|
||||
);
|
||||
const uploadResponse = await retryOnDNSFailure(fetch)(new URL(url), {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: fs.createReadStream(archivePath),
|
||||
});
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(
|
||||
`Unexpected response from cache server (${uploadResponse.status}): ${uploadResponse.statusText}`
|
||||
);
|
||||
}
|
||||
logger.info(`Uploaded cache archive to ${archivePath} (${formatBytes(size)}).`);
|
||||
}
|
||||
|
||||
@@ -224,13 +226,16 @@ export async function uploadPublicCacheAsync({
|
||||
|
||||
logger.info(`Uploading public cache...`);
|
||||
|
||||
await uploadWithSignedUrl(
|
||||
{
|
||||
signedUrl: { url, headers },
|
||||
srcGeneratorAsync: async () => fs.createReadStream(archivePath),
|
||||
},
|
||||
{ retries: 3, retryIntervalMs: 100 }
|
||||
);
|
||||
const uploadResponse = await retryOnDNSFailure(fetch)(new URL(url), {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: fs.createReadStream(archivePath),
|
||||
});
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(
|
||||
`Unexpected response from cache server (${uploadResponse.status}): ${uploadResponse.statusText}`
|
||||
);
|
||||
}
|
||||
logger.info(`Uploaded cache archive to ${archivePath} (${formatBytes(size)}).`);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@ import path from 'node:path';
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { uploadRemoteSessionConfigWithLocalEgressAsync } from '../utils/localEgressSession';
|
||||
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import { createWarmDeviceSessionServer } from '../utils/warmDeviceSessionHost';
|
||||
import { type WarmIosSession } from '../utils/warmIosSession';
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
PackageManager,
|
||||
@@ -25,7 +23,6 @@ import { type ServeSimApplicationOptions } from '../utils/remoteDeviceRunSession
|
||||
import { type StartupTasks } from '../utils/startupTasks';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
DeviceRunSessionStoppedError,
|
||||
finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
@@ -101,8 +98,6 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
frameAncestors,
|
||||
corsOrigins,
|
||||
tasks,
|
||||
warm,
|
||||
onStarted,
|
||||
device,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
@@ -119,9 +114,6 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
frameAncestors?: string[];
|
||||
corsOrigins?: string[];
|
||||
tasks: StartupTasks;
|
||||
/** A claimed warm Simulator: adopt it and use its agent-device daemon. */
|
||||
warm?: WarmIosSession;
|
||||
onStarted?: () => Promise<void>;
|
||||
device:
|
||||
| {
|
||||
booted: Promise<unknown>;
|
||||
@@ -145,9 +137,6 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
let agentDeviceTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let eventCollection: Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>> | undefined;
|
||||
let artifactUploads: Promise<void> | undefined;
|
||||
// Ends periodic polling; the poller then runs one bounded final inventory and upload drain.
|
||||
const stopArtifactPolling = new AbortController();
|
||||
let sessionFailed = false;
|
||||
const daemonSecrets = [ngrokAuthtoken];
|
||||
const hostBootedDevice = 'iosSimulatorUdid' in device ? device : undefined;
|
||||
@@ -155,70 +144,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
|
||||
// Each task stores what it started, so the teardown below can stop it even when
|
||||
// another task failed first.
|
||||
const sessionHostStartup = tasks.run('session host', async taskLogger => {
|
||||
if (externallyBootedDevice) {
|
||||
await tasks.untilAborted(externallyBootedDevice.booted);
|
||||
}
|
||||
tasks.signal.throwIfAborted();
|
||||
sessionHost = await startDeviceSessionHostAsync(
|
||||
ctx,
|
||||
{
|
||||
separateLogPhase: true,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken },
|
||||
disableShare,
|
||||
privatePreviewUrl,
|
||||
frameAncestors,
|
||||
corsOrigins,
|
||||
signal: tasks.signal,
|
||||
onStartupError: tasks.abort,
|
||||
networkCapture: capture.networkCapture,
|
||||
networkCaptureFields: capture.networkCaptureFields,
|
||||
iosSimulatorUdid: hostBootedDevice?.iosSimulatorUdid,
|
||||
bootEnv: hostBootedDevice?.bootEnv,
|
||||
application: hostBootedDevice?.application,
|
||||
},
|
||||
...(warm ? [createWarmDeviceSessionServer(ctx, taskLogger, warm)] : [])
|
||||
);
|
||||
tasks.signal.throwIfAborted();
|
||||
const webPreview = await sessionHost.openPreviewAsync();
|
||||
tasks.signal.throwIfAborted();
|
||||
taskLogger.info(
|
||||
webPreview.sharePageUrl
|
||||
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
|
||||
: 'Private preview URL unavailable for this session.'
|
||||
);
|
||||
return webPreview;
|
||||
});
|
||||
const agentDeviceStartup = tasks.run('agent-device daemon', async taskLogger => {
|
||||
let daemonInfo: { port: number; token: string };
|
||||
if (warm) {
|
||||
// Adoption prepares the launcher's daemon for the claimed device.
|
||||
await tasks.untilAborted(sessionHostStartup);
|
||||
if (!warm.tools) {
|
||||
throw new Error('The warm agent-device daemon was not prepared.');
|
||||
}
|
||||
daemonInfo = warm.tools;
|
||||
} else {
|
||||
daemonInfo = await startDaemonAsync(taskLogger);
|
||||
}
|
||||
tasks.signal.throwIfAborted();
|
||||
agentDeviceTunnel = await startNgrokTunnelAsync({
|
||||
port: daemonInfo.port,
|
||||
subdomainPrefix: 'agent-device',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
healthCheck: { path: '/health' },
|
||||
logger: taskLogger,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
taskLogger.info(`Tunnel is ready at ${agentDeviceTunnel.url}.`);
|
||||
return { ...daemonInfo, remoteSessionUrl: agentDeviceTunnel.url };
|
||||
});
|
||||
async function startDaemonAsync(taskLogger: bunyan) {
|
||||
taskLogger.info('Launching agent-device daemon.');
|
||||
daemonProcess = await startAgentDeviceDaemonAsync({
|
||||
packageVersion,
|
||||
@@ -234,8 +160,55 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
const daemonInfo = await tasks.untilAborted(waitForDaemonInfoAsync({ daemonProcess }));
|
||||
daemonSecrets.push(daemonInfo.token);
|
||||
taskLogger.info(`Daemon is listening on port ${daemonInfo.port}; loaded auth token.`);
|
||||
return daemonInfo;
|
||||
}
|
||||
|
||||
tasks.signal.throwIfAborted();
|
||||
agentDeviceTunnel = await startNgrokTunnelAsync({
|
||||
port: daemonInfo.port,
|
||||
subdomainPrefix: 'agent-device',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
logger: taskLogger,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
taskLogger.info(`Tunnel is ready at ${agentDeviceTunnel.url}.`);
|
||||
return { ...daemonInfo, remoteSessionUrl: agentDeviceTunnel.url };
|
||||
});
|
||||
const sessionHostStartup = tasks.run('session host', async taskLogger => {
|
||||
if (externallyBootedDevice) {
|
||||
await tasks.untilAborted(externallyBootedDevice.booted);
|
||||
}
|
||||
tasks.signal.throwIfAborted();
|
||||
sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
separateLogPhase: true,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
disableShare,
|
||||
privatePreviewUrl,
|
||||
frameAncestors,
|
||||
corsOrigins,
|
||||
signal: tasks.signal,
|
||||
onStartupError: tasks.abort,
|
||||
networkCapture: capture.networkCapture,
|
||||
networkCaptureFields: capture.networkCaptureFields,
|
||||
iosSimulatorUdid: hostBootedDevice?.iosSimulatorUdid,
|
||||
bootEnv: hostBootedDevice?.bootEnv,
|
||||
application: hostBootedDevice?.application,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
const webPreview = await sessionHost.openPreviewAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
taskLogger.info(
|
||||
webPreview.sharePageUrl
|
||||
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
|
||||
: 'Private preview URL unavailable for this session.'
|
||||
);
|
||||
return webPreview;
|
||||
});
|
||||
const deviceReady = externallyBootedDevice?.ready;
|
||||
|
||||
try {
|
||||
@@ -252,9 +225,9 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
tasks.signal.throwIfAborted();
|
||||
logger.info(tasks.summary());
|
||||
|
||||
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal: tasks.signal,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
@@ -266,26 +239,18 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
},
|
||||
logger,
|
||||
});
|
||||
if (sessionStart === 'stopped') {
|
||||
return;
|
||||
}
|
||||
await onStarted?.();
|
||||
tasks.signal.throwIfAborted();
|
||||
artifactUploads = pollAgentDeviceArtifactsForUploadAsync(ctx, {
|
||||
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
daemonUrl: `http://127.0.0.1:${daemonPort}`,
|
||||
daemonToken,
|
||||
logger,
|
||||
signal,
|
||||
stopPollingSignal: stopArtifactPolling.signal,
|
||||
});
|
||||
// Attach a handler at once; the teardown still awaits and reports a rejection.
|
||||
void artifactUploads.catch(() => {});
|
||||
|
||||
eventCollection = await startAgentDeviceEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
stateDir: warm?.agentDeviceStateDir ?? AGENT_DEVICE_STATE_DIR,
|
||||
stateDir: AGENT_DEVICE_STATE_DIR,
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -300,69 +265,41 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
tasks.signal.reason instanceof DeviceRunSessionStoppedError &&
|
||||
!(error instanceof AggregateError)
|
||||
) {
|
||||
throw tasks.signal.reason;
|
||||
}
|
||||
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.
|
||||
const startupResults = await Promise.allSettled([
|
||||
agentDeviceStartup,
|
||||
sessionHostStartup,
|
||||
deviceReady,
|
||||
]);
|
||||
await Promise.allSettled([agentDeviceStartup, sessionHostStartup, deviceReady]);
|
||||
const startedDaemon = daemonProcess;
|
||||
const toolTeardown = (async () => {
|
||||
try {
|
||||
if (eventCollection) {
|
||||
await stopAgentDeviceEventCollectionSafelyAsync({
|
||||
eventCollection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
// The final inventory reads from the daemon, so it finishes before the daemon stops.
|
||||
stopArtifactPolling.abort();
|
||||
await artifactUploads;
|
||||
} finally {
|
||||
await startedDaemon?.stopAsync();
|
||||
}
|
||||
}
|
||||
})();
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
teardown: [
|
||||
...startupResults.flatMap((result): [string, Promise<unknown>][] =>
|
||||
result.status === 'rejected' && result.reason instanceof AggregateError
|
||||
? [['startup cleanup', Promise.reject(result.reason)]]
|
||||
: []
|
||||
),
|
||||
['agent-device tunnel', agentDeviceTunnel?.stopAsync()],
|
||||
['agent-device daemon', toolTeardown],
|
||||
[
|
||||
'session host',
|
||||
(async () => {
|
||||
if (warm) {
|
||||
// The warm host owns the daemon too; keep it alive through the final downloads.
|
||||
await Promise.allSettled([toolTeardown]);
|
||||
}
|
||||
await sessionHost?.finishAsync();
|
||||
})(),
|
||||
'agent-device daemon',
|
||||
startedDaemon &&
|
||||
(async () => {
|
||||
try {
|
||||
if (eventCollection) {
|
||||
await stopAgentDeviceEventCollectionSafelyAsync({
|
||||
eventCollection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await startedDaemon.stopAsync();
|
||||
}
|
||||
})(),
|
||||
],
|
||||
['session host', sessionHost?.finishAsync()],
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserError, validateAndroidLaunchOptions } from '@expo/eas-build-job';
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
@@ -15,31 +15,27 @@ import {
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
|
||||
import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi';
|
||||
import { readIosApplicationIdentifierAsync } from '../utils/iosAppArtifact';
|
||||
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
|
||||
import { runOnWarmIosSimulatorAsync } from '../utils/warmDeviceSessionHost';
|
||||
import { type WarmIosSession } from '../utils/warmIosSession';
|
||||
import { withLocalEgressSession } from '../utils/localEgressSession';
|
||||
import {
|
||||
type ServeSimApplicationOptions,
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
createPublicPreviewInputProvider,
|
||||
createServeSimLaunchInputProviders,
|
||||
omitNgrokAuthtoken,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
validateServeSimLaunchOptions,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
parseNetworkCaptureInputs,
|
||||
} from '../utils/networkCaptureFields';
|
||||
import { createStartupTasks } from '../utils/startupTasks';
|
||||
import {
|
||||
type ServeSimApplicationOptions,
|
||||
validateServeSimLaunchOptions,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
import { downloadBuildAsync } from './downloadBuild';
|
||||
import { installBuildAsync } from './installBuild';
|
||||
@@ -53,23 +49,22 @@ import {
|
||||
runAgentDeviceRemoteSessionAsync,
|
||||
} from './startAgentDeviceRemoteSession';
|
||||
import { startAndroidEmulatorAsync } from './startAndroidEmulator';
|
||||
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
|
||||
import { readIosApplicationIdentifierAsync } from '../utils/iosAppArtifact';
|
||||
|
||||
const ANDROID_DEVICE_NAME = 'EasAndroidDevice01' as AndroidVirtualDeviceName;
|
||||
|
||||
/**
|
||||
* Starts a device session using an optional local app artifact, or one it downloads.
|
||||
* Starts a device session, downloading the app alongside daemon startup.
|
||||
* On iOS, serve-sim boots the selected Simulator, then installs and launches
|
||||
* the app. Build-tools verifies any egress guard before install and launch.
|
||||
* the downloaded app. Build-tools verifies any egress guard before install and launch.
|
||||
*/
|
||||
export function createStartAgentDeviceSessionBuildFunction(
|
||||
ctx: CustomBuildContext,
|
||||
{ warm = false }: { warm?: boolean } = {}
|
||||
): BuildFunction {
|
||||
export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: warm ? 'start_warm_agent_device_session' : 'start_agent_device_session',
|
||||
id: 'start_agent_device_session',
|
||||
name: 'Start agent-device session',
|
||||
__metricsId: warm ? 'eas/start_warm_agent_device_session' : 'eas/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({
|
||||
@@ -98,13 +93,7 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
// Application. Pass at most one of install_app_path, build_id and application_archive_url.
|
||||
BuildStepInput.createProvider({
|
||||
id: 'install_app_path',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
// Deprecated: remove once workflows pass install_app_path.
|
||||
// Application. Pass at most one of build_id and application_archive_url.
|
||||
BuildStepInput.createProvider({
|
||||
id: 'build_id',
|
||||
required: false,
|
||||
@@ -115,7 +104,16 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
...createServeSimLaunchInputProviders(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'launch_args',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'open_url',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
// Session.
|
||||
...createNetworkCaptureInputProviders(),
|
||||
createPublicPreviewInputProvider(),
|
||||
@@ -146,47 +144,26 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
// Validate everything before any expensive work starts.
|
||||
const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(stepEnv);
|
||||
const env = omitNgrokAuthtoken(stepEnv);
|
||||
const installAppPath = inputs.install_app_path.value as string | undefined;
|
||||
const buildId = inputs.build_id.value as string | undefined;
|
||||
const applicationArchiveUrl = inputs.application_archive_url.value as string | undefined;
|
||||
const launchAppIdentifier = inputs.launch_app_identifier.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);
|
||||
const download = buildId || applicationArchiveUrl;
|
||||
if ([installAppPath, buildId, applicationArchiveUrl].filter(Boolean).length > 1) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
|
||||
'Pass only one of install_app_path, build_id or application_archive_url.'
|
||||
);
|
||||
}
|
||||
if (download && launchAppIdentifier !== undefined) {
|
||||
if (!hasApplication && (launchArgs.length > 0 || openUrl)) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
'launch_app_identifier cannot be combined with build_id or application_archive_url.'
|
||||
);
|
||||
}
|
||||
if (
|
||||
!installAppPath &&
|
||||
!download &&
|
||||
!launchAppIdentifier &&
|
||||
(launchArgs.length > 0 || openUrl)
|
||||
) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
'launch_args and open_url need an application: pass install_app_path or launch_app_identifier.'
|
||||
);
|
||||
}
|
||||
if (!isIos && launchAppIdentifier !== undefined) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
'launch_app_identifier is only supported for iOS Simulator sessions. Pass install_app_path for Android.'
|
||||
'launch_args and open_url need an application: pass build_id or application_archive_url.'
|
||||
);
|
||||
}
|
||||
if (isIos) {
|
||||
validateServeSimLaunchOptions({ launchArgs, openUrl });
|
||||
} else {
|
||||
validateAndroidLaunchOptions({ launchArgs, openUrl });
|
||||
}
|
||||
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
|
||||
const capture = parseNetworkCaptureInputs(
|
||||
@@ -197,121 +174,87 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
{ runtimePlatform }
|
||||
);
|
||||
|
||||
const startup = new AbortController();
|
||||
const startupSignal = signal ? AbortSignal.any([signal, startup.signal]) : startup.signal;
|
||||
const startupWatch = watchDeviceRunSessionStartup({
|
||||
ctx,
|
||||
deviceRunSessionId: sessionEnv.deviceRunSessionId,
|
||||
logger,
|
||||
startup: { signal: startupSignal, abort: reason => startup.abort(reason) },
|
||||
});
|
||||
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
|
||||
const tasks = createStartupTasks(logger, startupSignal);
|
||||
let booted: Promise<void> | undefined;
|
||||
let sessionOwnsTeardown = false;
|
||||
let downloaded: Promise<string> | undefined;
|
||||
let applicationReady: Promise<ServeSimApplicationOptions | void> | undefined;
|
||||
const tasks = createStartupTasks(logger, signal);
|
||||
let downloaded: Promise<string> | undefined;
|
||||
let applicationReady: Promise<ServeSimApplicationOptions | void> | undefined;
|
||||
|
||||
try {
|
||||
if (isIos) {
|
||||
// Select Xcode before serve-sim boots the device or any Simulator tools run.
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
tasks.signal.throwIfAborted();
|
||||
const { launch, iosStartup } = isIos
|
||||
? await resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator: true,
|
||||
deviceIdentifier: warmSession?.device.udid ?? deviceIdentifier,
|
||||
installAppPath,
|
||||
launchAppIdentifier,
|
||||
// A downloaded app gets its launch options after the download.
|
||||
launchArgs: download ? [] : launchArgs,
|
||||
openUrl: download ? undefined : openUrl,
|
||||
try {
|
||||
if (isIos) {
|
||||
// Select Xcode before serve-sim boots the device or any Simulator tools run.
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
tasks.signal.throwIfAborted();
|
||||
const { iosStartup } = isIos
|
||||
? await resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator: true,
|
||||
deviceIdentifier,
|
||||
env,
|
||||
logger,
|
||||
signal: tasks.signal,
|
||||
})
|
||||
: {};
|
||||
const iosSimulatorUdid = iosStartup?.iosSimulatorUdid;
|
||||
if (iosSimulatorUdid) {
|
||||
logger.info(`Selected iOS Simulator: ${iosSimulatorUdid}.`);
|
||||
}
|
||||
|
||||
const booted = iosSimulatorUdid
|
||||
? undefined
|
||||
: tasks.run('Android Emulator boot', async taskLogger => {
|
||||
const logcatDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
|
||||
);
|
||||
tasks.signal.throwIfAborted();
|
||||
await startAndroidEmulatorAsync({
|
||||
deviceName: ANDROID_DEVICE_NAME,
|
||||
systemImagePackage: `${inputs.system_image_package.value}`,
|
||||
deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined,
|
||||
lcdWidth: inputs.lcd_width.value as number | undefined,
|
||||
lcdHeight: inputs.lcd_height.value as number | undefined,
|
||||
lcdDensity: inputs.lcd_density.value as number | undefined,
|
||||
logcatDirectory,
|
||||
env,
|
||||
logger,
|
||||
logger: taskLogger,
|
||||
});
|
||||
});
|
||||
|
||||
downloaded = hasApplication
|
||||
? tasks.run('build download', async taskLogger => {
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: taskLogger,
|
||||
...(buildId ? { buildId } : { applicationArchiveUrl: applicationArchiveUrl! }),
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
deviceRunSessionWorkerApi: buildId ? getDeviceRunSessionWorkerApi(ctx) : null,
|
||||
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
|
||||
extensions: isIos ? ['app'] : ['apk'],
|
||||
signal: tasks.signal,
|
||||
})
|
||||
: {};
|
||||
const iosSimulatorUdid = iosStartup?.iosSimulatorUdid;
|
||||
if (iosSimulatorUdid) {
|
||||
logger.info(`Selected iOS Simulator: ${iosSimulatorUdid}.`);
|
||||
}
|
||||
|
||||
booted = iosSimulatorUdid
|
||||
? Promise.resolve()
|
||||
: tasks.run('Android Emulator boot', async taskLogger => {
|
||||
const logcatDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
|
||||
);
|
||||
tasks.signal.throwIfAborted();
|
||||
await startAndroidEmulatorAsync({
|
||||
deviceName: ANDROID_DEVICE_NAME,
|
||||
systemImagePackage: `${inputs.system_image_package.value}`,
|
||||
deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined,
|
||||
lcdWidth: inputs.lcd_width.value as number | undefined,
|
||||
lcdHeight: inputs.lcd_height.value as number | undefined,
|
||||
lcdDensity: inputs.lcd_density.value as number | undefined,
|
||||
logcatDirectory,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
});
|
||||
return artifactPath;
|
||||
})
|
||||
: undefined;
|
||||
|
||||
downloaded = download
|
||||
? tasks.run('build download', async taskLogger => {
|
||||
const source = buildId
|
||||
? { buildId }
|
||||
: applicationArchiveUrl
|
||||
? { applicationArchiveUrl }
|
||||
: undefined;
|
||||
if (!source) {
|
||||
throw new Error('No app source was provided for download.');
|
||||
}
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: taskLogger,
|
||||
...source,
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
deviceRunSessionWorkerApi: buildId ? getDeviceRunSessionWorkerApi(ctx) : null,
|
||||
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
|
||||
extensions: isIos ? ['app'] : ['apk'],
|
||||
signal: tasks.signal,
|
||||
});
|
||||
return artifactPath;
|
||||
})
|
||||
: undefined;
|
||||
const downloadedApp = downloaded;
|
||||
|
||||
if (isIos && downloadedApp) {
|
||||
applicationReady = tasks.run('app preparation', async () => {
|
||||
const artifactPath = await downloadedApp;
|
||||
tasks.signal.throwIfAborted();
|
||||
const applicationIdentifier = await readIosApplicationIdentifierAsync({
|
||||
artifactPath,
|
||||
env,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
return {
|
||||
installAppPath: artifactPath,
|
||||
launchAppIdentifier: applicationIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
};
|
||||
});
|
||||
} else if (isIos && launch?.launchAppIdentifier) {
|
||||
applicationReady = Promise.resolve({
|
||||
...launch,
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
installAppPath,
|
||||
});
|
||||
} else if (!isIos && (installAppPath || downloadedApp)) {
|
||||
applicationReady = tasks.run('app install and launch', async taskLogger => {
|
||||
await tasks.untilAborted(booted!);
|
||||
const artifactPath = installAppPath ?? (await downloadedApp);
|
||||
if (!artifactPath) {
|
||||
throw new Error('No app artifact was prepared.');
|
||||
const download = downloaded;
|
||||
applicationReady = download
|
||||
? tasks.run(isIos ? 'app preparation' : 'app install and launch', async taskLogger => {
|
||||
if (booted) {
|
||||
await tasks.untilAborted(booted);
|
||||
}
|
||||
const artifactPath = await download;
|
||||
tasks.signal.throwIfAborted();
|
||||
if (isIos) {
|
||||
const applicationIdentifier = await readIosApplicationIdentifierAsync({
|
||||
artifactPath,
|
||||
env,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
return {
|
||||
installAppPath: artifactPath,
|
||||
launchAppIdentifier: applicationIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
};
|
||||
}
|
||||
const { applicationIdentifier, activityName } = await installBuildAsync({
|
||||
artifactPath,
|
||||
runtimePlatform,
|
||||
@@ -328,76 +271,39 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
: undefined;
|
||||
|
||||
sessionOwnsTeardown = true;
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
runtimePlatform,
|
||||
sessionEnv,
|
||||
packageVersion: inputs.package_version.value as string | undefined,
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined,
|
||||
maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined,
|
||||
capture,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
tasks,
|
||||
warm: warmSession,
|
||||
onStarted: () => startupWatch.completeAsync(),
|
||||
device: iosSimulatorUdid
|
||||
? {
|
||||
iosSimulatorUdid,
|
||||
bootEnv: iosStartup?.bootEnv,
|
||||
application: applicationReady,
|
||||
}
|
||||
: {
|
||||
booted,
|
||||
ready: applicationReady ?? tasks.untilAborted(booted),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
!sessionOwnsTeardown &&
|
||||
tasks.signal.reason === startupWatch.stopped &&
|
||||
!(error instanceof AggregateError)
|
||||
) {
|
||||
throw startupWatch.stopped;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
tasks.abort(new Error('Agent-device session ended.'));
|
||||
await Promise.allSettled([booted, downloaded, applicationReady]);
|
||||
}
|
||||
};
|
||||
try {
|
||||
startupSignal.throwIfAborted();
|
||||
if (warm && isIos && !download) {
|
||||
await runOnWarmIosSimulatorAsync(
|
||||
{
|
||||
engine: 'agent-device',
|
||||
requestedDevice: deviceIdentifier,
|
||||
packageVersion: inputs.package_version.value as string | undefined,
|
||||
env,
|
||||
logger,
|
||||
signal: startupSignal,
|
||||
},
|
||||
startSessionAsync
|
||||
);
|
||||
} else {
|
||||
await startSessionAsync();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== startupWatch.stopped) {
|
||||
throw error;
|
||||
}
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
runtimePlatform,
|
||||
sessionEnv,
|
||||
packageVersion: inputs.package_version.value as string | undefined,
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined,
|
||||
maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined,
|
||||
capture,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
tasks,
|
||||
device: iosSimulatorUdid
|
||||
? {
|
||||
...iosStartup,
|
||||
iosSimulatorUdid,
|
||||
application: applicationReady,
|
||||
}
|
||||
: {
|
||||
booted: booted!,
|
||||
ready: applicationReady ?? tasks.untilAborted(booted!),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await startupWatch.finishAsync();
|
||||
tasks.abort(new Error('Agent-device session ended.'));
|
||||
await Promise.allSettled([downloaded, applicationReady]);
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type Result, asyncResult } from '@expo/results';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
@@ -8,11 +8,10 @@ import {
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import spawn, { type SpawnOptions, type SpawnResult } from '@expo/turtle-spawn';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -22,35 +21,24 @@ import {
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import {
|
||||
WarmSessionAdoptionError,
|
||||
createWarmDeviceSessionServer,
|
||||
runOnWarmIosSimulatorAsync,
|
||||
} from '../utils/warmDeviceSessionHost';
|
||||
import { type WarmIosSession } from '../utils/warmIosSession';
|
||||
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
parseNetworkCaptureInputs,
|
||||
} from '../utils/networkCaptureFields';
|
||||
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageAdd,
|
||||
} from '../../utils/packageManager';
|
||||
import { killProcessGroup } from '../../utils/processes';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
|
||||
import { preparePreinstalledWdaAsync } from '../utils/preinstalledWda';
|
||||
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
|
||||
import {
|
||||
DeviceRunSessionStoppedError,
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createIosSessionStartupInputProviders,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
createPublicPreviewInputProvider,
|
||||
createServeSimLaunchInputProviders,
|
||||
@@ -63,6 +51,7 @@ import {
|
||||
omitNgrokAuthtoken,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
@@ -73,7 +62,6 @@ const APPIUM_HOST = '127.0.0.1';
|
||||
const APPIUM_PORT = 4723;
|
||||
const APPIUM_STARTUP_TIMEOUT_MS = 120_000;
|
||||
const DEFAULT_APPIUM_VERSION = '^3';
|
||||
const FDA_HELPER = '/usr/local/libexec/turtle-tcc-fda';
|
||||
|
||||
const AppiumInstalledDriversSchema = z.record(
|
||||
z.string(),
|
||||
@@ -81,17 +69,15 @@ const AppiumInstalledDriversSchema = z.record(
|
||||
);
|
||||
|
||||
export function createStartAppiumRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext,
|
||||
{ warm = false }: { warm?: boolean } = {}
|
||||
ctx: CustomBuildContext
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: warm ? 'start_warm_appium_remote_session' : 'start_appium_remote_session',
|
||||
id: 'start_appium_remote_session',
|
||||
name: 'Start Appium remote session',
|
||||
__metricsId: warm ? 'eas/start_warm_appium_remote_session' : 'eas/start_appium_remote_session',
|
||||
__metricsId: 'eas/start_appium_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createIosSessionStartupInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
createPublicPreviewInputProvider(),
|
||||
createPrivatePreviewUrlInputProvider(),
|
||||
@@ -116,327 +102,173 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
const startupStop = new AbortController();
|
||||
const watchSignal = signal
|
||||
? AbortSignal.any([signal, startupStop.signal])
|
||||
: startupStop.signal;
|
||||
const startupWatch = watchDeviceRunSessionStartup({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
|
||||
});
|
||||
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
|
||||
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
|
||||
const startupAbortController = new AbortController();
|
||||
const startupSignal = AbortSignal.any([watchSignal, startupAbortController.signal]);
|
||||
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
|
||||
deviceIdentifier: warmSession?.device.udid ?? deviceIdentifier,
|
||||
installAppPath: inputs.install_app_path?.value as string | undefined,
|
||||
const launch = parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture?.value,
|
||||
networkCaptureFields: inputs.network_capture_fields?.value,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
const versionSpec = resolveAppium3VersionSpec(packageVersion);
|
||||
|
||||
logger.info(
|
||||
`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
const device = await resolveAppiumDeviceAsync({ runtimePlatform, env, logger });
|
||||
// Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the
|
||||
// simulator stream with it, and UiAutomator2 merges long recordings with it. The
|
||||
// macOS session image does not ship ffmpeg, so install it concurrently with Appium.
|
||||
// Both installs settle before the step continues or fails, so no install outlives it.
|
||||
const [appiumInstall] = await Promise.all([
|
||||
asyncResult(
|
||||
installAppiumAsync({ versionSpec, driverName: device.driverName, env, logger })
|
||||
),
|
||||
ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger }),
|
||||
]);
|
||||
const { appiumHome, appiumBinPath, appiumEnv } = appiumInstall.enforceValue();
|
||||
const wdaCapabilities = await preparePreinstalledWdaAsync({
|
||||
runtimeIdentifier: device.runtimeIdentifier,
|
||||
appiumHome,
|
||||
appiumBinPath,
|
||||
appiumEnv,
|
||||
logger,
|
||||
});
|
||||
|
||||
const appiumProcess = spawnDetached({
|
||||
command: appiumBinPath,
|
||||
args: [
|
||||
'--address',
|
||||
APPIUM_HOST,
|
||||
'--port',
|
||||
String(APPIUM_PORT),
|
||||
'--base-path',
|
||||
'/',
|
||||
'--log-level',
|
||||
'error',
|
||||
// Appium 3 gates session listing (GET /appium/sessions) behind the
|
||||
// session_discovery insecure feature. We rely on it to poll for
|
||||
// Appium Event Timings, so enable it for all drivers.
|
||||
'--allow-insecure',
|
||||
'*:session_discovery',
|
||||
'--default-capabilities',
|
||||
JSON.stringify({ 'appium:eventTimings': true, ...wdaCapabilities }),
|
||||
],
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
});
|
||||
let eventCollection: Awaited<ReturnType<typeof startAppiumEventCollectionAsync>> | undefined;
|
||||
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let sessionFailed = false;
|
||||
try {
|
||||
await waitForAppiumReadyAsync({ appiumProcess, logger });
|
||||
eventCollection = await startAppiumEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
appiumUrl: `http://${APPIUM_HOST}:${APPIUM_PORT}/`,
|
||||
logger,
|
||||
});
|
||||
appiumTunnel = await startNgrokTunnelAsync({
|
||||
port: APPIUM_PORT,
|
||||
subdomainPrefix: 'appium',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
logger,
|
||||
});
|
||||
|
||||
// expo-device-hub has no serial-selection flag. Device run session workflows must expose
|
||||
// a single booted Android emulator so the Hub and Appium resolve the same device.
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
separateLogPhase: true,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
signal: startupSignal,
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
});
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture?.value,
|
||||
networkCaptureFields: inputs.network_capture_fields?.value,
|
||||
const webPreview = await sessionHost.openPreviewAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
});
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
appiumUrl: appiumTunnel.url,
|
||||
capabilities: {
|
||||
platformName: device.platformName,
|
||||
'appium:automationName': device.automationName,
|
||||
'appium:udid': device.udid,
|
||||
},
|
||||
webPreviewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
const versionSpec = resolveAppium3VersionSpec(packageVersion);
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
const device: AppiumDevice = iosStartup
|
||||
? {
|
||||
platformName: 'iOS',
|
||||
automationName: 'XCUITest',
|
||||
driverName: 'xcuitest',
|
||||
udid: iosStartup.iosSimulatorUdid,
|
||||
}
|
||||
: await resolveAppiumDeviceAsync({ runtimePlatform, env });
|
||||
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let appiumProcess: ReturnType<typeof spawnDetached> | undefined;
|
||||
let appiumHome: string | undefined;
|
||||
let eventCollection:
|
||||
| Awaited<ReturnType<typeof startAppiumEventCollectionAsync>>
|
||||
| undefined;
|
||||
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let sessionFailed = false;
|
||||
let sessionError: unknown;
|
||||
const startHost = () =>
|
||||
startDeviceSessionHostAsync(
|
||||
ctx,
|
||||
{
|
||||
...iosStartup,
|
||||
previewDeviceId: device.udid,
|
||||
separateLogPhase: true,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken },
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
signal: startupSignal,
|
||||
onStartupError: error => startupAbortController.abort(error),
|
||||
...launch,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
},
|
||||
...(warmSession ? [createWarmDeviceSessionServer(ctx, logger, warmSession)] : [])
|
||||
).catch(error => {
|
||||
startupAbortController.abort(error);
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
if (iosStartup) {
|
||||
hostStartup = asyncResult(startHost());
|
||||
}
|
||||
// Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the
|
||||
// simulator stream with it, and UiAutomator2 merges long recordings with it. The
|
||||
// macOS session image does not ship ffmpeg, so install it concurrently with Appium.
|
||||
// Both owned installs settle before proceeding; a shared FFmpeg install keeps its owner.
|
||||
const [appiumInstall] = await Promise.all([
|
||||
asyncResult(
|
||||
installAppiumAsync({
|
||||
versionSpec,
|
||||
driverName: device.driverName,
|
||||
env,
|
||||
logger,
|
||||
signal: startupSignal,
|
||||
onError: error => startupAbortController.abort(error),
|
||||
}).catch(error => {
|
||||
startupAbortController.abort(error);
|
||||
throw error;
|
||||
})
|
||||
),
|
||||
asyncResult(
|
||||
ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
signal: startupSignal,
|
||||
})
|
||||
),
|
||||
]);
|
||||
const installation = appiumInstall.enforceValue();
|
||||
appiumHome = installation.appiumHome;
|
||||
startupSignal?.throwIfAborted();
|
||||
const { appiumBinPath, appiumEnv } = installation;
|
||||
const wdaCapabilities = await preparePreinstalledWdaAsync({
|
||||
runtimeIdentifier: iosStartup
|
||||
? (
|
||||
await IosSimulatorUtils.getDeviceAsync({
|
||||
udid: iosStartup.iosSimulatorUdid as IosSimulatorUuid,
|
||||
env,
|
||||
})
|
||||
)?.runtime
|
||||
: device.runtimeIdentifier,
|
||||
appiumHome,
|
||||
appiumBinPath,
|
||||
appiumEnv,
|
||||
logger,
|
||||
signal: startupSignal,
|
||||
});
|
||||
|
||||
startupSignal?.throwIfAborted();
|
||||
const useFdaHelper =
|
||||
runtimePlatform === BuildRuntimePlatform.DARWIN && fs.existsSync(FDA_HELPER);
|
||||
appiumProcess = spawnDetached({
|
||||
command: useFdaHelper ? FDA_HELPER : appiumBinPath,
|
||||
args: [
|
||||
...(useFdaHelper ? [appiumBinPath] : []),
|
||||
'--address',
|
||||
APPIUM_HOST,
|
||||
'--port',
|
||||
String(APPIUM_PORT),
|
||||
'--base-path',
|
||||
'/',
|
||||
'--log-level',
|
||||
'error',
|
||||
// Appium 3 gates session listing (GET /appium/sessions) behind the
|
||||
// session_discovery insecure feature. We rely on it to poll for
|
||||
// Appium Event Timings, so enable it for all drivers.
|
||||
'--allow-insecure',
|
||||
'*:session_discovery',
|
||||
'--default-capabilities',
|
||||
JSON.stringify({
|
||||
'appium:eventTimings': true,
|
||||
// Without it, XCUITest restarted the booted Simulator at session start, which
|
||||
// closed the app serve-sim had launched. EAS VMs never show the Simulator window.
|
||||
...(device.platformName === 'iOS' ? { 'appium:isHeadless': true } : {}),
|
||||
...wdaCapabilities,
|
||||
}),
|
||||
],
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
});
|
||||
await waitForAppiumReadyAsync({ appiumProcess, logger, signal: startupSignal });
|
||||
if (hostStartup) {
|
||||
sessionHost = (await hostStartup).enforceValue();
|
||||
}
|
||||
startupSignal?.throwIfAborted();
|
||||
eventCollection = await startAppiumEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
appiumUrl: `http://${APPIUM_HOST}:${APPIUM_PORT}/`,
|
||||
logger,
|
||||
});
|
||||
appiumTunnel = await startNgrokTunnelAsync({
|
||||
port: APPIUM_PORT,
|
||||
subdomainPrefix: 'appium',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
healthCheck: { path: '/status' },
|
||||
logger,
|
||||
});
|
||||
|
||||
// The preview host uses the same device selected for Appium.
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
|
||||
startupSignal.throwIfAborted();
|
||||
const webPreview = await sessionHost.openPreviewAsync();
|
||||
|
||||
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal: startupSignal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
appiumUrl: appiumTunnel.url,
|
||||
capabilities: {
|
||||
platformName: device.platformName,
|
||||
'appium:automationName': device.automationName,
|
||||
'appium:udid': device.udid,
|
||||
},
|
||||
webPreviewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
if (sessionStart === 'stopped') {
|
||||
return;
|
||||
}
|
||||
|
||||
await startupWatch.completeAsync();
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error === startupSignal.reason &&
|
||||
startupSignal.reason instanceof DeviceRunSessionStoppedError
|
||||
) {
|
||||
throw startupSignal.reason;
|
||||
}
|
||||
sessionFailed = true;
|
||||
sessionError =
|
||||
error instanceof AggregateError
|
||||
? error
|
||||
: startupSignal.aborted &&
|
||||
!(startupSignal.reason instanceof DeviceRunSessionStoppedError)
|
||||
? startupSignal.reason
|
||||
: error;
|
||||
throw sessionError;
|
||||
} finally {
|
||||
startupAbortController.abort(new Error('Appium session ended.'));
|
||||
const appiumStopErrors: unknown[] = [];
|
||||
const appiumTeardown = (async () => {
|
||||
try {
|
||||
await eventCollection?.stopAsync();
|
||||
} finally {
|
||||
try {
|
||||
await appiumProcess?.stopAsync();
|
||||
} catch (error) {
|
||||
appiumStopErrors.push(error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (appiumHome) {
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
teardown: [
|
||||
['Appium tunnel', appiumTunnel?.stopAsync()],
|
||||
['Appium server', appiumTeardown],
|
||||
[
|
||||
'session host',
|
||||
hostStartup?.then(async result => {
|
||||
if (warmSession) {
|
||||
await Promise.allSettled([appiumTeardown]);
|
||||
}
|
||||
if (result.reason instanceof AggregateError) {
|
||||
throw result.reason;
|
||||
}
|
||||
await result.value?.finishAsync();
|
||||
}),
|
||||
],
|
||||
],
|
||||
});
|
||||
if (sessionError instanceof WarmSessionAdoptionError && appiumStopErrors.length) {
|
||||
throw new AggregateError(
|
||||
[sessionError, ...appiumStopErrors],
|
||||
'Could not stop Appium after warm adoption failed; a cold session cannot start safely.'
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
try {
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
watchSignal.throwIfAborted();
|
||||
if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await runOnWarmIosSimulatorAsync(
|
||||
// package_version is the Appium version, which the job installs either way.
|
||||
{
|
||||
engine: 'appium',
|
||||
requestedDevice: deviceIdentifier,
|
||||
env,
|
||||
logger,
|
||||
signal: watchSignal,
|
||||
},
|
||||
startSessionAsync
|
||||
);
|
||||
} else {
|
||||
await startSessionAsync();
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error !== startupWatch.stopped) {
|
||||
throw error;
|
||||
}
|
||||
sessionFailed = true;
|
||||
throw error;
|
||||
} finally {
|
||||
await startupWatch.finishAsync();
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
teardown: [
|
||||
['Appium tunnel', appiumTunnel?.stopAsync()],
|
||||
[
|
||||
'Appium server',
|
||||
(async () => {
|
||||
try {
|
||||
await eventCollection?.stopAsync();
|
||||
} finally {
|
||||
try {
|
||||
await appiumProcess.stopAsync();
|
||||
} finally {
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
})(),
|
||||
],
|
||||
['session host', sessionHost?.finishAsync()],
|
||||
],
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -464,12 +296,15 @@ export type AppiumDevice = {
|
||||
export async function resolveAppiumDeviceAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<AppiumDevice> {
|
||||
switch (runtimePlatform) {
|
||||
case BuildRuntimePlatform.DARWIN: {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
const [bootedDevice] = await IosSimulatorUtils.getAvailableDevicesAsync({
|
||||
env,
|
||||
filter: 'booted',
|
||||
@@ -506,136 +341,65 @@ export async function installAppiumAsync({
|
||||
driverName,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
onError,
|
||||
}: {
|
||||
versionSpec: string;
|
||||
driverName: AppiumDevice['driverName'];
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
onError?: (error: unknown) => void;
|
||||
}): Promise<{ appiumHome: string; appiumBinPath: string; appiumEnv: BuildStepEnv }> {
|
||||
signal?.throwIfAborted();
|
||||
const appiumHome = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-appium-home-'));
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
await fs.promises.writeFile(
|
||||
path.join(appiumHome, 'package.json'),
|
||||
`${JSON.stringify({ name: 'eas-appium-home', private: true })}\n`
|
||||
);
|
||||
const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome };
|
||||
const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium');
|
||||
const add = resolvePackageAdd(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
`appium@${versionSpec}`
|
||||
);
|
||||
await fs.promises.writeFile(
|
||||
path.join(appiumHome, 'package.json'),
|
||||
`${JSON.stringify({ name: 'eas-appium-home', private: true })}\n`
|
||||
);
|
||||
const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome };
|
||||
const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium');
|
||||
const add = resolvePackageAdd(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
`appium@${versionSpec}`
|
||||
);
|
||||
|
||||
logger.info(`Installing appium@${versionSpec} with ${add.command}.`);
|
||||
await runAppiumSetupCommandAsync(
|
||||
add.command,
|
||||
add.args,
|
||||
{
|
||||
cwd: appiumHome,
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
},
|
||||
signal
|
||||
);
|
||||
const { stdout } = await runAppiumSetupCommandAsync(
|
||||
appiumBinPath,
|
||||
['driver', 'list', '--installed', '--json'],
|
||||
{
|
||||
env: appiumEnv,
|
||||
stdio: 'pipe',
|
||||
},
|
||||
signal
|
||||
);
|
||||
const installedDrivers = AppiumInstalledDriversSchema.parse(JSON.parse(stdout));
|
||||
if (installedDrivers[driverName]?.installed) {
|
||||
logger.info(`Updating the installed Appium ${driverName} driver.`);
|
||||
await runAppiumSetupCommandAsync(
|
||||
appiumBinPath,
|
||||
['driver', 'update', driverName],
|
||||
{ env: appiumEnv, logger },
|
||||
signal
|
||||
);
|
||||
} else {
|
||||
logger.info(`Installing the Appium ${driverName} driver.`);
|
||||
await runAppiumSetupCommandAsync(
|
||||
appiumBinPath,
|
||||
['driver', 'install', driverName],
|
||||
{ env: appiumEnv, logger },
|
||||
signal
|
||||
);
|
||||
}
|
||||
return { appiumHome, appiumBinPath, appiumEnv };
|
||||
} catch (error) {
|
||||
onError?.(error);
|
||||
if (signal) {
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
signal.throwIfAborted();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function runAppiumSetupCommandAsync(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: SpawnOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<SpawnResult> {
|
||||
if (!signal) {
|
||||
return await spawn(command, args, options);
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
const task = spawn(command, args, { ...options, detached: true });
|
||||
const abort = (): void => killProcessGroup(task.child, 'SIGKILL');
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
if (signal.aborted) {
|
||||
abort();
|
||||
}
|
||||
try {
|
||||
const result = await task;
|
||||
signal.throwIfAborted();
|
||||
return result;
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abort);
|
||||
logger.info(`Installing appium@${versionSpec} with ${add.command}.`);
|
||||
await spawn(add.command, add.args, {
|
||||
cwd: appiumHome,
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
});
|
||||
const { stdout } = await spawn(appiumBinPath, ['driver', 'list', '--installed', '--json'], {
|
||||
env: appiumEnv,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const installedDrivers = AppiumInstalledDriversSchema.parse(JSON.parse(stdout));
|
||||
if (installedDrivers[driverName]?.installed) {
|
||||
logger.info(`Updating the installed Appium ${driverName} driver.`);
|
||||
await spawn(appiumBinPath, ['driver', 'update', driverName], { env: appiumEnv, logger });
|
||||
} else {
|
||||
logger.info(`Installing the Appium ${driverName} driver.`);
|
||||
await spawn(appiumBinPath, ['driver', 'install', driverName], { env: appiumEnv, logger });
|
||||
}
|
||||
return { appiumHome, appiumBinPath, appiumEnv };
|
||||
}
|
||||
|
||||
async function waitForAppiumReadyAsync({
|
||||
appiumProcess,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
appiumProcess: { getOutput: () => string };
|
||||
logger: bunyan;
|
||||
signal: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const deadline = Date.now() + APPIUM_STARTUP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
signal.throwIfAborted();
|
||||
try {
|
||||
const response = await turtleFetch(`http://${APPIUM_HOST}:${APPIUM_PORT}/status`, 'GET', {
|
||||
timeout: 2_000,
|
||||
retries: 0,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
signal.throwIfAborted();
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
signal.throwIfAborted();
|
||||
try {
|
||||
await setTimeoutAsync(1_000, undefined, { signal });
|
||||
} catch (error) {
|
||||
signal.throwIfAborted();
|
||||
throw error;
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
const output = appiumProcess.getOutput();
|
||||
throw new SystemError(
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type Result, asyncResult } from '@expo/results';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import spawn, { type SpawnOptions } from '@expo/turtle-spawn';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
@@ -20,13 +19,6 @@ import {
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import {
|
||||
createWarmDeviceSessionServer,
|
||||
runOnWarmIosSimulatorAsync,
|
||||
} from '../utils/warmDeviceSessionHost';
|
||||
import { type WarmIosSession } from '../utils/warmIosSession';
|
||||
import { validateControllerVersion } from '../utils/controllerVersion';
|
||||
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
parseNetworkCaptureInputs,
|
||||
@@ -37,16 +29,13 @@ import {
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageExec,
|
||||
} from '../../utils/packageManager';
|
||||
import { isProcessDescendantOfAsync, killProcessGroup } from '../../utils/processes';
|
||||
import { isProcessDescendantOfAsync } from '../../utils/processes';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
|
||||
import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents';
|
||||
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
|
||||
import {
|
||||
DeviceRunSessionStoppedError,
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createIosSessionStartupInputProviders,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
createPublicPreviewInputProvider,
|
||||
createServeSimLaunchInputProviders,
|
||||
@@ -59,6 +48,7 @@ import {
|
||||
omitNgrokAuthtoken,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
@@ -91,17 +81,15 @@ const ArgentToolServerStateSchema = z.object({
|
||||
type ArgentToolServerState = z.infer<typeof ArgentToolServerStateSchema>;
|
||||
|
||||
export function createStartArgentRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext,
|
||||
{ warm = false }: { warm?: boolean } = {}
|
||||
ctx: CustomBuildContext
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: warm ? 'start_warm_argent_remote_session' : 'start_argent_remote_session',
|
||||
id: 'start_argent_remote_session',
|
||||
name: 'Start argent remote session',
|
||||
__metricsId: warm ? 'eas/start_warm_argent_remote_session' : 'eas/start_argent_remote_session',
|
||||
__metricsId: 'eas/start_argent_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createIosSessionStartupInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
createPublicPreviewInputProvider(),
|
||||
createPrivatePreviewUrlInputProvider(),
|
||||
@@ -137,392 +125,265 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
// 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;
|
||||
validateControllerVersion({
|
||||
packageName: ARGENT_PACKAGE_NAME,
|
||||
minimumVersion: MIN_ARGENT_REMOTE_SESSION_VERSION,
|
||||
packageVersion,
|
||||
logger,
|
||||
});
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
|
||||
const versionSpec = packageVersion ?? 'latest';
|
||||
const { runtimePlatform } = global;
|
||||
const startupStop = new AbortController();
|
||||
const watchSignal = signal
|
||||
? AbortSignal.any([signal, startupStop.signal])
|
||||
: startupStop.signal;
|
||||
const startupWatch = watchDeviceRunSessionStartup({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
|
||||
});
|
||||
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
|
||||
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
|
||||
const startupAbortController = new AbortController();
|
||||
const startupSignal = AbortSignal.any([watchSignal, startupAbortController.signal]);
|
||||
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
|
||||
deviceIdentifier: warmSession?.device.udid ?? deviceIdentifier,
|
||||
installAppPath: inputs.install_app_path?.value as string | undefined,
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value,
|
||||
const launch = parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value,
|
||||
openUrl: inputs.open_url?.value as string | undefined,
|
||||
},
|
||||
{ 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}).`
|
||||
);
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
|
||||
// Start the potentially slow installation while Argent is being prepared.
|
||||
// On Linux expo-device-hub calls this again and awaits the same in-flight
|
||||
// setup before launching. On macOS this remains non-blocking, so only a
|
||||
// recording started in the first moments may miss ffmpeg.
|
||||
// Never rejects, so `void` is safe.
|
||||
void ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
|
||||
|
||||
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
|
||||
const argentExec = (args: string[]): { command: string; args: string[] } =>
|
||||
resolvePackageExec(packageManager, args);
|
||||
|
||||
logger.info('Enabling the Argent artifacts list endpoint flag.');
|
||||
const enableArtifacts = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
|
||||
]);
|
||||
await spawn(enableArtifacts.command, enableArtifacts.args, { env, logger });
|
||||
|
||||
logger.info('Enabling the Argent tool-server event log flag.');
|
||||
const enableEventLog = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_EVENT_LOG_FLAG,
|
||||
]);
|
||||
await spawn(enableEventLog.command, enableEventLog.args, { env, logger });
|
||||
|
||||
logger.info('Disabling the Argent video watermark flag.');
|
||||
const disableVideoWatermark = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'disable',
|
||||
ARGENT_VIDEO_WATERMARK_FLAG,
|
||||
]);
|
||||
// The watermark is cosmetic, so a failure here must not fail the session.
|
||||
try {
|
||||
await spawn(disableVideoWatermark.command, disableVideoWatermark.args, { env, logger });
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.'
|
||||
);
|
||||
}
|
||||
|
||||
const startServer = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
'start',
|
||||
'--port',
|
||||
'0',
|
||||
'--idle-timeout',
|
||||
'0',
|
||||
'--force',
|
||||
]);
|
||||
logger.info(
|
||||
`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.`
|
||||
);
|
||||
// Keep Argent itself in foreground mode under the detached process. This preserves
|
||||
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
const secrets: string[] = [];
|
||||
const argentServer = spawnDetached({
|
||||
command: startServer.command,
|
||||
args: startServer.args,
|
||||
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH, ARGENT_EMULATOR_NO_WINDOW: '1' },
|
||||
logger,
|
||||
secrets,
|
||||
});
|
||||
if (argentServer.pid === undefined) {
|
||||
throw new SystemError(
|
||||
'Failed to start Argent: could not determine the PID of the launched process.'
|
||||
);
|
||||
}
|
||||
|
||||
const artifactPollAbortController = new AbortController();
|
||||
let artifactPollingPromise: Promise<void> | undefined;
|
||||
let eventCollection: Awaited<ReturnType<typeof startArgentEventCollectionAsync>> | undefined;
|
||||
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let sessionFailed = false;
|
||||
try {
|
||||
logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`);
|
||||
let toolServerPort: number;
|
||||
let toolServerToken: string | undefined;
|
||||
try {
|
||||
const toolServerState = await waitForArgentToolServerStateAsync({
|
||||
stateDir: ARGENT_STATE_DIR,
|
||||
ancestorPid: argentServer.pid,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
getExitError: argentServer.getExitError,
|
||||
});
|
||||
toolServerPort = toolServerState.port;
|
||||
toolServerToken = toolServerState.token;
|
||||
if (toolServerToken) {
|
||||
secrets.push(toolServerToken);
|
||||
}
|
||||
} catch (err) {
|
||||
const output = argentServer.getOutput();
|
||||
throw new SystemError(
|
||||
`${
|
||||
err instanceof Error ? err.message : `Timed out waiting for argent tool-server state.`
|
||||
}${output ? `\nArgent tool-server output:\n${output}` : ''}`
|
||||
);
|
||||
}
|
||||
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
|
||||
const artifactPollSignal = signal
|
||||
? AbortSignal.any([signal, artifactPollAbortController.signal])
|
||||
: artifactPollAbortController.signal;
|
||||
artifactPollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
toolsUrl: `http://127.0.0.1:${toolServerPort}`,
|
||||
toolsAuthToken: toolServerToken,
|
||||
logger,
|
||||
signal: artifactPollSignal,
|
||||
});
|
||||
|
||||
eventCollection = await startArgentEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
eventLogPath: ARGENT_EVENT_LOG_PATH,
|
||||
logger,
|
||||
});
|
||||
|
||||
toolsTunnel = await startNgrokTunnelAsync({
|
||||
port: toolServerPort,
|
||||
subdomainPrefix: 'argent',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
rewriteHostHeader: true,
|
||||
logger,
|
||||
});
|
||||
const publicToolsUrl = toolsTunnel.url;
|
||||
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
|
||||
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
separateLogPhase: true,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
signal: startupSignal,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
});
|
||||
const webPreview = await sessionHost.openPreviewAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
});
|
||||
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}).`
|
||||
webPreview.sharePageUrl
|
||||
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
|
||||
: 'Private preview URL unavailable for this session.'
|
||||
);
|
||||
|
||||
// Start the potentially slow installation while Argent is being prepared.
|
||||
// On Linux expo-device-hub calls this again and awaits the same in-flight
|
||||
// setup before launching. On macOS this remains non-blocking, so only a
|
||||
// recording started in the first moments may miss ffmpeg.
|
||||
// Never rejects, so `void` is safe.
|
||||
void ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
toolsUrl: publicToolsUrl,
|
||||
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
|
||||
webPreviewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
|
||||
const argentExec = (args: string[]): { command: string; args: string[] } =>
|
||||
resolvePackageExec(packageManager, args);
|
||||
|
||||
const artifactPollAbortController = new AbortController();
|
||||
let artifactPollingPromise: Promise<void> | undefined;
|
||||
let eventCollection:
|
||||
| Awaited<ReturnType<typeof startArgentEventCollectionAsync>>
|
||||
| undefined;
|
||||
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let argentServer: ReturnType<typeof spawnDetached> | undefined;
|
||||
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let sessionFailed = false;
|
||||
const startHost = () =>
|
||||
startDeviceSessionHostAsync(
|
||||
ctx,
|
||||
{
|
||||
...iosStartup,
|
||||
separateLogPhase: true,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken },
|
||||
signal: startupSignal,
|
||||
onStartupError: error => startupAbortController.abort(error),
|
||||
...launch,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
},
|
||||
...(warmSession ? [createWarmDeviceSessionServer(ctx, logger, warmSession)] : [])
|
||||
).catch(error => {
|
||||
startupAbortController.abort(error);
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
if (iosStartup) {
|
||||
hostStartup = asyncResult(startHost());
|
||||
}
|
||||
let toolServerPort: number;
|
||||
let toolServerToken: string | undefined;
|
||||
if (warmSession) {
|
||||
// Adoption prepares the launcher's Argent server for the claimed device.
|
||||
sessionHost = (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
|
||||
const tools = warmSession.tools;
|
||||
if (!tools) {
|
||||
throw new Error('The warm Argent server was not prepared.');
|
||||
}
|
||||
toolServerPort = tools.port;
|
||||
toolServerToken = tools.token;
|
||||
} else {
|
||||
logger.info('Enabling the Argent artifacts list endpoint flag.');
|
||||
const enableArtifacts = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
|
||||
]);
|
||||
await runArgentSetupCommandAsync(
|
||||
enableArtifacts.command,
|
||||
enableArtifacts.args,
|
||||
{ env, logger },
|
||||
startupSignal
|
||||
);
|
||||
|
||||
logger.info('Enabling the Argent tool-server event log flag.');
|
||||
const enableEventLog = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_EVENT_LOG_FLAG,
|
||||
]);
|
||||
await runArgentSetupCommandAsync(
|
||||
enableEventLog.command,
|
||||
enableEventLog.args,
|
||||
{ env, logger },
|
||||
startupSignal
|
||||
);
|
||||
|
||||
logger.info('Disabling the Argent video watermark flag.');
|
||||
const disableVideoWatermark = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'disable',
|
||||
ARGENT_VIDEO_WATERMARK_FLAG,
|
||||
]);
|
||||
// The watermark is cosmetic, so a failure here must not fail the session.
|
||||
try {
|
||||
await runArgentSetupCommandAsync(
|
||||
disableVideoWatermark.command,
|
||||
disableVideoWatermark.args,
|
||||
{ env, logger },
|
||||
startupSignal
|
||||
);
|
||||
} catch (err) {
|
||||
startupSignal?.throwIfAborted();
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.'
|
||||
);
|
||||
}
|
||||
|
||||
startupSignal?.throwIfAborted();
|
||||
const startServer = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
'start',
|
||||
'--port',
|
||||
'0',
|
||||
'--idle-timeout',
|
||||
'0',
|
||||
'--force',
|
||||
]);
|
||||
logger.info(
|
||||
`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.`
|
||||
);
|
||||
// Keep Argent itself in foreground mode under the detached process. This preserves
|
||||
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
const secrets: string[] = [];
|
||||
argentServer = spawnDetached({
|
||||
command: startServer.command,
|
||||
args: startServer.args,
|
||||
env: {
|
||||
...env,
|
||||
ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH,
|
||||
ARGENT_EMULATOR_NO_WINDOW: '1',
|
||||
},
|
||||
logger,
|
||||
secrets,
|
||||
});
|
||||
if (argentServer.pid === undefined) {
|
||||
throw new SystemError(
|
||||
'Failed to start Argent: could not determine the PID of the launched process.'
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`);
|
||||
try {
|
||||
const toolServerState = await waitForArgentToolServerStateAsync({
|
||||
stateDir: ARGENT_STATE_DIR,
|
||||
ancestorPid: argentServer.pid,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
getExitError: argentServer.getExitError,
|
||||
signal: startupSignal,
|
||||
});
|
||||
toolServerPort = toolServerState.port;
|
||||
toolServerToken = toolServerState.token;
|
||||
if (toolServerToken) {
|
||||
secrets.push(toolServerToken);
|
||||
}
|
||||
} catch (err) {
|
||||
const output = argentServer.getOutput();
|
||||
throw new SystemError(
|
||||
`${
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: `Timed out waiting for argent tool-server state.`
|
||||
}${output ? `\nArgent tool-server output:\n${output}` : ''}`
|
||||
);
|
||||
}
|
||||
if (hostStartup) {
|
||||
sessionHost = (await hostStartup).enforceValue();
|
||||
}
|
||||
startupSignal?.throwIfAborted();
|
||||
}
|
||||
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
|
||||
const artifactPollSignal = signal
|
||||
? AbortSignal.any([signal, artifactPollAbortController.signal])
|
||||
: artifactPollAbortController.signal;
|
||||
artifactPollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
toolsUrl: `http://127.0.0.1:${toolServerPort}`,
|
||||
toolsAuthToken: toolServerToken,
|
||||
logger,
|
||||
signal: artifactPollSignal,
|
||||
});
|
||||
|
||||
eventCollection = await startArgentEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
eventLogPath: warmSession?.argentEventLogPath ?? ARGENT_EVENT_LOG_PATH,
|
||||
logger,
|
||||
});
|
||||
|
||||
toolsTunnel = await startNgrokTunnelAsync({
|
||||
port: toolServerPort,
|
||||
subdomainPrefix: 'argent',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
rewriteHostHeader: true,
|
||||
healthCheck: { path: '/tools', statuses: [200, 401] },
|
||||
logger,
|
||||
});
|
||||
const publicToolsUrl = toolsTunnel.url;
|
||||
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
|
||||
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
|
||||
startupSignal.throwIfAborted();
|
||||
const webPreview = await sessionHost.openPreviewAsync();
|
||||
logger.info(
|
||||
webPreview.sharePageUrl
|
||||
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
|
||||
: 'Private preview URL unavailable for this session.'
|
||||
);
|
||||
|
||||
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal: startupSignal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
toolsUrl: publicToolsUrl,
|
||||
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
|
||||
webPreviewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
if (sessionStart === 'stopped') {
|
||||
return;
|
||||
}
|
||||
|
||||
await startupWatch.completeAsync();
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
maxDurationSeconds,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
startupSignal.reason instanceof DeviceRunSessionStoppedError &&
|
||||
!(error instanceof AggregateError)
|
||||
) {
|
||||
throw startupSignal.reason;
|
||||
}
|
||||
sessionFailed = true;
|
||||
throw error instanceof AggregateError
|
||||
? error
|
||||
: startupSignal.aborted &&
|
||||
!(startupSignal.reason instanceof DeviceRunSessionStoppedError)
|
||||
? startupSignal.reason
|
||||
: error;
|
||||
} finally {
|
||||
startupAbortController.abort(new Error('Argent session ended.'));
|
||||
const toolTeardown = (async () => {
|
||||
try {
|
||||
if (eventCollection) {
|
||||
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();
|
||||
}
|
||||
})();
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
teardown: [
|
||||
['Argent tunnel', toolsTunnel?.stopAsync()],
|
||||
['Argent tool-server', toolTeardown],
|
||||
[
|
||||
'session host',
|
||||
hostStartup?.then(async result => {
|
||||
if (warmSession) {
|
||||
// The warm host owns Argent too; leave it alive until the final downloads finish.
|
||||
await Promise.allSettled([toolTeardown]);
|
||||
}
|
||||
if (result.reason instanceof AggregateError) {
|
||||
throw result.reason;
|
||||
}
|
||||
await result.value?.finishAsync();
|
||||
}),
|
||||
],
|
||||
],
|
||||
});
|
||||
}
|
||||
};
|
||||
try {
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
watchSignal.throwIfAborted();
|
||||
if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await runOnWarmIosSimulatorAsync(
|
||||
{
|
||||
engine: 'argent',
|
||||
requestedDevice: deviceIdentifier,
|
||||
packageVersion,
|
||||
env,
|
||||
logger,
|
||||
signal: watchSignal,
|
||||
},
|
||||
startSessionAsync
|
||||
);
|
||||
} else {
|
||||
await startSessionAsync();
|
||||
}
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
maxDurationSeconds,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error !== startupWatch.stopped) {
|
||||
throw error;
|
||||
}
|
||||
sessionFailed = true;
|
||||
throw error;
|
||||
} finally {
|
||||
await startupWatch.finishAsync();
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
teardown: [
|
||||
['Argent tunnel', toolsTunnel?.stopAsync()],
|
||||
[
|
||||
'Argent tool-server',
|
||||
(async () => {
|
||||
try {
|
||||
if (eventCollection) {
|
||||
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()],
|
||||
],
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -550,28 +411,33 @@ export async function stopArgentEventCollectionSafelyAsync({
|
||||
}
|
||||
}
|
||||
|
||||
async function runArgentSetupCommandAsync(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: SpawnOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
if (!signal) {
|
||||
await spawn(command, args, options);
|
||||
export function warnIfArgentPackageVersionCannotBeVerified({
|
||||
packageVersion,
|
||||
logger,
|
||||
}: {
|
||||
packageVersion: string | undefined;
|
||||
logger: bunyan;
|
||||
}): void {
|
||||
if (!packageVersion || packageVersion === 'latest') {
|
||||
return;
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
const task = spawn(command, args, { ...options, detached: true });
|
||||
const abort = (): void => killProcessGroup(task.child, 'SIGKILL');
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
if (signal.aborted) {
|
||||
abort();
|
||||
|
||||
const validVersion = semver.valid(packageVersion);
|
||||
if (!validVersion) {
|
||||
logger.warn(
|
||||
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer, ` +
|
||||
`but package_version "${packageVersion}" is not an exact semver version that EAS can verify. ` +
|
||||
`Continuing and letting the package manager resolve it.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await task;
|
||||
signal.throwIfAborted();
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abort);
|
||||
|
||||
if (semver.lt(validVersion, MIN_ARGENT_REMOTE_SESSION_VERSION)) {
|
||||
throw new SystemError(
|
||||
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer. ` +
|
||||
`The requested package_version "${packageVersion}" is too old for the EAS remote-session API. ` +
|
||||
`Use "latest" or pass an exact version >= ${MIN_ARGENT_REMOTE_SESSION_VERSION}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,16 +463,13 @@ export async function waitForArgentToolServerStateAsync({
|
||||
timeoutMs,
|
||||
pollIntervalMs = 1_000,
|
||||
getExitError,
|
||||
signal,
|
||||
}: {
|
||||
stateDir: string;
|
||||
ancestorPid: number;
|
||||
timeoutMs: number;
|
||||
pollIntervalMs?: number;
|
||||
getExitError?: () => Error | undefined;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ArgentToolServerState> {
|
||||
signal?.throwIfAborted();
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
const throwIfExited = (): void => {
|
||||
@@ -617,7 +480,6 @@ export async function waitForArgentToolServerStateAsync({
|
||||
};
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
signal?.throwIfAborted();
|
||||
throwIfExited();
|
||||
let matchingState: ArgentToolServerState | undefined;
|
||||
try {
|
||||
@@ -643,19 +505,13 @@ export async function waitForArgentToolServerStateAsync({
|
||||
lastError = err;
|
||||
}
|
||||
// Keep process failures outside the retryable state-file read errors.
|
||||
signal?.throwIfAborted();
|
||||
throwIfExited();
|
||||
if (matchingState) {
|
||||
return matchingState;
|
||||
}
|
||||
if (signal) {
|
||||
await setTimeoutAsync(pollIntervalMs, undefined, { signal });
|
||||
} else {
|
||||
await sleepAsync(pollIntervalMs);
|
||||
}
|
||||
await sleepAsync(pollIntervalMs);
|
||||
}
|
||||
|
||||
signal?.throwIfAborted();
|
||||
throw new SystemError(
|
||||
`Timed out waiting for an argent tool-server state file belonging to process ${ancestorPid} in ${stateDir}${
|
||||
lastError instanceof Error ? `: ${lastError.message}` : ''
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import { graphql } from 'gql.tada';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import { withLogPhaseAsync } from '../../utils/logPhase';
|
||||
import { getLegacySandboxConnection } from '../utils/legacySandboxApi';
|
||||
import { startSandboxDaemonAsync } from '../utils/sandboxDaemon';
|
||||
import { getSandboxWorkerApi } from '../utils/sandboxWorkerApi';
|
||||
|
||||
const MARK_SANDBOX_READY_MUTATION = graphql(`
|
||||
mutation MarkSandboxReady($sandboxId: ID!) {
|
||||
sandbox {
|
||||
markSandboxReady(sandboxId: $sandboxId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const RECONNECT_DELAY_MS = 1_000;
|
||||
const AWAIT_SANDBOX_STEP_NAME = 'Await sandbox completion';
|
||||
@@ -22,80 +32,38 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_duration_seconds',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, signal, env }) => {
|
||||
const workerApi = getSandboxWorkerApi(ctx);
|
||||
const { sandboxApi, credential, commandEnv } = workerApi
|
||||
? {
|
||||
sandboxApi: workerApi,
|
||||
credential: workerApi.token,
|
||||
commandEnv: env,
|
||||
}
|
||||
: getLegacySandboxConnection({
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
env,
|
||||
sandboxId: String(inputs.sandbox_id.value),
|
||||
});
|
||||
// The daemon needs this credential, but shell commands do not.
|
||||
const { __EAS_SANDBOX_MCP_TOKEN: sandboxToken, ...commandEnv } = env;
|
||||
if (!sandboxToken) {
|
||||
throw new SystemError('__EAS_SANDBOX_MCP_TOKEN is required to start the sandbox daemon.');
|
||||
}
|
||||
const mcpServerUrl = ctx.mcpServerUrl;
|
||||
if (!mcpServerUrl) {
|
||||
throw new SystemError('MCP server URL is required to start the sandbox daemon.');
|
||||
}
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value;
|
||||
if (
|
||||
maxDurationSeconds !== undefined &&
|
||||
(typeof maxDurationSeconds !== 'number' ||
|
||||
!Number.isFinite(maxDurationSeconds) ||
|
||||
maxDurationSeconds <= 0)
|
||||
) {
|
||||
throw new UserError(
|
||||
'EAS_SANDBOX_INVALID_DURATION',
|
||||
`Invalid sandbox maximum duration: ${String(maxDurationSeconds)}. Provide a finite number greater than zero, or omit max_duration_seconds.`
|
||||
);
|
||||
}
|
||||
const sandboxId = String(inputs.sandbox_id.value);
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential,
|
||||
credential: sandboxToken,
|
||||
serverUrl: mcpServerUrl,
|
||||
reconnectDelayMs: RECONNECT_DELAY_MS,
|
||||
logger: stepCtx.logger,
|
||||
signal,
|
||||
workingDirectory: stepCtx.workingDirectory,
|
||||
env: commandEnv,
|
||||
workerApi: sandboxApi,
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
sandboxId,
|
||||
});
|
||||
try {
|
||||
await daemon.ready;
|
||||
signal?.throwIfAborted();
|
||||
await sandboxApi.markReadyAsync({ signal }).catch((error: unknown) => {
|
||||
signal?.throwIfAborted();
|
||||
throw new SystemError(
|
||||
`Failed to mark sandbox ${String(inputs.sandbox_id.value)} as ready.`,
|
||||
{ cause: error }
|
||||
);
|
||||
});
|
||||
await markSandboxReadyAsync(ctx, sandboxId, signal);
|
||||
signal?.throwIfAborted();
|
||||
stepCtx.logger.info('Sandbox daemon started.');
|
||||
|
||||
await withLogPhaseAsync(stepCtx.logger, AWAIT_SANDBOX_STEP_NAME, async () => {
|
||||
const timeoutSignal =
|
||||
maxDurationSeconds === undefined
|
||||
? undefined
|
||||
: AbortSignal.timeout(Math.ceil(maxDurationSeconds * 1000));
|
||||
await waitForCompletionAsync(
|
||||
AbortSignal.any([
|
||||
...(signal ? [signal] : []),
|
||||
...(timeoutSignal ? [timeoutSignal] : []),
|
||||
])
|
||||
);
|
||||
if (timeoutSignal?.aborted) {
|
||||
stepCtx.logger.info(
|
||||
`Sandbox reached its maximum runtime of ${maxDurationSeconds} seconds. Stopping.`
|
||||
);
|
||||
}
|
||||
await waitForCancellationAsync(signal);
|
||||
});
|
||||
} finally {
|
||||
await daemon.stopAsync();
|
||||
@@ -104,11 +72,28 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForCompletionAsync(signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) {
|
||||
export async function markSandboxReadyAsync(
|
||||
ctx: CustomBuildContext,
|
||||
sandboxId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(MARK_SANDBOX_READY_MUTATION, { sandboxId }, graphqlAbortContext(signal))
|
||||
.toPromise();
|
||||
signal?.throwIfAborted();
|
||||
if (result.error) {
|
||||
throw new SystemError(`Failed to mark sandbox ${sandboxId} as ready.`, {
|
||||
cause: result.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForCancellationAsync(signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>(resolve => {
|
||||
signal.addEventListener('abort', () => resolve(), { once: true });
|
||||
signal?.addEventListener('abort', () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
@@ -7,13 +6,7 @@ import {
|
||||
} from '@expo/steps';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
|
||||
import { startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import {
|
||||
createWarmDeviceSessionServer,
|
||||
runOnWarmIosSimulatorAsync,
|
||||
} from '../utils/warmDeviceSessionHost';
|
||||
import { type WarmIosSession } from '../utils/warmIosSession';
|
||||
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
@@ -44,16 +37,13 @@ import {
|
||||
const STARTUP_TIMEOUT_MS = 60_000;
|
||||
|
||||
export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext,
|
||||
{ warm = false }: { warm?: boolean } = {}
|
||||
ctx: CustomBuildContext
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: warm ? 'start_warm_serve_sim_remote_session' : 'start_serve_sim_remote_session',
|
||||
id: 'start_serve_sim_remote_session',
|
||||
name: 'Start web preview remote session',
|
||||
__metricsId: warm
|
||||
? 'eas/start_warm_serve_sim_remote_session'
|
||||
: 'eas/start_serve_sim_remote_session',
|
||||
__metricsId: 'eas/start_serve_sim_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createIosSessionStartupInputProviders(),
|
||||
@@ -67,11 +57,6 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
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,
|
||||
@@ -83,169 +68,95 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(stepEnv);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(stepEnv);
|
||||
const env = omitNgrokAuthtoken(stepEnv);
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes?.value as number | undefined;
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
const packageVersion = inputs.package_version?.value as string | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
if (
|
||||
maxIdleTimeMinutes !== undefined &&
|
||||
maxIdleTimeMinutes > 0 &&
|
||||
runtimePlatform !== BuildRuntimePlatform.DARWIN
|
||||
) {
|
||||
throw new UserError(
|
||||
'UNSUPPORTED_WEB_PREVIEW_IDLE_TIMEOUT',
|
||||
'Web preview idle timeouts require iOS preview activity reporting. Omit max_idle_time_minutes for Android sessions.'
|
||||
);
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
const startupStop = new AbortController();
|
||||
const watchSignal = signal
|
||||
? AbortSignal.any([signal, startupStop.signal])
|
||||
: startupStop.signal;
|
||||
const startupWatch = watchDeviceRunSessionStartup({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
|
||||
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
|
||||
installAppPath: inputs.install_app_path?.value as string | undefined,
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value,
|
||||
env,
|
||||
logger,
|
||||
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
|
||||
signal,
|
||||
});
|
||||
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
|
||||
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
|
||||
const startupAbortController = new AbortController();
|
||||
const startupSignal = AbortSignal.any([watchSignal, startupAbortController.signal]);
|
||||
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
|
||||
deviceIdentifier: warmSession?.device.udid ?? deviceIdentifier,
|
||||
installAppPath: inputs.install_app_path?.value as string | undefined,
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value,
|
||||
env,
|
||||
logger,
|
||||
signal: startupSignal,
|
||||
});
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture?.value,
|
||||
networkCaptureFields: inputs.network_capture_fields?.value,
|
||||
},
|
||||
{ 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);
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`);
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
|
||||
const sessionHost = await startDeviceSessionHostAsync(
|
||||
ctx,
|
||||
{
|
||||
...iosStartup,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken },
|
||||
signal: startupSignal,
|
||||
onStartupError: error => startupAbortController.abort(error),
|
||||
packageVersion,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
},
|
||||
...(warmSession ? [createWarmDeviceSessionServer(ctx, logger, warmSession)] : [])
|
||||
).catch(error => {
|
||||
if (startupSignal.reason === startupWatch.stopped && !(error instanceof AggregateError)) {
|
||||
throw startupWatch.stopped;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
const sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
...iosStartup,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
signal,
|
||||
packageVersion,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
});
|
||||
|
||||
try {
|
||||
startupSignal.throwIfAborted();
|
||||
const webPreview = await sessionHost.openPreviewAsync();
|
||||
logger.info(
|
||||
webPreview.sharePageUrl
|
||||
? `Preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
|
||||
: 'Private preview URL unavailable for this session.'
|
||||
);
|
||||
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal: startupSignal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
// WEB_PREVIEW_ONLY sessions read webPreviewUrl and webPreviewToken. Legacy SERVE_SIM
|
||||
// sessions read previewUrl and previewToken. The server drops the keys that the
|
||||
// session type does not use.
|
||||
remoteConfig: {
|
||||
webPreviewUrl: webPreview.previewPageUrl,
|
||||
previewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken
|
||||
? {
|
||||
webPreviewToken: webPreview.previewToken,
|
||||
previewToken: webPreview.previewToken,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
if (sessionStart === 'stopped') {
|
||||
return;
|
||||
}
|
||||
|
||||
await startupWatch.completeAsync();
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
maxDurationSeconds,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
getLastPreviewActivityAt: () => sessionHost.getLastPreviewActivityAt(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} finally {
|
||||
await sessionHost.finishAsync();
|
||||
}
|
||||
};
|
||||
try {
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
watchSignal.throwIfAborted();
|
||||
if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await runOnWarmIosSimulatorAsync(
|
||||
{
|
||||
engine: 'serve-sim',
|
||||
requestedDevice: deviceIdentifier,
|
||||
packageVersion,
|
||||
env,
|
||||
logger,
|
||||
signal: watchSignal,
|
||||
},
|
||||
startSessionAsync
|
||||
);
|
||||
} else {
|
||||
await startSessionAsync();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== startupWatch.stopped) {
|
||||
throw error;
|
||||
}
|
||||
const webPreview = await sessionHost.openPreviewAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
});
|
||||
logger.info(
|
||||
webPreview.sharePageUrl
|
||||
? `Preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
|
||||
: 'Private preview URL unavailable for this session.'
|
||||
);
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
// WEB_PREVIEW_ONLY sessions read webPreviewUrl and webPreviewToken. Legacy SERVE_SIM
|
||||
// sessions read previewUrl and previewToken. The server drops the keys that the
|
||||
// session type does not use.
|
||||
remoteConfig: {
|
||||
webPreviewUrl: webPreview.previewPageUrl,
|
||||
previewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken
|
||||
? { webPreviewToken: webPreview.previewToken, previewToken: webPreview.previewToken }
|
||||
: {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
maxDurationSeconds,
|
||||
signal,
|
||||
});
|
||||
} finally {
|
||||
await startupWatch.finishAsync();
|
||||
await sessionHost.finishAsync();
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
import { BuildJob, Platform } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
spawnAsync,
|
||||
} from '@expo/steps';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import fs from 'fs-extra';
|
||||
import { Client } from '@urql/core';
|
||||
import { graphql } from 'gql.tada';
|
||||
import path from 'node:path';
|
||||
|
||||
import { parseInfoPlistBuffer, readIpaInfoAsync } from './readIpaInfo';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
|
||||
export function createUpdateResolvedVersionBuildFunction(
|
||||
ctx: CustomBuildContext<BuildJob>
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'update_resolved_version',
|
||||
name: 'Update resolved version',
|
||||
__metricsId: 'eas/update_resolved_version',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'application_archive_path',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs }) => {
|
||||
try {
|
||||
const archivePathInput = inputs.application_archive_path.value as string | undefined;
|
||||
if (!archivePathInput) {
|
||||
stepCtx.logger.info('No application archive path provided, skipping.');
|
||||
return;
|
||||
}
|
||||
const archivePath = path.resolve(stepCtx.workingDirectory, archivePathInput);
|
||||
|
||||
const buildId = ctx.env.EAS_BUILD_ID;
|
||||
if (!buildId) {
|
||||
stepCtx.logger.info('EAS_BUILD_ID not set, skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { appVersion, appBuildVersion } =
|
||||
ctx.job.platform === Platform.IOS
|
||||
? await extractIosVersionAsync(archivePath)
|
||||
: await extractAndroidVersionAsync(archivePath);
|
||||
|
||||
if (!appVersion && !appBuildVersion) {
|
||||
stepCtx.logger.info('No resolved version found, skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
stepCtx.logger.info(
|
||||
`Resolved version: ${appVersion ?? 'N/A'} (${appBuildVersion ?? 'N/A'})`
|
||||
);
|
||||
|
||||
await updateResolvedVersionAsync(ctx.graphqlClient, buildId, {
|
||||
appVersion,
|
||||
appBuildVersion,
|
||||
});
|
||||
|
||||
stepCtx.logger.info('Updated resolved version on EAS.');
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Failed to update resolved version', error);
|
||||
stepCtx.logger.warn({ err: error }, 'Failed to update resolved version (non-fatal)');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function extractIosVersionAsync(
|
||||
archivePath: string
|
||||
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
|
||||
const ext = path.extname(archivePath).toLowerCase();
|
||||
|
||||
if (ext === '.app') {
|
||||
return await extractSimulatorAppVersionAsync(archivePath);
|
||||
}
|
||||
|
||||
const ipaInfo = await readIpaInfoAsync(archivePath);
|
||||
return {
|
||||
appVersion: ipaInfo.bundleShortVersion,
|
||||
appBuildVersion: ipaInfo.bundleVersion,
|
||||
};
|
||||
}
|
||||
|
||||
async function extractSimulatorAppVersionAsync(
|
||||
appPath: string
|
||||
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
|
||||
const infoPlistPath = path.join(appPath, 'Info.plist');
|
||||
|
||||
if (!(await fs.pathExists(infoPlistPath))) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const infoPlistBuffer = await fs.readFile(infoPlistPath);
|
||||
const infoPlist = parseInfoPlistBuffer(infoPlistBuffer);
|
||||
|
||||
return {
|
||||
appVersion:
|
||||
typeof infoPlist.CFBundleShortVersionString === 'string'
|
||||
? infoPlist.CFBundleShortVersionString
|
||||
: undefined,
|
||||
appBuildVersion:
|
||||
typeof infoPlist.CFBundleVersion === 'string' ? infoPlist.CFBundleVersion : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function extractAndroidVersionAsync(
|
||||
archivePath: string
|
||||
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
|
||||
const ext = path.extname(archivePath).toLowerCase();
|
||||
|
||||
if (ext === '.apk') {
|
||||
return await extractVersionFromApkAsync(archivePath);
|
||||
} else if (ext === '.aab') {
|
||||
return await extractVersionFromAabAsync(archivePath);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
async function extractVersionFromApkAsync(
|
||||
apkPath: string
|
||||
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
|
||||
const result = await spawnAsync('aapt2', ['dump', 'badging', apkPath], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
return parseAaptOutput(result.stdout);
|
||||
}
|
||||
|
||||
async function extractVersionFromAabAsync(
|
||||
aabPath: string
|
||||
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
|
||||
const result = await spawnAsync('bundletool', ['dump', 'manifest', '--bundle', aabPath], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
return parseManifestXml(result.stdout);
|
||||
}
|
||||
|
||||
export function parseAaptOutput(output: string): { appVersion?: string; appBuildVersion?: string } {
|
||||
// aapt2 does not escape apostrophes inside versionName, so stop at the next known
|
||||
// badging field (or the end of the line) rather than at the first closing quote.
|
||||
const versionNameMatch = output.match(
|
||||
/versionName='(.*?)'(?= split=| platformBuildVersionName=| platformBuildVersionCode=| compileSdkVersion=|\s*$)/m
|
||||
);
|
||||
const versionCodeMatch = output.match(/versionCode='([^']+)'/);
|
||||
|
||||
return {
|
||||
appVersion: versionNameMatch?.[1] || undefined,
|
||||
appBuildVersion: versionCodeMatch?.[1],
|
||||
};
|
||||
}
|
||||
|
||||
export function parseManifestXml(xml: string): { appVersion?: string; appBuildVersion?: string } {
|
||||
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_' });
|
||||
const parsed = parser.parse(xml);
|
||||
|
||||
const manifest = parsed?.manifest;
|
||||
if (!manifest) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const versionName = manifest['@_android:versionName'];
|
||||
const versionCode = manifest['@_android:versionCode'];
|
||||
|
||||
return {
|
||||
appVersion: versionName != null ? String(versionName) : undefined,
|
||||
appBuildVersion: versionCode != null ? String(versionCode) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateResolvedVersionAsync(
|
||||
graphqlClient: Client,
|
||||
buildId: string,
|
||||
{
|
||||
appVersion,
|
||||
appBuildVersion,
|
||||
}: {
|
||||
appVersion: string | undefined;
|
||||
appBuildVersion: string | undefined;
|
||||
}
|
||||
): Promise<void> {
|
||||
const result = await graphqlClient
|
||||
.mutation(
|
||||
graphql(`
|
||||
mutation UpdateResolvedVersionMutation(
|
||||
$buildId: ID!
|
||||
$appVersion: String
|
||||
$appBuildVersion: String
|
||||
) {
|
||||
build {
|
||||
updateBuildMetadata(
|
||||
buildId: $buildId
|
||||
metadata: {
|
||||
appVersion: $appVersion
|
||||
appBuildVersion: $appBuildVersion
|
||||
}
|
||||
) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`),
|
||||
{
|
||||
buildId,
|
||||
appVersion,
|
||||
appBuildVersion,
|
||||
}
|
||||
)
|
||||
.toPromise();
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Job, ManagedArtifactType } from '@expo/eas-build-job';
|
||||
import {
|
||||
GenericArtifactType,
|
||||
Job,
|
||||
ManagedArtifactType,
|
||||
isGenericArtifact,
|
||||
} from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
@@ -28,12 +33,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
|
||||
ManagedArtifactType.APPLICATION_ARCHIVE,
|
||||
ManagedArtifactType.BUILD_ARTIFACTS,
|
||||
...Object.keys(artifactTypeInputToManagedArtifactType),
|
||||
// Keep accepting these legacy artifact types for compatibility.
|
||||
'android-apk',
|
||||
'android-aab',
|
||||
'ios-simulator-app',
|
||||
'ios-ipa',
|
||||
'other',
|
||||
...Object.values(GenericArtifactType),
|
||||
],
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
@@ -126,13 +126,12 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
|
||||
paths: artifactPaths,
|
||||
name: (inputs.name.value || inputs.key.value) as string,
|
||||
};
|
||||
const artifact =
|
||||
artifactSpec.type === null
|
||||
? {
|
||||
...artifactSpec,
|
||||
metadata: inputs.metadata.value as Record<string, unknown> | undefined,
|
||||
}
|
||||
: artifactSpec;
|
||||
const artifact = isGenericArtifact(artifactSpec)
|
||||
? {
|
||||
...artifactSpec,
|
||||
metadata: inputs.metadata.value as Record<string, unknown> | undefined,
|
||||
}
|
||||
: artifactSpec;
|
||||
|
||||
try {
|
||||
const { artifactId } = await ctx.runtimeApi.uploadArtifact({ artifact, logger });
|
||||
@@ -142,7 +141,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
|
||||
}
|
||||
} catch (error) {
|
||||
if (inputs.ignore_error.value) {
|
||||
logger.error({ err: error }, 'Failed to upload artifact. Ignoring error.');
|
||||
logger.error({ err: error }, `Failed to upload ${artifact.type}. Ignoring error.`);
|
||||
// Ignoring error.
|
||||
return;
|
||||
}
|
||||
@@ -159,7 +158,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
|
||||
* values. This makes sure we support all:
|
||||
* - kebab-case managed artifact types (the original)
|
||||
* - snake-caps-case managed artifact types (the mistake)
|
||||
* - workflow artifact types.
|
||||
* - generic artifact types.
|
||||
*/
|
||||
function parseArtifactTypeInput({
|
||||
inputValue,
|
||||
@@ -167,15 +166,19 @@ function parseArtifactTypeInput({
|
||||
}: {
|
||||
inputValue: string;
|
||||
platform: Job['platform'];
|
||||
}): ManagedArtifactType | null {
|
||||
}): GenericArtifactType | ManagedArtifactType {
|
||||
if (!inputValue) {
|
||||
// In build jobs the default artifact type is application-archive.
|
||||
return platform ? ManagedArtifactType.APPLICATION_ARCHIVE : null;
|
||||
return platform ? ManagedArtifactType.APPLICATION_ARCHIVE : GenericArtifactType.OTHER;
|
||||
}
|
||||
|
||||
return (
|
||||
artifactTypeInputToManagedArtifactType[inputValue] ??
|
||||
Object.values(ManagedArtifactType).find(type => type === inputValue) ??
|
||||
null
|
||||
);
|
||||
// Step's allowedValues ensures input is either
|
||||
// a key of artifactTypeInputToManagedArtifactType
|
||||
// or a value of an artifact type.
|
||||
const translatedManagedArtifactType = artifactTypeInputToManagedArtifactType[inputValue];
|
||||
if (translatedManagedArtifactType) {
|
||||
return translatedManagedArtifactType;
|
||||
}
|
||||
|
||||
return inputValue as GenericArtifactType | ManagedArtifactType;
|
||||
}
|
||||
@@ -262,45 +262,6 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
}
|
||||
);
|
||||
}
|
||||
if (isRedundantBinaryUploadError(errors)) {
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE',
|
||||
`Increment Build Number: Build number ${bundleVersion} for app version ${bundleShortVersion} has already been used. ` +
|
||||
'App Store Connect requires unique build numbers within each app version (version train). ' +
|
||||
'Increment it by setting ios.buildNumber in app.json, or set "autoIncrement": true in eas.json (recommended). Then rebuild and resubmit.' +
|
||||
`\n\nApp Store Connect errors:\n${itemizeMessages(errors)}`,
|
||||
{
|
||||
docsUrl: 'https://docs.expo.dev/build-reference/app-versions/',
|
||||
cause: state,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (isMissingBackgroundTaskIdentifiersError(errors)) {
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_MISSING_BG_TASK_IDENTIFIERS',
|
||||
'Build upload was rejected by App Store Connect because Info.plist is missing `BGTaskSchedulerPermittedIdentifiers` while `UIBackgroundModes` includes `processing`. ' +
|
||||
'In an Expo project, add `BGTaskSchedulerPermittedIdentifiers` to `ios.infoPlist` in your app config and include every background task identifier the app schedules. ' +
|
||||
'If you do not use Continuous Native Generation (CNG), add the same key to the native target Info.plist. Then rebuild and submit again.' +
|
||||
`\n\nApp Store Connect errors:\n${itemizeMessages(errors)}`,
|
||||
{
|
||||
docsUrl: 'https://docs.expo.dev/versions/latest/config/app/#infoplist',
|
||||
cause: state,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (isMissingAppIconsError(errors)) {
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_MISSING_APP_ICONS',
|
||||
'Build upload was rejected by App Store Connect because required app icons are missing or misconfigured in one of the iOS bundles. ' +
|
||||
'In an Expo project, check your icon config (`expo.icon`, `ios.icon`) and any custom native targets or plugins that create extra bundles, such as watch extensions. ' +
|
||||
'Make sure the affected target has the required icon assets and Info.plist icon keys (for example `CFBundleIconName` / `CFBundleIconFiles`), then rebuild and submit again.' +
|
||||
`\n\nApp Store Connect errors:\n${itemizeMessages(errors)}`,
|
||||
{
|
||||
docsUrl: 'https://docs.expo.dev/develop/user-interface/splash-screen-and-app-icon/',
|
||||
cause: state,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (isInvalidBundleIdentifierError(errors)) {
|
||||
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
|
||||
const ipaBundleIdentifier = ipaInfoResult.ok
|
||||
@@ -383,21 +344,6 @@ export function isSdkVersionIssueError(messages: { code: string }[]): boolean {
|
||||
return messages.length > 0 && messages.every(message => message.code === '90725');
|
||||
}
|
||||
|
||||
export function isRedundantBinaryUploadError(messages: { code: string }[]): boolean {
|
||||
return messages.length > 0 && messages.every(message => message.code === '90189');
|
||||
}
|
||||
|
||||
export function isMissingBackgroundTaskIdentifiersError(messages: { code: string }[]): boolean {
|
||||
return messages.length > 0 && messages.every(message => message.code === '90771');
|
||||
}
|
||||
|
||||
export function isMissingAppIconsError(messages: { code: string }[]): boolean {
|
||||
return (
|
||||
messages.length > 0 &&
|
||||
messages.every(message => ['90022', '90023', '90391', '90713'].includes(message.code))
|
||||
);
|
||||
}
|
||||
|
||||
export function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
|
||||
return (
|
||||
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { BuildFunction } from '@expo/steps';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { setUpNpmrcAsync } from '../../utils/npmrc';
|
||||
import { NpmrcTemplate } from '../../templates/npmrc';
|
||||
import { findPackagerRootDir } from '../../utils/packageManager';
|
||||
|
||||
export function createSetUpNpmrcBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -9,12 +12,28 @@ export function createSetUpNpmrcBuildFunction(): BuildFunction {
|
||||
name: 'Use NPM_TOKEN',
|
||||
__metricsId: 'eas/use_npm_token',
|
||||
fn: async (stepCtx, { env }) => {
|
||||
await setUpNpmrcAsync({
|
||||
projectTargetDirectory: stepCtx.global.projectTargetDirectory,
|
||||
workingDirectory: stepCtx.workingDirectory,
|
||||
env,
|
||||
logger: stepCtx.logger,
|
||||
});
|
||||
const { logger } = stepCtx;
|
||||
if (env.NPM_TOKEN) {
|
||||
logger.info('We detected that you set the NPM_TOKEN environment variable');
|
||||
const projectNpmrcPath = path.join(stepCtx.global.projectTargetDirectory, '.npmrc');
|
||||
if (await fs.pathExists(projectNpmrcPath)) {
|
||||
logger.info('.npmrc already exists in your project directory, skipping generation');
|
||||
} else {
|
||||
logger.info('Creating .npmrc in your project directory with the following contents:');
|
||||
logger.info(NpmrcTemplate);
|
||||
await fs.writeFile(projectNpmrcPath, NpmrcTemplate);
|
||||
}
|
||||
} else {
|
||||
const projectNpmrcPath = path.join(findPackagerRootDir(stepCtx.workingDirectory), '.npmrc');
|
||||
if (await fs.pathExists(projectNpmrcPath)) {
|
||||
logger.info(
|
||||
`.npmrc found at ${path.relative(
|
||||
stepCtx.global.projectTargetDirectory,
|
||||
projectNpmrcPath
|
||||
)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -36,7 +36,7 @@ const SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS =
|
||||
const SERVE_SIM_FINISH_TIMEOUT_MS =
|
||||
SERVE_SIM_CONTROL_REQUEST_TIMEOUT_MS + SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS;
|
||||
const SERVE_SIM_FORCE_STOP_TIMEOUT_MS = 5_000;
|
||||
const SERVE_SIM_RECORDING_STARTED_LINE = /(?:^|\n)serve-sim:recording-started\r?\n/;
|
||||
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;
|
||||
|
||||
@@ -57,19 +57,11 @@ type ActiveIosSimulatorRecording = IosSimulatorRecording & {
|
||||
hasExited: () => boolean;
|
||||
};
|
||||
|
||||
type AdoptedIosSimulatorRecording = {
|
||||
udid: string;
|
||||
serverToken: string;
|
||||
collectAsync(): Promise<void>;
|
||||
};
|
||||
|
||||
type IosSimulatorRecordingSession = {
|
||||
env: Env;
|
||||
logger: bunyan;
|
||||
recordingsRootDirectory: string;
|
||||
activeRecordings: Map<IosSimulatorUuid, ActiveIosSimulatorRecording>;
|
||||
adoptedRecordings: Map<string, AdoptedIosSimulatorRecording>;
|
||||
runtimeDisplayNames: Map<string, string>;
|
||||
completedRecordings: IosSimulatorRecording[];
|
||||
recordingFailureCounts: Map<IosSimulatorUuid, number>;
|
||||
/** Failed attempts for the whole session; unlike recordingFailureCounts, never reset. */
|
||||
@@ -89,52 +81,6 @@ export namespace IosSimulatorRecordingUtils {
|
||||
serveSimPackageSpec ??= packageSpec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserves this job's recording output before warm serve-sim adoption, which starts the
|
||||
* recording. The poller skips that device, so it never starts a second recorder.
|
||||
*/
|
||||
export async function prepareAdoptedRecordingAsync({
|
||||
udid,
|
||||
deviceName,
|
||||
serverToken,
|
||||
}: {
|
||||
udid: string;
|
||||
deviceName: string;
|
||||
serverToken: string;
|
||||
}): Promise<{ output: string; recordingId: string } | undefined> {
|
||||
const session = activeIosSimulatorRecordingSession;
|
||||
if (!session) {
|
||||
return undefined;
|
||||
}
|
||||
udid = udid.toUpperCase();
|
||||
const recordingId = randomUUID();
|
||||
const output = path.join(session.recordingsRootDirectory, recordingId);
|
||||
const startedAt = new Date();
|
||||
await mkdir(output, { recursive: true });
|
||||
session.adoptedRecordings.set(recordingId, {
|
||||
udid,
|
||||
serverToken,
|
||||
async collectAsync() {
|
||||
// Keep real footage eligible for the manifest wait if serve-sim shutdown was unconfirmed.
|
||||
// The uploader preserves the manifest's partial or failed status.
|
||||
await access(path.join(output, 'session.json')).catch(() =>
|
||||
access(path.join(output, 'recording.mp4'))
|
||||
);
|
||||
session.completedRecordings.push({
|
||||
id: recordingId,
|
||||
udid: udid as IosSimulatorUuid,
|
||||
deviceName,
|
||||
runtimeDisplayName: session.runtimeDisplayNames.get(udid) ?? 'Unknown iOS runtime',
|
||||
outputDirectory: output,
|
||||
startedAt,
|
||||
getOutput: () => '',
|
||||
hasStarted: () => true,
|
||||
});
|
||||
},
|
||||
});
|
||||
return { output, recordingId };
|
||||
}
|
||||
|
||||
export async function startAsync({ env, logger }: { env: Env; logger: bunyan }): Promise<void> {
|
||||
if (activeIosSimulatorRecordingSession) {
|
||||
logger.info('iOS Simulator screen recording polling is already running.');
|
||||
@@ -149,8 +95,6 @@ export namespace IosSimulatorRecordingUtils {
|
||||
logger,
|
||||
recordingsRootDirectory,
|
||||
activeRecordings: new Map(),
|
||||
adoptedRecordings: new Map(),
|
||||
runtimeDisplayNames: new Map(),
|
||||
completedRecordings: [],
|
||||
recordingFailureCounts: new Map(),
|
||||
failedAttemptCounts: new Map(),
|
||||
@@ -246,17 +190,6 @@ export namespace IosSimulatorRecordingUtils {
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
[...session.adoptedRecordings.values()].map(recording =>
|
||||
recording.collectAsync().catch(err => {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Could not collect the warm Simulator video recording after shutdown.'
|
||||
);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const completedRecordings = [...session.completedRecordings].sort(
|
||||
(a, b) => a.startedAt.getTime() - b.startedAt.getTime()
|
||||
);
|
||||
@@ -310,9 +243,6 @@ async function pollIosSimulatorRecordingsAsync(
|
||||
break;
|
||||
}
|
||||
listDevicesErrorCount = 0;
|
||||
for (const device of bootedDevices) {
|
||||
session.runtimeDisplayNames.set(device.udid.toUpperCase(), device.runtimeDisplayName);
|
||||
}
|
||||
const readyServers = new Map(
|
||||
(await readServeSimServersAsync(SERVE_SIM_STATE_DIR)).flatMap(server =>
|
||||
server.token ? [[server.udid, server.token] as const] : []
|
||||
@@ -332,14 +262,7 @@ async function pollIosSimulatorRecordingsAsync(
|
||||
for (const device of bootedDevices) {
|
||||
const token = readyServers.get(device.udid);
|
||||
const packageSpec = serveSimPackageSpec;
|
||||
if (
|
||||
!token ||
|
||||
!packageSpec ||
|
||||
[...session.adoptedRecordings.values()].some(
|
||||
recording =>
|
||||
recording.udid === device.udid.toUpperCase() && recording.serverToken === token
|
||||
)
|
||||
) {
|
||||
if (!token || !packageSpec) {
|
||||
continue;
|
||||
}
|
||||
if (session.serveBearerTokens.get(device.udid) !== token) {
|
||||
@@ -584,19 +507,14 @@ function captureProcessOutput(recordingProcess: ChildProcess): {
|
||||
} {
|
||||
let output = '';
|
||||
let started = false;
|
||||
const captureStreamOutput = (stream: ChildProcess['stdout']): void => {
|
||||
let streamOutput = '';
|
||||
stream?.on('data', (chunk: Buffer | string) => {
|
||||
const text = chunk.toString();
|
||||
output = `${output}${text}`.slice(-16_384);
|
||||
streamOutput = `${streamOutput}${text}`;
|
||||
if (SERVE_SIM_RECORDING_STARTED_LINE.test(streamOutput)) {
|
||||
started = true;
|
||||
}
|
||||
streamOutput = streamOutput.slice(-16_384);
|
||||
});
|
||||
const appendChunk = (chunk: Buffer | string): void => {
|
||||
output = `${output}${chunk.toString()}`;
|
||||
if (output.includes(SERVE_SIM_RECORDING_STARTED)) {
|
||||
started = true;
|
||||
}
|
||||
output = output.slice(-16_384);
|
||||
};
|
||||
captureStreamOutput(recordingProcess.stdout);
|
||||
captureStreamOutput(recordingProcess.stderr);
|
||||
recordingProcess.stdout?.on('data', appendChunk);
|
||||
recordingProcess.stderr?.on('data', appendChunk);
|
||||
return { getOutput: () => output, hasStarted: () => started };
|
||||
}
|
||||
@@ -3,20 +3,18 @@ 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 { readFile, rm, writeFile } from 'node:fs/promises';
|
||||
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 { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils';
|
||||
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../serveSimMetricsRecorder');
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
@@ -146,10 +144,7 @@ test('starts serve-sim record-video and returns only a finalized recording', asy
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await setTimeout(20);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
child.stdout?.emit('data', 'Downloading');
|
||||
stderr.write('serve-sim:recording-');
|
||||
child.stdout?.emit('data', ' package');
|
||||
stderr.write('started\r\n');
|
||||
stderr.write('serve-sim:recording-started\n');
|
||||
stderr.write('x'.repeat(17_000));
|
||||
const recordings = await finishing;
|
||||
|
||||
@@ -367,32 +362,6 @@ test('keeps a recorder active after its package-manager wrapper exits', async ()
|
||||
expect(recordings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('does not mistake bundled source in an error for a recording 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();
|
||||
recorder.child.stderr?.emit(
|
||||
'data',
|
||||
'const marker = "serve-sim:recording-started";\nError: No running serve-sim session found.\n'
|
||||
);
|
||||
recorder.fail(new Error('recorder exited before connecting'));
|
||||
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
try {
|
||||
expect(await Promise.race([finishing.then(() => true), setTimeout(250, false)])).toBe(true);
|
||||
} finally {
|
||||
currentTime += 311_000;
|
||||
await finishing;
|
||||
}
|
||||
expect(recorder.child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not signal a recorder that exits before it reports its start', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
@@ -568,171 +537,3 @@ test('records every booted device with the first serve-sim package', async () =>
|
||||
currentTime += 311_000;
|
||||
await IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
});
|
||||
|
||||
test('does not request adopted video when job recording is disabled', async () => {
|
||||
await expect(
|
||||
IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
|
||||
udid,
|
||||
deviceName: 'iPhone 17 Pro',
|
||||
serverToken: 'job-token',
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each(['complete', 'failed'])(
|
||||
'collects an adopted %s recording unchanged after host shutdown without a control request',
|
||||
async status => {
|
||||
let releaseDevices!: (devices: never[]) => void;
|
||||
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
releaseDevices = resolve as never;
|
||||
})
|
||||
);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:3200', token: 'job-token' }]);
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
const recording = await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
|
||||
udid,
|
||||
deviceName: 'iPhone 17 Pro',
|
||||
serverToken: 'job-token',
|
||||
});
|
||||
if (!recording) {
|
||||
throw new Error('Expected the adopted recording output.');
|
||||
}
|
||||
releaseDevices([{ udid, name: 'iPhone 17 Pro', runtimeDisplayName: 'iOS 26.4' }] as never);
|
||||
await setTimeout(20);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
const manifest = JSON.stringify({
|
||||
recording: 'recording.mp4',
|
||||
status,
|
||||
...(status === 'failed' && { error: 'Recording interrupted; partial footage preserved.' }),
|
||||
});
|
||||
await writeFile(`${recording.output}/session.json`, manifest);
|
||||
jest.mocked(turtleFetch).mockRejectedValue(new Error('serve-sim has stopped'));
|
||||
expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toEqual([
|
||||
{
|
||||
udid,
|
||||
deviceName: 'iPhone 17 Pro',
|
||||
runtimeDisplayName: 'iOS 26.4',
|
||||
directory: recording.output,
|
||||
},
|
||||
]);
|
||||
expect(await readFile(`${recording.output}/session.json`, 'utf8')).toBe(manifest);
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
test('waits for an adopted recording manifest when its MP4 already exists', async () => {
|
||||
jest.mocked(readServeSimServersAsync).mockResolvedValue([]);
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
const recording = await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
|
||||
udid,
|
||||
deviceName: 'iPhone 17 Pro',
|
||||
serverToken: 'job-token',
|
||||
});
|
||||
if (!recording) {
|
||||
throw new Error('Expected the adopted recording output.');
|
||||
}
|
||||
await writeFile(`${recording.output}/recording.mp4`, 'partial footage');
|
||||
let finished = false;
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger }).then(recordings => {
|
||||
finished = true;
|
||||
return recordings;
|
||||
});
|
||||
await setTimeout(20);
|
||||
expect(finished).toBe(false);
|
||||
const manifest = JSON.stringify({ recording: 'recording.mp4' });
|
||||
await writeFile(`${recording.output}/session.json`, manifest);
|
||||
|
||||
expect(await finishing).toEqual([
|
||||
{
|
||||
udid,
|
||||
deviceName: 'iPhone 17 Pro',
|
||||
runtimeDisplayName: 'iOS 26.4',
|
||||
directory: recording.output,
|
||||
},
|
||||
]);
|
||||
expect(await readFile(`${recording.output}/session.json`, 'utf8')).toBe(manifest);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('bounds the manifest wait for an adopted MP4 and reports missing metadata', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
jest.mocked(readServeSimServersAsync).mockResolvedValue([]);
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
const recording = await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
|
||||
udid,
|
||||
deviceName: 'iPhone 17 Pro',
|
||||
serverToken: 'job-token',
|
||||
});
|
||||
if (!recording) {
|
||||
throw new Error('Expected the adopted recording output.');
|
||||
}
|
||||
await writeFile(`${recording.output}/recording.mp4`, 'partial footage');
|
||||
let finished = false;
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger }).then(recordings => {
|
||||
finished = true;
|
||||
return recordings;
|
||||
});
|
||||
await setTimeout(50);
|
||||
expect(finished).toBe(false);
|
||||
currentTime += 311_000;
|
||||
|
||||
expect(await finishing).toEqual([]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ recorderOutput: '' },
|
||||
'Screen recording for iPhone 17 Pro ended without a manifest; its footage cannot be uploaded.'
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'iOS Simulator screen recording ended without a manifest',
|
||||
{ level: 'warning', extras: { deviceName: 'iPhone 17 Pro' } }
|
||||
);
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not wait for an empty adopted recording reservation', async () => {
|
||||
jest.mocked(readServeSimServersAsync).mockResolvedValue([]);
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
|
||||
udid,
|
||||
deviceName: 'iPhone',
|
||||
serverToken: 'job-token',
|
||||
});
|
||||
expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toEqual([]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
'Could not collect the warm Simulator video recording after shutdown.'
|
||||
);
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a reserved adopted recording does not suppress recording a replacement server token', async () => {
|
||||
let releaseDevices!: (devices: never[]) => void;
|
||||
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
releaseDevices = resolve as never;
|
||||
})
|
||||
);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:3200', token: 'replacement-token' }]);
|
||||
mockFailingRecorder();
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
|
||||
udid,
|
||||
deviceName: 'iPhone',
|
||||
serverToken: 'old-token',
|
||||
});
|
||||
jest.mocked(turtleFetch).mockRejectedValue(new Error('old server stopped'));
|
||||
releaseDevices([{ udid, name: 'iPhone', runtimeDisplayName: 'iOS 26.4' }] as never);
|
||||
await waitForRecorderSpawnAsync();
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import fetch from 'node-fetch';
|
||||
import { Readable } from 'node:stream';
|
||||
import timers from 'node:timers/promises';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
@@ -16,7 +15,6 @@ jest.mock('../deviceRunSessionArtifacts');
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('node-fetch');
|
||||
|
||||
const sleepWithSignal = timers.setTimeout;
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
async function readStreamAsync(stream: NodeJS.ReadableStream): Promise<void> {
|
||||
@@ -278,14 +276,6 @@ describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
describe(pollAgentDeviceArtifactsForUploadAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
// Keep fake-clock tests deterministic; cancellation tests use the native abortable timer.
|
||||
jest
|
||||
.spyOn(timers, 'setTimeout')
|
||||
.mockImplementation((delay, value, options) =>
|
||||
options?.signal
|
||||
? sleepWithSignal(delay, value, options)
|
||||
: new Promise(resolve => setTimeout(resolve, delay, value))
|
||||
);
|
||||
jest.mocked(fetch).mockReset();
|
||||
jest.mocked(Sentry.capture).mockReset();
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockReset();
|
||||
@@ -294,257 +284,6 @@ describe(pollAgentDeviceArtifactsForUploadAsync, () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllTimers();
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('cancels a warm poller during its wait without another inventory request', async () => {
|
||||
jest.useRealTimers();
|
||||
const controller = new AbortController();
|
||||
jest.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ artifacts: [] })));
|
||||
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
daemonUrl: 'http://127.0.0.1:1234',
|
||||
daemonToken: 'token',
|
||||
logger: createLoggerMock(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
controller.abort();
|
||||
await polling;
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ signal: controller.signal })
|
||||
);
|
||||
});
|
||||
|
||||
it('cancels an in-flight warm artifact transfer and drains the poller', async () => {
|
||||
jest.useRealTimers();
|
||||
const controller = new AbortController();
|
||||
let downloading!: () => void;
|
||||
const started = new Promise<void>(resolve => {
|
||||
downloading = resolve;
|
||||
});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ artifacts: [{ id: 'new-artifact', filename: 'new.png' }] }))
|
||||
)
|
||||
.mockImplementationOnce(async (_url, options) => {
|
||||
const signal = options?.signal as AbortSignal;
|
||||
expect(signal).toBe(controller.signal);
|
||||
downloading();
|
||||
return await new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
});
|
||||
});
|
||||
const logger = createLoggerMock();
|
||||
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
daemonUrl: 'http://127.0.0.1:1234',
|
||||
daemonToken: 'token',
|
||||
logger,
|
||||
signal: controller.signal,
|
||||
});
|
||||
await started;
|
||||
controller.abort(new Error('session ended'));
|
||||
await polling;
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(uploadDeviceRunSessionArtifactAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('finishes an active cloud upload and collects final artifacts after normal stop', async () => {
|
||||
jest.useRealTimers();
|
||||
const stopPolling = new AbortController();
|
||||
const cancellation = new AbortController();
|
||||
let releaseUpload!: () => void;
|
||||
let uploadStarted!: () => void;
|
||||
const pendingUpload = new Promise<void>(resolve => {
|
||||
releaseUpload = resolve;
|
||||
});
|
||||
const started = new Promise<void>(resolve => {
|
||||
uploadStarted = resolve;
|
||||
});
|
||||
let inventories = 0;
|
||||
const first = { id: 'first', filename: 'first.png' };
|
||||
const last = { id: 'last', filename: 'last.png' };
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockImplementation(async url =>
|
||||
String(url).endsWith('/artifacts')
|
||||
? new Response(
|
||||
JSON.stringify({ artifacts: ++inventories === 1 ? [first] : [first, last] })
|
||||
)
|
||||
: new Response(Readable.from(['png']))
|
||||
);
|
||||
let transferSignal: AbortSignal | undefined;
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_ctx, options) => {
|
||||
await readStreamAsync(options.openStream());
|
||||
if (options.artifactId === 'first') {
|
||||
transferSignal = options.signal;
|
||||
uploadStarted();
|
||||
await pendingUpload;
|
||||
}
|
||||
});
|
||||
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
daemonUrl: 'http://127.0.0.1:1234',
|
||||
daemonToken: 'token',
|
||||
logger: createLoggerMock(),
|
||||
signal: cancellation.signal,
|
||||
stopPollingSignal: stopPolling.signal,
|
||||
});
|
||||
let finished = false;
|
||||
void polling.then(() => {
|
||||
finished = true;
|
||||
});
|
||||
try {
|
||||
await started;
|
||||
stopPolling.abort();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(transferSignal?.aborted).toBe(false);
|
||||
expect(finished).toBe(false);
|
||||
releaseUpload();
|
||||
await polling;
|
||||
expect(inventories).toBe(2);
|
||||
expect(
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mock.calls.map(([, options]) => options.artifactId)
|
||||
).toEqual(['first', 'last']);
|
||||
} finally {
|
||||
releaseUpload();
|
||||
cancellation.abort();
|
||||
await polling;
|
||||
}
|
||||
});
|
||||
|
||||
it('checks for an artifact created between the last poll and normal stop', async () => {
|
||||
jest.useRealTimers();
|
||||
const stopPolling = new AbortController();
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ artifacts: [] })))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ artifacts: [{ id: 'last', filename: 'last.png' }] }))
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(Readable.from(['png'])));
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_ctx, options) => {
|
||||
await readStreamAsync(options.openStream());
|
||||
});
|
||||
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
daemonUrl: 'http://127.0.0.1:1234',
|
||||
daemonToken: 'token',
|
||||
logger: createLoggerMock(),
|
||||
stopPollingSignal: stopPolling.signal,
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
stopPolling.abort();
|
||||
await polling;
|
||||
expect(fetch).toHaveBeenCalledTimes(3);
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ artifactId: 'last' })
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a failed final inventory after normal stop', async () => {
|
||||
jest.useRealTimers();
|
||||
const stopPolling = new AbortController();
|
||||
const logger = createLoggerMock();
|
||||
const error = new Error('daemon is not ready');
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ artifacts: [] })))
|
||||
.mockRejectedValueOnce(error);
|
||||
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
daemonUrl: 'http://127.0.0.1:1234',
|
||||
daemonToken: 'token',
|
||||
logger,
|
||||
stopPollingSignal: stopPolling.signal,
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
stopPolling.abort();
|
||||
await polling;
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: error, failedArtifactListCount: 1 },
|
||||
'Could not list agent-device remote session artifacts.'
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cancels a graceful collector immediately when the job is cancelled', async () => {
|
||||
jest.useRealTimers();
|
||||
const stopPolling = new AbortController();
|
||||
const cancellation = new AbortController();
|
||||
let entered!: () => void;
|
||||
const started = new Promise<void>(resolve => {
|
||||
entered = resolve;
|
||||
});
|
||||
jest.mocked(fetch).mockImplementation(async (_url, options) => {
|
||||
const signal = options?.signal as AbortSignal;
|
||||
entered();
|
||||
return await new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
});
|
||||
});
|
||||
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
daemonUrl: 'http://127.0.0.1:1234',
|
||||
daemonToken: 'token',
|
||||
logger: createLoggerMock(),
|
||||
signal: cancellation.signal,
|
||||
stopPollingSignal: stopPolling.signal,
|
||||
});
|
||||
await started;
|
||||
cancellation.abort();
|
||||
await polling;
|
||||
expect(stopPolling.signal.aborted).toBe(false);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cancels a stuck transfer after the normal-stop drain budget expires', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
|
||||
const stopPolling = new AbortController();
|
||||
const logger = createLoggerMock();
|
||||
let entered!: () => void;
|
||||
const started = new Promise<void>(resolve => {
|
||||
entered = resolve;
|
||||
});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ artifacts: [{ id: 'large', filename: 'large.mp4' }] }))
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(Readable.from(['video'])));
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_ctx, options) => {
|
||||
await readStreamAsync(options.openStream());
|
||||
entered();
|
||||
return await new Promise((_resolve, reject) => {
|
||||
options.signal!.addEventListener('abort', () => reject(options.signal!.reason), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
daemonUrl: 'http://127.0.0.1:1234',
|
||||
daemonToken: 'token',
|
||||
logger,
|
||||
stopPollingSignal: stopPolling.signal,
|
||||
});
|
||||
await started;
|
||||
stopPolling.abort();
|
||||
await jest.advanceTimersByTimeAsync(30_000);
|
||||
await polling;
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Timed out draining agent-device artifacts; some session artifacts may not have been uploaded.'
|
||||
);
|
||||
});
|
||||
|
||||
it('reports artifact listing errors on every fifth consecutive failure', async () => {
|
||||
|
||||
@@ -9,8 +9,10 @@ import RemoteLoggerStream from '../../../logging/RemoteLoggerStream';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../agentDeviceEvents';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
import { WorkerApiError } from '../workerApi';
|
||||
import {
|
||||
DEVICE_RUN_SESSION_TOKEN_HEADER,
|
||||
DeviceRunSessionWorkerApiError,
|
||||
} from '../deviceRunSessionWorkerApi';
|
||||
|
||||
const mockEventLogStream = {
|
||||
init: jest.fn(async () => undefined),
|
||||
@@ -575,7 +577,7 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
'Failed to create device run session event log upload session: ' +
|
||||
'POST /v2/device-run-session-worker/event-log-upload-session failed with status 503: ' +
|
||||
'WWW unavailable',
|
||||
cause: expect.any(WorkerApiError),
|
||||
cause: expect.any(DeviceRunSessionWorkerApiError),
|
||||
}),
|
||||
},
|
||||
'Could not persist device run session events to the artifact.'
|
||||
@@ -587,7 +589,7 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
'Failed to create device run session event log upload session: ' +
|
||||
'POST /v2/device-run-session-worker/event-log-upload-session failed with status 503: ' +
|
||||
'WWW unavailable',
|
||||
cause: expect.any(WorkerApiError),
|
||||
cause: expect.any(DeviceRunSessionWorkerApiError),
|
||||
}),
|
||||
{
|
||||
level: 'warning',
|
||||
|
||||
@@ -186,7 +186,6 @@ describe(runAgentAsync, () => {
|
||||
'--package-lock=false',
|
||||
'--no-audit',
|
||||
'--no-fund',
|
||||
'--no-update-notifier',
|
||||
'--registry',
|
||||
'https://npm-cache.expo.test',
|
||||
'@anthropic-ai/[email protected]',
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
import { createColdIosSessionAsync } from '../coldIosSession';
|
||||
import { spawnDetached } from '../remoteDeviceRunSession';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../remoteDeviceRunSession', () => ({
|
||||
fetchWebPreviewTurnArgsAsync: jest.fn(async () => []),
|
||||
findAvailablePortAsync: jest.fn(async () => 3200),
|
||||
spawnDetached: jest.fn(() => ({ stopAsync: jest.fn() })),
|
||||
}));
|
||||
|
||||
const STATE_FILE = '/tmp/expo-simulator-startup.json';
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as any;
|
||||
|
||||
async function startServerAsync(signal?: AbortSignal) {
|
||||
const server = await createColdIosSessionAsync({} as any, { env: {}, logger });
|
||||
return await server.startAsync({
|
||||
command: 'serve-sim',
|
||||
args: [],
|
||||
env: {},
|
||||
logger,
|
||||
secrets: [],
|
||||
stopGracePeriodMs: 1000,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: 'current-boot\n' } as never);
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
await fs.mkdir('/tmp', { recursive: true });
|
||||
await fs.writeFile(STATE_FILE, JSON.stringify({ status: 'stopping' }));
|
||||
});
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', platform);
|
||||
});
|
||||
|
||||
it('starts serve-sim only after warm Simulator cleanup finished', async () => {
|
||||
const starting = startServerAsync();
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
await fs.writeFile(STATE_FILE, JSON.stringify({ status: 'disabled', bootUuid: 'current-boot' }));
|
||||
await starting;
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not start serve-sim when the job is cancelled while cleanup runs', async () => {
|
||||
const controller = new AbortController();
|
||||
const starting = startServerAsync(controller.signal);
|
||||
// Cleanup is still running; the cancellation alone must end the wait.
|
||||
controller.abort(new Error('cancelled'));
|
||||
await expect(starting).rejects.toThrow('cancelled');
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
|
||||
import { validateControllerVersion } from '../controllerVersion';
|
||||
|
||||
describe(validateControllerVersion, () => {
|
||||
const minimumVersion = '0.16.0';
|
||||
const controller = { packageName: 'example-controller', minimumVersion };
|
||||
const warn = jest.fn();
|
||||
const logger = { warn } as unknown as bunyan;
|
||||
const supportedVersion = '999.0.0';
|
||||
const oldVersion = '0.0.0';
|
||||
|
||||
beforeEach(() => {
|
||||
warn.mockClear();
|
||||
});
|
||||
|
||||
it('allows the default latest package version', () => {
|
||||
expect(() =>
|
||||
validateControllerVersion({ ...controller, packageVersion: undefined, logger })
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
validateControllerVersion({ ...controller, packageVersion: 'latest', logger })
|
||||
).not.toThrow();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows exact supported semver versions', () => {
|
||||
expect(() =>
|
||||
validateControllerVersion({ ...controller, packageVersion: minimumVersion, logger })
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
validateControllerVersion({ ...controller, packageVersion: supportedVersion, logger })
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
validateControllerVersion({ ...controller, packageVersion: `v${supportedVersion}`, logger })
|
||||
).not.toThrow();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects exact semver versions that are too old', () => {
|
||||
expect(() =>
|
||||
validateControllerVersion({ ...controller, packageVersion: oldVersion, logger })
|
||||
).toThrow(SystemError);
|
||||
expect(() =>
|
||||
validateControllerVersion({ ...controller, packageVersion: oldVersion, logger })
|
||||
).toThrow(`Use "latest" or pass an exact version >= ${minimumVersion}`);
|
||||
});
|
||||
|
||||
it('warns for package tags or ranges that cannot be verified', () => {
|
||||
expect(() =>
|
||||
validateControllerVersion({ ...controller, packageVersion: 'next', logger })
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
validateControllerVersion({ ...controller, packageVersion: `^${minimumVersion}`, logger })
|
||||
).not.toThrow();
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Continuing and letting the package manager resolve it.')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -18,14 +18,7 @@ import {
|
||||
} from '../deviceRunSessionPreview';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
|
||||
jest.mock('node-fetch', () => {
|
||||
const actual = jest.requireActual('node-fetch');
|
||||
return {
|
||||
__esModule: true,
|
||||
...actual,
|
||||
default: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.mock('node-fetch');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
@@ -94,8 +87,8 @@ describe(startDeviceRunSessionPreview, () => {
|
||||
'https://uploads.expo.test/preview',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: expect.any(Readable),
|
||||
headers: { 'content-type': 'image/webp', 'content-length': String(image.length) },
|
||||
body: image,
|
||||
headers: { 'content-type': 'image/webp' },
|
||||
})
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(59_999);
|
||||
@@ -137,7 +130,9 @@ describe(startDeviceRunSessionPreview, () => {
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
const [fields] = jest.mocked(logger.warn).mock.calls[0] as unknown as [{ err: Error }];
|
||||
expect(fields.err.message).toBe('Signed URL upload failed: network error.');
|
||||
expect(fields.err.message).toBe(
|
||||
'Session preview upload request failed (name: FetchError, type: request-timeout).'
|
||||
);
|
||||
expect(JSON.stringify(jest.mocked(logger.warn).mock.calls)).not.toContain('uploads.expo.test');
|
||||
});
|
||||
|
||||
|
||||
+6
-35
@@ -1,7 +1,7 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import type { BuildStepEnv } from '@expo/steps';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { copyFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -40,14 +40,7 @@ beforeEach(async () => {
|
||||
creationTokens.push(request.headers['expo-device-run-session-token'] as string | undefined);
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
uploadSession: {
|
||||
url: `${url}/artifact/${encodeURIComponent(JSON.parse(body).filename)}`,
|
||||
headers: {},
|
||||
},
|
||||
},
|
||||
})
|
||||
JSON.stringify({ data: { uploadSession: { url: `${url}/artifact`, headers: {} } } })
|
||||
);
|
||||
});
|
||||
} else {
|
||||
@@ -163,14 +156,10 @@ it('recovers unlisted recordings from a killed host and flags the unfinished one
|
||||
]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('empty/recording.mp4.partial'));
|
||||
|
||||
const uploaded = new Map<string, Buffer>();
|
||||
let putBytes = 0;
|
||||
handle = (request, response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
request.on('data', chunk => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
request.on('data', chunk => (putBytes += chunk.length));
|
||||
request.on('end', () => {
|
||||
uploaded.set(request.url!, Buffer.concat(chunks));
|
||||
response.statusCode = 200;
|
||||
response.end();
|
||||
});
|
||||
@@ -182,18 +171,10 @@ it('recovers unlisted recordings from a killed host and flags the unfinished one
|
||||
recordings,
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
for (const recording of recordings) {
|
||||
const manifest = await readFile(path.join(recording.directory, 'session.json'));
|
||||
const recordingId = path.basename(recording.directory);
|
||||
expect(uploaded.get(`/artifact/${recordingId}.json`)).toEqual(manifest);
|
||||
expect(uploaded.get(`/artifact/${recordingId}.mp4`)).toEqual(
|
||||
await readFile(path.join(recording.directory, JSON.parse(manifest.toString()).recording))
|
||||
);
|
||||
}
|
||||
expect(creationTokens).toEqual(Array(6).fill('session-token'));
|
||||
expect(putBytes).toBe(3 * (await stat(path.join(cut, 'recording.mp4.partial'))).size);
|
||||
expect(creationTokens).toEqual(['session-token', 'session-token', 'session-token']);
|
||||
const inputs = creationBodies
|
||||
.map(body => JSON.parse(body))
|
||||
.filter(input => input.kind === 'screen-recording')
|
||||
.sort((a, b) => a.filename.localeCompare(b.filename));
|
||||
expect(inputs.map(input => input.filename)).toEqual(['cut.mp4', 'done.mp4', 'failed.mp4']);
|
||||
expect(inputs.map(input => input.name.includes(', partial)'))).toEqual([true, false, true]);
|
||||
@@ -204,16 +185,6 @@ it('recovers unlisted recordings from a killed host and flags the unfinished one
|
||||
width: 128,
|
||||
height: 96,
|
||||
});
|
||||
const manifests = creationBodies
|
||||
.map(body => JSON.parse(body))
|
||||
.filter(input => input.kind === 'screen-recording-metadata')
|
||||
.sort((a, b) => a.filename.localeCompare(b.filename));
|
||||
expect(manifests.map(input => input.filename)).toEqual(['cut.json', 'done.json', 'failed.json']);
|
||||
expect(manifests.map(input => input.metadata.recordingFilename)).toEqual([
|
||||
'cut.mp4',
|
||||
'done.mp4',
|
||||
'failed.mp4',
|
||||
]);
|
||||
expect(inputs[2].metadata).toMatchObject({
|
||||
partial: true,
|
||||
partialReason: 'Recording finalization exceeded 30000 ms.',
|
||||
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { vol } from 'memfs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import { uploadDeviceRunSessionScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
|
||||
|
||||
jest.mock('../deviceRunSessionArtifacts');
|
||||
jest.mock('../../../sentry');
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const recording = {
|
||||
udid: 'simulator-id',
|
||||
deviceName: 'iPhone Duo',
|
||||
runtimeDisplayName: 'iOS 27.1',
|
||||
directory: '/recordings/recording-id',
|
||||
};
|
||||
const manifest = {
|
||||
firstFrameWallClock: { unixMs: 1_791_072_000_000, iso8601: '2026-10-04T00:00:00.000Z' },
|
||||
width: 1440,
|
||||
height: 1200,
|
||||
recording: 'recording.mp4',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{},
|
||||
{
|
||||
deviceStates: [
|
||||
{
|
||||
timeMs: 12.5,
|
||||
state: {
|
||||
width: 1200,
|
||||
height: 1440,
|
||||
orientation: 'landscape-left',
|
||||
screen: 'inner',
|
||||
hingeAngle: 90,
|
||||
physicalOrientation: 'portrait',
|
||||
tableMode: false,
|
||||
contentRect: { x: 220, y: 0, width: 1000, height: 1200 },
|
||||
},
|
||||
},
|
||||
],
|
||||
interruptions: [{ reason: 'simulatorReboot', pausedAtSeconds: 1, resumedAtSeconds: 2 }],
|
||||
futureField: { label: '서울 🌲' },
|
||||
},
|
||||
])('uploads the complete manifest unchanged beside its video (%j)', async extra => {
|
||||
const contents = Buffer.from(JSON.stringify({ ...manifest, ...extra }, null, 2) + '\n');
|
||||
vol.fromJSON({
|
||||
[recording.directory + '/session.json']: contents.toString(),
|
||||
[recording.directory + '/recording.mp4']: 'video',
|
||||
});
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_, input) => {
|
||||
if (input.kind === 'screen-recording') {
|
||||
await writeFile(recording.directory + '/session.json', '{}');
|
||||
}
|
||||
});
|
||||
|
||||
await expect(
|
||||
uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: 'session-id',
|
||||
recordings: [recording],
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
|
||||
const inputs = jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mock.calls.map(([, input]) => input);
|
||||
expect(inputs.map(input => input.filename)).toEqual(['recording-id.mp4', 'recording-id.json']);
|
||||
expect(inputs[1]).toMatchObject({
|
||||
deviceRunSessionId: 'session-id',
|
||||
artifactId: 'recording-id-metadata',
|
||||
kind: 'screen-recording-metadata',
|
||||
metadata: {
|
||||
__eas_type: 'screen-recording-metadata',
|
||||
recordingFilename: 'recording-id.mp4',
|
||||
udid: recording.udid,
|
||||
},
|
||||
size: contents.length,
|
||||
});
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of inputs[1].openStream()) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
expect(Buffer.concat(chunks)).toEqual(contents);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a metadata upload failure while keeping the video and uploading other recordings', async () => {
|
||||
const other = { ...recording, directory: '/recordings/other-recording', udid: 'other-device' };
|
||||
for (const item of [recording, other]) {
|
||||
vol.fromJSON({
|
||||
[item.directory + '/session.json']: JSON.stringify(manifest),
|
||||
[item.directory + '/recording.mp4']: 'video',
|
||||
});
|
||||
}
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_, input) => {
|
||||
if (input.filename === 'recording-id.json') {
|
||||
throw new Error('Storage unavailable');
|
||||
}
|
||||
});
|
||||
|
||||
await expect(
|
||||
uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: 'session-id',
|
||||
recordings: [recording, other],
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls.map(([, input]) => input.filename)
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
'recording-id.mp4',
|
||||
'recording-id.json',
|
||||
'other-recording.mp4',
|
||||
'other-recording.json',
|
||||
])
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not upload device screen recording metadata',
|
||||
expect.any(Error)
|
||||
);
|
||||
expect(Sentry.capture).not.toHaveBeenCalledWith(
|
||||
'Could not upload device screen recording',
|
||||
expect.any(Error)
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
`Could not upload screen recording metadata for ${recording.deviceName}.`
|
||||
);
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { watchDeviceRunSessionStartup } from '../deviceRunSessionStartup';
|
||||
import {
|
||||
DeviceRunSessionStoppedError,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../remoteDeviceRunSession';
|
||||
import { createStartupTasks } from '../startupTasks';
|
||||
|
||||
jest.mock('../remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../remoteDeviceRunSession'),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
child: jest.fn().mockReturnThis(),
|
||||
} as unknown as bunyan;
|
||||
|
||||
/** Makes the status poll settle only when the test says so, or end when its signal aborts. */
|
||||
function holdStatusPoll(): { stop: () => void; fail: (error: Error) => void } {
|
||||
let stop!: () => void;
|
||||
let fail!: (error: Error) => void;
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(
|
||||
({ signal }) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
stop = resolve;
|
||||
fail = reject;
|
||||
signal?.addEventListener('abort', () => resolve(), { once: true });
|
||||
})
|
||||
);
|
||||
return { stop: () => stop(), fail: error => fail(error) };
|
||||
}
|
||||
|
||||
function watch(startup = createStartupTasks(logger)) {
|
||||
return {
|
||||
startup,
|
||||
watch: watchDeviceRunSessionStartup({ ctx, deviceRunSessionId: 'drs-id', logger, startup }),
|
||||
};
|
||||
}
|
||||
|
||||
describe(watchDeviceRunSessionStartup, () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('aborts startup when the session is stopped', async () => {
|
||||
const poll = holdStatusPoll();
|
||||
const { startup, watch: startupWatch } = watch();
|
||||
poll.stop();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(startupWatch.stopped).toBeInstanceOf(DeviceRunSessionStoppedError);
|
||||
expect(startup.signal.reason).toBe(startupWatch.stopped);
|
||||
expect(startupWatch.isStopped()).toBe(true);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Device run session drs-id was stopped during startup.'
|
||||
);
|
||||
await expect(startupWatch.completeAsync()).rejects.toBe(startupWatch.stopped);
|
||||
});
|
||||
|
||||
it('ends the poll when startup completes, without aborting startup', async () => {
|
||||
holdStatusPoll();
|
||||
const { startup, watch: startupWatch } = watch();
|
||||
await expect(startupWatch.completeAsync()).resolves.toBeUndefined();
|
||||
const [{ signal }] = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0];
|
||||
expect(signal?.aborted).toBe(true);
|
||||
expect(startup.signal.aborted).toBe(false);
|
||||
expect(startupWatch.isStopped()).toBe(false);
|
||||
});
|
||||
|
||||
it('aborts startup with a status poll failure', async () => {
|
||||
const poll = holdStatusPoll();
|
||||
const { startup, watch: startupWatch } = watch();
|
||||
const failed = new Error('status poll failed');
|
||||
poll.fail(failed);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(startup.signal.reason).toBe(failed);
|
||||
expect(startupWatch.isStopped()).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the first abort reason when startup ends for another reason', async () => {
|
||||
holdStatusPoll();
|
||||
const { startup, watch: startupWatch } = watch();
|
||||
const failed = new Error('host startup failed');
|
||||
startup.abort(failed);
|
||||
await startupWatch.finishAsync();
|
||||
expect(startup.signal.reason).toBe(failed);
|
||||
expect(startupWatch.isStopped()).toBe(false);
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('follows the step signal', async () => {
|
||||
holdStatusPoll();
|
||||
const step = new AbortController();
|
||||
const { startup, watch: startupWatch } = watch(createStartupTasks(logger, step.signal));
|
||||
step.abort(new Error('step cancelled'));
|
||||
await startupWatch.finishAsync();
|
||||
expect(startup.signal.aborted).toBe(true);
|
||||
expect(startupWatch.isStopped()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5,12 +5,12 @@ import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import {
|
||||
DEVICE_RUN_SESSION_TOKEN_HEADER,
|
||||
DeviceRunSessionWorkerApi,
|
||||
DeviceRunSessionWorkerApiError,
|
||||
getDeviceRunSessionWorkerApi,
|
||||
requireDeviceRunSessionWorkerApi,
|
||||
} from '../deviceRunSessionWorkerApi';
|
||||
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.unmock('node-fetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
type Response = InstanceType<typeof Response>;
|
||||
@@ -63,6 +63,23 @@ describe(getDeviceRunSessionWorkerApi, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('derives the API v2 base URL from __API_SERVER_URL when the context has none', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
|
||||
const api = getDeviceRunSessionWorkerApi({
|
||||
env: { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN, __API_SERVER_URL: 'https://api.expo.test' },
|
||||
} as unknown as CustomBuildContext);
|
||||
|
||||
await api!.getSessionAsync();
|
||||
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/device-run-session-worker/session',
|
||||
'GET',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a system error when the token is set but no API URL is known', () => {
|
||||
expect(() =>
|
||||
getDeviceRunSessionWorkerApi({
|
||||
@@ -245,8 +262,100 @@ describe(DeviceRunSessionWorkerApi, () => {
|
||||
it('rejects an application response without the build field', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { platform: 'ios' } }));
|
||||
|
||||
await expect(createApi().getApplicationAsync({ retries: 3 })).rejects.toThrow(
|
||||
await expect(createApi().getApplicationAsync()).rejects.toThrow(
|
||||
'GET /v2/device-run-session-worker/application returned an unexpected response'
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the signal, timeout, and retries to the request', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { iceServers: [] } }));
|
||||
const controller = new AbortController();
|
||||
|
||||
await createApi().fetchTurnIceServersAsync({
|
||||
signal: controller.signal,
|
||||
timeoutMs: 5000,
|
||||
retries: 1,
|
||||
});
|
||||
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'POST',
|
||||
expect.objectContaining({ signal: controller.signal, timeout: 5000, retries: 1 })
|
||||
);
|
||||
});
|
||||
|
||||
it('reports the API v2 error message and status without the token', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
code: 'UNAUTHORIZED_ERROR',
|
||||
message: 'This device run session no longer accepts worker requests.',
|
||||
},
|
||||
],
|
||||
},
|
||||
403
|
||||
)
|
||||
);
|
||||
|
||||
const error = await createApi()
|
||||
.stopAsync()
|
||||
.catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
|
||||
expect(error.status).toBe(403);
|
||||
expect(error.message).toBe(
|
||||
'POST /v2/device-run-session-worker/stop failed with status 403: This device run session no longer accepts worker requests.'
|
||||
);
|
||||
expect(error.message).not.toContain(TOKEN);
|
||||
});
|
||||
|
||||
it('reports the status text when the error response is not an API v2 error', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(
|
||||
new Response('<html>bad gateway</html>', { status: 502, statusText: 'Bad Gateway' })
|
||||
);
|
||||
|
||||
const error = await createApi()
|
||||
.getSessionAsync()
|
||||
.catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
|
||||
expect(error.status).toBe(502);
|
||||
expect(error.message).toBe(
|
||||
'GET /v2/device-run-session-worker/session failed with status 502: Bad Gateway'
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a request that failed before www responded', async () => {
|
||||
jest.mocked(turtleFetch).mockRejectedValueOnce(new Error('socket hang up'));
|
||||
|
||||
const error = await createApi()
|
||||
.getSessionAsync()
|
||||
.catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
|
||||
expect(error.status).toBeUndefined();
|
||||
expect(error.message).toContain('failed before www responded: socket hang up');
|
||||
});
|
||||
|
||||
it('rejects a response that does not match the route contract', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'STOPPED' } }));
|
||||
|
||||
await expect(createApi().getSessionAsync()).rejects.toThrow(
|
||||
'GET /v2/device-run-session-worker/session returned an unexpected response'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a successful response that is not JSON', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('ok', { status: 200 }));
|
||||
|
||||
await expect(createApi().getSessionAsync()).rejects.toThrow(
|
||||
'GET /v2/device-run-session-worker/session returned a response that is not JSON.'
|
||||
);
|
||||
});
|
||||
});
|
||||
+4
-100
@@ -7,7 +7,6 @@ import { setImmediate } from 'node:timers/promises';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import RemoteLoggerStream from '../../../logging/RemoteLoggerStream';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { downloadBuildAsync } from '../../functions/downloadBuild';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
@@ -152,11 +151,10 @@ describe('with a device run session token', () => {
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries a transient failure when starting the session through the worker API', async () => {
|
||||
it('starts the session through the worker API', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'Service unavailable' }] }, 503))
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
@@ -167,57 +165,9 @@ describe('with a device run session token', () => {
|
||||
});
|
||||
|
||||
expectWorkerApiCall('start', 'POST', { json: { remoteConfig: { type: 'agent-device' } } });
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(2);
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles the structured stopped code without matching the message', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
errors: [
|
||||
{ code: 'DEVICE_RUN_SESSION_ALREADY_STOPPED', message: 'A different explanation.' },
|
||||
],
|
||||
},
|
||||
400
|
||||
)
|
||||
);
|
||||
await expect(
|
||||
uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
remoteConfig: {},
|
||||
logger,
|
||||
})
|
||||
).resolves.toBe('stopped');
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Device run session drs-id was stopped during startup.'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([undefined, 'VALIDATION_ERROR'])('preserves an ordinary 400 with code %s', async code => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{ errors: [{ code, message: 'Device run session has already stopped.' }] },
|
||||
400
|
||||
)
|
||||
);
|
||||
await expect(
|
||||
uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
remoteConfig: {},
|
||||
logger,
|
||||
})
|
||||
).rejects.toBeInstanceOf(SystemError);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fails the start with a system error that does not contain the token', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest
|
||||
@@ -263,52 +213,10 @@ describe('with a device run session token', () => {
|
||||
'--turn-credential',
|
||||
'credential',
|
||||
]);
|
||||
expectWorkerApiCall('turn-ice-servers', 'POST', { timeout: 5000, retries: 0 });
|
||||
expectWorkerApiCall('turn-ice-servers', 'POST', { timeout: 5000, retries: 1 });
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles a stopped-session TURN response without a warning or retry', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{ errors: [{ code: 'DEVICE_RUN_SESSION_ALREADY_STOPPED', message: 'Stopped.' }] },
|
||||
400
|
||||
)
|
||||
);
|
||||
|
||||
await expect(fetchWebPreviewTurnArgsAsync(ctx, { logger })).resolves.toEqual([]);
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Device run session was stopped before TURN credentials were fetched.'
|
||||
);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([undefined, 'VALIDATION_ERROR'])(
|
||||
'still warns on a TURN failure with code %s',
|
||||
async code => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{ errors: [{ code, message: 'Device run session has already stopped.' }] },
|
||||
400
|
||||
)
|
||||
);
|
||||
|
||||
await expect(fetchWebPreviewTurnArgsAsync(ctx, { logger })).resolves.toEqual([]);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalled();
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('creates artifact upload sessions through the worker API', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
@@ -454,9 +362,7 @@ describe('with a device run session token', () => {
|
||||
robotAccessToken: ROBOT_ACCESS_TOKEN,
|
||||
extensions: ['app'],
|
||||
})
|
||||
).rejects.toThrow(
|
||||
`Failed to download the application archive for build ${BUILD_ID} (HTTP 500): stop here`
|
||||
);
|
||||
).rejects.toThrow('Unexpected response from server (500): stop here');
|
||||
|
||||
expectWorkerApiCall('application', 'GET');
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
@@ -681,9 +587,7 @@ describe('without a device run session token', () => {
|
||||
robotAccessToken: ROBOT_ACCESS_TOKEN,
|
||||
extensions: ['app'],
|
||||
})
|
||||
).rejects.toThrow(
|
||||
`Failed to download the application archive for build ${BUILD_ID} (HTTP 500): stop here`
|
||||
);
|
||||
).rejects.toThrow('Unexpected response from server (500)');
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalled();
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { isPreinstalledExpoGoAsync, readIosApplicationIdentifierAsync } from '../iosAppArtifact';
|
||||
import { readIosApplicationIdentifierAsync } from '../iosAppArtifact';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.unmock('fs');
|
||||
@@ -61,59 +61,3 @@ describe(readIosApplicationIdentifierAsync, () => {
|
||||
).rejects.toMatchObject({ errorCode: 'EAS_INSTALL_BUILD_MISSING_IDENTIFIER' });
|
||||
});
|
||||
});
|
||||
|
||||
describe(isPreinstalledExpoGoAsync, () => {
|
||||
const preinstalled = {
|
||||
bundleIdentifier: 'host.exp.Exponent',
|
||||
shortVersion: '58.0.7',
|
||||
version: '58.0.7',
|
||||
};
|
||||
function plist(values: Record<string, string>) {
|
||||
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => ({
|
||||
stdout: `${values[args[1]]}\n`,
|
||||
})) as never);
|
||||
}
|
||||
const app = (shortVersion: string, bundleIdentifier = 'host.exp.Exponent') => ({
|
||||
CFBundleIdentifier: bundleIdentifier,
|
||||
CFBundleShortVersionString: shortVersion,
|
||||
CFBundleVersion: shortVersion,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(spawn).mockReset();
|
||||
});
|
||||
|
||||
it('matches the preinstalled Expo Go without asking the Simulator', async () => {
|
||||
plist(app('58.0.7'));
|
||||
await expect(
|
||||
isPreinstalledExpoGoAsync({ appPath: '/job/Expo Go.app', preinstalled, env: {} })
|
||||
).resolves.toBe(true);
|
||||
expect(jest.mocked(spawn).mock.calls.every(([command]) => command === 'plutil')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match a different Expo Go version', async () => {
|
||||
plist(app('58.0.6'));
|
||||
await expect(
|
||||
isPreinstalledExpoGoAsync({ appPath: '/job/Expo Go.app', preinstalled, env: {} })
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('never matches a custom build, even with the same version', async () => {
|
||||
plist(app('58.0.7', 'com.example.app'));
|
||||
await expect(
|
||||
isPreinstalledExpoGoAsync({
|
||||
appPath: '/job/Example.app',
|
||||
preinstalled: { ...preinstalled, bundleIdentifier: 'com.example.app' },
|
||||
env: {},
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not match an app it cannot read', async () => {
|
||||
jest.mocked(spawn).mockRejectedValue(new Error('plutil failed'));
|
||||
await expect(
|
||||
isPreinstalledExpoGoAsync({ appPath: '/job/Expo Go.app', preinstalled, env: {} })
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -155,53 +155,6 @@ describe('serve-sim Simulator preparation', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it('preserves the Stop reason when Simulator shutdown rejects after cancellation', async () => {
|
||||
const controller = new AbortController();
|
||||
const stopped = new Error('Session stopped');
|
||||
const wrapped = new Error('Shutdown process aborted', { cause: stopped });
|
||||
let shutdownEntered!: () => void;
|
||||
const entered = new Promise<void>(resolve => {
|
||||
shutdownEntered = resolve;
|
||||
});
|
||||
jest
|
||||
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
|
||||
.mockResolvedValue({ SERVE_SIM_ADDITIONAL_DYLIBS: '/guard.dylib' });
|
||||
mockedUtils.getDeviceAsync.mockResolvedValue({ state: 'Booted' } as never);
|
||||
mockedSpawn.mockImplementationOnce((_command, _args, options) => {
|
||||
shutdownEntered();
|
||||
return new Promise((_resolve, reject) =>
|
||||
options!.signal!.addEventListener('abort', () => reject(wrapped), { once: true })
|
||||
) as ReturnType<typeof spawn>;
|
||||
});
|
||||
const running = resolveIosSessionStartupAsync({
|
||||
...hostOptions,
|
||||
bootSimulator: true,
|
||||
signal: controller.signal,
|
||||
});
|
||||
running.catch(() => {});
|
||||
await entered;
|
||||
controller.abort(stopped);
|
||||
await expect(running).rejects.toBe(stopped);
|
||||
expect(startLocalEgressGuardRelayAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves a genuine Simulator shutdown failure', async () => {
|
||||
const failure = new Error('Could not shut down Simulator');
|
||||
jest
|
||||
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
|
||||
.mockResolvedValue({ SERVE_SIM_ADDITIONAL_DYLIBS: '/guard.dylib' });
|
||||
mockedUtils.getDeviceAsync.mockResolvedValue({ state: 'Booted' } as never);
|
||||
mockedSpawn.mockRejectedValueOnce(failure);
|
||||
await expect(
|
||||
resolveIosSessionStartupAsync({
|
||||
...hostOptions,
|
||||
bootSimulator: true,
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
).rejects.toBe(failure);
|
||||
expect(startLocalEgressGuardRelayAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves an explicit Simulator without booting or configuring it', async () => {
|
||||
const startup = await resolveIosSessionStartupAsync({
|
||||
...hostOptions,
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { type Client, CombinedError } from '@urql/core';
|
||||
|
||||
import { LegacySandboxApi } from '../legacySandboxApi';
|
||||
import { WorkerApiError } from '../workerApi';
|
||||
|
||||
const ARTIFACT_ID = '0199c0de-7b3a-7c1e-8f00-1234567890ab';
|
||||
|
||||
describe(LegacySandboxApi, () => {
|
||||
let mutation: jest.Mock;
|
||||
let api: LegacySandboxApi;
|
||||
|
||||
beforeEach(() => {
|
||||
mutation = jest.fn();
|
||||
api = new LegacySandboxApi({ mutation } as unknown as Client, 'sandbox-id');
|
||||
});
|
||||
|
||||
function mockMutationResults(...results: unknown[]): void {
|
||||
for (const result of results) {
|
||||
mutation.mockReturnValueOnce({ toPromise: async () => result });
|
||||
}
|
||||
}
|
||||
|
||||
it('marks the sandbox ready and rethrows a GraphQL error', async () => {
|
||||
const error = new CombinedError({ graphQLErrors: ['Sandbox has stopped.'] });
|
||||
mockMutationResults({ data: {} }, { error });
|
||||
|
||||
await api.markReadyAsync();
|
||||
await expect(api.markReadyAsync()).rejects.toBe(error);
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sandboxId: 'sandbox-id' },
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('creates an upload session for the sandbox', async () => {
|
||||
mockMutationResults({
|
||||
data: {
|
||||
sandbox: {
|
||||
createArtifactUploadSession: {
|
||||
artifact: { id: ARTIFACT_ID },
|
||||
uploadSession: { url: 'https://uploads.expo.test/artifact', headers: { A: '1' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
api.createArtifactUploadSessionAsync({ name: 'Crash log', filename: 'crash.log', size: 5 })
|
||||
).resolves.toEqual({
|
||||
artifact: { id: ARTIFACT_ID },
|
||||
uploadSession: { url: 'https://uploads.expo.test/artifact', headers: { A: '1' } },
|
||||
});
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sandboxId: 'sandbox-id', input: { name: 'Crash log', filename: 'crash.log', size: 5 } },
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ codes: ['VALIDATION_ERROR'], code: 'VALIDATION_ERROR' },
|
||||
{ codes: ['VALIDATION_ERROR', 'UNEXPECTED_ERROR'], code: undefined },
|
||||
])(
|
||||
'reports upload session errors with codes $codes as a worker API error with code $code',
|
||||
async ({ codes, code }) => {
|
||||
mockMutationResults({
|
||||
error: new CombinedError({
|
||||
graphQLErrors: codes.map((errorCode, index) => ({
|
||||
message: `Error ${index}.`,
|
||||
extensions: { errorCode },
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
const error = await api
|
||||
.createArtifactUploadSessionAsync({ name: 'Crash log', filename: 'crash.log', size: 5 })
|
||||
.catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(WorkerApiError);
|
||||
expect(error.code).toBe(code);
|
||||
expect(error.message).toBe(codes.map((_, index) => `Error ${index}.`).join('\n'));
|
||||
}
|
||||
);
|
||||
|
||||
it('retries finalize after a server error and throws a failure that persists', async () => {
|
||||
const persistent = new CombinedError({
|
||||
graphQLErrors: ['Sandbox artifact "Crash log" has no uploaded file.'],
|
||||
response: { status: 200 },
|
||||
});
|
||||
mockMutationResults(
|
||||
{
|
||||
error: new CombinedError({
|
||||
networkError: new Error('Bad Gateway'),
|
||||
response: { status: 502 },
|
||||
}),
|
||||
},
|
||||
{ error: persistent }
|
||||
);
|
||||
|
||||
await expect(api.finalizeArtifactAsync(ARTIFACT_ID)).rejects.toBe(persistent);
|
||||
expect(mutation).toHaveBeenCalledTimes(2);
|
||||
expect(mutation).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
{ artifactId: ARTIFACT_ID },
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a finalized artifact without a download URL', async () => {
|
||||
mockMutationResults({
|
||||
data: { sandbox: { finalizeArtifact: { id: ARTIFACT_ID, downloadUrl: null } } },
|
||||
});
|
||||
|
||||
await expect(api.finalizeArtifactAsync(ARTIFACT_ID)).rejects.toThrow(
|
||||
`Finalized sandbox artifact ${ARTIFACT_ID} has no download URL.`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,873 +0,0 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import { once } from 'node:events';
|
||||
import { createServer } from 'node:http';
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
import { type AddressInfo, createConnection, createServer as createTcpServer } from 'node:net';
|
||||
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startNgrokTunnelAsync } from '../ngrokTunnel';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
jest.mock('node:timers', () => ({
|
||||
setTimeout: (...args: Parameters<typeof setTimeout>) => setTimeout(...args),
|
||||
clearTimeout: (...args: Parameters<typeof clearTimeout>) => clearTimeout(...args),
|
||||
}));
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
|
||||
const { Response } = jest.requireActual<typeof import('node-fetch')>('node-fetch');
|
||||
const url = 'https://web-preview-fixed.example.test';
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const options = {
|
||||
port: 4321,
|
||||
subdomainPrefix: 'web-preview',
|
||||
subdomainId: 'fixed',
|
||||
baseDomain: 'example.test',
|
||||
authtoken: 'token',
|
||||
rewriteHostHeader: true,
|
||||
healthCheck: { path: '/healthz' },
|
||||
logger,
|
||||
};
|
||||
|
||||
function listener(publicUrl = url) {
|
||||
return { url: () => publicUrl, close: jest.fn().mockResolvedValue(undefined) };
|
||||
}
|
||||
|
||||
let initial: ReturnType<typeof listener>;
|
||||
let replacement: ReturnType<typeof listener>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
|
||||
jest.clearAllMocks();
|
||||
initial = listener();
|
||||
replacement = listener();
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockResolvedValue(replacement as never);
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockReset()
|
||||
.mockResolvedValue(new Response(undefined, { status: 200 }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
function failPublicProbes() {
|
||||
jest.mocked(turtleFetch).mockImplementation(
|
||||
async target =>
|
||||
new Response(undefined, {
|
||||
status: target.startsWith('https:') ? 503 : 200,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
it('keeps tunnels without a health check unsupervised and closes them on stop', async () => {
|
||||
const tunnel = await startNgrokTunnelAsync({ ...options, healthCheck: undefined });
|
||||
await jest.advanceTimersByTimeAsync(90_000);
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
await tunnel.stopAsync();
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('probes the public service and keeps a healthy listener open', async () => {
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(3);
|
||||
expect(turtleFetch).toHaveBeenCalledWith(
|
||||
`${url}/healthz`,
|
||||
'GET',
|
||||
expect.objectContaining({
|
||||
retries: 0,
|
||||
redirect: 'manual',
|
||||
headers: expect.objectContaining({ 'ngrok-skip-browser-warning': 'true' }),
|
||||
})
|
||||
);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('reopens after three failed public probes with a healthy local service, preserving the endpoint', async () => {
|
||||
failPublicProbes();
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(30_000);
|
||||
expect(initial.close).not.toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
expect(jest.mocked(ngrok.forward).mock.calls[1][0]).toEqual({
|
||||
addr: expect.any(Number),
|
||||
authtoken: 'token',
|
||||
domain: 'web-preview-fixed.example.test',
|
||||
request_header_add: ['Host:localhost:4321'],
|
||||
force_new_session: true,
|
||||
});
|
||||
expect(tunnel.url).toBe(url);
|
||||
jest.mocked(turtleFetch).mockResolvedValue(new Response(undefined, { status: 200 }));
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('healthy again'));
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not reopen when the local service is unhealthy', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValue(new Response(undefined, { status: 503 }));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(90_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
expect(initial.close).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('resets consecutive failures after a successful public probe', async () => {
|
||||
failPublicProbes();
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(30_000);
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response(undefined, { status: 200 }));
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
await jest.advanceTimersByTimeAsync(30_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('retries a failed reopen with backoff', async () => {
|
||||
failPublicProbes();
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValue(replacement as never);
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
await jest.advanceTimersByTimeAsync(1_999);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('closes a listener that finishes opening after shutdown, with idempotent cleanup', async () => {
|
||||
failPublicProbes();
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
resolveOpening = resolve;
|
||||
})
|
||||
);
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
const stopping = tunnel.stopAsync();
|
||||
expect(tunnel.stopAsync()).toBe(stopping);
|
||||
const stopped = jest.fn();
|
||||
void stopping.then(stopped);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(stopped).not.toHaveBeenCalled();
|
||||
resolveOpening(replacement as never);
|
||||
await stopping;
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(120_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('waits for a replacement acquired during shutdown to finish closing', async () => {
|
||||
failPublicProbes();
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
let resolveClose!: () => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
|
||||
replacement.close.mockReturnValue(new Promise<void>(resolve => (resolveClose = resolve)));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
let stopped = false;
|
||||
const stopping = tunnel.stopAsync().then(() => {
|
||||
stopped = true;
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
resolveOpening(replacement as never);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
const stoppedBeforeClose = stopped;
|
||||
resolveClose();
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
await stopping;
|
||||
expect(stoppedBeforeClose).toBe(false);
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([0, 4, 5, 10])('retires a reopen cancelled after %s promise turns', async turns => {
|
||||
failPublicProbes();
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
resolveOpening(replacement as never);
|
||||
for (let turn = 0; turn < turns; turn++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
await tunnel.stopAsync();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries a rejected close when a reopen finishes after shutdown', async () => {
|
||||
failPublicProbes();
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
const stopping = tunnel.stopAsync();
|
||||
replacement.close.mockRejectedValueOnce(new Error('temporary unlisten failure'));
|
||||
resolveOpening(replacement as never);
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
await stopping;
|
||||
expect(replacement.close).toHaveBeenCalledTimes(2);
|
||||
const warnings = jest.mocked(logger.warn).mock.calls.length;
|
||||
await jest.advanceTimersByTimeAsync(120_000);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(warnings);
|
||||
});
|
||||
|
||||
it('fails stop if an in-flight reopen still has not settled by the stop deadline', async () => {
|
||||
failPublicProbes();
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
const rejected = expect(tunnel.stopAsync()).rejects.toThrow(
|
||||
'Ngrok tunnel web-preview-fixed.example.test stop timed out after 4000ms'
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
await rejected;
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
resolveOpening(replacement as never);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries an old listener while a reopen outlasts shutdown', async () => {
|
||||
failPublicProbes();
|
||||
initial.close.mockRejectedValueOnce(new Error('agent disconnected'));
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
const rejected = expect(tunnel.stopAsync()).rejects.toThrow(
|
||||
'Ngrok tunnel web-preview-fixed.example.test stop timed out after 4000ms'
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
await rejected;
|
||||
const oldCloseCalls = initial.close.mock.calls.length;
|
||||
resolveOpening(replacement as never);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(oldCloseCalls).toBe(2);
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('fails stop if a pending reopen arrives but cannot be retired', async () => {
|
||||
failPublicProbes();
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
|
||||
replacement.close.mockRejectedValue(new Error('unlisten failed'));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
const rejected = expect(tunnel.stopAsync()).rejects.toThrow(
|
||||
'Ngrok tunnel web-preview-fixed.example.test stop timed out'
|
||||
);
|
||||
resolveOpening(replacement as never);
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
await rejected;
|
||||
expect(replacement.close.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Could not retire late ngrok tunnel web-preview-fixed.example.test.'
|
||||
);
|
||||
const warnings = jest.mocked(logger.warn).mock.calls.length;
|
||||
await jest.advanceTimersByTimeAsync(120_000);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(warnings);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('rejects a changed public URL and closes the replacement', async () => {
|
||||
failPublicProbes();
|
||||
const changed = listener('https://different.example.test');
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockResolvedValue(changed as never);
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
expect(changed.close).toHaveBeenCalledTimes(1);
|
||||
expect(tunnel.url).toBe(url);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
expect.stringContaining('Could not reopen')
|
||||
);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('treats ngrok error responses as failures even if their status is accepted', async () => {
|
||||
jest.mocked(turtleFetch).mockImplementation(
|
||||
async target =>
|
||||
new Response(undefined, {
|
||||
status: 200,
|
||||
headers: target.startsWith('https:') ? { 'ngrok-error-code': 'ERR_NGROK_3200' } : {},
|
||||
})
|
||||
);
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reopens on a fresh agent session even if closing the old listener fails', async () => {
|
||||
failPublicProbes();
|
||||
initial.close.mockRejectedValueOnce(new Error('agent disconnected'));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
expect(ngrok.forward).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ force_new_session: true })
|
||||
);
|
||||
expect(ngrok.disconnect).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
expect(initial.close).toHaveBeenCalledTimes(2);
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries a rejected close before reopening an endpoint that remains bound', async () => {
|
||||
failPublicProbes();
|
||||
let domainOccupied = true;
|
||||
initial.close
|
||||
.mockRejectedValueOnce(new Error('agent disconnected'))
|
||||
.mockImplementation(async () => {
|
||||
domainOccupied = false;
|
||||
});
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockImplementation(async () => {
|
||||
if (domainOccupied) {
|
||||
throw new Error('endpoint is still bound');
|
||||
}
|
||||
return replacement as never;
|
||||
});
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
await jest.advanceTimersByTimeAsync(2_000);
|
||||
expect(initial.close).toHaveBeenCalledTimes(2);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(3);
|
||||
expect(tunnel.url).toBe(url);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retires an old listener even after its replacement is healthy', async () => {
|
||||
failPublicProbes();
|
||||
initial.close.mockRejectedValueOnce(new Error('agent disconnected'));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
jest.mocked(turtleFetch).mockResolvedValue(new Response(undefined, { status: 200 }));
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
expect(initial.close).toHaveBeenCalledTimes(2);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('retries an old listener without repeating its close warning on every probe', async () => {
|
||||
failPublicProbes();
|
||||
initial.close.mockRejectedValue(new Error('old session disconnected'));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
const warnings = jest.mocked(logger.warn).mock.calls.length;
|
||||
expect(warnings).toBeGreaterThan(0);
|
||||
jest.mocked(turtleFetch).mockResolvedValue(new Response(undefined, { status: 200 }));
|
||||
await jest.advanceTimersByTimeAsync(120_000);
|
||||
expect(initial.close.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(warnings);
|
||||
const rejected = expect(tunnel.stopAsync()).rejects.toThrow(
|
||||
'Ngrok tunnel web-preview-fixed.example.test stop timed out'
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
await rejected;
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries a rejected close during shutdown', async () => {
|
||||
const failure = new Error('agent disconnected');
|
||||
initial.close.mockRejectedValueOnce(failure);
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
const stopping = tunnel.stopAsync();
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
await stopping;
|
||||
expect(initial.close).toHaveBeenCalledTimes(2);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: failure },
|
||||
'Could not stop ngrok tunnel web-preview-fixed.example.test.'
|
||||
);
|
||||
});
|
||||
|
||||
it('logs the first close rejection once while bounded shutdown retries it', async () => {
|
||||
const failure = new Error('agent disconnected');
|
||||
initial.close.mockRejectedValue(failure);
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
const rejected = expect(tunnel.stopAsync()).rejects.toThrow('stop timed out');
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
await rejected;
|
||||
expect(initial.close.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: failure },
|
||||
'Could not stop ngrok tunnel web-preview-fixed.example.test.'
|
||||
);
|
||||
});
|
||||
|
||||
it('bounds shutdown during recovery without duplicating a pending native close', async () => {
|
||||
failPublicProbes();
|
||||
let finishClose!: () => void;
|
||||
initial.close.mockReturnValueOnce(new Promise<void>(resolve => (finishClose = resolve)));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
const stopping = tunnel.stopAsync();
|
||||
expect(tunnel.stopAsync()).toBe(stopping);
|
||||
const rejected = expect(stopping).rejects.toThrow(
|
||||
'Ngrok tunnel web-preview-fixed.example.test stop timed out after 4000ms'
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
await rejected;
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
const warnings = jest.mocked(logger.warn).mock.calls.length;
|
||||
finishClose();
|
||||
await jest.advanceTimersByTimeAsync(120_000);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(warnings);
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('bounds a stalled listener close before reopening', async () => {
|
||||
failPublicProbes();
|
||||
initial.close.mockImplementationOnce(async () => await new Promise(() => {}));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(49_999);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
const stopping = tunnel.stopAsync();
|
||||
const rejected = expect(stopping).rejects.toThrow(
|
||||
'Ngrok tunnel web-preview-fixed.example.test stop timed out'
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
await rejected;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not queue more native opens while a timed-out open is still pending', async () => {
|
||||
failPublicProbes();
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
resolveOpening = resolve;
|
||||
})
|
||||
)
|
||||
.mockResolvedValue(replacement as never);
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
const late = listener();
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(180_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
resolveOpening(late as never);
|
||||
await jest.advanceTimersByTimeAsync(30_000);
|
||||
expect(late.close).toHaveBeenCalledTimes(1);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('bounds initial tunnel creation and retires a late listener', async () => {
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
resolveOpening = resolve;
|
||||
})
|
||||
);
|
||||
const opening = startNgrokTunnelAsync(options);
|
||||
const rejected = expect(opening).rejects.toThrow('Ngrok tunnel open timed out');
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
await rejected;
|
||||
resolveOpening(initial as never);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries a rejected close after initial tunnel creation timed out', async () => {
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
|
||||
const opening = startNgrokTunnelAsync(options);
|
||||
const rejected = expect(opening).rejects.toThrow('Ngrok tunnel open timed out');
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
await rejected;
|
||||
initial.close.mockRejectedValueOnce(new Error('temporary unlisten failure'));
|
||||
resolveOpening(initial as never);
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
expect(initial.close).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('retires only the late listener without blaming it for an older stuck listener', async () => {
|
||||
failPublicProbes();
|
||||
initial.close.mockRejectedValue(new Error('old session disconnected'));
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)))
|
||||
.mockResolvedValue(replacement as never);
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
const late = listener();
|
||||
resolveOpening(late as never);
|
||||
jest.mocked(turtleFetch).mockResolvedValue(new Response(undefined, { status: 200 }));
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
expect(late.close).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
jest
|
||||
.mocked(logger.warn)
|
||||
.mock.calls.some(([, message]) => message?.includes('Could not retire late'))
|
||||
).toBe(false);
|
||||
} finally {
|
||||
initial.close.mockResolvedValue(undefined);
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('recovers using actual HTTP health probes with a local ngrok adapter', async () => {
|
||||
const actualFetch = jest.requireActual<typeof import('node-fetch')>('node-fetch').default;
|
||||
const realSetTimeout = jest.requireActual<typeof import('node:timers')>('node:timers').setTimeout;
|
||||
let broken = true;
|
||||
const local = createServer((_req, res) => {
|
||||
res.writeHead(200);
|
||||
res.end('healthy');
|
||||
});
|
||||
const publicServer = createServer((_req, res) => {
|
||||
res.writeHead(broken ? 503 : 200);
|
||||
res.end('probe');
|
||||
});
|
||||
await Promise.all(
|
||||
[local, publicServer].map(
|
||||
server => new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
)
|
||||
);
|
||||
const publicUrl = `http://127.0.0.1:${(publicServer.address() as AddressInfo).port}`;
|
||||
let completedRequests = 0;
|
||||
jest.mocked(turtleFetch).mockImplementation(async (target, method, requestOptions) => {
|
||||
const response = await actualFetch(target, { ...requestOptions, method });
|
||||
completedRequests++;
|
||||
return response;
|
||||
});
|
||||
const first = listener(publicUrl);
|
||||
const next = listener(publicUrl);
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(first as never)
|
||||
.mockImplementation(async () => {
|
||||
broken = false;
|
||||
return next as never;
|
||||
});
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
...options,
|
||||
port: (local.address() as AddressInfo).port,
|
||||
});
|
||||
try {
|
||||
for (let count = 0; count < 3; count++) {
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
for (let turns = 0; turns < 50 && completedRequests < (count + 1) * 2; turns++) {
|
||||
await new Promise(resolve => realSetTimeout(resolve, 10));
|
||||
}
|
||||
expect(completedRequests).toBe((count + 1) * 2);
|
||||
}
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
expect(first.close).toHaveBeenCalledTimes(1);
|
||||
expect((await actualFetch(`${tunnel.url}/healthz`)).status).toBe(200);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
await Promise.all(
|
||||
[local, publicServer].map(
|
||||
server =>
|
||||
new Promise<void>((resolve, reject) =>
|
||||
server.close(err => (err ? reject(err) : resolve()))
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
expect(next.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('blocks HTTP and existing WebSockets on Stop even when ngrok cannot close', async () => {
|
||||
jest.useRealTimers();
|
||||
const actualFetch = jest.requireActual<typeof import('node-fetch')>('node-fetch').default;
|
||||
let requests = 0;
|
||||
let messages = 0;
|
||||
const backend = createServer((_request, response) => {
|
||||
requests++;
|
||||
response.end('owned backend');
|
||||
});
|
||||
const websockets = new WebSocketServer({ server: backend });
|
||||
websockets.on('connection', socket => {
|
||||
socket.on('message', data => {
|
||||
messages++;
|
||||
socket.send(data);
|
||||
});
|
||||
});
|
||||
backend.listen(0, '127.0.0.1');
|
||||
await once(backend, 'listening');
|
||||
const backendPort = (backend.address() as AddressInfo).port;
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
...options,
|
||||
port: backendPort,
|
||||
healthCheck: undefined,
|
||||
});
|
||||
const targetPort = (jest.mocked(ngrok.forward).mock.calls[0][0] as ngrok.Config).addr;
|
||||
const targetUrl = `http://127.0.0.1:${targetPort}`;
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${targetPort}`);
|
||||
const opened = once(socket, 'open');
|
||||
try {
|
||||
expect(await (await actualFetch(targetUrl)).text()).toBe('owned backend');
|
||||
await opened;
|
||||
const echoed = once(socket, 'message');
|
||||
socket.send('before-stop');
|
||||
expect((await echoed)[0].toString()).toBe('before-stop');
|
||||
const closed = once(socket, 'close');
|
||||
initial.close.mockRejectedValue(new Error('agent disconnected'));
|
||||
const stopping = tunnel.stopAsync();
|
||||
const rejected = expect(stopping).rejects.toThrow('stop timed out after 4000ms');
|
||||
await expect(actualFetch(targetUrl, { timeout: 1_000 })).rejects.toThrow();
|
||||
await closed;
|
||||
expect(requests).toBe(1);
|
||||
expect(messages).toBe(1);
|
||||
expect(await (await actualFetch(`http://127.0.0.1:${backendPort}`)).text()).toBe(
|
||||
'owned backend'
|
||||
);
|
||||
await rejected;
|
||||
await expect(actualFetch(targetUrl, { timeout: 1_000 })).rejects.toThrow();
|
||||
expect(requests).toBe(2);
|
||||
expect(messages).toBe(1);
|
||||
const competing = createTcpServer();
|
||||
const conflict = once(competing, 'error');
|
||||
competing.listen(targetPort as number, '127.0.0.1');
|
||||
expect((await conflict)[0]).toMatchObject({ code: 'EADDRINUSE' });
|
||||
} finally {
|
||||
socket.terminate();
|
||||
for (const client of websockets.clients) {
|
||||
client.terminate();
|
||||
}
|
||||
websockets.close();
|
||||
backend.closeAllConnections();
|
||||
await new Promise<void>(resolve => backend.close(() => resolve()));
|
||||
await tunnel.stopAsync().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('releases its local target when initial tunnel creation fails', async () => {
|
||||
jest.useRealTimers();
|
||||
jest.mocked(ngrok.forward).mockReset().mockRejectedValue(new Error('forward failed'));
|
||||
await expect(startNgrokTunnelAsync(options)).rejects.toThrow('forward failed');
|
||||
const targetPort = (jest.mocked(ngrok.forward).mock.calls[0][0] as ngrok.Config).addr;
|
||||
const server = createServer();
|
||||
server.listen(targetPort as number, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('preserves a full response for a half-closed slow reader', async () => {
|
||||
jest.useRealTimers();
|
||||
const payload = Buffer.alloc(4 * 1024 * 1024, 37);
|
||||
const backend = createTcpServer({ allowHalfOpen: true }, socket => {
|
||||
socket.resume();
|
||||
socket.on('end', () => socket.end(payload));
|
||||
});
|
||||
backend.listen(0, '127.0.0.1');
|
||||
await once(backend, 'listening');
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
...options,
|
||||
port: (backend.address() as AddressInfo).port,
|
||||
healthCheck: undefined,
|
||||
});
|
||||
const targetPort = (jest.mocked(ngrok.forward).mock.calls[0][0] as ngrok.Config).addr as number;
|
||||
const socket = createConnection({ host: '127.0.0.1', port: targetPort, allowHalfOpen: true });
|
||||
try {
|
||||
const chunks: Buffer[] = [];
|
||||
socket.on('data', chunk => chunks.push(chunk));
|
||||
const ended = once(socket, 'end');
|
||||
socket.pause();
|
||||
socket.end('request');
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
socket.resume();
|
||||
await ended;
|
||||
expect(Buffer.concat(chunks).equals(payload)).toBe(true);
|
||||
} finally {
|
||||
socket.destroy();
|
||||
await tunnel.stopAsync();
|
||||
await new Promise<void>(resolve => backend.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['resolve', 'reject'])(
|
||||
'releases the target after timed-out startup later %s',
|
||||
async outcome => {
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
let rejectOpening!: (error: Error) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockReturnValue(
|
||||
new Promise((resolve, reject) => {
|
||||
resolveOpening = resolve;
|
||||
rejectOpening = reject;
|
||||
})
|
||||
);
|
||||
const opening = startNgrokTunnelAsync(options);
|
||||
const rejected = expect(opening).rejects.toThrow('open timed out');
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
await rejected;
|
||||
const targetPort = (jest.mocked(ngrok.forward).mock.calls[0][0] as ngrok.Config).addr as number;
|
||||
if (outcome === 'resolve') {
|
||||
resolveOpening(replacement as never);
|
||||
} else {
|
||||
rejectOpening(new Error('forward failed'));
|
||||
}
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
const server = createTcpServer();
|
||||
server.listen(targetPort, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
}
|
||||
);
|
||||
|
||||
it('drains a full upload when the backend ends its response early', async () => {
|
||||
jest.useRealTimers();
|
||||
const payload = Buffer.alloc(4 * 1024 * 1024, 51);
|
||||
let received!: Buffer;
|
||||
let receivedRequest!: () => void;
|
||||
const completed = new Promise<void>(resolve => {
|
||||
receivedRequest = resolve;
|
||||
});
|
||||
const backend = createTcpServer({ allowHalfOpen: true }, socket => {
|
||||
const chunks: Buffer[] = [];
|
||||
socket.on('data', chunk => chunks.push(chunk));
|
||||
socket.on('end', () => {
|
||||
received = Buffer.concat(chunks);
|
||||
receivedRequest();
|
||||
});
|
||||
socket.pause();
|
||||
socket.end('early response');
|
||||
setTimeout(() => socket.resume(), 100);
|
||||
});
|
||||
backend.listen(0, '127.0.0.1');
|
||||
await once(backend, 'listening');
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
...options,
|
||||
port: (backend.address() as AddressInfo).port,
|
||||
healthCheck: undefined,
|
||||
});
|
||||
const targetPort = (jest.mocked(ngrok.forward).mock.calls[0][0] as ngrok.Config).addr as number;
|
||||
const socket = createConnection({ host: '127.0.0.1', port: targetPort, allowHalfOpen: true });
|
||||
try {
|
||||
socket.resume();
|
||||
socket.end(payload);
|
||||
await completed;
|
||||
expect(received.equals(payload)).toBe(true);
|
||||
} finally {
|
||||
socket.destroy();
|
||||
await tunnel.stopAsync();
|
||||
await new Promise<void>(resolve => backend.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import * as processes from '../../../utils/processes';
|
||||
import { preparePreinstalledWdaAsync } from '../preinstalledWda';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
@@ -79,37 +78,3 @@ it('does not enable preinstalled startup when the download produces no runner',
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '' } as never);
|
||||
await expect(preparePreinstalledWdaAsync(options)).resolves.toEqual({});
|
||||
});
|
||||
|
||||
it('drains an aborted WDA downloader before removing its partial artifact', async () => {
|
||||
await fs.promises.mkdir(outdir, { recursive: true });
|
||||
const controller = new AbortController();
|
||||
const error = new Error('Simulator startup failed');
|
||||
const child = { pid: 12345, kill: jest.fn() };
|
||||
let closeDownloader!: (error: Error) => void;
|
||||
jest.mocked(spawn).mockReturnValueOnce(
|
||||
Object.assign(
|
||||
new Promise((_resolve, reject) => {
|
||||
closeDownloader = reject;
|
||||
}),
|
||||
{ child }
|
||||
) as never
|
||||
);
|
||||
const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
|
||||
const running = preparePreinstalledWdaAsync({ ...options, signal: controller.signal });
|
||||
const settled = jest.fn();
|
||||
const observed = running.then(settled, settled);
|
||||
try {
|
||||
controller.abort(error);
|
||||
expect(killGroup).toHaveBeenCalledWith(child, 'SIGKILL');
|
||||
expect(settled).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(outdir)).toBe(true);
|
||||
closeDownloader(new Error('download interrupted'));
|
||||
await expect(running).rejects.toBe(error);
|
||||
expect(fs.existsSync(outdir)).toBe(false);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
closeDownloader(error);
|
||||
await observed;
|
||||
killGroup.mockRestore();
|
||||
}
|
||||
});
|
||||
@@ -18,7 +18,6 @@ import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import * as processes from '../../../utils/processes';
|
||||
import {
|
||||
IosSimulatorRecordingUtils,
|
||||
SERVE_SIM_STOP_GRACE_PERIOD_MS,
|
||||
@@ -530,22 +529,6 @@ describe(describeServeSimLaunch, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('only pins hosts when a valid device ID is supplied', () => {
|
||||
const udid = 'AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA';
|
||||
expect(createServeSimArgs({ port: 4321, iosSimulatorUdid: udid })).toEqual(
|
||||
expect.arrayContaining(['--single-device', udid])
|
||||
);
|
||||
for (const iosSimulatorUdid of [undefined, '', 'booted', 'iPhone 17']) {
|
||||
expect(createServeSimArgs({ port: 4321, iosSimulatorUdid })).not.toContain('--single-device');
|
||||
}
|
||||
expect(createExpoDeviceHubArgs({ port: 4321, device: 'emulator-5554' })).toEqual(
|
||||
expect.arrayContaining(['--single-device', 'emulator-5554'])
|
||||
);
|
||||
for (const device of [undefined, '', 'all', 'booted', 'invalid serial']) {
|
||||
expect(createExpoDeviceHubArgs({ port: 4321, device })).not.toContain('--single-device');
|
||||
}
|
||||
});
|
||||
|
||||
describe(createServeSimArgs, () => {
|
||||
it('uses the latest Expo package and applies the EAS streaming policy', () => {
|
||||
expect(
|
||||
@@ -677,14 +660,6 @@ describe(createServeSimArgs, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('passes a capture proxy as one argument, including when capture will be enabled later', () => {
|
||||
const proxy = 'http://user:p%40ss%[email protected]:8899';
|
||||
const args = createServeSimArgs({ port: 4321, networkCaptureProxy: proxy });
|
||||
expect(args.slice(-2)).toEqual(['--network-capture-proxy', proxy]);
|
||||
expect(args).not.toContain('--network-capture');
|
||||
expect(createServeSimArgs({ port: 4321 })).not.toContain('--network-capture-proxy');
|
||||
});
|
||||
|
||||
it('appends --network-capture when enabled, which also covers an already booted simulator', () => {
|
||||
const args = createServeSimArgs({ port: 4321, networkCapture: true });
|
||||
expect(args).toContain('--network-capture');
|
||||
@@ -932,7 +907,7 @@ describe(waitForWebPreviewReadyAsync, () => {
|
||||
|
||||
describe(startNgrokTunnelAsync, () => {
|
||||
it.each(['rejected', 'stalled'] as const)(
|
||||
'bounds %s listener retirement and reuses the stop promise',
|
||||
'retains a %s close failure on repeated stops',
|
||||
async mode => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
@@ -954,12 +929,14 @@ describe(startNgrokTunnelAsync, () => {
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
const stopping = tunnel.stopAsync();
|
||||
const assertion = expect(stopping).rejects.toThrow(/stop timed out after 4000ms/);
|
||||
const assertion = expect(stopping).rejects.toThrow(
|
||||
mode === 'rejected' ? 'close failed' : 'Ngrok tunnel stop timed out after 4000ms.'
|
||||
);
|
||||
expect(tunnel.stopAsync()).toBe(stopping);
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
await assertion;
|
||||
await expect(tunnel.stopAsync()).rejects.toThrow();
|
||||
expect(close).toHaveBeenCalled();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
@@ -983,7 +960,7 @@ describe(startNgrokTunnelAsync, () => {
|
||||
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
addr: expect.any(Number),
|
||||
addr: 4321,
|
||||
authtoken: 'token',
|
||||
domain: expect.stringMatching(/^web-preview-[a-f0-9]{32}\.eas-simulator\.ngrok\.dev$/),
|
||||
})
|
||||
@@ -1087,13 +1064,7 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockReset();
|
||||
jest.mocked(spawn).mockReset();
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
url: () => 'https://preview.example.test',
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
jest.mocked(ngrok.forward).mockReset();
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
@@ -1156,7 +1127,6 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
it('gives serve-sim its recording grace period on shutdown', async () => {
|
||||
const spawnDetachedSpy = jest.spyOn(remoteDeviceRunSession, 'spawnDetached');
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
@@ -1174,7 +1144,6 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
'passes private sharing flags directly to serve-sim version %s',
|
||||
async packageVersion => {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
@@ -1203,7 +1172,6 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
const privatePreviewUrl =
|
||||
'https://expo.dev/accounts/owner/projects/app/simulator-sessions/session-id/preview';
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
@@ -1211,7 +1179,7 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
disableShare: true,
|
||||
privatePreviewUrl,
|
||||
});
|
||||
const preview = await host.openPreviewAsync();
|
||||
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
|
||||
|
||||
const serveSimArgs = jest
|
||||
.mocked(spawn)
|
||||
@@ -1231,14 +1199,13 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
disableShare: true,
|
||||
});
|
||||
const preview = await host.openPreviewAsync();
|
||||
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
|
||||
|
||||
const serveSimArgs = jest
|
||||
.mocked(spawn)
|
||||
@@ -1265,14 +1232,13 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>);
|
||||
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
packageVersion,
|
||||
});
|
||||
const preview = await host.openPreviewAsync();
|
||||
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
|
||||
|
||||
const spawnCalls = jest.mocked(spawn).mock.calls;
|
||||
expect(spawnCalls[0]).toEqual([
|
||||
@@ -1311,10 +1277,7 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
recordingDirectory,
|
||||
}),
|
||||
]);
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ addr: expect.any(Number) })
|
||||
);
|
||||
expect(ngrok.forward).not.toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(preview.apiUrl).toBe('https://android-preview.example.test');
|
||||
|
||||
await host.finishAsync();
|
||||
@@ -1329,13 +1292,12 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
} as never);
|
||||
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const preview = await host.openPreviewAsync();
|
||||
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
|
||||
|
||||
expect(preview.previewToken).toBe('tok-1');
|
||||
expect(preview.apiUrl).toBe('https://preview.example.test');
|
||||
@@ -1365,13 +1327,12 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
} as never);
|
||||
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: { ...env, EXPO_STAGING: '1' },
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const preview = await host.openPreviewAsync();
|
||||
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
|
||||
|
||||
expect(preview.previewPageUrl).toMatch(
|
||||
/^https:\/\/staging\.expo\.dev\/simulator-preview\/[a-f0-9]{32}$/
|
||||
@@ -1391,7 +1352,6 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
|
||||
await expect(
|
||||
startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
@@ -1409,13 +1369,12 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
} as never);
|
||||
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const preview = await host.openPreviewAsync();
|
||||
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
|
||||
|
||||
expect(preview.previewToken).toBe('hub-token');
|
||||
const [, args] = jest.mocked(spawn).mock.calls.find(([command]) => command === 'npx')!;
|
||||
@@ -1432,14 +1391,13 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
} as never);
|
||||
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
packageVersion,
|
||||
});
|
||||
const preview = await host.openPreviewAsync();
|
||||
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
@@ -1455,10 +1413,7 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
packageVersion,
|
||||
}),
|
||||
]);
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ addr: expect.any(Number) })
|
||||
);
|
||||
expect(ngrok.forward).not.toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(preview.apiUrl).toBe('https://ios-preview.example.test');
|
||||
|
||||
await host.finishAsync();
|
||||
@@ -1475,14 +1430,13 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
|
||||
const bunEnv = { ...env, EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' };
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: bunEnv,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
packageVersion: '4.5.6',
|
||||
});
|
||||
const preview = await host.openPreviewAsync();
|
||||
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
@@ -1511,13 +1465,12 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
} as never);
|
||||
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: { ...env, EAS_FALLBACK_PACKAGE_MANAGER: 'bun' },
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const preview = await host.openPreviewAsync();
|
||||
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
@@ -1543,7 +1496,6 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
} as never);
|
||||
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
@@ -1570,7 +1522,6 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
it('refuses to launch an application on Linux, where expo-device-hub cannot', async () => {
|
||||
await expect(
|
||||
startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
@@ -1851,23 +1802,6 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
|
||||
});
|
||||
|
||||
it('preserves an errored status when the duration timer fires during the response', async () => {
|
||||
const ctx = createStatusCtxMock([]);
|
||||
jest.mocked(turtleFetch).mockImplementationOnce(async () => {
|
||||
(jest.mocked(setTimeoutCallback).mock.calls[0][0] as () => void)();
|
||||
return workerApiResponse({ id: 'drs-id', status: 'errored' });
|
||||
});
|
||||
await expect(
|
||||
waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger: createLoggerMock(),
|
||||
maxDurationSeconds: 30,
|
||||
})
|
||||
).rejects.toThrow('Device run session drs-id errored.');
|
||||
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
|
||||
});
|
||||
|
||||
it('clears the duration timeout when the build step is aborted', async () => {
|
||||
const ctx = createStatusCtxMock([{ status: 'in-progress' }]);
|
||||
const abortController = new AbortController();
|
||||
@@ -1886,33 +1820,6 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
|
||||
});
|
||||
|
||||
it('does not warn when retiring a watch aborts its pending status request', async () => {
|
||||
const controller = new AbortController();
|
||||
const logger = createLoggerMock();
|
||||
const ctx = createCtxMock();
|
||||
ctx.env.EAS_DEVICE_RUN_SESSION_TOKEN = 'worker-token';
|
||||
jest.mocked(turtleFetch).mockImplementation(
|
||||
async (_url, _method, options) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
options!.signal!.addEventListener('abort', () => reject(new Error('request aborted')), {
|
||||
once: true,
|
||||
});
|
||||
})
|
||||
);
|
||||
const waiting = waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
controller.abort();
|
||||
await waiting;
|
||||
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs and retries transient polling errors', async () => {
|
||||
const ctx = createStatusCtxMock([{ error: new Error('network down') }, { status: 'stopped' }]);
|
||||
const logger = createLoggerMock();
|
||||
@@ -2023,47 +1930,6 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
expect(logger.info).toHaveBeenCalledWith('Device run session drs-id was stopped.');
|
||||
});
|
||||
|
||||
it('keeps the session alive while preview input keeps arriving', async () => {
|
||||
const ctx = createStatusCtxMock([...manyInProgressStatuses(), { status: 'stopped' }]);
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger,
|
||||
idleTimeout: {
|
||||
maxIdleTimeMinutes: 1,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
getLastPreviewActivityAt: () => new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(workerApiCallCount('session')).toBe(21);
|
||||
expect(workerApiCallCount('stop')).toBe(0);
|
||||
});
|
||||
|
||||
it('counts idle time from the later of automation and preview activity', async () => {
|
||||
const ctx = createStatusCtxMock(manyInProgressStatuses());
|
||||
const logger = createLoggerMock();
|
||||
const previewInputAt = new Date(Date.now() + 30_000);
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger,
|
||||
idleTimeout: {
|
||||
maxIdleTimeMinutes: 1,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
getLastPreviewActivityAt: () =>
|
||||
Date.now() >= previewInputAt.getTime() ? previewInputAt : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Idle from 30 s, so the stop comes at 90 s: 18 polls.
|
||||
expect(workerApiCallCount('session')).toBe(18);
|
||||
expect(workerApiCallCount('stop')).toBe(1);
|
||||
});
|
||||
|
||||
it('still returns when the session cannot be marked stopped', async () => {
|
||||
const ctx = createStatusCtxMock(manyInProgressStatuses(), {
|
||||
stopError: new Error('forbidden'),
|
||||
@@ -2094,65 +1960,6 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote configuration cancellation', () => {
|
||||
beforeEach(() => jest.mocked(turtleFetch).mockReset());
|
||||
|
||||
it('does not report readiness for an already stopped startup', async () => {
|
||||
const controller = new AbortController();
|
||||
const stopped = new Error('startup stopped');
|
||||
controller.abort(stopped);
|
||||
|
||||
await expect(
|
||||
remoteDeviceRunSession.uploadRemoteSessionConfigAsync({
|
||||
ctx: createCtxMock(),
|
||||
deviceRunSessionId: 'drs-id',
|
||||
remoteConfig: {},
|
||||
logger: createLoggerMock(),
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toBe(stopped);
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards cancellation to the in-flight readiness HTTP request', async () => {
|
||||
const controller = new AbortController();
|
||||
const ctx = createCtxMock();
|
||||
ctx.env.EAS_DEVICE_RUN_SESSION_TOKEN = 'worker-token';
|
||||
const stopped = new Error('session cancelled');
|
||||
let entered!: () => void;
|
||||
const requested = new Promise<void>(resolve => {
|
||||
entered = resolve;
|
||||
});
|
||||
let requestAborted = false;
|
||||
jest.mocked(turtleFetch).mockImplementation(
|
||||
async (_url, _method, options) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
entered();
|
||||
options!.signal!.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
requestAborted = true;
|
||||
reject(new Error('ready request aborted'));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
})
|
||||
);
|
||||
const reporting = remoteDeviceRunSession.uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
remoteConfig: {},
|
||||
logger: createLoggerMock(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
await requested;
|
||||
controller.abort(stopped);
|
||||
|
||||
await expect(reporting).rejects.toBe(stopped);
|
||||
expect(requestAborted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
const spawnMock = jest.mocked(spawn);
|
||||
let binDirectory: string;
|
||||
@@ -2328,105 +2135,6 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
expect(spawnMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('retries one aborted FFmpeg install for two surviving shared callers after it drains', async () => {
|
||||
const controller = new AbortController();
|
||||
const error = new Error('Simulator startup failed');
|
||||
const child = { pid: 12345, kill: jest.fn() };
|
||||
let closeInstaller!: (error: Error) => void;
|
||||
spawnMock.mockReturnValueOnce(
|
||||
Object.assign(
|
||||
new Promise((_resolve, reject) => {
|
||||
closeInstaller = reject;
|
||||
}),
|
||||
{ child }
|
||||
) as never
|
||||
);
|
||||
let finishReplacement!: () => void;
|
||||
spawnMock.mockReturnValueOnce(
|
||||
new Promise<void>(resolve => {
|
||||
finishReplacement = resolve;
|
||||
}) as never
|
||||
);
|
||||
spawnMock.mockReturnValue(spawnResolved());
|
||||
const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
|
||||
const options = {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createPathEnv(),
|
||||
logger: createLoggerMock(),
|
||||
};
|
||||
const owner = ensureFfmpegInstalledOnceAsync({ ...options, signal: controller.signal });
|
||||
const surviving = [
|
||||
ensureFfmpegInstalledOnceAsync(options),
|
||||
ensureFfmpegInstalledOnceAsync({ ...options, signal: new AbortController().signal }),
|
||||
];
|
||||
const settled = jest.fn();
|
||||
const observed = owner.then(settled, settled);
|
||||
const borrowersSettled = jest.fn();
|
||||
const observedBorrowers = Promise.all(surviving).then(borrowersSettled, borrowersSettled);
|
||||
try {
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
controller.abort(error);
|
||||
expect(killGroup).toHaveBeenCalledWith(child, 'SIGKILL');
|
||||
expect(settled).not.toHaveBeenCalled();
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
closeInstaller(new Error('brew interrupted'));
|
||||
await expect(owner).rejects.toBe(error);
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2);
|
||||
expect(borrowersSettled).not.toHaveBeenCalled();
|
||||
finishReplacement();
|
||||
await expect(Promise.all(surviving)).resolves.toEqual([undefined, undefined]);
|
||||
expect(spawnMock).toHaveBeenCalledTimes(3);
|
||||
expect(spawnMock).toHaveBeenLastCalledWith(...warmUpCall);
|
||||
expect(options.logger.warn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
closeInstaller?.(error);
|
||||
finishReplacement();
|
||||
await Promise.all([observed, observedBorrowers]);
|
||||
killGroup.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('stops an aborted shared waiter without cancelling the installer owner', async () => {
|
||||
let finishInstall!: () => void;
|
||||
spawnMock.mockReturnValueOnce(
|
||||
new Promise<void>(resolve => {
|
||||
finishInstall = resolve;
|
||||
}) as never
|
||||
);
|
||||
spawnMock.mockReturnValue(spawnResolved());
|
||||
const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
|
||||
const controller = new AbortController();
|
||||
const error = new Error('Appium startup failed');
|
||||
const options = {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createPathEnv(),
|
||||
logger: createLoggerMock(),
|
||||
};
|
||||
const owner = ensureFfmpegInstalledOnceAsync(options);
|
||||
let ownerFinished = false;
|
||||
const observedOwner = owner.then(() => {
|
||||
ownerFinished = true;
|
||||
});
|
||||
const waiter = ensureFfmpegInstalledOnceAsync({ ...options, signal: controller.signal });
|
||||
const observedWaiter = waiter.catch(() => {});
|
||||
try {
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
controller.abort(error);
|
||||
await expect(waiter).rejects.toBe(error);
|
||||
expect(ownerFinished).toBe(false);
|
||||
expect(killGroup).not.toHaveBeenCalled();
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
finishInstall();
|
||||
await expect(owner).resolves.toBeUndefined();
|
||||
expect(spawnMock).toHaveBeenLastCalledWith(...warmUpCall);
|
||||
} finally {
|
||||
finishInstall();
|
||||
await Promise.all([observedOwner, observedWaiter]);
|
||||
killGroup.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('still installs on linux when the apt index refresh fails', async () => {
|
||||
spawnMock
|
||||
.mockReturnValueOnce(spawnRejected()) // apt-get update
|
||||
@@ -2464,30 +2172,6 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
expect(jest.mocked(Sentry).capture).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not retry an ordinary shared FFmpeg install failure', async () => {
|
||||
let failInstall!: (error: Error) => void;
|
||||
spawnMock.mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failInstall = reject;
|
||||
}) as never
|
||||
);
|
||||
const options = {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createPathEnv(),
|
||||
logger: createLoggerMock(),
|
||||
};
|
||||
const callers = [
|
||||
ensureFfmpegInstalledOnceAsync(options),
|
||||
ensureFfmpegInstalledOnceAsync(options),
|
||||
ensureFfmpegInstalledOnceAsync({ ...options, signal: new AbortController().signal }),
|
||||
];
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
failInstall(new Error('Homebrew unavailable'));
|
||||
await expect(Promise.all(callers)).resolves.toEqual([undefined, undefined, undefined]);
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
expect(options.logger.warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// The caller runs this with `void` and the worker installs no unhandledRejection
|
||||
// handler, so a rejection here would crash the process. `spawn` is not async and
|
||||
// can throw synchronously, which `asyncResult` cannot catch.
|
||||
|
||||
Loaded 100 of 440 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user