mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23952dfb64 | ||
|
|
6ab793bbc9 | ||
|
|
96b3e76c63 | ||
|
|
9f087b164e | ||
|
|
2d8ad58854 | ||
|
|
95bcffb731 | ||
|
|
1099ac72ac | ||
|
|
4dbdaed113 | ||
|
|
85ea9a2a0b | ||
|
|
b03bd85fec | ||
|
|
2d5c88d2bc | ||
|
|
4ce581cf7b | ||
|
|
e412e2923a | ||
|
|
c6231366e1 | ||
|
|
04f8663856 | ||
|
|
81243a6631 | ||
|
|
e38cc79811 | ||
|
|
b5de1517ac | ||
|
|
dd3808f345 | ||
|
|
85c77cc85b | ||
|
|
b38126abb3 | ||
|
|
c68978acd8 | ||
|
|
2dda59d57c | ||
|
|
156e38eb54 | ||
|
|
3263ecfa36 | ||
|
|
1da5a21f42 | ||
|
|
64b9b14d09 | ||
|
|
5cca4faff5 | ||
|
|
80fb6df91c | ||
|
|
16ed2a92f7 | ||
|
|
68fd6027b7 | ||
|
|
f15fe03a71 | ||
|
|
5907c64556 | ||
|
|
ddbde07029 | ||
|
|
5e59c64231 | ||
|
|
0efaffd982 | ||
|
|
2dbaa12750 | ||
|
|
bc25602d65 | ||
|
|
261836aaf6 | ||
|
|
55e746a0e7 | ||
|
|
821e7f85f5 | ||
|
|
6f0f90bd99 | ||
|
|
6c545d3460 | ||
|
|
243c8b8023 | ||
|
|
fe6c35ef73 | ||
|
|
a29b1d1d70 | ||
|
|
92e1b01bd7 | ||
|
|
9adcdfab1c | ||
|
|
444c926bb8 | ||
|
|
3e73b077ce | ||
|
|
3350e5c6f3 | ||
|
|
3ffbbb2f45 | ||
|
|
4e453e5d9c | ||
|
|
4f7ba2d5a3 |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Stop an agent-device session that is still starting up when it is stopped.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Stop an Appium session that is still starting up when it is stopped.
|
||||
Report temporary-directory cleanup failures when Stop interrupts Appium installation.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Stop an Argent session that is still starting up when it is stopped.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Explain missing background task identifiers reported by App Store Connect.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Explain App Store Connect NOT_AUTHORIZED responses in the shared API client.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Explain App Store Connect UNEXPECTED_ERROR responses in the shared API client.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Explain missing app icons reported by App Store Connect, including codes 90022 and 90023, while preserving Apple’s validation details.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Use single-device previews when the session already has a valid device ID.
|
||||
@@ -19,7 +19,9 @@
|
||||
"@expo/downloader",
|
||||
"@expo/turtle-spawn",
|
||||
"@expo/template-file",
|
||||
"create-eas-build-function"
|
||||
"create-eas-build-function",
|
||||
"@expo/vm-tools",
|
||||
"@expo/simulator-launcher"
|
||||
]
|
||||
],
|
||||
"linked": [],
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
List failed Google Play commit attempts in order, including network failures, when the outcome is uncertain.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/worker': patch
|
||||
---
|
||||
|
||||
Run package-manager version queries in parallel during worker preparation.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/worker': patch
|
||||
---
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Record and upload Android emulator process output as a simulator session artifact by default. Add explicit source selection and a simulator CLI flag to opt in to recording and uploading device-wide logcat. Unselected sources are not recorded by simulator sessions.
|
||||
|
||||
Simulator sessions retain the last 20 MiB of each selected Android log source across launches and retries. Each log file is a fixed-size ring on disk, so the end of the final attempt is always kept; older output is dropped first, and artifacts remain uncompressed.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Remove the Free plan build limit guidance experiment for coding agents from `eas build`.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Import ordered action configurations with `eas simulator:control --import` and show progress for each action.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Add searchable interactive simulator controls with session and device details, guided parameters, and action results.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Start simulator sessions from saved settings and apply control actions after readiness.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Show the Expo Simulators hint after a successful build from November 12 instead of November 10.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/worker': patch
|
||||
---
|
||||
|
||||
Allow jobs to skip installing the CocoaPods cache proxy with EAS_BUILD_DISABLE_COCOAPODS_CACHE_PLUGIN=1.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Stop a web-preview session that is still starting up when it is stopped.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Preserve indented and incomplete Xcode compiler errors in formatted build logs, including the final diagnostic when logging stops.
|
||||
@@ -2,6 +2,77 @@
|
||||
|
||||
This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
## [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
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "27.1.0",
|
||||
"PRODUCTION": "27.1.0"
|
||||
"STAGING": "27.4.1",
|
||||
"PRODUCTION": "27.4.1"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "27.1.0",
|
||||
"version": "27.4.1",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,5 +1,50 @@
|
||||
# @expo/build-tools
|
||||
|
||||
## 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "27.1.0",
|
||||
"version": "27.4.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -42,17 +42,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "27.1.0",
|
||||
"@expo/eas-build-job": "27.1.0",
|
||||
"@expo/downloader": "27.4.1",
|
||||
"@expo/eas-build-job": "27.4.1",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "27.1.0",
|
||||
"@expo/logger": "27.4.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.1.0",
|
||||
"@expo/template-file": "27.1.0",
|
||||
"@expo/turtle-spawn": "27.1.0",
|
||||
"@expo/steps": "27.4.1",
|
||||
"@expo/template-file": "27.4.1",
|
||||
"@expo/turtle-spawn": "27.4.1",
|
||||
"@expo/xcpretty": "^4.4.7",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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 () => {}),
|
||||
};
|
||||
}
|
||||
@@ -53,7 +53,7 @@ jest.mock('../../steps/utils/android/gradleConfig', () => ({
|
||||
injectCredentialsGradleConfig: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/artifacts', () => ({
|
||||
uploadApplicationArchive: jest.fn(),
|
||||
uploadApplicationArchive: jest.fn(async () => []),
|
||||
}));
|
||||
jest.mock('../../utils/expoUpdates', () => ({
|
||||
configureExpoUpdatesIfInstalledAsync: jest.fn(),
|
||||
|
||||
@@ -17,6 +17,10 @@ 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,
|
||||
@@ -220,11 +224,30 @@ async function buildInnerAsync(
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
|
||||
await uploadApplicationArchive(ctx, {
|
||||
const [archivePath] = 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,6 +18,10 @@ 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,
|
||||
@@ -236,11 +240,30 @@ async function buildInnerAsync(
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
|
||||
await uploadApplicationArchive(ctx, {
|
||||
const [archivePath] = 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)) {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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,6 +33,7 @@ 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 {
|
||||
@@ -61,6 +62,7 @@ import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/sta
|
||||
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
|
||||
import { createStartServeSimLogsBuildFunction } from './functions/startServeSimLogs';
|
||||
import { createCollectServeSimLogsBuildFunction } from './functions/collectServeSimLogs';
|
||||
import { createCollectAndroidEmulatorLogsBuildFunction } from './functions/collectAndroidEmulatorLogs';
|
||||
import { createStartSandboxBuildFunction } from './functions/startSandbox';
|
||||
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
|
||||
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
|
||||
@@ -111,8 +113,11 @@ 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(),
|
||||
@@ -121,9 +126,11 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createFinishIosSimulatorRecordingsBuildFunction(),
|
||||
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
|
||||
createStartWebPreviewRemoteSessionBuildFunction(ctx),
|
||||
createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true }),
|
||||
createStartServeSimMetricsBuildFunction(),
|
||||
createStartServeSimLogsBuildFunction(),
|
||||
createCollectServeSimLogsBuildFunction(ctx),
|
||||
createCollectAndroidEmulatorLogsBuildFunction(ctx),
|
||||
createStartSandboxBuildFunction(ctx),
|
||||
createRunAgentBuildFunction(ctx),
|
||||
createCollectServeSimMetricsBuildFunction(ctx),
|
||||
@@ -154,6 +161,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
functions.push(
|
||||
...[
|
||||
createFindAndUploadBuildArtifactsBuildFunction(ctx),
|
||||
createUpdateResolvedVersionBuildFunction(ctx),
|
||||
createUploadEmbeddedBundleBuildFunction(ctx),
|
||||
createResolveBuildConfigBuildFunction(ctx),
|
||||
createGetCredentialsForBuildTriggeredByGithubIntegration(ctx),
|
||||
|
||||
@@ -130,6 +130,35 @@ 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,6 +19,7 @@ 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';
|
||||
@@ -183,7 +184,20 @@ function createStepsForIosSimulatorBuild({
|
||||
runFastlane,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
).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 }',
|
||||
},
|
||||
}
|
||||
),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
@@ -304,7 +318,10 @@ function createStepsForIosBuildWithCredentials({
|
||||
runFastlane,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
).createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'find_and_upload_build_artifacts',
|
||||
workingDirectory,
|
||||
}),
|
||||
...(shouldUploadEmbeddedBundle({
|
||||
env: globalCtx.env,
|
||||
metadata: globalCtx.staticContext.metadata,
|
||||
@@ -318,6 +335,16 @@ 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,
|
||||
@@ -404,7 +431,10 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
runGradle,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
).createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'find_and_upload_build_artifacts',
|
||||
workingDirectory,
|
||||
}),
|
||||
...(shouldUploadEmbeddedBundle({
|
||||
env: globalCtx.env,
|
||||
metadata: globalCtx.staticContext.metadata,
|
||||
@@ -418,6 +448,16 @@ 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,
|
||||
@@ -510,7 +550,10 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
: []),
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
).createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'find_and_upload_build_artifacts',
|
||||
workingDirectory,
|
||||
}),
|
||||
...(shouldUploadEmbeddedBundle({
|
||||
env: globalCtx.env,
|
||||
metadata: globalCtx.staticContext.metadata,
|
||||
@@ -524,6 +567,16 @@ 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,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import {
|
||||
createCustomBuildContextMock,
|
||||
createGlobalContextMock,
|
||||
} from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { collectAndroidEmulatorLogsAsync } from '../../utils/androidEmulatorLogs';
|
||||
import { createCollectAndroidEmulatorLogsBuildFunction } from '../collectAndroidEmulatorLogs';
|
||||
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../utils/androidEmulatorLogs');
|
||||
|
||||
function createStep(callInputs: Record<string, unknown> = {}) {
|
||||
const logger = createMockLogger();
|
||||
const ctx = createCustomBuildContextMock();
|
||||
const global = createGlobalContextMock({ logger });
|
||||
const fn = createCollectAndroidEmulatorLogsBuildFunction(ctx);
|
||||
const step = fn.createBuildStepFromFunctionCall(global, {
|
||||
callInputs,
|
||||
env: { DEVICE_RUN_SESSION_ID: 'session' },
|
||||
});
|
||||
return { ctx, global, logger, fn, step };
|
||||
}
|
||||
|
||||
it('collects captured logs without a second source selection', async () => {
|
||||
const { ctx, global, fn, step } = createStep();
|
||||
expect(fn.supportedRuntimePlatforms).toEqual([BuildRuntimePlatform.LINUX]);
|
||||
await step.executeAsync();
|
||||
expect(collectAndroidEmulatorLogsAsync).toHaveBeenCalledWith(ctx, {
|
||||
global,
|
||||
deviceRunSessionId: 'session',
|
||||
logger: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('reports collection failure without changing the session result', async () => {
|
||||
jest
|
||||
.mocked(collectAndroidEmulatorLogsAsync)
|
||||
.mockRejectedValueOnce(new Error('collection failed'));
|
||||
const { step } = createStep();
|
||||
await expect(step.executeAsync()).resolves.toBeUndefined();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not collect Android emulator logs',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
+53
-13
@@ -2,10 +2,12 @@ import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
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';
|
||||
@@ -44,6 +46,26 @@ 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();
|
||||
@@ -54,6 +76,8 @@ 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.
|
||||
@@ -99,7 +123,7 @@ beforeEach(() => {
|
||||
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue(events);
|
||||
jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue(events);
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
lifetimeWait.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -116,13 +140,20 @@ describe.each([
|
||||
corsOrigins?: string[]
|
||||
) {
|
||||
const fn = createFunction({} as CustomBuildContext);
|
||||
const global = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX });
|
||||
const defaults = new Map(
|
||||
fn.inputProviders?.map(provider => {
|
||||
const input = provider(global, 'Test session');
|
||||
return [input.id, input.defaultValue];
|
||||
})
|
||||
);
|
||||
await fn.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
// Inputs a step does not get default to undefined, like in a real step call.
|
||||
// Match step execution: omitted inputs use their declared defaults.
|
||||
inputs: new Proxy(
|
||||
{
|
||||
public_preview: { value: publicPreview },
|
||||
@@ -130,7 +161,7 @@ describe.each([
|
||||
cors_origins: { value: corsOrigins },
|
||||
} as Record<string, { value: unknown }>,
|
||||
{
|
||||
get: (target, id: string) => target[id] ?? { value: undefined },
|
||||
get: (target, id: string) => target[id] ?? { value: defaults.get(id) },
|
||||
}
|
||||
),
|
||||
outputs: {},
|
||||
@@ -145,7 +176,11 @@ describe.each([
|
||||
|
||||
it('holds the host until session completion', async () => {
|
||||
await runAsync();
|
||||
expect(openPreview).toHaveBeenCalledWith({ baseDomain: 'example.test', authtoken: 'token' });
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ preview: { baseDomain: 'example.test', authtoken: 'token' } })
|
||||
);
|
||||
expect(openPreview).toHaveBeenCalledWith();
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({
|
||||
@@ -157,7 +192,7 @@ describe.each([
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
expect(closePreview).not.toHaveBeenCalled();
|
||||
expect(finishHost.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
|
||||
lifetimeWait.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(stopTool).toHaveBeenCalledTimes(1);
|
||||
expect(stopEvents).toHaveBeenCalledTimes(1);
|
||||
@@ -302,7 +337,7 @@ describe.each([
|
||||
} else if (phase === 'config') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
|
||||
} else {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
|
||||
lifetimeWait.mockRejectedValueOnce(error);
|
||||
}
|
||||
await expect(runAsync()).rejects.toBe(error);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
@@ -350,6 +385,10 @@ 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 =>
|
||||
@@ -360,25 +399,26 @@ 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;
|
||||
return { finish, started };
|
||||
}
|
||||
|
||||
it.each(['installed', 'failed'] as const)(
|
||||
'starts Appium after the ffmpeg install settles (%s)',
|
||||
async outcome => {
|
||||
const finishFfmpegInstall = holdFfmpegInstall();
|
||||
const { finish, started } = holdFfmpegInstall();
|
||||
|
||||
const run = runAppiumAsync();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await started;
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
|
||||
finishFfmpegInstall(outcome);
|
||||
finish(outcome);
|
||||
await run;
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
@@ -386,16 +426,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 finishFfmpegInstall = holdFfmpegInstall({ appiumError });
|
||||
const { finish, started } = holdFfmpegInstall({ appiumError });
|
||||
|
||||
let settled = false;
|
||||
const run = runAppiumAsync().finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await started;
|
||||
expect(settled).toBe(false);
|
||||
|
||||
finishFfmpegInstall('installed');
|
||||
finish('installed');
|
||||
await expect(run).rejects.toBe(appiumError);
|
||||
});
|
||||
});
|
||||
+18
@@ -51,6 +51,24 @@ 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'];
|
||||
|
||||
+42
-18
@@ -7,6 +7,7 @@ import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
monitorLocalEgressAsync,
|
||||
@@ -82,6 +83,26 @@ 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(),
|
||||
@@ -124,6 +145,8 @@ 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);
|
||||
@@ -166,7 +189,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
finishAsync: stopPreview,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
|
||||
lifetimeWait.mockImplementation(async () => {
|
||||
expect(lifetime.aborted).toBe(false);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -179,7 +202,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
it('releases resources without monitoring when the session stopped during startup', async () => {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValueOnce('stopped');
|
||||
await run();
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
expect(lifetimeWait).not.toHaveBeenCalled();
|
||||
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(stopPreview).toHaveBeenCalledTimes(1);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
@@ -188,16 +211,18 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
|
||||
it('reports credentials and starts monitoring before waiting, then releases resources', async () => {
|
||||
await run();
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
deviceRunSessionId: 'session-id',
|
||||
logger,
|
||||
remoteConfig: expect.objectContaining({
|
||||
...fields,
|
||||
[controllerField]: expect.any(String),
|
||||
[controllerField === 'previewUrl' ? 'previewToken' : 'webPreviewToken']: 'preview-secret',
|
||||
}),
|
||||
});
|
||||
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(monitorLocalEgressAsync).toHaveBeenCalledWith({
|
||||
port: handoff.port,
|
||||
env,
|
||||
@@ -211,7 +236,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
stopEgress.mockClear();
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(null);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
lifetimeWait.mockResolvedValue(undefined);
|
||||
await run();
|
||||
const { remoteConfig } = jest.mocked(uploadRemoteSessionConfigAsync).mock.calls[0][0];
|
||||
expect(remoteConfig).toHaveProperty(controllerField);
|
||||
@@ -230,7 +255,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
} else if (phase === 'report') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValue(error);
|
||||
} else if (phase === 'wait') {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValue(error);
|
||||
lifetimeWait.mockRejectedValue(error);
|
||||
} else {
|
||||
stopPreview.mockRejectedValue(error);
|
||||
}
|
||||
@@ -244,11 +269,10 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
);
|
||||
it('passes cancellation to the monitor and releases the lifetime', async () => {
|
||||
const abort = new AbortController();
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => abort.abort());
|
||||
lifetimeWait.mockImplementation(async () => abort.abort());
|
||||
await run(abort.signal);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signal: 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(lifetime.aborted).toBe(true);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
+332
-24
@@ -1,14 +1,18 @@
|
||||
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,
|
||||
@@ -21,6 +25,8 @@ 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
|
||||
@@ -38,10 +44,14 @@ 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(),
|
||||
@@ -74,10 +84,7 @@ const NO_CAPTURE: CaptureInputs = { networkCapture: false, networkCaptureFields:
|
||||
async function runAsync(
|
||||
logger: { info: jest.Mock; warn: jest.Mock; child: jest.Mock },
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
capture: CaptureInputs = NO_CAPTURE,
|
||||
disableShare = false,
|
||||
privatePreviewUrl?: string,
|
||||
maxIdleTimeMinutes?: number
|
||||
options: Partial<Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]> = {}
|
||||
): Promise<void> {
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env: {},
|
||||
@@ -89,16 +96,15 @@ async function runAsync(
|
||||
ngrokAuthtoken: 'ngrok-token',
|
||||
},
|
||||
packageVersion: undefined,
|
||||
maxIdleTimeMinutes,
|
||||
maxIdleTimeMinutes: undefined,
|
||||
maxDurationSeconds: undefined,
|
||||
capture,
|
||||
disableShare,
|
||||
privatePreviewUrl,
|
||||
tasks: createStartupTasks(logger as never),
|
||||
capture: NO_CAPTURE,
|
||||
tasks: createStartupTasks(logger as never, options.signal),
|
||||
device:
|
||||
runtimePlatform === BuildRuntimePlatform.DARWIN
|
||||
? { iosSimulatorUdid: 'selected-udid', application: Promise.resolve() }
|
||||
: { booted: Promise.resolve(), ready: Promise.resolve() },
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -195,6 +201,213 @@ 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/+';
|
||||
@@ -224,7 +437,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
} as never);
|
||||
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(previewInputAt);
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.LINUX, NO_CAPTURE, false, undefined, 5);
|
||||
await runAsync(logger, BuildRuntimePlatform.LINUX, { maxIdleTimeMinutes: 5 });
|
||||
|
||||
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
|
||||
@@ -235,8 +448,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
|
||||
networkCapture: true,
|
||||
networkCaptureFields: ['header'],
|
||||
capture: { networkCapture: true, networkCaptureFields: ['header'] },
|
||||
});
|
||||
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
@@ -247,13 +459,10 @@ 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,
|
||||
NO_CAPTURE,
|
||||
true,
|
||||
'https://expo.dev/private-preview'
|
||||
);
|
||||
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
|
||||
disableShare: true,
|
||||
privatePreviewUrl: 'https://expo.dev/private-preview',
|
||||
});
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
@@ -349,7 +558,8 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
type Device = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['device'];
|
||||
function startSession(
|
||||
device: Device | ((tasks: StartupTasks) => Device),
|
||||
signal?: AbortSignal
|
||||
signal?: AbortSignal,
|
||||
onStarted?: () => Promise<void>
|
||||
) {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
@@ -377,6 +587,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
capture: NO_CAPTURE,
|
||||
tasks,
|
||||
device: sessionDevice,
|
||||
onStarted,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -615,10 +826,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
ctx,
|
||||
{} as never
|
||||
);
|
||||
const preview = await host.openPreviewAsync({
|
||||
baseDomain: 'tunnel.example.com',
|
||||
authtoken: 'ngrok-token',
|
||||
});
|
||||
const preview = await host.openPreviewAsync();
|
||||
jest.mocked(host.openPreviewAsync).mockImplementation(async () => {
|
||||
await previewReady.promise;
|
||||
return preview;
|
||||
@@ -652,6 +860,106 @@ 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 });
|
||||
|
||||
+471
-236
@@ -4,14 +4,18 @@ import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { DeviceRunSessionWorkerApi } from '../../utils/deviceRunSessionWorkerApi';
|
||||
import { AndroidEmulatorLogCapture } from '../../../utils/androidEmulatorLogCapture';
|
||||
import { getAndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogs';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { stopLocalEgressResourcesAsync } from '../../utils/localEgress';
|
||||
import {
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync,
|
||||
startLocalEgressGuardRelayAsync,
|
||||
} from '../../utils/localEgressGuard';
|
||||
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
|
||||
import {
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { downloadBuildAsync } from '../downloadBuild';
|
||||
import { installBuildAsync } from '../installBuild';
|
||||
import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact';
|
||||
@@ -20,6 +24,7 @@ import {
|
||||
getAgentDeviceRemoteSessionEnvOrThrow,
|
||||
runAgentDeviceRemoteSessionAsync,
|
||||
} from '../startAgentDeviceRemoteSession';
|
||||
import { runOnWarmIosSimulatorAsync } from '../../utils/warmDeviceSessionHost';
|
||||
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
|
||||
import { startAndroidEmulatorAsync } from '../startAndroidEmulator';
|
||||
import { bootIosSimulatorAsync } from '../startIosSimulator';
|
||||
@@ -32,16 +37,19 @@ jest.mock('../../utils/localEgressGuard', () => ({
|
||||
startLocalEgressGuardRelayAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../utils/androidEmulatorLogs');
|
||||
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', () => ({
|
||||
@@ -102,41 +110,29 @@ function runStep(
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
inputValues: Record<string, unknown> = {},
|
||||
signal?: AbortSignal,
|
||||
stepCtx: CustomBuildContext = ctx
|
||||
stepCtx: CustomBuildContext = ctx,
|
||||
warm = false,
|
||||
env: NodeJS.ProcessEnv = {}
|
||||
): Promise<void> {
|
||||
const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx);
|
||||
const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx, { warm });
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const global = createGlobalContextMock({
|
||||
runtimePlatform,
|
||||
staticContextContent: { job: { secrets: { robotAccessToken: 'robot-token' } } },
|
||||
});
|
||||
const inputs = Object.fromEntries(
|
||||
[
|
||||
'device_identifier',
|
||||
'system_image_package',
|
||||
'lcd_width',
|
||||
'lcd_height',
|
||||
'lcd_density',
|
||||
'build_id',
|
||||
'application_archive_url',
|
||||
'launch_args',
|
||||
'open_url',
|
||||
'network_capture',
|
||||
'network_capture_fields',
|
||||
'public_preview',
|
||||
'private_preview_url',
|
||||
'frame_ancestors',
|
||||
'cors_origins',
|
||||
'package_version',
|
||||
'max_idle_time_minutes',
|
||||
'max_duration_seconds',
|
||||
].map(id => [id, { value: inputValues[id] }])
|
||||
(buildFunction.inputProviders ?? []).map(provider => {
|
||||
const input = provider(global, 'test');
|
||||
input.set(inputValues[input.id]);
|
||||
return [input.id, { value: input.rawValue }];
|
||||
})
|
||||
);
|
||||
return buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: {
|
||||
runtimePlatform,
|
||||
staticContext: { job: { secrets: { robotAccessToken: 'robot-token' } } },
|
||||
},
|
||||
global,
|
||||
} as unknown as BuildStepContext,
|
||||
{ inputs, outputs: {}, env: {}, signal } as never
|
||||
{ inputs, outputs: {}, env, signal } as never
|
||||
) as Promise<void>;
|
||||
}
|
||||
|
||||
@@ -145,7 +141,31 @@ 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(() => {
|
||||
jest
|
||||
.mocked(getAndroidEmulatorLogCapture)
|
||||
.mockImplementation(
|
||||
jest.requireActual('../../utils/androidEmulatorLogs').getAndroidEmulatorLogCapture
|
||||
);
|
||||
deviceLaunch = undefined;
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(null);
|
||||
@@ -157,6 +177,13 @@ 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 }) => {
|
||||
@@ -172,7 +199,6 @@ 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' });
|
||||
@@ -180,6 +206,43 @@ 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',
|
||||
@@ -188,7 +251,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
};
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(bootEnv);
|
||||
await runStep(BuildRuntimePlatform.DARWIN, {
|
||||
build_id: 'build-id',
|
||||
install_app_path: '/tmp/App.app',
|
||||
launch_args: ['-flag'],
|
||||
open_url: 'exp://example.test',
|
||||
});
|
||||
@@ -202,14 +265,7 @@ 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({
|
||||
@@ -253,63 +309,48 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
it('fails before app installation 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, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'shutdown failed'
|
||||
);
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app' })
|
||||
).rejects.toThrow('shutdown failed');
|
||||
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('provides Simulator preparation and downloaded startup options to the remote session', async () => {
|
||||
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>
|
||||
);
|
||||
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 () => {
|
||||
const hostReady = deferred();
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
|
||||
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
|
||||
@@ -324,7 +365,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
});
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, {
|
||||
device_identifier: 'iPhone 17',
|
||||
build_id: 'build-id',
|
||||
install_app_path: '/tmp/App.app',
|
||||
launch_args: ['-flag'],
|
||||
open_url: 'exp://example.test',
|
||||
});
|
||||
@@ -334,7 +375,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
env: {},
|
||||
});
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
|
||||
artifactPath: '/tmp/App.app',
|
||||
env: {},
|
||||
@@ -352,15 +393,15 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('fails a guarded session before download or host startup if its boot files are missing', async () => {
|
||||
it('fails a guarded session before app installation 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, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'missing bin/egress-guard-check'
|
||||
);
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app' })
|
||||
).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();
|
||||
@@ -375,37 +416,21 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
jest
|
||||
.mocked(runAgentDeviceRemoteSessionAsync)
|
||||
.mockRejectedValueOnce(new Error('guard check failed'));
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'guard check failed'
|
||||
);
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app' })
|
||||
).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(getAndroidEmulatorLogCapture).not.toHaveBeenCalled();
|
||||
expect(sessionDevice()).toEqual({
|
||||
iosSimulatorUdid: 'SELECTED-UDID',
|
||||
bootEnv: undefined,
|
||||
@@ -413,6 +438,48 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults an omitted session log input to emulator output through the step boundary', async () => {
|
||||
const buildFunction = createStartAgentDeviceSessionBuildFunction(ctx);
|
||||
const global = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX });
|
||||
const step = buildFunction.createBuildStepFromFunctionCall(global, {
|
||||
callInputs: {},
|
||||
env: { DEVICE_RUN_SESSION_ID: 'session' },
|
||||
});
|
||||
await step.executeAsync();
|
||||
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ logSources: ['emulator-output'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('passes an explicit empty source list and the workflow capture to Android boot', async () => {
|
||||
const capture = new AndroidEmulatorLogCapture();
|
||||
jest.mocked(getAndroidEmulatorLogCapture).mockReturnValue(capture);
|
||||
const env = { DEVICE_RUN_SESSION_ID: 'session' };
|
||||
await runStep(
|
||||
BuildRuntimePlatform.LINUX,
|
||||
{ android_log_sources: [] },
|
||||
undefined,
|
||||
ctx,
|
||||
false,
|
||||
env
|
||||
);
|
||||
expect(getAndroidEmulatorLogCapture).toHaveBeenCalledWith(expect.anything(), env);
|
||||
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ logSources: [], logCapture: capture })
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid Android log sources before boot or downloading', async () => {
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.LINUX, {
|
||||
android_log_sources: ['unknown'],
|
||||
build_id: 'build-id',
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('boots the Android Emulator with the device inputs', async () => {
|
||||
await runStep(BuildRuntimePlatform.LINUX, {
|
||||
device_identifier: 'pixel_7',
|
||||
@@ -420,7 +487,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
lcd_width: 720,
|
||||
lcd_height: 1600,
|
||||
lcd_density: 300,
|
||||
build_id: 'build-id',
|
||||
install_app_path: '/tmp/App.app',
|
||||
});
|
||||
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
@@ -433,67 +500,21 @@ 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'
|
||||
);
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' }, undefined, ctx, false, {
|
||||
DEVICE_RUN_SESSION_ID: 'session',
|
||||
})
|
||||
).rejects.toThrow('boot failed');
|
||||
|
||||
expect(download.aborted()).toBe(true);
|
||||
// The step returned only after the download stopped.
|
||||
@@ -501,40 +522,107 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
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, { build_id: 'build-id' }, controller.signal)
|
||||
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app' }, 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();
|
||||
});
|
||||
@@ -553,11 +641,15 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not start device or download work after cancellation during Xcode selection', async () => {
|
||||
it('does not start device or app 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, { build_id: 'build-id' }, controller.signal);
|
||||
const step = runStep(
|
||||
BuildRuntimePlatform.DARWIN,
|
||||
{ install_app_path: '/tmp/App.app' },
|
||||
controller.signal
|
||||
);
|
||||
const failure = expect(step).rejects.toThrow('cancelled');
|
||||
await flushAsync();
|
||||
controller.abort(new Error('cancelled'));
|
||||
@@ -566,42 +658,7 @@ 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();
|
||||
});
|
||||
|
||||
@@ -612,13 +669,16 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual([
|
||||
'android_log_sources',
|
||||
'device_identifier',
|
||||
'system_image_package',
|
||||
'lcd_width',
|
||||
'lcd_height',
|
||||
'lcd_density',
|
||||
'install_app_path',
|
||||
'build_id',
|
||||
'application_archive_url',
|
||||
'launch_app_identifier',
|
||||
'launch_args',
|
||||
'open_url',
|
||||
'network_capture',
|
||||
@@ -678,12 +738,15 @@ 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, { build_id: 'build-id', ...inputs });
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, {
|
||||
install_app_path: '/tmp/App.app',
|
||||
...inputs,
|
||||
});
|
||||
await expect(step).rejects.toBeInstanceOf(UserError);
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -692,16 +755,22 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
['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, { build_id: 'build-id', ...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, { build_id: 'build-id', launch_args: launchArgs });
|
||||
await runStep(BuildRuntimePlatform.LINUX, {
|
||||
install_app_path: '/tmp/App.app',
|
||||
launch_args: launchArgs,
|
||||
});
|
||||
expect(launchApplicationAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, launchArgs })
|
||||
);
|
||||
@@ -728,4 +797,170 @@ 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();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from '../../../utils/AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../../../utils/retry';
|
||||
import { resolveAndroidEmulatorLocalEgressAsync } from '../../utils/androidLocalEgress';
|
||||
import { AndroidEmulatorLogCapture } from '../../../utils/androidEmulatorLogCapture';
|
||||
import { getAndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogs';
|
||||
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
@@ -24,6 +26,8 @@ jest.mock('../../utils/androidLocalEgress', () => ({
|
||||
resolveAndroidEmulatorLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/androidEmulatorLogs');
|
||||
|
||||
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
AndroidEmulatorUtils: {
|
||||
defaultSystemImagePackage: 'system-images;android-30;default;x86_64',
|
||||
@@ -62,6 +66,11 @@ function createStartResult(serialId: string) {
|
||||
|
||||
describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.mocked(getAndroidEmulatorLogCapture)
|
||||
.mockImplementation(
|
||||
jest.requireActual('../../utils/androidEmulatorLogs').getAndroidEmulatorLogCapture
|
||||
);
|
||||
mockedMkdtemp.mockResolvedValue('/tmp/logcat-directory');
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
mockedAndroidUtils.getAvailableDevicesAsync.mockResolvedValue([]);
|
||||
@@ -90,6 +99,30 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
mockedMkdtemp.mockRestore();
|
||||
});
|
||||
|
||||
it('preserves an explicit empty source list and one capture through boot retries and clones', async () => {
|
||||
mockedAndroidUtils.waitForReadyAsync.mockRejectedValueOnce(new Error('retry boot'));
|
||||
const capture = new AndroidEmulatorLogCapture();
|
||||
jest.mocked(getAndroidEmulatorLogCapture).mockReturnValue(capture);
|
||||
await createStep(
|
||||
{ android_log_sources: [], count: 2 },
|
||||
{ DEVICE_RUN_SESSION_ID: 'session' }
|
||||
).executeAsync();
|
||||
expect(getAndroidEmulatorLogCapture).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ DEVICE_RUN_SESSION_ID: 'session' })
|
||||
);
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledTimes(4);
|
||||
for (const [options] of mockedAndroidUtils.startAsync.mock.calls) {
|
||||
expect(options.logSources).toEqual([]);
|
||||
expect(options.logCapture).toBe(capture);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid sources before starting the emulator', async () => {
|
||||
await expect(createStep({ android_log_sources: ['unknown'] }).executeAsync()).rejects.toThrow();
|
||||
expect(mockedAndroidUtils.startAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('local egress', () => {
|
||||
function createLocalEgress() {
|
||||
return {
|
||||
@@ -111,6 +144,8 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
deviceName: 'EasAndroidDevice01',
|
||||
env: expect.any(Object),
|
||||
logcatDirectory: '/tmp/logcat-directory',
|
||||
logSources: ['emulator-output', 'device-logcat'],
|
||||
logCapture: expect.any(AndroidEmulatorLogCapture),
|
||||
});
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith({
|
||||
env: expect.any(Object),
|
||||
|
||||
+639
-6
@@ -1,7 +1,9 @@
|
||||
import { type WarmIosSession } from '../../utils/warmIosSession';
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as timersPromises from 'node:timers/promises';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
@@ -13,6 +15,12 @@ import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import { preparePreinstalledWdaAsync } from '../../utils/preinstalledWda';
|
||||
import { resolveLocalEgressServeSimBootEnvironmentAsync } from '../../utils/localEgressGuard';
|
||||
import {
|
||||
WarmSessionAdoptionError,
|
||||
runOnWarmIosSimulatorAsync,
|
||||
} from '../../utils/warmDeviceSessionHost';
|
||||
import * as warmIosSession from '../../utils/warmIosSession';
|
||||
import {
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
@@ -20,6 +28,8 @@ import {
|
||||
startNgrokTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { watchDeviceRunSessionStartup } from '../../utils/deviceRunSessionStartup';
|
||||
import { createStartupWatchMock } from '../../../__tests__/utils/startupWatch';
|
||||
|
||||
import {
|
||||
createStartAppiumRemoteSessionBuildFunction,
|
||||
@@ -28,6 +38,10 @@ import {
|
||||
resolveAppiumDeviceAsync,
|
||||
} from '../startAppiumRemoteSession';
|
||||
|
||||
jest.mock('node:timers/promises', () => ({
|
||||
...jest.requireActual('node:timers/promises'),
|
||||
setTimeout: jest.fn(jest.requireActual('node:timers/promises').setTimeout),
|
||||
}));
|
||||
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
AndroidEmulatorUtils: { getAttachedDevicesAsync: jest.fn() },
|
||||
}));
|
||||
@@ -46,6 +60,9 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/deviceRunSessionStartup', () => ({
|
||||
watchDeviceRunSessionStartup: jest.fn(),
|
||||
}));
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../utils/retry', () => ({
|
||||
@@ -58,6 +75,14 @@ jest.mock('../../../utils/turtleFetch', () => ({
|
||||
}));
|
||||
jest.mock('../../utils/appiumEvents', () => ({ startAppiumEventCollectionAsync: jest.fn() }));
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/warmDeviceSessionHost', () => ({
|
||||
...jest.createMockFromModule<typeof import('../../utils/warmDeviceSessionHost')>(
|
||||
'../../utils/warmDeviceSessionHost'
|
||||
),
|
||||
WarmSessionAdoptionError: jest.requireActual<typeof import('../../utils/warmDeviceSessionHost')>(
|
||||
'../../utils/warmDeviceSessionHost'
|
||||
).WarmSessionAdoptionError,
|
||||
}));
|
||||
jest.mock('../../utils/preinstalledWda', () => ({ preparePreinstalledWdaAsync: jest.fn() }));
|
||||
jest.mock('../../utils/iosAppArtifact', () => ({
|
||||
...jest.requireActual('../../utils/iosAppArtifact'),
|
||||
@@ -93,6 +118,7 @@ describe(resolveAppium3VersionSpec, () => {
|
||||
describe(resolveAppiumDeviceAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock);
|
||||
});
|
||||
|
||||
it('returns XCUITest capabilities for the booted iOS simulator', async () => {
|
||||
@@ -145,6 +171,7 @@ describe(resolveAppiumDeviceAsync, () => {
|
||||
describe(installAppiumAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock);
|
||||
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (args.includes('--json')) {
|
||||
return { stdout: JSON.stringify({}) };
|
||||
@@ -275,6 +302,36 @@ describe('createStartAppiumRemoteSessionBuildFunction', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('claims the warm Simulator for Appium without its package version', async () => {
|
||||
jest.mocked(runOnWarmIosSimulatorAsync).mockResolvedValue(undefined);
|
||||
const buildFunction = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext, {
|
||||
warm: true,
|
||||
});
|
||||
expect(buildFunction.id).toBe('start_warm_appium_remote_session');
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: '3.5.0' },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
device_identifier: { value: 'iPhone 17' },
|
||||
},
|
||||
outputs: {},
|
||||
env: {
|
||||
DEVICE_RUN_SESSION_ID: 'session',
|
||||
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'tunnel.example',
|
||||
NGROK_AUTHTOKEN: 'ngrok-token',
|
||||
},
|
||||
} as never
|
||||
);
|
||||
const [claim] = jest.mocked(runOnWarmIosSimulatorAsync).mock.calls[0];
|
||||
expect(claim).toMatchObject({ engine: 'appium', requestedDevice: 'iPhone 17' });
|
||||
expect(claim).not.toHaveProperty('packageVersion');
|
||||
});
|
||||
|
||||
it('declares the launch inputs so serve-sim can launch the app', () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const buildFunction = createStartAppiumRemoteSessionBuildFunction(ctx);
|
||||
@@ -298,8 +355,13 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
const stopError = new Error('Process output drain timed out after 5000ms.');
|
||||
const stopAppium = jest.fn();
|
||||
const stopEventCollection = jest.fn();
|
||||
const finishHost = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.mocked(timersPromises.setTimeout)
|
||||
.mockImplementation(jest.requireActual('node:timers/promises').setTimeout);
|
||||
jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock);
|
||||
jest.mocked(ensureFfmpegInstalledOnceAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(preparePreinstalledWdaAsync).mockResolvedValue({});
|
||||
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({
|
||||
@@ -331,6 +393,7 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
subdomainId: 'appium-abc',
|
||||
stopAsync: jest.fn(),
|
||||
});
|
||||
finishHost.mockResolvedValue(undefined);
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
@@ -338,7 +401,7 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: jest.fn(),
|
||||
finishAsync: finishHost,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -350,9 +413,11 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
logger: { info: jest.Mock; warn: jest.Mock },
|
||||
maxIdleTimeMinutes?: number,
|
||||
runtimePlatform = BuildRuntimePlatform.LINUX,
|
||||
startupInputs: Record<string, { value: unknown }> = {}
|
||||
startupInputs: Record<string, { value: unknown }> = {},
|
||||
warm = false,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
await createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
|
||||
await createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext, { warm }).fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform },
|
||||
@@ -364,6 +429,7 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
...startupInputs,
|
||||
},
|
||||
outputs: {},
|
||||
signal,
|
||||
env: {
|
||||
DEVICE_RUN_SESSION_ID: 'device-run-session-id',
|
||||
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'tunnel.example.com',
|
||||
@@ -382,6 +448,134 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
);
|
||||
}
|
||||
|
||||
it('does not allocate Appium or a host after parent cancellation 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(
|
||||
runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {}, false, controller.signal)
|
||||
).rejects.toBe(failure);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves an aggregate host startup failure', async () => {
|
||||
const failure = new AggregateError([
|
||||
new Error('host failed'),
|
||||
new Error('host cleanup failed'),
|
||||
]);
|
||||
jest.mocked(startDeviceSessionHostAsync).mockRejectedValueOnce(failure);
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
await expect(runSessionAsync(logger)).rejects.toBe(failure);
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports late host cleanup failure after startup Stop', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
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 = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
|
||||
boot_simulator: { value: true },
|
||||
device_identifier: { value: 'selected-ios-udid' },
|
||||
});
|
||||
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(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps watching startup when warm adoption retries cold', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
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(
|
||||
runSessionAsync(
|
||||
logger,
|
||||
undefined,
|
||||
BuildRuntimePlatform.DARWIN,
|
||||
{ boot_simulator: { value: true }, device_identifier: { value: 'selected-ios-udid' } },
|
||||
true
|
||||
)
|
||||
)
|
||||
).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 = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {}, true);
|
||||
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('passes the existing Android selection to the preview host', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
await runSessionAsync(logger);
|
||||
@@ -405,6 +599,297 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
expect(options.iosSimulatorUdid).toBeUndefined();
|
||||
});
|
||||
|
||||
function mockWarmSession(): jest.Mock {
|
||||
const stopWarm = jest.fn().mockResolvedValue(undefined);
|
||||
const warm: warmIosSession.WarmIosSession = {
|
||||
device: { udid: 'CHOSEN-IOS-UDID', name: 'iPhone 17' },
|
||||
server: { pid: 100, port: 3200, token: 'warm-token' },
|
||||
agentDeviceStateDir: '/warm/agent-device',
|
||||
argentEventLogPath: '/warm/argent-events',
|
||||
prepareToolsAsync: jest.fn(),
|
||||
process: {
|
||||
pid: 100,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopWarm,
|
||||
},
|
||||
};
|
||||
jest.spyOn(warmIosSession, 'tryClaimWarmIosSessionAsync').mockResolvedValue(warm);
|
||||
jest
|
||||
.mocked(runOnWarmIosSimulatorAsync)
|
||||
.mockImplementation(
|
||||
jest.requireActual<typeof import('../../utils/warmDeviceSessionHost')>(
|
||||
'../../utils/warmDeviceSessionHost'
|
||||
).runOnWarmIosSimulatorAsync
|
||||
);
|
||||
return stopWarm;
|
||||
}
|
||||
|
||||
it.each(['event collection', 'Appium process'])(
|
||||
'waits for warm %s teardown before finishing the session host',
|
||||
async stage => {
|
||||
mockWarmSession();
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
let finishTeardown = () => {};
|
||||
const teardownGate = new Promise<void>(resolve => {
|
||||
finishTeardown = resolve;
|
||||
});
|
||||
let startedTeardown = () => {};
|
||||
const teardownStarted = new Promise<void>(resolve => {
|
||||
startedTeardown = resolve;
|
||||
});
|
||||
const stop = async () => {
|
||||
startedTeardown();
|
||||
await teardownGate;
|
||||
};
|
||||
(stage === 'event collection' ? stopEventCollection : stopAppium).mockImplementationOnce(
|
||||
stop
|
||||
);
|
||||
|
||||
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {}, true);
|
||||
try {
|
||||
await teardownStarted;
|
||||
await Promise.resolve();
|
||||
expect(finishHost).not.toHaveBeenCalled();
|
||||
if (stage === 'event collection') {
|
||||
expect(stopAppium).not.toHaveBeenCalled();
|
||||
}
|
||||
} finally {
|
||||
finishTeardown();
|
||||
await running;
|
||||
}
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
expect(stopAppium.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
finishHost.mock.invocationCallOrder[0]
|
||||
);
|
||||
const home = jest.mocked(spawnDetached).mock.calls[0][0].env.APPIUM_HOME;
|
||||
expect(fs.existsSync(home ?? '')).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['event collection', 'Appium process'])(
|
||||
'finishes the warm host after %s teardown fails',
|
||||
async stage => {
|
||||
const stopWarm = mockWarmSession();
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
const error = new Error(`${stage} stop failed`);
|
||||
(stage === 'event collection' ? stopEventCollection : stopAppium).mockRejectedValueOnce(
|
||||
error
|
||||
);
|
||||
|
||||
await expect(
|
||||
runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {}, true)
|
||||
).rejects.toBe(error);
|
||||
|
||||
expect(stopAppium).toHaveBeenCalledTimes(1);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
expect(stopWarm).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
'finishes a cold host while Appium is still stopping (warm step: %s)',
|
||||
async warm => {
|
||||
if (warm) {
|
||||
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementationOnce((_claim, run) => run());
|
||||
}
|
||||
let finishAppium = () => {};
|
||||
const appiumGate = new Promise<void>(resolve => {
|
||||
finishAppium = resolve;
|
||||
});
|
||||
let startedAppiumStop = () => {};
|
||||
const appiumStopping = new Promise<void>(resolve => {
|
||||
startedAppiumStop = resolve;
|
||||
});
|
||||
stopAppium.mockImplementationOnce(async () => {
|
||||
startedAppiumStop();
|
||||
await appiumGate;
|
||||
});
|
||||
let startedHostFinish = () => {};
|
||||
const hostFinishing = new Promise<void>(resolve => {
|
||||
startedHostFinish = resolve;
|
||||
});
|
||||
finishHost.mockImplementationOnce(async () => startedHostFinish());
|
||||
|
||||
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {}, warm);
|
||||
try {
|
||||
await appiumStopping;
|
||||
await Promise.resolve();
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
await hostFinishing;
|
||||
} finally {
|
||||
finishAppium();
|
||||
await running;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['stopped', 'process stop failed', 'home cleanup failed'])(
|
||||
'retries a failed warm adoption only after Appium stops (%s)',
|
||||
async cleanup => {
|
||||
const stopFails = cleanup === 'process stop failed';
|
||||
const adoptionError = new WarmSessionAdoptionError();
|
||||
const stopWarm = mockWarmSession();
|
||||
let failHost: (error: Error) => void = () => {};
|
||||
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failHost = reject;
|
||||
})
|
||||
);
|
||||
jest.mocked(spawnDetached).mockImplementationOnce(() => {
|
||||
failHost(adoptionError);
|
||||
return {
|
||||
pid: 4242,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopAppium,
|
||||
};
|
||||
});
|
||||
if (!stopFails) {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
}
|
||||
if (cleanup === 'home cleanup failed') {
|
||||
jest.spyOn(fs.promises, 'rm').mockRejectedValueOnce(new Error('home cleanup failed'));
|
||||
}
|
||||
|
||||
const running = runSessionAsync(
|
||||
{ info: jest.fn(), warn: jest.fn() },
|
||||
undefined,
|
||||
BuildRuntimePlatform.DARWIN,
|
||||
{ boot_simulator: { value: true }, device_identifier: { value: 'iPhone 17' } },
|
||||
true
|
||||
);
|
||||
|
||||
if (stopFails) {
|
||||
await expect(running).rejects.toMatchObject({ errors: [adoptionError, stopError] });
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
expect(stopWarm).toHaveBeenCalledTimes(1);
|
||||
} else {
|
||||
await expect(running).resolves.toBeUndefined();
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(2);
|
||||
expect(stopWarm).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
expect(stopAppium).toHaveBeenCalledTimes(stopFails ? 1 : 2);
|
||||
if (cleanup === 'home cleanup failed') {
|
||||
const home = jest.mocked(spawnDetached).mock.calls[0][0].env.APPIUM_HOME;
|
||||
if (home) {
|
||||
await fs.promises.rm(home, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
it.each([
|
||||
[BuildRuntimePlatform.LINUX, false],
|
||||
[BuildRuntimePlatform.DARWIN, false],
|
||||
[BuildRuntimePlatform.DARWIN, true],
|
||||
])(
|
||||
'handles Stop during the readiness retry sleep on %s with startup=%s',
|
||||
async (runtimePlatform, bootSimulator) => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid: 'chosen-ios-udid' } as never]);
|
||||
jest.mocked(turtleFetch).mockResolvedValue({ ok: false } as never);
|
||||
jest.mocked(timersPromises.setTimeout).mockImplementationOnce((async (ms, value, options) => {
|
||||
const sleeping = jest.requireActual('node:timers/promises').setTimeout(ms, value, options);
|
||||
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
|
||||
return sleeping;
|
||||
}) as typeof timersPromises.setTimeout);
|
||||
await expect(
|
||||
runSessionAsync(
|
||||
logger,
|
||||
undefined,
|
||||
runtimePlatform,
|
||||
bootSimulator
|
||||
? { boot_simulator: { value: true }, device_identifier: { value: 'chosen-device' } }
|
||||
: {}
|
||||
)
|
||||
).resolves.toBeUndefined();
|
||||
expect(stopAppium).toHaveBeenCalledTimes(1);
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('does not open the preview after Stop while the Appium tunnel is opening', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
let tunnelEntered!: () => void;
|
||||
const entered = new Promise<void>(resolve => {
|
||||
tunnelEntered = resolve;
|
||||
});
|
||||
let finishTunnel!: () => void;
|
||||
const pendingTunnel = new Promise<void>(resolve => {
|
||||
finishTunnel = resolve;
|
||||
});
|
||||
const stopTunnel = jest.fn();
|
||||
jest.mocked(startNgrokTunnelAsync).mockImplementationOnce(async () => {
|
||||
tunnelEntered();
|
||||
await pendingTunnel;
|
||||
return { url: 'https://appium.example.com', subdomainId: 'appium', stopAsync: stopTunnel };
|
||||
});
|
||||
const openPreviewAsync = jest.fn();
|
||||
const finishHost = jest.fn();
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
|
||||
openPreviewAsync,
|
||||
finishAsync: finishHost,
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
});
|
||||
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
|
||||
boot_simulator: { value: true },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
});
|
||||
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(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
expect(stopAppium).toHaveBeenCalledTimes(1);
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ends normally when Stop interrupts the LOCAL-guard Simulator shutdown', async () => {
|
||||
jest
|
||||
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
|
||||
.mockResolvedValueOnce({ SERVE_SIM_ADDITIONAL_DYLIBS: '/guard.dylib' });
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getDeviceAsync)
|
||||
.mockResolvedValueOnce({ state: 'Booted' } as never);
|
||||
let shutdownEntered!: () => void;
|
||||
const entered = new Promise<void>(resolve => {
|
||||
shutdownEntered = resolve;
|
||||
});
|
||||
jest.mocked(spawn).mockImplementationOnce((_command, _args, options) => {
|
||||
shutdownEntered();
|
||||
return new Promise((_resolve, reject) =>
|
||||
options!.signal!.addEventListener(
|
||||
'abort',
|
||||
() => reject(new Error('Shutdown process aborted', { cause: options!.signal!.reason })),
|
||||
{ once: true }
|
||||
)
|
||||
) as ReturnType<typeof spawn>;
|
||||
});
|
||||
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
|
||||
boot_simulator: { value: true },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
});
|
||||
running.catch(() => {});
|
||||
await entered;
|
||||
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
|
||||
await expect(running).resolves.toBeUndefined();
|
||||
expect(ensureFfmpegInstalledOnceAsync).not.toHaveBeenCalled();
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'preserves preview settings with Simulator startup %s',
|
||||
async bootSimulator => {
|
||||
@@ -433,6 +918,154 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
[BuildRuntimePlatform.DARWIN, false],
|
||||
[BuildRuntimePlatform.LINUX, false],
|
||||
[BuildRuntimePlatform.DARWIN, true],
|
||||
])(
|
||||
'handles Stop during %s setup, preserving teardown failure=%s',
|
||||
async (runtimePlatform, teardownFails) => {
|
||||
let setupEntered!: () => void;
|
||||
const entered = new Promise<void>(resolve => {
|
||||
setupEntered = resolve;
|
||||
});
|
||||
jest.mocked(ensureFfmpegInstalledOnceAsync).mockImplementationOnce(({ signal }) => {
|
||||
setupEntered();
|
||||
return new Promise<void>(resolve =>
|
||||
signal!.addEventListener('abort', () => resolve(), { once: true })
|
||||
);
|
||||
});
|
||||
const teardownError = new Error('host teardown failed');
|
||||
const finishHost = jest.fn().mockImplementation(async () => {
|
||||
if (teardownFails) {
|
||||
throw teardownError;
|
||||
}
|
||||
});
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
|
||||
openPreviewAsync: jest.fn(),
|
||||
finishAsync: finishHost,
|
||||
getLastPreviewActivityAt: () => undefined,
|
||||
});
|
||||
}
|
||||
const running = runSessionAsync(
|
||||
{ info: jest.fn(), warn: jest.fn() },
|
||||
undefined,
|
||||
runtimePlatform,
|
||||
runtimePlatform === BuildRuntimePlatform.DARWIN
|
||||
? { boot_simulator: { value: true }, device_identifier: { value: 'chosen-ios-udid' } }
|
||||
: {}
|
||||
);
|
||||
running.catch(() => {});
|
||||
await entered;
|
||||
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(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(watch.finishAsync).toHaveBeenCalled();
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
[BuildRuntimePlatform.DARWIN, false],
|
||||
[BuildRuntimePlatform.DARWIN, true],
|
||||
[BuildRuntimePlatform.LINUX, false],
|
||||
])(
|
||||
'preserves failed installer cleanup after Stop on %s with Simulator startup=%s',
|
||||
async (runtimePlatform, bootSimulator) => {
|
||||
const cleanupError = new Error('Appium home removal failed: EACCES');
|
||||
const remove = fs.promises.rm.bind(fs.promises);
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid: 'chosen-ios-udid' } as never]);
|
||||
let enteredInstall!: () => void;
|
||||
const installEntered = new Promise<void>(resolve => {
|
||||
enteredInstall = resolve;
|
||||
});
|
||||
let closeInstaller!: (error: Error) => void;
|
||||
jest.mocked(spawn).mockImplementationOnce(() => {
|
||||
enteredInstall();
|
||||
return Object.assign(
|
||||
new Promise((_resolve, reject) => {
|
||||
closeInstaller = reject;
|
||||
}),
|
||||
{ child: { pid: 12345 } }
|
||||
) as never;
|
||||
});
|
||||
jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
|
||||
const rm = jest.spyOn(fs.promises, 'rm').mockRejectedValueOnce(cleanupError);
|
||||
const running = runSessionAsync(
|
||||
{ info: jest.fn(), warn: jest.fn() },
|
||||
undefined,
|
||||
runtimePlatform,
|
||||
bootSimulator
|
||||
? { boot_simulator: { value: true }, device_identifier: { value: 'chosen-ios-udid' } }
|
||||
: {}
|
||||
);
|
||||
const observed = running.catch(() => {});
|
||||
let home: string | undefined;
|
||||
try {
|
||||
await installEntered;
|
||||
home = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
|
||||
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
|
||||
watch.stop();
|
||||
closeInstaller(new Error('installer interrupted'));
|
||||
await expect(running).rejects.toBe(cleanupError);
|
||||
expect(rm).toHaveBeenCalledWith(home, { recursive: true, force: true });
|
||||
expect(fs.existsSync(home)).toBe(true);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(watch.finishAsync).toHaveBeenCalled();
|
||||
} finally {
|
||||
closeInstaller?.(new Error('test cleanup'));
|
||||
await observed;
|
||||
if (home) {
|
||||
await remove(home, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('preserves an Android installer failure observed before a later Stop', async () => {
|
||||
const failed = new Error('Appium install failed first');
|
||||
let observedFailure!: () => void;
|
||||
const installFailed = new Promise<void>(resolve => {
|
||||
observedFailure = resolve;
|
||||
});
|
||||
jest.mocked(spawn).mockImplementationOnce((() => {
|
||||
observedFailure();
|
||||
return Promise.reject(failed);
|
||||
}) as never);
|
||||
jest
|
||||
.mocked(ensureFfmpegInstalledOnceAsync)
|
||||
.mockImplementationOnce(
|
||||
({ signal }) =>
|
||||
new Promise<void>(resolve =>
|
||||
signal!.addEventListener('abort', () => resolve(), { once: true })
|
||||
)
|
||||
);
|
||||
const running = runSessionAsync(
|
||||
{ info: jest.fn(), warn: jest.fn() },
|
||||
undefined,
|
||||
BuildRuntimePlatform.LINUX
|
||||
);
|
||||
running.catch(() => {});
|
||||
await installFailed;
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
|
||||
watch.stop();
|
||||
await expect(running).rejects.toBe(failed);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('selects Xcode only once for an already-prepared iOS session', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockClear();
|
||||
@@ -749,9 +1382,9 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
it('keeps the readiness error when Appium cannot be stopped', async () => {
|
||||
let now = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
jest.mocked(sleepAsync).mockImplementation(async ms => {
|
||||
now += ms;
|
||||
});
|
||||
jest.mocked(timersPromises.setTimeout).mockImplementation((async ms => {
|
||||
now += Number(ms ?? 0);
|
||||
}) as typeof timersPromises.setTimeout);
|
||||
jest.mocked(turtleFetch).mockRejectedValue(new Error('connect ECONNREFUSED'));
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
|
||||
|
||||
+437
-2
@@ -24,6 +24,13 @@ 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';
|
||||
|
||||
@@ -50,6 +57,7 @@ jest.mock('../../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'),
|
||||
@@ -80,6 +88,9 @@ 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');
|
||||
@@ -130,6 +141,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
|
||||
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 });
|
||||
@@ -143,6 +155,363 @@ 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);
|
||||
@@ -605,6 +974,72 @@ 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() };
|
||||
@@ -892,7 +1327,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
});
|
||||
|
||||
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
|
||||
'keeps %s prepared setup commands outside the new cancellation path',
|
||||
'makes %s prepared setup commands cancellable',
|
||||
async runtimePlatform => {
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await buildFunction.fn!(
|
||||
@@ -911,7 +1346,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
);
|
||||
expect(spawn).toHaveBeenCalledTimes(3);
|
||||
for (const [, , options] of jest.mocked(spawn).mock.calls) {
|
||||
expect(options).not.toHaveProperty('detached');
|
||||
expect(options).toHaveProperty('detached', true);
|
||||
expect(options).not.toHaveProperty('signal');
|
||||
}
|
||||
expect(killProcessGroup).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
@@ -6,79 +5,12 @@ 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;
|
||||
|
||||
|
||||
+243
-6
@@ -12,9 +12,17 @@ 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'),
|
||||
@@ -39,6 +47,9 @@ 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;
|
||||
@@ -48,7 +59,8 @@ const stopAsync = jest.fn();
|
||||
|
||||
async function runAsync(
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
launchInputs: Record<string, { value: unknown }> = {}
|
||||
launchInputs: Record<string, { value: unknown }> = {},
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx);
|
||||
await buildFunction.fn!(
|
||||
@@ -63,6 +75,7 @@ async function runAsync(
|
||||
...launchInputs,
|
||||
},
|
||||
outputs: {},
|
||||
signal,
|
||||
env,
|
||||
} as never
|
||||
);
|
||||
@@ -86,9 +99,131 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
|
||||
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];
|
||||
@@ -130,6 +265,108 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
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({
|
||||
@@ -246,7 +483,9 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
env: childEnv,
|
||||
logger,
|
||||
timeoutMs: 60_000,
|
||||
signal: undefined,
|
||||
preview: { baseDomain: 'tunnel.example.com', authtoken: 'ngrok-token' },
|
||||
signal: expect.any(AbortSignal),
|
||||
onStartupError: expect.any(Function),
|
||||
...(selectsXcode
|
||||
? {
|
||||
iosSimulatorUdid: 'SELECTED-IOS-UDID',
|
||||
@@ -265,10 +504,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
networkCaptureFields: [],
|
||||
});
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
|
||||
expect(host.openPreviewAsync).toHaveBeenCalledWith({
|
||||
baseDomain: 'tunnel.example.com',
|
||||
authtoken: 'ngrok-token',
|
||||
});
|
||||
expect(host.openPreviewAsync).toHaveBeenCalledWith();
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
@@ -278,6 +514,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
previewApiUrl: 'https://web-preview.example.test',
|
||||
},
|
||||
logger,
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { collectAndroidEmulatorLogsAsync } from '../utils/androidEmulatorLogs';
|
||||
import { getDeviceRunSessionIdOrThrow } from '../utils/remoteDeviceRunSession';
|
||||
|
||||
export function createCollectAndroidEmulatorLogsBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'collect_android_emulator_logs',
|
||||
name: 'Collect Android emulator logs',
|
||||
__metricsId: 'eas/collect_android_emulator_logs',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.LINUX],
|
||||
fn: async ({ logger, global }, { env }) => {
|
||||
try {
|
||||
await collectAndroidEmulatorLogsAsync(ctx, {
|
||||
global,
|
||||
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
|
||||
logger,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not collect Android emulator logs', error);
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Could not collect Android logs; the session result is unchanged.'
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BuildJob, Ios, ManagedArtifactType, Platform } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildStepContext } from '@expo/steps';
|
||||
import { BuildFunction, BuildStepContext, BuildStepOutput } from '@expo/steps';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { findXcodeBuildLogsPathAsync } from '../../ios/xcodeBuildLogs';
|
||||
@@ -13,7 +13,13 @@ export function createFindAndUploadBuildArtifactsBuildFunction(
|
||||
id: 'find_and_upload_build_artifacts',
|
||||
name: 'Find and upload build artifacts',
|
||||
__metricsId: 'eas/find_and_upload_build_artifacts',
|
||||
fn: async stepCtx => {
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'application_archive_path',
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { outputs }) => {
|
||||
// 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.
|
||||
@@ -24,7 +30,10 @@ export function createFindAndUploadBuildArtifactsBuildFunction(
|
||||
let firstError: any = null;
|
||||
|
||||
try {
|
||||
await uploadApplicationArchivesAsync({ ctx, stepCtx });
|
||||
const [firstArchivePath] = await uploadApplicationArchivesAsync({ ctx, stepCtx });
|
||||
if (firstArchivePath) {
|
||||
outputs.application_archive_path.set(firstArchivePath);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
stepCtx.logger.error({ err }, `Failed to upload application archives.`);
|
||||
firstError ||= err;
|
||||
@@ -68,7 +77,7 @@ async function uploadApplicationArchivesAsync({
|
||||
}: {
|
||||
ctx: CustomBuildContext<BuildJob>;
|
||||
stepCtx: BuildStepContext;
|
||||
}): Promise<void> {
|
||||
}): Promise<string[]> {
|
||||
const applicationArchivePatternOrPath =
|
||||
ctx.job.platform === Platform.ANDROID
|
||||
? (ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}')
|
||||
@@ -99,6 +108,8 @@ async function uploadApplicationArchivesAsync({
|
||||
logger,
|
||||
});
|
||||
logger.info('Done.');
|
||||
|
||||
return applicationArchives;
|
||||
}
|
||||
|
||||
async function uploadBuildArtifacts({
|
||||
|
||||
@@ -117,7 +117,7 @@ export async function readIpaInfoAsync(ipaPath: string): Promise<IpaInfo> {
|
||||
}
|
||||
}
|
||||
|
||||
function parseInfoPlistBuffer(data: Buffer): Record<string, unknown> {
|
||||
export function parseInfoPlistBuffer(data: Buffer): Record<string, unknown> {
|
||||
const isBinaryPlist = data.subarray(0, 8).toString('ascii') === 'bplist00';
|
||||
if (isBinaryPlist) {
|
||||
const parsedBinaryPlists = bplistParser.parseBuffer(data);
|
||||
|
||||
@@ -9,6 +9,8 @@ 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,
|
||||
@@ -23,6 +25,7 @@ import { type ServeSimApplicationOptions } from '../utils/remoteDeviceRunSession
|
||||
import { type StartupTasks } from '../utils/startupTasks';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
DeviceRunSessionStoppedError,
|
||||
finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
@@ -98,6 +101,8 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
frameAncestors,
|
||||
corsOrigins,
|
||||
tasks,
|
||||
warm,
|
||||
onStarted,
|
||||
device,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
@@ -114,6 +119,9 @@ 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>;
|
||||
@@ -137,6 +145,9 @@ 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;
|
||||
@@ -144,7 +155,70 @@ 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,
|
||||
@@ -160,55 +234,8 @@ 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.`);
|
||||
|
||||
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;
|
||||
});
|
||||
return daemonInfo;
|
||||
}
|
||||
const deviceReady = externallyBootedDevice?.ready;
|
||||
|
||||
try {
|
||||
@@ -227,7 +254,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
|
||||
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
signal: tasks.signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
@@ -242,18 +269,23 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
if (sessionStart === 'stopped') {
|
||||
return;
|
||||
}
|
||||
await onStarted?.();
|
||||
tasks.signal.throwIfAborted();
|
||||
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
|
||||
artifactUploads = 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: AGENT_DEVICE_STATE_DIR,
|
||||
stateDir: warm?.agentDeviceStateDir ?? AGENT_DEVICE_STATE_DIR,
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -273,37 +305,64 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
: 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.
|
||||
await Promise.allSettled([agentDeviceStartup, sessionHostStartup, deviceReady]);
|
||||
const startupResults = 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],
|
||||
[
|
||||
'agent-device daemon',
|
||||
startedDaemon &&
|
||||
(async () => {
|
||||
try {
|
||||
if (eventCollection) {
|
||||
await stopAgentDeviceEventCollectionSafelyAsync({
|
||||
eventCollection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await startedDaemon.stopAsync();
|
||||
}
|
||||
})(),
|
||||
'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();
|
||||
})(),
|
||||
],
|
||||
['session host', sessionHost?.finishAsync()],
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,27 +15,33 @@ import {
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
|
||||
import { AndroidEmulatorLogSourcesSchema } from '../../utils/androidEmulatorLogSources';
|
||||
import { getAndroidEmulatorLogCapture } from '../utils/androidEmulatorLogs';
|
||||
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';
|
||||
@@ -49,23 +55,30 @@ 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, downloading the app alongside daemon startup.
|
||||
* Starts a device session using an optional local app artifact, or one it downloads.
|
||||
* On iOS, serve-sim boots the selected Simulator, then installs and launches
|
||||
* the downloaded app. Build-tools verifies any egress guard before install and launch.
|
||||
* the app. Build-tools verifies any egress guard before install and launch.
|
||||
*/
|
||||
export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
export function createStartAgentDeviceSessionBuildFunction(
|
||||
ctx: CustomBuildContext,
|
||||
{ warm = false }: { warm?: boolean } = {}
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_agent_device_session',
|
||||
id: warm ? 'start_warm_agent_device_session' : 'start_agent_device_session',
|
||||
name: 'Start agent-device session',
|
||||
__metricsId: 'eas/start_agent_device_session',
|
||||
__metricsId: warm ? 'eas/start_warm_agent_device_session' : 'eas/start_agent_device_session',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'android_log_sources',
|
||||
required: false,
|
||||
defaultValue: ['emulator-output'],
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
// Device. On iOS a Simulator name or UDID, on Android an AVD hardware profile.
|
||||
BuildStepInput.createProvider({
|
||||
id: 'device_identifier',
|
||||
@@ -93,7 +106,13 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
// Application. Pass at most one of build_id and application_archive_url.
|
||||
// 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.
|
||||
BuildStepInput.createProvider({
|
||||
id: 'build_id',
|
||||
required: false,
|
||||
@@ -104,16 +123,7 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'launch_args',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'open_url',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
...createServeSimLaunchInputProviders(),
|
||||
// Session.
|
||||
...createNetworkCaptureInputProviders(),
|
||||
createPublicPreviewInputProvider(),
|
||||
@@ -144,22 +154,41 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
// 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;
|
||||
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 launchAppIdentifier = inputs.launch_app_identifier.value as string | undefined;
|
||||
const launchArgs = parseLaunchArgsInput(inputs.launch_args.value);
|
||||
const openUrl =
|
||||
inputs.open_url.value === undefined ? undefined : parseOpenUrlInput(inputs.open_url.value);
|
||||
if (!hasApplication && (launchArgs.length > 0 || openUrl)) {
|
||||
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) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
'launch_args and open_url need an application: pass build_id or application_archive_url.'
|
||||
'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.'
|
||||
);
|
||||
}
|
||||
if (isIos) {
|
||||
@@ -167,6 +196,9 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
} else {
|
||||
validateAndroidLaunchOptions({ launchArgs, openUrl });
|
||||
}
|
||||
const logSources = isIos
|
||||
? []
|
||||
: AndroidEmulatorLogSourcesSchema.parse(inputs.android_log_sources.value);
|
||||
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
|
||||
const capture = parseNetworkCaptureInputs(
|
||||
{
|
||||
@@ -176,87 +208,123 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
{ runtimePlatform }
|
||||
);
|
||||
|
||||
const tasks = createStartupTasks(logger, signal);
|
||||
let downloaded: Promise<string> | undefined;
|
||||
let applicationReady: Promise<ServeSimApplicationOptions | void> | undefined;
|
||||
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;
|
||||
|
||||
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,
|
||||
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,
|
||||
env,
|
||||
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'],
|
||||
logger,
|
||||
signal: tasks.signal,
|
||||
});
|
||||
return artifactPath;
|
||||
})
|
||||
: undefined;
|
||||
})
|
||||
: {};
|
||||
const iosSimulatorUdid = iosStartup?.iosSimulatorUdid;
|
||||
if (iosSimulatorUdid) {
|
||||
logger.info(`Selected iOS Simulator: ${iosSimulatorUdid}.`);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
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();
|
||||
return {
|
||||
installAppPath: artifactPath,
|
||||
launchAppIdentifier: applicationIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
};
|
||||
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,
|
||||
logSources,
|
||||
logCapture: getAndroidEmulatorLogCapture(global, env),
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
});
|
||||
|
||||
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.');
|
||||
}
|
||||
tasks.signal.throwIfAborted();
|
||||
const { applicationIdentifier, activityName } = await installBuildAsync({
|
||||
artifactPath,
|
||||
runtimePlatform,
|
||||
@@ -273,39 +341,76 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
})
|
||||
: undefined;
|
||||
});
|
||||
}
|
||||
|
||||
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!),
|
||||
},
|
||||
});
|
||||
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;
|
||||
}
|
||||
} finally {
|
||||
tasks.abort(new Error('Agent-device session ended.'));
|
||||
await Promise.allSettled([downloaded, applicationReady]);
|
||||
await startupWatch.finishAsync();
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -20,8 +20,15 @@ import {
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { type AndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogCapture';
|
||||
import { retryAsync } from '../../utils/retry';
|
||||
import { resolveAndroidEmulatorLocalEgressAsync } from '../utils/androidLocalEgress';
|
||||
import {
|
||||
ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
type AndroidEmulatorLogSource,
|
||||
AndroidEmulatorLogSourcesSchema,
|
||||
} from '../../utils/androidEmulatorLogSources';
|
||||
import { getAndroidEmulatorLogCapture } from '../utils/androidEmulatorLogs';
|
||||
|
||||
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
|
||||
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
|
||||
@@ -33,6 +40,12 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
name: 'Start Android Emulator',
|
||||
__metricsId: 'eas/start_android_emulator',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'android_log_sources',
|
||||
required: false,
|
||||
defaultValue: [...ALL_ANDROID_EMULATOR_LOG_SOURCES],
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'device_name',
|
||||
required: false,
|
||||
@@ -78,10 +91,12 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
required: true,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { inputs, outputs, env }) => {
|
||||
fn: async ({ logger, global }, { inputs, outputs, env }) => {
|
||||
const logSources = AndroidEmulatorLogSourcesSchema.parse(inputs.android_log_sources.value);
|
||||
const logcatDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
|
||||
);
|
||||
const logCapture = getAndroidEmulatorLogCapture(global, env);
|
||||
outputs.logcat_directory.set(logcatDirectory);
|
||||
|
||||
const deviceName = `${inputs.device_name.value}` as AndroidVirtualDeviceName;
|
||||
@@ -95,6 +110,8 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
lcdHeight: inputs.lcd_height.value as number | undefined,
|
||||
lcdDensity: inputs.lcd_density.value as number | undefined,
|
||||
logcatDirectory,
|
||||
logSources,
|
||||
logCapture,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
@@ -133,6 +150,8 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
deviceName: cloneIdentifier,
|
||||
env,
|
||||
logcatDirectory,
|
||||
logSources,
|
||||
logCapture,
|
||||
});
|
||||
cloneSerialId = startResult.serialId;
|
||||
|
||||
@@ -202,6 +221,8 @@ export async function startAndroidEmulatorAsync({
|
||||
lcdHeight,
|
||||
lcdDensity,
|
||||
logcatDirectory,
|
||||
logSources,
|
||||
logCapture,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
@@ -212,6 +233,8 @@ export async function startAndroidEmulatorAsync({
|
||||
lcdHeight?: number;
|
||||
lcdDensity?: number;
|
||||
logcatDirectory: string;
|
||||
logSources: readonly AndroidEmulatorLogSource[];
|
||||
logCapture: AndroidEmulatorLogCapture;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<{
|
||||
@@ -293,6 +316,8 @@ export async function startAndroidEmulatorAsync({
|
||||
deviceName,
|
||||
env,
|
||||
logcatDirectory,
|
||||
logSources,
|
||||
logCapture,
|
||||
...(localEgress ? { launchGate: localEgress.launchGate } : {}),
|
||||
});
|
||||
attemptSerialId = startResult.serialId;
|
||||
|
||||
@@ -22,6 +22,12 @@ 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,
|
||||
@@ -39,7 +45,9 @@ 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,
|
||||
@@ -73,13 +81,14 @@ const AppiumInstalledDriversSchema = z.record(
|
||||
);
|
||||
|
||||
export function createStartAppiumRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
ctx: CustomBuildContext,
|
||||
{ warm = false }: { warm?: boolean } = {}
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_appium_remote_session',
|
||||
id: warm ? 'start_warm_appium_remote_session' : 'start_appium_remote_session',
|
||||
name: 'Start Appium remote session',
|
||||
__metricsId: 'eas/start_appium_remote_session',
|
||||
__metricsId: warm ? 'eas/start_warm_appium_remote_session' : 'eas/start_appium_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createIosSessionStartupInputProviders(),
|
||||
@@ -107,252 +116,327 @@ 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;
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
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,
|
||||
const startupStop = new AbortController();
|
||||
const watchSignal = signal
|
||||
? AbortSignal.any([signal, startupStop.signal])
|
||||
: startupStop.signal;
|
||||
const startupWatch = watchDeviceRunSessionStartup({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
signal,
|
||||
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
|
||||
});
|
||||
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: AppiumDevice = iosStartup
|
||||
? {
|
||||
platformName: 'iOS',
|
||||
automationName: 'XCUITest',
|
||||
driverName: 'xcuitest',
|
||||
udid: iosStartup.iosSimulatorUdid,
|
||||
}
|
||||
: await resolveAppiumDeviceAsync({ runtimePlatform, env });
|
||||
const startupAbortController = new AbortController();
|
||||
const startupSignal = signal
|
||||
? AbortSignal.any([signal, startupAbortController.signal])
|
||||
: startupAbortController.signal;
|
||||
const setupSignal = iosStartup ? startupSignal : undefined;
|
||||
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;
|
||||
const startHost = () =>
|
||||
startDeviceSessionHostAsync(ctx, {
|
||||
...iosStartup,
|
||||
previewDeviceId: device.udid,
|
||||
separateLogPhase: true,
|
||||
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,
|
||||
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),
|
||||
signal: startupSignal,
|
||||
onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined,
|
||||
...launch,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
}).catch(error => {
|
||||
if (iosStartup) {
|
||||
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,
|
||||
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: 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,
|
||||
signal: setupSignal,
|
||||
onError: iosStartup ? error => startupAbortController.abort(error) : undefined,
|
||||
}).catch(error => {
|
||||
if (iosStartup) {
|
||||
startupAbortController.abort(error);
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
),
|
||||
asyncResult(
|
||||
ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger, signal: setupSignal })
|
||||
),
|
||||
]);
|
||||
const installation = appiumInstall.enforceValue();
|
||||
appiumHome = installation.appiumHome;
|
||||
setupSignal?.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: setupSignal,
|
||||
});
|
||||
|
||||
setupSignal?.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: setupSignal });
|
||||
if (hostStartup) {
|
||||
sessionHost = (await hostStartup).enforceValue();
|
||||
}
|
||||
setupSignal?.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,
|
||||
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();
|
||||
const webPreview = await sessionHost.openPreviewAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
});
|
||||
|
||||
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
appiumUrl: appiumTunnel.url,
|
||||
capabilities: {
|
||||
platformName: device.platformName,
|
||||
'appium:automationName': device.automationName,
|
||||
'appium:udid': device.udid,
|
||||
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,
|
||||
},
|
||||
webPreviewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
if (sessionStart === 'stopped') {
|
||||
return;
|
||||
}
|
||||
...(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,
|
||||
});
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
sessionFailed = true;
|
||||
throw setupSignal?.aborted ? setupSignal.reason : error;
|
||||
} finally {
|
||||
startupAbortController.abort(new Error('Appium session ended.'));
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
teardown: [
|
||||
['Appium tunnel', appiumTunnel?.stopAsync()],
|
||||
[
|
||||
'Appium server',
|
||||
(async () => {
|
||||
try {
|
||||
await eventCollection?.stopAsync();
|
||||
} finally {
|
||||
try {
|
||||
await appiumProcess?.stopAsync();
|
||||
} finally {
|
||||
if (appiumHome) {
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
})(),
|
||||
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,
|
||||
}),
|
||||
],
|
||||
['session host', hostStartup?.then(result => result.value?.finishAsync())],
|
||||
],
|
||||
});
|
||||
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 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();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== startupWatch.stopped) {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await startupWatch.finishAsync();
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -528,11 +612,11 @@ async function waitForAppiumReadyAsync({
|
||||
}: {
|
||||
appiumProcess: { getOutput: () => string };
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
signal: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const deadline = Date.now() + APPIUM_STARTUP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
signal?.throwIfAborted();
|
||||
signal.throwIfAborted();
|
||||
try {
|
||||
const response = await turtleFetch(`http://${APPIUM_HOST}:${APPIUM_PORT}/status`, 'GET', {
|
||||
timeout: 2_000,
|
||||
@@ -540,16 +624,17 @@ async function waitForAppiumReadyAsync({
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
signal.throwIfAborted();
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
signal?.throwIfAborted();
|
||||
if (signal) {
|
||||
signal.throwIfAborted();
|
||||
try {
|
||||
await setTimeoutAsync(1_000, undefined, { signal });
|
||||
} else {
|
||||
await sleepAsync(1_000);
|
||||
} catch (error) {
|
||||
signal.throwIfAborted();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const output = appiumProcess.getOutput();
|
||||
|
||||
@@ -12,7 +12,6 @@ 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';
|
||||
@@ -21,6 +20,12 @@ 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,
|
||||
@@ -36,7 +41,9 @@ import { isProcessDescendantOfAsync, killProcessGroup } from '../../utils/proces
|
||||
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,
|
||||
@@ -84,13 +91,14 @@ const ArgentToolServerStateSchema = z.object({
|
||||
type ArgentToolServerState = z.infer<typeof ArgentToolServerStateSchema>;
|
||||
|
||||
export function createStartArgentRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
ctx: CustomBuildContext,
|
||||
{ warm = false }: { warm?: boolean } = {}
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_argent_remote_session',
|
||||
id: warm ? 'start_warm_argent_remote_session' : 'start_argent_remote_session',
|
||||
name: 'Start argent remote session',
|
||||
__metricsId: 'eas/start_argent_remote_session',
|
||||
__metricsId: warm ? 'eas/start_warm_argent_remote_session' : 'eas/start_argent_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createIosSessionStartupInputProviders(),
|
||||
@@ -129,313 +137,392 @@ 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;
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
|
||||
validateControllerVersion({
|
||||
packageName: ARGENT_PACKAGE_NAME,
|
||||
minimumVersion: MIN_ARGENT_REMOTE_SESSION_VERSION,
|
||||
packageVersion,
|
||||
logger,
|
||||
});
|
||||
const versionSpec = packageVersion ?? 'latest';
|
||||
const { runtimePlatform } = global;
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
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,
|
||||
const startupStop = new AbortController();
|
||||
const watchSignal = signal
|
||||
? AbortSignal.any([signal, startupStop.signal])
|
||||
: startupStop.signal;
|
||||
const startupWatch = watchDeviceRunSessionStartup({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
signal,
|
||||
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
|
||||
});
|
||||
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}).`
|
||||
);
|
||||
|
||||
// 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);
|
||||
|
||||
const startupAbortController = new AbortController();
|
||||
const startupSignal = signal
|
||||
? AbortSignal.any([signal, startupAbortController.signal])
|
||||
: startupAbortController.signal;
|
||||
const setupSignal = iosStartup ? startupSignal : undefined;
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
signal: startupSignal,
|
||||
onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined,
|
||||
...launch,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
}).catch(error => {
|
||||
if (iosStartup) {
|
||||
startupAbortController.abort(error);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
if (iosStartup) {
|
||||
hostStartup = asyncResult(startHost());
|
||||
}
|
||||
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 },
|
||||
setupSignal
|
||||
);
|
||||
|
||||
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 },
|
||||
setupSignal
|
||||
);
|
||||
|
||||
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 },
|
||||
setupSignal
|
||||
);
|
||||
} catch (err) {
|
||||
setupSignal?.throwIfAborted();
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.'
|
||||
);
|
||||
}
|
||||
|
||||
setupSignal?.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}.`);
|
||||
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,
|
||||
signal: setupSignal,
|
||||
});
|
||||
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();
|
||||
}
|
||||
setupSignal?.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: 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 (hostStartup ??= asyncResult(startHost()))).enforceValue();
|
||||
const webPreview = await sessionHost.openPreviewAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
});
|
||||
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,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
toolsUrl: publicToolsUrl,
|
||||
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
|
||||
webPreviewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture?.value,
|
||||
networkCaptureFields: inputs.network_capture_fields?.value,
|
||||
},
|
||||
logger,
|
||||
});
|
||||
if (sessionStart === 'stopped') {
|
||||
return;
|
||||
}
|
||||
{ runtimePlatform }
|
||||
);
|
||||
logger.info(
|
||||
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
maxDurationSeconds,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
sessionFailed = true;
|
||||
throw setupSignal?.aborted ? setupSignal.reason : error;
|
||||
} finally {
|
||||
startupAbortController.abort(new Error('Argent session ended.'));
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
teardown: [
|
||||
['Argent tunnel', toolsTunnel?.stopAsync()],
|
||||
[
|
||||
'Argent tool-server',
|
||||
(async () => {
|
||||
try {
|
||||
if (eventCollection) {
|
||||
await stopArgentEventCollectionSafelyAsync({
|
||||
eventCollection,
|
||||
deviceRunSessionId,
|
||||
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);
|
||||
|
||||
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(),
|
||||
}
|
||||
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.'
|
||||
);
|
||||
: 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]);
|
||||
}
|
||||
} finally {
|
||||
await argentServer?.stopAsync();
|
||||
}
|
||||
})(),
|
||||
if (result.reason instanceof AggregateError) {
|
||||
throw result.reason;
|
||||
}
|
||||
await result.value?.finishAsync();
|
||||
}),
|
||||
],
|
||||
],
|
||||
['session host', hostStartup?.then(result => 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();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== startupWatch.stopped) {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await startupWatch.finishAsync();
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -463,36 +550,6 @@ export async function stopArgentEventCollectionSafelyAsync({
|
||||
}
|
||||
}
|
||||
|
||||
export function warnIfArgentPackageVersionCannotBeVerified({
|
||||
packageVersion,
|
||||
logger,
|
||||
}: {
|
||||
packageVersion: string | undefined;
|
||||
logger: bunyan;
|
||||
}): void {
|
||||
if (!packageVersion || packageVersion === 'latest') {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function runArgentSetupCommandAsync(
|
||||
command: string,
|
||||
args: string[],
|
||||
|
||||
@@ -7,7 +7,13 @@ 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,
|
||||
@@ -38,13 +44,16 @@ import {
|
||||
const STARTUP_TIMEOUT_MS = 60_000;
|
||||
|
||||
export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
ctx: CustomBuildContext,
|
||||
{ warm = false }: { warm?: boolean } = {}
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_serve_sim_remote_session',
|
||||
id: warm ? 'start_warm_serve_sim_remote_session' : 'start_serve_sim_remote_session',
|
||||
name: 'Start web preview remote session',
|
||||
__metricsId: 'eas/start_serve_sim_remote_session',
|
||||
__metricsId: warm
|
||||
? 'eas/start_warm_serve_sim_remote_session'
|
||||
: 'eas/start_serve_sim_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createIosSessionStartupInputProviders(),
|
||||
@@ -88,103 +97,155 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
'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 { 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,
|
||||
const startupStop = new AbortController();
|
||||
const watchSignal = signal
|
||||
? AbortSignal.any([signal, startupStop.signal])
|
||||
: startupStop.signal;
|
||||
const startupWatch = watchDeviceRunSessionStartup({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
signal,
|
||||
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
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 {
|
||||
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.'
|
||||
);
|
||||
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
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,
|
||||
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,
|
||||
signal: startupSignal,
|
||||
});
|
||||
if (sessionStart === 'stopped') {
|
||||
return;
|
||||
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);
|
||||
}
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
const sessionHost = await startDeviceSessionHostAsync(
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
maxDurationSeconds,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
getLastPreviewActivityAt: () => sessionHost.getLastPreviewActivityAt(),
|
||||
}
|
||||
: undefined,
|
||||
{
|
||||
...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;
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
} finally {
|
||||
await sessionHost.finishAsync();
|
||||
await startupWatch.finishAsync();
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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 = 'serve-sim:recording-started';
|
||||
const SERVE_SIM_RECORDING_STARTED_LINE = /(?:^|\n)serve-sim:recording-started\r?\n/;
|
||||
// The serve-sim package the session host started; every recorder runs `record-video` from it.
|
||||
let serveSimPackageSpec: string | null = null;
|
||||
|
||||
@@ -57,11 +57,19 @@ 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. */
|
||||
@@ -81,6 +89,52 @@ 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.');
|
||||
@@ -95,6 +149,8 @@ export namespace IosSimulatorRecordingUtils {
|
||||
logger,
|
||||
recordingsRootDirectory,
|
||||
activeRecordings: new Map(),
|
||||
adoptedRecordings: new Map(),
|
||||
runtimeDisplayNames: new Map(),
|
||||
completedRecordings: [],
|
||||
recordingFailureCounts: new Map(),
|
||||
failedAttemptCounts: new Map(),
|
||||
@@ -190,6 +246,17 @@ 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()
|
||||
);
|
||||
@@ -243,6 +310,9 @@ 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] : []
|
||||
@@ -262,7 +332,14 @@ async function pollIosSimulatorRecordingsAsync(
|
||||
for (const device of bootedDevices) {
|
||||
const token = readyServers.get(device.udid);
|
||||
const packageSpec = serveSimPackageSpec;
|
||||
if (!token || !packageSpec) {
|
||||
if (
|
||||
!token ||
|
||||
!packageSpec ||
|
||||
[...session.adoptedRecordings.values()].some(
|
||||
recording =>
|
||||
recording.udid === device.udid.toUpperCase() && recording.serverToken === token
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (session.serveBearerTokens.get(device.udid) !== token) {
|
||||
@@ -507,14 +584,19 @@ function captureProcessOutput(recordingProcess: ChildProcess): {
|
||||
} {
|
||||
let output = '';
|
||||
let started = false;
|
||||
const appendChunk = (chunk: Buffer | string): void => {
|
||||
output = `${output}${chunk.toString()}`;
|
||||
if (output.includes(SERVE_SIM_RECORDING_STARTED)) {
|
||||
started = true;
|
||||
}
|
||||
output = output.slice(-16_384);
|
||||
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);
|
||||
});
|
||||
};
|
||||
recordingProcess.stdout?.on('data', appendChunk);
|
||||
recordingProcess.stderr?.on('data', appendChunk);
|
||||
captureStreamOutput(recordingProcess.stdout);
|
||||
captureStreamOutput(recordingProcess.stderr);
|
||||
return { getOutput: () => output, hasStarted: () => started };
|
||||
}
|
||||
@@ -3,18 +3,20 @@ import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { type ChildProcess } from 'node:child_process';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { rm, writeFile } from 'node:fs/promises';
|
||||
import { readFile, 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(),
|
||||
@@ -144,7 +146,10 @@ 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();
|
||||
stderr.write('serve-sim:recording-started\n');
|
||||
child.stdout?.emit('data', 'Downloading');
|
||||
stderr.write('serve-sim:recording-');
|
||||
child.stdout?.emit('data', ' package');
|
||||
stderr.write('started\r\n');
|
||||
stderr.write('x'.repeat(17_000));
|
||||
const recordings = await finishing;
|
||||
|
||||
@@ -362,6 +367,32 @@ 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);
|
||||
@@ -537,3 +568,171 @@ 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,6 +1,7 @@
|
||||
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';
|
||||
@@ -15,6 +16,7 @@ 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> {
|
||||
@@ -276,6 +278,14 @@ 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();
|
||||
@@ -284,6 +294,257 @@ 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 () => {
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { type BuildStepGlobalContext } from '@expo/steps';
|
||||
import { once } from 'node:events';
|
||||
import fsPromises, {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { PassThrough, Readable } from 'node:stream';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { MAX_ANDROID_LOG_BYTES_PER_SOURCE } from '../../../utils/androidEmulatorLogCapture';
|
||||
import { type AndroidEmulatorLogSource } from '../../../utils/androidEmulatorLogSources';
|
||||
import {
|
||||
collectAndroidEmulatorLogsAsync,
|
||||
getAndroidEmulatorLogCapture,
|
||||
} from '../androidEmulatorLogs';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
|
||||
jest.mock('../deviceRunSessionArtifacts');
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const upload = jest.mocked(uploadDeviceRunSessionArtifactAsync);
|
||||
let directory: string;
|
||||
let emulatorPath: string;
|
||||
let logcatPath: string;
|
||||
let global: BuildStepGlobalContext;
|
||||
let logger: ReturnType<typeof createMockLogger>;
|
||||
let uploadedContents: Map<string, string>;
|
||||
|
||||
beforeEach(async () => {
|
||||
global = {} as BuildStepGlobalContext;
|
||||
logger = createMockLogger();
|
||||
uploadedContents = new Map();
|
||||
directory = await mkdtemp(path.join(os.tmpdir(), 'android-log-collection-'));
|
||||
emulatorPath = path.join(directory, 'device-attempt-1-emulator.log');
|
||||
logcatPath = path.join(directory, 'device-attempt-1-logcat.log');
|
||||
upload.mockReset().mockImplementation(async (_ctx, { filename, openStream }) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of openStream()) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
uploadedContents.set(filename, Buffer.concat(chunks).toString());
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
jest.restoreAllMocks();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function captureLog(
|
||||
filePath: string,
|
||||
source: AndroidEmulatorLogSource,
|
||||
text: string,
|
||||
workflow = global
|
||||
): Promise<void> {
|
||||
const capture = getAndroidEmulatorLogCapture(workflow, { DEVICE_RUN_SESSION_ID: 'session' });
|
||||
const input = Readable.from([Buffer.from(text)]);
|
||||
capture.attach(input, filePath, source);
|
||||
await once(input, 'end');
|
||||
await capture.finishFileAsync(filePath);
|
||||
}
|
||||
|
||||
function collect(): Promise<void> {
|
||||
return collectAndroidEmulatorLogsAsync(ctx, {
|
||||
global,
|
||||
deviceRunSessionId: 'session',
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ env: {}, expectedBytes: 20 * 1024 * 1024 + 1 },
|
||||
{ env: { DEVICE_RUN_SESSION_ID: 'session' }, expectedBytes: 20 * 1024 * 1024 },
|
||||
])('retains $expectedBytes with workflow environment $env', async ({ env, expectedBytes }) => {
|
||||
const capture = getAndroidEmulatorLogCapture(global, env);
|
||||
const input = Readable.from([Buffer.alloc(20 * 1024 * 1024 + 1, 'x')]);
|
||||
capture.attach(input, emulatorPath, 'emulator-output');
|
||||
await once(input, 'end');
|
||||
await capture.finishAsync();
|
||||
expect((await fsPromises.stat(emulatorPath)).size).toBe(expectedBytes);
|
||||
});
|
||||
|
||||
it('uses captured source records and ignores files outside the capture inventory', async () => {
|
||||
const first = path.join(directory, 'first.txt');
|
||||
const second = path.join(directory, 'second.txt');
|
||||
await captureLog(first, 'emulator-output', 'host output');
|
||||
await captureLog(second, 'device-logcat', 'Android output');
|
||||
await captureLog(path.join(directory, 'retry.txt'), 'emulator-output', 'retry output');
|
||||
await writeFile(emulatorPath, 'not captured');
|
||||
const elsewhere = path.join(directory, 'elsewhere');
|
||||
await mkdir(elsewhere);
|
||||
await writeFile(path.join(elsewhere, 'other-emulator.log'), 'another job');
|
||||
await symlink(path.join(elsewhere, 'other-emulator.log'), logcatPath);
|
||||
await collect();
|
||||
|
||||
expect([...uploadedContents.values()]).toEqual(['host output', 'Android output', 'retry output']);
|
||||
expect(upload).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
filename: 'first.txt',
|
||||
kind: 'simulator-log',
|
||||
metadata: expect.objectContaining({
|
||||
platform: 'android',
|
||||
source: 'emulator-output',
|
||||
scope: 'emulator-process',
|
||||
originalSizeBytes: 11,
|
||||
omittedBytes: 0,
|
||||
truncated: false,
|
||||
}),
|
||||
signal: expect.any(AbortSignal),
|
||||
attempts: 2,
|
||||
})
|
||||
);
|
||||
expect(upload).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
filename: 'second.txt',
|
||||
metadata: expect.objectContaining({ source: 'device-logcat', scope: 'device' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('seals a live producer before upload and keeps retry bodies identical without copying files', async () => {
|
||||
const input = new PassThrough();
|
||||
const capture = getAndroidEmulatorLogCapture(global, { DEVICE_RUN_SESSION_ID: 'session' });
|
||||
capture.attach(input, emulatorPath, 'emulator-output');
|
||||
input.write('before');
|
||||
const contents: string[] = [];
|
||||
upload.mockImplementationOnce(async (_ctx, { size, openStream, metadata }) => {
|
||||
expect(size).toBe(6);
|
||||
expect(metadata).toEqual(expect.objectContaining({ originalSizeBytes: 6, truncated: false }));
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
input.write(' ignored after finalization');
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of openStream()) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
contents.push(Buffer.concat(chunks).toString());
|
||||
}
|
||||
});
|
||||
try {
|
||||
await collect();
|
||||
expect(contents).toEqual(['before', 'before']);
|
||||
expect(await readFile(emulatorPath, 'utf8')).toBe('before');
|
||||
expect(await readdir(directory)).toEqual([path.basename(emulatorPath)]);
|
||||
expect(input.readableFlowing).toBe(true);
|
||||
const late = new PassThrough();
|
||||
capture.attach(late, logcatPath, 'device-logcat');
|
||||
late.end('not captured');
|
||||
expect(late.readableFlowing).toBe(true);
|
||||
await expect(fsPromises.stat(logcatPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
} finally {
|
||||
input.end();
|
||||
}
|
||||
});
|
||||
|
||||
it('continues with the other log after a failed upload and never repeats the collection', async () => {
|
||||
await captureLog(emulatorPath, 'emulator-output', 'host output');
|
||||
await captureLog(logcatPath, 'device-logcat', 'Android output');
|
||||
upload.mockRejectedValueOnce(new Error('storage unavailable'));
|
||||
await Promise.all([collect(), collect()]);
|
||||
await collect();
|
||||
expect(upload).toHaveBeenCalledTimes(2);
|
||||
expect(uploadedContents.get(path.basename(logcatPath))).toBe('Android output');
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.stringContaining('other logs will continue')
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a fresh 30 second budget for the next file after an upload times out', async () => {
|
||||
await captureLog(emulatorPath, 'emulator-output', 'host output');
|
||||
await captureLog(logcatPath, 'device-logcat', 'Android output');
|
||||
const controllers: AbortController[] = [];
|
||||
const timeout = jest.spyOn(AbortSignal, 'timeout').mockImplementation(() => {
|
||||
const controller = new AbortController();
|
||||
controllers.push(controller);
|
||||
return controller.signal;
|
||||
});
|
||||
upload.mockImplementationOnce(async (_ctx, { signal }) => {
|
||||
controllers[0].abort(new Error('upload timed out'));
|
||||
signal?.throwIfAborted();
|
||||
});
|
||||
await expect(collect()).resolves.toBeUndefined();
|
||||
expect(uploadedContents.get(path.basename(logcatPath))).toBe('Android output');
|
||||
expect(timeout.mock.calls).toEqual([[30_000], [30_000]]);
|
||||
expect(upload.mock.calls[1][1].signal?.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it('does not collect another workflow and warns when a captured file disappears', async () => {
|
||||
await captureLog(emulatorPath, 'emulator-output', 'other workflow', {} as BuildStepGlobalContext);
|
||||
await collect();
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
await captureLog(path.join(directory, 'missing.log'), 'emulator-output', 'removed');
|
||||
await rm(path.join(directory, 'missing.log'));
|
||||
await expect(collect()).resolves.toBeUndefined();
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.stringContaining('missing.log')
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads the tail of a truncated log with the retained size and omission metadata', async () => {
|
||||
const capture = getAndroidEmulatorLogCapture(global, { DEVICE_RUN_SESSION_ID: 'session' });
|
||||
const input = Readable.from([
|
||||
Buffer.concat([Buffer.from('head: '), Buffer.alloc(MAX_ANDROID_LOG_BYTES_PER_SOURCE, 'b')]),
|
||||
]);
|
||||
capture.attach(input, emulatorPath, 'emulator-output');
|
||||
await once(input, 'end');
|
||||
await collect();
|
||||
expect(upload).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
size: MAX_ANDROID_LOG_BYTES_PER_SOURCE,
|
||||
name: expect.stringContaining('truncated, last 20 MiB retained'),
|
||||
metadata: expect.objectContaining({
|
||||
originalSizeBytes: MAX_ANDROID_LOG_BYTES_PER_SOURCE + 6,
|
||||
omittedBytes: 6,
|
||||
truncated: true,
|
||||
}),
|
||||
})
|
||||
);
|
||||
const uploaded = uploadedContents.get(path.basename(emulatorPath));
|
||||
expect(uploaded).toHaveLength(MAX_ANDROID_LOG_BYTES_PER_SOURCE);
|
||||
expect(uploaded).not.toContain('head');
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
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.')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import { findUnlistedDeviceScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
import { uploadDeviceRunSessionFileAsync } from '../uploadDeviceRunSessionFile';
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
jest.unmock('node:fs');
|
||||
@@ -123,6 +124,41 @@ it('replays the full file after a transient PUT failure without recreating the u
|
||||
expect(streams.every(stream => stream.destroyed)).toBe(true);
|
||||
});
|
||||
|
||||
it('retries a PUT that fails before reading the body without aborting the file stream', async () => {
|
||||
let puts = 0;
|
||||
let replayed = 0;
|
||||
handle = (request, response) => {
|
||||
puts++;
|
||||
if (puts === 1) {
|
||||
response.statusCode = 503;
|
||||
response.flushHeaders();
|
||||
return;
|
||||
}
|
||||
request.on('data', chunk => {
|
||||
replayed += chunk.length;
|
||||
});
|
||||
request.on('end', () => response.end());
|
||||
};
|
||||
const file = path.join(directory, 'emulator.log');
|
||||
const payload = Buffer.alloc(8 * 1024 * 1024, '*');
|
||||
await writeFile(file, payload);
|
||||
await expect(
|
||||
uploadDeviceRunSessionFileAsync(ctx, {
|
||||
filePath: file,
|
||||
describe: () => ({
|
||||
deviceRunSessionId: 'drs-id',
|
||||
artifactId: 'log-id',
|
||||
name: 'Emulator log',
|
||||
filename: 'emulator.log',
|
||||
kind: 'simulator-log',
|
||||
}),
|
||||
attempts: 2,
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
expect(puts).toBe(2);
|
||||
expect(replayed).toBe(payload.length);
|
||||
});
|
||||
|
||||
it('keeps a slow PUT alive while its body is still flowing', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
|
||||
let received = 0;
|
||||
|
||||
+43
@@ -7,6 +7,7 @@ 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';
|
||||
@@ -266,6 +267,48 @@ describe('with a device run session token', () => {
|
||||
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(
|
||||
|
||||
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 { readIosApplicationIdentifierAsync } from '../iosAppArtifact';
|
||||
import { isPreinstalledExpoGoAsync, readIosApplicationIdentifierAsync } from '../iosAppArtifact';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.unmock('fs');
|
||||
@@ -61,3 +61,59 @@ 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,6 +155,53 @@ 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,
|
||||
|
||||
@@ -0,0 +1,873 @@
|
||||
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()));
|
||||
}
|
||||
});
|
||||
@@ -932,7 +932,7 @@ describe(waitForWebPreviewReadyAsync, () => {
|
||||
|
||||
describe(startNgrokTunnelAsync, () => {
|
||||
it.each(['rejected', 'stalled'] as const)(
|
||||
'retains a %s close failure on repeated stops',
|
||||
'bounds %s listener retirement and reuses the stop promise',
|
||||
async mode => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
@@ -954,14 +954,12 @@ describe(startNgrokTunnelAsync, () => {
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
const stopping = tunnel.stopAsync();
|
||||
const assertion = expect(stopping).rejects.toThrow(
|
||||
mode === 'rejected' ? 'close failed' : 'Ngrok tunnel stop timed out after 4000ms.'
|
||||
);
|
||||
const assertion = expect(stopping).rejects.toThrow(/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).toHaveBeenCalledTimes(1);
|
||||
expect(close).toHaveBeenCalled();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
@@ -985,7 +983,7 @@ describe(startNgrokTunnelAsync, () => {
|
||||
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
addr: 4321,
|
||||
addr: expect.any(Number),
|
||||
authtoken: 'token',
|
||||
domain: expect.stringMatching(/^web-preview-[a-f0-9]{32}\.eas-simulator\.ngrok\.dev$/),
|
||||
})
|
||||
@@ -1089,7 +1087,13 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockReset();
|
||||
jest.mocked(spawn).mockReset();
|
||||
jest.mocked(ngrok.forward).mockReset();
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
url: () => 'https://preview.example.test',
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
@@ -1152,6 +1156,7 @@ 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(),
|
||||
@@ -1169,6 +1174,7 @@ 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(),
|
||||
@@ -1197,6 +1203,7 @@ 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(),
|
||||
@@ -1204,7 +1211,7 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
disableShare: true,
|
||||
privatePreviewUrl,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
|
||||
const preview = await host.openPreviewAsync();
|
||||
|
||||
const serveSimArgs = jest
|
||||
.mocked(spawn)
|
||||
@@ -1224,13 +1231,14 @@ 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({ baseDomain, authtoken });
|
||||
const preview = await host.openPreviewAsync();
|
||||
|
||||
const serveSimArgs = jest
|
||||
.mocked(spawn)
|
||||
@@ -1257,13 +1265,14 @@ 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({ baseDomain, authtoken });
|
||||
const preview = await host.openPreviewAsync();
|
||||
|
||||
const spawnCalls = jest.mocked(spawn).mock.calls;
|
||||
expect(spawnCalls[0]).toEqual([
|
||||
@@ -1302,7 +1311,10 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
recordingDirectory,
|
||||
}),
|
||||
]);
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ addr: expect.any(Number) })
|
||||
);
|
||||
expect(ngrok.forward).not.toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(preview.apiUrl).toBe('https://android-preview.example.test');
|
||||
|
||||
await host.finishAsync();
|
||||
@@ -1317,12 +1329,13 @@ 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({ baseDomain, authtoken });
|
||||
const preview = await host.openPreviewAsync();
|
||||
|
||||
expect(preview.previewToken).toBe('tok-1');
|
||||
expect(preview.apiUrl).toBe('https://preview.example.test');
|
||||
@@ -1352,12 +1365,13 @@ 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({ baseDomain, authtoken });
|
||||
const preview = await host.openPreviewAsync();
|
||||
|
||||
expect(preview.previewPageUrl).toMatch(
|
||||
/^https:\/\/staging\.expo\.dev\/simulator-preview\/[a-f0-9]{32}$/
|
||||
@@ -1377,6 +1391,7 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
|
||||
await expect(
|
||||
startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
@@ -1394,12 +1409,13 @@ 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({ baseDomain, authtoken });
|
||||
const preview = await host.openPreviewAsync();
|
||||
|
||||
expect(preview.previewToken).toBe('hub-token');
|
||||
const [, args] = jest.mocked(spawn).mock.calls.find(([command]) => command === 'npx')!;
|
||||
@@ -1416,13 +1432,14 @@ 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({ baseDomain, authtoken });
|
||||
const preview = await host.openPreviewAsync();
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
@@ -1438,7 +1455,10 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
packageVersion,
|
||||
}),
|
||||
]);
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ addr: expect.any(Number) })
|
||||
);
|
||||
expect(ngrok.forward).not.toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(preview.apiUrl).toBe('https://ios-preview.example.test');
|
||||
|
||||
await host.finishAsync();
|
||||
@@ -1455,13 +1475,14 @@ 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({ baseDomain, authtoken });
|
||||
const preview = await host.openPreviewAsync();
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
@@ -1490,12 +1511,13 @@ 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({ baseDomain, authtoken });
|
||||
const preview = await host.openPreviewAsync();
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
@@ -1521,6 +1543,7 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
} as never);
|
||||
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
@@ -1547,6 +1570,7 @@ 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(),
|
||||
@@ -1827,6 +1851,23 @@ 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();
|
||||
@@ -1845,6 +1886,33 @@ 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();
|
||||
@@ -2026,6 +2094,65 @@ 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;
|
||||
|
||||
@@ -22,29 +22,17 @@ it('uploads NDJSON with explicit user-app scope', async () => {
|
||||
kind: 'simulator-log',
|
||||
filename: 'app-logs.ndjson',
|
||||
size: 11,
|
||||
attempts: 1,
|
||||
metadata: expect.objectContaining({ scope: 'user-apps', udid: 'A' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('skips empty and missing files', async () => {
|
||||
await uploadServeSimLogsFileAsync(ctx, args);
|
||||
await writeFile(filePath, '');
|
||||
await uploadServeSimLogsFileAsync(ctx, args);
|
||||
expect(uploadDeviceRunSessionArtifactAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aborts the upload on a file read error without an unhandled error', async () => {
|
||||
it('warns and resolves when the upload fails', async () => {
|
||||
await writeFile(filePath, '{}\n');
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { openStream, signal }) => {
|
||||
const aborted = new Promise<void>((_resolve, reject) => {
|
||||
signal!.addEventListener('abort', () => reject(signal!.reason), { once: true });
|
||||
});
|
||||
openStream().destroy(new Error('read failure'));
|
||||
await aborted;
|
||||
});
|
||||
.mockRejectedValueOnce(new Error('storage unavailable'));
|
||||
await expect(uploadServeSimLogsFileAsync(ctx, args)).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import { uploadDeviceRunSessionFileAsync } from '../uploadDeviceRunSessionFile';
|
||||
|
||||
jest.mock('../deviceRunSessionArtifacts');
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const upload = jest.mocked(uploadDeviceRunSessionArtifactAsync);
|
||||
const describeFile = (size: number) => ({
|
||||
deviceRunSessionId: 'session',
|
||||
artifactId: 'log',
|
||||
name: `log (${size} bytes)`,
|
||||
filename: 'log.txt',
|
||||
kind: 'simulator-log',
|
||||
});
|
||||
let directory: string;
|
||||
let filePath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(path.join(os.tmpdir(), 'device-run-session-file-'));
|
||||
filePath = path.join(directory, 'log.txt');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function uploadFile(options: { signal?: AbortSignal; attempts?: number } = {}): Promise<boolean> {
|
||||
return uploadDeviceRunSessionFileAsync(ctx, { filePath, describe: describeFile, ...options });
|
||||
}
|
||||
|
||||
it('skips an empty file without uploading', async () => {
|
||||
await writeFile(filePath, '');
|
||||
expect(await uploadFile()).toBe(false);
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a missing file before uploading', async () => {
|
||||
await expect(uploadFile()).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('describes the file by its size and forwards attempts and the caller signal', async () => {
|
||||
await writeFile(filePath, 'abc');
|
||||
const controller = new AbortController();
|
||||
expect(await uploadFile({ signal: controller.signal, attempts: 2 })).toBe(true);
|
||||
expect(upload).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ name: 'log (3 bytes)', size: 3, attempts: 2, filename: 'log.txt' })
|
||||
);
|
||||
const { signal } = upload.mock.calls[0][1];
|
||||
const reason = new Error('stop');
|
||||
controller.abort(reason);
|
||||
expect(signal?.reason).toBe(reason);
|
||||
});
|
||||
|
||||
it('aborts the upload on a file read error and ignores an aborted request body', async () => {
|
||||
await writeFile(filePath, '{}\n');
|
||||
const readError = Object.assign(new Error('read failed'), { code: 'EIO' });
|
||||
upload.mockImplementationOnce(async (_ctx, { openStream, signal }) => {
|
||||
const aborted = openStream();
|
||||
aborted.destroy(Object.assign(new Error('request body aborted'), { name: 'AbortError' }));
|
||||
await new Promise<void>(resolve => aborted.once('close', resolve));
|
||||
expect(signal?.aborted).toBe(false);
|
||||
const failed = openStream();
|
||||
failed.destroy(readError);
|
||||
await new Promise<void>(resolve => failed.once('close', resolve));
|
||||
expect(signal?.reason).toBe(readError);
|
||||
});
|
||||
expect(await uploadFile()).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils';
|
||||
import { fetchWebPreviewTurnArgsAsync } from '../remoteDeviceRunSession';
|
||||
import {
|
||||
WarmSessionAdoptionError,
|
||||
createWarmDeviceSessionServer,
|
||||
runOnWarmIosSimulatorAsync,
|
||||
} from '../warmDeviceSessionHost';
|
||||
import { type WarmIosSession, tryClaimWarmIosSessionAsync } from '../warmIosSession';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../IosSimulatorRecordingUtils');
|
||||
jest.mock('../warmIosSession');
|
||||
jest.mock('../remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../remoteDeviceRunSession'),
|
||||
fetchWebPreviewTurnArgsAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
let warm: WarmIosSession;
|
||||
|
||||
function launch(signal?: AbortSignal) {
|
||||
return {
|
||||
command: 'bunx',
|
||||
args: ['serve-sim', '--port', '3200'],
|
||||
env: {},
|
||||
logger,
|
||||
signal,
|
||||
secrets: [],
|
||||
stopGracePeriodMs: 1_000,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(fetchWebPreviewTurnArgsAsync).mockResolvedValue(['--turn', 'creds']);
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '' } as never);
|
||||
jest
|
||||
.mocked(IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync)
|
||||
.mockResolvedValue({ output: '/recordings/id', recordingId: 'id' });
|
||||
warm = {
|
||||
device: { udid: 'UDID', name: 'iPhone 17' },
|
||||
server: { pid: 10, port: 3200, token: 'serve-token' },
|
||||
agentDeviceStateDir: '/agent',
|
||||
argentEventLogPath: '/argent/events',
|
||||
preinstalledExpoGo: {
|
||||
bundleIdentifier: 'host.exp.Exponent',
|
||||
shortVersion: '2.0',
|
||||
version: '1',
|
||||
},
|
||||
prepareToolsAsync: jest.fn().mockResolvedValue(undefined),
|
||||
process: {
|
||||
pid: 10,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('adopts the warm device on the warm port with its recording, then prepares tools', async () => {
|
||||
const server = createWarmDeviceSessionServer(ctx, logger, warm);
|
||||
expect(server).toMatchObject({
|
||||
port: 3200,
|
||||
requiresPreviewRetirement: true,
|
||||
preinstalledApp: warm.preinstalledExpoGo,
|
||||
});
|
||||
const handle = await server.startAsync(launch());
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bunx',
|
||||
[
|
||||
'serve-sim',
|
||||
'--port',
|
||||
'3200',
|
||||
'--single-device',
|
||||
'UDID',
|
||||
'--adopt',
|
||||
'--recording-output',
|
||||
'/recordings/id',
|
||||
'--recording-id',
|
||||
'id',
|
||||
],
|
||||
expect.objectContaining({ stdio: 'pipe' })
|
||||
);
|
||||
expect(warm.prepareToolsAsync).toHaveBeenCalled();
|
||||
expect(handle.pid).toBe(10);
|
||||
await handle.stopAsync();
|
||||
expect(warm.process.stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['adoption', () => jest.mocked(spawn).mockRejectedValue(new Error('adopt --turn creds failed'))],
|
||||
[
|
||||
'tool preparation',
|
||||
() => jest.mocked(warm.prepareToolsAsync).mockRejectedValue(new Error('no tool')),
|
||||
],
|
||||
])('reports a %s failure as an adoption error without its details', async (_name, setup) => {
|
||||
setup();
|
||||
const error = await createWarmDeviceSessionServer(ctx, logger, warm)
|
||||
.startAsync(launch())
|
||||
.catch(error => error);
|
||||
expect(error).toBeInstanceOf(WarmSessionAdoptionError);
|
||||
expect(String(error)).not.toContain('creds');
|
||||
});
|
||||
|
||||
it('reports a cancellation as a cancellation', async () => {
|
||||
const controller = new AbortController();
|
||||
jest.mocked(spawn).mockImplementation((() => {
|
||||
controller.abort(new Error('cancelled'));
|
||||
return Promise.reject(new Error('killed'));
|
||||
}) as never);
|
||||
await expect(
|
||||
createWarmDeviceSessionServer(ctx, logger, warm).startAsync(launch(controller.signal))
|
||||
).rejects.toThrow('cancelled');
|
||||
});
|
||||
|
||||
describe(runOnWarmIosSimulatorAsync, () => {
|
||||
const claim = { engine: 'serve-sim' as const, env: {}, logger };
|
||||
|
||||
it('runs cold when there is no warm Simulator to claim', async () => {
|
||||
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(undefined);
|
||||
const run = jest.fn().mockResolvedValue('cold');
|
||||
await expect(runOnWarmIosSimulatorAsync(claim, run)).resolves.toBe('cold');
|
||||
expect(run).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('runs on the claimed warm Simulator', async () => {
|
||||
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm);
|
||||
const run = jest.fn().mockResolvedValue('warm');
|
||||
await expect(runOnWarmIosSimulatorAsync(claim, run)).resolves.toBe('warm');
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledWith(warm);
|
||||
});
|
||||
|
||||
it('stops warmup and runs cold after an adoption failure', async () => {
|
||||
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm);
|
||||
const run = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new WarmSessionAdoptionError())
|
||||
.mockResolvedValueOnce('cold');
|
||||
await expect(runOnWarmIosSimulatorAsync(claim, run)).resolves.toBe('cold');
|
||||
expect(warm.process.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenLastCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('does not run cold after a failure during the session', async () => {
|
||||
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm);
|
||||
const error = new Error('session failed');
|
||||
const run = jest.fn().mockRejectedValue(error);
|
||||
await expect(runOnWarmIosSimulatorAsync(claim, run)).rejects.toBe(error);
|
||||
expect(warm.process.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([new Error('session failed'), new WarmSessionAdoptionError()])(
|
||||
'preserves both errors and does not run cold when cleanup fails (%s)',
|
||||
async error => {
|
||||
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm);
|
||||
const cleanupError = new Error('cleanup failed');
|
||||
jest.mocked(warm.process.stopAsync).mockRejectedValue(cleanupError);
|
||||
const run = jest.fn().mockRejectedValue(error);
|
||||
await expect(runOnWarmIosSimulatorAsync(claim, run)).rejects.toMatchObject({
|
||||
errors: [error, cleanupError],
|
||||
});
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
'waits for cleanup before settling (adoption failure: %s)',
|
||||
async adoption => {
|
||||
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm);
|
||||
const error = adoption ? new WarmSessionAdoptionError() : new Error('session failed');
|
||||
let releaseCleanup = () => {};
|
||||
let cleanupStarted = () => {};
|
||||
const started = new Promise<void>(resolve => {
|
||||
cleanupStarted = resolve;
|
||||
});
|
||||
jest.mocked(warm.process.stopAsync).mockImplementation(() => {
|
||||
cleanupStarted();
|
||||
return new Promise<void>(resolve => {
|
||||
releaseCleanup = resolve;
|
||||
});
|
||||
});
|
||||
const run = jest.fn().mockRejectedValueOnce(error).mockResolvedValue('cold');
|
||||
const running = runOnWarmIosSimulatorAsync(claim, run);
|
||||
let settled = false;
|
||||
const result = running.then(
|
||||
value => {
|
||||
settled = true;
|
||||
return value;
|
||||
},
|
||||
error => {
|
||||
settled = true;
|
||||
return error;
|
||||
}
|
||||
);
|
||||
await started;
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
releaseCleanup();
|
||||
expect(await result).toBe(adoption ? 'cold' : error);
|
||||
expect(run).toHaveBeenCalledTimes(adoption ? 2 : 1);
|
||||
}
|
||||
);
|
||||
|
||||
it('does not retry cold when cancellation occurs during cleanup', async () => {
|
||||
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm);
|
||||
const controller = new AbortController();
|
||||
const cancellation = new Error('cancelled');
|
||||
jest.mocked(warm.process.stopAsync).mockImplementation(async () => {
|
||||
controller.abort(cancellation);
|
||||
});
|
||||
const run = jest.fn().mockRejectedValue(new WarmSessionAdoptionError());
|
||||
await expect(
|
||||
runOnWarmIosSimulatorAsync({ ...claim, signal: controller.signal }, run)
|
||||
).rejects.toBe(cancellation);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { runSimulatorLauncherAsync, stopWarmSimulatorAsync } from '../../../utils/warmSimulator';
|
||||
import { readLocalEgressHandoffAsync } from '../localEgress';
|
||||
import { tryClaimWarmIosSessionAsync } from '../warmIosSession';
|
||||
|
||||
jest.mock('../../../utils/warmSimulator');
|
||||
jest.mock('../localEgress');
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const env = { EAS_USE_WARM_SIMULATOR: '1' };
|
||||
const claim = {
|
||||
status: 'claimed',
|
||||
device: { udid: 'UDID', name: 'iPhone 17' },
|
||||
server: { pid: 10, port: 3200, token: 'serve-token', url: 'http://127.0.0.1:3200' },
|
||||
agentDeviceStateDir: '/agent',
|
||||
argentEventLogPath: '/argent/events',
|
||||
preinstalledExpoGo: { bundleIdentifier: 'host.exp.Exponent', shortVersion: '2.0', version: '1' },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(stopWarmSimulatorAsync).mockResolvedValue();
|
||||
jest
|
||||
.mocked(runSimulatorLauncherAsync)
|
||||
.mockImplementation(async ([command]) => (command === 'claim' ? JSON.stringify(claim) : '{}'));
|
||||
});
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('does not claim when the job does not use the warm Simulator', async () => {
|
||||
await expect(
|
||||
tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env: {}, logger })
|
||||
).resolves.toBeUndefined();
|
||||
expect(runSimulatorLauncherAsync).not.toHaveBeenCalled();
|
||||
expect(stopWarmSimulatorAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs the wait before claiming, without logging the private launcher record', async () => {
|
||||
jest.mocked(runSimulatorLauncherAsync).mockImplementationOnce(async () => {
|
||||
expect(logger.info).toHaveBeenCalledWith('Waiting for the warm Simulator launcher.');
|
||||
return JSON.stringify(claim);
|
||||
});
|
||||
const session = await tryClaimWarmIosSessionAsync({
|
||||
engine: 'serve-sim',
|
||||
requestedDevice: 'iPhone 17',
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
expect(runSimulatorLauncherAsync).toHaveBeenCalledWith(['claim', 'serve-sim', 'iPhone 17'], {
|
||||
env,
|
||||
signal: undefined,
|
||||
});
|
||||
expect(jest.mocked(logger.info).mock.calls).toEqual([
|
||||
['Waiting for the warm Simulator launcher.'],
|
||||
['Claimed the warm Simulator iPhone 17 (UDID).'],
|
||||
]);
|
||||
expect(session).toMatchObject({
|
||||
device: claim.device,
|
||||
server: { pid: 10, port: 3200, token: 'serve-token' },
|
||||
preinstalledExpoGo: claim.preinstalledExpoGo,
|
||||
});
|
||||
await session!.prepareToolsAsync();
|
||||
expect(runSimulatorLauncherAsync).toHaveBeenLastCalledWith(['prepare-tools'], {
|
||||
env,
|
||||
signal: undefined,
|
||||
});
|
||||
expect(session!.tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it('takes the preinstalled Expo Go that prepare-tools reports', async () => {
|
||||
const expoGo = { bundleIdentifier: 'host.exp.Exponent', shortVersion: '3.0', version: '2' };
|
||||
jest
|
||||
.mocked(runSimulatorLauncherAsync)
|
||||
.mockImplementation(async ([command]) =>
|
||||
command === 'claim'
|
||||
? JSON.stringify({ ...claim, preinstalledExpoGo: undefined })
|
||||
: JSON.stringify({ preinstalledExpoGo: expoGo })
|
||||
);
|
||||
const session = await tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger });
|
||||
expect(session!.preinstalledExpoGo).toBeUndefined();
|
||||
await session!.prepareToolsAsync();
|
||||
expect(session!.preinstalledExpoGo).toEqual(expoGo);
|
||||
});
|
||||
|
||||
it('reads the agent-device server after tool preparation', async () => {
|
||||
jest
|
||||
.spyOn(fs, 'readFile')
|
||||
.mockResolvedValue(JSON.stringify({ pid: 20, httpPort: 4310, token: 'agent-token' }));
|
||||
const session = await tryClaimWarmIosSessionAsync({ engine: 'agent-device', env, logger });
|
||||
await session!.prepareToolsAsync();
|
||||
expect(fs.readFile).toHaveBeenCalledWith(path.join('/agent', 'daemon.json'), 'utf8');
|
||||
expect(session!.tools).toEqual({ pid: 20, port: 4310, token: 'agent-token' });
|
||||
});
|
||||
|
||||
it('uses the Argent identity validated by the launcher without scanning state files again', async () => {
|
||||
const tools = { pid: 30, port: 3201, token: 'argent-token' };
|
||||
const readDirectory = jest.spyOn(fs, 'readdir');
|
||||
const readFile = jest.spyOn(fs, 'readFile');
|
||||
jest
|
||||
.mocked(runSimulatorLauncherAsync)
|
||||
.mockImplementation(async ([command]) =>
|
||||
JSON.stringify(command === 'claim' ? claim : { tools })
|
||||
);
|
||||
const session = await tryClaimWarmIosSessionAsync({ engine: 'argent', env, logger });
|
||||
await session?.prepareToolsAsync();
|
||||
expect(session?.tools).toEqual(tools);
|
||||
expect(readDirectory).not.toHaveBeenCalled();
|
||||
expect(readFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects Argent preparation when the launcher returns no validated identity', async () => {
|
||||
const session = await tryClaimWarmIosSessionAsync({ engine: 'argent', env, logger });
|
||||
await expect(session?.prepareToolsAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a failed claim',
|
||||
{},
|
||||
() => jest.mocked(runSimulatorLauncherAsync).mockRejectedValue(new Error('no session')),
|
||||
],
|
||||
['a pinned package version', { packageVersion: '1.2.3' }, () => {}],
|
||||
['a job Simulator setting', { env: { ...env, SIMCTL_CHILD_FOO: '1' } }, () => {}],
|
||||
[
|
||||
'a local egress session',
|
||||
{},
|
||||
() => jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue({ port: 1 } as never),
|
||||
],
|
||||
])('stops warmup and returns nothing for %s', async (_name, options, setup) => {
|
||||
setup();
|
||||
await expect(
|
||||
tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger, ...options })
|
||||
).resolves.toBeUndefined();
|
||||
expect(stopWarmSimulatorAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects when warmup cleanup cannot be confirmed', async () => {
|
||||
jest.mocked(runSimulatorLauncherAsync).mockRejectedValue(new Error('no session'));
|
||||
jest.mocked(stopWarmSimulatorAsync).mockRejectedValue(new Error('cleanup failed'));
|
||||
await expect(tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger })).rejects.toThrow(
|
||||
'cleanup failed'
|
||||
);
|
||||
});
|
||||
|
||||
it('stops the claimed session once', async () => {
|
||||
const session = await tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger });
|
||||
await Promise.all([session!.process.stopAsync(), session!.process.stopAsync()]);
|
||||
expect(stopWarmSimulatorAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -6,15 +6,16 @@ import fetch from 'node-fetch';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { setTimeout as sleepAsync } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
|
||||
|
||||
const AGENT_DEVICE_ARTIFACT_UPLOAD_POLL_INTERVAL_MS = 5_000;
|
||||
const AGENT_DEVICE_ARTIFACT_CLEANUP_TIMEOUT_MS = 30_000;
|
||||
|
||||
const AgentDeviceArtifactSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -45,69 +46,128 @@ export async function pollAgentDeviceArtifactsForUploadAsync(
|
||||
daemonUrl,
|
||||
daemonToken,
|
||||
logger,
|
||||
signal,
|
||||
stopPollingSignal,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
daemonUrl: string;
|
||||
daemonToken: string;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
stopPollingSignal?: AbortSignal;
|
||||
}
|
||||
): Promise<void> {
|
||||
logger.info('Started polling agent-device daemon for artifacts.');
|
||||
const uploadedArtifactIds = new Set<string>();
|
||||
let listArtifactsErrorCount = 0;
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
const artifacts = await listAgentDeviceArtifactsAsync({ daemonUrl, daemonToken });
|
||||
listArtifactsErrorCount = 0;
|
||||
for (const artifact of artifacts) {
|
||||
if (uploadedArtifactIds.has(artifact.id)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await uploadAgentDeviceArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
daemonUrl,
|
||||
daemonToken,
|
||||
artifact,
|
||||
logger,
|
||||
});
|
||||
uploadedArtifactIds.add(artifact.id);
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not upload agent-device remote session artifact', error);
|
||||
logger.warn({ err: error }, 'Could not upload agent-device remote session artifact.');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof AgentDeviceArtifactsUnsupportedError) {
|
||||
logger.warn(
|
||||
'agent-device daemon does not expose artifact inventory; remote session artifact uploads are disabled.'
|
||||
);
|
||||
// Ending normal polling must not cancel a transfer that the session already produced.
|
||||
const cleanup = new AbortController();
|
||||
const transferSignal = stopPollingSignal
|
||||
? AbortSignal.any([cleanup.signal, ...(signal ? [signal] : [])])
|
||||
: signal;
|
||||
const pollingSignal = stopPollingSignal
|
||||
? AbortSignal.any([stopPollingSignal, ...(signal ? [signal] : [])])
|
||||
: signal;
|
||||
let cleanupTimer: NodeJS.Timeout | undefined;
|
||||
const startCleanupDeadline = (): void => {
|
||||
cleanupTimer ??= setTimeout(() => cleanup.abort(), AGENT_DEVICE_ARTIFACT_CLEANUP_TIMEOUT_MS);
|
||||
};
|
||||
stopPollingSignal?.addEventListener('abort', startCleanupDeadline, { once: true });
|
||||
if (stopPollingSignal?.aborted) {
|
||||
startCleanupDeadline();
|
||||
}
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const finalInventory = stopPollingSignal?.aborted;
|
||||
if (transferSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
listArtifactsErrorCount += 1;
|
||||
if (listArtifactsErrorCount % 5 === 0) {
|
||||
Sentry.capture('Could not list agent-device remote session artifacts', error);
|
||||
logger.warn(
|
||||
{ err: error, failedArtifactListCount: listArtifactsErrorCount },
|
||||
'Could not list agent-device remote session artifacts.'
|
||||
);
|
||||
try {
|
||||
const artifacts = await listAgentDeviceArtifactsAsync({
|
||||
daemonUrl,
|
||||
daemonToken,
|
||||
signal: transferSignal,
|
||||
});
|
||||
listArtifactsErrorCount = 0;
|
||||
for (const artifact of artifacts) {
|
||||
if (uploadedArtifactIds.has(artifact.id)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await uploadAgentDeviceArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
daemonUrl,
|
||||
daemonToken,
|
||||
artifact,
|
||||
logger,
|
||||
...(transferSignal ? { signal: transferSignal } : {}),
|
||||
});
|
||||
uploadedArtifactIds.add(artifact.id);
|
||||
} catch (err) {
|
||||
if (transferSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not upload agent-device remote session artifact', error);
|
||||
logger.warn({ err: error }, 'Could not upload agent-device remote session artifact.');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (transferSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
if (err instanceof AgentDeviceArtifactsUnsupportedError) {
|
||||
logger.warn(
|
||||
'agent-device daemon does not expose artifact inventory; remote session artifact uploads are disabled.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
listArtifactsErrorCount += 1;
|
||||
// The final inventory has no later retry, so always report its failure.
|
||||
if (finalInventory || listArtifactsErrorCount % 5 === 0) {
|
||||
Sentry.capture('Could not list agent-device remote session artifacts', error);
|
||||
logger.warn(
|
||||
{ err: error, failedArtifactListCount: listArtifactsErrorCount },
|
||||
'Could not list agent-device remote session artifacts.'
|
||||
);
|
||||
}
|
||||
}
|
||||
if (finalInventory) {
|
||||
return;
|
||||
}
|
||||
await sleepAsync(AGENT_DEVICE_ARTIFACT_UPLOAD_POLL_INTERVAL_MS, undefined, {
|
||||
signal: pollingSignal,
|
||||
}).catch(error => {
|
||||
if (!pollingSignal?.aborted) {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(cleanupTimer);
|
||||
stopPollingSignal?.removeEventListener('abort', startCleanupDeadline);
|
||||
if (cleanup.signal.aborted) {
|
||||
logger.warn(
|
||||
'Timed out draining agent-device artifacts; some session artifacts may not have been uploaded.'
|
||||
);
|
||||
}
|
||||
await sleepAsync(AGENT_DEVICE_ARTIFACT_UPLOAD_POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAgentDeviceArtifactsAsync({
|
||||
daemonUrl,
|
||||
daemonToken,
|
||||
signal,
|
||||
}: {
|
||||
daemonUrl: string;
|
||||
daemonToken: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<AgentDeviceArtifact[]> {
|
||||
const response = await fetch(new URL('/artifacts', daemonUrl).toString(), {
|
||||
...(signal ? { signal } : {}),
|
||||
headers: { Authorization: `Bearer ${daemonToken}` },
|
||||
});
|
||||
if (response.status === 404) {
|
||||
@@ -133,14 +193,17 @@ export async function uploadAgentDeviceArtifactAsync(
|
||||
daemonToken,
|
||||
artifact,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
daemonUrl: string;
|
||||
daemonToken: string;
|
||||
artifact: AgentDeviceArtifact;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
logger.info(`Downloading artifact ${artifact.filename}.`);
|
||||
const temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), 'agent-device-artifact-'));
|
||||
try {
|
||||
@@ -150,6 +213,7 @@ export async function uploadAgentDeviceArtifactAsync(
|
||||
daemonUrl,
|
||||
daemonToken,
|
||||
destinationPath: temporaryArtifactPath,
|
||||
signal,
|
||||
});
|
||||
const { size } = await stat(temporaryArtifactPath);
|
||||
logger.info(`Uploading artifact ${artifact.filename} (${formatBytes(size)}).`);
|
||||
@@ -164,6 +228,7 @@ export async function uploadAgentDeviceArtifactAsync(
|
||||
: {}),
|
||||
size,
|
||||
openStream: () => createReadStream(temporaryArtifactPath),
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
} finally {
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
@@ -175,16 +240,19 @@ async function downloadAgentDeviceArtifactToFileAsync({
|
||||
daemonUrl,
|
||||
daemonToken,
|
||||
destinationPath,
|
||||
signal,
|
||||
}: {
|
||||
artifact: AgentDeviceArtifact;
|
||||
daemonUrl: string;
|
||||
daemonToken: string;
|
||||
destinationPath: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const response = await fetch(
|
||||
new URL(`/artifacts/${encodeURIComponent(artifact.id)}`, daemonUrl).toString(),
|
||||
{
|
||||
headers: { Authorization: `Bearer ${daemonToken}` },
|
||||
...(signal ? { signal } : {}),
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
@@ -197,5 +265,9 @@ async function downloadAgentDeviceArtifactToFileAsync({
|
||||
`Agent-device artifact ${artifact.id} response did not include a readable body.`
|
||||
);
|
||||
}
|
||||
await pipeline(response.body, createWriteStream(destinationPath));
|
||||
if (signal) {
|
||||
await pipeline(response.body, createWriteStream(destinationPath), { signal });
|
||||
} else {
|
||||
await pipeline(response.body, createWriteStream(destinationPath));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type BuildStepGlobalContext } from '@expo/steps';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
AndroidEmulatorLogCapture,
|
||||
type FinalizedAndroidEmulatorLog,
|
||||
MAX_ANDROID_LOG_BYTES_PER_SOURCE,
|
||||
} from '../../utils/androidEmulatorLogCapture';
|
||||
import { type AndroidEmulatorLogSource } from '../../utils/androidEmulatorLogSources';
|
||||
import { uploadDeviceRunSessionFileAsync } from './uploadDeviceRunSessionFile';
|
||||
|
||||
const UPLOAD_TIMEOUT_MS = 30_000;
|
||||
const LOG_SOURCE_DETAILS = {
|
||||
'emulator-output': { name: 'Emulator output', scope: 'emulator-process' },
|
||||
'device-logcat': { name: 'Android device logcat', scope: 'device' },
|
||||
} satisfies Record<AndroidEmulatorLogSource, { name: string; scope: string }>;
|
||||
|
||||
type Collection = {
|
||||
capture: AndroidEmulatorLogCapture;
|
||||
upload?: Promise<void>;
|
||||
};
|
||||
|
||||
// A worker can execute more than one job; each workflow owns its capture and uploads.
|
||||
const collections = new WeakMap<BuildStepGlobalContext, Collection>();
|
||||
|
||||
export function getAndroidEmulatorLogCapture(
|
||||
global: BuildStepGlobalContext,
|
||||
env: NodeJS.ProcessEnv
|
||||
): AndroidEmulatorLogCapture {
|
||||
let collection = collections.get(global);
|
||||
if (!collection) {
|
||||
collection = {
|
||||
capture: new AndroidEmulatorLogCapture(
|
||||
env.DEVICE_RUN_SESSION_ID ? MAX_ANDROID_LOG_BYTES_PER_SOURCE : Infinity
|
||||
),
|
||||
};
|
||||
collections.set(global, collection);
|
||||
}
|
||||
return collection.capture;
|
||||
}
|
||||
|
||||
export async function collectAndroidEmulatorLogsAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
global,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
}: {
|
||||
global: BuildStepGlobalContext;
|
||||
deviceRunSessionId: string;
|
||||
logger: bunyan;
|
||||
}
|
||||
): Promise<void> {
|
||||
const collection = collections.get(global);
|
||||
if (!collection) {
|
||||
logger.info('No Android emulator logs captured; skipping upload.');
|
||||
return;
|
||||
}
|
||||
// Generated session workflows collect once. Repeated calls share that finalization/upload.
|
||||
collection.upload ??= uploadCapturedLogsAsync(ctx, {
|
||||
collection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
});
|
||||
await collection.upload;
|
||||
}
|
||||
|
||||
async function uploadCapturedLogsAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
collection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
}: {
|
||||
collection: Collection;
|
||||
deviceRunSessionId: string;
|
||||
logger: bunyan;
|
||||
}
|
||||
): Promise<void> {
|
||||
const logs = await collection.capture.finishAsync();
|
||||
for (const log of logs) {
|
||||
const filename = path.basename(log.filePath);
|
||||
try {
|
||||
const uploaded = await uploadLogAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
log,
|
||||
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
|
||||
});
|
||||
if (uploaded) {
|
||||
logger.info(`Uploaded Android diagnostic log ${filename}.`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `Could not upload Android log ${filename}; other logs will continue.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadLogAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
deviceRunSessionId,
|
||||
log: { filePath, source, bytesSeen },
|
||||
signal,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
log: FinalizedAndroidEmulatorLog;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
const filename = path.basename(filePath);
|
||||
return await uploadDeviceRunSessionFileAsync(ctx, {
|
||||
filePath,
|
||||
describe: size => {
|
||||
const retainedSize =
|
||||
size >= 1024 * 1024 ? `${Number((size / (1024 * 1024)).toFixed(2))} MiB` : `${size} bytes`;
|
||||
const truncationNote = size < bytesSeen ? `; truncated, last ${retainedSize} retained` : '';
|
||||
return {
|
||||
deviceRunSessionId,
|
||||
artifactId: filename,
|
||||
name: `${LOG_SOURCE_DETAILS[source].name} (${filename}${truncationNote})`,
|
||||
filename,
|
||||
kind: 'simulator-log',
|
||||
metadata: {
|
||||
__eas_type: 'simulator-log',
|
||||
platform: 'android',
|
||||
source,
|
||||
scope: LOG_SOURCE_DETAILS[source].scope,
|
||||
originalSizeBytes: bytesSeen,
|
||||
// The capture keeps the newest bytes, so the omitted bytes are the head.
|
||||
omittedBytes: bytesSeen - size,
|
||||
truncated: size < bytesSeen,
|
||||
},
|
||||
};
|
||||
},
|
||||
signal,
|
||||
attempts: 2,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import type { BuildStepEnv } from '@expo/steps';
|
||||
|
||||
import type { CustomBuildContext } from '../../customBuildContext';
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { waitForWarmSimulatorCleanupAsync } from '../../utils/warmSimulatorCleanup';
|
||||
import type { DeviceSessionServer } from './deviceSessionHost';
|
||||
import {
|
||||
fetchWebPreviewTurnArgsAsync,
|
||||
findAvailablePortAsync,
|
||||
spawnDetached,
|
||||
} from './remoteDeviceRunSession';
|
||||
|
||||
/** Creates the cold iOS server handle; start and setup run when the host calls them. */
|
||||
export async function createColdIosSessionAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
env,
|
||||
logger,
|
||||
iosSimulatorUdid,
|
||||
}: { env: BuildStepEnv; logger: bunyan; iosSimulatorUdid?: string }
|
||||
): Promise<DeviceSessionServer> {
|
||||
const port = await findAvailablePortAsync();
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { logger });
|
||||
return {
|
||||
port,
|
||||
turnArgs: Promise.resolve(turnArgs),
|
||||
requiresPreviewRetirement: false,
|
||||
async startAsync({ command, args, env, logger, signal, secrets, stopGracePeriodMs }) {
|
||||
// serve-sim boots the device, so warm Simulator cleanup must finish first.
|
||||
await waitForWarmSimulatorCleanupAsync(env, { signal });
|
||||
signal?.throwIfAborted();
|
||||
return spawnDetached({ command, args, env, logger, secrets, stopGracePeriodMs });
|
||||
},
|
||||
async setupSimulatorAsync({ signal, logger }) {
|
||||
try {
|
||||
await IosSimulatorUtils.disableApsdAsync({
|
||||
udid: iosSimulatorUdid as IosSimulatorUuid,
|
||||
env,
|
||||
});
|
||||
} catch (err) {
|
||||
signal.throwIfAborted();
|
||||
logger.warn({ err }, 'Failed to disable apsd in the Simulator.');
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
},
|
||||
// A cold launch either returns its handle or starts no process.
|
||||
async stopAsync() {},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import semver from 'semver';
|
||||
|
||||
export function validateControllerVersion({
|
||||
packageName,
|
||||
minimumVersion,
|
||||
packageVersion,
|
||||
logger,
|
||||
}: {
|
||||
packageName: string;
|
||||
minimumVersion: string;
|
||||
packageVersion: string | undefined;
|
||||
logger: bunyan;
|
||||
}): void {
|
||||
if (!packageVersion || packageVersion === 'latest') {
|
||||
return;
|
||||
}
|
||||
const requirement = `Remote sessions require ${packageName}@${minimumVersion} or newer.`;
|
||||
const version = semver.valid(packageVersion);
|
||||
if (!version) {
|
||||
logger.warn(
|
||||
`${requirement} package_version "${packageVersion}" is not an exact semver version that EAS can verify. ` +
|
||||
'Continuing and letting the package manager resolve it.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (semver.lt(version, minimumVersion)) {
|
||||
throw new SystemError(
|
||||
`${requirement} The requested package_version "${packageVersion}" is too old for the EAS remote-session API. ` +
|
||||
`Use "latest" or pass an exact version >= ${minimumVersion}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
|
||||
|
||||
type ArtifactMetadata = {
|
||||
export type ArtifactMetadata = {
|
||||
deviceRunSessionId: string;
|
||||
artifactId: string;
|
||||
name: string;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
DeviceRunSessionStoppedError,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from './remoteDeviceRunSession';
|
||||
import { type StartupTasks } from './startupTasks';
|
||||
|
||||
export type DeviceRunSessionStartupWatch = {
|
||||
/** The startup abort reason when a Stop request arrives during startup. */
|
||||
readonly stopped: DeviceRunSessionStoppedError;
|
||||
/** Whether a Stop request aborted startup. */
|
||||
isStopped(): boolean;
|
||||
/** Ends the watch after the session reports ready. Throws if startup was aborted meanwhile. */
|
||||
completeAsync(): Promise<void>;
|
||||
/** Ends the watch without throwing, for cleanup. Safe to call more than once. */
|
||||
finishAsync(): Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Polls for a Stop request while the session starts up, and aborts startup with `stopped` when
|
||||
* one arrives. A poll failure aborts startup with that error. Call `completeAsync` before the
|
||||
* lifetime wait, so only one poll runs at a time, and `finishAsync` in cleanup.
|
||||
*/
|
||||
export function watchDeviceRunSessionStartup({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
startup,
|
||||
}: {
|
||||
ctx: CustomBuildContext;
|
||||
deviceRunSessionId: string;
|
||||
logger: bunyan;
|
||||
startup: Pick<StartupTasks, 'signal' | 'abort'>;
|
||||
}): DeviceRunSessionStartupWatch {
|
||||
const stopped = new DeviceRunSessionStoppedError(
|
||||
`Device run session ${deviceRunSessionId} was stopped during startup.`
|
||||
);
|
||||
const done = new AbortController();
|
||||
const watchSignal = AbortSignal.any([startup.signal, done.signal]);
|
||||
const watching = waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
signal: watchSignal,
|
||||
}).then(
|
||||
() => {
|
||||
if (!watchSignal.aborted) {
|
||||
logger.info(stopped.message);
|
||||
startup.abort(stopped);
|
||||
}
|
||||
},
|
||||
error => startup.abort(error)
|
||||
);
|
||||
const finishAsync = async (): Promise<void> => {
|
||||
done.abort();
|
||||
await watching;
|
||||
};
|
||||
return {
|
||||
stopped,
|
||||
isStopped: () => startup.signal.reason === stopped,
|
||||
completeAsync: async () => {
|
||||
await finishAsync();
|
||||
startup.signal.throwIfAborted();
|
||||
},
|
||||
finishAsync,
|
||||
};
|
||||
}
|
||||
@@ -49,9 +49,15 @@ import {
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
import { startLogPhase } from '../../utils/logPhase';
|
||||
import { runServeSimActionAsync, stageServeSimAppAsync } from './serveSimActions';
|
||||
import {
|
||||
type IosAppIdentity,
|
||||
isPreinstalledExpoGoAsync,
|
||||
readIosApplicationIdentifierAsync,
|
||||
} from './iosAppArtifact';
|
||||
import { type ServeSimActivityPolling, startServeSimActivityPolling } from './serveSimActivity';
|
||||
import { verifyLocalEgressGuardAsync } from './localEgressGuard';
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { createColdIosSessionAsync } from './coldIosSession';
|
||||
import { createStartupTasks } from './startupTasks';
|
||||
|
||||
const WEB_PREVIEW_HOST = '127.0.0.1';
|
||||
@@ -362,12 +368,13 @@ export type DeviceWebPreview = {
|
||||
};
|
||||
|
||||
export type DeviceSessionHost = {
|
||||
openPreviewAsync(options: { baseDomain: string; authtoken: string }): Promise<DeviceWebPreview>;
|
||||
/** The preview tunnel opened during startup, or a new one after closeAsync. */
|
||||
openPreviewAsync(): Promise<DeviceWebPreview>;
|
||||
/** Last observed preview input. Always `undefined` on Android, which keeps no event log. */
|
||||
getLastPreviewActivityAt(): Date | undefined;
|
||||
/**
|
||||
* Terminal and idempotent. Never rejects: a failed finalization, host stop or upload is logged
|
||||
* and reported, so callers need no error handling around it.
|
||||
* Terminal and idempotent. Finalization, host-stop and upload failures are logged and reported.
|
||||
* Warm preview-tunnel cleanup can reject when retirement cannot be confirmed.
|
||||
*/
|
||||
finishAsync(): Promise<void>;
|
||||
};
|
||||
@@ -379,11 +386,13 @@ type AndroidSessionRecording = {
|
||||
env: BuildStepEnv;
|
||||
};
|
||||
|
||||
type DeviceSessionHostOptions = {
|
||||
export type DeviceSessionHostOptions = {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
/** The host opens this preview tunnel while the server starts. */
|
||||
preview: { baseDomain: string; authtoken: string };
|
||||
separateLogPhase?: boolean;
|
||||
startupTimeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
@@ -405,12 +414,69 @@ type DeviceSessionHostOptions = {
|
||||
networkCaptureFields?: string[];
|
||||
} & ServeSimLaunchOptions;
|
||||
|
||||
export type DeviceSessionServerLaunch = {
|
||||
command: string;
|
||||
args: string[];
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
/** Redacted from server output. The host adds the preview token after launch. */
|
||||
secrets: string[];
|
||||
stopGracePeriodMs: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The server process behind a session host. A cold server is launched for this session; a warm
|
||||
* server was prepared before the job and is adopted. The host runs the same readiness, app
|
||||
* actions and finalization for both.
|
||||
*/
|
||||
export type DeviceSessionServer = {
|
||||
port: number;
|
||||
turnArgs: Promise<string[]>;
|
||||
/** Cold iOS setup runs after control readiness, alongside app installation and before launch. */
|
||||
setupSimulatorAsync?(options: { signal: AbortSignal; logger: bunyan }): Promise<void>;
|
||||
/** An app the server's launcher installed and never launched. */
|
||||
preinstalledApp?: IosAppIdentity;
|
||||
/** Another session can start on this machine, so an unconfirmed tunnel close fails cleanup. */
|
||||
requiresPreviewRetirement: boolean;
|
||||
startAsync(launch: DeviceSessionServerLaunch): Promise<DetachedProcessHandle>;
|
||||
/** Stops the server after startAsync failed. Finalization stops the returned handle. */
|
||||
stopAsync(): Promise<void>;
|
||||
};
|
||||
|
||||
/** Fetches TURN before the host starts its collectors. */
|
||||
async function createAndroidDeviceSessionServerAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{ logger }: { logger: bunyan }
|
||||
): Promise<DeviceSessionServer> {
|
||||
const port = await findAvailablePortAsync();
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { logger });
|
||||
return {
|
||||
port,
|
||||
turnArgs: Promise.resolve(turnArgs),
|
||||
requiresPreviewRetirement: false,
|
||||
async startAsync({ command, args, env, logger, secrets, stopGracePeriodMs }) {
|
||||
return spawnDetached({ command, args, env, logger, secrets, stopGracePeriodMs });
|
||||
},
|
||||
// A cold launch either returns its handle or starts no process.
|
||||
async stopAsync() {},
|
||||
};
|
||||
}
|
||||
|
||||
/** Without a server, the host launches a cold one. */
|
||||
export async function startDeviceSessionHostAsync(
|
||||
ctx: CustomBuildContext,
|
||||
options: DeviceSessionHostOptions
|
||||
options: DeviceSessionHostOptions,
|
||||
server?: DeviceSessionServer
|
||||
): Promise<DeviceSessionHost> {
|
||||
if (!options.separateLogPhase) {
|
||||
return await startDeviceSessionHostInternalAsync(ctx, options, () => {}, options.logger);
|
||||
return await startDeviceSessionHostInternalAsync(
|
||||
ctx,
|
||||
options,
|
||||
() => {},
|
||||
options.logger,
|
||||
server
|
||||
);
|
||||
}
|
||||
const phase = startLogPhase(options.logger, 'Simulator preview');
|
||||
let ready = false;
|
||||
@@ -420,7 +486,8 @@ export async function startDeviceSessionHostAsync(
|
||||
{ ...options, logger: phase.logger },
|
||||
successful =>
|
||||
phase.end(ready && successful ? BuildPhaseResult.SUCCESS : BuildPhaseResult.FAIL),
|
||||
options.logger
|
||||
options.logger,
|
||||
server
|
||||
);
|
||||
ready = true;
|
||||
return host;
|
||||
@@ -455,9 +522,11 @@ async function startDeviceSessionHostInternalAsync(
|
||||
networkCapture = false,
|
||||
networkCaptureFields = [],
|
||||
bootEnv = {},
|
||||
preview,
|
||||
}: DeviceSessionHostOptions,
|
||||
onFinished: (successful: boolean) => void,
|
||||
artifactLogger: bunyan
|
||||
artifactLogger: bunyan,
|
||||
providedServer?: DeviceSessionServer
|
||||
): Promise<DeviceSessionHost> {
|
||||
signal?.throwIfAborted();
|
||||
const isAndroid = runtimePlatform === BuildRuntimePlatform.LINUX;
|
||||
@@ -509,95 +578,28 @@ async function startDeviceSessionHostInternalAsync(
|
||||
const subdomainId = randomBytes(16).toString('hex');
|
||||
const previewPageUrl = simulatorPreviewPageUrl(env, subdomainId);
|
||||
const sharePageUrl = disableShare ? privatePreviewUrl : previewPageUrl;
|
||||
const port = await findAvailablePortAsync();
|
||||
const server =
|
||||
providedServer ??
|
||||
(isAndroid
|
||||
? await createAndroidDeviceSessionServerAsync(ctx, { logger })
|
||||
: await createColdIosSessionAsync(ctx, { env, logger, iosSimulatorUdid }));
|
||||
const port = server.port;
|
||||
const serverName = isAndroid ? 'expo-device-hub' : 'serve-sim';
|
||||
const packageSpec = isAndroid
|
||||
? createExpoDeviceHubPackageSpec(packageVersion)
|
||||
: createServeSimPackageSpec(packageVersion);
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { logger });
|
||||
const localEgress = !isAndroid ? await readLocalEgressHandoffAsync() : null;
|
||||
const previewExec = resolvePackageExec(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
isAndroid
|
||||
? createExpoDeviceHubArgs({
|
||||
port,
|
||||
device: previewDevice,
|
||||
turnArgs,
|
||||
packageVersion,
|
||||
recordingDirectory: recording?.directory,
|
||||
websiteArgs: websiteOriginArgs(env),
|
||||
frameAncestorArgs: frameAncestorArgs(frameAncestors),
|
||||
corsOriginArgs: corsOriginArgs(corsOrigins),
|
||||
})
|
||||
: createServeSimArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
packageVersion,
|
||||
iosSimulatorUdid: previewDevice,
|
||||
websiteArgs: websiteOriginArgs(env),
|
||||
frameAncestorArgs: frameAncestorArgs(frameAncestors),
|
||||
corsOriginArgs: corsOriginArgs(corsOrigins),
|
||||
// serve-sim adds its bearer token to --share-url; private links are for worker logs only.
|
||||
shareUrl: disableShare ? undefined : previewPageUrl,
|
||||
disableShare,
|
||||
installAppPath: iosSimulatorUdid ? undefined : installAppPath,
|
||||
launchAppIdentifier: iosSimulatorUdid ? undefined : launchAppIdentifier,
|
||||
launchArgs: iosSimulatorUdid ? [] : launchArgs,
|
||||
openUrl: iosSimulatorUdid ? undefined : openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
networkCaptureProxy: localEgress
|
||||
? `http://${LOCAL_EGRESS_PROXY_HOST}:${localEgress.port}`
|
||||
: undefined,
|
||||
})
|
||||
);
|
||||
logger.info(
|
||||
`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port} via ${previewExec.command}.`
|
||||
);
|
||||
// Redact these credentials before forwarding process output to build logs.
|
||||
const secrets = [
|
||||
...turnArgs.filter((_, index) => turnArgs[index - 1] === '--turn-credential'),
|
||||
...(recording ? [recording.controlToken] : []),
|
||||
];
|
||||
const turnArgsResult = asyncResult(server.turnArgs);
|
||||
const screenshots = await startDeviceRunSessionScreenshotsAsync(ctx, {
|
||||
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
|
||||
logger: artifactLogger,
|
||||
});
|
||||
let previewServer: DetachedProcessHandle;
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
previewServer = spawnDetached({
|
||||
command: previewExec.command,
|
||||
args: previewExec.args,
|
||||
env: {
|
||||
...env,
|
||||
...bootEnv,
|
||||
...(isAndroid ? {} : { TMPDIR: serveSimTmpdir, SERVE_SIM_STATE_DIR: serveSimStateDir }),
|
||||
EXPO_DEVICE_HUB_SCREENSHOT_DIRECTORY: screenshots.directory,
|
||||
...(recording ? { EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN: recording.controlToken } : {}),
|
||||
// The Hub would resolve its state directory from the step's env, which may set its own
|
||||
// TMPDIR or state directory, so it writes its token where the host reads it below.
|
||||
...(isAndroid ? { EXPO_DEVICE_HUB_STATE_DIR } : {}),
|
||||
},
|
||||
// Only an Android host records through expo-device-hub; serve-sim records on iOS.
|
||||
stopGracePeriodMs: recording
|
||||
? RECORDING_STOP_GRACE_PERIOD_MS
|
||||
: SERVE_SIM_STOP_GRACE_PERIOD_MS,
|
||||
logger,
|
||||
secrets,
|
||||
});
|
||||
} catch (error) {
|
||||
// Nothing was spawned, so nothing can still write into the directory.
|
||||
await screenshots.finishAsync(true);
|
||||
throw error;
|
||||
}
|
||||
|
||||
let stagedAppDirectory: string | undefined;
|
||||
let simulatorSetup: ReturnType<typeof asyncResult<void>> | undefined;
|
||||
const readinessController = new AbortController();
|
||||
const readinessSignal = AbortSignal.any([startup.signal, readinessController.signal]);
|
||||
let previewToken: string | undefined;
|
||||
let previewTask: Promise<DeviceWebPreview> | null = null;
|
||||
const previewRetirementErrors: unknown[] = [];
|
||||
let finishTask: Promise<void> | null = null;
|
||||
let hostReady = false;
|
||||
let sessionPreview: ReturnType<typeof startDeviceRunSessionPreview> | null = null;
|
||||
@@ -606,85 +608,239 @@ async function startDeviceSessionHostInternalAsync(
|
||||
let previewFailed = false;
|
||||
let previewActivity: ServeSimActivityPolling | null = null;
|
||||
|
||||
const host: DeviceSessionHost = {
|
||||
openPreviewAsync({ baseDomain, authtoken }) {
|
||||
if (finishTask) {
|
||||
return Promise.reject(
|
||||
new SystemError('Cannot open a preview after session host finalization.')
|
||||
);
|
||||
}
|
||||
if (previewTask) {
|
||||
return previewTask;
|
||||
}
|
||||
const opening = (async (): Promise<DeviceWebPreview> => {
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix: 'web-preview',
|
||||
subdomainId,
|
||||
baseDomain,
|
||||
authtoken,
|
||||
logger,
|
||||
});
|
||||
if (finishTask) {
|
||||
await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Late preview tunnel close', timeoutMs: 5_000 },
|
||||
async () => await tunnel.stopAsync()
|
||||
).catch(err => logger.warn({ err }, 'Could not close a late preview tunnel.'));
|
||||
throw new SystemError('Session host finalized while the preview was opening.');
|
||||
}
|
||||
let closeTask: Promise<void> | null = null;
|
||||
previewFailed = false;
|
||||
return {
|
||||
previewPageUrl,
|
||||
sharePageUrl,
|
||||
apiUrl: tunnel.url,
|
||||
previewToken,
|
||||
closeAsync() {
|
||||
return (closeTask ??= (async () => {
|
||||
try {
|
||||
await tunnel.stopAsync();
|
||||
} finally {
|
||||
if (previewTask === opening) {
|
||||
previewTask = null;
|
||||
}
|
||||
}
|
||||
})());
|
||||
},
|
||||
};
|
||||
})();
|
||||
previewTask = opening;
|
||||
// A failed tunnel may be retried without restarting capture.
|
||||
void opening.catch(() => {
|
||||
previewFailed = true;
|
||||
if (previewTask === opening) {
|
||||
previewTask = null;
|
||||
}
|
||||
function openPreviewAsync(): Promise<DeviceWebPreview> {
|
||||
if (finishTask) {
|
||||
return Promise.reject(
|
||||
new SystemError('Cannot open a preview after session host finalization.')
|
||||
);
|
||||
}
|
||||
if (previewTask) {
|
||||
return previewTask;
|
||||
}
|
||||
const opening = (async (): Promise<DeviceWebPreview> => {
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix: 'web-preview',
|
||||
subdomainId,
|
||||
baseDomain: preview.baseDomain,
|
||||
authtoken: preview.authtoken,
|
||||
healthCheck: { path: isAndroid ? '/readyz' : '/healthz' },
|
||||
logger,
|
||||
});
|
||||
return opening;
|
||||
},
|
||||
if (finishTask) {
|
||||
await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Late preview tunnel close', timeoutMs: 5_000 },
|
||||
async () => await tunnel.stopAsync()
|
||||
).catch(err => {
|
||||
if (server.requiresPreviewRetirement) {
|
||||
previewRetirementErrors.push(err);
|
||||
}
|
||||
logger.warn({ err }, 'Could not close a late preview tunnel.');
|
||||
});
|
||||
throw new SystemError('Session host finalized while the preview was opening.');
|
||||
}
|
||||
let closeTask: Promise<void> | null = null;
|
||||
previewFailed = false;
|
||||
return {
|
||||
previewPageUrl,
|
||||
sharePageUrl,
|
||||
apiUrl: tunnel.url,
|
||||
get previewToken() {
|
||||
return previewToken;
|
||||
},
|
||||
closeAsync() {
|
||||
return (closeTask ??= (async () => {
|
||||
try {
|
||||
await tunnel.stopAsync();
|
||||
} catch (error) {
|
||||
if (server.requiresPreviewRetirement) {
|
||||
previewRetirementErrors.push(error);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (previewTask === opening) {
|
||||
previewTask = null;
|
||||
}
|
||||
}
|
||||
})());
|
||||
},
|
||||
};
|
||||
})();
|
||||
previewTask = opening;
|
||||
// A failed tunnel may be retried without restarting capture.
|
||||
void opening.catch(() => {
|
||||
previewFailed = true;
|
||||
if (previewTask === opening) {
|
||||
previewTask = null;
|
||||
}
|
||||
});
|
||||
return opening;
|
||||
}
|
||||
const earlyPreview = asyncResult(
|
||||
withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Preview tunnel opening', timeoutMs, signal: startup.signal },
|
||||
async () => await openPreviewAsync()
|
||||
).catch(error => {
|
||||
if (!finishTask) {
|
||||
startup.abort(error);
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
);
|
||||
function retirePreviewAsync(): Promise<boolean> {
|
||||
return withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Preview tunnel retirement', timeoutMs: 5_000 },
|
||||
async () => {
|
||||
const preview = await previewTask?.catch(() => null);
|
||||
await preview?.closeAsync();
|
||||
}
|
||||
).then(
|
||||
() => true,
|
||||
err => {
|
||||
if (server.requiresPreviewRetirement && !previewRetirementErrors.includes(err)) {
|
||||
previewRetirementErrors.push(err);
|
||||
}
|
||||
logger.warn(
|
||||
{ err },
|
||||
`Could not close the ${serverName} preview tunnel within its deadline.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
);
|
||||
}
|
||||
const secrets: string[] = [];
|
||||
let previewServer: DetachedProcessHandle;
|
||||
try {
|
||||
const turnArgs = (await turnArgsResult).enforceValue();
|
||||
const localEgress = !isAndroid ? await readLocalEgressHandoffAsync() : null;
|
||||
const previewExec = resolvePackageExec(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
isAndroid
|
||||
? createExpoDeviceHubArgs({
|
||||
port,
|
||||
device: previewDevice,
|
||||
turnArgs,
|
||||
packageVersion,
|
||||
recordingDirectory: recording?.directory,
|
||||
websiteArgs: websiteOriginArgs(env),
|
||||
frameAncestorArgs: frameAncestorArgs(frameAncestors),
|
||||
corsOriginArgs: corsOriginArgs(corsOrigins),
|
||||
})
|
||||
: createServeSimArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
packageVersion,
|
||||
iosSimulatorUdid: previewDevice,
|
||||
websiteArgs: websiteOriginArgs(env),
|
||||
frameAncestorArgs: frameAncestorArgs(frameAncestors),
|
||||
corsOriginArgs: corsOriginArgs(corsOrigins),
|
||||
// serve-sim adds its bearer token to --share-url; private links are for worker logs only.
|
||||
shareUrl: disableShare ? undefined : previewPageUrl,
|
||||
disableShare,
|
||||
installAppPath: iosSimulatorUdid ? undefined : installAppPath,
|
||||
launchAppIdentifier: iosSimulatorUdid ? undefined : launchAppIdentifier,
|
||||
launchArgs: iosSimulatorUdid ? [] : launchArgs,
|
||||
openUrl: iosSimulatorUdid ? undefined : openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
networkCaptureProxy: localEgress
|
||||
? `http://${LOCAL_EGRESS_PROXY_HOST}:${localEgress.port}`
|
||||
: undefined,
|
||||
})
|
||||
);
|
||||
logger.info(
|
||||
`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port} via ${previewExec.command}.`
|
||||
);
|
||||
// Redact these credentials before forwarding process output to build logs.
|
||||
secrets.push(
|
||||
...turnArgs.filter((_, index) => turnArgs[index - 1] === '--turn-credential'),
|
||||
...(recording ? [recording.controlToken] : [])
|
||||
);
|
||||
startup.signal.throwIfAborted();
|
||||
const launchOptions = {
|
||||
command: previewExec.command,
|
||||
args: previewExec.args,
|
||||
env: {
|
||||
...env,
|
||||
...bootEnv,
|
||||
...(isAndroid ? {} : { TMPDIR: serveSimTmpdir, SERVE_SIM_STATE_DIR: serveSimStateDir }),
|
||||
EXPO_DEVICE_HUB_SCREENSHOT_DIRECTORY: screenshots.directory,
|
||||
...(recording ? { EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN: recording.controlToken } : {}),
|
||||
// The Hub must write its token in the directory read by this host.
|
||||
...(isAndroid ? { EXPO_DEVICE_HUB_STATE_DIR } : {}),
|
||||
},
|
||||
logger,
|
||||
};
|
||||
previewServer = await server.startAsync({
|
||||
...launchOptions,
|
||||
signal: startup.signal,
|
||||
secrets,
|
||||
// Only an Android host records through expo-device-hub; serve-sim records on iOS.
|
||||
stopGracePeriodMs: recording
|
||||
? RECORDING_STOP_GRACE_PERIOD_MS
|
||||
: SERVE_SIM_STOP_GRACE_PERIOD_MS,
|
||||
});
|
||||
} catch (error) {
|
||||
startup.abort(error);
|
||||
// Mark the opening tunnel as retired; its late-open path closes it.
|
||||
finishTask = Promise.resolve();
|
||||
await Promise.allSettled([simulatorSetup, retirePreviewAsync()]);
|
||||
let hostStopped = true;
|
||||
try {
|
||||
await server.stopAsync();
|
||||
} catch (stopError) {
|
||||
hostStopped = false;
|
||||
logger.warn({ err: stopError }, 'Could not stop the session server after startup failed.');
|
||||
}
|
||||
// A warm server may already be writing here even if its adoption command failed.
|
||||
await screenshots.finishAsync(hostStopped);
|
||||
if (previewRetirementErrors.length) {
|
||||
throw new AggregateError(
|
||||
[error, ...previewRetirementErrors],
|
||||
'The warm Simulator preview tunnel could not be closed. Another session cannot start safely.'
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const host: DeviceSessionHost = {
|
||||
openPreviewAsync,
|
||||
getLastPreviewActivityAt() {
|
||||
return previewActivity?.getLastActivityObservedAt();
|
||||
},
|
||||
finishAsync() {
|
||||
readinessController.abort(new Error('Simulator preview ended.'));
|
||||
return (finishTask ??= finishDeviceSessionHostAsync(ctx, {
|
||||
previewTask,
|
||||
stopSessionPreviewAsync: async () => {
|
||||
previewSetupController.abort();
|
||||
await previewSetupTask;
|
||||
await sessionPreview?.stopAsync();
|
||||
await previewActivity?.stopAsync();
|
||||
},
|
||||
previewServer,
|
||||
screenshots,
|
||||
serverName,
|
||||
port,
|
||||
// A host that never answered /readyz has nothing to finalize or upload.
|
||||
recording: hostReady ? recording : null,
|
||||
logger,
|
||||
artifactLogger,
|
||||
onStopped: successful => onFinished(successful && !previewFailed),
|
||||
})
|
||||
return (finishTask ??= (async () => {
|
||||
const previewRetirement = retirePreviewAsync();
|
||||
if (server.requiresPreviewRetirement) {
|
||||
// Confirm the tunnel is closed before stop, so the next session cannot reach this one.
|
||||
await previewRetirement;
|
||||
}
|
||||
await finishDeviceSessionHostAsync(ctx, {
|
||||
previewRetirement,
|
||||
stopSessionPreviewAsync: async () => {
|
||||
previewSetupController.abort();
|
||||
await previewSetupTask;
|
||||
await sessionPreview?.stopAsync();
|
||||
await previewActivity?.stopAsync();
|
||||
},
|
||||
previewServer,
|
||||
screenshots,
|
||||
serverName,
|
||||
port,
|
||||
// A host that never answered /readyz has nothing to finalize or upload.
|
||||
recording: hostReady ? recording : null,
|
||||
logger,
|
||||
artifactLogger,
|
||||
onStopped: successful => onFinished(successful && !previewFailed),
|
||||
});
|
||||
if (previewRetirementErrors.length) {
|
||||
throw new AggregateError(
|
||||
previewRetirementErrors,
|
||||
'The warm Simulator preview tunnel could not be closed. Another session cannot start safely.'
|
||||
);
|
||||
}
|
||||
})()
|
||||
.finally(async () => {
|
||||
await simulatorSetup;
|
||||
if (stagedAppDirectory) {
|
||||
@@ -694,6 +850,12 @@ async function startDeviceSessionHostInternalAsync(
|
||||
.catch(err => {
|
||||
artifactLogger.warn({ err }, 'Could not finish the simulator preview.');
|
||||
onFinished(false);
|
||||
if (previewRetirementErrors.length) {
|
||||
throw new AggregateError(
|
||||
[err, ...previewRetirementErrors],
|
||||
'The warm Simulator preview tunnel could not be closed. Another session cannot start safely.'
|
||||
);
|
||||
}
|
||||
}));
|
||||
},
|
||||
};
|
||||
@@ -712,7 +874,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
})
|
||||
);
|
||||
if (iosSimulatorUdid) {
|
||||
previewToken = await waitForServeSimControlAsync({
|
||||
const token = await waitForServeSimControlAsync({
|
||||
device: iosSimulatorUdid,
|
||||
port,
|
||||
previewServer,
|
||||
@@ -720,7 +882,8 @@ async function startDeviceSessionHostInternalAsync(
|
||||
timeoutMs: startupTimeoutMs ?? timeoutMs,
|
||||
signal: readinessSignal,
|
||||
});
|
||||
secrets.push(previewToken);
|
||||
previewToken = token;
|
||||
secrets.push(token);
|
||||
if (Object.keys(bootEnv).length > 0) {
|
||||
await startup.untilAborted(
|
||||
verifyLocalEgressGuardAsync({ udid: iosSimulatorUdid as IosSimulatorUuid, env, logger })
|
||||
@@ -728,22 +891,16 @@ async function startDeviceSessionHostInternalAsync(
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
// APSD cleanup runs alongside app startup and readiness; teardown joins the task.
|
||||
simulatorSetup = asyncResult(
|
||||
startup.untilAborted(
|
||||
startup.run('Simulator setup', async taskLogger => {
|
||||
try {
|
||||
await IosSimulatorUtils.disableApsdAsync({
|
||||
udid: iosSimulatorUdid as IosSimulatorUuid,
|
||||
env,
|
||||
});
|
||||
} catch (err) {
|
||||
startup.signal.throwIfAborted();
|
||||
taskLogger.warn({ err }, 'Failed to disable apsd in the Simulator.');
|
||||
}
|
||||
startup.signal.throwIfAborted();
|
||||
})
|
||||
)
|
||||
);
|
||||
const setupSimulatorAsync = server.setupSimulatorAsync;
|
||||
if (setupSimulatorAsync) {
|
||||
simulatorSetup = asyncResult(
|
||||
startup.untilAborted(
|
||||
startup.run('Simulator setup', taskLogger =>
|
||||
setupSimulatorAsync({ signal: startup.signal, logger: taskLogger })
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
const app = (application ? await startup.untilAborted(application) : undefined) ?? {
|
||||
installAppPath,
|
||||
launchAppIdentifier,
|
||||
@@ -754,18 +911,39 @@ async function startDeviceSessionHostInternalAsync(
|
||||
const action = (name: string, params: Record<string, unknown>) =>
|
||||
runServeSimActionAsync({
|
||||
port,
|
||||
token: previewToken!,
|
||||
token,
|
||||
action: name,
|
||||
params,
|
||||
timeoutMs: startupTimeoutMs ?? timeoutMs,
|
||||
signal: startup.signal,
|
||||
});
|
||||
if (app.installAppPath) {
|
||||
// The bundle that has not run yet: just installed, or preinstalled and never launched.
|
||||
let notYetLaunched =
|
||||
!app.installAppPath && app.launchAppIdentifier === server.preinstalledApp?.bundleIdentifier
|
||||
? server.preinstalledApp?.bundleIdentifier
|
||||
: undefined;
|
||||
if (
|
||||
app.installAppPath &&
|
||||
server.preinstalledApp &&
|
||||
(await isPreinstalledExpoGoAsync({
|
||||
appPath: app.installAppPath,
|
||||
preinstalled: server.preinstalledApp,
|
||||
env,
|
||||
}))
|
||||
) {
|
||||
logger.info('This Expo Go version is already installed on the Simulator.');
|
||||
notYetLaunched = server.preinstalledApp.bundleIdentifier;
|
||||
} else if (app.installAppPath) {
|
||||
const staged = await stageServeSimAppAsync(app.installAppPath, serveSimTmpdir);
|
||||
stagedAppDirectory = staged.directory;
|
||||
signal?.throwIfAborted();
|
||||
await action('app.install', { udid: iosSimulatorUdid, path: staged.path });
|
||||
notYetLaunched = await readIosApplicationIdentifierAsync({
|
||||
artifactPath: app.installAppPath,
|
||||
env,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
startup.signal.throwIfAborted();
|
||||
if (app.launchAppIdentifier) {
|
||||
signal?.throwIfAborted();
|
||||
await action('app.launch', {
|
||||
@@ -773,6 +951,8 @@ async function startDeviceSessionHostInternalAsync(
|
||||
bundleId: app.launchAppIdentifier,
|
||||
launchArgs: app.launchArgs ?? [],
|
||||
openUrl: app.openUrl,
|
||||
// An app that has not run yet needs no terminate check; any other app keeps it.
|
||||
...(notYetLaunched === app.launchAppIdentifier ? { restart: false } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -834,11 +1014,19 @@ async function startDeviceSessionHostInternalAsync(
|
||||
logger.warn({ err }, 'Could not start refreshing the session preview.');
|
||||
}
|
||||
});
|
||||
(await earlyPreview).enforceValue();
|
||||
signal?.throwIfAborted();
|
||||
return host;
|
||||
} catch (error) {
|
||||
onStartupError?.(error);
|
||||
await host.finishAsync();
|
||||
try {
|
||||
await host.finishAsync();
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError(
|
||||
[error, cleanupError],
|
||||
'Device session startup failed, and cleanup also failed.'
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -887,7 +1075,7 @@ async function waitForServeSimControlAsync({
|
||||
async function finishDeviceSessionHostAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
previewTask,
|
||||
previewRetirement,
|
||||
stopSessionPreviewAsync,
|
||||
previewServer,
|
||||
screenshots,
|
||||
@@ -898,7 +1086,7 @@ async function finishDeviceSessionHostAsync(
|
||||
artifactLogger,
|
||||
onStopped,
|
||||
}: {
|
||||
previewTask: Promise<DeviceWebPreview> | null;
|
||||
previewRetirement: Promise<boolean>;
|
||||
stopSessionPreviewAsync: () => Promise<void>;
|
||||
previewServer: DetachedProcessHandle;
|
||||
screenshots: { finishAsync(hostStopped: boolean): Promise<void> };
|
||||
@@ -913,21 +1101,6 @@ async function finishDeviceSessionHostAsync(
|
||||
// Stop capturing before the host stops, so the last thumbnail shows the session, not shutdown.
|
||||
await stopSessionPreviewAsync();
|
||||
const hostExited = previewServer.getExitError() !== undefined;
|
||||
// Native ngrok operations have no scoped cancellation. Retire a late listener too.
|
||||
const retirePreview = withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Preview tunnel retirement', timeoutMs: 5_000 },
|
||||
async () => {
|
||||
// A preview that failed to open, or was closed by its opener after finish began, has no tunnel left.
|
||||
const preview = await previewTask?.catch(() => null);
|
||||
await preview?.closeAsync();
|
||||
}
|
||||
).then(
|
||||
() => true,
|
||||
err => {
|
||||
logger.warn({ err }, `Could not close the ${serverName} preview tunnel within its deadline.`);
|
||||
return false;
|
||||
}
|
||||
);
|
||||
let finalization: AndroidRecordingFinalization | null = null;
|
||||
if (recording) {
|
||||
// stopAsync signals the whole process group, including capture's encoder.
|
||||
@@ -948,7 +1121,7 @@ async function finishDeviceSessionHostAsync(
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `Could not stop the ${serverName} session host.`);
|
||||
}
|
||||
const previewRetired = await retirePreview;
|
||||
const previewRetired = await previewRetirement;
|
||||
onStopped(hostStopped && !hostExited && previewRetired);
|
||||
await screenshots.finishAsync(hostStopped);
|
||||
// A Hub that never recorded has logged its reason and left nothing to upload.
|
||||
|
||||
@@ -39,3 +39,54 @@ export async function readIosApplicationIdentifierAsync({
|
||||
}
|
||||
return applicationIdentifier;
|
||||
}
|
||||
|
||||
const EXPO_GO_BUNDLE_IDENTIFIER = 'host.exp.Exponent';
|
||||
|
||||
export type IosAppIdentity = { bundleIdentifier: string; shortVersion: string; version: string };
|
||||
|
||||
/**
|
||||
* Expo Go releases are identified by their version, so a preinstalled copy with the same version is
|
||||
* the same app. Custom builds can reuse a version, so they are always installed.
|
||||
*/
|
||||
export async function isPreinstalledExpoGoAsync({
|
||||
appPath,
|
||||
preinstalled,
|
||||
env,
|
||||
}: {
|
||||
appPath: string;
|
||||
preinstalled: IosAppIdentity;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<boolean> {
|
||||
if (preinstalled.bundleIdentifier !== EXPO_GO_BUNDLE_IDENTIFIER) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const requested = await readIosAppIdentityAsync(appPath, env);
|
||||
return (
|
||||
requested.bundleIdentifier === preinstalled.bundleIdentifier &&
|
||||
requested.shortVersion === preinstalled.shortVersion &&
|
||||
requested.version === preinstalled.version
|
||||
);
|
||||
} catch {
|
||||
// An app that cannot be read is installed as usual.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readIosAppIdentityAsync(
|
||||
appPath: string,
|
||||
env: BuildStepEnv
|
||||
): Promise<IosAppIdentity> {
|
||||
const infoPlistPath = path.join(appPath, 'Info.plist');
|
||||
const [bundleIdentifier, shortVersion, version] = await Promise.all(
|
||||
['CFBundleIdentifier', 'CFBundleShortVersionString', 'CFBundleVersion'].map(async key =>
|
||||
(
|
||||
await spawn('plutil', ['-extract', key, 'raw', '-o', '-', infoPlistPath], {
|
||||
stdio: 'pipe',
|
||||
env,
|
||||
})
|
||||
).stdout.trim()
|
||||
)
|
||||
);
|
||||
return { bundleIdentifier, shortVersion, version };
|
||||
}
|
||||
@@ -97,7 +97,12 @@ export async function resolveIosSessionStartupAsync({
|
||||
signal?.throwIfAborted();
|
||||
if (device && device.state !== 'Shutdown') {
|
||||
logger.info(`Shutting down ${iosSimulatorUdid} to apply the local egress guard at boot.`);
|
||||
await spawn('xcrun', ['simctl', 'shutdown', iosSimulatorUdid], { env, logger, signal });
|
||||
try {
|
||||
await spawn('xcrun', ['simctl', 'shutdown', iosSimulatorUdid], { env, logger, signal });
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
await startLocalEgressGuardRelayAsync({ logger });
|
||||
|
||||
@@ -37,6 +37,7 @@ export async function uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
signal?.throwIfAborted();
|
||||
const sessionStart = await uploadRemoteSessionConfigAsync({
|
||||
...options,
|
||||
signal,
|
||||
remoteConfig: { ...options.remoteConfig, ...buildEgressRemoteConfigFields(localEgress) },
|
||||
});
|
||||
if (sessionStart === 'stopped') {
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { once } from 'node:events';
|
||||
import { type AddressInfo, type Socket, createConnection, createServer } from 'node:net';
|
||||
import { clearTimeout, setTimeout } from 'node:timers';
|
||||
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
|
||||
const PROBE_INTERVAL_MS = 15_000;
|
||||
const FAILURE_THRESHOLD = 3;
|
||||
const STOP_TIMEOUT_MS = 4_000;
|
||||
|
||||
export type NgrokTunnelHandle = {
|
||||
url: string;
|
||||
subdomainId: string;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
export async function startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix,
|
||||
subdomainId = randomBytes(16).toString('hex'),
|
||||
baseDomain,
|
||||
authtoken,
|
||||
rewriteHostHeader,
|
||||
healthCheck,
|
||||
logger,
|
||||
}: {
|
||||
port: number;
|
||||
subdomainPrefix: string;
|
||||
subdomainId?: string;
|
||||
baseDomain: string;
|
||||
authtoken: string;
|
||||
rewriteHostHeader?: boolean;
|
||||
healthCheck?: { path: string; statuses?: number[] };
|
||||
logger: bunyan;
|
||||
}): Promise<NgrokTunnelHandle> {
|
||||
const domain = `${subdomainPrefix}-${subdomainId}.${baseDomain}`;
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
const sockets = new Set<Socket>();
|
||||
const target = createServer({ allowHalfOpen: true }, client => {
|
||||
if (signal.aborted) {
|
||||
client.destroy();
|
||||
return;
|
||||
}
|
||||
const upstream = createConnection({ host: '127.0.0.1', port, allowHalfOpen: true });
|
||||
for (const socket of [client, upstream]) {
|
||||
sockets.add(socket);
|
||||
socket.on('error', () => {
|
||||
client.destroy();
|
||||
upstream.destroy();
|
||||
});
|
||||
socket.on('close', () => sockets.delete(socket));
|
||||
}
|
||||
client.pipe(upstream).pipe(client);
|
||||
});
|
||||
target.listen(0, '127.0.0.1');
|
||||
await once(target, 'listening');
|
||||
target.unref();
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
for (const socket of sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
const config: ngrok.Config = {
|
||||
addr: (target.address() as AddressInfo).port,
|
||||
authtoken,
|
||||
domain,
|
||||
...(rewriteHostHeader ? { request_header_add: [`Host:localhost:${port}`] } : {}),
|
||||
};
|
||||
|
||||
const ownedListeners = new Set<ngrok.Listener>();
|
||||
const retiredListeners = new Set<ngrok.Listener>();
|
||||
const closingListeners = new Map<ngrok.Listener, Promise<void>>();
|
||||
let pendingOpen: Promise<ngrok.Listener> | undefined;
|
||||
let currentListener: ngrok.Listener | undefined;
|
||||
function releaseTarget(): void {
|
||||
if (signal.aborted && !pendingOpen && ownedListeners.size === 0 && target.listening) {
|
||||
target.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function closeAsync(
|
||||
listener: ngrok.Listener,
|
||||
closeSignal: AbortSignal,
|
||||
logFailure = true
|
||||
): Promise<void> {
|
||||
retiredListeners.add(listener);
|
||||
let closing = closingListeners.get(listener);
|
||||
if (!closing) {
|
||||
closing = Promise.resolve().then(async () => await listener.close());
|
||||
closingListeners.set(listener, closing);
|
||||
void closing.then(
|
||||
() => {
|
||||
closingListeners.delete(listener);
|
||||
retiredListeners.delete(listener);
|
||||
ownedListeners.delete(listener);
|
||||
releaseTarget();
|
||||
},
|
||||
() => closingListeners.delete(listener)
|
||||
);
|
||||
}
|
||||
try {
|
||||
await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Ngrok tunnel close', timeoutMs: 5_000, signal: closeSignal },
|
||||
async () => await closing
|
||||
);
|
||||
} catch (err) {
|
||||
if (logFailure && !closeSignal.aborted) {
|
||||
logger.warn({ err }, `Could not stop ngrok tunnel ${domain}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function retireListenersAsync(retirementSignal: AbortSignal): Promise<void> {
|
||||
let logFailure = true;
|
||||
while (retiredListeners.size > 0) {
|
||||
retirementSignal.throwIfAborted();
|
||||
await Promise.all(
|
||||
[...retiredListeners].map(retired => closeAsync(retired, retirementSignal, logFailure))
|
||||
);
|
||||
logFailure = false;
|
||||
if (retiredListeners.size > 0) {
|
||||
await waitAsync(250, retirementSignal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function retireLateListenerAsync(listener: ngrok.Listener): Promise<void> {
|
||||
retiredListeners.add(listener);
|
||||
try {
|
||||
await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Late ngrok tunnel retirement', timeoutMs: STOP_TIMEOUT_MS },
|
||||
async retirementSignal => {
|
||||
while (retiredListeners.has(listener)) {
|
||||
retirementSignal.throwIfAborted();
|
||||
await closeAsync(listener, retirementSignal, false);
|
||||
if (retiredListeners.has(listener)) {
|
||||
await waitAsync(250, retirementSignal);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `Could not retire late ngrok tunnel ${domain}.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function openAsync(forceNewSession = false): Promise<ngrok.Listener> {
|
||||
if (pendingOpen) {
|
||||
throw new SystemError(`A previous ngrok tunnel open for ${domain} is still pending.`);
|
||||
}
|
||||
let acquired: ngrok.Listener | undefined;
|
||||
let retirement: Promise<void> | undefined;
|
||||
const retireAcquired = () => {
|
||||
if (acquired) {
|
||||
retirement ??= retireLateListenerAsync(acquired);
|
||||
}
|
||||
};
|
||||
try {
|
||||
return await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Ngrok tunnel open', timeoutMs: 15_000, signal },
|
||||
async openSignal => {
|
||||
openSignal.throwIfAborted();
|
||||
const opening = ngrok
|
||||
.forward({
|
||||
...config,
|
||||
...(forceNewSession ? { force_new_session: true } : {}),
|
||||
})
|
||||
.then(listener => {
|
||||
acquired = listener;
|
||||
ownedListeners.add(listener);
|
||||
return listener;
|
||||
})
|
||||
.finally(() => {
|
||||
pendingOpen = undefined;
|
||||
});
|
||||
pendingOpen = opening;
|
||||
void opening
|
||||
.then(() => {
|
||||
if (openSignal.aborted) {
|
||||
retireAcquired();
|
||||
}
|
||||
})
|
||||
.catch(() => releaseTarget());
|
||||
const listener = await opening;
|
||||
openSignal.throwIfAborted();
|
||||
return listener;
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
retireAcquired();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Starting ngrok tunnel ${domain} -> http://localhost:${port}.`);
|
||||
let publicUrl: string | null;
|
||||
try {
|
||||
currentListener = await openAsync();
|
||||
publicUrl = currentListener.url();
|
||||
if (!publicUrl) {
|
||||
await closeAsync(currentListener, signal);
|
||||
currentListener = undefined;
|
||||
throw new SystemError(`ngrok tunnel for ${domain} did not return a public URL.`);
|
||||
}
|
||||
} catch (error) {
|
||||
controller.abort();
|
||||
releaseTarget();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const url = publicUrl;
|
||||
|
||||
async function probeAsync(
|
||||
baseUrl: string,
|
||||
{ path, statuses = [200] }: NonNullable<typeof healthCheck>
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Ngrok tunnel health probe', timeoutMs: 5_000, signal },
|
||||
async probeSignal => {
|
||||
const requestController = new AbortController();
|
||||
try {
|
||||
const response = await turtleFetch(new URL(path, baseUrl).href, 'GET', {
|
||||
retries: 0,
|
||||
redirect: 'manual',
|
||||
shouldThrowOnNotOk: false,
|
||||
headers: { 'ngrok-skip-browser-warning': 'true', 'Cache-Control': 'no-cache' },
|
||||
signal: AbortSignal.any([probeSignal, requestController.signal]),
|
||||
});
|
||||
return !response.headers.has('ngrok-error-code') && statuses.includes(response.status);
|
||||
} finally {
|
||||
requestController.abort();
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function superviseAsync(check: NonNullable<typeof healthCheck>): Promise<void> {
|
||||
let failures = 0;
|
||||
let attempts = 0;
|
||||
let localUnhealthy = false;
|
||||
while (
|
||||
await waitAsync(
|
||||
currentListener || localUnhealthy
|
||||
? PROBE_INTERVAL_MS
|
||||
: Math.min(1_000 * 2 ** Math.min(attempts, 5), 30_000),
|
||||
signal
|
||||
)
|
||||
) {
|
||||
await Promise.all([...retiredListeners].map(retired => closeAsync(retired, signal, false)));
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
if (currentListener && (await probeAsync(url, check))) {
|
||||
if (failures > 0 || attempts > 0 || localUnhealthy) {
|
||||
logger.info(`Ngrok tunnel ${domain} is healthy again.`);
|
||||
}
|
||||
failures = 0;
|
||||
attempts = 0;
|
||||
localUnhealthy = false;
|
||||
continue;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
if (!(await probeAsync(`http://127.0.0.1:${port}`, check))) {
|
||||
if (!localUnhealthy && !signal.aborted) {
|
||||
logger.warn(`Local service on port ${port} is unhealthy; waiting for it to recover.`);
|
||||
}
|
||||
localUnhealthy = true;
|
||||
failures = 0;
|
||||
continue;
|
||||
}
|
||||
localUnhealthy = false;
|
||||
if (currentListener) {
|
||||
failures++;
|
||||
if (failures < FAILURE_THRESHOLD) {
|
||||
continue;
|
||||
}
|
||||
logger.warn(`Ngrok tunnel ${domain} failed ${failures} health probes; reopening it.`);
|
||||
await closeAsync(currentListener, signal);
|
||||
currentListener = undefined;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
attempts++;
|
||||
try {
|
||||
const reopened = await openAsync(/* forceNewSession */ true);
|
||||
if (reopened.url() !== url) {
|
||||
await closeAsync(reopened, signal);
|
||||
throw new SystemError(
|
||||
`Reopened ngrok tunnel for ${domain} returned a different public URL.`
|
||||
);
|
||||
}
|
||||
currentListener = reopened;
|
||||
failures = 0;
|
||||
} catch (err) {
|
||||
if (!signal.aborted && (attempts === 1 || attempts % 5 === 0)) {
|
||||
logger.warn(
|
||||
{ err, attempt: attempts },
|
||||
`Could not reopen ngrok tunnel ${domain}; will retry.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const supervision = healthCheck
|
||||
? superviseAsync(healthCheck).catch(err => {
|
||||
if (!signal.aborted) {
|
||||
logger.warn({ err }, `Ngrok tunnel supervision stopped for ${domain}.`);
|
||||
}
|
||||
})
|
||||
: Promise.resolve();
|
||||
let stopTask: Promise<void> | undefined;
|
||||
return {
|
||||
url,
|
||||
subdomainId,
|
||||
stopAsync: () =>
|
||||
(stopTask ??= (async () => {
|
||||
controller.abort();
|
||||
await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: `Ngrok tunnel ${domain} stop`, timeoutMs: STOP_TIMEOUT_MS },
|
||||
async stopSignal => {
|
||||
await supervision;
|
||||
if (currentListener) {
|
||||
retiredListeners.add(currentListener);
|
||||
currentListener = undefined;
|
||||
}
|
||||
await Promise.all([pendingOpen?.catch(() => {}), retireListenersAsync(stopSignal)]);
|
||||
await retireListenersAsync(stopSignal);
|
||||
}
|
||||
);
|
||||
releaseTarget();
|
||||
})()),
|
||||
};
|
||||
}
|
||||
|
||||
function waitAsync(ms: number, signal: AbortSignal): Promise<boolean> {
|
||||
if (signal.aborted) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return new Promise(resolve => {
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
resolve(false);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
resolve(true);
|
||||
}, ms);
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
@@ -9,9 +9,7 @@ import {
|
||||
spawnAsync,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import { z } from 'zod';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import { createServer } from 'node:net';
|
||||
import path from 'node:path';
|
||||
@@ -131,6 +129,8 @@ export type DeviceRunSessionIdleTimeout = {
|
||||
getLastPreviewActivityAt?: () => Date | undefined;
|
||||
};
|
||||
|
||||
export class DeviceRunSessionStoppedError extends Error {}
|
||||
|
||||
export async function waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
@@ -164,9 +164,7 @@ export async function waitForDeviceRunSessionStoppedAsync({
|
||||
}, maxDurationSeconds * 1_000);
|
||||
|
||||
try {
|
||||
logger.info(
|
||||
`Remote session is live. Polling device run session ${deviceRunSessionId} until it is stopped.`
|
||||
);
|
||||
logger.info(`Polling device run session ${deviceRunSessionId} until it is stopped.`);
|
||||
if (durationTimeout !== undefined) {
|
||||
logger.info(
|
||||
`The device run session will stop automatically after ${maxDurationSeconds} seconds.`
|
||||
@@ -213,6 +211,9 @@ export async function waitForDeviceRunSessionStoppedAsync({
|
||||
if (err instanceof SystemError) {
|
||||
throw err;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
pollErrorCount += 1;
|
||||
@@ -524,6 +525,14 @@ export async function fetchWebPreviewTurnArgsAsync(
|
||||
}
|
||||
return args;
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof DeviceRunSessionWorkerApiError &&
|
||||
err.code === 'DEVICE_RUN_SESSION_ALREADY_STOPPED'
|
||||
) {
|
||||
logger.info('Device run session was stopped before TURN credentials were fetched.');
|
||||
return [];
|
||||
}
|
||||
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not fetch Cloudflare TURN ICE servers', error, { level: 'warning' });
|
||||
logger.warn(
|
||||
@@ -550,12 +559,15 @@ export async function uploadRemoteSessionConfigAsync({
|
||||
deviceRunSessionId,
|
||||
remoteConfig,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
ctx: CustomBuildContext;
|
||||
deviceRunSessionId: string;
|
||||
remoteConfig: Record<string, unknown>;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<'started' | 'stopped'> {
|
||||
signal?.throwIfAborted();
|
||||
logger.info(
|
||||
`Reporting remote config to the API server (device run session: ${deviceRunSessionId}).`
|
||||
);
|
||||
@@ -563,10 +575,11 @@ export async function uploadRemoteSessionConfigAsync({
|
||||
try {
|
||||
await workerApi.startAsync(
|
||||
{ remoteConfig },
|
||||
{ retries: 3, timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS, logger }
|
||||
{ retries: 3, timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS, logger, signal }
|
||||
);
|
||||
return 'started';
|
||||
} catch (err) {
|
||||
signal?.throwIfAborted();
|
||||
if (
|
||||
err instanceof DeviceRunSessionWorkerApiError &&
|
||||
err.code === 'DEVICE_RUN_SESSION_ALREADY_STOPPED'
|
||||
@@ -601,7 +614,7 @@ export function isProcessRunning(pid: number): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
async function stopDetachedProcessAsync(
|
||||
export async function stopDetachedProcessAsync(
|
||||
pid: number | undefined,
|
||||
gracePeriodMs = 5_000
|
||||
): Promise<void> {
|
||||
@@ -767,7 +780,7 @@ export function createIosSessionStartupInputProviders(): ReturnType<
|
||||
}
|
||||
|
||||
export type ServeSimApplicationOptions = ServeSimLaunchOptions & {
|
||||
installAppPath: string;
|
||||
installAppPath?: string;
|
||||
launchAppIdentifier: string;
|
||||
};
|
||||
|
||||
@@ -959,63 +972,7 @@ export async function findAvailablePortAsync(): Promise<number> {
|
||||
return address.port;
|
||||
}
|
||||
|
||||
export type NgrokTunnelHandle = {
|
||||
url: string;
|
||||
subdomainId: string;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
export async function startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix,
|
||||
subdomainId: subdomainIdArg,
|
||||
baseDomain,
|
||||
authtoken,
|
||||
rewriteHostHeader,
|
||||
logger,
|
||||
}: {
|
||||
port: number;
|
||||
subdomainPrefix: string;
|
||||
subdomainId?: string;
|
||||
baseDomain: string;
|
||||
authtoken: string;
|
||||
rewriteHostHeader?: boolean;
|
||||
logger: bunyan;
|
||||
}): Promise<NgrokTunnelHandle> {
|
||||
const subdomainId = subdomainIdArg ?? randomBytes(16).toString('hex');
|
||||
const domain = `${subdomainPrefix}-${subdomainId}.${baseDomain}`;
|
||||
logger.info(`Starting ngrok tunnel ${domain} -> http://localhost:${port}.`);
|
||||
// Run the ngrok agent in-process via the SDK; it keeps the session alive until
|
||||
// the process exits, and the step blocks forever to hold it open.
|
||||
const listener = await ngrok.forward({
|
||||
addr: port,
|
||||
authtoken,
|
||||
domain,
|
||||
...(rewriteHostHeader ? { request_header_add: [`Host:localhost:${port}`] } : {}),
|
||||
});
|
||||
const url = listener.url();
|
||||
if (!url) {
|
||||
await listener.close();
|
||||
throw new SystemError(`ngrok tunnel for ${domain} did not return a public URL.`);
|
||||
}
|
||||
let stopTask: Promise<void> | undefined;
|
||||
return {
|
||||
url,
|
||||
subdomainId,
|
||||
stopAsync: () =>
|
||||
(stopTask ??= (async () => {
|
||||
try {
|
||||
await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Ngrok tunnel stop', timeoutMs: 4_000 },
|
||||
async () => await listener.close()
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn({ err: error }, `Could not stop ngrok tunnel ${domain}.`);
|
||||
throw error;
|
||||
}
|
||||
})()),
|
||||
};
|
||||
}
|
||||
export { type NgrokTunnelHandle, startNgrokTunnelAsync } from './ngrokTunnel';
|
||||
|
||||
export async function waitForFileAsync<T>({
|
||||
filePath,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
|
||||
import { uploadDeviceRunSessionFileAsync } from './uploadDeviceRunSessionFile';
|
||||
|
||||
export async function uploadServeSimLogsFileAsync(
|
||||
ctx: CustomBuildContext,
|
||||
@@ -22,21 +20,22 @@ export async function uploadServeSimLogsFileAsync(
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { size } = await stat(filePath);
|
||||
if (size === 0) {
|
||||
return;
|
||||
}
|
||||
const fileController = new AbortController();
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId: `simulator-log-${udid}`,
|
||||
name: `App logs (${udid.slice(0, 8)})`,
|
||||
filename: 'app-logs.ndjson',
|
||||
kind: 'simulator-log',
|
||||
metadata: { __eas_type: 'simulator-log', udid, scope: 'user-apps', source: 'serve-sim/logs' },
|
||||
size,
|
||||
openStream: () => createReadStream(filePath).on('error', err => fileController.abort(err)),
|
||||
signal: AbortSignal.any([fileController.signal, ...(signal ? [signal] : [])]),
|
||||
await uploadDeviceRunSessionFileAsync(ctx, {
|
||||
filePath,
|
||||
describe: () => ({
|
||||
deviceRunSessionId,
|
||||
artifactId: `simulator-log-${udid}`,
|
||||
name: `App logs (${udid.slice(0, 8)})`,
|
||||
filename: 'app-logs.ndjson',
|
||||
kind: 'simulator-log',
|
||||
metadata: {
|
||||
__eas_type: 'simulator-log',
|
||||
udid,
|
||||
scope: 'user-apps',
|
||||
source: 'serve-sim/logs',
|
||||
},
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
type ArtifactMetadata,
|
||||
uploadDeviceRunSessionArtifactAsync,
|
||||
} from './deviceRunSessionArtifacts';
|
||||
|
||||
/** Upload a finalized file, reopening it for each attempt. */
|
||||
export async function uploadDeviceRunSessionFileAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
filePath,
|
||||
describe,
|
||||
signal,
|
||||
attempts = 1,
|
||||
}: {
|
||||
filePath: string;
|
||||
describe: (size: number) => Omit<ArtifactMetadata, 'size'>;
|
||||
signal?: AbortSignal;
|
||||
attempts?: number;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
const { size } = await stat(filePath);
|
||||
if (size === 0) {
|
||||
return false;
|
||||
}
|
||||
const readController = new AbortController();
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...describe(size),
|
||||
size,
|
||||
openStream: () =>
|
||||
createReadStream(filePath).on('error', err => {
|
||||
// An HTTP error aborts one request body, not the remaining upload attempts.
|
||||
if (err.name !== 'AbortError') {
|
||||
readController.abort(err);
|
||||
}
|
||||
}),
|
||||
signal: AbortSignal.any([readController.signal, ...(signal ? [signal] : [])]),
|
||||
attempts,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { IosSimulatorRecordingUtils } from './IosSimulatorRecordingUtils';
|
||||
import { type DeviceSessionServer } from './deviceSessionHost';
|
||||
import { fetchWebPreviewTurnArgsAsync } from './remoteDeviceRunSession';
|
||||
import { type WarmIosSession, tryClaimWarmIosSessionAsync } from './warmIosSession';
|
||||
|
||||
/** Adoption failed before app actions, so the caller can stop warmup and start cold. */
|
||||
export class WarmSessionAdoptionError extends Error {
|
||||
constructor() {
|
||||
// The adoption command line includes TURN credentials, so the cause stays out of the message.
|
||||
super('Could not adopt the warm Simulator for this job.');
|
||||
this.name = 'WarmSessionAdoptionError';
|
||||
}
|
||||
}
|
||||
|
||||
/** A session server that adopts the warm serve-sim for the job's device instead of launching one. */
|
||||
export function createWarmDeviceSessionServer(
|
||||
ctx: CustomBuildContext,
|
||||
logger: bunyan,
|
||||
warm: WarmIosSession
|
||||
): DeviceSessionServer {
|
||||
const { udid, name } = warm.device;
|
||||
async function stopAsync(): Promise<void> {
|
||||
await warm.process.stopAsync();
|
||||
}
|
||||
return {
|
||||
port: warm.server.port,
|
||||
turnArgs: fetchWebPreviewTurnArgsAsync(ctx, { logger }),
|
||||
// Set by prepare-tools, which the host runs before it installs the app.
|
||||
get preinstalledApp() {
|
||||
return warm.preinstalledExpoGo;
|
||||
},
|
||||
requiresPreviewRetirement: true,
|
||||
stopAsync,
|
||||
async startAsync({ command, args, env, signal }) {
|
||||
try {
|
||||
// serve-sim starts this job's recording when it adopts the device.
|
||||
const recording = await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
|
||||
udid,
|
||||
deviceName: name,
|
||||
serverToken: warm.server.token,
|
||||
});
|
||||
const recordingArgs = recording
|
||||
? ['--recording-output', recording.output, '--recording-id', recording.recordingId]
|
||||
: [];
|
||||
const timeout = AbortSignal.timeout(360_000);
|
||||
await spawn(command, [...args, '--single-device', udid, '--adopt', ...recordingArgs], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
||||
});
|
||||
await warm.prepareToolsAsync(signal);
|
||||
} catch {
|
||||
signal?.throwIfAborted();
|
||||
throw new WarmSessionAdoptionError();
|
||||
}
|
||||
return { ...warm.process, stopAsync };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an iOS session on the warm Simulator when the job can claim it, else cold. After a
|
||||
* callback failure, it stops warmup. Only an adoption failure can retry cold.
|
||||
*/
|
||||
export async function runOnWarmIosSimulatorAsync<T>(
|
||||
claim: Parameters<typeof tryClaimWarmIosSessionAsync>[0],
|
||||
run: (warm?: WarmIosSession) => Promise<T>
|
||||
): Promise<T> {
|
||||
const warm = await tryClaimWarmIosSessionAsync(claim);
|
||||
if (!warm) {
|
||||
return await run(undefined);
|
||||
}
|
||||
try {
|
||||
return await run(warm);
|
||||
} catch (error) {
|
||||
try {
|
||||
await warm.process.stopAsync();
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError(
|
||||
[error, cleanupError],
|
||||
'Could not stop the warm Simulator after failure.'
|
||||
);
|
||||
}
|
||||
if (!(error instanceof WarmSessionAdoptionError)) {
|
||||
throw error;
|
||||
}
|
||||
claim.logger.warn('Could not adopt the warm Simulator; starting a cold Simulator.');
|
||||
claim.signal?.throwIfAborted();
|
||||
return await run(undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type BuildStepEnv } from '@expo/steps';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { runSimulatorLauncherAsync, stopWarmSimulatorAsync } from '../../utils/warmSimulator';
|
||||
import { readLocalEgressHandoffAsync } from './localEgress';
|
||||
import { type DetachedProcessHandle, isProcessRunning } from './remoteDeviceRunSession';
|
||||
|
||||
const ServiceSchema = z.object({ pid: z.number(), port: z.number(), token: z.string() });
|
||||
const ClaimSchema = z.object({
|
||||
device: z.object({ udid: z.string(), name: z.string() }),
|
||||
server: ServiceSchema,
|
||||
agentDeviceStateDir: z.string(),
|
||||
argentEventLogPath: z.string(),
|
||||
preinstalledExpoGo: z
|
||||
.object({ bundleIdentifier: z.string(), shortVersion: z.string(), version: z.string() })
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type Service = z.infer<typeof ServiceSchema>;
|
||||
|
||||
/** The warm Simulator that the launcher handed to this job. */
|
||||
export type WarmIosSession = z.infer<typeof ClaimSchema> & {
|
||||
/** The selected tool's server; set by prepareToolsAsync. */
|
||||
tools?: Service;
|
||||
/** Runs after adoption: the launcher prepares the selected tool for the adopted device. */
|
||||
prepareToolsAsync(signal?: AbortSignal): Promise<void>;
|
||||
process: DetachedProcessHandle;
|
||||
};
|
||||
|
||||
/**
|
||||
* Claims the warm Simulator. The launcher checks the session; build-tools only uses it. On any
|
||||
* failure, warmup stops and the caller uses a cold start; a failed stop rejects.
|
||||
*/
|
||||
export async function tryClaimWarmIosSessionAsync({
|
||||
engine,
|
||||
requestedDevice,
|
||||
packageVersion,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
engine: 'serve-sim' | 'agent-device' | 'argent' | 'appium';
|
||||
requestedDevice?: string;
|
||||
packageVersion?: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<WarmIosSession | undefined> {
|
||||
if (env.EAS_USE_WARM_SIMULATOR !== '1') {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
// Warm tools run the latest release with the VM's Simulator settings.
|
||||
if (packageVersion && packageVersion !== 'latest') {
|
||||
throw new Error('A pinned package_version needs a cold start.');
|
||||
}
|
||||
const setting = Object.keys(env).find(
|
||||
name => name === 'SERVE_SIM_ADDITIONAL_DYLIBS' || name.startsWith('SIMCTL_CHILD_')
|
||||
);
|
||||
if (setting) {
|
||||
throw new Error(`${setting} needs a cold start.`);
|
||||
}
|
||||
if (await readLocalEgressHandoffAsync()) {
|
||||
throw new Error('A local egress session needs a cold start.');
|
||||
}
|
||||
logger.info('Waiting for the warm Simulator launcher.');
|
||||
const claimArgs = ['claim', engine, ...(requestedDevice ? [requestedDevice] : [])];
|
||||
const claim = ClaimSchema.parse(
|
||||
JSON.parse(await runSimulatorLauncherAsync(claimArgs, { env, signal }))
|
||||
);
|
||||
logger.info(`Claimed the warm Simulator ${claim.device.name} (${claim.device.udid}).`);
|
||||
let stopping: Promise<void> | undefined;
|
||||
const session: WarmIosSession = {
|
||||
...claim,
|
||||
async prepareToolsAsync(prepareSignal = signal) {
|
||||
const prepared = ClaimSchema.pick({ preinstalledExpoGo: true })
|
||||
.extend({ tools: ServiceSchema.optional() })
|
||||
.parse(
|
||||
JSON.parse(
|
||||
await runSimulatorLauncherAsync(['prepare-tools'], { env, signal: prepareSignal })
|
||||
)
|
||||
);
|
||||
// The launcher reports the Expo Go it preinstalled once its install has finished.
|
||||
session.preinstalledExpoGo = prepared.preinstalledExpoGo ?? session.preinstalledExpoGo;
|
||||
if (engine === 'agent-device') {
|
||||
const daemon = z
|
||||
.object({ pid: z.number(), httpPort: z.number(), token: z.string() })
|
||||
.parse(await readJsonAsync(path.join(claim.agentDeviceStateDir, 'daemon.json')));
|
||||
session.tools = { pid: daemon.pid, port: daemon.httpPort, token: daemon.token };
|
||||
} else if (engine === 'argent') {
|
||||
session.tools = ServiceSchema.parse(prepared.tools);
|
||||
}
|
||||
},
|
||||
process: {
|
||||
pid: claim.server.pid,
|
||||
getOutput: () => '',
|
||||
getExitError: () =>
|
||||
isProcessRunning(claim.server.pid) &&
|
||||
(!session.tools || isProcessRunning(session.tools.pid))
|
||||
? undefined
|
||||
: new Error('A service of the warm Simulator session has exited.'),
|
||||
stopAsync: () => (stopping ??= stopWarmSimulatorAsync(env)),
|
||||
},
|
||||
};
|
||||
return session;
|
||||
} catch (error) {
|
||||
logger.warn({ err: error }, 'Could not use the warm Simulator; stopping it for a cold start.');
|
||||
await stopWarmSimulatorAsync(env);
|
||||
signal?.throwIfAborted();
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonAsync(file: string): Promise<unknown> {
|
||||
return JSON.parse(await fs.readFile(file, 'utf8'));
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { setTimeout } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Sentry } from '../sentry';
|
||||
import { type AndroidEmulatorLogSource } from './androidEmulatorLogSources';
|
||||
import { type AndroidEmulatorLogCapture } from './androidEmulatorLogCapture';
|
||||
import { retryAsync } from './retry';
|
||||
|
||||
/** Android Virtual Device is the device we run. */
|
||||
@@ -326,20 +328,22 @@ export namespace AndroidEmulatorUtils {
|
||||
deviceName,
|
||||
env,
|
||||
logcatDirectory,
|
||||
logSources,
|
||||
launchGate,
|
||||
logCapture,
|
||||
}: {
|
||||
deviceName: AndroidVirtualDeviceName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logcatDirectory: string;
|
||||
logSources: readonly AndroidEmulatorLogSource[];
|
||||
launchGate?: AndroidEmulatorLaunchGate;
|
||||
logCapture: AndroidEmulatorLogCapture;
|
||||
}): Promise<{
|
||||
emulatorPromise: SpawnPromise<SpawnResult>;
|
||||
serialId: AndroidDeviceSerialId;
|
||||
logcatOutputPath: string;
|
||||
emulatorOutputPath: string;
|
||||
}> {
|
||||
let logcatOutputPath: string;
|
||||
let emulatorOutputPath: string;
|
||||
let logcatOutputPath: string | undefined;
|
||||
let emulatorOutputPath: string | undefined;
|
||||
try {
|
||||
await fs.promises.mkdir(logcatDirectory, { recursive: true });
|
||||
const safeDeviceName = deviceName.replace(/[^a-zA-Z0-9_.-]/g, '_');
|
||||
@@ -347,16 +351,23 @@ export namespace AndroidEmulatorUtils {
|
||||
.toString(16)
|
||||
.padStart(8, '0');
|
||||
const outputName = `${safeDeviceName}-${timestamp}-${randomBytes(2).toString('hex')}`;
|
||||
logcatOutputPath = path.join(logcatDirectory, `${outputName}-logcat.log`);
|
||||
emulatorOutputPath = path.join(logcatDirectory, `${outputName}-emulator.log`);
|
||||
await fs.promises.writeFile(logcatOutputPath, '');
|
||||
await fs.promises.writeFile(emulatorOutputPath, '');
|
||||
if (logSources.includes('device-logcat')) {
|
||||
logcatOutputPath = path.join(logcatDirectory, `${outputName}-logcat.log`);
|
||||
await fs.promises.writeFile(logcatOutputPath, '');
|
||||
}
|
||||
if (logSources.includes('emulator-output')) {
|
||||
emulatorOutputPath = path.join(logcatDirectory, `${outputName}-emulator.log`);
|
||||
await fs.promises.writeFile(emulatorOutputPath, '');
|
||||
}
|
||||
} catch (err) {
|
||||
throw new SystemError(`Failed to prepare Android emulator output for ${deviceName}.`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
|
||||
const logcatOutput = logcatOutputPath
|
||||
? await logCapture.prepareLogcatOutputAsync(logcatOutputPath)
|
||||
: undefined;
|
||||
const emulatorCommand = `${process.env.ANDROID_HOME}/emulator/emulator`;
|
||||
const emulatorArgs = [
|
||||
'-no-window',
|
||||
@@ -364,10 +375,7 @@ export namespace AndroidEmulatorUtils {
|
||||
'-writable-system',
|
||||
'-noaudio',
|
||||
'-no-snapshot-save',
|
||||
'-logcat',
|
||||
'*:v',
|
||||
'-logcat-output',
|
||||
logcatOutputPath,
|
||||
...(logcatOutput ? ['-logcat', '*:v', '-logcat-output', logcatOutput.path] : []),
|
||||
'-avd',
|
||||
deviceName,
|
||||
'-accel',
|
||||
@@ -381,49 +389,37 @@ export namespace AndroidEmulatorUtils {
|
||||
// We don't need to wait for emulator to exit gracefully.
|
||||
ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1',
|
||||
};
|
||||
const outputStdio = emulatorOutputPath ? 'pipe' : 'ignore';
|
||||
const emulatorPromise = launchGate
|
||||
? spawn(
|
||||
launchGate.wrapperCommand,
|
||||
[...launchGate.wrapperArgs, emulatorCommand, ...emulatorArgs, ...launchGate.emulatorArgs],
|
||||
{
|
||||
detached: true,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
stdio: ['pipe', outputStdio, outputStdio],
|
||||
ignoreStdio: true,
|
||||
env: emulatorEnv,
|
||||
}
|
||||
)
|
||||
: spawn(emulatorCommand, emulatorArgs, {
|
||||
detached: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
stdio: ['ignore', outputStdio, outputStdio],
|
||||
ignoreStdio: true,
|
||||
env: emulatorEnv,
|
||||
});
|
||||
const emulatorOutputStream = fs.createWriteStream(emulatorOutputPath, { flags: 'a' });
|
||||
let reportedEmulatorOutputError = false;
|
||||
emulatorOutputStream.on('error', err => {
|
||||
process.stderr.write(
|
||||
`Failed to write Android emulator output to ${emulatorOutputPath}: ${err}\n`
|
||||
);
|
||||
if (!reportedEmulatorOutputError) {
|
||||
reportedEmulatorOutputError = true;
|
||||
Sentry.capture('Failed to write Android emulator process output', err, {
|
||||
level: 'warning',
|
||||
tags: {
|
||||
errorCode: (err as NodeJS.ErrnoException).code ?? 'unknown',
|
||||
},
|
||||
extras: {
|
||||
deviceName,
|
||||
emulatorOutputPath,
|
||||
},
|
||||
});
|
||||
if (logcatOutput) {
|
||||
emulatorPromise.child.once('close', logcatOutput.close);
|
||||
}
|
||||
if (emulatorOutputPath) {
|
||||
for (const input of [emulatorPromise.child.stdout, emulatorPromise.child.stderr]) {
|
||||
if (input) {
|
||||
logCapture.attach(input, emulatorOutputPath, 'emulator-output');
|
||||
}
|
||||
}
|
||||
});
|
||||
// Only into the log file -- this process' stdout/stderr is not watched or uploaded.
|
||||
emulatorPromise.child.stdout?.pipe(emulatorOutputStream, { end: false });
|
||||
emulatorPromise.child.stderr?.pipe(emulatorOutputStream, { end: false });
|
||||
emulatorPromise.child.once('close', () => {
|
||||
emulatorOutputStream.end();
|
||||
});
|
||||
emulatorPromise.child.once('close', () => {
|
||||
void logCapture.finishFileAsync(emulatorOutputPath);
|
||||
});
|
||||
}
|
||||
// Piped stdio creates socket handles in this process which, unlike inherited
|
||||
// file descriptors, keep the event loop alive for as long as the emulator runs.
|
||||
// We never stop the emulator explicitly, so without unref-ing these the process
|
||||
@@ -473,7 +469,7 @@ export namespace AndroidEmulatorUtils {
|
||||
|
||||
// We don't want to await the SpawnPromise here.
|
||||
// eslint-disable-next-line @typescript-eslint/return-await
|
||||
return { emulatorPromise, serialId, logcatOutputPath, emulatorOutputPath };
|
||||
return { emulatorPromise, serialId };
|
||||
}
|
||||
|
||||
export async function waitForReadyAsync({
|
||||
|
||||
@@ -7,6 +7,7 @@ import { setTimeout } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { retryAsync } from './retry';
|
||||
import { waitForWarmSimulatorCleanupAsync } from './warmSimulatorCleanup';
|
||||
|
||||
export type IosSimulatorUuid = string & z.BRAND<'IosSimulatorUuid'>;
|
||||
export type IosSimulatorName = string & z.BRAND<'IosSimulatorName'>;
|
||||
@@ -119,6 +120,7 @@ export namespace IosSimulatorUtils {
|
||||
destinationDeviceName: IosSimulatorName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<void> {
|
||||
await waitForWarmSimulatorCleanupAsync(env);
|
||||
await spawn('xcrun', ['simctl', 'clone', sourceDeviceIdentifier, destinationDeviceName], {
|
||||
env,
|
||||
});
|
||||
@@ -131,6 +133,7 @@ export namespace IosSimulatorUtils {
|
||||
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<void> {
|
||||
await waitForWarmSimulatorCleanupAsync(env);
|
||||
try {
|
||||
const devices = await getAvailableDevicesAsync({ env, filter: 'available' });
|
||||
const device = devices.find(
|
||||
@@ -231,6 +234,7 @@ export namespace IosSimulatorUtils {
|
||||
env: NodeJS.ProcessEnv;
|
||||
launchdEnvironment?: Record<string, string>;
|
||||
}): Promise<void> {
|
||||
await waitForWarmSimulatorCleanupAsync(env);
|
||||
const bootEnv = { ...env };
|
||||
for (const [name, value] of Object.entries(launchdEnvironment)) {
|
||||
bootEnv[`SIMCTL_CHILD_${name}`] = value;
|
||||
@@ -253,6 +257,7 @@ export namespace IosSimulatorUtils {
|
||||
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<{ udid: IosSimulatorUuid }> {
|
||||
await waitForWarmSimulatorCleanupAsync(env);
|
||||
const bootstatusResult = await spawn(
|
||||
'xcrun',
|
||||
['simctl', 'bootstatus', deviceIdentifier, '-b'],
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
import { AndroidEmulatorLogCapture } from '../androidEmulatorLogCapture';
|
||||
import { ALL_ANDROID_EMULATOR_LOG_SOURCES } from '../androidEmulatorLogSources';
|
||||
|
||||
// We need to use real fs for cloning devices to work.
|
||||
jest.unmock('fs');
|
||||
@@ -78,6 +80,8 @@ describe('AndroidEmulatorUtils', () => {
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
({ serialId } = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogCapture(Infinity),
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
@@ -105,6 +109,8 @@ describe('AndroidEmulatorUtils', () => {
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogCapture(Infinity),
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
@@ -135,6 +141,8 @@ describe('AndroidEmulatorUtils', () => {
|
||||
|
||||
const { serialId: serialIdClone, emulatorPromise: emulatorPromiseClone } =
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogCapture(Infinity),
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
deviceName: cloneDeviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
@@ -172,6 +180,8 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
|
||||
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogCapture(Infinity),
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
@@ -226,6 +236,8 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogCapture(Infinity),
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
deviceName,
|
||||
env: envForAttempt,
|
||||
logcatDirectory,
|
||||
@@ -309,6 +321,8 @@ describe('AndroidEmulatorUtils', () => {
|
||||
logger,
|
||||
});
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogCapture(Infinity),
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory: testLogcatDirectory,
|
||||
@@ -319,7 +333,16 @@ describe('AndroidEmulatorUtils', () => {
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({ serialId, env: process.env });
|
||||
await retryAsync(
|
||||
async () => {
|
||||
const contents = await fs.promises.readFile(startResult.emulatorOutputPath, 'utf-8');
|
||||
const filename = (await fs.promises.readdir(testLogcatDirectory)).find(file =>
|
||||
file.endsWith('-emulator.log')
|
||||
);
|
||||
if (!filename) {
|
||||
throw new Error('Missing emulator output');
|
||||
}
|
||||
const contents = await fs.promises.readFile(
|
||||
path.join(testLogcatDirectory, filename),
|
||||
'utf-8'
|
||||
);
|
||||
if (!contents.includes('Android emulator version')) {
|
||||
throw new Error('Did not find the emulator version in emulator process output yet.');
|
||||
}
|
||||
@@ -340,7 +363,16 @@ describe('AndroidEmulatorUtils', () => {
|
||||
|
||||
await retryAsync(
|
||||
async () => {
|
||||
const contents = await fs.promises.readFile(startResult.logcatOutputPath, 'utf-8');
|
||||
const filename = (await fs.promises.readdir(testLogcatDirectory)).find(file =>
|
||||
file.endsWith('-logcat.log')
|
||||
);
|
||||
if (!filename) {
|
||||
throw new Error('Missing logcat output');
|
||||
}
|
||||
const contents = await fs.promises.readFile(
|
||||
path.join(testLogcatDirectory, filename),
|
||||
'utf-8'
|
||||
);
|
||||
if (!contents.includes(marker)) {
|
||||
throw new Error(`Did not find marker ${marker} in logcat output yet.`);
|
||||
}
|
||||
@@ -384,6 +416,8 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
|
||||
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogCapture(Infinity),
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { EventEmitter, once } from 'node:events';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import { Socket } from 'node:net';
|
||||
import os from 'node:os';
|
||||
@@ -14,6 +14,16 @@ import {
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
import { AndroidEmulatorLogCapture } from '../androidEmulatorLogCapture';
|
||||
import {
|
||||
ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
AndroidEmulatorLogSourcesSchema,
|
||||
} from '../androidEmulatorLogSources';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
@@ -35,9 +45,11 @@ const mockedRetryAsync = jest.mocked(retryAsync);
|
||||
|
||||
describe('AndroidEmulatorUtils', () => {
|
||||
let temporaryDirectories: string[] = [];
|
||||
let homeDirectory: string;
|
||||
|
||||
beforeEach(() => {
|
||||
temporaryDirectories = [];
|
||||
beforeEach(async () => {
|
||||
homeDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'android-utils-home-'));
|
||||
temporaryDirectories = [homeDirectory];
|
||||
jest.mocked(Sentry.capture).mockReset();
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
mockedRetryAsync.mockImplementation(async fn => await fn(0));
|
||||
@@ -55,7 +67,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
describe(AndroidEmulatorUtils.createAsync, () => {
|
||||
it('applies an explicit LCD configuration after screen scaling', async () => {
|
||||
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
|
||||
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
|
||||
const avdDirectory = `${homeDirectory}/.android/avd/${deviceName}.avd`;
|
||||
await fs.promises.mkdir(avdDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
`${avdDirectory}/config.ini`,
|
||||
@@ -83,7 +95,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
lcdHeight: 1600,
|
||||
lcdDensity: 262,
|
||||
env: {
|
||||
HOME: '/home/expo',
|
||||
HOME: homeDirectory,
|
||||
ANDROID_EMULATOR_ADJUST_SCREEN: '1',
|
||||
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
|
||||
},
|
||||
@@ -141,7 +153,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
['device-run-session-id', 4096],
|
||||
])('configures memory for session %s without an LCD override', async (sessionId, ramSize) => {
|
||||
const deviceName = 'generic-emulator' as AndroidVirtualDeviceName;
|
||||
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
|
||||
const avdDirectory = `${homeDirectory}/.android/avd/${deviceName}.avd`;
|
||||
const initialConfig = ['skin.path=pixel_9', 'showDeviceFrame=yes'].join('\n');
|
||||
await fs.promises.mkdir(avdDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(`${avdDirectory}/config.ini`, initialConfig);
|
||||
@@ -157,7 +169,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: {
|
||||
HOME: '/home/expo',
|
||||
HOME: homeDirectory,
|
||||
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
|
||||
DEVICE_RUN_SESSION_ID: sessionId,
|
||||
},
|
||||
@@ -172,6 +184,24 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.startAsync, () => {
|
||||
let buildCapture: AndroidEmulatorLogCapture;
|
||||
beforeEach(() => {
|
||||
buildCapture = new AndroidEmulatorLogCapture(Infinity);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await buildCapture.finishAsync();
|
||||
});
|
||||
|
||||
async function logFile(directory: string, source: 'logcat' | 'emulator'): Promise<string> {
|
||||
const filename = (await fs.promises.readdir(directory)).find(file =>
|
||||
file.endsWith(`-${source}.log`)
|
||||
);
|
||||
if (!filename) {
|
||||
throw new Error(`Missing ${source} log in ${directory}`);
|
||||
}
|
||||
return path.join(directory, filename);
|
||||
}
|
||||
|
||||
function mockSuccessfulStart(deviceName: AndroidVirtualDeviceName) {
|
||||
// Under `stdio: 'pipe'` these are net.Sockets, which is what startAsync
|
||||
// narrows on before unref-ing them.
|
||||
@@ -202,35 +232,30 @@ describe('AndroidEmulatorUtils', () => {
|
||||
return { child };
|
||||
}
|
||||
|
||||
it('captures logcat and emulator process output', async () => {
|
||||
it('names log files after the device and detaches the emulator', async () => {
|
||||
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'logcat-staging-'));
|
||||
temporaryDirectories.push(outputDir);
|
||||
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
|
||||
const { child } = mockSuccessfulStart(deviceName);
|
||||
const createWriteStreamSpy = jest.spyOn(fs, 'createWriteStream');
|
||||
|
||||
const result = await AndroidEmulatorUtils.startAsync({
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
logCapture: buildCapture,
|
||||
deviceName,
|
||||
env: process.env,
|
||||
logcatDirectory: outputDir,
|
||||
});
|
||||
|
||||
expect(result.logcatOutputPath).toMatch(
|
||||
expect(await logFile(outputDir, 'logcat')).toMatch(
|
||||
new RegExp(`^${outputDir}/eas-simulator-[a-f0-9]{8}-[a-f0-9]{4}-logcat\\.log$`)
|
||||
);
|
||||
expect(result.emulatorOutputPath).toMatch(
|
||||
expect(await logFile(outputDir, 'emulator')).toMatch(
|
||||
new RegExp(`^${outputDir}/eas-simulator-[a-f0-9]{8}-[a-f0-9]{4}-emulator\\.log$`)
|
||||
);
|
||||
await expect(fs.promises.access(result.logcatOutputPath)).resolves.toBeUndefined();
|
||||
await expect(fs.promises.access(result.emulatorOutputPath)).resolves.toBeUndefined();
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/emulator\/emulator$/),
|
||||
expect.arrayContaining(['-logcat', '*:v', '-logcat-output', result.logcatOutputPath]),
|
||||
expect.objectContaining({
|
||||
detached: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
ignoreStdio: true,
|
||||
})
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ detached: true, ignoreStdio: true })
|
||||
);
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith(
|
||||
'adb',
|
||||
@@ -242,28 +267,85 @@ describe('AndroidEmulatorUtils', () => {
|
||||
// for as long as the emulator runs.
|
||||
expect(child.stdout.unref).toHaveBeenCalled();
|
||||
expect(child.stderr.unref).toHaveBeenCalled();
|
||||
const emulatorOutputStream = createWriteStreamSpy.mock.results[0].value;
|
||||
const stderrWriteSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
const writeError = Object.assign(new Error('disk full'), { code: 'ENOSPC' });
|
||||
emulatorOutputStream.emit('error', writeError);
|
||||
emulatorOutputStream.emit('error', writeError);
|
||||
expect(stderrWriteSpy).toHaveBeenCalledTimes(2);
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to write Android emulator process output',
|
||||
writeError,
|
||||
{
|
||||
level: 'warning',
|
||||
tags: { errorCode: 'ENOSPC' },
|
||||
extras: {
|
||||
deviceName,
|
||||
emulatorOutputPath: result.emulatorOutputPath,
|
||||
},
|
||||
}
|
||||
);
|
||||
const emulatorOutputStreamClosed = once(emulatorOutputStream, 'close');
|
||||
child.emit('close');
|
||||
await emulatorOutputStreamClosed;
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ sources: [] },
|
||||
{ sources: ['emulator-output'] },
|
||||
{ sources: ['device-logcat'] },
|
||||
{ sources: ['emulator-output', 'device-logcat'] },
|
||||
])('records only $sources', async ({ sources }) => {
|
||||
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'selected-logs-'));
|
||||
temporaryDirectories.push(outputDir);
|
||||
const deviceName = 'selected-emulator' as AndroidVirtualDeviceName;
|
||||
const { child } = mockSuccessfulStart(deviceName);
|
||||
const stdoutPipe = jest.spyOn(child.stdout, 'pipe');
|
||||
const stderrPipe = jest.spyOn(child.stderr, 'pipe');
|
||||
const prepare = jest.spyOn(buildCapture, 'prepareLogcatOutputAsync');
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: buildCapture,
|
||||
deviceName,
|
||||
env: {},
|
||||
logcatDirectory: outputDir,
|
||||
logSources: AndroidEmulatorLogSourcesSchema.parse(sources),
|
||||
});
|
||||
const capturesOutput = sources.includes('emulator-output');
|
||||
const capturesLogcat = sources.includes('device-logcat');
|
||||
const [, args, options] = mockedSpawn.mock.calls[0];
|
||||
expect(prepare).toHaveBeenCalledTimes(capturesLogcat ? 1 : 0);
|
||||
expect(args.includes('-logcat')).toBe(capturesLogcat);
|
||||
expect(args.includes('-logcat-output')).toBe(capturesLogcat);
|
||||
expect(options?.stdio).toEqual([
|
||||
'ignore',
|
||||
capturesOutput ? 'pipe' : 'ignore',
|
||||
capturesOutput ? 'pipe' : 'ignore',
|
||||
]);
|
||||
expect(stdoutPipe).toHaveBeenCalledTimes(capturesOutput ? 1 : 0);
|
||||
expect(stderrPipe).toHaveBeenCalledTimes(capturesOutput ? 1 : 0);
|
||||
if (capturesOutput) {
|
||||
child.stdout.emit('data', Buffer.from('stdout marker\n'));
|
||||
child.stderr.emit('data', Buffer.from('stderr marker\n'));
|
||||
child.emit('close');
|
||||
await buildCapture.finishAsync();
|
||||
await expect(
|
||||
fs.promises.readFile(await logFile(outputDir, 'emulator'), 'utf8')
|
||||
).resolves.toBe('stdout marker\nstderr marker\n');
|
||||
}
|
||||
const files = await fs.promises.readdir(outputDir);
|
||||
expect(files).toHaveLength(sources.length);
|
||||
expect(files.some(file => file.endsWith('-logcat.log'))).toBe(capturesLogcat);
|
||||
expect(files.some(file => file.endsWith('-emulator.log'))).toBe(capturesOutput);
|
||||
});
|
||||
|
||||
it('routes a session logcat through the capture pipe and closes it with the emulator', async () => {
|
||||
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'bounded-logs-'));
|
||||
temporaryDirectories.push(outputDir);
|
||||
const deviceName = 'bounded-emulator' as AndroidVirtualDeviceName;
|
||||
const { child } = mockSuccessfulStart(deviceName);
|
||||
const capture = new AndroidEmulatorLogCapture();
|
||||
const close = jest.fn();
|
||||
const createPipe = jest.spyOn(capture, 'prepareLogcatOutputAsync').mockResolvedValue({
|
||||
path: '/logcat.pipe',
|
||||
close,
|
||||
closed: Promise.resolve(),
|
||||
});
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env: {},
|
||||
logcatDirectory: outputDir,
|
||||
logSources: ['device-logcat'],
|
||||
logCapture: capture,
|
||||
});
|
||||
expect(createPipe).toHaveBeenCalledWith(await logFile(outputDir, 'logcat'));
|
||||
expect(mockedSpawn.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining(['-logcat-output', '/logcat.pipe'])
|
||||
);
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
child.emit('close');
|
||||
await capture.finishAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(await fs.promises.readdir(outputDir)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('spawns the emulator directly with the same arguments when there is no launch gate', async () => {
|
||||
@@ -273,7 +355,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
mockSuccessfulStart(deviceName);
|
||||
const env = { ANDROID_EMULATOR_EXTRA_ARGS: '-gpu swiftshader_indirect' };
|
||||
|
||||
const result = await AndroidEmulatorUtils.startAsync({
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
logCapture: buildCapture,
|
||||
deviceName,
|
||||
env,
|
||||
logcatDirectory: outputDir,
|
||||
@@ -294,7 +378,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
'-logcat',
|
||||
'*:v',
|
||||
'-logcat-output',
|
||||
result.logcatOutputPath,
|
||||
await logFile(outputDir, 'logcat'),
|
||||
'-avd',
|
||||
deviceName,
|
||||
'-accel',
|
||||
@@ -331,7 +415,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
: gatedSpawn(command, args, options as any)) as any);
|
||||
const admitAsync = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const result = await AndroidEmulatorUtils.startAsync({
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
logCapture: buildCapture,
|
||||
deviceName,
|
||||
env: {},
|
||||
logcatDirectory: outputDir,
|
||||
@@ -358,7 +444,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
'-logcat',
|
||||
'*:v',
|
||||
'-logcat-output',
|
||||
result.logcatOutputPath,
|
||||
await logFile(outputDir, 'logcat'),
|
||||
'-avd',
|
||||
deviceName,
|
||||
'-accel',
|
||||
@@ -395,6 +481,8 @@ describe('AndroidEmulatorUtils', () => {
|
||||
|
||||
await expect(
|
||||
AndroidEmulatorUtils.startAsync({
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
logCapture: buildCapture,
|
||||
deviceName,
|
||||
env: {},
|
||||
logcatDirectory: outputDir,
|
||||
@@ -419,6 +507,8 @@ describe('AndroidEmulatorUtils', () => {
|
||||
try {
|
||||
await expect(
|
||||
AndroidEmulatorUtils.startAsync({
|
||||
logSources: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
logCapture: buildCapture,
|
||||
deviceName,
|
||||
env: process.env,
|
||||
logcatDirectory: outputDir,
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from 'node:path';
|
||||
|
||||
import { IosSimulatorUtils } from '../IosSimulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
import { waitForWarmSimulatorCleanupAsync } from '../warmSimulatorCleanup';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
@@ -14,6 +15,7 @@ jest.mock('@expo/turtle-spawn', () => ({
|
||||
jest.mock('../retry', () => ({
|
||||
retryAsync: jest.fn(async fn => await fn(0)),
|
||||
}));
|
||||
jest.mock('../warmSimulatorCleanup');
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
@@ -21,6 +23,7 @@ describe('IosSimulatorUtils', () => {
|
||||
beforeEach(() => {
|
||||
mockedSpawn.mockReset();
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
jest.mocked(waitForWarmSimulatorCleanupAsync).mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.getAvailableRuntimesAsync, () => {
|
||||
@@ -151,6 +154,30 @@ describe('IosSimulatorUtils', () => {
|
||||
}) as any);
|
||||
}
|
||||
|
||||
it('waits for warm cleanup before checking device state and writing accessibility prefs', async () => {
|
||||
let finishCleanup: (() => void) | undefined;
|
||||
jest.mocked(waitForWarmSimulatorCleanupAsync).mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise<void>(resolve => {
|
||||
finishCleanup = resolve;
|
||||
})
|
||||
);
|
||||
mockAvailableDevices([device()]);
|
||||
|
||||
const setup = IosSimulatorUtils.enableAccessibilitySettingsAsync({
|
||||
deviceIdentifier: device().udid as any,
|
||||
env: {},
|
||||
});
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
expect(finishCleanup).toBeDefined();
|
||||
finishCleanup?.();
|
||||
await setup;
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('plutil', expect.anything(), {
|
||||
env: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('writes accessibility prefs for an exact shutdown simulator match', async () => {
|
||||
mockAvailableDevices([
|
||||
device({
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import fs, { type FileHandle } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { PassThrough, Readable } from 'node:stream';
|
||||
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
AndroidEmulatorLogCapture,
|
||||
MAX_ANDROID_LOG_BYTES_PER_SOURCE,
|
||||
} from '../androidEmulatorLogCapture';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
jest.mock('../../sentry', () => ({ Sentry: { capture: jest.fn() } }));
|
||||
|
||||
let directory: string;
|
||||
beforeEach(async () => {
|
||||
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'android-capped-logs-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
jest.restoreAllMocks();
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function record(
|
||||
capture: AndroidEmulatorLogCapture,
|
||||
file: string,
|
||||
text: string
|
||||
): Promise<void> {
|
||||
const input = Readable.from([Buffer.from(text)]);
|
||||
capture.attach(input, file, 'emulator-output');
|
||||
await once(input, 'end');
|
||||
await capture.finishFileAsync(file);
|
||||
}
|
||||
|
||||
async function waitForSize(file: string, size: number): Promise<void> {
|
||||
const deadline = Date.now() + 2_000;
|
||||
while ((await fs.stat(file)).size !== size && Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
expect((await fs.stat(file)).size).toBe(size);
|
||||
}
|
||||
|
||||
function trackOpenedFiles(): FileHandle[] {
|
||||
const opened: FileHandle[] = [];
|
||||
const open = fs.open;
|
||||
jest.spyOn(fs, 'open').mockImplementation(async (file, flags, mode) => {
|
||||
const handle = await open(file, flags, mode);
|
||||
opened.push(handle);
|
||||
return handle;
|
||||
});
|
||||
return opened;
|
||||
}
|
||||
|
||||
it('keeps the newest bytes of a source across launches, with independent budgets for the two sources', async () => {
|
||||
const capture = new AndroidEmulatorLogCapture(10);
|
||||
const first = path.join(directory, 'first.log');
|
||||
const retry = path.join(directory, 'retry.log');
|
||||
const device = path.join(directory, 'logcat.log');
|
||||
await record(capture, first, 'first!');
|
||||
await record(capture, retry, 'second!!');
|
||||
const logcat = Readable.from([Buffer.from('device logs are separate')]);
|
||||
capture.attach(logcat, device, 'device-logcat');
|
||||
await once(logcat, 'end');
|
||||
// A partially evicted file keeps its size on disk until it is sealed.
|
||||
expect((await fs.stat(first)).size).toBe(6);
|
||||
const logs = await capture.finishAsync();
|
||||
expect(logs).toEqual([
|
||||
{ filePath: first, source: 'emulator-output', bytesSeen: 6 },
|
||||
{ filePath: retry, source: 'emulator-output', bytesSeen: 8 },
|
||||
{ filePath: device, source: 'device-logcat', bytesSeen: 24 },
|
||||
]);
|
||||
// The retry evicts the head of the earlier launch; its own tail stays whole.
|
||||
expect(await fs.readFile(first, 'utf8')).toBe('t!');
|
||||
expect(await fs.readFile(retry, 'utf8')).toBe('second!!');
|
||||
expect(await fs.readFile(device, 'utf8')).toBe('e separate');
|
||||
});
|
||||
|
||||
it('frees a finished file on disk once a later launch evicts all of it', async () => {
|
||||
const capture = new AndroidEmulatorLogCapture(10);
|
||||
const first = path.join(directory, 'first.log');
|
||||
const retry = path.join(directory, 'retry.log');
|
||||
await record(capture, first, 'first!');
|
||||
const input = new PassThrough();
|
||||
capture.attach(input, retry, 'emulator-output');
|
||||
input.write('second log is too long');
|
||||
await waitForSize(first, 0);
|
||||
await capture.finishAsync();
|
||||
expect(await fs.readFile(first, 'utf8')).toBe('');
|
||||
expect(await fs.readFile(retry, 'utf8')).toBe('s too long');
|
||||
});
|
||||
|
||||
it('frees a fully evicted live capture on disk and records its later output', async () => {
|
||||
const capture = new AndroidEmulatorLogCapture(10);
|
||||
const first = path.join(directory, 'first.log');
|
||||
const second = path.join(directory, 'second.log');
|
||||
const a = new PassThrough();
|
||||
const b = new PassThrough();
|
||||
capture.attach(a, first, 'emulator-output');
|
||||
capture.attach(b, second, 'emulator-output');
|
||||
// Wait between writes: two streams deliver their first chunks in either order.
|
||||
a.write('0123456789');
|
||||
await waitForSize(first, 10);
|
||||
b.write('abcdefghij');
|
||||
await waitForSize(first, 0);
|
||||
await waitForSize(second, 10);
|
||||
a.write('xy');
|
||||
await capture.finishAsync();
|
||||
expect(await fs.readFile(first, 'utf8')).toBe('xy');
|
||||
expect(await fs.readFile(second, 'utf8')).toBe('cdefghij');
|
||||
});
|
||||
|
||||
it('closes an uncapped file when its stream finishes', async () => {
|
||||
const opened = trackOpenedFiles();
|
||||
const capture = new AndroidEmulatorLogCapture(Infinity);
|
||||
const file = path.join(directory, 'build.log');
|
||||
await record(capture, file, 'ordinary build output');
|
||||
expect(opened).toHaveLength(1);
|
||||
expect(opened[0].fd).toBe(-1);
|
||||
expect(await fs.readFile(file, 'utf8')).toBe('ordinary build output');
|
||||
expect(await capture.finishAsync()).toEqual([
|
||||
{ filePath: file, source: 'emulator-output', bytesSeen: 21 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('closes the file and reports once when sealing fails', async () => {
|
||||
const opened = trackOpenedFiles();
|
||||
const capture = new AndroidEmulatorLogCapture(10);
|
||||
const file = path.join(directory, 'wrapped.log');
|
||||
await record(capture, file, '0123456789abcdef');
|
||||
const diskError = Object.assign(new Error('full'), { code: 'ENOSPC' });
|
||||
jest.spyOn(opened[0], 'truncate').mockRejectedValueOnce(diskError);
|
||||
await capture.finishAsync();
|
||||
expect(opened[0].fd).toBe(-1);
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to write Android diagnostic log',
|
||||
diskError,
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the tail of a wrapped ring in order', async () => {
|
||||
const capture = new AndroidEmulatorLogCapture(10);
|
||||
const file = path.join(directory, 'wrapped.log');
|
||||
const input = new PassThrough();
|
||||
capture.attach(input, file, 'emulator-output');
|
||||
for (const piece of ['0123', '4567', '89ab', 'cdef']) {
|
||||
input.write(piece);
|
||||
}
|
||||
await capture.finishAsync();
|
||||
expect(await fs.readFile(file, 'utf8')).toBe('6789abcdef');
|
||||
});
|
||||
|
||||
it('reserves bytes across simultaneous captures and drains after finalization', async () => {
|
||||
const capture = new AndroidEmulatorLogCapture(10);
|
||||
const a = new PassThrough();
|
||||
const b = new PassThrough();
|
||||
const first = path.join(directory, 'first.log');
|
||||
const second = path.join(directory, 'second.log');
|
||||
capture.attach(a, first, 'emulator-output');
|
||||
capture.attach(b, second, 'emulator-output');
|
||||
a.write('12345678');
|
||||
await waitForSize(first, 8);
|
||||
b.write('abcdefgh');
|
||||
await capture.finishAsync();
|
||||
// The least recently written capture gives up its bytes first.
|
||||
expect(await fs.readFile(first, 'utf8')).toBe('78');
|
||||
expect(await fs.readFile(second, 'utf8')).toBe('abcdefgh');
|
||||
a.end('ignored after cleanup');
|
||||
b.end('also ignored');
|
||||
expect(a.readableFlowing).toBe(true);
|
||||
expect(b.readableFlowing).toBe(true);
|
||||
});
|
||||
|
||||
it('drains a real FIFO beyond 20 MiB, retains the tail, and closes after the writer exits', async () => {
|
||||
const file = path.join(directory, 'logcat.log');
|
||||
const capture = new AndroidEmulatorLogCapture();
|
||||
const pipe = await capture.prepareLogcatOutputAsync(file);
|
||||
expect((await fs.stat(pipe.path)).isFIFO()).toBe(true);
|
||||
const writer = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
`
|
||||
const fs = require('node:fs');
|
||||
const fd = fs.openSync(process.argv[1], 'w');
|
||||
fs.writeSync(fd, 'BEGIN\\n');
|
||||
const chunk = Buffer.alloc(64 * 1024, 'x');
|
||||
for (let i = 0; i < 1024; i++) {
|
||||
let offset = 0;
|
||||
while (offset < chunk.length) offset += fs.writeSync(fd, chunk, offset);
|
||||
}
|
||||
fs.writeSync(fd, '\\nEND');
|
||||
fs.closeSync(fd);
|
||||
`,
|
||||
pipe.path,
|
||||
],
|
||||
{ stdio: 'ignore' }
|
||||
);
|
||||
try {
|
||||
expect(await once(writer, 'close')).toEqual([0, null]);
|
||||
pipe.close();
|
||||
pipe.close();
|
||||
await pipe.closed;
|
||||
const logs = await capture.finishAsync();
|
||||
const result = await fs.readFile(file);
|
||||
expect(result.length).toBe(MAX_ANDROID_LOG_BYTES_PER_SOURCE);
|
||||
expect(result.subarray(0, 4).toString()).toBe('xxxx');
|
||||
expect(result.subarray(-4).toString()).toBe('\nEND');
|
||||
expect(logs).toEqual([
|
||||
{ filePath: file, source: 'device-logcat', bytesSeen: 64 * 1024 * 1024 + 10 },
|
||||
]);
|
||||
await expect(fs.stat(pipe.path)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
} finally {
|
||||
writer.kill();
|
||||
pipe.close();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it('uses the original logcat path for ordinary builds without creating a FIFO', async () => {
|
||||
const file = path.join(directory, 'build-logcat.log');
|
||||
const capture = new AndroidEmulatorLogCapture(Infinity);
|
||||
const output = await capture.prepareLogcatOutputAsync(file);
|
||||
expect(output.path).toBe(file);
|
||||
expect(await fs.readdir(directory)).toEqual([]);
|
||||
output.close();
|
||||
await output.closed;
|
||||
expect(await capture.finishAsync()).toEqual([]);
|
||||
});
|
||||
|
||||
it('closes an unused logcat pipe when the emulator fails to launch', async () => {
|
||||
const file = path.join(directory, 'empty.log');
|
||||
const capture = new AndroidEmulatorLogCapture();
|
||||
const pipe = await capture.prepareLogcatOutputAsync(file);
|
||||
pipe.close();
|
||||
await pipe.closed;
|
||||
await expect(fs.stat(pipe.path)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
expect(await capture.finishAsync()).toEqual([
|
||||
{ filePath: file, source: 'device-logcat', bytesSeen: 0 },
|
||||
]);
|
||||
expect((await fs.stat(file)).size).toBe(0);
|
||||
}, 5_000);
|
||||
|
||||
it('keeps draining the FIFO after capture finishes while the emulator is alive', async () => {
|
||||
const file = path.join(directory, 'live-logcat.log');
|
||||
const capture = new AndroidEmulatorLogCapture(10);
|
||||
const output = await capture.prepareLogcatOutputAsync(file);
|
||||
const writer = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
`
|
||||
const fs = require('node:fs');
|
||||
const fd = fs.openSync(process.argv[1], 'w');
|
||||
process.stdout.write('ready');
|
||||
process.stdin.once('data', () => {
|
||||
const chunk = Buffer.alloc(64 * 1024, 'x');
|
||||
for (let i = 0; i < 128; i++) {
|
||||
let offset = 0;
|
||||
while (offset < chunk.length) offset += fs.writeSync(fd, chunk, offset);
|
||||
}
|
||||
fs.closeSync(fd);
|
||||
process.stdin.destroy();
|
||||
});
|
||||
`,
|
||||
output.path,
|
||||
],
|
||||
{ stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
try {
|
||||
await once(writer.stdout, 'data');
|
||||
await capture.finishAsync();
|
||||
writer.stdin.end('go');
|
||||
expect(await once(writer, 'close')).toEqual([0, null]);
|
||||
output.close();
|
||||
await output.closed;
|
||||
expect((await fs.stat(file)).size).toBe(0);
|
||||
await expect(fs.stat(output.path)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
} finally {
|
||||
writer.kill();
|
||||
output.close();
|
||||
await output.closed;
|
||||
}
|
||||
}, 10_000);
|
||||
|
||||
it('reports a disk write failure once and continues draining output', async () => {
|
||||
const capture = new AndroidEmulatorLogCapture(10);
|
||||
const input = new PassThrough();
|
||||
const file = path.join(directory, 'failed.log');
|
||||
const diskError = Object.assign(new Error('full'), { code: 'ENOSPC' });
|
||||
jest.spyOn(fs, 'open').mockRejectedValueOnce(diskError);
|
||||
capture.attach(input, file, 'emulator-output');
|
||||
input.write('first');
|
||||
input.write('second');
|
||||
await capture.finishAsync();
|
||||
input.end('more');
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to write Android diagnostic log',
|
||||
diskError,
|
||||
expect.anything()
|
||||
);
|
||||
expect(input.readableFlowing).toBe(true);
|
||||
});
|
||||
@@ -76,11 +76,7 @@ describe(getSshRelayServerUrl, () => {
|
||||
});
|
||||
|
||||
describe(startSshSessionAsync, () => {
|
||||
const config1: SshConnectionConfig = {
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'secret-1',
|
||||
};
|
||||
const config1: SshConnectionConfig = { type: 'upterm-v1', secret: 'secret-1' };
|
||||
const config2: SshConnectionConfig = { ...config1, secret: 'secret-2' };
|
||||
const target = { type: 'JOB_RUN', id: 'jr-1' } as const;
|
||||
|
||||
@@ -133,7 +129,7 @@ describe(startSshSessionAsync, () => {
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
target: { type: 'JOB_RUN', id: 'jr-1' },
|
||||
connectionConfig: { ...config1, reconnecting: false, type: 'UPTERM_V1' },
|
||||
connectionConfig: { type: 'UPTERM_V1', secret: 'secret-1', reconnecting: false },
|
||||
sessionSettings: { idleTimeoutSeconds: 900 },
|
||||
});
|
||||
expect(idleTimeoutSeconds).toBe(900);
|
||||
@@ -155,7 +151,7 @@ describe(startSshSessionAsync, () => {
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
target: { type: 'BUILD', id: 'b-1' },
|
||||
connectionConfig: { ...config1, reconnecting: false, type: 'UPTERM_V1' },
|
||||
connectionConfig: { type: 'UPTERM_V1', secret: 'secret-1', reconnecting: false },
|
||||
sessionSettings: { idleTimeoutSeconds: 900 },
|
||||
});
|
||||
});
|
||||
@@ -242,7 +238,7 @@ describe(startSshSessionAsync, () => {
|
||||
);
|
||||
expect(mutation).toHaveBeenLastCalledWith(expect.anything(), {
|
||||
target: { type: 'JOB_RUN', id: 'jr-1' },
|
||||
connectionConfig: { ...config2, reconnecting: false, type: 'UPTERM_V1' },
|
||||
connectionConfig: { type: 'UPTERM_V1', secret: 'secret-2', reconnecting: false },
|
||||
sessionSettings: { idleTimeoutSeconds: 300 },
|
||||
});
|
||||
});
|
||||
@@ -298,7 +294,7 @@ describe(startSshSessionAsync, () => {
|
||||
expect(host.redialAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mutation).toHaveBeenLastCalledWith(expect.anything(), {
|
||||
target: { type: 'JOB_RUN', id: 'jr-1' },
|
||||
connectionConfig: { ...config2, reconnecting: false, type: 'UPTERM_V1' },
|
||||
connectionConfig: { type: 'UPTERM_V1', secret: 'secret-2', reconnecting: false },
|
||||
sessionSettings: { idleTimeoutSeconds: 300 },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { sleepAsync } from '../retry';
|
||||
import {
|
||||
connectionConfigFromUptermSession,
|
||||
redactConnectionSecrets,
|
||||
redactSpawnErrorForLog,
|
||||
resolveUptermGcsObjectName,
|
||||
@@ -42,49 +41,6 @@ describe(resolveUptermGcsObjectName, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(connectionConfigFromUptermSession, () => {
|
||||
it('parses sessionId and host from session current fields', () => {
|
||||
expect(
|
||||
connectionConfigFromUptermSession({
|
||||
sessionId: 'TOKENabc123',
|
||||
host: 'ssh://relay.expo.dev:22',
|
||||
})
|
||||
).toEqual({
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'TOKENabc123',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a bare hostname', () => {
|
||||
expect(connectionConfigFromUptermSession({ sessionId: 'tok', host: 'relay.expo.dev' })).toEqual(
|
||||
{
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'tok',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a non-default port so the CLI can pass it to ssh -p', () => {
|
||||
expect(
|
||||
connectionConfigFromUptermSession({
|
||||
sessionId: 'tok',
|
||||
host: 'ssh://relay.expo.dev:2222',
|
||||
})
|
||||
).toEqual({
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev:2222',
|
||||
secret: 'tok',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when the host is not a parseable url', () => {
|
||||
expect(connectionConfigFromUptermSession({ sessionId: 'tok', host: 'ssh://' })).toBeNull();
|
||||
expect(connectionConfigFromUptermSession({ sessionId: 'tok', host: 'ssh://[' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(redactConnectionSecrets, () => {
|
||||
it('strips the userinfo secret from a connection string', () => {
|
||||
expect(
|
||||
@@ -257,11 +213,7 @@ describe(startUptermHostAsync, () => {
|
||||
relayServerUrl: 'wss://relay.expo.dev',
|
||||
});
|
||||
|
||||
expect(host.connectionConfig).toEqual({
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'TOKENx',
|
||||
});
|
||||
expect(host.connectionConfig).toEqual({ type: 'upterm-v1', secret: 'TOKENx' });
|
||||
expect(host.isAlive()).toBe(true);
|
||||
expect(mockedDownloadFile).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('upterm', ['version'], {
|
||||
@@ -369,6 +321,21 @@ describe(startUptermHostAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps polling until upterm reports a session id', async () => {
|
||||
sessionStdout = JSON.stringify({ host: 'ssh://relay.expo.dev:22', clientCount: 0 });
|
||||
mockedSleep.mockImplementationOnce(async () => {
|
||||
sessionStdout = JSON.stringify({
|
||||
sessionId: 'TOKENy',
|
||||
host: 'ssh://relay.expo.dev:22',
|
||||
clientCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
|
||||
expect(host.connectionConfig).toEqual({ type: 'upterm-v1', secret: 'TOKENy' });
|
||||
});
|
||||
|
||||
it('tears down and rethrows when the host never registers with the relay', async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(0);
|
||||
@@ -420,11 +387,7 @@ describe(startUptermHostAsync, () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
const config = await host.redialAsync();
|
||||
|
||||
expect(config).toEqual({
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'TOKENx',
|
||||
});
|
||||
expect(config).toEqual({ type: 'upterm-v1', secret: 'TOKENx' });
|
||||
expect(host.connectionConfig).toEqual(config);
|
||||
});
|
||||
|
||||
@@ -462,11 +425,7 @@ describe(startUptermHostAsync, () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
hostProcess.catch = () => new Promise<void>(() => {});
|
||||
|
||||
await expect(host.redialAsync()).resolves.toEqual({
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'TOKENx',
|
||||
});
|
||||
await expect(host.redialAsync()).resolves.toEqual({ type: 'upterm-v1', secret: 'TOKENx' });
|
||||
expect(mockedSleep).toHaveBeenCalledWith(5_000);
|
||||
});
|
||||
|
||||
|
||||
Loaded 100 of 221 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user