mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e34c0cb3f6 | ||
|
|
c7f766568c | ||
|
|
a328e59ee4 | ||
|
|
f843a97165 | ||
|
|
1e112fee24 | ||
|
|
6d3e79c255 | ||
|
|
04e909c4d9 | ||
|
|
1a6e94123a | ||
|
|
544cd18bac | ||
|
|
8dc514e9f2 | ||
|
|
f4b4f035b0 | ||
|
|
92397fd3d4 | ||
|
|
154d90d3ce | ||
|
|
5a0908533b | ||
|
|
14e9f1af44 | ||
|
|
291ec6f2c8 | ||
|
|
a4edee8178 | ||
|
|
6fa3f47f91 | ||
|
|
71338cbaa2 | ||
|
|
36f83dee90 | ||
|
|
352c3b68f0 | ||
|
|
5dafefd484 | ||
|
|
b7ff9b10d8 | ||
|
|
55d1e2942d | ||
|
|
a7f9742a03 | ||
|
|
f615fc5ba3 | ||
|
|
00ef775b2b | ||
|
|
e914819052 | ||
|
|
ab32f0c241 | ||
|
|
951ccba62c | ||
|
|
8c0e01d64c | ||
|
|
539acb8c23 | ||
|
|
18d85d571a | ||
|
|
f2cf409b21 | ||
|
|
0cf8cd014f | ||
|
|
8df11a04ee | ||
|
|
c9d198a894 | ||
|
|
4b70eb14fb | ||
|
|
c224ba58ff | ||
|
|
8d5a2e3332 | ||
|
|
b95b7c930a | ||
|
|
8ed5c16c7b | ||
|
|
fa5dee437f | ||
|
|
65ef172a78 | ||
|
|
b15ea359b6 | ||
|
|
ab86b647cf | ||
|
|
f6669461cd | ||
|
|
5f560f8fc2 | ||
|
|
a3e4617d1c | ||
|
|
2e45595250 | ||
|
|
0c768729a9 | ||
|
|
f37ad5e137 | ||
|
|
4d3cba95af | ||
|
|
d57eadf887 | ||
|
|
ccba9a0c8d | ||
|
|
3fc8dadd63 | ||
|
|
50dcb89a0c | ||
|
|
84b3bc6354 | ||
|
|
2749518a31 |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Count Android web preview input as session activity, so `max_idle_time_minutes` works on Android sessions. `eas/start_serve_sim_remote_session` no longer rejects it on Android; the API does not accept an idle timeout for Android web-preview-only sessions yet.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Mark the default device for each platform in `eas simulator:devices` and include `isDefault` in JSON output.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
A successful remote device run session no longer fails when its agent-device, Appium, or Argent tunnel does not stop within its time-out.
|
||||
@@ -28,7 +28,9 @@ jobs:
|
||||
contains(github.event.pull_request.labels.*.name, 'no changelog') ||
|
||||
github.event.pull_request.user.login == 'dependabot[bot]'
|
||||
}}
|
||||
BASE_REF: ${{ github.event.pull_request.base.sha || 'origin/main' }}
|
||||
# The base branch, not `pull_request.base.sha`: that SHA can be older than the base that the
|
||||
# checked-out merge commit contains, and the diff then includes other people's changes.
|
||||
BASE_REF: origin/${{ github.base_ref || 'main' }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
|
||||
@@ -2,6 +2,100 @@
|
||||
|
||||
This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
## [27.10.0](https://github.com/expo/eas-cli/releases/tag/v27.10.0) - 2026-11-13
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Add the experimental `experimental_emulator_capture_version` input to `eas/start_android_emulator`. The Android Emulator then preloads that version of the `expo-emulator-capture` agent and uses the lavapipe renderer, and later steps start expo-device-hub with the emulator capture on. When the agent cannot install, the emulator starts as before. Only web preview sessions support the capture for now, so other session types fail with an error when the input is set. ([#4827](https://github.com/expo/eas-cli/pull/4827) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
|
||||
- [eas-cli] Add `eas simulator:devices` to list the devices Expo suggests for a simulator session. ([#4879](https://github.com/expo/eas-cli/pull/4879) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Retry the first ngrok tunnel open of a device run session up to two times when ngrok rejects it with ERR_NGROK_802. The first open now waits 19 seconds instead of 15, so a tunnel that opens late is kept instead of closed. Startup can be cancelled during the retries, and a preview tunnel stops retrying when its session host finishes. ([#4874](https://github.com/expo/eas-cli/pull/4874) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Include the parent CLI command in telemetry to distinguish nested commands from direct usage. ([#4876](https://github.com/expo/eas-cli/pull/4876) by [@davidmokos](https://github.com/davidmokos))
|
||||
- [build-tools] Run worker SSH sessions as detached upterm sessions that reconnect on their own under the same session ID, and stop them with `upterm session stop`. ([#4857](https://github.com/expo/eas-cli/pull/4857) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [build-tools] Run the command a client passes with `eas workflow:ssh <id> <command>` in worker SSH sessions, in the build directory. ([#4863](https://github.com/expo/eas-cli/pull/4863) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [build-tools] Always download upterm for worker SSH sessions instead of using an `upterm` found on `PATH`. ([#4859](https://github.com/expo/eas-cli/pull/4859) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli][build-tools] Bump Node.js to 22.23.5 to fix the CVEs in the [November 2026 Node.js security releases](https://nodejs.org/en/blog/vulnerability/november-2026-security-releases). ([#4889](https://github.com/expo/eas-cli/pull/4889) by [@ramonclaudio](https://github.com/ramonclaudio))
|
||||
|
||||
## [27.9.0](https://github.com/expo/eas-cli/releases/tag/v27.9.0) - 2026-11-12
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Warn about Expo Simulators and EAS Workflows outages from status.expo.dev before `eas simulator:start` creates a session, and describe degraded performance and maintenance correctly in all outage warnings. ([#4873](https://github.com/expo/eas-cli/pull/4873) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [27.8.0](https://github.com/expo/eas-cli/releases/tag/v27.8.0) - 2026-11-12
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Tell accounts without EAS Simulator access that it is available on the Starter, Production, and Enterprise plans and link to the billing page, instead of pointing at the waitlist. `simulator:availability --json` now returns `billingUrl` instead of `waitlistUrl`. ([#4866](https://github.com/expo/eas-cli/pull/4866) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Point expo-device-hub at the scrcpy server copy in the `turtle-v2` bucket in Android device sessions, so a failed GitHub release download no longer fails the session. expo-device-hub falls back to GitHub when the copy is missing. ([#4848](https://github.com/expo/eas-cli/pull/4848) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
- [build-tools] Fix black iOS session preview artifacts on multi-display Simulators by capturing the active display through serve-sim. ([#4777](https://github.com/expo/eas-cli/pull/4777) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
Skip automatic previews on older serve-sim versions that cannot exclude them from idle activity.
|
||||
|
||||
- [build-tools] Report flow tags with the Maestro test results of jobs that run `maestro-runner`. The tags are read from the runner's `report.json`. ([#4853](https://github.com/expo/eas-cli/pull/4853) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [build-tools] Send the session preview size to `preview-upload-session` and refresh it when the size changes. ([#4864](https://github.com/expo/eas-cli/pull/4864) by [@hirbod](https://github.com/hirbod))
|
||||
- [eas-cli] Remove the "Android emulator support is still in development" warning from `eas simulator`. ([#4870](https://github.com/expo/eas-cli/pull/4870) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Send the width, height and source of simulator session screenshots (manual, agent-device and Argent, including Argent screenshot diffs) as artifact metadata, read from the image header. ([#4858](https://github.com/expo/eas-cli/pull/4858) by [@hirbod](https://github.com/hirbod))
|
||||
- [eas-cli][build-tools][eas-build-job] Fix sizes of 102.4 GB and larger being printed with a value about a million times too large, and share one `formatBytes` helper between `eas-cli` and `build-tools`. ([#4854](https://github.com/expo/eas-cli/pull/4854) by [@vonovak](https://github.com/vonovak))
|
||||
- [eas-cli] Package local Simulator app bundles for upload when the host supports gzip tar archives, and reject unsupported IPA and ZIP installs before uploading. ([#4684](https://github.com/expo/eas-cli/pull/4684) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
Fix packaging stalls for app bundles containing hard links and clean temporary archives when interrupted. Local app directories and `.tar.gz`/`.tgz` files require a host advertising archive support.
|
||||
|
||||
- [eas-cli] Remove the `[EXPERIMENTAL]` label from `eas simulator` commands, except `eas simulator:egress` and the `--egress` flag. ([#4869](https://github.com/expo/eas-cli/pull/4869) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [27.7.2](https://github.com/expo/eas-cli/releases/tag/v27.7.2) - 2026-11-10
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Add `--log-source` to `eas simulator` to select which Android logs a session records. ([#4823](https://github.com/expo/eas-cli/pull/4823) by [@vonovak](https://github.com/vonovak))
|
||||
- [build-tools] Start SSH session shells in the job's build directory instead of the worker's directory. ([#4843](https://github.com/expo/eas-cli/pull/4843) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [eas-cli] Show that the ssh session has ended, instead of saying SSH was not enabled, when `eas workflow:ssh` is given the ID of a finished build. ([#4842](https://github.com/expo/eas-cli/pull/4842) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [eas-cli] Show the ID argument and examples in `eas workflow:ssh --help`. ([#4829](https://github.com/expo/eas-cli/pull/4829) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
## [27.7.1](https://github.com/expo/eas-cli/releases/tag/v27.7.1) - 2026-11-10
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli][eas-json][build-tools][eas-build-job][steps][local-build-plugin][worker][simulator-launcher] Update `node-forge`, `tar`, `nanoid`, `minimatch`, `joi`, `ajv`, `yaml`, and `zod` to versions with fixes for published security advisories. ([#4847](https://github.com/expo/eas-cli/pull/4847) by [@tchayen](https://github.com/tchayen))
|
||||
- [build-tools] Strip the GitHub repository access token from the git remote before a sandbox agent starts. ([#4826](https://github.com/expo/eas-cli/pull/4826) by [@sswrk](https://github.com/sswrk))
|
||||
|
||||
## [27.7.0](https://github.com/expo/eas-cli/releases/tag/v27.7.0) - 2026-11-10
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--git-branch` and `--git-commit-hash` to `eas workflow:run` to record the branch and commit of the uploaded project. ([#4815](https://github.com/expo/eas-cli/pull/4815) by [@hSATAC](https://github.com/hSATAC))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Record Android emulator output and, when selected, device logcat during simulator sessions, and upload the last 20 MiB of each as a session artifact. ([#4822](https://github.com/expo/eas-cli/pull/4822) by [@vonovak](https://github.com/vonovak))
|
||||
- [eas-cli] Stop spinners from flickering when their text wraps onto a second line, such as the `simulator:start` queue message in a narrow terminal. ([#4850](https://github.com/expo/eas-cli/pull/4850) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [27.6.0](https://github.com/expo/eas-cli/releases/tag/v27.6.0) - 2026-11-10
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Reuse compatible prefetched Appium and XCUITest files without removing them during cleanup. Resolve explicit package versions, including `latest`, and reuse prepared tools only when they match. Use the launcher's runtime serve-sim version for warm adoption and recordings, including prereleases. ([#4837](https://github.com/expo/eas-cli/pull/4837) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add the `--property key=value` flag to `eas observe:events` to filter events by a property value. ([#4812](https://github.com/expo/eas-cli/pull/4812) by [@Ubax](https://github.com/Ubax))
|
||||
- [simulator-launcher] Prepare pinned Appium, XCUITest and WebDriverAgent files with `prefetch`. ([#4836](https://github.com/expo/eas-cli/pull/4836) by [@gwdp](https://github.com/gwdp))
|
||||
- [simulator-launcher] Add `simulator-launcher prefetch` to cache Expo Go downloads. ([#4834](https://github.com/expo/eas-cli/pull/4834) by [@gwdp](https://github.com/gwdp))
|
||||
- [simulator-launcher] Use caller-supplied serve-sim, agent-device and Argent versions and include exact version inputs in claims. ([#4845](https://github.com/expo/eas-cli/pull/4845) by [@gwdp](https://github.com/gwdp))
|
||||
- [simulator-launcher] Add `simulator-launcher warmup` so consumers can warm up Simulators, install Expo Go and prepare native tool caches. The command stops its tools and shuts down the selected Simulators before returning. ([#4838](https://github.com/expo/eas-cli/pull/4838) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Stop waiting for `com.apple.datamigrator` to exit before an iOS Simulator on iOS 27.2 or later counts as ready; there it stays resident, so the wait never finished. Earlier runtimes keep the wait. Take the readiness screenshot from the first integrated screen when the device lists several, so the iPhone Duo on iOS 27.2 passes the check. ([#4816](https://github.com/expo/eas-cli/pull/4816) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Stop suggesting other event names in `eas observe:events` when an event name with `--session-id`, `--app-version`, `--build-number`, or `--update-id` returns no events. ([#4813](https://github.com/expo/eas-cli/pull/4813) by [@Ubax](https://github.com/Ubax))
|
||||
- [build-tools] Require the sandbox worker token for sandbox jobs and remove the sandbox GraphQL fallback. ([#4808](https://github.com/expo/eas-cli/pull/4808) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [vm-tools] Run the macOS VM-tools service with interactive scheduling so Simulator startup and preview processes avoid launchd's default CPU and I/O throttling. ([#4833](https://github.com/expo/eas-cli/pull/4833) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [27.5.0](https://github.com/expo/eas-cli/releases/tag/v27.5.0) - 2026-11-05
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "27.4.1",
|
||||
"PRODUCTION": "27.4.1"
|
||||
"STAGING": "27.10.0",
|
||||
"PRODUCTION": "27.10.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "27.5.0",
|
||||
"version": "27.10.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -6,4 +6,4 @@ MISE_NODE_COREPACK = "true"
|
||||
actionlint = "1.7.12"
|
||||
gcloud = "570.0.0"
|
||||
jq = "1.8.1"
|
||||
node = "22.23.4"
|
||||
node = "22.23.5"
|
||||
@@ -1,5 +1,66 @@
|
||||
# @expo/build-tools
|
||||
|
||||
## 27.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add the experimental `experimental_emulator_capture_version` input to `eas/start_android_emulator`. The Android Emulator then preloads that version of the `expo-emulator-capture` agent and uses the lavapipe renderer, and later steps start expo-device-hub with the emulator capture on. When the agent cannot install, the emulator starts as before. Only web preview sessions support the capture for now, so other session types fail with an error when the input is set. ([#4827](https://github.com/expo/eas-cli/pull/4827) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Retry the first ngrok tunnel open of a device run session up to two times when ngrok rejects it with ERR_NGROK_802. The first open now waits 19 seconds instead of 15, so a tunnel that opens late is kept instead of closed. Startup can be cancelled during the retries, and a preview tunnel stops retrying when its session host finishes. ([#4874](https://github.com/expo/eas-cli/pull/4874) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- Run worker SSH sessions as detached upterm sessions that reconnect on their own under the same session ID, and stop them with `upterm session stop`. ([#4857](https://github.com/expo/eas-cli/pull/4857) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- Run the command a client passes with `eas workflow:ssh <id> <command>` in worker SSH sessions, in the build directory. ([#4863](https://github.com/expo/eas-cli/pull/4863) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- Always download upterm for worker SSH sessions instead of using an `upterm` found on `PATH`. ([#4859](https://github.com/expo/eas-cli/pull/4859) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
## 27.9.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.8.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Point expo-device-hub at the scrcpy server copy in the `turtle-v2` bucket in Android device sessions, so a failed GitHub release download no longer fails the session. expo-device-hub falls back to GitHub when the copy is missing. ([#4848](https://github.com/expo/eas-cli/pull/4848) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
- Fix black iOS session preview artifacts on multi-display Simulators by capturing the active display through serve-sim. ([#4777](https://github.com/expo/eas-cli/pull/4777) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
Skip automatic previews on older serve-sim versions that cannot exclude them from idle activity.
|
||||
|
||||
- Report flow tags with the Maestro test results of jobs that run `maestro-runner`. The tags are read from the runner's `report.json`. ([#4853](https://github.com/expo/eas-cli/pull/4853) by [@hSATAC](https://github.com/hSATAC))
|
||||
- Send the session preview size to `preview-upload-session` and refresh it when the size changes. ([#4864](https://github.com/expo/eas-cli/pull/4864) by [@hirbod](https://github.com/hirbod))
|
||||
- Send the width, height and source of simulator session screenshots (manual, agent-device and Argent, including Argent screenshot diffs) as artifact metadata, read from the image header. ([#4858](https://github.com/expo/eas-cli/pull/4858) by [@hirbod](https://github.com/hirbod))
|
||||
- Fix sizes of 102.4 GB and larger being printed with a value about a million times too large, and share one `formatBytes` helper between `eas-cli` and `build-tools`. ([#4854](https://github.com/expo/eas-cli/pull/4854) by [@vonovak](https://github.com/vonovak))
|
||||
|
||||
## 27.7.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Start SSH session shells in the job's build directory instead of the worker's directory. ([#4843](https://github.com/expo/eas-cli/pull/4843) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
## 27.7.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Update `node-forge`, `tar`, `nanoid`, `minimatch`, `joi`, `ajv`, `yaml`, and `zod` to versions with fixes for published security advisories. ([#4847](https://github.com/expo/eas-cli/pull/4847) by [@tchayen](https://github.com/tchayen))
|
||||
- Strip the GitHub repository access token from the git remote before a sandbox agent starts. ([#4826](https://github.com/expo/eas-cli/pull/4826) by [@sswrk](https://github.com/sswrk))
|
||||
|
||||
## 27.7.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Record Android emulator output and, when selected, device logcat during simulator sessions, and upload the last 20 MiB of each as a session artifact. ([#4822](https://github.com/expo/eas-cli/pull/4822) by [@vonovak](https://github.com/vonovak))
|
||||
|
||||
## 27.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Reuse compatible prefetched Appium and XCUITest files without removing them during cleanup. Resolve explicit package versions, including `latest`, and reuse prepared tools only when they match. Use the launcher's runtime serve-sim version for warm adoption and recordings, including prereleases. ([#4837](https://github.com/expo/eas-cli/pull/4837) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stop waiting for `com.apple.datamigrator` to exit before an iOS Simulator on iOS 27.2 or later counts as ready; there it stays resident, so the wait never finished. Earlier runtimes keep the wait. Take the readiness screenshot from the first integrated screen when the device lists several, so the iPhone Duo on iOS 27.2 passes the check. ([#4816](https://github.com/expo/eas-cli/pull/4816) by [@hSATAC](https://github.com/hSATAC))
|
||||
- Require the sandbox worker token for sandbox jobs and remove the sandbox GraphQL fallback. ([#4808](https://github.com/expo/eas-cli/pull/4808) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
## 27.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "27.5.0",
|
||||
"version": "27.10.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -26,7 +26,7 @@
|
||||
"build:egress-guard": "resources/egress-guard/build.sh",
|
||||
"test:egress-guard": "resources/egress-guard/tests/run-policy-tests.sh && resources/egress-guard/tests/run-guard-tests.sh",
|
||||
"typecheck": "tsc",
|
||||
"generate-appium-commands": "mise exec [email protected].4 -- node scripts/generate-appium-commands.js",
|
||||
"generate-appium-commands": "mise exec [email protected].5 -- node scripts/generate-appium-commands.js",
|
||||
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
|
||||
"jest-unit": "jest --config jest/unit-config.ts",
|
||||
"jest-integration": "jest --config jest/integration-config.ts",
|
||||
@@ -42,17 +42,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "27.5.0",
|
||||
"@expo/eas-build-job": "27.5.0",
|
||||
"@expo/downloader": "27.10.0",
|
||||
"@expo/eas-build-job": "27.10.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "27.5.0",
|
||||
"@expo/logger": "27.10.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "27.5.0",
|
||||
"@expo/template-file": "27.5.0",
|
||||
"@expo/turtle-spawn": "27.5.0",
|
||||
"@expo/steps": "27.10.0",
|
||||
"@expo/template-file": "27.10.0",
|
||||
"@expo/turtle-spawn": "27.10.0",
|
||||
"@expo/xcpretty": "^4.4.7",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
@@ -63,11 +63,11 @@
|
||||
"fast-xml-parser": "^4.4.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
"gql.tada": "^1.11.3",
|
||||
"joi": "^17.13.1",
|
||||
"joi": "^17.13.9",
|
||||
"jose": "5.10.0",
|
||||
"lodash": "^4.18.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"node-forge": "^1.3.1",
|
||||
"node-forge": "^1.4.3",
|
||||
"node-pty": "1.2.0-beta.15",
|
||||
"node-stream-zip": "1.15.0",
|
||||
"nullthrows": "^1.1.1",
|
||||
@@ -77,11 +77,11 @@
|
||||
"resolve-from": "^5.0.0",
|
||||
"retry": "^0.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"tar": "7.5.19",
|
||||
"tar": "7.5.22",
|
||||
"uuid": "9.0.1",
|
||||
"ws": "8.21.1",
|
||||
"yaml": "^2.8.1",
|
||||
"zod": "^4.3.5"
|
||||
"yaml": "^2.9.1",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/repack-app": "~0.9.0",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { fetchAndCheckoutRefAsync } from '../git';
|
||||
import { fetchAndCheckoutRefAsync, stripRepositoryCredentialsAsync } from '../git';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
|
||||
@@ -147,3 +147,64 @@ describe(fetchAndCheckoutRefAsync, () => {
|
||||
expect(error.message).not.toContain('ghs_secret123');
|
||||
});
|
||||
});
|
||||
|
||||
describe(stripRepositoryCredentialsAsync, () => {
|
||||
const token = 'ghs_secret123';
|
||||
|
||||
function mockRemoteUrl(url: string): void {
|
||||
jest.mocked(spawn).mockResolvedValueOnce({ stdout: `${url}\n` } as never);
|
||||
}
|
||||
|
||||
it('strips the token from the origin remote', async () => {
|
||||
mockRemoteUrl(`https://x-access-token:${token}@github.com/expo/app.git`);
|
||||
|
||||
await stripRepositoryCredentialsAsync({ repositoryDirectory });
|
||||
|
||||
expect(spawn).toHaveBeenNthCalledWith(1, 'git', ['remote', 'get-url', 'origin'], {
|
||||
cwd: repositoryDirectory,
|
||||
});
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'git',
|
||||
['remote', 'set-url', 'origin', 'https://github.com/expo/app.git'],
|
||||
{ cwd: repositoryDirectory }
|
||||
);
|
||||
expect(spawn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps the host and port of a GitHub Enterprise remote', async () => {
|
||||
mockRemoteUrl(`https://x-access-token:${token}@ghe.example.com:8443/org/app.git`);
|
||||
|
||||
await stripRepositoryCredentialsAsync({ repositoryDirectory });
|
||||
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'git',
|
||||
['remote', 'set-url', 'origin', 'https://ghe.example.com:8443/org/app.git'],
|
||||
{ cwd: repositoryDirectory }
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when the origin remote carries no credentials', async () => {
|
||||
mockRemoteUrl('https://github.com/expo/app.git');
|
||||
|
||||
await stripRepositoryCredentialsAsync({ repositoryDirectory });
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws when the remote URL cannot be rewritten', async () => {
|
||||
mockRemoteUrl(`https://x-access-token:${token}@github.com/expo/app.git`);
|
||||
const gitError = Object.assign(new Error('git exited with non-zero code: 128'), {
|
||||
stderr: "fatal: No such remote 'origin'",
|
||||
});
|
||||
jest.mocked(spawn).mockRejectedValueOnce(gitError);
|
||||
|
||||
const promise = stripRepositoryCredentialsAsync({ repositoryDirectory });
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(promise).rejects.toMatchObject({ cause: gitError });
|
||||
const error = await promise.catch(err => err);
|
||||
expect(error.message).not.toContain(token);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArchiveSource, ArchiveSourceType, UserError } from '@expo/eas-build-job';
|
||||
import { ArchiveSource, ArchiveSourceType, SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
@@ -99,6 +99,45 @@ export async function fetchAndCheckoutRefAsync({
|
||||
}
|
||||
}
|
||||
|
||||
export async function stripRepositoryCredentialsAsync({
|
||||
repositoryDirectory,
|
||||
}: {
|
||||
repositoryDirectory: string;
|
||||
}): Promise<void> {
|
||||
let remoteUrl: URL;
|
||||
try {
|
||||
const { stdout } = await spawn('git', ['remote', 'get-url', 'origin'], {
|
||||
cwd: repositoryDirectory,
|
||||
});
|
||||
remoteUrl = new URL(stdout.trim());
|
||||
} catch (err) {
|
||||
throw new SystemError('Failed to read the origin remote URL of the project repository.', {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
|
||||
if (!remoteUrl.username && !remoteUrl.password) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await spawn('git', ['remote', 'set-url', 'origin', stripCredentialsFromGitUrl(remoteUrl)], {
|
||||
cwd: repositoryDirectory,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new SystemError('Failed to strip credentials from the origin remote URL.', {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function stripCredentialsFromGitUrl(url: URL): string {
|
||||
const strippedUrl = new URL(url.toString());
|
||||
strippedUrl.username = '';
|
||||
strippedUrl.password = '';
|
||||
return strippedUrl.toString();
|
||||
}
|
||||
|
||||
function getSanitizedGitUrl(maybeGitUrl: string): string | null {
|
||||
try {
|
||||
const url = new URL(maybeGitUrl);
|
||||
|
||||
@@ -62,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';
|
||||
@@ -129,6 +130,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createStartServeSimMetricsBuildFunction(),
|
||||
createStartServeSimLogsBuildFunction(),
|
||||
createCollectServeSimLogsBuildFunction(ctx),
|
||||
createCollectAndroidEmulatorLogsBuildFunction(ctx),
|
||||
createStartSandboxBuildFunction(ctx),
|
||||
createRunAgentBuildFunction(ctx),
|
||||
createCollectServeSimMetricsBuildFunction(ctx),
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
+10
-2
@@ -2,6 +2,7 @@ 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';
|
||||
@@ -139,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 },
|
||||
@@ -153,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: {},
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
parseMaestroResults,
|
||||
parseMaestroResultsFromFileAttrs,
|
||||
parseMaestroRunnerReport,
|
||||
parseMaestroRunnerResults,
|
||||
} from '../maestroResultParser';
|
||||
|
||||
describe(parseFailedFlowsFromMaestroRunnerReport, () => {
|
||||
@@ -483,6 +484,271 @@ describe(parseMaestroResultsFromFileAttrs, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseMaestroRunnerResults, () => {
|
||||
function runnerJUnit(testcases: { name: string; file: string; failed?: boolean }[]): string {
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="maestro-runner">',
|
||||
...testcases.flatMap(({ name, file, failed }) => [
|
||||
` <testcase id="${name}" name="${name}" classname="${name}" time="1.000" status="${failed ? 'ERROR' : 'SUCCESS'}">`,
|
||||
' <properties>',
|
||||
` <property name="file" value="${file}"/>`,
|
||||
' <property name="device.platform" value="android"/>',
|
||||
' </properties>',
|
||||
...(failed ? [' <failure message="Element not found">tapOn</failure>'] : []),
|
||||
' </testcase>',
|
||||
]),
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
it('adds the tags of each flow from the report.json of its attempt', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/android-maestro-junit-attempt-0.xml': runnerJUnit([
|
||||
{ name: 'sign-in', file: 'flows/auth/sign-in.yaml', failed: true },
|
||||
{ name: 'settings', file: 'flows/settings.yaml' },
|
||||
]),
|
||||
'/tests/android-maestro-runner-attempt-0/report.json': JSON.stringify({
|
||||
flows: [
|
||||
{
|
||||
name: 'sign-in',
|
||||
sourceFile: 'flows/auth/sign-in.yaml',
|
||||
status: 'failed',
|
||||
tags: ['auth', 'smoke'],
|
||||
},
|
||||
{ name: 'settings', sourceFile: 'flows/settings.yaml', status: 'passed' },
|
||||
],
|
||||
}),
|
||||
'/junit/android-maestro-junit-attempt-1.xml': runnerJUnit([
|
||||
{ name: 'sign-in', file: 'flows/auth/sign-in.yaml' },
|
||||
]),
|
||||
'/tests/android-maestro-runner-attempt-1/report.json': JSON.stringify({
|
||||
flows: [
|
||||
{
|
||||
name: 'sign-in',
|
||||
sourceFile: 'flows/auth/sign-in.yaml',
|
||||
status: 'passed',
|
||||
tags: ['auth'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroRunnerResults({
|
||||
junitDirectory: '/junit',
|
||||
testsDirectory: '/tests',
|
||||
workingDirectory: '/project',
|
||||
});
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
name: 'sign-in',
|
||||
path: 'flows/auth/sign-in.yaml',
|
||||
status: 'failed',
|
||||
errorMessage: 'Element not found',
|
||||
duration: 1000,
|
||||
retryCount: 0,
|
||||
tags: ['auth', 'smoke'],
|
||||
properties: { file: 'flows/auth/sign-in.yaml', 'device.platform': 'android' },
|
||||
},
|
||||
expect.objectContaining({ path: 'flows/auth/sign-in.yaml', retryCount: 1, tags: ['auth'] }),
|
||||
expect.objectContaining({ path: 'flows/settings.yaml', retryCount: 0, tags: [] }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches an absolute sourceFile with a JUnit path relative to the working directory', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/ios-maestro-junit-attempt-0.xml': runnerJUnit([
|
||||
{ name: 'sign-in', file: 'flows/sign-in.yaml' },
|
||||
]),
|
||||
'/tests/ios-maestro-runner-attempt-0/report.json': JSON.stringify({
|
||||
flows: [
|
||||
{
|
||||
name: 'sign-in',
|
||||
sourceFile: '/project/flows/sign-in.yaml',
|
||||
status: 'passed',
|
||||
tags: ['auth'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroRunnerResults({
|
||||
junitDirectory: '/junit',
|
||||
testsDirectory: '/tests',
|
||||
workingDirectory: '/project',
|
||||
});
|
||||
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({ path: 'flows/sign-in.yaml', tags: ['auth'] }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches a sourceFile under a symlinked directory with the JUnit path of its target', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/flows/sign-in.yaml': '',
|
||||
'/junit/ios-maestro-junit-attempt-0.xml': runnerJUnit([
|
||||
{ name: 'sign-in', file: 'flows/sign-in.yaml' },
|
||||
]),
|
||||
'/tests/ios-maestro-runner-attempt-0/report.json': JSON.stringify({
|
||||
flows: [
|
||||
{ name: 'sign-in', sourceFile: 'linked/sign-in.yaml', status: 'passed', tags: ['auth'] },
|
||||
],
|
||||
}),
|
||||
});
|
||||
vol.symlinkSync('/project/flows', '/project/linked');
|
||||
|
||||
const results = await parseMaestroRunnerResults({
|
||||
junitDirectory: '/junit',
|
||||
testsDirectory: '/tests',
|
||||
workingDirectory: '/project',
|
||||
});
|
||||
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({ path: 'flows/sign-in.yaml', tags: ['auth'] }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches a sourceFile whose symlink leaves the working directory by the path as given', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/.keep': '',
|
||||
'/shared/sign-in.yaml': '',
|
||||
'/junit/ios-maestro-junit-attempt-0.xml': runnerJUnit([
|
||||
{ name: 'sign-in', file: 'linked/sign-in.yaml' },
|
||||
]),
|
||||
'/tests/ios-maestro-runner-attempt-0/report.json': JSON.stringify({
|
||||
flows: [
|
||||
{ name: 'sign-in', sourceFile: 'linked/sign-in.yaml', status: 'passed', tags: ['auth'] },
|
||||
],
|
||||
}),
|
||||
});
|
||||
vol.symlinkSync('/shared', '/project/linked');
|
||||
|
||||
const results = await parseMaestroRunnerResults({
|
||||
junitDirectory: '/junit',
|
||||
testsDirectory: '/tests',
|
||||
workingDirectory: '/project',
|
||||
});
|
||||
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({ path: 'linked/sign-in.yaml', tags: ['auth'] }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not follow a symlink from the bare file name that maestro-runner <= 1.1.17 writes', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/flows/sign-in.yaml': '',
|
||||
'/project/settings.yaml': '',
|
||||
'/junit/ios-maestro-junit-attempt-0.xml': runnerJUnit([
|
||||
{ name: 'sign-in', file: 'sign-in.yaml' },
|
||||
{ name: 'settings', file: 'settings.yaml' },
|
||||
]),
|
||||
'/tests/ios-maestro-runner-attempt-0/report.json': JSON.stringify({
|
||||
flows: [
|
||||
{ name: 'sign-in', sourceFile: 'flows/sign-in.yaml', status: 'passed', tags: ['auth'] },
|
||||
{ name: 'settings', sourceFile: 'settings.yaml', status: 'passed', tags: ['settings'] },
|
||||
],
|
||||
}),
|
||||
});
|
||||
vol.symlinkSync('/project/settings.yaml', '/project/sign-in.yaml');
|
||||
|
||||
const results = await parseMaestroRunnerResults({
|
||||
junitDirectory: '/junit',
|
||||
testsDirectory: '/tests',
|
||||
workingDirectory: '/project',
|
||||
});
|
||||
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({ path: 'sign-in.yaml', tags: ['auth'] }),
|
||||
expect.objectContaining({ path: 'settings.yaml', tags: ['settings'] }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches by file name when JUnit has only the file name and it is unique', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/ios-maestro-junit-attempt-0.xml': runnerJUnit([
|
||||
{ name: 'sign-in', file: 'sign-in.yaml' },
|
||||
]),
|
||||
'/tests/ios-maestro-runner-attempt-0/report.json': JSON.stringify({
|
||||
flows: [
|
||||
{
|
||||
name: 'sign-in',
|
||||
sourceFile: 'flows/auth/sign-in.yaml',
|
||||
status: 'passed',
|
||||
tags: ['auth'],
|
||||
},
|
||||
{
|
||||
name: 'settings',
|
||||
sourceFile: 'flows/settings.yaml',
|
||||
status: 'passed',
|
||||
tags: ['settings'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroRunnerResults({
|
||||
junitDirectory: '/junit',
|
||||
testsDirectory: '/tests',
|
||||
workingDirectory: '/project',
|
||||
});
|
||||
|
||||
expect(results).toEqual([expect.objectContaining({ path: 'sign-in.yaml', tags: ['auth'] })]);
|
||||
});
|
||||
|
||||
it('adds no tags when two flows of the attempt have the same file name', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/ios-maestro-junit-attempt-0.xml': runnerJUnit([
|
||||
{ name: 'sign-in', file: 'sign-in.yaml' },
|
||||
]),
|
||||
'/tests/ios-maestro-runner-attempt-0/report.json': JSON.stringify({
|
||||
flows: [
|
||||
{ name: 'a', sourceFile: 'flows/a/sign-in.yaml', status: 'passed', tags: ['a'] },
|
||||
{ name: 'b', sourceFile: 'flows/b/sign-in.yaml', status: 'passed', tags: ['b'] },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroRunnerResults({
|
||||
junitDirectory: '/junit',
|
||||
testsDirectory: '/tests',
|
||||
workingDirectory: '/project',
|
||||
});
|
||||
|
||||
expect(results).toEqual([expect.objectContaining({ path: 'sign-in.yaml', tags: [] })]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: 'is not valid JSON', content: '{' },
|
||||
{ name: 'has an unknown shape', content: JSON.stringify({ flows: [{ name: 'sign-in' }] }) },
|
||||
{
|
||||
name: 'does not have the flow',
|
||||
content: JSON.stringify({
|
||||
flows: [{ name: 'other', sourceFile: 'flows/other.yaml', status: 'passed', tags: ['x'] }],
|
||||
}),
|
||||
},
|
||||
])('keeps the JUnit result without tags when report.json $name', async ({ content }) => {
|
||||
vol.fromJSON({
|
||||
'/junit/android-maestro-junit-attempt-0.xml': runnerJUnit([
|
||||
{ name: 'sign-in', file: 'flows/sign-in.yaml' },
|
||||
]),
|
||||
'/tests/android-maestro-runner-attempt-0/report.json': content,
|
||||
});
|
||||
|
||||
const results = await parseMaestroRunnerResults({
|
||||
junitDirectory: '/junit',
|
||||
testsDirectory: '/tests',
|
||||
workingDirectory: '/project',
|
||||
});
|
||||
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({ name: 'sign-in', path: 'flows/sign-in.yaml', tags: [] }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('junitFileHasFileAttrs', () => {
|
||||
it('returns true when every testcase has a non-empty file= attribute', async () => {
|
||||
vol.fromJSON({
|
||||
|
||||
@@ -358,6 +358,51 @@ describe(createReportMaestroTestResultsFunction, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reports maestro-runner results with the tags from report.json', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/android-maestro-junit-attempt-0.xml': [
|
||||
'<testsuites><testsuite name="maestro-runner">',
|
||||
' <testcase id="sign-in" name="sign-in" classname="sign-in" time="2.000" status="SUCCESS">',
|
||||
' <properties><property name="file" value="flows/sign-in.yaml"/></properties>',
|
||||
' </testcase>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
'/tests/android-maestro-runner-attempt-0/report.json': JSON.stringify({
|
||||
flows: [
|
||||
{
|
||||
name: 'sign-in',
|
||||
sourceFile: 'flows/sign-in.yaml',
|
||||
status: 'passed',
|
||||
tags: ['auth', 'smoke'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
mockMutationFn.mockResolvedValue({
|
||||
data: {
|
||||
workflowDeviceTestCaseResult: {
|
||||
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await createStep().executeAsync();
|
||||
|
||||
const variables = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0][1];
|
||||
expect(variables.input.testCaseResults).toEqual([
|
||||
{
|
||||
name: 'sign-in',
|
||||
path: 'flows/sign-in.yaml',
|
||||
status: 'PASSED',
|
||||
errorMessage: null,
|
||||
duration: 2000,
|
||||
retryCount: 0,
|
||||
tags: ['auth', 'smoke'],
|
||||
properties: { file: 'flows/sign-in.yaml' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses default directories when inputs are not provided', async () => {
|
||||
vol.fromJSON({
|
||||
'/home/expo/.maestro/tests/report.xml': JUNIT_PASS,
|
||||
|
||||
+30
@@ -201,6 +201,21 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not fail a session when only the agent-device tunnel cannot stop', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const tunnelError = new Error('Ngrok tunnel stop timed out after 4000ms.');
|
||||
mockTunnelStopAsync.mockRejectedValueOnce(tunnelError);
|
||||
|
||||
await expect(runAsync(logger, BuildRuntimePlatform.LINUX)).resolves.toBeUndefined();
|
||||
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: tunnelError },
|
||||
'Could not stop the agent-device tunnel during remote session teardown.'
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -918,6 +933,21 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
await expect(session).rejects.toBe(failed);
|
||||
});
|
||||
|
||||
it('reports a startup Stop when only the agent-device tunnel cannot stop', 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();
|
||||
mockTunnelStopAsync.mockRejectedValueOnce(new Error('tunnel stop timed out'));
|
||||
tasks.abort(stopped);
|
||||
await expect(session).rejects.toBe(stopped);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retires startup monitoring after reporting and before lifetime waiting', async () => {
|
||||
const retired = deferred();
|
||||
const retiring = jest.fn(() => retired.promise);
|
||||
|
||||
+105
-28
@@ -4,6 +4,8 @@ import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { AndroidEmulatorLogRing } from '../../../utils/androidEmulatorLogCapture';
|
||||
import { getAndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogs';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { stopLocalEgressResourcesAsync } from '../../utils/localEgress';
|
||||
import {
|
||||
@@ -35,6 +37,7 @@ 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'),
|
||||
@@ -108,43 +111,28 @@ function runStep(
|
||||
inputValues: Record<string, unknown> = {},
|
||||
signal?: AbortSignal,
|
||||
stepCtx: CustomBuildContext = ctx,
|
||||
warm = false
|
||||
warm = false,
|
||||
env: NodeJS.ProcessEnv = {}
|
||||
): Promise<void> {
|
||||
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',
|
||||
'install_app_path',
|
||||
'build_id',
|
||||
'application_archive_url',
|
||||
'launch_app_identifier',
|
||||
'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>;
|
||||
}
|
||||
|
||||
@@ -173,6 +161,11 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.mocked(getAndroidEmulatorLogCapture)
|
||||
.mockImplementation(
|
||||
jest.requireActual('../../utils/androidEmulatorLogs').getAndroidEmulatorLogCapture
|
||||
);
|
||||
deviceLaunch = undefined;
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(null);
|
||||
@@ -437,6 +430,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
expect(getAndroidEmulatorLogCapture).not.toHaveBeenCalled();
|
||||
expect(sessionDevice()).toEqual({
|
||||
iosSimulatorUdid: 'SELECTED-UDID',
|
||||
bootEnv: undefined,
|
||||
@@ -444,6 +438,61 @@ 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({
|
||||
logCapture: expect.objectContaining({ sources: ['emulator-output'] }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('passes an explicit empty source list and the workflow capture to Android boot', async () => {
|
||||
const capture = new AndroidEmulatorLogRing({ sources: [] });
|
||||
jest.mocked(getAndroidEmulatorLogCapture).mockReturnValue(capture);
|
||||
const env = { DEVICE_RUN_SESSION_ID: 'session' };
|
||||
await runStep(BuildRuntimePlatform.LINUX, { log_sources: [] }, undefined, ctx, false, env);
|
||||
expect(getAndroidEmulatorLogCapture).toHaveBeenCalledWith(expect.anything(), env, []);
|
||||
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ logCapture: capture })
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid Android log sources before boot or downloading', async () => {
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.LINUX, {
|
||||
log_sources: ['unknown'],
|
||||
build_id: 'build-id',
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects Android log sources on an iOS session before boot or downloading', async () => {
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, {
|
||||
log_sources: ['device-logcat'],
|
||||
build_id: 'build-id',
|
||||
})
|
||||
).rejects.toThrow('Input "log_sources" selects Android emulator logs');
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('boots an iOS session with an explicit empty source list', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN, { log_sources: [] });
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalled();
|
||||
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('boots the Android Emulator with the device inputs', async () => {
|
||||
await runStep(BuildRuntimePlatform.LINUX, {
|
||||
device_identifier: 'pixel_7',
|
||||
@@ -470,6 +519,22 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
);
|
||||
});
|
||||
|
||||
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' }, 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.
|
||||
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'));
|
||||
@@ -617,6 +682,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual([
|
||||
'log_sources',
|
||||
'device_identifier',
|
||||
'system_image_package',
|
||||
'lcd_width',
|
||||
@@ -821,6 +887,17 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('passes the resolved version to agent-device after a warm version mismatch', async () => {
|
||||
jest
|
||||
.mocked(runOnWarmIosSimulatorAsync)
|
||||
.mockImplementation((_claim, run) => run(undefined, '999.1.0'));
|
||||
await runStep(BuildRuntimePlatform.DARWIN, { package_version: 'latest' }, undefined, ctx, true);
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ packageVersion: '999.1.0' })
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts only one application source', async () => {
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app', build_id: 'id' })
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
} from '../../../utils/AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../../../utils/retry';
|
||||
import { resolveAndroidEmulatorLocalEgressAsync } from '../../utils/androidLocalEgress';
|
||||
import { AndroidEmulatorLogRing } from '../../../utils/androidEmulatorLogCapture';
|
||||
import { getAndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogs';
|
||||
import { installEmulatorCaptureAsync } from '../../utils/emulatorCapture';
|
||||
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
@@ -24,6 +27,13 @@ jest.mock('../../utils/androidLocalEgress', () => ({
|
||||
resolveAndroidEmulatorLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/androidEmulatorLogs');
|
||||
|
||||
jest.mock('../../utils/emulatorCapture', () => ({
|
||||
...jest.requireActual('../../utils/emulatorCapture'),
|
||||
installEmulatorCaptureAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
AndroidEmulatorUtils: {
|
||||
defaultSystemImagePackage: 'system-images;android-30;default;x86_64',
|
||||
@@ -34,6 +44,7 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
waitForReadyAsync: jest.fn(),
|
||||
disableWindowAndTransitionAnimationsAsync: jest.fn(),
|
||||
deleteAsync: jest.fn(),
|
||||
findFreeConsolePortAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -50,7 +61,12 @@ function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.
|
||||
const step = fn.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs,
|
||||
});
|
||||
return Object.assign(step, { logger });
|
||||
return Object.assign(step, { logger, globalCtx });
|
||||
}
|
||||
|
||||
/** The logger that the step function gets: a child of the base logger. */
|
||||
function stepLogger(step: ReturnType<typeof createStep>) {
|
||||
return jest.mocked(step.logger.child).mock.results[0].value;
|
||||
}
|
||||
|
||||
function createStartResult(serialId: string) {
|
||||
@@ -62,6 +78,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 +111,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 AndroidEmulatorLogRing({ sources: [] });
|
||||
jest.mocked(getAndroidEmulatorLogCapture).mockReturnValue(capture);
|
||||
await createStep(
|
||||
{ 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.logCapture).toBe(capture);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid sources before starting the emulator', async () => {
|
||||
await expect(createStep({ log_sources: ['unknown'] }).executeAsync()).rejects.toThrow();
|
||||
expect(mockedAndroidUtils.startAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('local egress', () => {
|
||||
function createLocalEgress() {
|
||||
return {
|
||||
@@ -111,6 +156,7 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
deviceName: 'EasAndroidDevice01',
|
||||
env: expect.any(Object),
|
||||
logcatDirectory: '/tmp/logcat-directory',
|
||||
logCapture: expect.objectContaining({ sources: ['emulator-output', 'device-logcat'] }),
|
||||
});
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith({
|
||||
env: expect.any(Object),
|
||||
@@ -436,4 +482,170 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('experimental emulator capture', () => {
|
||||
const LIBRARY = '/tmp/package/libexpo_emulator_capture.so';
|
||||
const mockedInstall = jest.mocked(installEmulatorCaptureAsync);
|
||||
|
||||
function captureStart(port: number) {
|
||||
return {
|
||||
deviceName: 'EasAndroidDevice01',
|
||||
env: expect.any(Object),
|
||||
logcatDirectory: '/tmp/logcat-directory',
|
||||
logCapture: expect.objectContaining({ sources: ['emulator-output', 'device-logcat'] }),
|
||||
consolePort: port,
|
||||
extraArgs: ['-gpu', 'lavapipe'],
|
||||
extraEnv: {
|
||||
LD_PRELOAD: LIBRARY,
|
||||
EXPO_EMULATOR_CAPTURE_SOCKET: `/tmp/job/expo-emulator-capture/emulator-${port}.sock`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedInstall.mockReset().mockResolvedValue(LIBRARY);
|
||||
mockedAndroidUtils.findFreeConsolePortAsync
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(5554)
|
||||
.mockResolvedValueOnce(5556);
|
||||
mockedAndroidUtils.startAsync.mockResolvedValue(createStartResult('emulator-5554'));
|
||||
});
|
||||
|
||||
it('preloads the agent and turns the capture on for expo-device-hub', async () => {
|
||||
const step = createStep(
|
||||
{ experimental_emulator_capture_version: '0.1.0' },
|
||||
{ TMPDIR: '/tmp/job' }
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedInstall).toHaveBeenCalledWith({
|
||||
version: '0.1.0',
|
||||
env: expect.any(Object),
|
||||
logger: expect.anything(),
|
||||
});
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(captureStart(5554));
|
||||
expect(step.globalCtx.env).toMatchObject({
|
||||
EXPO_DEVICE_HUB_EXPERIMENTAL_EMULATOR_CAPTURE: '1',
|
||||
EXPO_DEVICE_HUB_EMULATOR_CAPTURE_LIBRARY: LIBRARY,
|
||||
});
|
||||
expect(stepLogger(step).info).toHaveBeenCalledWith(
|
||||
'emulator-5554 preloads [email protected] with -gpu lavapipe, and its agent socket is /tmp/job/expo-emulator-capture/emulator-5554.sock. Later steps start expo-device-hub with the emulator capture on.'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the temporary directory rule of Node when TMPDIR is empty', async () => {
|
||||
await createStep(
|
||||
{ experimental_emulator_capture_version: '0.1.0' },
|
||||
{ TMPDIR: '', TMP: '/tmp/from-tmp' }
|
||||
).executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
extraEnv: expect.objectContaining({
|
||||
EXPO_EMULATOR_CAPTURE_SOCKET: '/tmp/from-tmp/expo-emulator-capture/emulator-5554.sock',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uses /tmp when the job env names no temporary directory', async () => {
|
||||
await createStep({ experimental_emulator_capture_version: '0.1.0' }).executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
extraEnv: expect.objectContaining({
|
||||
EXPO_EMULATOR_CAPTURE_SOCKET: '/tmp/expo-emulator-capture/emulator-5554.sock',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not turn the capture on when the emulator runs on another console port', async () => {
|
||||
mockedAndroidUtils.startAsync.mockResolvedValue(createStartResult('emulator-5560'));
|
||||
const step = createStep(
|
||||
{ experimental_emulator_capture_version: '0.1.0' },
|
||||
{ TMPDIR: '/tmp/job' }
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(step.globalCtx.env.EXPO_DEVICE_HUB_EXPERIMENTAL_EMULATOR_CAPTURE).toBeUndefined();
|
||||
expect(stepLogger(step).warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('emulator-5560 does not use console port 5554')
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a library that the job already preloads, after the agent', async () => {
|
||||
await createStep(
|
||||
{ experimental_emulator_capture_version: '0.1.0' },
|
||||
{ TMPDIR: '/tmp/job', LD_PRELOAD: '/opt/other.so' }
|
||||
).executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
extraEnv: expect.objectContaining({ LD_PRELOAD: `${LIBRARY}:/opt/other.so` }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('chooses a new console port for each start attempt', async () => {
|
||||
mockedAndroidUtils.startAsync
|
||||
.mockRejectedValueOnce(new Error('emulator did not start'))
|
||||
.mockResolvedValue(createStartResult('emulator-5556'));
|
||||
|
||||
await createStep(
|
||||
{ experimental_emulator_capture_version: '0.1.0' },
|
||||
{ TMPDIR: '/tmp/job' }
|
||||
).executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenNthCalledWith(2, captureStart(5556));
|
||||
});
|
||||
|
||||
it('starts the emulator as without the input when the install fails', async () => {
|
||||
mockedInstall.mockResolvedValue(undefined);
|
||||
const step = createStep({ experimental_emulator_capture_version: '0.1.0' });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith({
|
||||
deviceName: 'EasAndroidDevice01',
|
||||
env: expect.any(Object),
|
||||
logcatDirectory: '/tmp/logcat-directory',
|
||||
logCapture: expect.objectContaining({ sources: ['emulator-output', 'device-logcat'] }),
|
||||
});
|
||||
expect(mockedAndroidUtils.findFreeConsolePortAsync).not.toHaveBeenCalled();
|
||||
expect(step.globalCtx.env.EXPO_DEVICE_HUB_EXPERIMENTAL_EMULATOR_CAPTURE).toBeUndefined();
|
||||
// The session step still rejects session types without the capture.
|
||||
expect(step.globalCtx.env.EAS_EXPERIMENTAL_EMULATOR_CAPTURE_VERSION).toBe('0.1.0');
|
||||
});
|
||||
|
||||
it('installs nothing without the input', async () => {
|
||||
const step = createStep();
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedInstall).not.toHaveBeenCalled();
|
||||
expect(mockedAndroidUtils.findFreeConsolePortAsync).not.toHaveBeenCalled();
|
||||
expect(step.globalCtx.env.EXPO_DEVICE_HUB_EXPERIMENTAL_EMULATOR_CAPTURE).toBeUndefined();
|
||||
expect(step.globalCtx.env.EAS_EXPERIMENTAL_EMULATOR_CAPTURE_VERSION).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects the capture for more than one emulator before it starts one', async () => {
|
||||
await expect(
|
||||
createStep({ experimental_emulator_capture_version: '0.1.0', count: 2 }).executeAsync()
|
||||
).rejects.toThrow('experimental_emulator_capture_version supports one emulator');
|
||||
|
||||
expect(mockedInstall).not.toHaveBeenCalled();
|
||||
expect(mockedAndroidUtils.startAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a version that is not an exact npm version or a dist-tag', async () => {
|
||||
await expect(
|
||||
createStep({ experimental_emulator_capture_version: 'npm:[email protected]' }).executeAsync()
|
||||
).rejects.toThrow('must be an npm version or dist-tag of expo-emulator-capture');
|
||||
|
||||
expect(mockedInstall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
+518
-4
@@ -1,7 +1,10 @@
|
||||
import { type WarmIosSession } from '../../utils/warmIosSession';
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
import plist from 'plist';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import * as timersPromises from 'node:timers/promises';
|
||||
|
||||
@@ -9,6 +12,7 @@ import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { PackageManager, resolveConfiguredPackageManager } from '../../../utils/packageManager';
|
||||
import * as processes from '../../../utils/processes';
|
||||
import { sleepAsync } from '../../../utils/retry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
@@ -98,6 +102,46 @@ jest.mock('../../utils/localEgressSession', () => ({
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as never;
|
||||
const preparedHome = path.join(os.homedir(), 'Library/Caches/expo-simulator-startup/appium');
|
||||
const preparedManifest = path.join(path.dirname(preparedHome), 'prepared.json');
|
||||
const preparedBin = path.join(preparedHome, 'node_modules', '.bin', 'appium');
|
||||
const preparedRunner = path.join(
|
||||
preparedHome,
|
||||
'preinstalled-wda',
|
||||
'WebDriverAgentRunner-Runner.app'
|
||||
);
|
||||
|
||||
const driverList = ['driver', 'list', '--installed', '--json'];
|
||||
|
||||
/** Today's `driver list` output: the prepared home lists xcuitest, a new home lists nothing. */
|
||||
function driverListStdout(command: string): string {
|
||||
return JSON.stringify(command === preparedBin ? { xcuitest: { installed: true } } : {});
|
||||
}
|
||||
|
||||
/** Writes the Appium home that `simulator-launcher prefetch` prepares for reuse. */
|
||||
function writePreparedHome(): void {
|
||||
fs.mkdirSync(path.dirname(preparedBin), { recursive: true });
|
||||
fs.writeFileSync(preparedBin, '');
|
||||
writeInstalledVersion('appium', '3.9.0');
|
||||
writeInstalledVersion('appium-xcuitest-driver', '12.22.8');
|
||||
fs.mkdirSync(preparedRunner, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(preparedRunner, 'Info.plist'),
|
||||
plist.build({ CFBundleExecutable: 'Runner' })
|
||||
);
|
||||
fs.writeFileSync(path.join(preparedRunner, 'Runner'), 'executable', { mode: 0o755 });
|
||||
writePreparedManifest({ version: '3.9.0', xcuitest: '12.22.8', home: preparedHome });
|
||||
}
|
||||
|
||||
function writePreparedManifest(appium: unknown): void {
|
||||
fs.writeFileSync(preparedManifest, `${JSON.stringify({ tools: { appium } })}\n`);
|
||||
}
|
||||
|
||||
function writeInstalledVersion(name: string, version: string): void {
|
||||
const directory = path.join(preparedHome, 'node_modules', name);
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
fs.writeFileSync(path.join(directory, 'package.json'), JSON.stringify({ name, version }));
|
||||
}
|
||||
|
||||
describe(resolveAppium3VersionSpec, () => {
|
||||
it('uses the worker-supported Appium 3 version by default', () => {
|
||||
@@ -172,9 +216,9 @@ describe(installAppiumAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock);
|
||||
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => {
|
||||
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => {
|
||||
if (args.includes('--json')) {
|
||||
return { stdout: JSON.stringify({}) };
|
||||
return { stdout: driverListStdout(command) };
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as never);
|
||||
@@ -208,6 +252,255 @@ describe(installAppiumAsync, () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the xcuitest home prefetched without installing', async () => {
|
||||
writePreparedHome();
|
||||
const info = jest.fn();
|
||||
const result = await installAppiumAsync({
|
||||
versionSpec: '^3',
|
||||
driverName: 'xcuitest',
|
||||
env: { EXISTING: 'value' },
|
||||
logger: { info } as never,
|
||||
});
|
||||
expect(jest.mocked(spawn).mock.calls).toEqual([
|
||||
[
|
||||
preparedBin,
|
||||
driverList,
|
||||
{ env: { EXISTING: 'value', APPIUM_HOME: preparedHome }, stdio: 'pipe' },
|
||||
],
|
||||
]);
|
||||
expect(info).toHaveBeenCalledWith(
|
||||
'Using the prefetched Appium home (appium 3.9.0, xcuitest 12.22.8).'
|
||||
);
|
||||
expect(result).toEqual({
|
||||
appiumHome: preparedHome,
|
||||
appiumBinPath: preparedBin,
|
||||
appiumEnv: { EXISTING: 'value', APPIUM_HOME: preparedHome },
|
||||
prepared: true,
|
||||
});
|
||||
|
||||
const env = { EAS_OVERRIDE_PACKAGE_MANAGER: 'nope' };
|
||||
const expected = (() => {
|
||||
try {
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM);
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
})();
|
||||
const onError = jest.fn();
|
||||
await expect(
|
||||
installAppiumAsync({ versionSpec: '^3', driverName: 'xcuitest', env, logger, onError })
|
||||
).rejects.toEqual(expected);
|
||||
expect(onError).toHaveBeenCalledWith(expected);
|
||||
expect(jest.mocked(spawn).mock.calls.map(([, args]) => args)).toEqual([driverList, driverList]);
|
||||
});
|
||||
|
||||
it('reuses WDA with binary bundle metadata', async () => {
|
||||
writePreparedHome();
|
||||
fs.writeFileSync(
|
||||
path.join(preparedRunner, 'Info.plist'),
|
||||
Buffer.from(
|
||||
'YnBsaXN0MDDRAQJfEBJDRkJ1bmRsZUV4ZWN1dGFibGVWUnVubmVyCAsgAAAAAAAAAQEAAAAAAAAAAwAAAAAAAAAAAAAAAAAAACc=',
|
||||
'base64'
|
||||
)
|
||||
);
|
||||
const result = await installAppiumAsync({
|
||||
versionSpec: '^3',
|
||||
driverName: 'xcuitest',
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
expect(result.prepared).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a version spec it does not satisfy', { versionSpec: '3.5.0' }, () => {}],
|
||||
['the uiautomator2 driver', { driverName: 'uiautomator2' as const }, () => {}],
|
||||
['no prepared.json', {}, () => fs.rmSync(preparedManifest)],
|
||||
['an invalid prepared.json', {}, () => fs.writeFileSync(preparedManifest, 'not json')],
|
||||
['a prepared.json without xcuitest', {}, () => writePreparedManifest({ version: '3.9.0' })],
|
||||
[
|
||||
'an older per-tool manifest',
|
||||
{},
|
||||
() => {
|
||||
fs.rmSync(preparedManifest);
|
||||
fs.writeFileSync(
|
||||
path.join(preparedHome, 'prepared.json'),
|
||||
JSON.stringify({
|
||||
appium: '3.9.0',
|
||||
xcuitest: '12.22.8',
|
||||
home: preparedHome,
|
||||
})
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'a home prepared at another path',
|
||||
{},
|
||||
() =>
|
||||
writePreparedManifest({
|
||||
version: '3.9.0',
|
||||
xcuitest: '12.22.8',
|
||||
home: '/Users/other/appium',
|
||||
}),
|
||||
],
|
||||
['another installed Appium', {}, () => writeInstalledVersion('appium', '2.19.0')],
|
||||
[
|
||||
'another installed xcuitest driver',
|
||||
{},
|
||||
() => writeInstalledVersion('appium-xcuitest-driver', '12.0.0'),
|
||||
],
|
||||
[
|
||||
'no xcuitest driver',
|
||||
{},
|
||||
() =>
|
||||
fs.rmSync(path.join(preparedHome, 'node_modules', 'appium-xcuitest-driver'), {
|
||||
recursive: true,
|
||||
}),
|
||||
],
|
||||
['no runner', {}, () => fs.rmSync(preparedRunner, { recursive: true })],
|
||||
['no runner metadata', {}, () => fs.rmSync(path.join(preparedRunner, 'Info.plist'))],
|
||||
[
|
||||
'invalid runner metadata',
|
||||
{},
|
||||
() => fs.writeFileSync(path.join(preparedRunner, 'Info.plist'), 'invalid'),
|
||||
],
|
||||
['no runner executable', {}, () => fs.rmSync(path.join(preparedRunner, 'Runner'))],
|
||||
[
|
||||
'a non-executable runner binary',
|
||||
{},
|
||||
() => fs.chmodSync(path.join(preparedRunner, 'Runner'), 0o644),
|
||||
],
|
||||
['an empty runner binary', {}, () => fs.writeFileSync(path.join(preparedRunner, 'Runner'), '')],
|
||||
[
|
||||
'a directory as runner binary',
|
||||
{},
|
||||
() => {
|
||||
fs.rmSync(path.join(preparedRunner, 'Runner'));
|
||||
fs.mkdirSync(path.join(preparedRunner, 'Runner'));
|
||||
},
|
||||
],
|
||||
[
|
||||
'an executable outside the bundle',
|
||||
{},
|
||||
() =>
|
||||
fs.writeFileSync(
|
||||
path.join(preparedRunner, 'Info.plist'),
|
||||
plist.build({ CFBundleExecutable: '../outside' })
|
||||
),
|
||||
],
|
||||
['an unreadable runner', {}, () => fs.chmodSync(preparedRunner, 0o000)],
|
||||
[
|
||||
'a runner that is a file',
|
||||
{},
|
||||
() => {
|
||||
fs.rmSync(preparedRunner, { recursive: true });
|
||||
// Readable and executable, so only the directory check rejects it.
|
||||
fs.writeFileSync(preparedRunner, '', { mode: 0o755 });
|
||||
},
|
||||
],
|
||||
])('installs as before for %s', async (_name, options, breakHome) => {
|
||||
writePreparedHome();
|
||||
breakHome();
|
||||
const before = vol.toJSON(preparedHome);
|
||||
const versionSpec = 'versionSpec' in options ? options.versionSpec : '^3';
|
||||
const resolveRequestedVersionAsync = jest.fn(async () => {
|
||||
throw new Error('registry unavailable');
|
||||
});
|
||||
const result = await installAppiumAsync({
|
||||
versionSpec,
|
||||
resolveRequestedVersionAsync,
|
||||
driverName: 'driverName' in options ? options.driverName : 'xcuitest',
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
try {
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'npm',
|
||||
['install', '--no-audit', `appium@${versionSpec}`],
|
||||
expect.objectContaining({ cwd: result.appiumHome })
|
||||
);
|
||||
expect(path.basename(result.appiumHome)).toMatch(/^eas-appium-home-/);
|
||||
expect(result.prepared).toBe(false);
|
||||
expect(resolveRequestedVersionAsync).not.toHaveBeenCalled();
|
||||
expect(vol.toJSON(preparedHome)).toEqual(before);
|
||||
} finally {
|
||||
await fs.promises.rm(result.appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a driver list without xcuitest', async () => ({ stdout: '{}' })],
|
||||
['a failed driver list', async () => Promise.reject(new Error('appium exited with 1.'))],
|
||||
])('installs as before after %s in the prepared home', async (_name, list) => {
|
||||
writePreparedHome();
|
||||
jest.mocked(spawn).mockImplementationOnce(list as never);
|
||||
const before = vol.toJSON(preparedHome);
|
||||
const result = await installAppiumAsync({
|
||||
versionSpec: '^3',
|
||||
driverName: 'xcuitest',
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
try {
|
||||
expect(jest.mocked(spawn).mock.calls.slice(0, 2)).toEqual([
|
||||
[preparedBin, driverList, expect.objectContaining({ stdio: 'pipe' })],
|
||||
[
|
||||
'npm',
|
||||
['install', '--no-audit', 'appium@^3'],
|
||||
expect.objectContaining({ cwd: result.appiumHome }),
|
||||
],
|
||||
]);
|
||||
expect(result.prepared).toBe(false);
|
||||
expect(vol.toJSON(preparedHome)).toEqual(before);
|
||||
} finally {
|
||||
await fs.promises.rm(result.appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('kills the prepared-home driver list on a stop and installs nothing', async () => {
|
||||
writePreparedHome();
|
||||
const before = vol.toJSON(preparedHome);
|
||||
const controller = new AbortController();
|
||||
const failure = new Error('session stopped');
|
||||
const child = { pid: 4242, kill: jest.fn() };
|
||||
let finishList!: (error: Error) => void;
|
||||
let listStarted!: () => void;
|
||||
const started = new Promise<void>(resolve => {
|
||||
listStarted = resolve;
|
||||
});
|
||||
jest.mocked(spawn).mockImplementationOnce(() => {
|
||||
listStarted();
|
||||
return Object.assign(
|
||||
new Promise((_resolve, reject) => {
|
||||
finishList = reject;
|
||||
}),
|
||||
{ child }
|
||||
) as never;
|
||||
});
|
||||
const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
|
||||
const running = installAppiumAsync({
|
||||
versionSpec: '^3',
|
||||
driverName: 'xcuitest',
|
||||
env: {},
|
||||
logger,
|
||||
signal: controller.signal,
|
||||
});
|
||||
running.catch(() => {});
|
||||
try {
|
||||
await started;
|
||||
controller.abort(failure);
|
||||
expect(killGroup).toHaveBeenCalledWith(child, 'SIGKILL');
|
||||
finishList(new Error('killed'));
|
||||
await expect(running).rejects.toBe(failure);
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(vol.toJSON(preparedHome)).toEqual(before);
|
||||
} finally {
|
||||
finishList?.(new Error('test cleanup'));
|
||||
killGroup.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('installs Appium with bun add when EAS_OVERRIDE_PACKAGE_MANAGER is bun', async () => {
|
||||
const result = await installAppiumAsync({
|
||||
versionSpec: '3.5.0',
|
||||
@@ -302,6 +595,115 @@ describe('createStartAppiumRemoteSessionBuildFunction', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(
|
||||
[BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX].flatMap(platform =>
|
||||
['^3', '3.x'].map(version => [platform, version] as const)
|
||||
)
|
||||
)(
|
||||
'starts cold Appium on %s with cached installer range %s and no registry lookup',
|
||||
async (runtimePlatform, packageVersion) => {
|
||||
// Android must ignore prepared XCUITest files too.
|
||||
if (runtimePlatform === BuildRuntimePlatform.LINUX) {
|
||||
writePreparedHome();
|
||||
}
|
||||
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (args[0] === 'view') {
|
||||
throw new Error('registry unavailable');
|
||||
}
|
||||
return { stdout: '{}' };
|
||||
}) as never);
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid: 'simulator' }] as never);
|
||||
jest
|
||||
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
|
||||
.mockResolvedValue([{ serialId: 'emulator-5554', state: 'device' }] as never);
|
||||
const launched = new Error('launch captured');
|
||||
jest.mocked(spawnDetached).mockImplementation(() => {
|
||||
throw launched;
|
||||
});
|
||||
const buildFunction = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: packageVersion },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {
|
||||
DEVICE_RUN_SESSION_ID: 'session',
|
||||
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
|
||||
NGROK_AUTHTOKEN: 'token',
|
||||
EAS_OVERRIDE_PACKAGE_MANAGER: 'bun',
|
||||
},
|
||||
} as never
|
||||
)
|
||||
).rejects.toBe(launched);
|
||||
expect(jest.mocked(spawn).mock.calls.some(([, args]) => args[0] === 'view')).toBe(false);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['add', `appium@${packageVersion}`],
|
||||
expect.anything()
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(
|
||||
['latest', '^3', '3.x'].flatMap(requested =>
|
||||
['3.9.0', '3.10.0'].map(latest => [requested, latest])
|
||||
)
|
||||
)('checks %s before using the prepared Appium version (%s)', async (requested, latest) => {
|
||||
writePreparedHome();
|
||||
const before = vol.toJSON(preparedHome);
|
||||
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => ({
|
||||
stdout:
|
||||
args[0] === 'view'
|
||||
? JSON.stringify(latest)
|
||||
: args.includes('--json')
|
||||
? driverListStdout(command)
|
||||
: '',
|
||||
})) as never);
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid: 'simulator' }] as never);
|
||||
const launched = new Error('launch captured');
|
||||
jest.mocked(spawnDetached).mockImplementation(() => {
|
||||
throw launched;
|
||||
});
|
||||
const buildFunction = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: requested },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {
|
||||
DEVICE_RUN_SESSION_ID: 'session',
|
||||
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
|
||||
NGROK_AUTHTOKEN: 'token',
|
||||
},
|
||||
} as never
|
||||
)
|
||||
).rejects.toBe(launched);
|
||||
const installs = jest.mocked(spawn).mock.calls.filter(([, args]) => args[0] === 'install');
|
||||
if (latest === '3.9.0') {
|
||||
expect(installs).toHaveLength(0);
|
||||
expect(jest.mocked(spawnDetached).mock.calls[0][0].env.APPIUM_HOME).toBe(preparedHome);
|
||||
} else {
|
||||
expect(installs).toHaveLength(1);
|
||||
expect(installs[0][1]).toContain('[email protected]');
|
||||
}
|
||||
expect(vol.toJSON(preparedHome)).toEqual(before);
|
||||
});
|
||||
|
||||
it('claims the warm Simulator for Appium without its package version', async () => {
|
||||
jest.mocked(runOnWarmIosSimulatorAsync).mockResolvedValue(undefined);
|
||||
const buildFunction = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext, {
|
||||
@@ -369,8 +771,8 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
|
||||
state: 'Shutdown',
|
||||
} as never);
|
||||
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => ({
|
||||
stdout: args.includes('--json') ? '{}' : '',
|
||||
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => ({
|
||||
stdout: args.includes('--json') ? driverListStdout(command) : '',
|
||||
})) as never);
|
||||
jest
|
||||
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
|
||||
@@ -448,6 +850,26 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
);
|
||||
}
|
||||
|
||||
it('does not fail a session when only the Appium tunnel cannot stop', async () => {
|
||||
const tunnelError = new Error('Ngrok tunnel stop timed out after 4000ms.');
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
stopAppium.mockResolvedValueOnce(undefined);
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValueOnce({
|
||||
url: 'https://appium-abc.tunnel.example.com',
|
||||
subdomainId: 'appium-abc',
|
||||
stopAsync: jest.fn().mockRejectedValue(tunnelError),
|
||||
});
|
||||
|
||||
await expect(runSessionAsync(logger)).resolves.toBeUndefined();
|
||||
|
||||
expect(stopAppium).toHaveBeenCalledTimes(1);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: tunnelError },
|
||||
'Could not stop the Appium tunnel during remote session teardown.'
|
||||
);
|
||||
});
|
||||
|
||||
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');
|
||||
@@ -625,6 +1047,73 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
return stopWarm;
|
||||
}
|
||||
|
||||
it.each(['cancelled lookup', 'AbortError lookup', 'resolved lookup'])(
|
||||
'recovers from failed warm adoption with a %s',
|
||||
async stage => {
|
||||
writePreparedHome();
|
||||
const stopWarm = mockWarmSession();
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
const adoptionError = new WarmSessionAdoptionError();
|
||||
let failHost!: (error: Error) => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failHost = reject;
|
||||
})
|
||||
);
|
||||
let lookups = 0;
|
||||
jest.mocked(spawn).mockImplementation((async (command, args, options) => {
|
||||
if (args[0] !== 'view') {
|
||||
return { stdout: args.includes('--json') ? driverListStdout(command) : '' };
|
||||
}
|
||||
lookups++;
|
||||
if (lookups === 1 && stage !== 'resolved lookup') {
|
||||
return await new Promise((_resolve, reject) => {
|
||||
options!.signal!.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
reject(
|
||||
stage === 'AbortError lookup'
|
||||
? new DOMException('Aborted', 'AbortError')
|
||||
: options!.signal!.reason
|
||||
);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
failHost(adoptionError);
|
||||
});
|
||||
}
|
||||
return { stdout: JSON.stringify('3.9.0') };
|
||||
}) as typeof spawn);
|
||||
if (stage === 'resolved lookup') {
|
||||
const process = jest.mocked(spawnDetached).getMockImplementation()!;
|
||||
jest.mocked(spawnDetached).mockImplementationOnce(options => {
|
||||
failHost(adoptionError);
|
||||
return process(options);
|
||||
});
|
||||
}
|
||||
|
||||
await expect(
|
||||
runSessionAsync(
|
||||
logger,
|
||||
undefined,
|
||||
BuildRuntimePlatform.DARWIN,
|
||||
{
|
||||
boot_simulator: { value: true },
|
||||
device_identifier: { value: 'iPhone 17' },
|
||||
package_version: { value: '^3' },
|
||||
},
|
||||
true
|
||||
)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(lookups).toBe(stage === 'resolved lookup' ? 1 : 2);
|
||||
expect(stopWarm).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(2);
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(stage === 'resolved lookup' ? 2 : 1);
|
||||
expect(fs.existsSync(preparedRunner)).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['event collection', 'Appium process'])(
|
||||
'waits for warm %s teardown before finishing the session host',
|
||||
async stage => {
|
||||
@@ -1102,6 +1591,31 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
);
|
||||
});
|
||||
|
||||
it('a Darwin session uses the prepared Appium home and keeps it', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
writePreparedHome();
|
||||
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockResolvedValue([
|
||||
{
|
||||
udid: 'chosen-ios-udid',
|
||||
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-4',
|
||||
} as never,
|
||||
]);
|
||||
await runSessionAsync(
|
||||
{ info: jest.fn(), warn: jest.fn() },
|
||||
undefined,
|
||||
BuildRuntimePlatform.DARWIN
|
||||
);
|
||||
expect(jest.mocked(spawnDetached).mock.calls[0][0].env.APPIUM_HOME).toBe(preparedHome);
|
||||
expect(preparePreinstalledWdaAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ appiumHome: preparedHome, prepared: true })
|
||||
);
|
||||
expect(jest.mocked(spawn).mock.calls.map(([command, args]) => [command, args])).toEqual([
|
||||
[preparedBin, driverList],
|
||||
]);
|
||||
expect(stopAppium).toHaveBeenCalledTimes(1);
|
||||
expect(fs.existsSync(preparedManifest)).toBe(true);
|
||||
});
|
||||
|
||||
it('starts serve-sim on the selected iOS device while Appium installation is pending', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockClear();
|
||||
|
||||
+51
-29
@@ -322,6 +322,29 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the resolved Argent version after a warm mismatch', async () => {
|
||||
jest
|
||||
.mocked(runOnWarmIosSimulatorAsync)
|
||||
.mockImplementation((_claim, run) => run(undefined, '999.1.0'));
|
||||
await createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext, { warm: true }).fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as never,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: 'latest' },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
expect(spawnDetached).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ args: expect.arrayContaining(['@swmansion/[email protected]']) })
|
||||
);
|
||||
});
|
||||
|
||||
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() };
|
||||
@@ -644,7 +667,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
['succeeded', false],
|
||||
['failed', true],
|
||||
])(
|
||||
'fails after stopping the host and tools when the tools tunnel cannot close and the session %s',
|
||||
'stops the host and tools when the tools tunnel cannot close and the session %s',
|
||||
async (_description, sessionFails) => {
|
||||
const closeError = new Error('ngrok close failed');
|
||||
const sessionError = new Error('session failed');
|
||||
@@ -654,23 +677,26 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
}
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
// The tunnel may still be serving, so its failure fails a session that otherwise succeeded.
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
).rejects.toBe(sessionFails ? sessionError : closeError);
|
||||
// A stopped tunnel's proxy drops every connection, so its failure does not fail a session.
|
||||
const running = buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
if (sessionFails) {
|
||||
await expect(running).rejects.toBe(sessionError);
|
||||
} else {
|
||||
await expect(running).resolves.toBeUndefined();
|
||||
}
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockStopAsync).toHaveBeenCalledTimes(1);
|
||||
@@ -679,17 +705,13 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
{ err: closeError },
|
||||
'Could not stop the Argent tunnel during remote session teardown.'
|
||||
);
|
||||
expect(jest.mocked(Sentry.capture).mock.calls).toEqual(
|
||||
sessionFails
|
||||
? [
|
||||
[
|
||||
'Could not stop the Argent tunnel after the remote session failed',
|
||||
closeError,
|
||||
{ level: 'warning' },
|
||||
],
|
||||
]
|
||||
: []
|
||||
);
|
||||
expect(jest.mocked(Sentry.capture).mock.calls).toEqual([
|
||||
[
|
||||
`Could not stop the Argent tunnel after the remote session ${sessionFails ? 'failed' : 'ended'}`,
|
||||
closeError,
|
||||
{ level: 'warning' },
|
||||
],
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
import { EnvironmentSecretType, SystemError, UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
ArchiveSourceType,
|
||||
EnvironmentSecretType,
|
||||
SystemError,
|
||||
UserError,
|
||||
} from '@expo/eas-build-job';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
import { stripRepositoryCredentialsAsync } from '../../../common/git';
|
||||
import * as sandboxDaemon from '../../utils/sandboxDaemon';
|
||||
import { SandboxWorkerApi } from '../../utils/sandboxWorkerApi';
|
||||
import { createStartSandboxBuildFunction } from '../startSandbox';
|
||||
|
||||
const { AbortError, Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
jest.mock('../../../common/git');
|
||||
|
||||
const checkedOutStepCtx = { logger: {}, global: { wasCheckedOut: () => true } } as any;
|
||||
const notCheckedOutStepCtx = { logger: {}, global: { wasCheckedOut: () => false } } as any;
|
||||
|
||||
const URL_JOB = {
|
||||
projectArchive: { type: ArchiveSourceType.URL, url: 'https://example.com/project.tar.gz' },
|
||||
};
|
||||
|
||||
const context = {
|
||||
job: {
|
||||
...URL_JOB,
|
||||
secrets: {
|
||||
environmentSecrets: [
|
||||
{
|
||||
@@ -183,67 +199,102 @@ describe('sandbox build functions', () => {
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to the legacy token and keeps the Expo token for commands without a worker token', async () => {
|
||||
it('strips the repository credentials before starting the daemon for git sources', async () => {
|
||||
const startupError = new Error('stop after checking options');
|
||||
const start = jest
|
||||
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
|
||||
.mockRejectedValue(startupError);
|
||||
const fn = createStartSandboxBuildFunction({ ...context, job: {} } as any);
|
||||
|
||||
await expect(
|
||||
fn.fn!({ logger: {} } as any, {
|
||||
inputs,
|
||||
outputs: {},
|
||||
env: {
|
||||
PREPARED_BY_EARLIER_STEP: 'value',
|
||||
EXPO_TOKEN: 'expo-token',
|
||||
__EAS_SANDBOX_MCP_TOKEN: 'legacy-token',
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
...context,
|
||||
job: {
|
||||
...context.job,
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash: '0123456789abcdef0123456789abcdef01234567',
|
||||
},
|
||||
})
|
||||
).rejects.toBe(startupError);
|
||||
expect(start.mock.calls[0][0]).toMatchObject({ credential: 'legacy-token' });
|
||||
expect(start.mock.calls[0][0].env).toEqual({
|
||||
PREPARED_BY_EARLIER_STEP: 'value',
|
||||
EXPO_TOKEN: 'expo-token',
|
||||
},
|
||||
projectTargetDirectory: '/workingdir/build',
|
||||
} as any);
|
||||
|
||||
await expect(fn.fn!(checkedOutStepCtx, { inputs, outputs: {}, env: {} })).rejects.toBe(
|
||||
startupError
|
||||
);
|
||||
expect(stripRepositoryCredentialsAsync).toHaveBeenCalledWith({
|
||||
repositoryDirectory: '/workingdir/build',
|
||||
});
|
||||
expect(jest.mocked(stripRepositoryCredentialsAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
start.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('strips the repository credentials from the source directory before eas/checkout', async () => {
|
||||
const startupError = new Error('stop after checking options');
|
||||
jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockRejectedValue(startupError);
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
...context,
|
||||
job: {
|
||||
...context.job,
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
gitRef: null,
|
||||
gitCommitHash: '0123456789abcdef0123456789abcdef01234567',
|
||||
},
|
||||
},
|
||||
projectSourceDirectory: '/workingdir/temporary-custom-build',
|
||||
projectTargetDirectory: '/workingdir/build',
|
||||
} as any);
|
||||
|
||||
await expect(fn.fn!(notCheckedOutStepCtx, { inputs, outputs: {}, env: {} })).rejects.toBe(
|
||||
startupError
|
||||
);
|
||||
expect(stripRepositoryCredentialsAsync).toHaveBeenCalledWith({
|
||||
repositoryDirectory: '/workingdir/temporary-custom-build',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks the sandbox ready over GraphQL without a worker token', async () => {
|
||||
const stopAsync = jest.fn(async () => {});
|
||||
jest
|
||||
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
|
||||
.mockResolvedValue({ ready: Promise.resolve(), stopAsync });
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({ data: { sandbox: { markSandboxReady: { id: 'sandbox-id' } } } }),
|
||||
}));
|
||||
const logger = { info: jest.fn(), child: jest.fn() };
|
||||
logger.child.mockReturnValue(logger);
|
||||
it('does not start the daemon when stripping the repository credentials fails', async () => {
|
||||
const stripError = new SystemError('Failed to strip credentials from the origin remote URL.');
|
||||
jest.mocked(stripRepositoryCredentialsAsync).mockRejectedValueOnce(stripError);
|
||||
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync');
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
...context,
|
||||
job: {},
|
||||
graphqlClient: { mutation },
|
||||
job: {
|
||||
...context.job,
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
gitRef: null,
|
||||
gitCommitHash: '0123456789abcdef0123456789abcdef01234567',
|
||||
},
|
||||
},
|
||||
projectTargetDirectory: '/workingdir/build',
|
||||
} as any);
|
||||
|
||||
await fn.fn!({ logger } as any, {
|
||||
inputs: { ...inputs, max_duration_seconds: { value: 0.0001 } },
|
||||
outputs: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'legacy-token' },
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sandboxId: 'sandbox-id' },
|
||||
undefined
|
||||
await expect(fn.fn!(checkedOutStepCtx, { inputs, outputs: {}, env: {} })).rejects.toBe(
|
||||
stripError
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves the remote alone for non-git sources', async () => {
|
||||
const startupError = new Error('stop after checking options');
|
||||
jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockRejectedValue(startupError);
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
...context,
|
||||
projectTargetDirectory: '/workingdir/build',
|
||||
} as any);
|
||||
|
||||
await expect(fn.fn!({ logger: {} } as any, { inputs, outputs: {}, env: {} })).rejects.toBe(
|
||||
startupError
|
||||
);
|
||||
expect(stripRepositoryCredentialsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
missing: 'a sandbox worker token or a legacy token',
|
||||
missing: 'the sandbox worker token',
|
||||
overrides: { job: {} },
|
||||
message: 'This sandbox job has neither EAS_SANDBOX_WORKER_TOKEN nor __EAS_SANDBOX_MCP_TOKEN.',
|
||||
message: 'This sandbox job has no EAS_SANDBOX_WORKER_TOKEN secret.',
|
||||
},
|
||||
{
|
||||
missing: 'the MCP server URL',
|
||||
|
||||
+36
-20
@@ -224,18 +224,43 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses preview activity to reset the configured idle timeout', async () => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, { max_idle_time_minutes: { value: 5 } });
|
||||
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
|
||||
expect(idleTimeout?.getLastEventObservedAt()).toBeUndefined();
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
|
||||
const observedAt = new Date();
|
||||
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(observedAt);
|
||||
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(observedAt);
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
it('passes the resolved version to cold setup after a warm version mismatch', async () => {
|
||||
jest
|
||||
.mocked(runOnWarmIosSimulatorAsync)
|
||||
.mockImplementation((_claim, run) => run(undefined, '0.14.0'));
|
||||
await createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true }).fn!(
|
||||
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
|
||||
{ inputs: { package_version: { value: 'latest' } }, outputs: {}, env } as never
|
||||
);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ packageVersion: '0.14.0' })
|
||||
);
|
||||
});
|
||||
|
||||
it('lets the session host use the experimental emulator capture', async () => {
|
||||
await runAsync(BuildRuntimePlatform.LINUX);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ emulatorCapture: true })
|
||||
);
|
||||
});
|
||||
|
||||
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
|
||||
'uses preview activity to reset the configured idle timeout on %s',
|
||||
async runtimePlatform => {
|
||||
await runAsync(runtimePlatform, { max_idle_time_minutes: { value: 5 } });
|
||||
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
|
||||
expect(idleTimeout?.getLastEventObservedAt()).toBeUndefined();
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
|
||||
const observedAt = new Date();
|
||||
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(observedAt);
|
||||
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(observedAt);
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
|
||||
'leaves the idle timer disabled when omitted on %s',
|
||||
async runtimePlatform => {
|
||||
@@ -255,16 +280,6 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an Android idle timeout before starting the host', async () => {
|
||||
await expect(
|
||||
runAsync(BuildRuntimePlatform.LINUX, {
|
||||
max_idle_time_minutes: { value: 5 },
|
||||
})
|
||||
).rejects.toThrow('Omit max_idle_time_minutes for Android sessions.');
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
|
||||
'handles Stop while the %s host starts',
|
||||
async runtimePlatform => {
|
||||
@@ -494,6 +509,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
}
|
||||
: {}),
|
||||
packageVersion: '1.2.3',
|
||||
emulatorCapture: true,
|
||||
disableShare: false,
|
||||
frameAncestors: [],
|
||||
corsOrigins: [],
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { SystemError, formatBytes } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
@@ -15,7 +15,6 @@ import stream from 'stream';
|
||||
import { promisify } from 'util';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
|
||||
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { SystemError, UserError, formatBytes } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
@@ -23,7 +23,6 @@ import bplistParser from 'bplist-parser';
|
||||
import plist from 'plist';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import {
|
||||
|
||||
@@ -211,7 +211,9 @@ const MaestroRunnerRecordedFlowSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
sourceFile: z.string().min(1),
|
||||
status: z.enum(['passed', 'failed']),
|
||||
tags: z.array(z.string()).optional(),
|
||||
});
|
||||
type MaestroRunnerRecordedFlow = z.infer<typeof MaestroRunnerRecordedFlowSchema>;
|
||||
const MaestroRunnerReportSchema = z.object({
|
||||
flows: z
|
||||
.array(
|
||||
@@ -379,6 +381,111 @@ export async function parseFailedFlowsFromFileAttrs(args: {
|
||||
return paths;
|
||||
}
|
||||
|
||||
const MAESTRO_RUNNER_ATTEMPT_DIRECTORY_PATTERN = /-maestro-runner-attempt-(\d+)$/;
|
||||
|
||||
export function maestroRunnerAttemptDirectoryName(args: {
|
||||
platform: string;
|
||||
attempt: number;
|
||||
}): string {
|
||||
return `${args.platform}-maestro-runner-attempt-${args.attempt}`;
|
||||
}
|
||||
|
||||
async function listMaestroRunnerAttemptDirectories(
|
||||
testsDirectory: string
|
||||
): Promise<{ attempt: number; reportDirectory: string }[]> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(testsDirectory);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return entries.flatMap(entry => {
|
||||
const match = entry.match(MAESTRO_RUNNER_ATTEMPT_DIRECTORY_PATTERN);
|
||||
return match
|
||||
? [{ attempt: parseInt(match[1], 10), reportDirectory: path.join(testsDirectory, entry) }]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
export async function hasMaestroRunnerAttemptDirectories(testsDirectory: string): Promise<boolean> {
|
||||
return (await listMaestroRunnerAttemptDirectories(testsDirectory)).length > 0;
|
||||
}
|
||||
|
||||
export async function parseMaestroRunnerResults(args: {
|
||||
junitDirectory: string;
|
||||
testsDirectory: string;
|
||||
workingDirectory: string;
|
||||
}): Promise<MaestroFlowResult[]> {
|
||||
const results = await parseMaestroResultsFromFileAttrs(args.junitDirectory);
|
||||
|
||||
const attempts = await listMaestroRunnerAttemptDirectories(args.testsDirectory);
|
||||
const flows = await Promise.all(
|
||||
attempts.map(({ reportDirectory }) =>
|
||||
parseMaestroRunnerFlowsWithPaths({
|
||||
reportDirectory,
|
||||
workingDirectory: args.workingDirectory,
|
||||
})
|
||||
)
|
||||
);
|
||||
const flowsByAttempt = new Map(attempts.map(({ attempt }, index) => [attempt, flows[index]]));
|
||||
const realWorkingDirectory = await resolveRealPath(args.workingDirectory);
|
||||
|
||||
return results.map(result => {
|
||||
const tags = findMaestroRunnerFlowTags({
|
||||
flows: flowsByAttempt.get(result.retryCount) ?? [],
|
||||
junitFlowPath: result.path,
|
||||
workingDirectory: args.workingDirectory,
|
||||
realWorkingDirectory,
|
||||
});
|
||||
return tags ? { ...result, tags } : result;
|
||||
});
|
||||
}
|
||||
|
||||
type MaestroRunnerFlowWithPaths = MaestroRunnerRecordedFlow & {
|
||||
resolvedPath: string;
|
||||
realPath: string;
|
||||
};
|
||||
|
||||
// maestro-runner >= 1.1.18 resolves symlinks in the JUnit path, but not in report.json.
|
||||
async function parseMaestroRunnerFlowsWithPaths(args: {
|
||||
reportDirectory: string;
|
||||
workingDirectory: string;
|
||||
}): Promise<MaestroRunnerFlowWithPaths[]> {
|
||||
const report = await parseMaestroRunnerReport(args.reportDirectory);
|
||||
return await Promise.all(
|
||||
(report?.flows ?? []).map(async flow => {
|
||||
const resolvedPath = path.resolve(args.workingDirectory, flow.sourceFile);
|
||||
return { ...flow, resolvedPath, realPath: await resolveRealPath(resolvedPath) };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveRealPath(filePath: string): Promise<string> {
|
||||
const realPathResult = await asyncResult(fs.realpath(filePath));
|
||||
return realPathResult.ok ? realPathResult.value : filePath;
|
||||
}
|
||||
|
||||
function findMaestroRunnerFlowTags(args: {
|
||||
flows: MaestroRunnerFlowWithPaths[];
|
||||
junitFlowPath: string;
|
||||
workingDirectory: string;
|
||||
realWorkingDirectory: string;
|
||||
}): string[] | undefined {
|
||||
const junitPath = path.resolve(args.workingDirectory, args.junitFlowPath);
|
||||
const junitRealPath = path.resolve(args.realWorkingDirectory, args.junitFlowPath);
|
||||
const samePath = args.flows.find(
|
||||
flow => flow.resolvedPath === junitPath || flow.realPath === junitRealPath
|
||||
);
|
||||
if (samePath) {
|
||||
return samePath.tags;
|
||||
}
|
||||
// maestro-runner <= 1.1.17 writes only the file name to JUnit.
|
||||
const sameFileName = args.flows.filter(
|
||||
flow => path.basename(flow.sourceFile) === args.junitFlowPath
|
||||
);
|
||||
return sameFileName.length === 1 ? sameFileName[0].tags : undefined;
|
||||
}
|
||||
|
||||
export async function parseMaestroResults(
|
||||
junitDirectory: string,
|
||||
nameToPath: Map<string, string> | null
|
||||
|
||||
@@ -18,6 +18,7 @@ import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
|
||||
import {
|
||||
copyLatestAttemptXml,
|
||||
junitFileHasFileAttrs,
|
||||
maestroRunnerAttemptDirectoryName,
|
||||
mergeJUnitReports,
|
||||
parseFailedFlowNamesFromJUnitFile,
|
||||
parseFailedFlowsFromFileAttrs,
|
||||
@@ -353,7 +354,7 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
// maestro-runner writes all report formats and screenshot metadata here.
|
||||
const runnerOutputDirectory = path.join(
|
||||
testsDirectory,
|
||||
`${platform}-maestro-runner-attempt-${attempt}`
|
||||
maestroRunnerAttemptDirectoryName({ platform, attempt })
|
||||
);
|
||||
const outputPath = mayHaveJUnitReports
|
||||
? path.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
|
||||
|
||||
@@ -5,10 +5,12 @@ import { z } from 'zod';
|
||||
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
|
||||
import {
|
||||
MaestroFlowResult,
|
||||
hasMaestroRunnerAttemptDirectories,
|
||||
isFileAttrRun,
|
||||
parseJUnitTestCases,
|
||||
parseMaestroResults,
|
||||
parseMaestroResultsFromFileAttrs,
|
||||
parseMaestroRunnerResults,
|
||||
} from './maestroResultParser';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
|
||||
@@ -73,8 +75,15 @@ export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext):
|
||||
// map testcase names back to paths.
|
||||
const usedFileAttrs = isFileAttrRun(await parseJUnitTestCases(junitDirectory));
|
||||
|
||||
const testsDirectory = (inputs.tests_directory.value as string | undefined) ?? '';
|
||||
let flowResults: MaestroFlowResult[];
|
||||
if (usedFileAttrs) {
|
||||
if (usedFileAttrs && (await hasMaestroRunnerAttemptDirectories(testsDirectory))) {
|
||||
flowResults = await parseMaestroRunnerResults({
|
||||
junitDirectory,
|
||||
testsDirectory,
|
||||
workingDirectory: stepsCtx.workingDirectory,
|
||||
});
|
||||
} else if (usedFileAttrs) {
|
||||
flowResults = await parseMaestroResultsFromFileAttrs(junitDirectory);
|
||||
} else {
|
||||
// Legacy (Maestro < 2.6.0) — DELETE this arm once the fleet is on >= 2.6.0.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { Platform, formatBytes } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
@@ -17,7 +17,6 @@ import * as tar from 'tar';
|
||||
import { promisify } from 'util';
|
||||
import z from 'zod';
|
||||
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { PUBLIC_CACHE_KEY_PREFIX_BY_PLATFORM } from '../../utils/cacheKey';
|
||||
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
|
||||
import { TurtleFetchError, turtleFetch } from '../../utils/turtleFetch';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { Platform, formatBytes } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
@@ -14,7 +14,6 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { compressCacheAsync, uploadCacheAsync } from './saveCache';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import {
|
||||
CcacheBuildTarget,
|
||||
generateDefaultBuildCacheKeyAsync,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { Platform, formatBytes } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
@@ -12,7 +12,6 @@ import * as tar from 'tar';
|
||||
import z from 'zod';
|
||||
|
||||
import { uploadWithSignedUrl } from '../../storage/uploadWithSignedUrl';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
|
||||
import { getCacheVersion } from '../utils/cache';
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import { type StartupTasks } from '../utils/startupTasks';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
DeviceRunSessionStoppedError,
|
||||
TOOL_TUNNEL_TEARDOWN_OPTIONS,
|
||||
finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
@@ -213,6 +214,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
authtoken: ngrokAuthtoken,
|
||||
healthCheck: { path: '/health' },
|
||||
logger: taskLogger,
|
||||
signal: tasks.signal,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
taskLogger.info(`Tunnel is ready at ${agentDeviceTunnel.url}.`);
|
||||
@@ -351,7 +353,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
? [['startup cleanup', Promise.reject(result.reason)]]
|
||||
: []
|
||||
),
|
||||
['agent-device tunnel', agentDeviceTunnel?.stopAsync()],
|
||||
['agent-device tunnel', agentDeviceTunnel?.stopAsync(), TOOL_TUNNEL_TEARDOWN_OPTIONS],
|
||||
['agent-device daemon', toolTeardown],
|
||||
[
|
||||
'session host',
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
|
||||
import { getAndroidEmulatorLogCapture } from '../utils/androidEmulatorLogs';
|
||||
import { parseLogSourcesInput } from '../utils/logSourcesInput';
|
||||
import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi';
|
||||
import { readIosApplicationIdentifierAsync } from '../utils/iosAppArtifact';
|
||||
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
|
||||
@@ -71,6 +73,12 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
name: 'Start agent-device session',
|
||||
__metricsId: warm ? 'eas/start_warm_agent_device_session' : 'eas/start_agent_device_session',
|
||||
inputProviders: [
|
||||
// No default: an omitted input must stay distinguishable from an explicit selection.
|
||||
BuildStepInput.createProvider({
|
||||
id: 'log_sources',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
// Device. On iOS a Simulator name or UDID, on Android an AVD hardware profile.
|
||||
BuildStepInput.createProvider({
|
||||
id: 'device_identifier',
|
||||
@@ -188,6 +196,10 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
} else {
|
||||
validateAndroidLaunchOptions({ launchArgs, openUrl });
|
||||
}
|
||||
const logSources = parseLogSourcesInput(inputs.log_sources.value, {
|
||||
runtimePlatform,
|
||||
fallback: isIos ? [] : ['emulator-output'],
|
||||
});
|
||||
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
|
||||
const capture = parseNetworkCaptureInputs(
|
||||
{
|
||||
@@ -205,7 +217,10 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
logger,
|
||||
startup: { signal: startupSignal, abort: reason => startup.abort(reason) },
|
||||
});
|
||||
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
|
||||
const startSessionAsync = async (
|
||||
warmSession?: WarmIosSession,
|
||||
resolvedPackageVersion = inputs.package_version.value as string | undefined
|
||||
): Promise<void> => {
|
||||
const tasks = createStartupTasks(logger, startupSignal);
|
||||
let booted: Promise<void> | undefined;
|
||||
let sessionOwnsTeardown = false;
|
||||
@@ -253,6 +268,7 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
lcdHeight: inputs.lcd_height.value as number | undefined,
|
||||
lcdDensity: inputs.lcd_density.value as number | undefined,
|
||||
logcatDirectory,
|
||||
logCapture: getAndroidEmulatorLogCapture(global, env, logSources),
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
@@ -338,7 +354,7 @@ export function createStartAgentDeviceSessionBuildFunction(
|
||||
signal,
|
||||
runtimePlatform,
|
||||
sessionEnv,
|
||||
packageVersion: inputs.package_version.value as string | undefined,
|
||||
packageVersion: resolvedPackageVersion,
|
||||
// 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,
|
||||
|
||||
@@ -20,8 +20,17 @@ 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 } from '../../utils/androidEmulatorLogSources';
|
||||
import { getAndroidEmulatorLogCapture } from '../utils/androidEmulatorLogs';
|
||||
import {
|
||||
EMULATOR_CAPTURE_REQUESTED_ENV,
|
||||
installEmulatorCaptureAsync,
|
||||
parseExperimentalEmulatorCaptureVersionInput,
|
||||
} from '../utils/emulatorCapture';
|
||||
import { parseLogSourcesInput } from '../utils/logSourcesInput';
|
||||
|
||||
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 +42,12 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
name: 'Start Android Emulator',
|
||||
__metricsId: 'eas/start_android_emulator',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'log_sources',
|
||||
required: false,
|
||||
defaultValue: [...ALL_ANDROID_EMULATOR_LOG_SOURCES],
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'device_name',
|
||||
required: false,
|
||||
@@ -71,6 +86,12 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
defaultValue: 1,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
// Experimental: the expo-emulator-capture version that the emulator preloads.
|
||||
BuildStepInput.createProvider({
|
||||
id: 'experimental_emulator_capture_version',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
@@ -78,14 +99,41 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
required: true,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { inputs, outputs, env }) => {
|
||||
fn: async ({ logger, global }, { inputs, outputs, env }) => {
|
||||
const count = Number(inputs.count.value ?? 1);
|
||||
const emulatorCaptureVersion = parseExperimentalEmulatorCaptureVersionInput(
|
||||
inputs.experimental_emulator_capture_version.value
|
||||
);
|
||||
if (emulatorCaptureVersion && count > 1) {
|
||||
throw new UserError(
|
||||
'EAS_EMULATOR_CAPTURE_INVALID_INPUT',
|
||||
'experimental_emulator_capture_version supports one emulator. Remove it, or set count to 1.'
|
||||
);
|
||||
}
|
||||
const logSources = parseLogSourcesInput(inputs.log_sources.value, {
|
||||
runtimePlatform: global.runtimePlatform,
|
||||
fallback: ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
});
|
||||
if (emulatorCaptureVersion) {
|
||||
// Marks the request for the session step, which rejects session types that cannot use
|
||||
// the capture. The mark does not depend on the install, so the rejection does not either.
|
||||
global.updateEnv({
|
||||
...global.env,
|
||||
[EMULATOR_CAPTURE_REQUESTED_ENV]: emulatorCaptureVersion,
|
||||
});
|
||||
}
|
||||
const emulatorCaptureLibrary = emulatorCaptureVersion
|
||||
? await installEmulatorCaptureAsync({ version: emulatorCaptureVersion, env, logger })
|
||||
: undefined;
|
||||
|
||||
const logcatDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
|
||||
);
|
||||
const logCapture = getAndroidEmulatorLogCapture(global, env, logSources);
|
||||
outputs.logcat_directory.set(logcatDirectory);
|
||||
|
||||
const deviceName = `${inputs.device_name.value}` as AndroidVirtualDeviceName;
|
||||
const { serialId, emulatorPromise, shouldAdjustAnimationScale } =
|
||||
const { serialId, emulatorPromise, shouldAdjustAnimationScale, emulatorCaptureSocketPath } =
|
||||
await startAndroidEmulatorAsync({
|
||||
deviceName,
|
||||
systemImagePackage: `${inputs.system_image_package.value}`,
|
||||
@@ -95,11 +143,24 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
lcdHeight: inputs.lcd_height.value as number | undefined,
|
||||
lcdDensity: inputs.lcd_density.value as number | undefined,
|
||||
logcatDirectory,
|
||||
logCapture,
|
||||
env,
|
||||
logger,
|
||||
emulatorCaptureLibrary,
|
||||
});
|
||||
if (emulatorCaptureLibrary && emulatorCaptureSocketPath) {
|
||||
// The session step starts expo-device-hub with the job env. A Hub version without the
|
||||
// emulator capture ignores these variables.
|
||||
global.updateEnv({
|
||||
...global.env,
|
||||
EXPO_DEVICE_HUB_EXPERIMENTAL_EMULATOR_CAPTURE: '1',
|
||||
EXPO_DEVICE_HUB_EMULATOR_CAPTURE_LIBRARY: emulatorCaptureLibrary,
|
||||
});
|
||||
logger.info(
|
||||
`${serialId} preloads expo-emulator-capture@${emulatorCaptureVersion} with -gpu lavapipe, and its agent socket is ${emulatorCaptureSocketPath}. Later steps start expo-device-hub with the emulator capture on.`
|
||||
);
|
||||
}
|
||||
|
||||
const count = Number(inputs.count.value ?? 1);
|
||||
if (count > 1) {
|
||||
logger.info(`Requested ${count} emulators, shutting down ${deviceName} for cloning.`);
|
||||
await spawn('adb', ['-s', serialId, 'shell', 'reboot', '-p'], {
|
||||
@@ -133,6 +194,7 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
deviceName: cloneIdentifier,
|
||||
env,
|
||||
logcatDirectory,
|
||||
logCapture,
|
||||
});
|
||||
cloneSerialId = startResult.serialId;
|
||||
|
||||
@@ -202,8 +264,10 @@ export async function startAndroidEmulatorAsync({
|
||||
lcdHeight,
|
||||
lcdDensity,
|
||||
logcatDirectory,
|
||||
logCapture,
|
||||
env,
|
||||
logger,
|
||||
emulatorCaptureLibrary,
|
||||
}: {
|
||||
deviceName: AndroidVirtualDeviceName;
|
||||
systemImagePackage: string;
|
||||
@@ -212,12 +276,17 @@ export async function startAndroidEmulatorAsync({
|
||||
lcdHeight?: number;
|
||||
lcdDensity?: number;
|
||||
logcatDirectory: string;
|
||||
logCapture: AndroidEmulatorLogCapture;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
/** The expo-emulator-capture agent library that the emulator preloads (experimental). */
|
||||
emulatorCaptureLibrary?: string;
|
||||
}): Promise<{
|
||||
serialId: AndroidDeviceSerialId;
|
||||
emulatorPromise: Promise<unknown>;
|
||||
shouldAdjustAnimationScale: boolean;
|
||||
/** The capture agent's socket, when the emulator preloads the agent on the expected serial. */
|
||||
emulatorCaptureSocketPath?: string;
|
||||
}> {
|
||||
if (env.EAS_NO_EMULATOR_HOST_SUPPORT_CHECK !== '1') {
|
||||
await assertAndroidEmulatorHostSupportAsync({ env });
|
||||
@@ -265,8 +334,16 @@ export async function startAndroidEmulatorAsync({
|
||||
}
|
||||
);
|
||||
|
||||
// expo-device-hub gets the job env and finds the agent sockets in `<tmpdir>/expo-emulator-capture`,
|
||||
// where Node's os.tmpdir() applies this rule to that env. On EAS, TMPDIR is not set.
|
||||
const captureSocketDirectory = path.join(
|
||||
env.TMPDIR || env.TMP || env.TEMP || '/tmp',
|
||||
'expo-emulator-capture'
|
||||
);
|
||||
let emulatorPromise = null;
|
||||
let serialId = null;
|
||||
// Set by the attempt that starts; the cast keeps the type wide after the retry callback.
|
||||
let captureLaunch = undefined as { consolePort: number; socketPath: string } | undefined;
|
||||
await retryAsync(
|
||||
async attemptCount => {
|
||||
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
|
||||
@@ -289,11 +366,37 @@ export async function startAndroidEmulatorAsync({
|
||||
});
|
||||
|
||||
logger.info(`Starting emulator device${attemptSuffix}.`);
|
||||
// expo-device-hub finds the capture agent's socket by the serial, so the console port,
|
||||
// which sets the serial, is chosen before the start, again for each attempt.
|
||||
const consolePort = emulatorCaptureLibrary
|
||||
? await AndroidEmulatorUtils.findFreeConsolePortAsync()
|
||||
: undefined;
|
||||
const launch =
|
||||
consolePort !== undefined
|
||||
? {
|
||||
consolePort,
|
||||
socketPath: path.join(captureSocketDirectory, `emulator-${consolePort}.sock`),
|
||||
}
|
||||
: undefined;
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env,
|
||||
logcatDirectory,
|
||||
logCapture,
|
||||
...(localEgress ? { launchGate: localEgress.launchGate } : {}),
|
||||
...(emulatorCaptureLibrary && launch
|
||||
? {
|
||||
consolePort: launch.consolePort,
|
||||
// The Linux agent kept 60 fps with the lavapipe renderer.
|
||||
extraArgs: ['-gpu', 'lavapipe'],
|
||||
extraEnv: {
|
||||
// A library that the job already preloads stays preloaded, after the agent.
|
||||
LD_PRELOAD: [emulatorCaptureLibrary, env.LD_PRELOAD].filter(Boolean).join(':'),
|
||||
// The agent creates the directory.
|
||||
EXPO_EMULATOR_CAPTURE_SOCKET: launch.socketPath,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
attemptSerialId = startResult.serialId;
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
@@ -315,6 +418,7 @@ export async function startAndroidEmulatorAsync({
|
||||
|
||||
serialId = attemptSerialId;
|
||||
emulatorPromise = startResult.emulatorPromise;
|
||||
captureLaunch = launch;
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `${deviceName} failed to start on attempt ${attempt}/${maxAttempts}.`);
|
||||
try {
|
||||
@@ -348,7 +452,19 @@ export async function startAndroidEmulatorAsync({
|
||||
if (!serialId || !emulatorPromise) {
|
||||
throw new Error(`Failed to start emulator ${deviceName}.`);
|
||||
}
|
||||
return { serialId, emulatorPromise, shouldAdjustAnimationScale };
|
||||
// The Hub looks for the socket by serial. $ANDROID_EMULATOR_EXTRA_ARGS can override -port.
|
||||
if (captureLaunch && serialId !== `emulator-${captureLaunch.consolePort}`) {
|
||||
logger.warn(
|
||||
`${serialId} does not use console port ${captureLaunch.consolePort}, so expo-device-hub cannot find its capture agent socket ${captureLaunch.socketPath}. The session streams without the emulator capture. Remove -port from $ANDROID_EMULATOR_EXTRA_ARGS.`
|
||||
);
|
||||
captureLaunch = undefined;
|
||||
}
|
||||
return {
|
||||
serialId,
|
||||
emulatorPromise,
|
||||
shouldAdjustAnimationScale,
|
||||
emulatorCaptureSocketPath: captureLaunch?.socketPath,
|
||||
};
|
||||
}
|
||||
|
||||
async function assertAndroidEmulatorHostSupportAsync({
|
||||
|
||||
@@ -14,6 +14,8 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import semver from 'semver';
|
||||
import bplistParser from 'bplist-parser';
|
||||
import plist from 'plist';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
@@ -43,11 +45,14 @@ import {
|
||||
import { killProcessGroup } from '../../utils/processes';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { resolveSimulatorPackageVersionAsync } from '../../utils/simulatorPackageVersion';
|
||||
import { readPreparedSimulatorToolAsync } from '../../utils/warmSimulator';
|
||||
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
|
||||
import { preparePreinstalledWdaAsync } from '../utils/preinstalledWda';
|
||||
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
|
||||
import {
|
||||
DeviceRunSessionStoppedError,
|
||||
TOOL_TUNNEL_TEARDOWN_OPTIONS,
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createIosSessionStartupInputProviders,
|
||||
@@ -79,6 +84,12 @@ const AppiumInstalledDriversSchema = z.record(
|
||||
z.string(),
|
||||
z.object({ installed: z.boolean().optional() }).passthrough()
|
||||
);
|
||||
const PreparedAppiumSchema = z.object({
|
||||
version: z.string(),
|
||||
xcuitest: z.string(),
|
||||
home: z.string(),
|
||||
});
|
||||
const PackageVersionSchema = z.object({ version: z.string() });
|
||||
|
||||
export function createStartAppiumRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext,
|
||||
@@ -127,6 +138,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
|
||||
});
|
||||
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
|
||||
let requestedVersion: string | undefined;
|
||||
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
|
||||
const startupAbortController = new AbortController();
|
||||
const startupSignal = AbortSignal.any([watchSignal, startupAbortController.signal]);
|
||||
@@ -149,7 +161,21 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
const versionSpec = resolveAppium3VersionSpec(packageVersion);
|
||||
const resolveRequestedVersionAsync =
|
||||
packageVersion === undefined
|
||||
? undefined
|
||||
: async (): Promise<string> =>
|
||||
(requestedVersion ??= await resolveSimulatorPackageVersionAsync(
|
||||
'appium',
|
||||
packageVersion,
|
||||
{ env, signal: startupSignal }
|
||||
));
|
||||
// Keep cold ranges on the configured installer's path. Tags need resolution to enforce Appium 3.
|
||||
const versionSpec = resolveAppium3VersionSpec(
|
||||
packageVersion !== undefined && !semver.validRange(packageVersion)
|
||||
? await resolveRequestedVersionAsync!()
|
||||
: packageVersion
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
@@ -211,6 +237,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
asyncResult(
|
||||
installAppiumAsync({
|
||||
versionSpec,
|
||||
resolveRequestedVersionAsync,
|
||||
driverName: device.driverName,
|
||||
env,
|
||||
logger,
|
||||
@@ -231,7 +258,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
),
|
||||
]);
|
||||
const installation = appiumInstall.enforceValue();
|
||||
appiumHome = installation.appiumHome;
|
||||
// A prepared home is shared across sessions; remove only a job-owned home after a session.
|
||||
appiumHome = installation.prepared ? undefined : installation.appiumHome;
|
||||
startupSignal?.throwIfAborted();
|
||||
const { appiumBinPath, appiumEnv } = installation;
|
||||
const wdaCapabilities = await preparePreinstalledWdaAsync({
|
||||
@@ -243,7 +271,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
})
|
||||
)?.runtime
|
||||
: device.runtimeIdentifier,
|
||||
appiumHome,
|
||||
appiumHome: installation.appiumHome,
|
||||
prepared: installation.prepared,
|
||||
appiumBinPath,
|
||||
appiumEnv,
|
||||
logger,
|
||||
@@ -300,6 +329,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
authtoken: ngrokAuthtoken,
|
||||
healthCheck: { path: '/status' },
|
||||
logger,
|
||||
signal: startupSignal,
|
||||
});
|
||||
|
||||
// The preview host uses the same device selected for Appium.
|
||||
@@ -387,7 +417,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
logger,
|
||||
sessionFailed,
|
||||
teardown: [
|
||||
['Appium tunnel', appiumTunnel?.stopAsync()],
|
||||
['Appium tunnel', appiumTunnel?.stopAsync(), TOOL_TUNNEL_TEARDOWN_OPTIONS],
|
||||
['Appium server', appiumTeardown],
|
||||
[
|
||||
'session host',
|
||||
@@ -418,7 +448,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
watchSignal.throwIfAborted();
|
||||
if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await runOnWarmIosSimulatorAsync(
|
||||
// package_version is the Appium version, which the job installs either way.
|
||||
// package_version is the Appium version. The job installs it or uses the prepared
|
||||
// home; the warm claim does not depend on it.
|
||||
{
|
||||
engine: 'appium',
|
||||
requestedDevice: deviceIdentifier,
|
||||
@@ -503,6 +534,7 @@ export async function resolveAppiumDeviceAsync({
|
||||
|
||||
export async function installAppiumAsync({
|
||||
versionSpec,
|
||||
resolveRequestedVersionAsync,
|
||||
driverName,
|
||||
env,
|
||||
logger,
|
||||
@@ -510,13 +542,53 @@ export async function installAppiumAsync({
|
||||
onError,
|
||||
}: {
|
||||
versionSpec: string;
|
||||
resolveRequestedVersionAsync?: () => Promise<string>;
|
||||
driverName: AppiumDevice['driverName'];
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
onError?: (error: unknown) => void;
|
||||
}): Promise<{ appiumHome: string; appiumBinPath: string; appiumEnv: BuildStepEnv }> {
|
||||
}): Promise<{
|
||||
appiumHome: string;
|
||||
appiumBinPath: string;
|
||||
appiumEnv: BuildStepEnv;
|
||||
prepared: boolean;
|
||||
}> {
|
||||
signal?.throwIfAborted();
|
||||
let prepared =
|
||||
driverName === 'xcuitest'
|
||||
? await readPreparedSimulatorToolAsync('appium', PreparedAppiumSchema)
|
||||
: undefined;
|
||||
if (
|
||||
prepared &&
|
||||
!(await isPreparedAppiumCompatibleAsync(prepared, { versionSpec, env, signal }))
|
||||
) {
|
||||
prepared = undefined;
|
||||
}
|
||||
if (prepared && resolveRequestedVersionAsync) {
|
||||
versionSpec = resolveAppium3VersionSpec(await resolveRequestedVersionAsync());
|
||||
if (!semver.satisfies(prepared.version, versionSpec)) {
|
||||
prepared = undefined;
|
||||
}
|
||||
}
|
||||
if (prepared) {
|
||||
try {
|
||||
// An invalid package manager override fails as it does without a prepared home.
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM);
|
||||
} catch (error) {
|
||||
onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
logger.info(
|
||||
`Using the prefetched Appium home (appium ${prepared.version}, xcuitest ${prepared.xcuitest}).`
|
||||
);
|
||||
return {
|
||||
appiumHome: prepared.home,
|
||||
appiumBinPath: path.join(prepared.home, 'node_modules', '.bin', 'appium'),
|
||||
appiumEnv: { ...env, APPIUM_HOME: prepared.home },
|
||||
prepared: true,
|
||||
};
|
||||
}
|
||||
const appiumHome = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-appium-home-'));
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
@@ -569,7 +641,7 @@ export async function installAppiumAsync({
|
||||
signal
|
||||
);
|
||||
}
|
||||
return { appiumHome, appiumBinPath, appiumEnv };
|
||||
return { appiumHome, appiumBinPath, appiumEnv, prepared: false };
|
||||
} catch (error) {
|
||||
onError?.(error);
|
||||
if (signal) {
|
||||
@@ -580,6 +652,67 @@ export async function installAppiumAsync({
|
||||
}
|
||||
}
|
||||
|
||||
/** A metadata hit still needs Appium-specific version, driver and runner checks before reuse. */
|
||||
async function isPreparedAppiumCompatibleAsync(
|
||||
prepared: z.infer<typeof PreparedAppiumSchema>,
|
||||
{ versionSpec, env, signal }: { versionSpec: string; env: BuildStepEnv; signal?: AbortSignal }
|
||||
): Promise<boolean> {
|
||||
const { home } = prepared;
|
||||
if (home !== path.join(os.homedir(), 'Library', 'Caches', 'expo-simulator-startup', 'appium')) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const installedVersionAsync = async (name: string): Promise<string> =>
|
||||
PackageVersionSchema.parse(
|
||||
JSON.parse(
|
||||
await fs.promises.readFile(path.join(home, 'node_modules', name, 'package.json'), 'utf8')
|
||||
)
|
||||
).version;
|
||||
if (
|
||||
!semver.satisfies(prepared.version, versionSpec) ||
|
||||
(await installedVersionAsync('appium')) !== prepared.version ||
|
||||
(await installedVersionAsync('appium-xcuitest-driver')) !== prepared.xcuitest
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const runner = path.join(home, 'preinstalled-wda', 'WebDriverAgentRunner-Runner.app');
|
||||
await fs.promises.access(runner, fs.constants.R_OK | fs.constants.X_OK);
|
||||
if (!(await fs.promises.stat(runner)).isDirectory()) {
|
||||
return false;
|
||||
}
|
||||
const metadata = await fs.promises.readFile(path.join(runner, 'Info.plist'));
|
||||
const info =
|
||||
metadata.subarray(0, 8).toString('ascii') === 'bplist00'
|
||||
? bplistParser.parseBuffer(metadata)[0]
|
||||
: plist.parse(metadata.toString('utf8'));
|
||||
const executable = z
|
||||
.object({ CFBundleExecutable: z.string().min(1) })
|
||||
.parse(info).CFBundleExecutable;
|
||||
if (path.basename(executable) !== executable || executable === '.' || executable === '..') {
|
||||
return false;
|
||||
}
|
||||
const executablePath = path.join(runner, executable);
|
||||
await fs.promises.access(executablePath, fs.constants.R_OK | fs.constants.X_OK);
|
||||
const executableStat = await fs.promises.stat(executablePath);
|
||||
if (!executableStat.isFile() || executableStat.size === 0) {
|
||||
return false;
|
||||
}
|
||||
// Today's driver list shows that the Appium CLI runs from this home. It does not load the driver.
|
||||
const { stdout } = await runAppiumSetupCommandAsync(
|
||||
path.join(home, 'node_modules', '.bin', 'appium'),
|
||||
['driver', 'list', '--installed', '--json'],
|
||||
{ env: { ...env, APPIUM_HOME: home }, stdio: 'pipe' },
|
||||
signal
|
||||
);
|
||||
if (!AppiumInstalledDriversSchema.parse(JSON.parse(stdout)).xcuitest?.installed) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runAppiumSetupCommandAsync(
|
||||
command: string,
|
||||
args: string[],
|
||||
|
||||
@@ -44,6 +44,7 @@ import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../u
|
||||
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
|
||||
import {
|
||||
DeviceRunSessionStoppedError,
|
||||
TOOL_TUNNEL_TEARDOWN_OPTIONS,
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createIosSessionStartupInputProviders,
|
||||
@@ -137,13 +138,6 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
validateControllerVersion({
|
||||
packageName: ARGENT_PACKAGE_NAME,
|
||||
minimumVersion: MIN_ARGENT_REMOTE_SESSION_VERSION,
|
||||
packageVersion,
|
||||
logger,
|
||||
});
|
||||
const versionSpec = packageVersion ?? 'latest';
|
||||
const { runtimePlatform } = global;
|
||||
const startupStop = new AbortController();
|
||||
const watchSignal = signal
|
||||
@@ -156,7 +150,17 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
|
||||
});
|
||||
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
|
||||
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
|
||||
const startSessionAsync = async (
|
||||
warmSession?: WarmIosSession,
|
||||
resolvedPackageVersion = packageVersion
|
||||
): Promise<void> => {
|
||||
const versionSpec = resolvedPackageVersion ?? 'latest';
|
||||
validateControllerVersion({
|
||||
packageName: ARGENT_PACKAGE_NAME,
|
||||
minimumVersion: MIN_ARGENT_REMOTE_SESSION_VERSION,
|
||||
packageVersion: resolvedPackageVersion,
|
||||
logger,
|
||||
});
|
||||
const startupAbortController = new AbortController();
|
||||
const startupSignal = AbortSignal.any([watchSignal, startupAbortController.signal]);
|
||||
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
|
||||
@@ -383,6 +387,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
rewriteHostHeader: true,
|
||||
healthCheck: { path: '/tools', statuses: [200, 401] },
|
||||
logger,
|
||||
signal: startupSignal,
|
||||
});
|
||||
const publicToolsUrl = toolsTunnel.url;
|
||||
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
|
||||
@@ -478,7 +483,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
logger,
|
||||
sessionFailed,
|
||||
teardown: [
|
||||
['Argent tunnel', toolsTunnel?.stopAsync()],
|
||||
['Argent tunnel', toolsTunnel?.stopAsync(), TOOL_TUNNEL_TEARDOWN_OPTIONS],
|
||||
['Argent tool-server', toolTeardown],
|
||||
[
|
||||
'session host',
|
||||
|
||||
@@ -170,6 +170,7 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
logger,
|
||||
signal: startupSignal,
|
||||
}),
|
||||
startupSignal,
|
||||
async listener => await listener?.stopAsync(),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { ArchiveSourceType, SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
|
||||
import { stripRepositoryCredentialsAsync } from '../../common/git';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { withLogPhaseAsync } from '../../utils/logPhase';
|
||||
import { getLegacySandboxConnection } from '../utils/legacySandboxApi';
|
||||
import { startSandboxDaemonAsync } from '../utils/sandboxDaemon';
|
||||
import { getSandboxWorkerApi } from '../utils/sandboxWorkerApi';
|
||||
import { requireSandboxWorkerApi } from '../utils/sandboxWorkerApi';
|
||||
|
||||
const RECONNECT_DELAY_MS = 1_000;
|
||||
const AWAIT_SANDBOX_STEP_NAME = 'Await sandbox completion';
|
||||
@@ -29,18 +29,7 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, signal, env }) => {
|
||||
const workerApi = getSandboxWorkerApi(ctx);
|
||||
const { sandboxApi, credential, commandEnv } = workerApi
|
||||
? {
|
||||
sandboxApi: workerApi,
|
||||
credential: workerApi.token,
|
||||
commandEnv: env,
|
||||
}
|
||||
: getLegacySandboxConnection({
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
env,
|
||||
sandboxId: String(inputs.sandbox_id.value),
|
||||
});
|
||||
const workerApi = requireSandboxWorkerApi(ctx);
|
||||
const mcpServerUrl = ctx.mcpServerUrl;
|
||||
if (!mcpServerUrl) {
|
||||
throw new SystemError('MCP server URL is required to start the sandbox daemon.');
|
||||
@@ -57,20 +46,29 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
`Invalid sandbox maximum duration: ${String(maxDurationSeconds)}. Provide a finite number greater than zero, or omit max_duration_seconds.`
|
||||
);
|
||||
}
|
||||
if (ctx.job.projectArchive.type === ArchiveSourceType.GIT) {
|
||||
// Scripts and commands inside the sandbox could read the token from .git/config.
|
||||
await stripRepositoryCredentialsAsync({
|
||||
repositoryDirectory: stepCtx.global.wasCheckedOut()
|
||||
? ctx.projectTargetDirectory
|
||||
: ctx.projectSourceDirectory,
|
||||
});
|
||||
}
|
||||
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential,
|
||||
credential: workerApi.token,
|
||||
serverUrl: mcpServerUrl,
|
||||
reconnectDelayMs: RECONNECT_DELAY_MS,
|
||||
logger: stepCtx.logger,
|
||||
signal,
|
||||
workingDirectory: stepCtx.workingDirectory,
|
||||
env: commandEnv,
|
||||
workerApi: sandboxApi,
|
||||
env,
|
||||
workerApi,
|
||||
});
|
||||
try {
|
||||
await daemon.ready;
|
||||
signal?.throwIfAborted();
|
||||
await sandboxApi.markReadyAsync({ signal }).catch((error: unknown) => {
|
||||
await workerApi.markReadyAsync({ signal }).catch((error: unknown) => {
|
||||
signal?.throwIfAborted();
|
||||
throw new SystemError(
|
||||
`Failed to mark sandbox ${String(inputs.sandbox_id.value)} as ready.`,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
@@ -87,16 +86,6 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
const packageVersion = inputs.package_version?.value as string | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
if (
|
||||
maxIdleTimeMinutes !== undefined &&
|
||||
maxIdleTimeMinutes > 0 &&
|
||||
runtimePlatform !== BuildRuntimePlatform.DARWIN
|
||||
) {
|
||||
throw new UserError(
|
||||
'UNSUPPORTED_WEB_PREVIEW_IDLE_TIMEOUT',
|
||||
'Web preview idle timeouts require iOS preview activity reporting. Omit max_idle_time_minutes for Android sessions.'
|
||||
);
|
||||
}
|
||||
const startupStop = new AbortController();
|
||||
const watchSignal = signal
|
||||
? AbortSignal.any([signal, startupStop.signal])
|
||||
@@ -108,7 +97,10 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
|
||||
});
|
||||
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
|
||||
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
|
||||
const startSessionAsync = async (
|
||||
warmSession?: WarmIosSession,
|
||||
resolvedPackageVersion = packageVersion
|
||||
): Promise<void> => {
|
||||
const startupAbortController = new AbortController();
|
||||
const startupSignal = AbortSignal.any([watchSignal, startupAbortController.signal]);
|
||||
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
|
||||
@@ -148,7 +140,8 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken },
|
||||
signal: startupSignal,
|
||||
onStartupError: error => startupAbortController.abort(error),
|
||||
packageVersion,
|
||||
packageVersion: resolvedPackageVersion,
|
||||
emulatorCapture: true,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
|
||||
@@ -217,8 +217,12 @@ describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads a screenshot display rotation as artifact metadata', async () => {
|
||||
const data = Buffer.from('png-data');
|
||||
it('uploads the screenshot size, source and display rotation as artifact metadata', async () => {
|
||||
const data = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aF9sAAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
);
|
||||
const logger = createLoggerMock();
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
|
||||
@@ -232,7 +236,7 @@ describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
daemonUrl: 'http://127.0.0.1:1234',
|
||||
daemonToken: 'daemon-token',
|
||||
logger: createLoggerMock(),
|
||||
logger,
|
||||
artifact: {
|
||||
id: 'artifact-id',
|
||||
artifactType: 'screenshot',
|
||||
@@ -245,12 +249,48 @@ describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
kind: 'screenshot',
|
||||
metadata: { displayRotation: 'landscape-right' },
|
||||
metadata: {
|
||||
source: 'agent-device',
|
||||
displayRotation: 'landscape-right',
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads a screenshot without its size and warns when its header cannot be read', async () => {
|
||||
const data = Buffer.from('not-an-image');
|
||||
const logger = createLoggerMock();
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
await readStreamAsync(openStream());
|
||||
});
|
||||
|
||||
await uploadAgentDeviceArtifactAsync(ctx, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
daemonUrl: 'http://127.0.0.1:1234',
|
||||
daemonToken: 'daemon-token',
|
||||
logger,
|
||||
artifact: { id: 'artifact-id', artifactType: 'screenshot', filename: 'shot.png' },
|
||||
});
|
||||
|
||||
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ kind: 'screenshot', metadata: { source: 'agent-device' } })
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ file: expect.stringMatching(/shot\.png$/) }),
|
||||
expect.stringContaining('without its width and height')
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads no metadata when the artifact has no display rotation', async () => {
|
||||
it('uploads no metadata for a non-screenshot artifact without a display rotation', async () => {
|
||||
const data = Buffer.from('artifact-data');
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
|
||||
@@ -266,7 +306,7 @@ describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
daemonUrl: 'http://127.0.0.1:1234',
|
||||
daemonToken: 'daemon-token',
|
||||
logger: createLoggerMock(),
|
||||
artifact: { id: 'artifact-id', artifactType: 'screenshot', filename: 'shot.png' },
|
||||
artifact: { id: 'artifact-id', artifactType: 'screen-recording', filename: 'session.mp4' },
|
||||
});
|
||||
|
||||
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls[0][1]).not.toHaveProperty(
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
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 {
|
||||
ALL_ANDROID_EMULATOR_LOG_SOURCES,
|
||||
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 sessionEnv = { DEVICE_RUN_SESSION_ID: 'session' };
|
||||
const upload = jest.mocked(uploadDeviceRunSessionArtifactAsync);
|
||||
let directory: string;
|
||||
let emulatorPath: string;
|
||||
let logcatPath: string;
|
||||
let emulatorRing: string;
|
||||
let logcatRing: 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');
|
||||
emulatorRing = path.join(directory, 'emulator-output.log');
|
||||
logcatRing = path.join(directory, 'device-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 });
|
||||
});
|
||||
|
||||
/** `==== <launch> <ISO time> ====\n` precedes the first output of a launch. */
|
||||
const MARKER = String.raw`==== \S+ \S+ ====\n`;
|
||||
|
||||
async function captureLaunch(
|
||||
filePath: string,
|
||||
source: AndroidEmulatorLogSource,
|
||||
text: string,
|
||||
workflow = global
|
||||
): Promise<void> {
|
||||
const capture = getAndroidEmulatorLogCapture(
|
||||
workflow,
|
||||
sessionEnv,
|
||||
ALL_ANDROID_EMULATOR_LOG_SOURCES
|
||||
);
|
||||
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: {}, file: 'device-attempt-1-emulator.log', expectedBytes: 20 * 1024 * 1024 + 1 },
|
||||
{ env: sessionEnv, file: 'emulator-output.log', expectedBytes: 20 * 1024 * 1024 },
|
||||
])(
|
||||
'retains $expectedBytes in $file with workflow environment $env',
|
||||
async ({ env, file, expectedBytes }) => {
|
||||
const capture = getAndroidEmulatorLogCapture(global, env, ALL_ANDROID_EMULATOR_LOG_SOURCES);
|
||||
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(path.join(directory, file))).size).toBe(expectedBytes);
|
||||
}
|
||||
);
|
||||
|
||||
it('uploads one artifact per source and ignores files outside the capture inventory', async () => {
|
||||
await captureLaunch(emulatorPath, 'emulator-output', 'host output');
|
||||
await captureLaunch(logcatPath, 'device-logcat', 'Android output');
|
||||
await captureLaunch(
|
||||
path.join(directory, 'retry-emulator.log'),
|
||||
'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.keys()]).toEqual(['emulator-output.log', 'device-logcat.log']);
|
||||
expect(uploadedContents.get('emulator-output.log')).toMatch(
|
||||
new RegExp(`^${MARKER}host output\\n${MARKER}retry output$`)
|
||||
);
|
||||
expect(uploadedContents.get('device-logcat.log')).toMatch(
|
||||
new RegExp(`^${MARKER}Android output$`)
|
||||
);
|
||||
expect(upload).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
filename: 'emulator-output.log',
|
||||
kind: 'simulator-log',
|
||||
metadata: expect.objectContaining({
|
||||
platform: 'android',
|
||||
source: 'emulator-output',
|
||||
scope: 'emulator-process',
|
||||
originalSizeBytes: uploadedContents.get('emulator-output.log')?.length,
|
||||
omittedBytes: 0,
|
||||
truncated: false,
|
||||
}),
|
||||
signal: expect.any(AbortSignal),
|
||||
attempts: 2,
|
||||
})
|
||||
);
|
||||
expect(upload).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
filename: 'device-logcat.log',
|
||||
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,
|
||||
sessionEnv,
|
||||
ALL_ANDROID_EMULATOR_LOG_SOURCES
|
||||
);
|
||||
capture.attach(input, emulatorPath, 'emulator-output');
|
||||
input.write('before');
|
||||
const contents: string[] = [];
|
||||
upload.mockImplementationOnce(async (_ctx, { size, openStream, metadata }) => {
|
||||
expect(metadata).toEqual(
|
||||
expect.objectContaining({ originalSizeBytes: size, 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).toHaveLength(2);
|
||||
expect(contents[0]).toMatch(new RegExp(`^${MARKER}before$`));
|
||||
expect(contents[1]).toBe(contents[0]);
|
||||
expect(await readFile(emulatorRing, 'utf8')).toBe(contents[0]);
|
||||
expect(await readdir(directory)).toEqual(['emulator-output.log']);
|
||||
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(logcatRing)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
} finally {
|
||||
input.end();
|
||||
}
|
||||
});
|
||||
|
||||
it('continues with the other log after a failed upload and never repeats the collection', async () => {
|
||||
await captureLaunch(emulatorPath, 'emulator-output', 'host output');
|
||||
await captureLaunch(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('device-logcat.log')).toMatch(/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 captureLaunch(emulatorPath, 'emulator-output', 'host output');
|
||||
await captureLaunch(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('device-logcat.log')).toMatch(/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 () => {
|
||||
const otherWorkflow = {} as BuildStepGlobalContext;
|
||||
await captureLaunch(emulatorPath, 'emulator-output', 'other workflow', otherWorkflow);
|
||||
await collect();
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
await getAndroidEmulatorLogCapture(otherWorkflow, sessionEnv, []).finishAsync();
|
||||
await captureLaunch(path.join(directory, 'missing-emulator.log'), 'emulator-output', 'removed');
|
||||
await rm(emulatorRing);
|
||||
await expect(collect()).resolves.toBeUndefined();
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.stringContaining('emulator-output.log')
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads the tail of a truncated log with the retained size and omission metadata', async () => {
|
||||
const capture = getAndroidEmulatorLogCapture(
|
||||
global,
|
||||
sessionEnv,
|
||||
ALL_ANDROID_EMULATOR_LOG_SOURCES
|
||||
);
|
||||
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();
|
||||
const uploaded = uploadedContents.get('emulator-output.log');
|
||||
expect(uploaded).toHaveLength(MAX_ANDROID_LOG_BYTES_PER_SOURCE);
|
||||
expect(uploaded).not.toContain('head');
|
||||
expect(uploaded).not.toContain('====');
|
||||
const [, call] = upload.mock.calls[0];
|
||||
const { originalSizeBytes } = call.metadata as { originalSizeBytes: number };
|
||||
const omitted = originalSizeBytes - MAX_ANDROID_LOG_BYTES_PER_SOURCE;
|
||||
expect(omitted).toBeGreaterThanOrEqual(6);
|
||||
expect(call).toEqual(
|
||||
expect.objectContaining({
|
||||
size: MAX_ANDROID_LOG_BYTES_PER_SOURCE,
|
||||
name: expect.stringContaining('truncated, last 20.0 MB retained'),
|
||||
metadata: expect.objectContaining({ omittedBytes: omitted, truncated: true }),
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -155,17 +155,18 @@ describe(uploadArgentArtifactAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function uploadAsync(artifact: {
|
||||
id: string;
|
||||
kind?: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
isDirectory?: boolean;
|
||||
}): Promise<CustomBuildContext> {
|
||||
async function uploadAsync(
|
||||
artifact: {
|
||||
id: string;
|
||||
kind?: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
isDirectory?: boolean;
|
||||
},
|
||||
data = Buffer.from('artifact-data')
|
||||
): Promise<CustomBuildContext> {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(new Response(Readable.from([Buffer.from('artifact-data')])));
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { openStream }) => {
|
||||
@@ -200,6 +201,45 @@ describe(uploadArgentArtifactAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ kind: undefined, expectedKind: 'screenshot' },
|
||||
{ kind: 'screenshot-diff', expectedKind: 'screenshot-diff' },
|
||||
])('sends the size of a $expectedKind read from its header', async ({ kind, expectedKind }) => {
|
||||
const png = Buffer.alloc(33);
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(png);
|
||||
png.writeUInt32BE(13, 8);
|
||||
png.write('IHDR', 12, 'latin1');
|
||||
png.writeUInt32BE(1179, 16);
|
||||
png.writeUInt32BE(2556, 20);
|
||||
const ctx = await uploadAsync(
|
||||
{ id: 'artifact-id', kind, filename: 'screen.png', mimeType: 'image/png' },
|
||||
png
|
||||
);
|
||||
|
||||
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
kind: expectedKind,
|
||||
metadata: { source: 'argent', width: 1179, height: 2556 },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('sends no screenshot metadata for other artifacts', async () => {
|
||||
await uploadAsync({ id: 'artifact-id', filename: 'session.mp4', mimeType: 'video/mp4' });
|
||||
await uploadAsync({
|
||||
id: 'artifact-id',
|
||||
kind: 'screenshot',
|
||||
filename: 'screenshots',
|
||||
mimeType: 'image/png',
|
||||
isDirectory: true,
|
||||
});
|
||||
|
||||
for (const [, input] of jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls) {
|
||||
expect(input).not.toHaveProperty('metadata');
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the semantic kind reported by Argent', async () => {
|
||||
const ctx = await uploadAsync({
|
||||
id: 'artifact-id',
|
||||
|
||||
@@ -45,6 +45,17 @@ function createCtx(): CustomBuildContext {
|
||||
} as unknown as CustomBuildContext;
|
||||
}
|
||||
|
||||
function webpPreview(width: number, height: number): Buffer {
|
||||
const image = Buffer.alloc(30);
|
||||
image.write('RIFF', 0, 'latin1');
|
||||
image.writeUInt32LE(22, 4);
|
||||
image.write('WEBPVP8X', 8, 'latin1');
|
||||
image.writeUInt32LE(10, 16);
|
||||
image.writeUIntLE(width - 1, 24, 3);
|
||||
image.writeUIntLE(height - 1, 27, 3);
|
||||
return image;
|
||||
}
|
||||
|
||||
function uploadSessionResponse(): InstanceType<typeof Response> {
|
||||
return new Response(JSON.stringify({ data: { uploadSession } }), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -226,6 +237,89 @@ describe('session preview upload session from the worker API', () => {
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sends no metadata when the preview size cannot be read', async () => {
|
||||
start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(turtleFetch).toHaveBeenCalledWith(
|
||||
PREVIEW_UPLOAD_SESSION_URL,
|
||||
'POST',
|
||||
expect.not.objectContaining({ json: expect.anything() })
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sends the preview size and refreshes it only when the size changes', async () => {
|
||||
const captureAsync = jest
|
||||
.fn<Promise<Buffer>, [AbortSignal]>()
|
||||
.mockResolvedValueOnce(webpPreview(148, 320))
|
||||
.mockResolvedValueOnce(webpPreview(148, 320))
|
||||
.mockResolvedValue(webpPreview(320, 148));
|
||||
preview = startDeviceRunSessionPreview({
|
||||
ctx: createCtx(),
|
||||
deviceRunSessionId: 'session-id',
|
||||
captureAsync,
|
||||
logger,
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(120_000);
|
||||
expect(captureAsync).toHaveBeenCalledTimes(3);
|
||||
expect(fetch).toHaveBeenCalledTimes(3);
|
||||
expect(jest.mocked(turtleFetch).mock.calls.map(([, , options]) => options.json)).toEqual([
|
||||
{ metadata: { source: 'preview', width: 148, height: 320 } },
|
||||
{ metadata: { source: 'preview', width: 320, height: 148 } },
|
||||
]);
|
||||
expect(jest.mocked(turtleFetch).mock.invocationCallOrder[1]).toBeGreaterThan(
|
||||
jest.mocked(fetch).mock.invocationCallOrder[2]
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the stored size when the upload of a resized preview fails', async () => {
|
||||
const captureAsync = jest
|
||||
.fn<Promise<Buffer>, [AbortSignal]>()
|
||||
.mockResolvedValueOnce(webpPreview(148, 320))
|
||||
.mockResolvedValue(webpPreview(320, 148));
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(new Response('', { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response('', { status: 503 }));
|
||||
preview = startDeviceRunSessionPreview({
|
||||
ctx: createCtx(),
|
||||
deviceRunSessionId: 'session-id',
|
||||
captureAsync,
|
||||
logger,
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
await preview.stopAsync();
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(jest.mocked(turtleFetch).mock.calls.map(([, , options]) => options.json)).toEqual([
|
||||
{ metadata: { source: 'preview', width: 148, height: 320 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('sends the size again after a failed size refresh', async () => {
|
||||
const captureAsync = jest
|
||||
.fn<Promise<Buffer>, [AbortSignal]>()
|
||||
.mockResolvedValueOnce(webpPreview(148, 320))
|
||||
.mockResolvedValueOnce(webpPreview(320, 148))
|
||||
.mockResolvedValue(webpPreview(148, 320));
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockImplementationOnce(async () => uploadSessionResponse())
|
||||
.mockRejectedValueOnce(new Error('response lost'));
|
||||
preview = startDeviceRunSessionPreview({
|
||||
ctx: createCtx(),
|
||||
deviceRunSessionId: 'session-id',
|
||||
captureAsync,
|
||||
logger,
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(120_000);
|
||||
expect(jest.mocked(turtleFetch).mock.calls.map(([, , options]) => options.json)).toEqual([
|
||||
{ metadata: { source: 'preview', width: 148, height: 320 } },
|
||||
{ metadata: { source: 'preview', width: 320, height: 148 } },
|
||||
{ metadata: { source: 'preview', width: 148, height: 320 } },
|
||||
]);
|
||||
expect(fetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('logs a failure and uploads nothing when the job has no device run session token', async () => {
|
||||
start({
|
||||
env: {},
|
||||
@@ -315,10 +409,8 @@ describe(captureDeviceRunSessionPreviewAsync, () => {
|
||||
}) as ReturnType<typeof spawn>;
|
||||
}
|
||||
return (async () => {
|
||||
if (command === 'xcrun') {
|
||||
expect(args.slice(0, 4)).toEqual(['simctl', 'io', 'selected-device', 'screenshot']);
|
||||
await writeFile(args[4], Uint8Array.from(png));
|
||||
} else if (command === 'sips') {
|
||||
expect(command).not.toBe('xcrun');
|
||||
if (command === 'sips') {
|
||||
expect(args.slice(0, 2)).toEqual(['-Z', '320']);
|
||||
expect(await readFile(args[2])).toEqual(png);
|
||||
await writeFile(args[args.indexOf('--out') + 1], Uint8Array.from(resized));
|
||||
@@ -340,6 +432,10 @@ describe(captureDeviceRunSessionPreviewAsync, () => {
|
||||
await captureDeviceRunSessionPreviewAsync({
|
||||
runtimePlatform,
|
||||
device,
|
||||
captureIosScreenshotAsync: async (file, signal) => {
|
||||
signal.throwIfAborted();
|
||||
await writeFile(file, Uint8Array.from(png));
|
||||
},
|
||||
env: {},
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
@@ -372,6 +468,9 @@ describe(captureDeviceRunSessionPreviewAsync, () => {
|
||||
captureDeviceRunSessionPreviewAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
device: 'device',
|
||||
captureIosScreenshotAsync: async () => {
|
||||
throw new Error('capture failed');
|
||||
},
|
||||
env: {},
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
createScreenshotMetadataAsync,
|
||||
readImageSize,
|
||||
} from '../deviceRunSessionScreenshotMetadata';
|
||||
|
||||
function png(width: number, height: number): Buffer {
|
||||
const header = Buffer.alloc(33);
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(header);
|
||||
header.writeUInt32BE(13, 8);
|
||||
header.write('IHDR', 12, 'latin1');
|
||||
header.writeUInt32BE(width, 16);
|
||||
header.writeUInt32BE(height, 20);
|
||||
return header;
|
||||
}
|
||||
|
||||
function webp(chunk: string, data: Buffer): Buffer {
|
||||
const header = Buffer.alloc(20);
|
||||
header.write('RIFF', 0, 'latin1');
|
||||
header.writeUInt32LE(12 + data.length, 4);
|
||||
header.write('WEBP', 8, 'latin1');
|
||||
header.write(chunk, 12, 'latin1');
|
||||
header.writeUInt32LE(data.length, 16);
|
||||
return Buffer.concat([header, data]);
|
||||
}
|
||||
|
||||
function vp8(width: number, height: number): Buffer {
|
||||
const data = Buffer.alloc(10);
|
||||
Buffer.from([0x9d, 0x01, 0x2a]).copy(data, 3);
|
||||
data.writeUInt16LE(width, 6);
|
||||
data.writeUInt16LE(height, 8);
|
||||
return webp('VP8 ', data);
|
||||
}
|
||||
|
||||
function vp8l(width: number, height: number): Buffer {
|
||||
const data = Buffer.alloc(10);
|
||||
data[0] = 0x2f;
|
||||
data.writeUInt32LE((width - 1) | ((height - 1) << 14), 1);
|
||||
return webp('VP8L', data);
|
||||
}
|
||||
|
||||
function vp8x(width: number, height: number): Buffer {
|
||||
const data = Buffer.alloc(10);
|
||||
data.writeUIntLE(width - 1, 4, 3);
|
||||
data.writeUIntLE(height - 1, 7, 3);
|
||||
return webp('VP8X', data);
|
||||
}
|
||||
|
||||
function jpegSegment(marker: number, payload: Buffer): Buffer {
|
||||
const header = Buffer.from([0xff, marker, 0, 0]);
|
||||
header.writeUInt16BE(payload.length + 2, 2);
|
||||
return Buffer.concat([header, payload]);
|
||||
}
|
||||
|
||||
function jpegFrame(marker: number, width: number, height: number, components = 3): Buffer {
|
||||
const payload = Buffer.alloc(6 + 3 * components);
|
||||
payload[0] = 8;
|
||||
payload.writeUInt16BE(height, 1);
|
||||
payload.writeUInt16BE(width, 3);
|
||||
payload[5] = components;
|
||||
return jpegSegment(marker, payload);
|
||||
}
|
||||
|
||||
const JPEG_START = Buffer.from([0xff, 0xd8]);
|
||||
|
||||
describe(readImageSize, () => {
|
||||
it('reads the PNG IHDR size', () => {
|
||||
expect(readImageSize(png(1179, 2556))).toEqual({ width: 1179, height: 2556 });
|
||||
});
|
||||
|
||||
it('reads the lossy WebP frame size without its scale bits', () => {
|
||||
expect(readImageSize(vp8(0x4000 | 1179, 0xc000 | 2556))).toEqual({
|
||||
width: 1179,
|
||||
height: 2556,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the lossless WebP size', () => {
|
||||
expect(readImageSize(vp8l(1179, 2556))).toEqual({ width: 1179, height: 2556 });
|
||||
});
|
||||
|
||||
it('reads the size of a minimal lossless WebP shorter than other WebP headers', () => {
|
||||
const minimalLossless = Buffer.from(
|
||||
'5249464614000000574542505650384c080000002f0fc00310888808',
|
||||
'hex'
|
||||
);
|
||||
expect(readImageSize(minimalLossless)).toEqual({ width: 16, height: 16 });
|
||||
});
|
||||
|
||||
it('reads the extended WebP canvas size', () => {
|
||||
expect(readImageSize(vp8x(1179, 2556))).toEqual({ width: 1179, height: 2556 });
|
||||
});
|
||||
|
||||
it('reads the JPEG frame size after other segments', () => {
|
||||
const jpeg = Buffer.concat([
|
||||
JPEG_START,
|
||||
jpegSegment(0xe1, Buffer.alloc(300)),
|
||||
Buffer.from([0xff]),
|
||||
jpegSegment(0xc4, Buffer.alloc(20)),
|
||||
jpegFrame(0xc2, 1179, 2556),
|
||||
]);
|
||||
expect(readImageSize(jpeg)).toEqual({ width: 1179, height: 2556 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an empty buffer', Buffer.alloc(0)],
|
||||
['a truncated PNG', png(1179, 2556).subarray(0, 20)],
|
||||
['a PNG without IHDR first', Buffer.concat([png(1, 1).subarray(0, 12), Buffer.alloc(21)])],
|
||||
['a zero-width PNG', png(0, 2556)],
|
||||
['a truncated WebP', vp8x(1179, 2556).subarray(0, 26)],
|
||||
['a WebP with an unknown chunk', webp('ALPH', Buffer.alloc(10))],
|
||||
['a lossy WebP without a start code', webp('VP8 ', Buffer.alloc(10))],
|
||||
[
|
||||
'a JPEG truncated in its frame header',
|
||||
Buffer.concat([JPEG_START, jpegFrame(0xc0, 1179, 2556)]).subarray(0, 8),
|
||||
],
|
||||
[
|
||||
'a JPEG truncated before its frame',
|
||||
Buffer.concat([JPEG_START, jpegSegment(0xe1, Buffer.alloc(300))]),
|
||||
],
|
||||
[
|
||||
'a JPEG with scan data before a frame',
|
||||
Buffer.concat([JPEG_START, jpegSegment(0xda, Buffer.alloc(10)), jpegFrame(0xc0, 1, 1)]),
|
||||
],
|
||||
[
|
||||
'a JPEG frame without components',
|
||||
Buffer.concat([JPEG_START, jpegFrame(0xc0, 1179, 2556, 0)]),
|
||||
],
|
||||
['a JPEG frame shorter than its header', Buffer.from('ffd8ffc0000208049b09fc', 'hex')],
|
||||
['a JPEG frame longer than the read bytes', Buffer.from('ffd8ffc0ffff08049b09fc', 'hex')],
|
||||
[
|
||||
'a JPEG frame whose length does not match its components',
|
||||
Buffer.concat([
|
||||
JPEG_START,
|
||||
jpegSegment(
|
||||
0xc0,
|
||||
Buffer.concat([Buffer.from([8, 0x09, 0xfc, 0x04, 0x9b, 3]), Buffer.alloc(6)])
|
||||
),
|
||||
]),
|
||||
],
|
||||
['a GIF', Buffer.from('GIF89a\x01\x00\x01\x00\x00\x00\x00', 'latin1')],
|
||||
['a log file', Buffer.from('2026-11-10T12:00:00Z app started\n'.repeat(4))],
|
||||
])('returns undefined for %s', (_description, header) => {
|
||||
expect(readImageSize(header)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe(createScreenshotMetadataAsync, () => {
|
||||
let directory: string;
|
||||
let logger: bunyan;
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(path.join(os.tmpdir(), 'screenshot-metadata-test-'));
|
||||
logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('adds the image size to the screenshot metadata', async () => {
|
||||
const file = path.join(directory, 'shot.png');
|
||||
await writeFile(file, Buffer.concat([png(1179, 2556), Buffer.alloc(128 * 1024)]));
|
||||
await expect(
|
||||
createScreenshotMetadataAsync(file, {
|
||||
source: 'agent-device',
|
||||
displayRotation: 'landscape-left',
|
||||
logger,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
source: 'agent-device',
|
||||
displayRotation: 'landscape-left',
|
||||
width: 1179,
|
||||
height: 2556,
|
||||
});
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns and leaves out the size when the header is not a supported image', async () => {
|
||||
const file = path.join(directory, 'shot.png');
|
||||
await writeFile(file, 'not-an-image');
|
||||
await expect(
|
||||
createScreenshotMetadataAsync(file, { source: 'manual', logger })
|
||||
).resolves.toEqual({ source: 'manual' });
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ file },
|
||||
'Uploading the screenshot without its width and height because it does not start with a complete PNG, WebP or JPEG header.'
|
||||
);
|
||||
});
|
||||
|
||||
it('warns and leaves out the size when the file cannot be read', async () => {
|
||||
const file = path.join(directory, 'missing.png');
|
||||
await expect(
|
||||
createScreenshotMetadataAsync(file, { source: 'manual', logger })
|
||||
).resolves.toEqual({ source: 'manual' });
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.objectContaining({ code: 'ENOENT' }), file },
|
||||
'Uploading the screenshot without its width and height because its header could not be read.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -8,12 +8,14 @@ import path from 'node:path';
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import { createScreenshotMetadataAsync } from '../deviceRunSessionScreenshotMetadata';
|
||||
import {
|
||||
startDeviceRunSessionScreenshotsAsync,
|
||||
uploadDeviceRunSessionScreenshotsAsync,
|
||||
} from '../deviceRunSessionScreenshots';
|
||||
|
||||
jest.mock('../deviceRunSessionArtifacts');
|
||||
jest.mock('../deviceRunSessionScreenshotMetadata');
|
||||
jest.mock('../../../sentry');
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
@@ -27,6 +29,9 @@ beforeEach(async () => {
|
||||
directory = await mkdtemp(path.join(os.tmpdir(), 'screenshots-test-'));
|
||||
uploaded = [];
|
||||
failedUploads = new Map();
|
||||
jest
|
||||
.mocked(createScreenshotMetadataAsync)
|
||||
.mockResolvedValue({ source: 'manual', width: 1179, height: 2556 });
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementation(async (_ctx, { openStream }) => {
|
||||
@@ -68,9 +73,13 @@ it('uploads completed captures with their bytes and stable IDs, ignoring partial
|
||||
filename: 'screenshot-2026-09-24T08-45-59-123Z.png',
|
||||
kind: 'screenshot',
|
||||
size: 11,
|
||||
metadata: { __eas_type: 'screenshot' },
|
||||
metadata: { __eas_type: 'screenshot', source: 'manual', width: 1179, height: 2556 },
|
||||
})
|
||||
);
|
||||
expect(createScreenshotMetadataAsync).toHaveBeenCalledWith(path.join(directory, filename), {
|
||||
source: 'manual',
|
||||
logger,
|
||||
});
|
||||
expect(await readdir(directory)).not.toContain(filename);
|
||||
expect((await lstat(path.join(directory, link))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
@@ -152,6 +152,9 @@ it('publishes a manual preview PNG through the worker API and HTTP upload path',
|
||||
platform: 'ios',
|
||||
buildId: 'installed-build',
|
||||
gitCommitHash: installedCommit,
|
||||
source: 'manual',
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
});
|
||||
await expect(stat(path.join(directory, filename))).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -176,6 +176,18 @@ describe(DeviceRunSessionWorkerApi, () => {
|
||||
data: { uploadSession: { url: 'https://uploads.test/preview', headers: {} } },
|
||||
expected: { url: 'https://uploads.test/preview', headers: {} },
|
||||
},
|
||||
{
|
||||
name: 'createPreviewUploadSessionAsync with metadata',
|
||||
call: (api: DeviceRunSessionWorkerApi) =>
|
||||
api.createPreviewUploadSessionAsync({
|
||||
metadata: { source: 'preview', width: 148, height: 320 },
|
||||
}),
|
||||
method: 'POST',
|
||||
route: 'preview-upload-session',
|
||||
json: { metadata: { source: 'preview', width: 148, height: 320 } },
|
||||
data: { uploadSession: { url: 'https://uploads.test/preview', headers: {} } },
|
||||
expected: { url: 'https://uploads.test/preview', headers: {} },
|
||||
},
|
||||
{
|
||||
name: 'fetchTurnIceServersAsync',
|
||||
call: (api: DeviceRunSessionWorkerApi) => api.fetchTurnIceServersAsync(),
|
||||
|
||||
@@ -9,6 +9,7 @@ import path from 'node:path';
|
||||
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils';
|
||||
import { verifyLocalEgressGuardAsync } from '../localEgressGuard';
|
||||
import { type ServeSimApplicationOptions } from '../remoteDeviceRunSession';
|
||||
|
||||
@@ -39,9 +40,14 @@ import {
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
} from '../remoteDeviceRunSession';
|
||||
import { runServeSimActionAsync, stageServeSimAppAsync } from '../serveSimActions';
|
||||
import { startServeSimActivityPolling } from '../serveSimActivity';
|
||||
import {
|
||||
runServeSimActionAsync,
|
||||
runServeSimActionWithResultAsync,
|
||||
stageServeSimAppAsync,
|
||||
} from '../serveSimActions';
|
||||
import { startPreviewActivityPolling } from '../previewActivity';
|
||||
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
|
||||
import * as previewCapture from '../serveSimPreviewCapture';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
@@ -53,7 +59,7 @@ jest.mock('../serveSimActions');
|
||||
jest.mock('../iosAppArtifact', () => ({
|
||||
readIosApplicationIdentifierAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../serveSimActivity');
|
||||
jest.mock('../previewActivity');
|
||||
jest.mock('../localEgress', () => ({
|
||||
...jest.requireActual('../localEgress'),
|
||||
readLocalEgressHandoffAsync: jest.fn().mockResolvedValue(null),
|
||||
@@ -164,6 +170,12 @@ beforeEach(() => {
|
||||
.mockImplementation(async ({ artifactPath }) => installedBundleIds[artifactPath]);
|
||||
jest.mocked(verifyLocalEgressGuardAsync).mockReset().mockResolvedValue(undefined);
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockReset().mockResolvedValue(null);
|
||||
jest
|
||||
.mocked(runServeSimActionWithResultAsync)
|
||||
.mockReset()
|
||||
.mockResolvedValue(
|
||||
JSON.stringify({ 'screenshot.capture': { properties: { automation: { type: 'boolean' } } } })
|
||||
);
|
||||
stopSessionPreview.mockResolvedValue(undefined);
|
||||
jest.mocked(startDeviceRunSessionPreview).mockReturnValue({ stopAsync: stopSessionPreview });
|
||||
jest.mocked(ensureFfmpegInstalledOnceAsync).mockResolvedValue(undefined);
|
||||
@@ -632,7 +644,7 @@ it('starts the readiness budget after refusals that exceed the normal startup de
|
||||
it('reports preview activity from serve-sim on iOS and stops polling when finished', async () => {
|
||||
const observedAt = new Date('2026-10-19T12:00:00Z');
|
||||
const stopActivity = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(startServeSimActivityPolling).mockReturnValue({
|
||||
jest.mocked(startPreviewActivityPolling).mockReturnValue({
|
||||
getLastActivityObservedAt: () => observedAt,
|
||||
stopAsync: stopActivity,
|
||||
});
|
||||
@@ -649,7 +661,8 @@ it('reports preview activity from serve-sim on iOS and stops polling when finish
|
||||
]
|
||||
);
|
||||
|
||||
expect(startServeSimActivityPolling).toHaveBeenCalledWith({
|
||||
expect(startPreviewActivityPolling).toHaveBeenCalledWith({
|
||||
server: 'serve-sim',
|
||||
port,
|
||||
token: 'preview-token',
|
||||
logger: expect.anything(),
|
||||
@@ -676,15 +689,33 @@ it('does not start polling preview activity when startup is cancelled', async ()
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(startServeSimActivityPolling).not.toHaveBeenCalled();
|
||||
expect(startPreviewActivityPolling).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports no preview activity on Android, where expo-device-hub keeps no event log', async () => {
|
||||
it('reports preview activity from expo-device-hub on Android and stops polling when finished', async () => {
|
||||
const observedAt = new Date('2026-11-13T12:00:00Z');
|
||||
const stopActivity = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(startPreviewActivityPolling).mockReturnValue({
|
||||
getLastActivityObservedAt: () => observedAt,
|
||||
stopAsync: stopActivity,
|
||||
});
|
||||
const host = await startHostAsync();
|
||||
const port = Number(
|
||||
jest.mocked(spawnDetached).mock.calls[0][0].args[
|
||||
jest.mocked(spawnDetached).mock.calls[0][0].args.indexOf('--port') + 1
|
||||
]
|
||||
);
|
||||
|
||||
expect(startPreviewActivityPolling).toHaveBeenCalledWith({
|
||||
server: 'expo-device-hub',
|
||||
port,
|
||||
token: expect.any(String),
|
||||
logger: expect.anything(),
|
||||
});
|
||||
expect(host.getLastPreviewActivityAt()).toBe(observedAt);
|
||||
|
||||
expect(startServeSimActivityPolling).not.toHaveBeenCalled();
|
||||
expect(host.getLastPreviewActivityAt()).toBeUndefined();
|
||||
await host.finishAsync();
|
||||
expect(stopActivity).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops a host that became ready on a different Simulator', async () => {
|
||||
@@ -844,6 +875,73 @@ it('drains an in-flight tunnel when finishing and rejects new opens', async () =
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not retry a rejected preview tunnel open after finishing', async () => {
|
||||
const host = await startHostAsync();
|
||||
await (await host.openPreviewAsync()).closeAsync();
|
||||
const forwardCalls = jest.mocked(ngrok.forward).mock.calls.length;
|
||||
const tunnel = deferred<Awaited<ReturnType<typeof ngrok.forward>>>();
|
||||
jest.mocked(ngrok.forward).mockReturnValueOnce(tunnel.promise);
|
||||
const opening = host.openPreviewAsync();
|
||||
const finishing = host.finishAsync();
|
||||
tunnel.reject(
|
||||
new Error('failed to start listener: internal server error error_code: ERR_NGROK_802')
|
||||
);
|
||||
await expect(opening).rejects.toThrow('ERR_NGROK_802');
|
||||
await finishing;
|
||||
expect(jest.mocked(ngrok.forward).mock.calls.length).toBe(forwardCalls + 1);
|
||||
expect(logger.warn).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.stringContaining('; retrying.')
|
||||
);
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not retry a rejected preview tunnel open after startup fails', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['setImmediate'] });
|
||||
const forwardCalls = jest.mocked(ngrok.forward).mock.calls.length;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockRejectedValueOnce(
|
||||
new Error('failed to start listener: internal server error error_code: ERR_NGROK_802')
|
||||
);
|
||||
const startGate = deferred<void>();
|
||||
const startupError = new Error('startup failure');
|
||||
const startAsync = jest.fn(async () => {
|
||||
await startGate.promise;
|
||||
throw startupError;
|
||||
});
|
||||
const outcome = startDeviceSessionHostAsync(
|
||||
ctx,
|
||||
{
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
preview: { baseDomain, authtoken },
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 30_000,
|
||||
},
|
||||
{
|
||||
port: 3200,
|
||||
turnArgs: Promise.resolve([]),
|
||||
requiresPreviewRetirement: true,
|
||||
startAsync,
|
||||
stopAsync: stopServer,
|
||||
}
|
||||
).catch(error => error);
|
||||
const retrying = () =>
|
||||
jest
|
||||
.mocked(logger.warn)
|
||||
.mock.calls.some(([, message]) => String(message).endsWith('; retrying.'));
|
||||
for (let i = 0; i < 50 && !retrying(); i++) {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
expect(retrying()).toBe(true);
|
||||
startGate.resolve();
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
expect(await outcome).toBe(startupError);
|
||||
expect(jest.mocked(ngrok.forward).mock.calls.length).toBe(forwardCalls + 1);
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps cleanup and upload best-effort when tunnel close and finalization fail', async () => {
|
||||
jest.mocked(spawnDetached).mockImplementationOnce(options => {
|
||||
directories.push(options.args[options.args.indexOf('--android-recording-directory') + 1]);
|
||||
@@ -1115,6 +1213,75 @@ it.each([
|
||||
await host.finishAsync();
|
||||
});
|
||||
|
||||
it('keeps the experimental emulator capture off in expo-device-hub by default', async () => {
|
||||
const host = await startHostAsync();
|
||||
const options = jest.mocked(spawnDetached).mock.calls[0][0];
|
||||
expect(options.env.EXPO_DEVICE_HUB_EXPERIMENTAL_EMULATOR_CAPTURE).toBe('0');
|
||||
await host.finishAsync();
|
||||
});
|
||||
|
||||
it('rejects an emulator capture request in a session type without the capture', async () => {
|
||||
await expect(
|
||||
startDeviceSessionHostAsync(ctx, {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: { ...env, EAS_EXPERIMENTAL_EMULATOR_CAPTURE_VERSION: '0.1.0' },
|
||||
logger,
|
||||
separateLogPhase: true,
|
||||
timeoutMs: 10_000,
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'The experimental emulator capture ([email protected]) works only in web preview sessions for now.'
|
||||
);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes the emulator capture that the emulator start turned on to expo-device-hub', async () => {
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {
|
||||
...env,
|
||||
EXPO_DEVICE_HUB_EXPERIMENTAL_EMULATOR_CAPTURE: '1',
|
||||
EXPO_DEVICE_HUB_EMULATOR_CAPTURE_LIBRARY: '/tmp/package/libexpo_emulator_capture.so',
|
||||
EAS_EXPERIMENTAL_EMULATOR_CAPTURE_VERSION: '0.1.0',
|
||||
},
|
||||
logger,
|
||||
separateLogPhase: true,
|
||||
timeoutMs: 10_000,
|
||||
emulatorCapture: true,
|
||||
});
|
||||
const options = jest.mocked(spawnDetached).mock.calls[0][0];
|
||||
expect(options.env).toMatchObject({
|
||||
EXPO_DEVICE_HUB_EXPERIMENTAL_EMULATOR_CAPTURE: '1',
|
||||
EXPO_DEVICE_HUB_EMULATOR_CAPTURE_LIBRARY: '/tmp/package/libexpo_emulator_capture.so',
|
||||
});
|
||||
// The env turns the capture on, because an expo-device-hub without it rejects unknown flags.
|
||||
expect(options.args).not.toContain('--experimental-emulator-capture');
|
||||
await host.finishAsync();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the turtle-v2 copy by default', undefined, 'https://storage.googleapis.com/turtle-v2/scrcpy'],
|
||||
['the step env value when it is set', 'https://mirror.test/scrcpy', 'https://mirror.test/scrcpy'],
|
||||
])(
|
||||
'points expo-device-hub at %s for the scrcpy server',
|
||||
async (_description, override, expected) => {
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: override ? { ...env, SERVE_EMU_SCRCPY_BASE_URL: override } : env,
|
||||
logger,
|
||||
separateLogPhase: true,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const options = jest.mocked(spawnDetached).mock.calls[0][0];
|
||||
expect(options.env.SERVE_EMU_SCRCPY_BASE_URL).toBe(expected);
|
||||
await host.finishAsync();
|
||||
}
|
||||
);
|
||||
|
||||
it('lets the session add frame ancestors and CORS origins after the website origins', async () => {
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
preview: { baseDomain, authtoken },
|
||||
@@ -1162,6 +1329,7 @@ it('leaves iOS recording to its existing build steps', async () => {
|
||||
const options = jest.mocked(spawnDetached).mock.calls[0][0];
|
||||
expect(options.args).not.toContain('--android-recording-directory');
|
||||
expect(options.env.EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN).toBeUndefined();
|
||||
expect(options.env.EXPO_DEVICE_HUB_EXPERIMENTAL_EMULATOR_CAPTURE).toBeUndefined();
|
||||
await host.finishAsync();
|
||||
expect(jest.mocked(turtleFetch).mock.calls.some(([, method]) => method === 'POST')).toBe(false);
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
|
||||
@@ -1478,6 +1646,119 @@ it.each([BuildRuntimePlatform.LINUX, BuildRuntimePlatform.DARWIN])(
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['missing automation', 'invalid catalog', 'unavailable catalog'])(
|
||||
'skips automated thumbnails with %s without stopping the iOS session',
|
||||
async failure => {
|
||||
if (failure === 'unavailable catalog') {
|
||||
jest
|
||||
.mocked(runServeSimActionWithResultAsync)
|
||||
.mockRejectedValueOnce(new Error('unknown action'));
|
||||
} else {
|
||||
jest
|
||||
.mocked(runServeSimActionWithResultAsync)
|
||||
.mockResolvedValueOnce(
|
||||
failure === 'invalid catalog'
|
||||
? 'invalid JSON'
|
||||
: JSON.stringify({ 'screenshot.capture': { properties: {} } })
|
||||
);
|
||||
}
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
preview: { baseDomain, authtoken },
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(runServeSimActionWithResultAsync).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceRunSessionPreview).not.toHaveBeenCalled();
|
||||
expect(ensureMacosPreviewEncoderInstalledAsync).not.toHaveBeenCalled();
|
||||
expect(stopServer).not.toHaveBeenCalled();
|
||||
await host.finishAsync();
|
||||
}
|
||||
);
|
||||
|
||||
it('cancels catalog inspection during shutdown without sending a thumbnail capture', async () => {
|
||||
let inspectionSignal: AbortSignal | undefined;
|
||||
jest.mocked(runServeSimActionWithResultAsync).mockImplementationOnce(async ({ signal }) => {
|
||||
inspectionSignal = signal;
|
||||
return await new Promise<string>((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
});
|
||||
});
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
preview: { baseDomain, authtoken },
|
||||
});
|
||||
expect(inspectionSignal?.aborted).toBe(false);
|
||||
await host.finishAsync();
|
||||
expect(inspectionSignal?.aborted).toBe(true);
|
||||
expect(startDeviceRunSessionPreview).not.toHaveBeenCalled();
|
||||
expect(ensureMacosPreviewEncoderInstalledAsync).not.toHaveBeenCalled();
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('finishes manual screenshots even when thumbnail cleanup fails', async () => {
|
||||
const finishScreenshots = jest.fn().mockResolvedValue(undefined);
|
||||
const collector = jest
|
||||
.spyOn(screenshotCollector, 'startDeviceRunSessionScreenshotsAsync')
|
||||
.mockResolvedValueOnce({
|
||||
directory: '/tmp/manual-screenshots',
|
||||
finishAsync: finishScreenshots,
|
||||
});
|
||||
const cleanup = jest.fn().mockRejectedValue(new Error('unlink refused'));
|
||||
const factory = jest.spyOn(previewCapture, 'createServeSimPreviewCapture').mockReturnValueOnce({
|
||||
isSupportedAsync: jest.fn().mockResolvedValue(true),
|
||||
captureAsync: jest.fn(),
|
||||
cleanupAfterHostStoppedAsync: cleanup,
|
||||
});
|
||||
try {
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
preview: { baseDomain, authtoken },
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await host.finishAsync();
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
expect(finishScreenshots).toHaveBeenCalledWith(true);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Could not remove session preview capture files.'
|
||||
);
|
||||
} finally {
|
||||
factory.mockRestore();
|
||||
collector.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('drains thumbnail capture before stopping the iOS host', async () => {
|
||||
const stopped = deferred<void>();
|
||||
stopSessionPreview.mockReturnValueOnce(stopped.promise);
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
preview: { baseDomain, authtoken },
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const finishing = host.finishAsync();
|
||||
try {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(stopServer).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
stopped.resolve();
|
||||
await finishing;
|
||||
}
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(['success', 'failure'])(
|
||||
'keeps macOS readiness nonblocking and drains encoder setup %s before ending the phase',
|
||||
async result => {
|
||||
@@ -2193,6 +2474,58 @@ it.each([false, true])(
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
['0.13.3', '@expo/[email protected]'],
|
||||
[undefined, '@expo/serve-sim@latest'],
|
||||
])(
|
||||
'uses the warm serve-sim version %s for adoption and recordings',
|
||||
async (serveSimVersion, spec) => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([
|
||||
{ udid: 'emulator-5554', token: 'preview-token', url: 'http://127.0.0.1:3200' },
|
||||
]);
|
||||
const recording = jest.spyOn(IosSimulatorRecordingUtils, 'useServeSimPackage');
|
||||
const startAsync = jest.fn(async () => ({
|
||||
pid: undefined,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopServer,
|
||||
}));
|
||||
const host = await startDeviceSessionHostAsync(
|
||||
ctx,
|
||||
{
|
||||
preview: { baseDomain, authtoken },
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: { ...env, EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' },
|
||||
logger,
|
||||
iosSimulatorUdid: 'emulator-5554',
|
||||
timeoutMs: 1000,
|
||||
},
|
||||
{
|
||||
port: 3200,
|
||||
serveSimVersion,
|
||||
turnArgs: Promise.resolve([]),
|
||||
requiresPreviewRetirement: true,
|
||||
startAsync,
|
||||
stopAsync: stopServer,
|
||||
}
|
||||
);
|
||||
try {
|
||||
expect(startAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'bun',
|
||||
args: expect.arrayContaining(['x', spec]),
|
||||
})
|
||||
);
|
||||
expect(recording).toHaveBeenCalledWith(spec);
|
||||
} finally {
|
||||
await host.finishAsync();
|
||||
recording.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('opens the preview while a supplied server waits for TURN and its startup handshake', async () => {
|
||||
const turn = deferred<string[]>();
|
||||
const handshake = deferred<Awaited<ReturnType<typeof spawnDetached>>>();
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { installIsolatedNpmPackageAsync } from '../../../utils/installIsolatedNpmPackageAsync';
|
||||
import {
|
||||
installEmulatorCaptureAsync,
|
||||
parseExperimentalEmulatorCaptureVersionInput,
|
||||
} from '../emulatorCapture';
|
||||
|
||||
jest.mock('../../../utils/installIsolatedNpmPackageAsync', () => ({
|
||||
installIsolatedNpmPackageAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const TEMPORARY_DIRECTORY = '/tmp/eas-test';
|
||||
const AGENT = 'node_modules/expo-emulator-capture/dist/linux-x64/libexpo_emulator_capture.so';
|
||||
const mockedInstall = jest.mocked(installIsolatedNpmPackageAsync);
|
||||
const { platform, arch } = process;
|
||||
|
||||
function setHost(hostPlatform: NodeJS.Platform, hostArch: string): void {
|
||||
Object.defineProperty(process, 'platform', { value: hostPlatform });
|
||||
Object.defineProperty(process, 'arch', { value: hostArch });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(os, 'tmpdir').mockReturnValue(TEMPORARY_DIRECTORY);
|
||||
fs.mkdirSync(TEMPORARY_DIRECTORY, { recursive: true });
|
||||
mockedInstall.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
setHost(platform, arch);
|
||||
});
|
||||
|
||||
describe(parseExperimentalEmulatorCaptureVersionInput, () => {
|
||||
it.each([undefined, null, ''])('turns the capture off for %p', value => {
|
||||
expect(parseExperimentalEmulatorCaptureVersionInput(value)).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(['0.1.0', '0.1.0-canary-20261106-2b670aa', 'canary', 'latest'])('accepts %s', value => {
|
||||
expect(parseExperimentalEmulatorCaptureVersionInput(value)).toBe(value);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'^0.1.0',
|
||||
'../0.1.0',
|
||||
'npm:[email protected]',
|
||||
'file:/tmp/agent',
|
||||
'0.1.0 ',
|
||||
'-0.1.0',
|
||||
'x'.repeat(129),
|
||||
1,
|
||||
])('rejects %p', value => {
|
||||
expect(() => parseExperimentalEmulatorCaptureVersionInput(value)).toThrow(UserError);
|
||||
});
|
||||
});
|
||||
|
||||
describe(installEmulatorCaptureAsync, () => {
|
||||
const env = { PATH: '/usr/bin', EAS_BUILD_NPM_CACHE_URL: 'https://npm-cache.example' };
|
||||
|
||||
it('installs the package in its own directory and returns its agent library', async () => {
|
||||
setHost('linux', 'x64');
|
||||
mockedInstall.mockImplementation(async ({ directory }) => {
|
||||
fs.mkdirSync(path.dirname(path.join(directory, AGENT)), { recursive: true });
|
||||
fs.writeFileSync(path.join(directory, AGENT), '');
|
||||
});
|
||||
const logger = createMockLogger();
|
||||
|
||||
const library = await installEmulatorCaptureAsync({ version: '0.1.0', env, logger });
|
||||
|
||||
expect(mockedInstall).toHaveBeenCalledWith({
|
||||
packageSpec: '[email protected]',
|
||||
directory: expect.stringMatching(/^\/tmp\/eas-test\/eas-emulator-capture-package-/),
|
||||
npmCacheUrl: 'https://npm-cache.example',
|
||||
pathEnv: '/usr/bin',
|
||||
logger,
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(library).toBe(path.join(mockedInstall.mock.calls[0][0].directory, AGENT));
|
||||
});
|
||||
|
||||
it('returns undefined when the install fails', async () => {
|
||||
setHost('linux', 'x64');
|
||||
mockedInstall.mockRejectedValue(new Error('npm error 404'));
|
||||
const logger = createMockLogger();
|
||||
|
||||
await expect(
|
||||
installEmulatorCaptureAsync({ version: '0.1.0', env, logger })
|
||||
).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
expect.stringContaining('Could not install [email protected]')
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined when the package has no Linux agent library', async () => {
|
||||
setHost('linux', 'x64');
|
||||
mockedInstall.mockResolvedValue(undefined);
|
||||
const logger = createMockLogger();
|
||||
|
||||
await expect(
|
||||
installEmulatorCaptureAsync({ version: 'latest', env, logger })
|
||||
).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('expo-emulator-capture@latest has no Linux x86-64 agent at')
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined on a host that is not Linux x86-64', async () => {
|
||||
setHost('darwin', 'arm64');
|
||||
const logger = createMockLogger();
|
||||
|
||||
await expect(
|
||||
installEmulatorCaptureAsync({ version: '0.1.0', env, logger })
|
||||
).resolves.toBeUndefined();
|
||||
expect(mockedInstall).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Linux x86-64'));
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
import { type Client, CombinedError } from '@urql/core';
|
||||
|
||||
import { LegacySandboxApi } from '../legacySandboxApi';
|
||||
import { WorkerApiError } from '../workerApi';
|
||||
|
||||
const ARTIFACT_ID = '0199c0de-7b3a-7c1e-8f00-1234567890ab';
|
||||
|
||||
describe(LegacySandboxApi, () => {
|
||||
let mutation: jest.Mock;
|
||||
let api: LegacySandboxApi;
|
||||
|
||||
beforeEach(() => {
|
||||
mutation = jest.fn();
|
||||
api = new LegacySandboxApi({ mutation } as unknown as Client, 'sandbox-id');
|
||||
});
|
||||
|
||||
function mockMutationResults(...results: unknown[]): void {
|
||||
for (const result of results) {
|
||||
mutation.mockReturnValueOnce({ toPromise: async () => result });
|
||||
}
|
||||
}
|
||||
|
||||
it('marks the sandbox ready and rethrows a GraphQL error', async () => {
|
||||
const error = new CombinedError({ graphQLErrors: ['Sandbox has stopped.'] });
|
||||
mockMutationResults({ data: {} }, { error });
|
||||
|
||||
await api.markReadyAsync();
|
||||
await expect(api.markReadyAsync()).rejects.toBe(error);
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sandboxId: 'sandbox-id' },
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('creates an upload session for the sandbox', async () => {
|
||||
mockMutationResults({
|
||||
data: {
|
||||
sandbox: {
|
||||
createArtifactUploadSession: {
|
||||
artifact: { id: ARTIFACT_ID },
|
||||
uploadSession: { url: 'https://uploads.expo.test/artifact', headers: { A: '1' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
api.createArtifactUploadSessionAsync({ name: 'Crash log', filename: 'crash.log', size: 5 })
|
||||
).resolves.toEqual({
|
||||
artifact: { id: ARTIFACT_ID },
|
||||
uploadSession: { url: 'https://uploads.expo.test/artifact', headers: { A: '1' } },
|
||||
});
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sandboxId: 'sandbox-id', input: { name: 'Crash log', filename: 'crash.log', size: 5 } },
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ codes: ['VALIDATION_ERROR'], code: 'VALIDATION_ERROR' },
|
||||
{ codes: ['VALIDATION_ERROR', 'UNEXPECTED_ERROR'], code: undefined },
|
||||
])(
|
||||
'reports upload session errors with codes $codes as a worker API error with code $code',
|
||||
async ({ codes, code }) => {
|
||||
mockMutationResults({
|
||||
error: new CombinedError({
|
||||
graphQLErrors: codes.map((errorCode, index) => ({
|
||||
message: `Error ${index}.`,
|
||||
extensions: { errorCode },
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
const error = await api
|
||||
.createArtifactUploadSessionAsync({ name: 'Crash log', filename: 'crash.log', size: 5 })
|
||||
.catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(WorkerApiError);
|
||||
expect(error.code).toBe(code);
|
||||
expect(error.message).toBe(codes.map((_, index) => `Error ${index}.`).join('\n'));
|
||||
}
|
||||
);
|
||||
|
||||
it('retries finalize after a server error and throws a failure that persists', async () => {
|
||||
const persistent = new CombinedError({
|
||||
graphQLErrors: ['Sandbox artifact "Crash log" has no uploaded file.'],
|
||||
response: { status: 200 },
|
||||
});
|
||||
mockMutationResults(
|
||||
{
|
||||
error: new CombinedError({
|
||||
networkError: new Error('Bad Gateway'),
|
||||
response: { status: 502 },
|
||||
}),
|
||||
},
|
||||
{ error: persistent }
|
||||
);
|
||||
|
||||
await expect(api.finalizeArtifactAsync(ARTIFACT_ID)).rejects.toBe(persistent);
|
||||
expect(mutation).toHaveBeenCalledTimes(2);
|
||||
expect(mutation).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
{ artifactId: ARTIFACT_ID },
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a finalized artifact without a download URL', async () => {
|
||||
mockMutationResults({
|
||||
data: { sandbox: { finalizeArtifact: { id: ARTIFACT_ID, downloadUrl: null } } },
|
||||
});
|
||||
|
||||
await expect(api.finalizeArtifactAsync(ARTIFACT_ID)).rejects.toThrow(
|
||||
`Finalized sandbox artifact ${ARTIFACT_ID} has no download URL.`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -563,26 +563,276 @@ it('does not queue more native opens while a timed-out open is still pending', a
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('bounds initial tunnel creation and retires a late listener', async () => {
|
||||
const ERR_802 = 'failed to start listener: internal server error error_code: ERR_NGROK_802';
|
||||
|
||||
it('bounds initial tunnel creation, does not retry a timed-out open, and retires a late listener', async () => {
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
resolveOpening = resolve;
|
||||
})
|
||||
);
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)))
|
||||
.mockResolvedValue(replacement as never);
|
||||
const opening = startNgrokTunnelAsync(options);
|
||||
const rejected = expect(opening).rejects.toThrow('Ngrok tunnel open timed out');
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
const rejected = expect(opening).rejects.toThrow('Ngrok tunnel open timed out after 19000ms');
|
||||
await jest.advanceTimersByTimeAsync(19_000);
|
||||
await rejected;
|
||||
// ngrok runs opens one at a time, so a retry would only wait behind the stuck open.
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.stringContaining('); retrying.')
|
||||
);
|
||||
resolveOpening(initial as never);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ outcome: 'opened', elapsedMs: 19_000 }),
|
||||
'ngrok tunnel open for web-preview-fixed.example.test opened after 19000 ms, after it timed out or was cancelled.'
|
||||
);
|
||||
});
|
||||
|
||||
it('retries a rejected close after initial tunnel creation timed out', async () => {
|
||||
it('keeps a first open that settles after the reopen time-out', async () => {
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)))
|
||||
.mockResolvedValue(replacement as never);
|
||||
const opening = startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(16_000);
|
||||
resolveOpening(initial as never);
|
||||
const tunnel = await opening;
|
||||
try {
|
||||
expect(tunnel.url).toBe(url);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
expect(initial.close).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fails without a retry when the first open fails for another reason after 15 seconds', async () => {
|
||||
let rejectOpening!: (error: Error) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockReturnValueOnce(new Promise((_, reject) => (rejectOpening = reject)))
|
||||
.mockResolvedValue(replacement as never);
|
||||
const error = new Error('invalid authtoken');
|
||||
const opening = startNgrokTunnelAsync(options);
|
||||
const rejected = expect(opening).rejects.toBe(error);
|
||||
await jest.advanceTimersByTimeAsync(16_000);
|
||||
rejectOpening(error);
|
||||
await rejected;
|
||||
await jest.advanceTimersByTimeAsync(10_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not retry a timed-out open that ngrok rejects later', async () => {
|
||||
let rejectOpening!: (error: Error) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockReturnValueOnce(new Promise((_, reject) => (rejectOpening = reject)))
|
||||
.mockResolvedValue(replacement as never);
|
||||
const opening = startNgrokTunnelAsync(options);
|
||||
const rejected = expect(opening).rejects.toThrow('Ngrok tunnel open timed out');
|
||||
await jest.advanceTimersByTimeAsync(19_000);
|
||||
await rejected;
|
||||
rejectOpening(new Error(ERR_802));
|
||||
await jest.advanceTimersByTimeAsync(10_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries an initial open that ngrok rejects on a new agent session', async () => {
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockRejectedValueOnce(new Error(ERR_802))
|
||||
.mockResolvedValue(initial as never);
|
||||
const opening = startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(1_000);
|
||||
const tunnel = await opening;
|
||||
try {
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
expect(jest.mocked(ngrok.forward).mock.calls[0][0]).not.toHaveProperty('force_new_session');
|
||||
expect(jest.mocked(ngrok.forward).mock.calls[1][0]).toMatchObject({ force_new_session: true });
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ attempt: 1 }),
|
||||
'Could not open ngrok tunnel web-preview-fixed.example.test (attempt 1 of 3); retrying.'
|
||||
);
|
||||
expect(tunnel.url).toBe(url);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('retries an initial open whose error carries the ngrok error code', async () => {
|
||||
const error = Object.assign(new Error('failed to start listener'), {
|
||||
errorCode: 'ERR_NGROK_802',
|
||||
});
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockRejectedValueOnce(error)
|
||||
.mockResolvedValue(initial as never);
|
||||
const opening = startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(1_000);
|
||||
const tunnel = await opening;
|
||||
try {
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('gives a retry the reopen time-out', async () => {
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockRejectedValueOnce(new Error(ERR_802))
|
||||
.mockReturnValueOnce(new Promise(() => {}));
|
||||
const opening = startNgrokTunnelAsync(options);
|
||||
const rejected = expect(opening).rejects.toThrow('Ngrok tunnel open timed out after 15000ms');
|
||||
await jest.advanceTimersByTimeAsync(1_000 + 15_000);
|
||||
await rejected;
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('fails after three rejected initial opens', async () => {
|
||||
jest.mocked(ngrok.forward).mockReset().mockRejectedValue(new Error(ERR_802));
|
||||
const opening = startNgrokTunnelAsync(options);
|
||||
const rejected = expect(opening).rejects.toThrow('ERR_NGROK_802');
|
||||
await jest.advanceTimersByTimeAsync(1_000 + 2_000);
|
||||
await rejected;
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(3);
|
||||
expect(jest.mocked(ngrok.forward).mock.calls[2][0]).toMatchObject({ force_new_session: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['another reason', 'invalid authtoken'],
|
||||
['a longer error code', 'internal server error error_code: ERR_NGROK_8021'],
|
||||
])('does not retry an initial open that fails for %s', async (_, message) => {
|
||||
jest.mocked(ngrok.forward).mockReset().mockRejectedValue(new Error(message));
|
||||
await expect(startNgrokTunnelAsync(options)).rejects.toThrow(message);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fails with the abort reason when the caller aborts during the retry backoff', async () => {
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockRejectedValueOnce(new Error(ERR_802))
|
||||
.mockResolvedValue(initial as never);
|
||||
const startup = new AbortController();
|
||||
const reason = new Error('startup aborted');
|
||||
const opening = startNgrokTunnelAsync({ ...options, signal: startup.signal });
|
||||
const rejected = expect(opening).rejects.toBe(reason);
|
||||
await jest.advanceTimersByTimeAsync(500);
|
||||
startup.abort(reason);
|
||||
await rejected;
|
||||
await jest.advanceTimersByTimeAsync(10_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not open when the caller aborts while the local target starts', async () => {
|
||||
const startup = new AbortController();
|
||||
const reason = new Error('startup aborted');
|
||||
const opening = startNgrokTunnelAsync({ ...options, signal: startup.signal });
|
||||
startup.abort(reason);
|
||||
await expect(opening).rejects.toBe(reason);
|
||||
expect(ngrok.forward).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([1, 2])(
|
||||
'fails with the abort reason and retires the late listener when the caller aborts while open %s is pending',
|
||||
async pendingAttempt => {
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
const pending = new Promise<ngrok.Listener>(resolve => (resolveOpening = resolve));
|
||||
const forward = jest.mocked(ngrok.forward).mockReset();
|
||||
if (pendingAttempt === 2) {
|
||||
forward.mockRejectedValueOnce(new Error(ERR_802));
|
||||
}
|
||||
forward.mockReturnValueOnce(pending as never).mockResolvedValue(replacement as never);
|
||||
const startup = new AbortController();
|
||||
const reason = new Error('startup aborted');
|
||||
const opening = startNgrokTunnelAsync({ ...options, signal: startup.signal });
|
||||
const rejected = expect(opening).rejects.toBe(reason);
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(pendingAttempt);
|
||||
startup.abort(reason);
|
||||
await rejected;
|
||||
resolveOpening(initial as never);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(30_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(pendingAttempt);
|
||||
}
|
||||
);
|
||||
|
||||
it('stops retrying without a new open when the retry signal aborts during the backoff', async () => {
|
||||
const error = new Error(ERR_802);
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockRejectedValueOnce(error)
|
||||
.mockResolvedValue(initial as never);
|
||||
const retries = new AbortController();
|
||||
const opening = startNgrokTunnelAsync({ ...options, retrySignal: retries.signal });
|
||||
const rejected = expect(opening).rejects.toBe(error);
|
||||
await jest.advanceTimersByTimeAsync(500);
|
||||
retries.abort();
|
||||
await rejected;
|
||||
await jest.advanceTimersByTimeAsync(10_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('lets the open in progress finish when the retry signal aborts', async () => {
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
|
||||
const retries = new AbortController();
|
||||
const opening = startNgrokTunnelAsync({ ...options, retrySignal: retries.signal });
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
retries.abort();
|
||||
resolveOpening(initial as never);
|
||||
const tunnel = await opening;
|
||||
try {
|
||||
expect(tunnel.url).toBe(url);
|
||||
expect(initial.close).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('stops listening for the startup signal after startup fails', async () => {
|
||||
jest.mocked(ngrok.forward).mockReset().mockRejectedValue(new Error('invalid authtoken'));
|
||||
const startup = new AbortController();
|
||||
const removeEventListener = jest.spyOn(startup.signal, 'removeEventListener');
|
||||
await expect(startNgrokTunnelAsync({ ...options, signal: startup.signal })).rejects.toThrow(
|
||||
'invalid authtoken'
|
||||
);
|
||||
expect(removeEventListener).toHaveBeenCalledWith('abort', expect.any(Function));
|
||||
});
|
||||
|
||||
it('keeps the tunnel open when the startup signal aborts after startup', async () => {
|
||||
const startup = new AbortController();
|
||||
const tunnel = await startNgrokTunnelAsync({ ...options, signal: startup.signal });
|
||||
try {
|
||||
startup.abort();
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
expect(initial.close).not.toHaveBeenCalled();
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries a rejected close of a late listener after initial tunnel creation timed out', async () => {
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
@@ -590,7 +840,7 @@ it('retries a rejected close after initial tunnel creation timed out', async ()
|
||||
.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 jest.advanceTimersByTimeAsync(19_000);
|
||||
await rejected;
|
||||
initial.close.mockRejectedValueOnce(new Error('temporary unlisten failure'));
|
||||
resolveOpening(initial as never);
|
||||
@@ -816,8 +1066,10 @@ it.each(['resolve', '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);
|
||||
// The first open times out, and startup does not retry it while it is still pending.
|
||||
await jest.advanceTimersByTimeAsync(19_000);
|
||||
await rejected;
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
const targetPort = (jest.mocked(ngrok.forward).mock.calls[0][0] as ngrok.Config).addr as number;
|
||||
if (outcome === 'resolve') {
|
||||
resolveOpening(replacement as never);
|
||||
|
||||
@@ -54,6 +54,23 @@ it('downloads the driver-matched Simulator runner and retains its XCTest framewo
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the runner of a prepared Appium home without a download', async () => {
|
||||
await fs.promises.mkdir(runner, { recursive: true });
|
||||
await expect(preparePreinstalledWdaAsync({ ...options, prepared: true })).resolves.toEqual({
|
||||
'appium:usePreinstalledWDA': true,
|
||||
'appium:prebuiltWDAPath': runner,
|
||||
});
|
||||
await expect(
|
||||
preparePreinstalledWdaAsync({
|
||||
...options,
|
||||
prepared: true,
|
||||
runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-16-4',
|
||||
})
|
||||
).resolves.toEqual({});
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(runner)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
'com.apple.CoreSimulator.SimRuntime.iOS-16-4',
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { once } from 'node:events';
|
||||
import http from 'node:http';
|
||||
|
||||
import {
|
||||
readExpoDeviceHubInputLogAsync,
|
||||
readServeSimInputLogAsync,
|
||||
startPreviewActivityPolling,
|
||||
} from '../previewActivity';
|
||||
|
||||
const token = 'test-session-token';
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
let events: { id: number; at?: string; timestamp?: string }[];
|
||||
let status: number;
|
||||
let requests: { url?: string; authorization?: string }[];
|
||||
let rawBody: string | undefined;
|
||||
let replaying: boolean;
|
||||
let replayStartedAt: string | null;
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
events = [];
|
||||
status = 200;
|
||||
requests = [];
|
||||
rawBody = undefined;
|
||||
replaying = false;
|
||||
replayStartedAt = null;
|
||||
server = http.createServer((req, res) => {
|
||||
requests.push({ url: req.url, authorization: req.headers.authorization });
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(rawBody ?? JSON.stringify({ events: events.slice(-1), replaying, replayStartedAt }));
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
port = (server.address() as { port: number }).port;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
describe(readServeSimInputLogAsync, () => {
|
||||
it('reads the newest event ID with the session token', async () => {
|
||||
events = [{ id: 3 }, { id: 7 }];
|
||||
await expect(readServeSimInputLogAsync({ port, token })).resolves.toEqual({
|
||||
latestEventId: 7,
|
||||
latestEventAt: undefined,
|
||||
replaying: false,
|
||||
replayStartedAt: undefined,
|
||||
});
|
||||
expect(requests).toEqual([{ url: '/api/event-log?limit=1', authorization: `Bearer ${token}` }]);
|
||||
});
|
||||
|
||||
it('returns undefined when serve-sim has logged no event', async () => {
|
||||
await expect(readServeSimInputLogAsync({ port, token })).resolves.toEqual({
|
||||
latestEventId: undefined,
|
||||
latestEventAt: undefined,
|
||||
replaying: false,
|
||||
replayStartedAt: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unparseable JSON', 'not json'],
|
||||
['a body without an events array', '{}'],
|
||||
['an entry without a numeric ID', '{"events":[{"id":"7"}]}'],
|
||||
])('rejects %s', async (_, body) => {
|
||||
rawBody = body;
|
||||
await expect(readServeSimInputLogAsync({ port, token })).rejects.toThrow('serve-sim event log');
|
||||
});
|
||||
|
||||
it('rejects a refused request', async () => {
|
||||
status = 401;
|
||||
await expect(readServeSimInputLogAsync({ port, token })).rejects.toThrow('401');
|
||||
});
|
||||
});
|
||||
|
||||
describe(readExpoDeviceHubInputLogAsync, () => {
|
||||
it('reads the newest entry time from either server', async () => {
|
||||
events = [{ id: 2, timestamp: '2026-11-13T12:00:02.000Z' }];
|
||||
await expect(readServeSimInputLogAsync({ port, token })).resolves.toMatchObject({
|
||||
latestEventId: 2,
|
||||
latestEventAt: '2026-11-13T12:00:02.000Z',
|
||||
});
|
||||
events = [{ id: 3, at: '2026-11-13T12:00:03.000Z' }];
|
||||
await expect(readExpoDeviceHubInputLogAsync({ port, token })).resolves.toMatchObject({
|
||||
latestEventId: 3,
|
||||
latestEventAt: '2026-11-13T12:00:03.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the newest recorded Android input through the hub with the session token', async () => {
|
||||
events = [{ id: 3 }, { id: 9 }];
|
||||
await expect(readExpoDeviceHubInputLogAsync({ port, token })).resolves.toEqual({
|
||||
latestEventId: 9,
|
||||
latestEventAt: undefined,
|
||||
replaying: false,
|
||||
replayStartedAt: undefined,
|
||||
});
|
||||
expect(requests).toEqual([
|
||||
{ url: '/vendor/serve-emu/api/session?limit=1', authorization: `Bearer ${token}` },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports a running replay and when it started', async () => {
|
||||
replaying = true;
|
||||
replayStartedAt = '2026-11-13T12:00:00.000Z';
|
||||
await expect(readExpoDeviceHubInputLogAsync({ port, token })).resolves.toEqual({
|
||||
latestEventId: undefined,
|
||||
latestEventAt: undefined,
|
||||
replaying: true,
|
||||
replayStartedAt: '2026-11-13T12:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads replay state from the paged response shape', async () => {
|
||||
rawBody = JSON.stringify({
|
||||
events: [{ id: 4, at: '2026-11-13T12:00:01.000Z' }],
|
||||
session: { replaying: true, replayStartedAt: '2026-11-13T12:00:00.000Z' },
|
||||
});
|
||||
await expect(readExpoDeviceHubInputLogAsync({ port, token })).resolves.toEqual({
|
||||
latestEventId: 4,
|
||||
latestEventAt: '2026-11-13T12:00:01.000Z',
|
||||
replaying: true,
|
||||
replayStartedAt: '2026-11-13T12:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns no event ID when the hub has recorded no input', async () => {
|
||||
await expect(readExpoDeviceHubInputLogAsync({ port, token })).resolves.toEqual({
|
||||
latestEventId: undefined,
|
||||
latestEventAt: undefined,
|
||||
replaying: false,
|
||||
replayStartedAt: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unparseable JSON', 'not json'],
|
||||
['a body without an events array', '{}'],
|
||||
['an entry without a numeric ID', '{"events":[{"id":"9"}]}'],
|
||||
])('rejects %s', async (_, body) => {
|
||||
rawBody = body;
|
||||
await expect(readExpoDeviceHubInputLogAsync({ port, token })).rejects.toThrow(
|
||||
'expo-device-hub input log'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(startPreviewActivityPolling, () => {
|
||||
async function waitForRequestsAsync(count: number): Promise<void> {
|
||||
while (requests.length < count) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
}
|
||||
|
||||
it('reports activity only when a new event appears', async () => {
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'serve-sim',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
expect(polling.getLastActivityObservedAt()).toBeUndefined();
|
||||
|
||||
events = [{ id: 1 }];
|
||||
const before = Date.now();
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
const firstSeen = polling.getLastActivityObservedAt();
|
||||
expect(firstSeen?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBe(firstSeen);
|
||||
|
||||
events = [{ id: 1 }, { id: 2 }];
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).not.toBe(firstSeen);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports current activity while the event log fails, and warns once', async () => {
|
||||
status = 500;
|
||||
const logger = createLoggerMock();
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'serve-sim',
|
||||
port,
|
||||
token,
|
||||
logger,
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(3);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
const before = Date.now();
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
|
||||
status = 200;
|
||||
events = [{ id: 4 }];
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
const observedAt = polling.getLastActivityObservedAt();
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBe(observedAt);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the last observation while serve-sim refuses connections', async () => {
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
server = http.createServer();
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'serve-sim',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
expect(polling.getLastActivityObservedAt()).toBeUndefined();
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports current activity when serve-sim drops the connection mid-response', async () => {
|
||||
server.removeAllListeners('request');
|
||||
server.on('request', (req, res) => {
|
||||
requests.push({ url: req.url });
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': '100' });
|
||||
res.write('{"events":');
|
||||
setImmediate(() => res.socket?.destroy());
|
||||
});
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'serve-sim',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(3);
|
||||
const before = Date.now();
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports current activity while serve-sim answers without an event log', async () => {
|
||||
status = 404;
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'serve-sim',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
const before = Date.now();
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
|
||||
status = 200;
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBeUndefined();
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports Android preview input from expo-device-hub', async () => {
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'expo-device-hub',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
expect(polling.getLastActivityObservedAt()).toBeUndefined();
|
||||
expect(requests[0].url).toBe('/vendor/serve-emu/api/session?limit=1');
|
||||
|
||||
events = [{ id: 1 }];
|
||||
const before = Date.now();
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports activity while expo-device-hub replays recorded input', async () => {
|
||||
replaying = true;
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'expo-device-hub',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
const before = Date.now();
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a replay that started and finished between two reads', async () => {
|
||||
events = [{ id: 1 }];
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'expo-device-hub',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
const observedAt = polling.getLastActivityObservedAt();
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBe(observedAt);
|
||||
|
||||
replayStartedAt = '2026-11-13T12:00:00.000Z';
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
const replayObservedAt = polling.getLastActivityObservedAt();
|
||||
expect(replayObservedAt).not.toBe(observedAt);
|
||||
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBe(replayObservedAt);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('counts input in a new recording that repeats the last ID between two reads', async () => {
|
||||
events = [{ id: 1, at: '2026-11-13T12:00:00.000Z' }];
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'expo-device-hub',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
const firstSeen = polling.getLastActivityObservedAt();
|
||||
expect(firstSeen).toBeDefined();
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBe(firstSeen);
|
||||
|
||||
events = [{ id: 1, at: '2026-11-13T12:05:00.000Z' }];
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).not.toBe(firstSeen);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('counts input in a new recording whose IDs start again', async () => {
|
||||
events = [{ id: 1 }];
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'expo-device-hub',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
const firstSeen = polling.getLastActivityObservedAt();
|
||||
expect(firstSeen).toBeDefined();
|
||||
|
||||
events = [];
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
events = [{ id: 1 }];
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).not.toBe(firstSeen);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['before its headers', 'headers'],
|
||||
['after its headers', 'body'],
|
||||
] as const)(
|
||||
'reports current activity while an expo-device-hub read times out %s',
|
||||
async (_, stall) => {
|
||||
events = [{ id: 1 }];
|
||||
let stalled = false;
|
||||
server.removeAllListeners('request');
|
||||
server.on('request', (req, res) => {
|
||||
requests.push({ url: req.url });
|
||||
if (stalled && stall === 'headers') {
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
if (stalled) {
|
||||
res.write('{"events":[');
|
||||
return;
|
||||
}
|
||||
res.end(JSON.stringify({ events, replaying: false, replayStartedAt: null }));
|
||||
});
|
||||
const logger = createLoggerMock();
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'expo-device-hub',
|
||||
port,
|
||||
token,
|
||||
logger,
|
||||
pollIntervalMs: 10,
|
||||
requestTimeoutMs: 100,
|
||||
});
|
||||
try {
|
||||
while (polling.getLastActivityObservedAt() === undefined) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
const observedAt = polling.getLastActivityObservedAt();
|
||||
|
||||
stalled = true;
|
||||
// The next request starts only after the stalled read times out.
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
const reportedAt = polling.getLastActivityObservedAt();
|
||||
expect(reportedAt).not.toBe(observedAt);
|
||||
expect(jest.mocked(logger.warn).mock.calls[0][1]).toContain(
|
||||
'idle timeout does not stop this session'
|
||||
);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
server.closeAllConnections();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('warns about an unreadable log after an earlier device outage', async () => {
|
||||
const logger = createLoggerMock();
|
||||
status = 503;
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'expo-device-hub',
|
||||
port,
|
||||
token,
|
||||
logger,
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
|
||||
status = 500;
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(2);
|
||||
expect(jest.mocked(logger.warn).mock.calls[1][1]).toContain(
|
||||
'idle timeout does not stop this session'
|
||||
);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the last observation while expo-device-hub has no device', async () => {
|
||||
events = [{ id: 1 }];
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'expo-device-hub',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
const observedAt = polling.getLastActivityObservedAt();
|
||||
expect(observedAt).toBeDefined();
|
||||
|
||||
status = 503;
|
||||
await waitForRequestsAsync(requests.length + 3);
|
||||
expect(polling.getLastActivityObservedAt()).toBe(observedAt);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports current activity when serve-sim answers 503', async () => {
|
||||
status = 503;
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'serve-sim',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
const before = Date.now();
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('stops polling after stopAsync', async () => {
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'serve-sim',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
await waitForRequestsAsync(1);
|
||||
await polling.stopAsync();
|
||||
const count = requests.length;
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
expect(requests.length).toBe(count);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ import { sleepAsync } from '../../../utils/retry';
|
||||
import { uploadDeviceRunSessionScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
import {
|
||||
TOOL_TUNNEL_TEARDOWN_OPTIONS,
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createIosSessionStartupInputProviders,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
describeServeSimLaunch,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
fetchWebPreviewTurnArgsAsync,
|
||||
finishRemoteSessionAsync,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
parseServeSimLaunchInputs,
|
||||
@@ -226,6 +228,89 @@ function createEnvMock(): BuildStepEnv {
|
||||
return { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv;
|
||||
}
|
||||
|
||||
describe(finishRemoteSessionAsync, () => {
|
||||
const tunnelError = new Error('tunnel stop timed out');
|
||||
const serverError = new Error('server stop failed');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(Sentry).capture.mockReset();
|
||||
});
|
||||
|
||||
it('does not fail a successful session when only a tunnel cannot stop', async () => {
|
||||
const logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
await expect(
|
||||
finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed: false,
|
||||
teardown: [
|
||||
['Appium tunnel', Promise.reject(tunnelError), TOOL_TUNNEL_TEARDOWN_OPTIONS],
|
||||
['Appium server', Promise.resolve()],
|
||||
],
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: tunnelError },
|
||||
'Could not stop the Appium tunnel during remote session teardown.'
|
||||
);
|
||||
expect(jest.mocked(Sentry).capture.mock.calls).toEqual([
|
||||
[
|
||||
'Could not stop the Appium tunnel after the remote session ended',
|
||||
tunnelError,
|
||||
{ level: 'warning' },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('fails a successful session with the first failure that fails the session', async () => {
|
||||
const logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
await expect(
|
||||
finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed: false,
|
||||
teardown: [
|
||||
['Appium tunnel', Promise.reject(tunnelError), TOOL_TUNNEL_TEARDOWN_OPTIONS],
|
||||
['Appium server', Promise.reject(serverError)],
|
||||
],
|
||||
})
|
||||
).rejects.toBe(serverError);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(2);
|
||||
expect(jest.mocked(Sentry).capture.mock.calls).toEqual([
|
||||
[
|
||||
'Could not stop the Appium tunnel after the remote session ended',
|
||||
tunnelError,
|
||||
{ level: 'warning' },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports every teardown failure to Sentry when the session failed', async () => {
|
||||
const logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
await expect(
|
||||
finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed: true,
|
||||
teardown: [
|
||||
['Appium tunnel', Promise.reject(tunnelError), TOOL_TUNNEL_TEARDOWN_OPTIONS],
|
||||
['Appium server', Promise.reject(serverError)],
|
||||
['session host', undefined],
|
||||
],
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
expect(jest.mocked(Sentry).capture.mock.calls).toEqual([
|
||||
[
|
||||
'Could not stop the Appium tunnel after the remote session failed',
|
||||
tunnelError,
|
||||
{ level: 'warning' },
|
||||
],
|
||||
[
|
||||
'Could not stop the Appium server after the remote session failed',
|
||||
serverError,
|
||||
{ level: 'warning' },
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(createIosSessionStartupInputProviders, () => {
|
||||
it('declares optional inputs so existing workflows do not opt into owned startup', () => {
|
||||
const inputs = createIosSessionStartupInputProviders().map(provider =>
|
||||
|
||||
@@ -23,7 +23,6 @@ import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { inspect } from 'node:util';
|
||||
|
||||
import { SandboxArtifactUploadManager, startSandboxArtifactUploadAsync } from '../sandboxArtifacts';
|
||||
import { type LegacySandboxApi } from '../legacySandboxApi';
|
||||
import { type SandboxWorkerApi } from '../sandboxWorkerApi';
|
||||
import { WorkerApiError } from '../workerApi';
|
||||
|
||||
@@ -66,7 +65,7 @@ describe('sandbox artifact uploads', () => {
|
||||
logger = { info: jest.fn(), error: jest.fn() };
|
||||
uploadArtifactAsync = async options =>
|
||||
await startSandboxArtifactUploadAsync({
|
||||
workerApi: workerApi as unknown as SandboxWorkerApi | LegacySandboxApi,
|
||||
workerApi: workerApi as unknown as SandboxWorkerApi,
|
||||
logger: logger as unknown as bunyan,
|
||||
...options,
|
||||
});
|
||||
@@ -390,7 +389,7 @@ describe('sandbox artifact uploads', () => {
|
||||
|
||||
function createManager(signal: AbortSignal): SandboxArtifactUploadManager {
|
||||
return new SandboxArtifactUploadManager({
|
||||
workerApi: workerApi as unknown as SandboxWorkerApi | LegacySandboxApi,
|
||||
workerApi: workerApi as unknown as SandboxWorkerApi,
|
||||
logger: logger as unknown as bunyan,
|
||||
signal,
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ import WebSocket, { WebSocketServer } from 'ws';
|
||||
|
||||
import * as sandboxCommandImplementations from '../sandboxCommandImplementations';
|
||||
import { startSandboxDaemonAsync } from '../sandboxDaemon';
|
||||
import { type LegacySandboxApi } from '../legacySandboxApi';
|
||||
import { type SandboxWorkerApi } from '../sandboxWorkerApi';
|
||||
|
||||
jest.unmock('@expo/logger');
|
||||
@@ -17,7 +16,7 @@ jest.unmock('node:fs/promises');
|
||||
|
||||
describe(startSandboxDaemonAsync.name, () => {
|
||||
const logger = Log.child({ buildStepId: 'sandbox-test' });
|
||||
const workerApi = {} as SandboxWorkerApi | LegacySandboxApi;
|
||||
const workerApi = {} as SandboxWorkerApi;
|
||||
it.each(['ftp://localhost', 'not a URL'])('reports invalid server URL %s', async serverUrl => {
|
||||
const errorLog = jest.spyOn(logger, 'error').mockImplementation(() => {});
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
type SandboxDaemonCommandImplementations,
|
||||
createSandboxCommandImplementations,
|
||||
} from '../sandboxCommandImplementations';
|
||||
import { type LegacySandboxApi } from '../legacySandboxApi';
|
||||
import { type SandboxWorkerApi } from '../sandboxWorkerApi';
|
||||
|
||||
const READ_FILE_LIMITS = { maxTextBytes: 40_000, maxImageBytes: 3_000_000 };
|
||||
@@ -50,7 +49,7 @@ describe('sandbox daemon commands', () => {
|
||||
workingDirectory,
|
||||
env: process.env,
|
||||
signal: controller.signal,
|
||||
workerApi: workerApi as unknown as SandboxWorkerApi | LegacySandboxApi,
|
||||
workerApi: workerApi as unknown as SandboxWorkerApi,
|
||||
logger,
|
||||
});
|
||||
commandImplementations = commands.commandImplementations;
|
||||
@@ -79,7 +78,7 @@ describe('sandbox daemon commands', () => {
|
||||
workingDirectory,
|
||||
env: process.env,
|
||||
signal: controller.signal,
|
||||
workerApi: workerApi as unknown as SandboxWorkerApi | LegacySandboxApi,
|
||||
workerApi: workerApi as unknown as SandboxWorkerApi,
|
||||
logger,
|
||||
});
|
||||
const command = commands.commandImplementations.execCommand({ cmd: 'printf should-not-run' });
|
||||
|
||||
@@ -16,7 +16,11 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
import { runServeSimActionAsync, stageServeSimAppAsync } from '../serveSimActions';
|
||||
import {
|
||||
runServeSimActionAsync,
|
||||
runServeSimActionWithResultAsync,
|
||||
stageServeSimAppAsync,
|
||||
} from '../serveSimActions';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
@@ -67,6 +71,18 @@ it('authenticates the upgrade, waits for ready, and sends the action once', asyn
|
||||
expect(actions).toEqual([{ id: 1, action: 'app.install', params: request().params }]);
|
||||
});
|
||||
|
||||
it('returns stdout from an authenticated action for catalog inspection', async () => {
|
||||
server.on('connection', socket => {
|
||||
socket.on('message', () =>
|
||||
socket.send(JSON.stringify({ id: 1, stdout: '{"actions":[]}', exitCode: 0 }))
|
||||
);
|
||||
socket.send(JSON.stringify({ ready: true }));
|
||||
});
|
||||
await expect(
|
||||
runServeSimActionWithResultAsync({ ...request(), action: 'actions.describe', params: {} })
|
||||
).resolves.toBe('{"actions":[]}');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ id: 1, exitCode: 1, stderr: 'install refused' }, 'install refused'],
|
||||
[{ id: 1, error: 'unknown action app.install' }, 'unknown action'],
|
||||
@@ -137,6 +153,55 @@ it('aborts an in-flight action without resending it', async () => {
|
||||
expect(attempts).toBe(1);
|
||||
});
|
||||
|
||||
it('cancels before dispatch while waiting for ready in drain mode', async () => {
|
||||
const controller = new AbortController();
|
||||
const actions = jest.fn();
|
||||
server.on('connection', socket => {
|
||||
socket.on('message', actions);
|
||||
controller.abort(new Error('stopped before ready'));
|
||||
socket.send(JSON.stringify({ ready: true }));
|
||||
});
|
||||
await expect(
|
||||
runServeSimActionAsync({
|
||||
...request(),
|
||||
signal: controller.signal,
|
||||
drainAfterDispatch: true,
|
||||
})
|
||||
).rejects.toThrow('stopped before ready');
|
||||
expect(actions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drains dispatched actions after cancellation in drain mode', async () => {
|
||||
const controller = new AbortController();
|
||||
let reply!: () => void;
|
||||
let dispatched!: () => void;
|
||||
const ready = new Promise<void>(resolve => {
|
||||
dispatched = resolve;
|
||||
});
|
||||
server.on('connection', socket => {
|
||||
socket.send(JSON.stringify({ ready: true }));
|
||||
socket.on('message', () => {
|
||||
reply = () => socket.send(JSON.stringify({ id: 1, stdout: 'completed', exitCode: 0 }));
|
||||
dispatched();
|
||||
});
|
||||
});
|
||||
let settled = false;
|
||||
const action = runServeSimActionAsync({
|
||||
...request(),
|
||||
signal: controller.signal,
|
||||
drainAfterDispatch: true,
|
||||
});
|
||||
void action.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
await ready;
|
||||
controller.abort(new Error('stopped after dispatch'));
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(settled).toBe(false);
|
||||
reply();
|
||||
await expect(action).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not connect when already aborted', async () => {
|
||||
const connected = jest.fn();
|
||||
server.on('connection', connected);
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { once } from 'node:events';
|
||||
import http from 'node:http';
|
||||
|
||||
import { fetchLatestServeSimEventIdAsync, startServeSimActivityPolling } from '../serveSimActivity';
|
||||
|
||||
const token = 'test-session-token';
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
let events: { id: number }[];
|
||||
let status: number;
|
||||
let requests: { url?: string; authorization?: string }[];
|
||||
let rawBody: string | undefined;
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
events = [];
|
||||
status = 200;
|
||||
requests = [];
|
||||
rawBody = undefined;
|
||||
server = http.createServer((req, res) => {
|
||||
requests.push({ url: req.url, authorization: req.headers.authorization });
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(rawBody ?? JSON.stringify({ events: events.slice(-1) }));
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
port = (server.address() as { port: number }).port;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
describe(fetchLatestServeSimEventIdAsync, () => {
|
||||
it('reads the newest event ID with the session token', async () => {
|
||||
events = [{ id: 3 }, { id: 7 }];
|
||||
await expect(fetchLatestServeSimEventIdAsync({ port, token })).resolves.toBe(7);
|
||||
expect(requests).toEqual([{ url: '/api/event-log?limit=1', authorization: `Bearer ${token}` }]);
|
||||
});
|
||||
|
||||
it('returns undefined when serve-sim has logged no event', async () => {
|
||||
await expect(fetchLatestServeSimEventIdAsync({ port, token })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unparseable JSON', 'not json'],
|
||||
['a body without an events array', '{}'],
|
||||
['an entry without a numeric ID', '{"events":[{"id":"7"}]}'],
|
||||
])('rejects %s', async (_, body) => {
|
||||
rawBody = body;
|
||||
await expect(fetchLatestServeSimEventIdAsync({ port, token })).rejects.toThrow(
|
||||
'serve-sim event log'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a refused request', async () => {
|
||||
status = 401;
|
||||
await expect(fetchLatestServeSimEventIdAsync({ port, token })).rejects.toThrow('401');
|
||||
});
|
||||
});
|
||||
|
||||
describe(startServeSimActivityPolling, () => {
|
||||
async function waitForRequestsAsync(count: number): Promise<void> {
|
||||
while (requests.length < count) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
}
|
||||
|
||||
it('reports activity only when a new event appears', async () => {
|
||||
const polling = startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
expect(polling.getLastActivityObservedAt()).toBeUndefined();
|
||||
|
||||
events = [{ id: 1 }];
|
||||
const before = Date.now();
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
const firstSeen = polling.getLastActivityObservedAt();
|
||||
expect(firstSeen?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBe(firstSeen);
|
||||
|
||||
events = [{ id: 1 }, { id: 2 }];
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).not.toBe(firstSeen);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports current activity while the event log fails, and warns once', async () => {
|
||||
status = 500;
|
||||
const logger = createLoggerMock();
|
||||
const polling = startServeSimActivityPolling({ port, token, logger, pollIntervalMs: 10 });
|
||||
try {
|
||||
await waitForRequestsAsync(3);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
const before = Date.now();
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
|
||||
status = 200;
|
||||
events = [{ id: 4 }];
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
const observedAt = polling.getLastActivityObservedAt();
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBe(observedAt);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the last observation while serve-sim refuses connections', async () => {
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
server = http.createServer();
|
||||
const polling = startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
expect(polling.getLastActivityObservedAt()).toBeUndefined();
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports current activity when serve-sim drops the connection mid-response', async () => {
|
||||
server.removeAllListeners('request');
|
||||
server.on('request', (req, res) => {
|
||||
requests.push({ url: req.url });
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': '100' });
|
||||
res.write('{"events":');
|
||||
setImmediate(() => res.socket?.destroy());
|
||||
});
|
||||
const polling = startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(3);
|
||||
const before = Date.now();
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports current activity while serve-sim answers without an event log', async () => {
|
||||
status = 404;
|
||||
const polling = startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
const before = Date.now();
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
|
||||
status = 200;
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBeUndefined();
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('stops polling after stopAsync', async () => {
|
||||
const polling = startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
await waitForRequestsAsync(1);
|
||||
await polling.stopAsync();
|
||||
const count = requests.length;
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
expect(requests.length).toBe(count);
|
||||
});
|
||||
});
|
||||
@@ -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,153 @@
|
||||
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { runServeSimActionAsync, runServeSimActionWithResultAsync } from '../serveSimActions';
|
||||
import { createServeSimPreviewCapture } from '../serveSimPreviewCapture';
|
||||
|
||||
jest.mock('../serveSimActions');
|
||||
|
||||
const signal = new AbortController().signal;
|
||||
const tmpdir = path.join(os.tmpdir(), 'host-tmp');
|
||||
const staging = path.join(tmpdir, 'serve-sim-screenshots');
|
||||
const destination = path.join(os.tmpdir(), 'screen.png');
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0xff, 0x00]);
|
||||
const options = { port: 1234, token: 'host-token', device: 'selected-udid', tmpdir };
|
||||
|
||||
beforeEach(async () => {
|
||||
await mkdir(staging, { recursive: true });
|
||||
jest.mocked(runServeSimActionAsync).mockReset();
|
||||
});
|
||||
|
||||
it('captures the selected active display and removes only its own staging', async () => {
|
||||
await writeFile(path.join(staging, 'unrelated.png'), 'keep');
|
||||
jest.mocked(runServeSimActionAsync).mockImplementation(async request => {
|
||||
expect(request).toEqual({
|
||||
port: 1234,
|
||||
token: 'host-token',
|
||||
action: 'screenshot.capture',
|
||||
params: {
|
||||
udid: 'selected-udid',
|
||||
fileName: expect.stringMatching(/^serve-sim-screenshot-preview-.*\.png$/),
|
||||
automation: true,
|
||||
},
|
||||
timeoutMs: 30_000,
|
||||
signal,
|
||||
drainAfterDispatch: true,
|
||||
});
|
||||
await writeFile(path.join(staging, request.params.fileName as string), png);
|
||||
return;
|
||||
});
|
||||
const capture = createServeSimPreviewCapture(options);
|
||||
await capture.captureAsync(destination, signal);
|
||||
expect(await readFile(destination)).toEqual(png);
|
||||
expect(await readdir(staging)).toEqual(['unrelated.png']);
|
||||
});
|
||||
|
||||
it('drains an already sent action before honoring abort and deleting its output', async () => {
|
||||
const controller = new AbortController();
|
||||
let complete!: () => void;
|
||||
const pending = new Promise<void>(resolve => {
|
||||
complete = resolve;
|
||||
});
|
||||
let staged!: string;
|
||||
jest.mocked(runServeSimActionAsync).mockImplementation(async request => {
|
||||
staged = path.join(staging, request.params.fileName as string);
|
||||
await pending;
|
||||
await writeFile(staged, png);
|
||||
return;
|
||||
});
|
||||
const capture = createServeSimPreviewCapture(options);
|
||||
let settled = false;
|
||||
const capturing = capture.captureAsync(destination, controller.signal);
|
||||
void capturing.then(
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
}
|
||||
);
|
||||
controller.abort(new Error('session stopped'));
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
complete();
|
||||
await expect(capturing).rejects.toThrow('session stopped');
|
||||
expect(await readdir(staging)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(['timed out', 'connection closed'])(
|
||||
'keeps a possibly writable file after %s until the host stops',
|
||||
async message => {
|
||||
let staged!: string;
|
||||
jest.mocked(runServeSimActionAsync).mockImplementation(async request => {
|
||||
staged = path.join(staging, request.params.fileName as string);
|
||||
await writeFile(staged, 'reservation');
|
||||
throw new Error(message);
|
||||
});
|
||||
const capture = createServeSimPreviewCapture(options);
|
||||
await expect(capture.captureAsync(destination, signal)).rejects.toThrow(message);
|
||||
expect(await readFile(staged, 'utf8')).toBe('reservation');
|
||||
// The server may still write after the client's deadline or disconnect.
|
||||
await writeFile(staged, png);
|
||||
await capture.cleanupAfterHostStoppedAsync();
|
||||
expect(await readdir(staging)).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
it('attempts every owned cleanup when one file cannot be removed', async () => {
|
||||
let blocked!: string;
|
||||
let count = 0;
|
||||
jest.mocked(runServeSimActionAsync).mockImplementation(async request => {
|
||||
const staged = path.join(staging, request.params.fileName as string);
|
||||
if (count++ === 0) {
|
||||
blocked = staged;
|
||||
await mkdir(staged);
|
||||
} else {
|
||||
await writeFile(staged, png);
|
||||
}
|
||||
throw new Error('capture disconnected');
|
||||
});
|
||||
const capture = createServeSimPreviewCapture(options);
|
||||
await expect(capture.captureAsync(destination, signal)).rejects.toThrow('disconnected');
|
||||
await expect(capture.captureAsync(destination, signal)).rejects.toThrow('disconnected');
|
||||
await expect(capture.cleanupAfterHostStoppedAsync()).rejects.toThrow(
|
||||
'Could not remove preview captures'
|
||||
);
|
||||
expect(await readdir(staging)).toEqual([path.basename(blocked)]);
|
||||
});
|
||||
|
||||
it('does not send a capture when already aborted', async () => {
|
||||
const capture = createServeSimPreviewCapture(options);
|
||||
await expect(
|
||||
capture.captureAsync(destination, AbortSignal.abort(new Error('stopped')))
|
||||
).rejects.toThrow('stopped');
|
||||
expect(runServeSimActionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
{ 'screenshot.capture': { properties: { automation: { type: 'boolean', default: false } } } },
|
||||
true,
|
||||
],
|
||||
[{ 'screenshot.capture': { properties: {} } }, false],
|
||||
[{ 'screenshot.capture': { properties: { automation: { type: 'string' } } } }, false],
|
||||
[{}, false],
|
||||
[null, false],
|
||||
])(
|
||||
'checks the catalog automation field before enabling captures (%j)',
|
||||
async (catalog, supported) => {
|
||||
jest.mocked(runServeSimActionWithResultAsync).mockResolvedValueOnce(JSON.stringify(catalog));
|
||||
const capture = createServeSimPreviewCapture(options);
|
||||
await expect(capture.isSupportedAsync(signal)).resolves.toBe(supported);
|
||||
expect(runServeSimActionWithResultAsync).toHaveBeenCalledWith({
|
||||
port: options.port,
|
||||
token: options.token,
|
||||
action: 'actions.describe',
|
||||
params: {},
|
||||
timeoutMs: 30_000,
|
||||
signal,
|
||||
});
|
||||
expect(runServeSimActionAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -2,6 +2,8 @@ import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { resolveSimulatorPackageVersionAsync } from '../../../utils/simulatorPackageVersion';
|
||||
import { stopWarmSimulatorAsync } from '../../../utils/warmSimulator';
|
||||
import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils';
|
||||
import { fetchWebPreviewTurnArgsAsync } from '../remoteDeviceRunSession';
|
||||
import {
|
||||
@@ -12,6 +14,8 @@ import {
|
||||
import { type WarmIosSession, tryClaimWarmIosSessionAsync } from '../warmIosSession';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/simulatorPackageVersion');
|
||||
jest.mock('../../../utils/warmSimulator');
|
||||
jest.mock('../IosSimulatorRecordingUtils');
|
||||
jest.mock('../warmIosSession');
|
||||
jest.mock('../remoteDeviceRunSession', () => ({
|
||||
@@ -63,9 +67,11 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
it('adopts the warm device on the warm port with its recording, then prepares tools', async () => {
|
||||
warm.serveSimVersion = '0.13.3';
|
||||
const server = createWarmDeviceSessionServer(ctx, logger, warm);
|
||||
expect(server).toMatchObject({
|
||||
port: 3200,
|
||||
serveSimVersion: '0.13.3',
|
||||
requiresPreviewRetirement: true,
|
||||
preinstalledApp: warm.preinstalledExpoGo,
|
||||
});
|
||||
@@ -125,7 +131,7 @@ describe(runOnWarmIosSimulatorAsync, () => {
|
||||
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(undefined);
|
||||
const run = jest.fn().mockResolvedValue('cold');
|
||||
await expect(runOnWarmIosSimulatorAsync(claim, run)).resolves.toBe('cold');
|
||||
expect(run).toHaveBeenCalledWith(undefined);
|
||||
expect(run).toHaveBeenCalledWith(undefined, undefined);
|
||||
});
|
||||
|
||||
it('runs on the claimed warm Simulator', async () => {
|
||||
@@ -133,7 +139,7 @@ describe(runOnWarmIosSimulatorAsync, () => {
|
||||
const run = jest.fn().mockResolvedValue('warm');
|
||||
await expect(runOnWarmIosSimulatorAsync(claim, run)).resolves.toBe('warm');
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledWith(warm);
|
||||
expect(run).toHaveBeenCalledWith(warm, undefined);
|
||||
});
|
||||
|
||||
it('stops warmup and runs cold after an adoption failure', async () => {
|
||||
@@ -144,7 +150,71 @@ describe(runOnWarmIosSimulatorAsync, () => {
|
||||
.mockResolvedValueOnce('cold');
|
||||
await expect(runOnWarmIosSimulatorAsync(claim, run)).resolves.toBe('cold');
|
||||
expect(warm.process.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenLastCalledWith(undefined);
|
||||
expect(run).toHaveBeenLastCalledWith(undefined, undefined);
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
'uses the resolved request for claim and setup (warm: %s)',
|
||||
async reuse => {
|
||||
jest.mocked(resolveSimulatorPackageVersionAsync).mockResolvedValue('0.14.0');
|
||||
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(reuse ? warm : undefined);
|
||||
const request = { ...claim, env: { EAS_USE_WARM_SIMULATOR: '1' }, packageVersion: 'latest' };
|
||||
const run = jest.fn().mockResolvedValue('done');
|
||||
await expect(runOnWarmIosSimulatorAsync(request, run)).resolves.toBe('done');
|
||||
expect(tryClaimWarmIosSessionAsync).toHaveBeenCalledWith({
|
||||
...request,
|
||||
packageVersion: '0.14.0',
|
||||
});
|
||||
expect(run).toHaveBeenCalledWith(reuse ? warm : undefined, '0.14.0');
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps the resolved version through an adoption failure', async () => {
|
||||
jest.mocked(resolveSimulatorPackageVersionAsync).mockResolvedValue('0.14.0');
|
||||
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm);
|
||||
const run = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new WarmSessionAdoptionError())
|
||||
.mockResolvedValue('cold');
|
||||
await runOnWarmIosSimulatorAsync(
|
||||
{ ...claim, env: { EAS_USE_WARM_SIMULATOR: '1' }, packageVersion: 'latest' },
|
||||
run
|
||||
);
|
||||
expect(resolveSimulatorPackageVersionAsync).toHaveBeenCalledTimes(1);
|
||||
expect(run.mock.calls).toEqual([
|
||||
[warm, '0.14.0'],
|
||||
[undefined, '0.14.0'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('stops warmup and fails when an explicit request cannot be resolved', async () => {
|
||||
const error = new Error('lookup failed');
|
||||
jest.mocked(resolveSimulatorPackageVersionAsync).mockRejectedValue(error);
|
||||
const run = jest.fn();
|
||||
await expect(
|
||||
runOnWarmIosSimulatorAsync(
|
||||
{ ...claim, env: { EAS_USE_WARM_SIMULATOR: '1' }, packageVersion: 'latest' },
|
||||
run
|
||||
)
|
||||
).rejects.toBe(error);
|
||||
expect(stopWarmSimulatorAsync).toHaveBeenCalledTimes(1);
|
||||
expect(tryClaimWarmIosSessionAsync).not.toHaveBeenCalled();
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves lookup and cleanup failures without starting a session', async () => {
|
||||
const lookup = new Error('lookup failed');
|
||||
const cleanup = new Error('cleanup failed');
|
||||
jest.mocked(resolveSimulatorPackageVersionAsync).mockRejectedValue(lookup);
|
||||
jest.mocked(stopWarmSimulatorAsync).mockRejectedValue(cleanup);
|
||||
const run = jest.fn();
|
||||
await expect(
|
||||
runOnWarmIosSimulatorAsync(
|
||||
{ ...claim, env: { EAS_USE_WARM_SIMULATOR: '1' }, packageVersion: 'latest' },
|
||||
run
|
||||
)
|
||||
).rejects.toMatchObject({ errors: [lookup, cleanup] });
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not run cold after a failure during the session', async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from 'node:path';
|
||||
|
||||
import { runSimulatorLauncherAsync, stopWarmSimulatorAsync } from '../../../utils/warmSimulator';
|
||||
import { readLocalEgressHandoffAsync } from '../localEgress';
|
||||
import { runOnWarmIosSimulatorAsync } from '../warmDeviceSessionHost';
|
||||
import { tryClaimWarmIosSessionAsync } from '../warmIosSession';
|
||||
|
||||
jest.mock('../../../utils/warmSimulator');
|
||||
@@ -84,6 +85,24 @@ it('takes the preinstalled Expo Go that prepare-tools reports', async () => {
|
||||
expect(session!.preinstalledExpoGo).toEqual(expoGo);
|
||||
});
|
||||
|
||||
it('keeps the exact serve-sim version from the launcher claim', async () => {
|
||||
jest
|
||||
.mocked(runSimulatorLauncherAsync)
|
||||
.mockResolvedValueOnce(JSON.stringify({ ...claim, serveSimVersion: '0.13.3' }));
|
||||
const session = await tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger });
|
||||
expect(session?.serveSimVersion).toBe('0.13.3');
|
||||
});
|
||||
|
||||
it('stops warmup when the claim contains an invalid serve-sim version', async () => {
|
||||
jest
|
||||
.mocked(runSimulatorLauncherAsync)
|
||||
.mockResolvedValueOnce(JSON.stringify({ ...claim, serveSimVersion: 'latest' }));
|
||||
await expect(
|
||||
tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger })
|
||||
).resolves.toBeUndefined();
|
||||
expect(stopWarmSimulatorAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reads the agent-device server after tool preparation', async () => {
|
||||
jest
|
||||
.spyOn(fs, 'readFile')
|
||||
@@ -121,7 +140,6 @@ it.each([
|
||||
{},
|
||||
() => 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',
|
||||
@@ -149,3 +167,69 @@ it('stops the claimed session once', async () => {
|
||||
await Promise.all([session!.process.stopAsync(), session!.process.stopAsync()]);
|
||||
expect(stopWarmSimulatorAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['serve-sim', 'serveSimVersion'],
|
||||
['agent-device', 'agentDeviceVersion'],
|
||||
['argent', 'argentVersion'],
|
||||
] as const)(
|
||||
'reuses %s only when its recorded version matches the resolved request',
|
||||
async (engine, field) => {
|
||||
jest
|
||||
.mocked(runSimulatorLauncherAsync)
|
||||
.mockResolvedValue(JSON.stringify({ ...claim, [field]: '1.2.3' }));
|
||||
await expect(
|
||||
tryClaimWarmIosSessionAsync({ engine, packageVersion: '1.2.3', env, logger })
|
||||
).resolves.toBeDefined();
|
||||
expect(stopWarmSimulatorAsync).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
tryClaimWarmIosSessionAsync({ engine, packageVersion: '1.2.4', env, logger })
|
||||
).resolves.toBeUndefined();
|
||||
expect(stopWarmSimulatorAsync).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['serve-sim', 'agent-device', 'argent'] as const)(
|
||||
'uses normal setup for %s when older claims omit its version',
|
||||
async engine => {
|
||||
await expect(
|
||||
tryClaimWarmIosSessionAsync({ engine, packageVersion: '1.2.3', env, logger })
|
||||
).resolves.toBeUndefined();
|
||||
expect(stopWarmSimulatorAsync).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('accepts a resolved prerelease pin for matching warm adoption', async () => {
|
||||
jest
|
||||
.mocked(runSimulatorLauncherAsync)
|
||||
.mockResolvedValue(JSON.stringify({ ...claim, serveSimVersion: '0.13.4-beta.1+build.2' }));
|
||||
await expect(
|
||||
tryClaimWarmIosSessionAsync({
|
||||
engine: 'serve-sim',
|
||||
packageVersion: '0.13.4-beta.1+build.2',
|
||||
env,
|
||||
logger,
|
||||
})
|
||||
).resolves.toMatchObject({ serveSimVersion: '0.13.4-beta.1+build.2' });
|
||||
expect(stopWarmSimulatorAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves exact build metadata through job resolution and warm claim', async () => {
|
||||
const version = '0.13.4-beta.1+build.2';
|
||||
jest
|
||||
.mocked(runSimulatorLauncherAsync)
|
||||
.mockResolvedValueOnce(JSON.stringify({ ...claim, serveSimVersion: version }));
|
||||
const run = jest.fn(async (session, resolvedVersion) => {
|
||||
expect(session?.serveSimVersion).toBe(version);
|
||||
expect(resolvedVersion).toBe(version);
|
||||
return 'warm';
|
||||
});
|
||||
await expect(
|
||||
runOnWarmIosSimulatorAsync(
|
||||
{ engine: 'serve-sim', packageVersion: `v${version}`, env, logger },
|
||||
run
|
||||
)
|
||||
).resolves.toBe('warm');
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(stopWarmSimulatorAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { SystemError, formatBytes } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
import { mkdtemp, rm, stat } from 'node:fs/promises';
|
||||
@@ -11,8 +11,8 @@ import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
|
||||
import { createScreenshotMetadataAsync } from './deviceRunSessionScreenshotMetadata';
|
||||
|
||||
const AGENT_DEVICE_ARTIFACT_UPLOAD_POLL_INTERVAL_MS = 5_000;
|
||||
const AGENT_DEVICE_ARTIFACT_CLEANUP_TIMEOUT_MS = 30_000;
|
||||
@@ -216,6 +216,17 @@ export async function uploadAgentDeviceArtifactAsync(
|
||||
signal,
|
||||
});
|
||||
const { size } = await stat(temporaryArtifactPath);
|
||||
const display = artifact.displayRotation
|
||||
? { displayRotation: artifact.displayRotation }
|
||||
: undefined;
|
||||
const metadata =
|
||||
artifact.artifactType === 'screenshot'
|
||||
? await createScreenshotMetadataAsync(temporaryArtifactPath, {
|
||||
source: 'agent-device',
|
||||
...display,
|
||||
logger,
|
||||
})
|
||||
: display;
|
||||
logger.info(`Uploading artifact ${artifact.filename} (${formatBytes(size)}).`);
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
@@ -223,9 +234,7 @@ export async function uploadAgentDeviceArtifactAsync(
|
||||
name: `${artifact.filename} (${artifact.id})`,
|
||||
filename: artifact.filename,
|
||||
kind: artifact.artifactType ?? undefined,
|
||||
...(artifact.displayRotation
|
||||
? { metadata: { displayRotation: artifact.displayRotation } }
|
||||
: {}),
|
||||
...(metadata ? { metadata } : {}),
|
||||
size,
|
||||
openStream: () => createReadStream(temporaryArtifactPath),
|
||||
...(signal ? { signal } : {}),
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { formatBytes } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type BuildStepGlobalContext } from '@expo/steps';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
type AndroidEmulatorLogCapture,
|
||||
AndroidEmulatorLogRing,
|
||||
type FinalizedAndroidEmulatorLog,
|
||||
} from '../../utils/androidEmulatorLogCapture';
|
||||
import { AndroidEmulatorLogFiles } from '../../utils/androidEmulatorLogFiles';
|
||||
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>();
|
||||
|
||||
/** The first launch of a workflow fixes its source selection; later calls reuse the capture. */
|
||||
export function getAndroidEmulatorLogCapture(
|
||||
global: BuildStepGlobalContext,
|
||||
env: NodeJS.ProcessEnv,
|
||||
sources: readonly AndroidEmulatorLogSource[]
|
||||
): AndroidEmulatorLogCapture {
|
||||
let collection = collections.get(global);
|
||||
if (!collection) {
|
||||
collection = {
|
||||
// Sessions upload a bounded ring per source; builds keep plain launch files.
|
||||
capture: env.DEVICE_RUN_SESSION_ID
|
||||
? new AndroidEmulatorLogRing({ sources })
|
||||
: new AndroidEmulatorLogFiles({ sources }),
|
||||
};
|
||||
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 truncationNote =
|
||||
size < bytesSeen ? `; truncated, last ${formatBytes(size)} 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,
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { SystemError, formatBytes } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
import { mkdtemp, rm, stat } from 'node:fs/promises';
|
||||
@@ -11,8 +11,8 @@ import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
|
||||
import { createScreenshotMetadataAsync } from './deviceRunSessionScreenshotMetadata';
|
||||
|
||||
const ARGENT_ARTIFACT_UPLOAD_POLL_INTERVAL_MS = 5_000;
|
||||
const ARGENT_ARTIFACT_UPLOAD_CLEANUP_TIMEOUT_MS = 30_000;
|
||||
@@ -208,13 +208,19 @@ export async function uploadArgentArtifactAsync(
|
||||
destinationPath: temporaryArtifactPath,
|
||||
});
|
||||
const { size } = await stat(temporaryArtifactPath);
|
||||
const kind = getArgentArtifactKind(artifact);
|
||||
const metadata =
|
||||
(kind === 'screenshot' || kind === 'screenshot-diff') && !artifact.isDirectory
|
||||
? await createScreenshotMetadataAsync(temporaryArtifactPath, { source: 'argent', logger })
|
||||
: undefined;
|
||||
logger.info(`Uploading artifact ${filename} (${formatBytes(size)}).`);
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId: artifact.id,
|
||||
name: `${filename} (${artifact.id})`,
|
||||
filename,
|
||||
kind: getArgentArtifactKind(artifact),
|
||||
kind,
|
||||
...(metadata ? { metadata } : {}),
|
||||
size,
|
||||
openStream: () => createReadStream(temporaryArtifactPath),
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
|
||||
|
||||
type ArtifactMetadata = {
|
||||
export type ArtifactMetadata = {
|
||||
deviceRunSessionId: string;
|
||||
artifactId: string;
|
||||
name: string;
|
||||
|
||||
@@ -11,6 +11,11 @@ import path from 'node:path';
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { uploadContentWithSignedUrl } from '../../storage/uploadWithSignedUrl';
|
||||
import { killProcessGroup } from '../../utils/processes';
|
||||
import {
|
||||
type ImageSize,
|
||||
type ScreenshotMetadata,
|
||||
readImageSize,
|
||||
} from './deviceRunSessionScreenshotMetadata';
|
||||
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
|
||||
|
||||
const PREVIEW_INTERVAL_MS = 60_000;
|
||||
@@ -20,7 +25,6 @@ const MAX_PREVIEW_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
export function startDeviceRunSessionPreview({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
captureAsync,
|
||||
logger,
|
||||
}: {
|
||||
@@ -32,6 +36,7 @@ export function startDeviceRunSessionPreview({
|
||||
const controller = new AbortController();
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
let uploadSession: { url: string; headers: Record<string, string> } | undefined;
|
||||
let storedSize: ImageSize | undefined;
|
||||
let pending: Promise<void>;
|
||||
|
||||
const uploadAsync = async (): Promise<void> => {
|
||||
@@ -44,8 +49,10 @@ export function startDeviceRunSessionPreview({
|
||||
if (image.length === 0 || image.length > MAX_PREVIEW_SIZE_BYTES) {
|
||||
throw new Error('Session preview is empty or exceeds the 5 MiB upload limit.');
|
||||
}
|
||||
const size = readImageSize(image);
|
||||
if (!uploadSession) {
|
||||
uploadSession = await createPreviewUploadSessionAsync(ctx, deviceRunSessionId, signal);
|
||||
uploadSession = await createPreviewUploadSessionAsync(ctx, size, signal);
|
||||
storedSize = size;
|
||||
}
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
@@ -54,6 +61,16 @@ export function startDeviceRunSessionPreview({
|
||||
{ signedUrl: uploadSession, content: image },
|
||||
{ signal, retries: 0, stallTimeoutMs: 30_000 }
|
||||
);
|
||||
if (
|
||||
controller.signal.aborted ||
|
||||
!size ||
|
||||
(size.width === storedSize?.width && size.height === storedSize.height)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
storedSize = undefined;
|
||||
uploadSession = await createPreviewUploadSessionAsync(ctx, size, signal);
|
||||
storedSize = size;
|
||||
} catch (err) {
|
||||
if (!controller.signal.aborted) {
|
||||
logger.warn({ err }, 'Could not refresh the session preview; retrying in 60 seconds.');
|
||||
@@ -80,10 +97,16 @@ export function startDeviceRunSessionPreview({
|
||||
|
||||
async function createPreviewUploadSessionAsync(
|
||||
ctx: CustomBuildContext,
|
||||
deviceRunSessionId: string,
|
||||
size: ImageSize | undefined,
|
||||
signal: AbortSignal
|
||||
): Promise<{ url: string; headers: Record<string, string> }> {
|
||||
return await requireDeviceRunSessionWorkerApi(ctx).createPreviewUploadSessionAsync({ signal });
|
||||
const metadata: ScreenshotMetadata | undefined = size
|
||||
? { source: 'preview', ...size }
|
||||
: undefined;
|
||||
return await requireDeviceRunSessionWorkerApi(ctx).createPreviewUploadSessionAsync(
|
||||
{ metadata },
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,18 +164,25 @@ export async function captureDeviceRunSessionPreviewAsync({
|
||||
device,
|
||||
env,
|
||||
signal,
|
||||
captureIosScreenshotAsync,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
device: string;
|
||||
env: BuildStepEnv;
|
||||
signal: AbortSignal;
|
||||
captureIosScreenshotAsync?: (file: string, signal: AbortSignal) => Promise<void>;
|
||||
}): Promise<Buffer> {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), 'session-preview-'));
|
||||
try {
|
||||
const options = { env, signal, timeout: 30_000, stdio: 'pipe' as const };
|
||||
const preview = path.join(directory, 'preview.webp');
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await captureIosPreviewAsync({ device, directory, preview, options });
|
||||
if (!captureIosScreenshotAsync) {
|
||||
throw new Error(
|
||||
'Cannot capture the iOS session preview: serve-sim screenshot capture is not configured.'
|
||||
);
|
||||
}
|
||||
await captureIosPreviewAsync({ captureIosScreenshotAsync, directory, preview, options });
|
||||
} else {
|
||||
await captureAndroidPreviewAsync({ device, directory, preview, options });
|
||||
}
|
||||
@@ -170,19 +200,20 @@ type CaptureOptions = {
|
||||
};
|
||||
|
||||
async function captureIosPreviewAsync({
|
||||
device,
|
||||
captureIosScreenshotAsync,
|
||||
directory,
|
||||
preview,
|
||||
options,
|
||||
}: {
|
||||
device: string;
|
||||
captureIosScreenshotAsync: (file: string, signal: AbortSignal) => Promise<void>;
|
||||
directory: string;
|
||||
preview: string;
|
||||
options: CaptureOptions;
|
||||
}): Promise<void> {
|
||||
const screenshot = path.join(directory, 'screen.png');
|
||||
const resized = path.join(directory, 'resized.png');
|
||||
await spawn('xcrun', ['simctl', 'io', device, 'screenshot', screenshot], options);
|
||||
await captureIosScreenshotAsync(screenshot, options.signal);
|
||||
options.signal.throwIfAborted();
|
||||
await spawn('sips', ['-Z', String(PREVIEW_MAX_DIMENSION), screenshot, '--out', resized], options);
|
||||
await spawn('cwebp', ['-quiet', '-q', String(PREVIEW_QUALITY), resized, '-o', preview], options);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { SystemError, formatBytes } from '@expo/eas-build-job';
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import type { BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
@@ -11,7 +11,6 @@ import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
|
||||
|
||||
const RecordingsSchema = z.array(
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { open } from 'node:fs/promises';
|
||||
|
||||
const IMAGE_HEADER_MAX_BYTES = 64 * 1024;
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
export type ScreenshotSource = 'manual' | 'agent-device' | 'argent' | 'preview';
|
||||
|
||||
export type ScreenshotMetadata = {
|
||||
source: ScreenshotSource;
|
||||
width?: number;
|
||||
height?: number;
|
||||
displayRotation?: string;
|
||||
};
|
||||
|
||||
export type ImageSize = { width: number; height: number };
|
||||
|
||||
export async function createScreenshotMetadataAsync(
|
||||
file: string,
|
||||
{ logger, ...metadata }: Omit<ScreenshotMetadata, 'width' | 'height'> & { logger: bunyan }
|
||||
): Promise<ScreenshotMetadata> {
|
||||
try {
|
||||
const size = await readImageFileSizeAsync(file);
|
||||
if (size) {
|
||||
return { ...metadata, ...size };
|
||||
}
|
||||
logger.warn(
|
||||
{ file },
|
||||
'Uploading the screenshot without its width and height because it does not start with a complete PNG, WebP or JPEG header.'
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, file },
|
||||
'Uploading the screenshot without its width and height because its header could not be read.'
|
||||
);
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
async function readImageFileSizeAsync(file: string): Promise<ImageSize | undefined> {
|
||||
const handle = await open(file, 'r');
|
||||
try {
|
||||
const { buffer, bytesRead } = await handle.read(
|
||||
Buffer.alloc(IMAGE_HEADER_MAX_BYTES),
|
||||
0,
|
||||
IMAGE_HEADER_MAX_BYTES,
|
||||
0
|
||||
);
|
||||
return readImageSize(buffer.subarray(0, bytesRead));
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function readImageSize(header: Buffer): ImageSize | undefined {
|
||||
const size = readPngSize(header) ?? readWebpSize(header) ?? readJpegSize(header);
|
||||
return size && size.width > 0 && size.height > 0 ? size : undefined;
|
||||
}
|
||||
|
||||
function readPngSize(header: Buffer): ImageSize | undefined {
|
||||
if (
|
||||
header.length < 24 ||
|
||||
!header.subarray(0, 8).equals(PNG_SIGNATURE) ||
|
||||
header.toString('latin1', 12, 16) !== 'IHDR'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return { width: header.readUInt32BE(16), height: header.readUInt32BE(20) };
|
||||
}
|
||||
|
||||
function readWebpSize(header: Buffer): ImageSize | undefined {
|
||||
if (
|
||||
header.length < 16 ||
|
||||
header.toString('latin1', 0, 4) !== 'RIFF' ||
|
||||
header.toString('latin1', 8, 12) !== 'WEBP'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
switch (header.toString('latin1', 12, 16)) {
|
||||
case 'VP8 ':
|
||||
if (header.length < 30 || header.readUIntBE(23, 3) !== 0x9d012a) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
width: header.readUInt16LE(26) & 0x3fff,
|
||||
height: header.readUInt16LE(28) & 0x3fff,
|
||||
};
|
||||
case 'VP8L': {
|
||||
if (header.length < 25 || header[20] !== 0x2f) {
|
||||
return undefined;
|
||||
}
|
||||
const bits = header.readUInt32LE(21);
|
||||
return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 };
|
||||
}
|
||||
case 'VP8X':
|
||||
if (header.length < 30) {
|
||||
return undefined;
|
||||
}
|
||||
return { width: header.readUIntLE(24, 3) + 1, height: header.readUIntLE(27, 3) + 1 };
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function readJpegSize(header: Buffer): ImageSize | undefined {
|
||||
if (header.length < 4 || header[0] !== 0xff || header[1] !== 0xd8) {
|
||||
return undefined;
|
||||
}
|
||||
let offset = 2;
|
||||
while (offset + 4 <= header.length) {
|
||||
if (header[offset] !== 0xff) {
|
||||
return undefined;
|
||||
}
|
||||
const marker = header[offset + 1];
|
||||
if (marker === 0xff) {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
if (marker === 0xd9 || marker === 0xda) {
|
||||
return undefined;
|
||||
}
|
||||
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
|
||||
offset += 2;
|
||||
continue;
|
||||
}
|
||||
const segmentEnd = offset + 2 + header.readUInt16BE(offset + 2);
|
||||
if (segmentEnd < offset + 4) {
|
||||
return undefined;
|
||||
}
|
||||
if (isJpegStartOfFrame(marker)) {
|
||||
return segmentEnd <= header.length
|
||||
? readJpegFrameSize(header.subarray(offset + 4, segmentEnd))
|
||||
: undefined;
|
||||
}
|
||||
offset = segmentEnd;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readJpegFrameSize(frame: Buffer): ImageSize | undefined {
|
||||
if (frame.length < 6 || frame[5] === 0 || frame.length !== 6 + 3 * frame[5]) {
|
||||
return undefined;
|
||||
}
|
||||
return { width: frame.readUInt16BE(3), height: frame.readUInt16BE(1) };
|
||||
}
|
||||
|
||||
function isJpegStartOfFrame(marker: number): boolean {
|
||||
return marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { formatBytes } from '@expo/eas-build-job';
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { type FSWatcher, createReadStream, watch } from 'node:fs';
|
||||
import { mkdtemp, readFile, readdir, rm, rmdir, stat } from 'node:fs/promises';
|
||||
@@ -7,8 +8,8 @@ import { z } from 'zod';
|
||||
|
||||
import type { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
|
||||
import { createScreenshotMetadataAsync } from './deviceRunSessionScreenshotMetadata';
|
||||
import {
|
||||
type ScreenshotSession,
|
||||
loadScreenshotSessionAsync,
|
||||
@@ -93,7 +94,10 @@ async function uploadScreenshotAsync(
|
||||
artifactId: name.slice(0, -4),
|
||||
name: artifactDetails.name,
|
||||
filename: artifactDetails.filename,
|
||||
metadata: artifactDetails.metadata,
|
||||
metadata: {
|
||||
...artifactDetails.metadata,
|
||||
...(await createScreenshotMetadataAsync(file, { source: 'manual', logger })),
|
||||
},
|
||||
kind: 'screenshot',
|
||||
size,
|
||||
openStream: () => createReadStream(file),
|
||||
|
||||
@@ -126,13 +126,14 @@ export class DeviceRunSessionWorkerApi {
|
||||
}
|
||||
|
||||
public async createPreviewUploadSessionAsync(
|
||||
input: { metadata?: Record<string, unknown> } = {},
|
||||
options: WorkerApiRequestOptions = {}
|
||||
): Promise<DeviceRunSessionUploadSession> {
|
||||
const { uploadSession } = await this.client.requestAsync(
|
||||
'POST',
|
||||
'preview-upload-session',
|
||||
UploadSessionSchema,
|
||||
options
|
||||
input.metadata ? { ...options, json: { metadata: input.metadata } } : options
|
||||
);
|
||||
return uploadSession;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from './remoteDeviceRunSession';
|
||||
import { LOCAL_EGRESS_PROXY_HOST, readLocalEgressHandoffAsync } from './localEgress';
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
import { EMULATOR_CAPTURE_REQUESTED_ENV } from './emulatorCapture';
|
||||
import {
|
||||
IosSimulatorRecordingUtils,
|
||||
SERVE_SIM_STOP_GRACE_PERIOD_MS,
|
||||
@@ -54,7 +55,8 @@ import {
|
||||
isPreinstalledExpoGoAsync,
|
||||
readIosApplicationIdentifierAsync,
|
||||
} from './iosAppArtifact';
|
||||
import { type ServeSimActivityPolling, startServeSimActivityPolling } from './serveSimActivity';
|
||||
import { createServeSimPreviewCapture } from './serveSimPreviewCapture';
|
||||
import { type PreviewActivityPolling, startPreviewActivityPolling } from './previewActivity';
|
||||
import { verifyLocalEgressGuardAsync } from './localEgressGuard';
|
||||
import { type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { createColdIosSessionAsync } from './coldIosSession';
|
||||
@@ -72,6 +74,10 @@ const EXPO_DEVICE_HUB_PACKAGE_NAME = 'expo-device-hub';
|
||||
const EXPO_DEVICE_HUB_MAX_DIMENSION = '960';
|
||||
const EXPO_DEVICE_HUB_VIDEO_BITRATE = '6000000';
|
||||
const EXPO_DEVICE_HUB_VIDEO_FPS = '60';
|
||||
// expo-device-hub downloads the scrcpy server on first start. This copy keeps Android sessions
|
||||
// working when the GitHub release download fails; expo-device-hub falls back to GitHub when the
|
||||
// copy is missing.
|
||||
const SCRCPY_SERVER_MIRROR_BASE_URL = 'https://storage.googleapis.com/turtle-v2/scrcpy';
|
||||
// expo-device-hub's record of each running Hub, like serve-sim's: `server-<port>.json`.
|
||||
export const EXPO_DEVICE_HUB_STATE_DIR = path.join(os.tmpdir(), 'expo-device-hub');
|
||||
// On SIGTERM the Hub finalizes the recording itself, with this deadline before it force-exits,
|
||||
@@ -370,7 +376,7 @@ export type DeviceWebPreview = {
|
||||
export type DeviceSessionHost = {
|
||||
/** 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. */
|
||||
/** Last observed preview input from the serve-sim (iOS) or expo-device-hub (Android) input log. */
|
||||
getLastPreviewActivityAt(): Date | undefined;
|
||||
/**
|
||||
* Terminal and idempotent. Finalization, host-stop and upload failures are logged and reported.
|
||||
@@ -412,6 +418,11 @@ export type DeviceSessionHostOptions = {
|
||||
onStartupError?: (error: unknown) => void;
|
||||
networkCapture?: boolean;
|
||||
networkCaptureFields?: string[];
|
||||
/**
|
||||
* This session type runs an expo-device-hub that can use the experimental emulator capture.
|
||||
* Only web preview sets it; the host rejects a capture request from any other session type.
|
||||
*/
|
||||
emulatorCapture?: boolean;
|
||||
} & ServeSimLaunchOptions;
|
||||
|
||||
export type DeviceSessionServerLaunch = {
|
||||
@@ -432,6 +443,8 @@ export type DeviceSessionServerLaunch = {
|
||||
*/
|
||||
export type DeviceSessionServer = {
|
||||
port: number;
|
||||
/** The exact serve-sim version prepared by the warm launcher. */
|
||||
serveSimVersion?: string;
|
||||
turnArgs: Promise<string[]>;
|
||||
/** Cold iOS setup runs after control readiness, alongside app installation and before launch. */
|
||||
setupSimulatorAsync?(options: { signal: AbortSignal; logger: bunyan }): Promise<void>;
|
||||
@@ -523,6 +536,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
networkCaptureFields = [],
|
||||
bootEnv = {},
|
||||
preview,
|
||||
emulatorCapture = false,
|
||||
}: DeviceSessionHostOptions,
|
||||
onFinished: (successful: boolean) => void,
|
||||
artifactLogger: bunyan,
|
||||
@@ -530,6 +544,15 @@ async function startDeviceSessionHostInternalAsync(
|
||||
): Promise<DeviceSessionHost> {
|
||||
signal?.throwIfAborted();
|
||||
const isAndroid = runtimePlatform === BuildRuntimePlatform.LINUX;
|
||||
const requestedEmulatorCapture = env[EMULATOR_CAPTURE_REQUESTED_ENV];
|
||||
if (isAndroid && requestedEmulatorCapture && !emulatorCapture) {
|
||||
// Other session types run the latest expo-device-hub, which ignores the capture, so the
|
||||
// session would start without the capture that it asked for.
|
||||
throw new UserError(
|
||||
'EAS_EMULATOR_CAPTURE_UNSUPPORTED_SESSION',
|
||||
`The experimental emulator capture (expo-emulator-capture@${requestedEmulatorCapture}) works only in web preview sessions for now. Remove experimental_emulator_capture_version from eas/start_android_emulator, or start a web preview session.`
|
||||
);
|
||||
}
|
||||
if (
|
||||
Object.keys(bootEnv).length > 0 &&
|
||||
(runtimePlatform !== BuildRuntimePlatform.DARWIN || !iosSimulatorUdid)
|
||||
@@ -584,6 +607,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
? await createAndroidDeviceSessionServerAsync(ctx, { logger })
|
||||
: await createColdIosSessionAsync(ctx, { env, logger, iosSimulatorUdid }));
|
||||
const port = server.port;
|
||||
packageVersion = server.serveSimVersion ?? packageVersion;
|
||||
const serverName = isAndroid ? 'expo-device-hub' : 'serve-sim';
|
||||
const packageSpec = isAndroid
|
||||
? createExpoDeviceHubPackageSpec(packageVersion)
|
||||
@@ -600,13 +624,17 @@ async function startDeviceSessionHostInternalAsync(
|
||||
let previewToken: string | undefined;
|
||||
let previewTask: Promise<DeviceWebPreview> | null = null;
|
||||
const previewRetirementErrors: unknown[] = [];
|
||||
// Stops new attempts to open the preview tunnel when the host finishes. An attempt in progress
|
||||
// continues, so preview retirement still waits for it and closes its listener.
|
||||
const previewRetryController = new AbortController();
|
||||
let finishTask: Promise<void> | null = null;
|
||||
let hostReady = false;
|
||||
let iosPreviewCapture: ReturnType<typeof createServeSimPreviewCapture> | undefined;
|
||||
let sessionPreview: ReturnType<typeof startDeviceRunSessionPreview> | null = null;
|
||||
const previewSetupController = new AbortController();
|
||||
let previewSetupTask: Promise<void> | undefined;
|
||||
let previewFailed = false;
|
||||
let previewActivity: ServeSimActivityPolling | null = null;
|
||||
let previewActivity: PreviewActivityPolling | null = null;
|
||||
|
||||
function openPreviewAsync(): Promise<DeviceWebPreview> {
|
||||
if (finishTask) {
|
||||
@@ -626,6 +654,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
authtoken: preview.authtoken,
|
||||
healthCheck: { path: isAndroid ? '/readyz' : '/healthz' },
|
||||
logger,
|
||||
retrySignal: previewRetryController.signal,
|
||||
});
|
||||
if (finishTask) {
|
||||
await withDeviceRunSessionTimeoutAsync(
|
||||
@@ -763,11 +792,23 @@ async function startDeviceSessionHostInternalAsync(
|
||||
env: {
|
||||
...env,
|
||||
...bootEnv,
|
||||
// The emulator start turns the experimental emulator capture on in the job env. Otherwise
|
||||
// keep it off, so a Hub build with the capture on by default does not try to attach the
|
||||
// agent with a debugger.
|
||||
...(isAndroid && env.EXPO_DEVICE_HUB_EXPERIMENTAL_EMULATOR_CAPTURE === undefined
|
||||
? { EXPO_DEVICE_HUB_EXPERIMENTAL_EMULATOR_CAPTURE: '0' }
|
||||
: {}),
|
||||
...(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 } : {}),
|
||||
...(isAndroid
|
||||
? {
|
||||
SERVE_EMU_SCRCPY_BASE_URL:
|
||||
env.SERVE_EMU_SCRCPY_BASE_URL || SCRCPY_SERVER_MIRROR_BASE_URL,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
logger,
|
||||
};
|
||||
@@ -784,6 +825,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
startup.abort(error);
|
||||
// Mark the opening tunnel as retired; its late-open path closes it.
|
||||
finishTask = Promise.resolve();
|
||||
previewRetryController.abort();
|
||||
await Promise.allSettled([simulatorSetup, retirePreviewAsync()]);
|
||||
let hostStopped = true;
|
||||
try {
|
||||
@@ -811,6 +853,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
finishAsync() {
|
||||
readinessController.abort(new Error('Simulator preview ended.'));
|
||||
return (finishTask ??= (async () => {
|
||||
previewRetryController.abort();
|
||||
const previewRetirement = retirePreviewAsync();
|
||||
if (server.requiresPreviewRetirement) {
|
||||
// Confirm the tunnel is closed before stop, so the next session cannot reach this one.
|
||||
@@ -824,6 +867,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
await sessionPreview?.stopAsync();
|
||||
await previewActivity?.stopAsync();
|
||||
},
|
||||
cleanupPreviewCaptureAsync: async () => iosPreviewCapture?.cleanupAfterHostStoppedAsync(),
|
||||
previewServer,
|
||||
screenshots,
|
||||
serverName,
|
||||
@@ -981,14 +1025,31 @@ async function startDeviceSessionHostInternalAsync(
|
||||
secrets.push(previewToken);
|
||||
if (!isAndroid) {
|
||||
IosSimulatorRecordingUtils.useServeSimPackage(packageSpec);
|
||||
if (!finishTask) {
|
||||
previewActivity = startServeSimActivityPolling({ port, token: previewToken, logger });
|
||||
}
|
||||
}
|
||||
if (!finishTask) {
|
||||
previewActivity = startPreviewActivityPolling({
|
||||
server: isAndroid ? 'expo-device-hub' : 'serve-sim',
|
||||
port,
|
||||
token: previewToken,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
// Android installed FFmpeg before launching the host. The optional thumbnail must not delay
|
||||
// readiness on macOS, so it starts after its encoder is installed there.
|
||||
previewSetupTask = (async () => {
|
||||
if (!isAndroid) {
|
||||
iosPreviewCapture = createServeSimPreviewCapture({
|
||||
port,
|
||||
token: previewToken!,
|
||||
device,
|
||||
tmpdir: serveSimTmpdir,
|
||||
});
|
||||
if (!(await iosPreviewCapture.isSupportedAsync(previewSetupController.signal))) {
|
||||
logger.warn(
|
||||
'This serve-sim version does not support automated screenshots; skipping session previews.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
await ensureMacosPreviewEncoderInstalledAsync({
|
||||
env,
|
||||
logger,
|
||||
@@ -1002,6 +1063,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
logger,
|
||||
captureAsync: signal =>
|
||||
captureDeviceRunSessionPreviewAsync({
|
||||
captureIosScreenshotAsync: iosPreviewCapture?.captureAsync,
|
||||
runtimePlatform,
|
||||
device: previewDevice ?? device,
|
||||
env,
|
||||
@@ -1077,6 +1139,7 @@ async function finishDeviceSessionHostAsync(
|
||||
{
|
||||
previewRetirement,
|
||||
stopSessionPreviewAsync,
|
||||
cleanupPreviewCaptureAsync,
|
||||
previewServer,
|
||||
screenshots,
|
||||
serverName,
|
||||
@@ -1088,6 +1151,7 @@ async function finishDeviceSessionHostAsync(
|
||||
}: {
|
||||
previewRetirement: Promise<boolean>;
|
||||
stopSessionPreviewAsync: () => Promise<void>;
|
||||
cleanupPreviewCaptureAsync: () => Promise<void>;
|
||||
previewServer: DetachedProcessHandle;
|
||||
screenshots: { finishAsync(hostStopped: boolean): Promise<void> };
|
||||
serverName: string;
|
||||
@@ -1121,6 +1185,13 @@ async function finishDeviceSessionHostAsync(
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `Could not stop the ${serverName} session host.`);
|
||||
}
|
||||
if (hostStopped) {
|
||||
try {
|
||||
await cleanupPreviewCaptureAsync();
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Could not remove session preview capture files.');
|
||||
}
|
||||
}
|
||||
const previewRetired = await previewRetirement;
|
||||
onStopped(hostStopped && !hostExited && previewRetired);
|
||||
await screenshots.finishAsync(hostStopped);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type BuildStepEnv } from '@expo/steps';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { installIsolatedNpmPackageAsync } from '../../utils/installIsolatedNpmPackageAsync';
|
||||
|
||||
// Experimental emulator capture. The Android Emulator preloads the expo-emulator-capture agent,
|
||||
// which serves the emulator screen as H.264 on a Unix socket, and expo-device-hub streams the
|
||||
// screen from that socket. The step input names the agent version, so a new agent release needs
|
||||
// no change in build-tools or in the backend, while the package keeps its library at this path.
|
||||
|
||||
const PACKAGE_NAME = 'expo-emulator-capture';
|
||||
/**
|
||||
* `eas/start_android_emulator` sets this job env variable to the requested agent version. The
|
||||
* session host rejects it in every session type except web preview, the only one that runs an
|
||||
* expo-device-hub version that can use the capture for now.
|
||||
*/
|
||||
export const EMULATOR_CAPTURE_REQUESTED_ENV = 'EAS_EXPERIMENTAL_EMULATOR_CAPTURE_VERSION';
|
||||
const LINUX_AGENT_LIBRARY = path.join(
|
||||
'node_modules',
|
||||
PACKAGE_NAME,
|
||||
'dist',
|
||||
'linux-x64',
|
||||
'libexpo_emulator_capture.so'
|
||||
);
|
||||
/**
|
||||
* An npm version, a simple range such as `0.1.x`, or a dist-tag: letters, digits, `.`, `_` and `-`.
|
||||
* It keeps out specs that install another package, such as `npm:`, `file:` or git URLs. eas-cli
|
||||
* and www check the same pattern.
|
||||
*/
|
||||
const VERSION_PATTERN = /^[0-9A-Za-z][0-9A-Za-z._-]{0,127}$/;
|
||||
/** The registry normally answers in seconds; a stalled install must not hold the emulator. */
|
||||
const INSTALL_TIMEOUT_MS = 5 * 60_000;
|
||||
|
||||
/** The expo-emulator-capture version to preload, or undefined when the capture is off. */
|
||||
export function parseExperimentalEmulatorCaptureVersionInput(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'string' || !VERSION_PATTERN.test(value)) {
|
||||
throw new UserError(
|
||||
'EAS_EMULATOR_CAPTURE_INVALID_VERSION',
|
||||
`experimental_emulator_capture_version must be an npm version or dist-tag of ${PACKAGE_NAME}, made of letters, digits, ".", "_" and "-".`
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the agent package and returns the path of its library. Returns undefined after a
|
||||
* warning when the agent cannot run on this host or the install fails. The emulator then starts
|
||||
* as it does without the capture.
|
||||
*/
|
||||
export async function installEmulatorCaptureAsync({
|
||||
version,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
version: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<string | undefined> {
|
||||
if (process.platform !== 'linux' || process.arch !== 'x64') {
|
||||
logger.warn(
|
||||
`The experimental emulator capture runs only on Linux x86-64 hosts. The emulator starts without ${PACKAGE_NAME}.`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
let directory: string;
|
||||
try {
|
||||
directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-emulator-capture-package-'));
|
||||
logger.info(`Installing ${PACKAGE_NAME}@${version} for the experimental emulator capture.`);
|
||||
await installIsolatedNpmPackageAsync({
|
||||
packageSpec: `${PACKAGE_NAME}@${version}`,
|
||||
directory,
|
||||
npmCacheUrl: env.EAS_BUILD_NPM_CACHE_URL,
|
||||
pathEnv: env.PATH,
|
||||
logger,
|
||||
signal: AbortSignal.timeout(INSTALL_TIMEOUT_MS),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
`Could not install ${PACKAGE_NAME}@${version}. The emulator starts without it.`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
const library = path.join(directory, LINUX_AGENT_LIBRARY);
|
||||
if (!fs.existsSync(library)) {
|
||||
logger.warn(
|
||||
`${PACKAGE_NAME}@${version} has no Linux x86-64 agent at ${library}. The emulator starts without it.`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
return library;
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import { type Env, SystemError } from '@expo/eas-build-job';
|
||||
import { type Client, CombinedError } from '@urql/core';
|
||||
import { graphql } from 'gql.tada';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import { type SandboxArtifactUploadSession } from './sandboxWorkerApi';
|
||||
import { WorkerApiError, type WorkerApiRequestOptions } from './workerApi';
|
||||
|
||||
const MARK_SANDBOX_READY_MUTATION = graphql(`
|
||||
mutation MarkSandboxReady($sandboxId: ID!) {
|
||||
sandbox {
|
||||
markSandboxReady(sandboxId: $sandboxId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const CREATE_SANDBOX_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
|
||||
mutation CreateSandboxArtifactUploadSession(
|
||||
$sandboxId: ID!
|
||||
$input: CreateSandboxArtifactUploadSessionInput!
|
||||
) {
|
||||
sandbox {
|
||||
createArtifactUploadSession(sandboxId: $sandboxId, input: $input) {
|
||||
artifact {
|
||||
id
|
||||
}
|
||||
uploadSession {
|
||||
url
|
||||
headers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const FINALIZE_SANDBOX_ARTIFACT_MUTATION = graphql(`
|
||||
mutation FinalizeSandboxArtifact($artifactId: ID!) {
|
||||
sandbox {
|
||||
finalizeArtifact(artifactId: $artifactId) {
|
||||
id
|
||||
downloadUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const FINALIZE_ATTEMPTS = 3;
|
||||
const FINALIZE_TIMEOUT_MS = 15_000;
|
||||
|
||||
export class LegacySandboxApi {
|
||||
constructor(
|
||||
private readonly graphqlClient: Client,
|
||||
private readonly sandboxId: string
|
||||
) {}
|
||||
|
||||
public async markReadyAsync({ signal }: WorkerApiRequestOptions = {}): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
const result = await this.graphqlClient
|
||||
.mutation(
|
||||
MARK_SANDBOX_READY_MUTATION,
|
||||
{ sandboxId: this.sandboxId },
|
||||
graphqlAbortContext(signal)
|
||||
)
|
||||
.toPromise();
|
||||
signal?.throwIfAborted();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
}
|
||||
|
||||
public async createArtifactUploadSessionAsync(
|
||||
input: { name: string; filename: string; size: number },
|
||||
{ signal, timeoutMs }: WorkerApiRequestOptions = {}
|
||||
): Promise<SandboxArtifactUploadSession> {
|
||||
const requestSignal = AbortSignal.any([
|
||||
...(signal ? [signal] : []),
|
||||
...(timeoutMs !== undefined ? [AbortSignal.timeout(timeoutMs)] : []),
|
||||
]);
|
||||
const result = await this.graphqlClient
|
||||
.mutation(
|
||||
CREATE_SANDBOX_ARTIFACT_UPLOAD_SESSION_MUTATION,
|
||||
{ sandboxId: this.sandboxId, input },
|
||||
graphqlAbortContext(requestSignal)
|
||||
)
|
||||
.toPromise();
|
||||
requestSignal.throwIfAborted();
|
||||
if (result.error) {
|
||||
const { graphQLErrors } = result.error;
|
||||
const codes = Array.from(new Set(graphQLErrors.map(error => error.extensions?.errorCode)));
|
||||
throw new WorkerApiError(
|
||||
graphQLErrors.length > 0
|
||||
? graphQLErrors.map(error => error.message).join('\n')
|
||||
: result.error.message,
|
||||
result.error.response?.status,
|
||||
{ cause: result.error },
|
||||
codes.length === 1 && typeof codes[0] === 'string' ? codes[0] : undefined
|
||||
);
|
||||
}
|
||||
const { artifact, uploadSession } = result.data!.sandbox.createArtifactUploadSession;
|
||||
return {
|
||||
artifact,
|
||||
uploadSession: {
|
||||
url: uploadSession.url,
|
||||
headers: uploadSession.headers as Record<string, string>,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async finalizeArtifactAsync(
|
||||
artifactId: string,
|
||||
{ signal }: WorkerApiRequestOptions = {}
|
||||
): Promise<string> {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const timeoutSignal = AbortSignal.timeout(FINALIZE_TIMEOUT_MS);
|
||||
const finalizeSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
||||
try {
|
||||
const result = await this.graphqlClient
|
||||
.mutation(
|
||||
FINALIZE_SANDBOX_ARTIFACT_MUTATION,
|
||||
{ artifactId },
|
||||
graphqlAbortContext(finalizeSignal)
|
||||
)
|
||||
.toPromise();
|
||||
finalizeSignal.throwIfAborted();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
const { downloadUrl } = result.data!.sandbox.finalizeArtifact;
|
||||
if (downloadUrl === null) {
|
||||
throw new SystemError(`Finalized sandbox artifact ${artifactId} has no download URL.`);
|
||||
}
|
||||
return downloadUrl;
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
const retryable =
|
||||
timeoutSignal.aborted ||
|
||||
(error instanceof CombinedError && isTransientGraphqlError(error));
|
||||
if (!retryable || attempt >= FINALIZE_ATTEMPTS) {
|
||||
throw error;
|
||||
}
|
||||
await delay(500 * attempt, undefined, { signal });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLegacySandboxConnection({
|
||||
graphqlClient,
|
||||
env,
|
||||
sandboxId,
|
||||
}: {
|
||||
graphqlClient: Client;
|
||||
env: Env;
|
||||
sandboxId: string;
|
||||
}): { sandboxApi: LegacySandboxApi; credential: string; commandEnv: Env } {
|
||||
const { __EAS_SANDBOX_MCP_TOKEN: credential, ...commandEnv } = env;
|
||||
if (!credential) {
|
||||
throw new SystemError(
|
||||
'This sandbox job has neither EAS_SANDBOX_WORKER_TOKEN nor __EAS_SANDBOX_MCP_TOKEN. ' +
|
||||
'www gives one of them to every sandbox job. Start a new sandbox, and contact support if ' +
|
||||
'it happens again.'
|
||||
);
|
||||
}
|
||||
return { sandboxApi: new LegacySandboxApi(graphqlClient, sandboxId), credential, commandEnv };
|
||||
}
|
||||
|
||||
function isTransientGraphqlError(error: CombinedError): boolean {
|
||||
const status: number | undefined = error.response?.status;
|
||||
return status === undefined ? error.networkError !== undefined : status >= 500;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import {
|
||||
type AndroidEmulatorLogSource,
|
||||
AndroidEmulatorLogSourcesSchema,
|
||||
} from '../../utils/androidEmulatorLogSources';
|
||||
|
||||
/**
|
||||
* `log_sources` is unprefixed like every other step input, so the platform
|
||||
* rule is checked here at runtime, the way `network_capture` is.
|
||||
*/
|
||||
export function parseLogSourcesInput(
|
||||
value: unknown,
|
||||
{
|
||||
runtimePlatform,
|
||||
fallback,
|
||||
}: { runtimePlatform: BuildRuntimePlatform; fallback: readonly AndroidEmulatorLogSource[] }
|
||||
): readonly AndroidEmulatorLogSource[] {
|
||||
if (value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
const sources = AndroidEmulatorLogSourcesSchema.parse(value);
|
||||
if (sources.length > 0 && runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
throw new UserError(
|
||||
'EAS_LOG_SOURCES_INVALID_INPUT',
|
||||
`Input "log_sources" selects Android emulator logs, and this session runs on iOS. Drop "log_sources", or run the session on Android.`
|
||||
);
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
@@ -12,10 +12,23 @@ import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
const PROBE_INTERVAL_MS = 15_000;
|
||||
const FAILURE_THRESHOLD = 3;
|
||||
const STOP_TIMEOUT_MS = 4_000;
|
||||
const OPEN_TIMEOUT_MS = 15_000;
|
||||
// @ngrok/ngrok runs one open at a time, so nothing can open again until a timed-out open
|
||||
// settles. The first open waits longer instead, and keeps a listener that arrives late. Retries
|
||||
// use OPEN_TIMEOUT_MS, so all attempts fit in the 60-second preview opening bound.
|
||||
const FIRST_OPEN_TIMEOUT_MS = 19_000;
|
||||
const INITIAL_OPEN_ATTEMPTS = 3;
|
||||
const INITIAL_OPEN_RETRY_DELAY_MS = 1_000;
|
||||
/** ngrok error codes for failed control-plane requests that can succeed on a retry. */
|
||||
const RETRYABLE_NGROK_ERROR_CODES = new Set(['ERR_NGROK_802']);
|
||||
|
||||
export type NgrokTunnelHandle = {
|
||||
url: string;
|
||||
subdomainId: string;
|
||||
/**
|
||||
* Stops the tunnel. The local proxy drops every connection at once; then the listeners close.
|
||||
* Rejects when the stop does not complete within the stop time-out.
|
||||
*/
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -28,6 +41,8 @@ export async function startNgrokTunnelAsync({
|
||||
rewriteHostHeader,
|
||||
healthCheck,
|
||||
logger,
|
||||
signal: startupSignal,
|
||||
retrySignal,
|
||||
}: {
|
||||
port: number;
|
||||
subdomainPrefix: string;
|
||||
@@ -37,11 +52,21 @@ export async function startNgrokTunnelAsync({
|
||||
rewriteHostHeader?: boolean;
|
||||
healthCheck?: { path: string; statuses?: number[] };
|
||||
logger: bunyan;
|
||||
/** Cancels startup, including retries of the first open. It has no effect after startup. */
|
||||
signal?: AbortSignal;
|
||||
/**
|
||||
* Stops new attempts of the first open without cancelling the attempt in progress, so the
|
||||
* caller can still wait for that attempt and close its listener. The first attempt always starts.
|
||||
*/
|
||||
retrySignal?: AbortSignal;
|
||||
}): Promise<NgrokTunnelHandle> {
|
||||
startupSignal?.throwIfAborted();
|
||||
const domain = `${subdomainPrefix}-${subdomainId}.${baseDomain}`;
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
const sockets = new Set<Socket>();
|
||||
// After an abort, this proxy drops every connection, even while an ngrok listener is still open.
|
||||
// Session teardown relies on this (TOOL_TUNNEL_TEARDOWN_OPTIONS), so keep it when changing this.
|
||||
const target = createServer({ allowHalfOpen: true }, client => {
|
||||
if (signal.aborted) {
|
||||
client.destroy();
|
||||
@@ -154,10 +179,18 @@ export async function startNgrokTunnelAsync({
|
||||
}
|
||||
}
|
||||
|
||||
async function openAsync(forceNewSession = false): Promise<ngrok.Listener> {
|
||||
/**
|
||||
* No open is queued behind a pending one: @ngrok/ngrok runs every forward behind one
|
||||
* process-wide lock, so a new open cannot start until the pending one settles.
|
||||
*/
|
||||
async function openAsync({
|
||||
forceNewSession = false,
|
||||
timeoutMs = OPEN_TIMEOUT_MS,
|
||||
}: { forceNewSession?: boolean; timeoutMs?: number } = {}): Promise<ngrok.Listener> {
|
||||
if (pendingOpen) {
|
||||
throw new SystemError(`A previous ngrok tunnel open for ${domain} is still pending.`);
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
let acquired: ngrok.Listener | undefined;
|
||||
let retirement: Promise<void> | undefined;
|
||||
const retireAcquired = () => {
|
||||
@@ -167,7 +200,7 @@ export async function startNgrokTunnelAsync({
|
||||
};
|
||||
try {
|
||||
return await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Ngrok tunnel open', timeoutMs: 15_000, signal },
|
||||
{ name: 'Ngrok tunnel open', timeoutMs, signal },
|
||||
async openSignal => {
|
||||
openSignal.throwIfAborted();
|
||||
const opening = ngrok
|
||||
@@ -184,13 +217,28 @@ export async function startNgrokTunnelAsync({
|
||||
pendingOpen = undefined;
|
||||
});
|
||||
pendingOpen = opening;
|
||||
void opening
|
||||
.then(() => {
|
||||
// Records how long a timed-out or cancelled open takes to settle, to tune the time-out.
|
||||
const logLateSettlement = (outcome: 'opened' | 'failed', err?: unknown) => {
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
logger.info(
|
||||
{ err, outcome, elapsedMs },
|
||||
`ngrok tunnel open for ${domain} ${outcome} after ${elapsedMs} ms, after it timed out or was cancelled.`
|
||||
);
|
||||
};
|
||||
void opening.then(
|
||||
() => {
|
||||
if (openSignal.aborted) {
|
||||
logLateSettlement('opened');
|
||||
retireAcquired();
|
||||
}
|
||||
})
|
||||
.catch(() => releaseTarget());
|
||||
},
|
||||
err => {
|
||||
if (openSignal.aborted) {
|
||||
logLateSettlement('failed', err);
|
||||
}
|
||||
releaseTarget();
|
||||
}
|
||||
);
|
||||
const listener = await opening;
|
||||
openSignal.throwIfAborted();
|
||||
return listener;
|
||||
@@ -202,10 +250,56 @@ export async function startNgrokTunnelAsync({
|
||||
}
|
||||
}
|
||||
|
||||
// ngrok sometimes rejects an open because a control-plane request failed (for example
|
||||
// ERR_NGROK_802), so a rejected first open is retried before the session fails. A timed-out
|
||||
// open is not retried: it still holds the ngrok lock, so a retry would only wait behind it.
|
||||
async function openInitialAsync(): Promise<ngrok.Listener> {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
return await openAsync({
|
||||
// ERR_NGROK_802 is a server error while the endpoint binds on the shared agent session,
|
||||
// so a retry connects a new session, which can reach a different ngrok server.
|
||||
// @ngrok/ngrok 1.7.0 replaces its shared session but does not close the old one: the old
|
||||
// session and its listeners stay connected until the process exits.
|
||||
forceNewSession: attempt > 1,
|
||||
timeoutMs: attempt === 1 ? FIRST_OPEN_TIMEOUT_MS : OPEN_TIMEOUT_MS,
|
||||
});
|
||||
} catch (err) {
|
||||
// A cancelled startup fails with the caller's reason, so the caller can tell it from an error.
|
||||
signal.throwIfAborted();
|
||||
if (
|
||||
attempt >= INITIAL_OPEN_ATTEMPTS ||
|
||||
!isRetryableNgrokError(err) ||
|
||||
retrySignal?.aborted
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
logger.warn(
|
||||
{ err, attempt },
|
||||
`Could not open ngrok tunnel ${domain} (attempt ${attempt} of ${INITIAL_OPEN_ATTEMPTS}); retrying.`
|
||||
);
|
||||
await waitAsync(
|
||||
INITIAL_OPEN_RETRY_DELAY_MS * attempt,
|
||||
retrySignal ? AbortSignal.any([signal, retrySignal]) : signal
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
if (retrySignal?.aborted) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Added after the target listens, so the finally below always removes it.
|
||||
const abortStartup = () => controller.abort(startupSignal?.reason);
|
||||
startupSignal?.addEventListener('abort', abortStartup, { once: true });
|
||||
if (startupSignal?.aborted) {
|
||||
abortStartup();
|
||||
}
|
||||
logger.info(`Starting ngrok tunnel ${domain} -> http://localhost:${port}.`);
|
||||
let publicUrl: string | null;
|
||||
try {
|
||||
currentListener = await openAsync();
|
||||
currentListener = await openInitialAsync();
|
||||
publicUrl = currentListener.url();
|
||||
if (!publicUrl) {
|
||||
await closeAsync(currentListener, signal);
|
||||
@@ -216,6 +310,8 @@ export async function startNgrokTunnelAsync({
|
||||
controller.abort();
|
||||
releaseTarget();
|
||||
throw error;
|
||||
} finally {
|
||||
startupSignal?.removeEventListener('abort', abortStartup);
|
||||
}
|
||||
|
||||
const url = publicUrl;
|
||||
@@ -299,7 +395,7 @@ export async function startNgrokTunnelAsync({
|
||||
}
|
||||
attempts++;
|
||||
try {
|
||||
const reopened = await openAsync(/* forceNewSession */ true);
|
||||
const reopened = await openAsync({ forceNewSession: true });
|
||||
if (reopened.url() !== url) {
|
||||
await closeAsync(reopened, signal);
|
||||
throw new SystemError(
|
||||
@@ -350,6 +446,26 @@ export async function startNgrokTunnelAsync({
|
||||
};
|
||||
}
|
||||
|
||||
function isRetryableNgrokError(err: unknown): boolean {
|
||||
const code = getNgrokErrorCode(err);
|
||||
return code !== null && RETRYABLE_NGROK_ERROR_CODES.has(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ngrok error code of a failed open. @ngrok/ngrok sets `errorCode` from the message. When it is
|
||||
* missing, the code is read from the message here.
|
||||
*/
|
||||
function getNgrokErrorCode(err: unknown): string | null {
|
||||
if (!(err instanceof Error)) {
|
||||
return null;
|
||||
}
|
||||
const { errorCode } = err as { errorCode?: unknown };
|
||||
if (typeof errorCode === 'string') {
|
||||
return errorCode;
|
||||
}
|
||||
return /\berror_code: (ERR_NGROK_\d+)\b/.exec(err.message)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function waitAsync(ms: number, signal: AbortSignal): Promise<boolean> {
|
||||
if (signal.aborted) {
|
||||
return Promise.resolve(false);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { killProcessGroup } from '../../utils/processes';
|
||||
export async function preparePreinstalledWdaAsync({
|
||||
runtimeIdentifier,
|
||||
appiumHome,
|
||||
prepared = false,
|
||||
appiumBinPath,
|
||||
appiumEnv,
|
||||
logger,
|
||||
@@ -16,6 +17,8 @@ export async function preparePreinstalledWdaAsync({
|
||||
}: {
|
||||
runtimeIdentifier: string | undefined;
|
||||
appiumHome: string;
|
||||
/** A prepared Appium home that already holds the runner. */
|
||||
prepared?: boolean;
|
||||
appiumBinPath: string;
|
||||
appiumEnv: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
@@ -32,6 +35,13 @@ export async function preparePreinstalledWdaAsync({
|
||||
|
||||
const outdir = path.join(appiumHome, 'preinstalled-wda');
|
||||
const runner = path.join(outdir, 'WebDriverAgentRunner-Runner.app');
|
||||
if (prepared) {
|
||||
logger.info('Using preinstalled WebDriverAgent to avoid xcodebuild during session creation.');
|
||||
return {
|
||||
'appium:usePreinstalledWDA': true,
|
||||
'appium:prebuiltWDAPath': runner,
|
||||
};
|
||||
}
|
||||
try {
|
||||
// The driver selects the release matching its installed WDA and the host architecture.
|
||||
// Keep the Simulator runner intact; removing its XCTest frameworks breaks launch.
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
const REQUEST_TIMEOUT_MS = 5_000;
|
||||
// expo-device-hub serves serve-emu, which records Android preview input, under this prefix.
|
||||
const EXPO_DEVICE_HUB_SERVE_EMU_PREFIX = '/vendor/serve-emu';
|
||||
|
||||
/** The preview server whose input log counts as session activity. */
|
||||
export type PreviewActivityServer = 'serve-sim' | 'expo-device-hub';
|
||||
|
||||
export type PreviewActivityPolling = {
|
||||
/**
|
||||
* When a new input log entry (input or host action, not stream traffic) was first seen. Reports
|
||||
* now while a read fails for a reason other than a refused connection or a device-unavailable
|
||||
* answer, so an unobservable session stays active.
|
||||
*/
|
||||
getLastActivityObservedAt: () => Date | undefined;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
/** What one read of the server's input log shows. */
|
||||
export type PreviewInputLogState = {
|
||||
/** The newest entry's ID, or undefined when the log is empty. */
|
||||
latestEventId: number | undefined;
|
||||
/**
|
||||
* When the server logged the newest entry. A new recording starts its IDs again at 1, so the ID
|
||||
* alone can repeat for new input.
|
||||
*/
|
||||
latestEventAt: string | undefined;
|
||||
/** The server is replaying recorded input, which it does not log again. */
|
||||
replaying: boolean;
|
||||
/**
|
||||
* When the latest replay started, kept after it ends, so a replay that runs entirely between two
|
||||
* reads still shows up as a change.
|
||||
*/
|
||||
replayStartedAt: string | undefined;
|
||||
};
|
||||
|
||||
type ReadInputLogAsync = (options: {
|
||||
port: number;
|
||||
token: string;
|
||||
signal?: AbortSignal;
|
||||
}) => Promise<PreviewInputLogState>;
|
||||
|
||||
const READ_INPUT_LOG: Record<PreviewActivityServer, ReadInputLogAsync> = {
|
||||
'serve-sim': readServeSimInputLogAsync,
|
||||
'expo-device-hub': readExpoDeviceHubInputLogAsync,
|
||||
};
|
||||
|
||||
/**
|
||||
* The server is up but has no device to take input, such as an offline emulator. Like a down
|
||||
* server, it receives no input, so the last observation stands.
|
||||
*/
|
||||
class PreviewDeviceUnavailableError extends Error {}
|
||||
|
||||
/** Nothing listens on the port. A reset or dropped response can come from a running server. */
|
||||
function isServerDownError(err: unknown): boolean {
|
||||
if (err instanceof PreviewDeviceUnavailableError) {
|
||||
return true;
|
||||
}
|
||||
// Not `instanceof`: undici errors can come from another realm.
|
||||
let error: unknown = err;
|
||||
for (let depth = 0; depth < 5 && typeof error === 'object' && error !== null; depth++) {
|
||||
const { code, cause } = error as { code?: unknown; cause?: unknown };
|
||||
if (code === 'ECONNREFUSED') {
|
||||
return true;
|
||||
}
|
||||
error = cause;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A changed newest event ID, a new replay, or a running replay counts as activity, timed by the
|
||||
* local clock.
|
||||
*/
|
||||
export function startPreviewActivityPolling({
|
||||
server,
|
||||
port,
|
||||
token,
|
||||
logger,
|
||||
pollIntervalMs = POLL_INTERVAL_MS,
|
||||
requestTimeoutMs = REQUEST_TIMEOUT_MS,
|
||||
}: {
|
||||
server: PreviewActivityServer;
|
||||
port: number;
|
||||
token: string;
|
||||
logger: bunyan;
|
||||
pollIntervalMs?: number;
|
||||
requestTimeoutMs?: number;
|
||||
}): PreviewActivityPolling {
|
||||
const readInputLogAsync = READ_INPUT_LOG[server];
|
||||
const controller = new AbortController();
|
||||
let lastEventKey: string | undefined;
|
||||
let lastReplayStartedAt: string | undefined;
|
||||
let lastActivityObservedAt: Date | undefined;
|
||||
let eventLogUnavailable = false;
|
||||
let didWarnUnavailable = false;
|
||||
let didWarnDown = false;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
let pending: Promise<void> = Promise.resolve();
|
||||
|
||||
const pollAsync = async (): Promise<void> => {
|
||||
try {
|
||||
const { latestEventId, latestEventAt, replaying, replayStartedAt } = await readInputLogAsync({
|
||||
port,
|
||||
token,
|
||||
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(requestTimeoutMs)]),
|
||||
});
|
||||
eventLogUnavailable = false;
|
||||
const eventKey =
|
||||
latestEventId === undefined ? undefined : `${latestEventId}@${latestEventAt ?? ''}`;
|
||||
if (eventKey !== undefined && eventKey !== lastEventKey) {
|
||||
lastActivityObservedAt = new Date();
|
||||
}
|
||||
lastEventKey = eventKey;
|
||||
if (replayStartedAt !== undefined && replayStartedAt !== lastReplayStartedAt) {
|
||||
lastReplayStartedAt = replayStartedAt;
|
||||
lastActivityObservedAt = new Date();
|
||||
}
|
||||
if (replaying) {
|
||||
lastActivityObservedAt = new Date();
|
||||
}
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
// Only a down server or device, which receives no input either, keeps the last observation.
|
||||
// A timeout proves neither: expo-device-hub holds the read while it changes the capture
|
||||
// source, and the open preview keeps taking input meanwhile.
|
||||
eventLogUnavailable = !isServerDownError(err);
|
||||
if (eventLogUnavailable && !didWarnUnavailable) {
|
||||
didWarnUnavailable = true;
|
||||
logger.warn(
|
||||
{ err },
|
||||
`Could not read the ${server} input log. Preview input cannot be observed, so the ` +
|
||||
'idle timeout does not stop this session while the log cannot be read.'
|
||||
);
|
||||
} else if (!eventLogUnavailable && !didWarnDown) {
|
||||
didWarnDown = true;
|
||||
logger.warn({ err }, `Could not read ${server} preview activity.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
const tick = (): void => {
|
||||
pending = pollAsync().finally(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
timer = setTimeout(tick, pollIntervalMs);
|
||||
timer.unref();
|
||||
}
|
||||
});
|
||||
};
|
||||
tick();
|
||||
return {
|
||||
getLastActivityObservedAt: () => (eventLogUnavailable ? new Date() : lastActivityObservedAt),
|
||||
stopAsync: async () => {
|
||||
controller.abort();
|
||||
clearTimeout(timer);
|
||||
await pending;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function readServeSimInputLogAsync({
|
||||
port,
|
||||
token,
|
||||
signal,
|
||||
}: {
|
||||
port: number;
|
||||
token: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<PreviewInputLogState> {
|
||||
return await readInputLogAsync({
|
||||
url: `http://127.0.0.1:${port}/api/event-log?limit=1`,
|
||||
token,
|
||||
signal,
|
||||
logName: 'serve-sim event log',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads serve-emu's session recording through expo-device-hub. It records browser preview and REST
|
||||
* input: touches, keys, text, navigation buttons, and location. It does not record input sent with
|
||||
* `record: false` (the preview's Reload button) or actions such as rotation and screenshots, so
|
||||
* those alone do not keep a session active. Controller commands count through the session's own
|
||||
* event stream. The hub scopes the request to its single device.
|
||||
*/
|
||||
export async function readExpoDeviceHubInputLogAsync({
|
||||
port,
|
||||
token,
|
||||
signal,
|
||||
}: {
|
||||
port: number;
|
||||
token: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<PreviewInputLogState> {
|
||||
return await readInputLogAsync({
|
||||
// The hub returns the whole recording and ignores `limit`; serve-emu's paged route honors it.
|
||||
url: `http://127.0.0.1:${port}${EXPO_DEVICE_HUB_SERVE_EMU_PREFIX}/api/session?limit=1`,
|
||||
token,
|
||||
signal,
|
||||
logName: 'expo-device-hub input log',
|
||||
// The hub answers 503 while it cannot reach the emulator.
|
||||
deviceUnavailableStatus: 503,
|
||||
});
|
||||
}
|
||||
|
||||
async function readInputLogAsync({
|
||||
url,
|
||||
token,
|
||||
signal,
|
||||
logName,
|
||||
deviceUnavailableStatus,
|
||||
}: {
|
||||
url: string;
|
||||
token: string;
|
||||
signal?: AbortSignal;
|
||||
logName: string;
|
||||
deviceUnavailableStatus?: number;
|
||||
}): Promise<PreviewInputLogState> {
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
const message = `${logName} request failed: ${response.status}`;
|
||||
throw response.status === deviceUnavailableStatus
|
||||
? new PreviewDeviceUnavailableError(message)
|
||||
: new Error(message);
|
||||
}
|
||||
const text = await response.text();
|
||||
type ReplayState = { replaying?: unknown; replayStartedAt?: unknown };
|
||||
// The hub route returns the recorder snapshot; serve-emu's paged route nests it under `session`.
|
||||
let body: ({ events?: unknown; session?: ReplayState } & ReplayState) | undefined;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {}
|
||||
if (!Array.isArray(body?.events)) {
|
||||
throw new Error(`${logName} response has no events array.`);
|
||||
}
|
||||
const replayState: ReplayState = body.session ?? body;
|
||||
const replaying = replayState.replaying === true;
|
||||
const replayStartedAt =
|
||||
typeof replayState.replayStartedAt === 'string' ? replayState.replayStartedAt : undefined;
|
||||
if (body.events.length === 0) {
|
||||
return { latestEventId: undefined, latestEventAt: undefined, replaying, replayStartedAt };
|
||||
}
|
||||
const latest = body.events.at(-1) as { id?: unknown; at?: unknown; timestamp?: unknown } | null;
|
||||
if (typeof latest?.id !== 'number') {
|
||||
throw new Error(`${logName} entry has no numeric ID.`);
|
||||
}
|
||||
// serve-emu names the time `at`; serve-sim names it `timestamp`.
|
||||
const at = latest.at ?? latest.timestamp;
|
||||
return {
|
||||
latestEventId: latest.id,
|
||||
latestEventAt: typeof at === 'string' ? at : undefined,
|
||||
replaying,
|
||||
replayStartedAt,
|
||||
};
|
||||
}
|
||||
@@ -649,34 +649,66 @@ export async function stopDetachedProcessAsync(
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs every named teardown to completion and logs each failure. The first failure is rethrown
|
||||
* only when the session body succeeded, so a teardown error cannot replace the error that ended it.
|
||||
* Teardown options for the stop of a tool tunnel (agent-device, Appium, or Argent) at the end of a
|
||||
* session. The stop first makes the tunnel's local proxy drop every connection (see
|
||||
* `startNgrokTunnelAsync`), so a listener that does not close in time cannot reach the session. The
|
||||
* worker process exits after the job, and this closes the process's ngrok sessions. The warm-host
|
||||
* preview tunnel does not use these options: its retirement still fails the session.
|
||||
*/
|
||||
export const TOOL_TUNNEL_TEARDOWN_OPTIONS = { failsSession: false } as const;
|
||||
|
||||
/** A named task that `finishRemoteSessionAsync` waits for when a remote session ends. */
|
||||
export type RemoteSessionTeardown = [
|
||||
name: string,
|
||||
task: Promise<unknown> | undefined,
|
||||
options?: {
|
||||
/**
|
||||
* False when a failure of this task cannot affect the user or a later session, so it is
|
||||
* logged and reported to Sentry but does not fail a session that succeeded. Defaults to true.
|
||||
*/
|
||||
failsSession?: boolean;
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Runs every named teardown to completion and logs each failure. The first failure of a task that
|
||||
* fails the session is rethrown only when the session body succeeded, so a teardown error cannot
|
||||
* replace the error that ended it.
|
||||
*/
|
||||
export async function finishRemoteSessionAsync({
|
||||
teardown,
|
||||
sessionFailed,
|
||||
logger,
|
||||
}: {
|
||||
teardown: [name: string, task: Promise<unknown> | undefined][];
|
||||
teardown: RemoteSessionTeardown[];
|
||||
sessionFailed: boolean;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const results = await Promise.allSettled(teardown.map(([, task]) => task));
|
||||
const failures = results.flatMap((result, index) =>
|
||||
result.status === 'rejected' ? [{ name: teardown[index][0], err: result.reason }] : []
|
||||
);
|
||||
for (const { name, err } of failures) {
|
||||
const failures = results.flatMap((result, index) => {
|
||||
if (result.status !== 'rejected') {
|
||||
return [];
|
||||
}
|
||||
const [name, , options] = teardown[index];
|
||||
return [{ name, err: result.reason, failsSession: options?.failsSession ?? true }];
|
||||
});
|
||||
for (const { name, err, failsSession } of failures) {
|
||||
logger.warn({ err }, `Could not stop the ${name} during remote session teardown.`);
|
||||
if (sessionFailed) {
|
||||
// The session error is what the step reports, so a swallowed teardown failure goes to Sentry.
|
||||
if (sessionFailed || !failsSession) {
|
||||
// The step does not report this failure, so it goes to Sentry.
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture(`Could not stop the ${name} after the remote session failed`, error, {
|
||||
level: 'warning',
|
||||
});
|
||||
Sentry.capture(
|
||||
sessionFailed
|
||||
? `Could not stop the ${name} after the remote session failed`
|
||||
: `Could not stop the ${name} after the remote session ended`,
|
||||
error,
|
||||
{ level: 'warning' }
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!sessionFailed && failures.length > 0) {
|
||||
throw failures[0].err;
|
||||
const sessionFailure = failures.find(({ failsSession }) => failsSession);
|
||||
if (!sessionFailed && sessionFailure) {
|
||||
throw sessionFailure.err;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { performance } from 'node:perf_hooks';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { SignedUrlUploadApiError, uploadWithSignedUrl } from '../../storage/uploadWithSignedUrl';
|
||||
import { type LegacySandboxApi } from './legacySandboxApi';
|
||||
import { type SandboxArtifactUploadSession, type SandboxWorkerApi } from './sandboxWorkerApi';
|
||||
import { WorkerApiError } from './workerApi';
|
||||
|
||||
@@ -29,7 +28,7 @@ export class SandboxArtifactUploadManager {
|
||||
|
||||
public constructor(
|
||||
private readonly options: {
|
||||
workerApi: SandboxWorkerApi | LegacySandboxApi;
|
||||
workerApi: SandboxWorkerApi;
|
||||
logger: bunyan;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
@@ -88,7 +87,7 @@ export async function startSandboxArtifactUploadAsync({
|
||||
name,
|
||||
signal,
|
||||
}: {
|
||||
workerApi: SandboxWorkerApi | LegacySandboxApi;
|
||||
workerApi: SandboxWorkerApi;
|
||||
logger: bunyan;
|
||||
filePath: string;
|
||||
name: string;
|
||||
@@ -157,7 +156,7 @@ export async function startSandboxArtifactUploadAsync({
|
||||
}
|
||||
|
||||
async function createSandboxArtifactUploadSessionAsync(
|
||||
workerApi: SandboxWorkerApi | LegacySandboxApi,
|
||||
workerApi: SandboxWorkerApi,
|
||||
input: { name: string; filename: string; size: number },
|
||||
signal: AbortSignal
|
||||
): Promise<SandboxArtifactUploadSession> {
|
||||
|
||||
@@ -13,7 +13,6 @@ import { performance } from 'node:perf_hooks';
|
||||
import { readBytesAsync, readTextAsync } from '../../utils/files';
|
||||
import { detectFileType } from '../../utils/files/filetypes';
|
||||
import { SandboxArtifactUploadManager } from './sandboxArtifacts';
|
||||
import { type LegacySandboxApi } from './legacySandboxApi';
|
||||
import { type SandboxWorkerApi } from './sandboxWorkerApi';
|
||||
import { ShellSessionManager } from './shellSessionManager';
|
||||
|
||||
@@ -37,7 +36,7 @@ export function createSandboxCommandImplementations({
|
||||
workingDirectory: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
signal: AbortSignal;
|
||||
workerApi: SandboxWorkerApi | LegacySandboxApi;
|
||||
workerApi: SandboxWorkerApi;
|
||||
logger: bunyan;
|
||||
}): {
|
||||
commandImplementations: SandboxDaemonCommandImplementations;
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
type SandboxDaemonCommandImplementations,
|
||||
createSandboxCommandImplementations,
|
||||
} from './sandboxCommandImplementations';
|
||||
import { type LegacySandboxApi } from './legacySandboxApi';
|
||||
import { type SandboxWorkerApi } from './sandboxWorkerApi';
|
||||
|
||||
export interface SandboxDaemonOptions {
|
||||
@@ -27,7 +26,7 @@ export interface SandboxDaemonOptions {
|
||||
signal?: AbortSignal;
|
||||
workingDirectory: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
workerApi: SandboxWorkerApi | LegacySandboxApi;
|
||||
workerApi: SandboxWorkerApi;
|
||||
}
|
||||
|
||||
export interface SandboxDaemon {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Env, type EnvironmentSecret, type Job } from '@expo/eas-build-job';
|
||||
import { type Env, type EnvironmentSecret, type Job, SystemError } from '@expo/eas-build-job';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
@@ -97,3 +97,19 @@ export function getSandboxWorkerApi(ctx: {
|
||||
const token = getSandboxWorkerTokenFromEnvironmentSecrets(ctx.job.secrets?.environmentSecrets);
|
||||
return token ? new SandboxWorkerApi(getExpoApiV2BaseUrl(ctx), token) : null;
|
||||
}
|
||||
|
||||
export function requireSandboxWorkerApi(ctx: {
|
||||
job: Job;
|
||||
env: Env;
|
||||
expoApiV2BaseUrl?: string;
|
||||
}): SandboxWorkerApi {
|
||||
const workerApi = getSandboxWorkerApi(ctx);
|
||||
if (!workerApi) {
|
||||
throw new SystemError(
|
||||
`This sandbox job has no ${SANDBOX_WORKER_TOKEN_ENV} secret. ` +
|
||||
'www gives one to every sandbox job, and the worker needs it to call the sandbox API. ' +
|
||||
'Start a new sandbox, and contact support if it happens again.'
|
||||
);
|
||||
}
|
||||
return workerApi;
|
||||
}
|
||||
@@ -30,13 +30,20 @@ export async function stageServeSimAppAsync(
|
||||
}
|
||||
}
|
||||
|
||||
export async function runServeSimActionAsync({
|
||||
export async function runServeSimActionAsync(
|
||||
options: Parameters<typeof runServeSimActionWithResultAsync>[0]
|
||||
): Promise<void> {
|
||||
await runServeSimActionWithResultAsync(options);
|
||||
}
|
||||
|
||||
export async function runServeSimActionWithResultAsync({
|
||||
port,
|
||||
token,
|
||||
action,
|
||||
params,
|
||||
timeoutMs,
|
||||
signal,
|
||||
drainAfterDispatch = false,
|
||||
}: {
|
||||
port: number;
|
||||
token: string;
|
||||
@@ -44,13 +51,15 @@ export async function runServeSimActionAsync({
|
||||
params: Record<string, unknown>;
|
||||
timeoutMs: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
/** Abort before dispatch; once sent, wait for its reply or deadline because exec-ws cannot cancel it. */
|
||||
drainAfterDispatch?: boolean;
|
||||
}): Promise<string> {
|
||||
signal?.throwIfAborted();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${port}/exec-ws`, [`serve-sim.token.${token}`]);
|
||||
let settled = false;
|
||||
let sent = false;
|
||||
const finish = (error?: unknown) => {
|
||||
const finish = (error?: unknown, stdout = '') => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
@@ -61,10 +70,14 @@ export async function runServeSimActionAsync({
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
resolve(stdout);
|
||||
}
|
||||
};
|
||||
const aborted = () => {
|
||||
if (!sent || !drainAfterDispatch) {
|
||||
finish(signal?.reason ?? new Error('Serve-sim action aborted.'));
|
||||
}
|
||||
};
|
||||
const aborted = () => finish(signal?.reason ?? new Error('Serve-sim action aborted.'));
|
||||
const timer = setTimeout(
|
||||
() => finish(new SystemError(`serve-sim ${action} timed out.`)),
|
||||
timeoutMs
|
||||
@@ -75,6 +88,9 @@ export async function runServeSimActionAsync({
|
||||
finish(new SystemError(`serve-sim ${action} connection closed before completion.`))
|
||||
);
|
||||
socket.on('message', data => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
let reply;
|
||||
try {
|
||||
reply = JSON.parse(data.toString());
|
||||
@@ -90,7 +106,8 @@ export async function runServeSimActionAsync({
|
||||
? undefined
|
||||
: new SystemError(
|
||||
`serve-sim ${action} failed: ${(typeof reply.error === 'string' && reply.error) || (typeof reply.stderr === 'string' && reply.stderr) || 'invalid response'}`
|
||||
)
|
||||
),
|
||||
typeof reply.stdout === 'string' ? reply.stdout : ''
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
const REQUEST_TIMEOUT_MS = 5_000;
|
||||
|
||||
export type ServeSimActivityPolling = {
|
||||
/**
|
||||
* When a new event log entry (input or host action, not stream traffic) was first seen. Reports
|
||||
* now while the log is unreadable but serve-sim is up, so an unobservable session stays active.
|
||||
*/
|
||||
getLastActivityObservedAt: () => Date | undefined;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
/** Nothing listens on the port. A reset or dropped response can come from a running serve-sim. */
|
||||
function isServeSimDownError(err: unknown): boolean {
|
||||
// Not `instanceof`: undici errors can come from another realm.
|
||||
let error: unknown = err;
|
||||
for (let depth = 0; depth < 5 && typeof error === 'object' && error !== null; depth++) {
|
||||
const { code, cause } = error as { code?: unknown; cause?: unknown };
|
||||
if (code === 'ECONNREFUSED') {
|
||||
return true;
|
||||
}
|
||||
error = cause;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** A changed newest event ID counts as activity, timed by the local clock. */
|
||||
export function startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger,
|
||||
pollIntervalMs = POLL_INTERVAL_MS,
|
||||
}: {
|
||||
port: number;
|
||||
token: string;
|
||||
logger: bunyan;
|
||||
pollIntervalMs?: number;
|
||||
}): ServeSimActivityPolling {
|
||||
const controller = new AbortController();
|
||||
let lastEventId: number | undefined;
|
||||
let lastActivityObservedAt: Date | undefined;
|
||||
let eventLogUnavailable = false;
|
||||
let didWarn = false;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
let pending: Promise<void> = Promise.resolve();
|
||||
|
||||
const pollAsync = async (): Promise<void> => {
|
||||
try {
|
||||
const eventId = await fetchLatestServeSimEventIdAsync({
|
||||
port,
|
||||
token,
|
||||
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
|
||||
});
|
||||
eventLogUnavailable = false;
|
||||
if (eventId !== undefined && eventId !== lastEventId) {
|
||||
lastEventId = eventId;
|
||||
lastActivityObservedAt = new Date();
|
||||
}
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
// Only a down serve-sim, which receives no input either, keeps the last observation.
|
||||
eventLogUnavailable = !isServeSimDownError(err);
|
||||
if (!didWarn) {
|
||||
didWarn = true;
|
||||
logger.warn(
|
||||
{ err },
|
||||
eventLogUnavailable
|
||||
? 'Could not read the serve-sim event log. Preview input cannot be observed, so the ' +
|
||||
'idle timeout does not stop this session.'
|
||||
: 'Could not read serve-sim preview activity.'
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
const tick = (): void => {
|
||||
pending = pollAsync().finally(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
timer = setTimeout(tick, pollIntervalMs);
|
||||
timer.unref();
|
||||
}
|
||||
});
|
||||
};
|
||||
tick();
|
||||
return {
|
||||
getLastActivityObservedAt: () => (eventLogUnavailable ? new Date() : lastActivityObservedAt),
|
||||
stopAsync: async () => {
|
||||
controller.abort();
|
||||
clearTimeout(timer);
|
||||
await pending;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchLatestServeSimEventIdAsync({
|
||||
port,
|
||||
token,
|
||||
signal,
|
||||
}: {
|
||||
port: number;
|
||||
token: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<number | undefined> {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/event-log?limit=1`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new Error(`serve-sim event log request failed: ${response.status}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
let body: { events?: unknown } | undefined;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {}
|
||||
if (!Array.isArray(body?.events)) {
|
||||
throw new Error('serve-sim event log response has no events array.');
|
||||
}
|
||||
if (body.events.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const id = (body.events.at(-1) as { id?: unknown } | null)?.id;
|
||||
if (typeof id !== 'number') {
|
||||
throw new Error('serve-sim event log entry has no numeric ID.');
|
||||
}
|
||||
return id;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { formatBytes } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
|
||||
|
||||
const METRICS_ARTIFACT_FILENAME = 'metrics.ndjson';
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { copyFile, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { runServeSimActionAsync, runServeSimActionWithResultAsync } from './serveSimActions';
|
||||
|
||||
export function createServeSimPreviewCapture({
|
||||
port,
|
||||
token,
|
||||
device,
|
||||
tmpdir,
|
||||
}: {
|
||||
port: number;
|
||||
token: string;
|
||||
device: string;
|
||||
tmpdir: string;
|
||||
}) {
|
||||
const ownedFiles = new Set<string>();
|
||||
return {
|
||||
async isSupportedAsync(signal: AbortSignal): Promise<boolean> {
|
||||
const result = await runServeSimActionWithResultAsync({
|
||||
port,
|
||||
token,
|
||||
action: 'actions.describe',
|
||||
params: {},
|
||||
timeoutMs: 30_000,
|
||||
signal,
|
||||
});
|
||||
const catalog: {
|
||||
'screenshot.capture'?: { properties?: { automation?: { type?: unknown } } };
|
||||
} | null = JSON.parse(result);
|
||||
return catalog?.['screenshot.capture']?.properties?.automation?.type === 'boolean';
|
||||
},
|
||||
async captureAsync(destination: string, signal: AbortSignal): Promise<void> {
|
||||
signal.throwIfAborted();
|
||||
const fileName = `serve-sim-screenshot-preview-${randomUUID()}.png`;
|
||||
const staged = path.join(tmpdir, 'serve-sim-screenshots', fileName);
|
||||
ownedFiles.add(staged);
|
||||
// Closing exec-ws does not cancel its host action. Drain a sent capture before honoring
|
||||
// cancellation; on timeout/disconnect, leave its file alone until the host has stopped.
|
||||
await runServeSimActionAsync({
|
||||
port,
|
||||
token,
|
||||
action: 'screenshot.capture',
|
||||
params: { udid: device, fileName, automation: true },
|
||||
timeoutMs: 30_000,
|
||||
signal,
|
||||
drainAfterDispatch: true,
|
||||
});
|
||||
try {
|
||||
signal.throwIfAborted();
|
||||
await copyFile(staged, destination);
|
||||
} finally {
|
||||
await rm(staged, { force: true });
|
||||
ownedFiles.delete(staged);
|
||||
}
|
||||
},
|
||||
/** Call only after the host process group has stopped, including timed-out actions. */
|
||||
async cleanupAfterHostStoppedAsync(): Promise<void> {
|
||||
const results = await Promise.allSettled(
|
||||
[...ownedFiles].map(async file => {
|
||||
await rm(file, { force: true });
|
||||
ownedFiles.delete(file);
|
||||
})
|
||||
);
|
||||
const errors = results
|
||||
.filter(result => result.status === 'rejected')
|
||||
.map(result => result.reason);
|
||||
if (errors.length) {
|
||||
throw new AggregateError(errors, 'Could not remove preview captures.');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import type { bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { resolveSimulatorPackageVersionAsync } from '../../utils/simulatorPackageVersion';
|
||||
import { stopWarmSimulatorAsync } from '../../utils/warmSimulator';
|
||||
import { IosSimulatorRecordingUtils } from './IosSimulatorRecordingUtils';
|
||||
import { type DeviceSessionServer } from './deviceSessionHost';
|
||||
import { fetchWebPreviewTurnArgsAsync } from './remoteDeviceRunSession';
|
||||
@@ -28,6 +30,7 @@ export function createWarmDeviceSessionServer(
|
||||
}
|
||||
return {
|
||||
port: warm.server.port,
|
||||
serveSimVersion: warm.serveSimVersion,
|
||||
turnArgs: fetchWebPreviewTurnArgsAsync(ctx, { logger }),
|
||||
// Set by prepare-tools, which the host runs before it installs the app.
|
||||
get preinstalledApp() {
|
||||
@@ -68,14 +71,34 @@ export function createWarmDeviceSessionServer(
|
||||
*/
|
||||
export async function runOnWarmIosSimulatorAsync<T>(
|
||||
claim: Parameters<typeof tryClaimWarmIosSessionAsync>[0],
|
||||
run: (warm?: WarmIosSession) => Promise<T>
|
||||
run: (warm?: WarmIosSession, packageVersion?: string) => Promise<T>
|
||||
): Promise<T> {
|
||||
const warm = await tryClaimWarmIosSessionAsync(claim);
|
||||
let packageVersion = claim.packageVersion;
|
||||
if (packageVersion !== undefined && claim.env.EAS_USE_WARM_SIMULATOR === '1') {
|
||||
try {
|
||||
packageVersion = await resolveSimulatorPackageVersionAsync(
|
||||
claim.engine,
|
||||
packageVersion,
|
||||
claim
|
||||
);
|
||||
} catch (error) {
|
||||
try {
|
||||
await stopWarmSimulatorAsync(claim.env);
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError(
|
||||
[error, cleanupError],
|
||||
'Could not stop warmup after version resolution failed.'
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const warm = await tryClaimWarmIosSessionAsync({ ...claim, packageVersion });
|
||||
if (!warm) {
|
||||
return await run(undefined);
|
||||
return await run(undefined, packageVersion);
|
||||
}
|
||||
try {
|
||||
return await run(warm);
|
||||
return await run(warm, packageVersion);
|
||||
} catch (error) {
|
||||
try {
|
||||
await warm.process.stopAsync();
|
||||
@@ -90,6 +113,6 @@ export async function runOnWarmIosSimulatorAsync<T>(
|
||||
}
|
||||
claim.logger.warn('Could not adopt the warm Simulator; starting a cold Simulator.');
|
||||
claim.signal?.throwIfAborted();
|
||||
return await run(undefined);
|
||||
return await run(undefined, packageVersion);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { type bunyan } from '@expo/logger';
|
||||
import { type BuildStepEnv } from '@expo/steps';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { runSimulatorLauncherAsync, stopWarmSimulatorAsync } from '../../utils/warmSimulator';
|
||||
@@ -9,7 +10,11 @@ import { readLocalEgressHandoffAsync } from './localEgress';
|
||||
import { type DetachedProcessHandle, isProcessRunning } from './remoteDeviceRunSession';
|
||||
|
||||
const ServiceSchema = z.object({ pid: z.number(), port: z.number(), token: z.string() });
|
||||
const PackageVersionSchema = z.string().refine(version => semver.valid(version) !== null);
|
||||
const ClaimSchema = z.object({
|
||||
serveSimVersion: PackageVersionSchema.optional(),
|
||||
agentDeviceVersion: PackageVersionSchema.optional(),
|
||||
argentVersion: PackageVersionSchema.optional(),
|
||||
device: z.object({ udid: z.string(), name: z.string() }),
|
||||
server: ServiceSchema,
|
||||
agentDeviceStateDir: z.string(),
|
||||
@@ -53,10 +58,6 @@ export async function tryClaimWarmIosSessionAsync({
|
||||
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_')
|
||||
);
|
||||
@@ -71,6 +72,15 @@ export async function tryClaimWarmIosSessionAsync({
|
||||
const claim = ClaimSchema.parse(
|
||||
JSON.parse(await runSimulatorLauncherAsync(claimArgs, { env, signal }))
|
||||
);
|
||||
const selectedVersion = {
|
||||
'serve-sim': claim.serveSimVersion,
|
||||
'agent-device': claim.agentDeviceVersion,
|
||||
argent: claim.argentVersion,
|
||||
appium: undefined,
|
||||
}[engine];
|
||||
if (packageVersion !== undefined && selectedVersion !== packageVersion) {
|
||||
throw new Error('The warm tool does not match the requested package version.');
|
||||
}
|
||||
logger.info(`Claimed the warm Simulator ${claim.device.name} (${claim.device.udid}).`);
|
||||
let stopping: Promise<void> | undefined;
|
||||
const session: WarmIosSession = {
|
||||
|
||||
@@ -6,13 +6,14 @@ import assert from 'assert';
|
||||
import FastGlob from 'fast-glob';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import { Socket } from 'node:net';
|
||||
import { Socket, createServer } from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Sentry } from '../sentry';
|
||||
import { type AndroidEmulatorLogCapture } from './androidEmulatorLogCapture';
|
||||
import { retryAsync } from './retry';
|
||||
|
||||
/** Android Virtual Device is the device we run. */
|
||||
@@ -34,6 +35,8 @@ export type AndroidEmulatorLaunchGate = {
|
||||
|
||||
export namespace AndroidEmulatorUtils {
|
||||
const RETRY_INTERVAL_MS = 1_000;
|
||||
const FIRST_CONSOLE_PORT = 5554;
|
||||
const LAST_CONSOLE_PORT = 5584;
|
||||
|
||||
export const defaultSystemImagePackage = `system-images;android-30;default;${
|
||||
process.arch === 'arm64' ? 'arm64-v8a' : 'x86_64'
|
||||
@@ -327,19 +330,31 @@ export namespace AndroidEmulatorUtils {
|
||||
env,
|
||||
logcatDirectory,
|
||||
launchGate,
|
||||
logCapture,
|
||||
consolePort,
|
||||
extraArgs = [],
|
||||
extraEnv = {},
|
||||
}: {
|
||||
deviceName: AndroidVirtualDeviceName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logcatDirectory: string;
|
||||
launchGate?: AndroidEmulatorLaunchGate;
|
||||
logCapture: AndroidEmulatorLogCapture;
|
||||
/**
|
||||
* The console port, from `findFreeConsolePortAsync`. It sets the serial (`emulator-<port>`)
|
||||
* before the start. Without it, the emulator picks a free port itself.
|
||||
*/
|
||||
consolePort?: number;
|
||||
/** Emulator arguments before $ANDROID_EMULATOR_EXTRA_ARGS, which can still override them. */
|
||||
extraArgs?: string[];
|
||||
/** Environment variables for the emulator process only. */
|
||||
extraEnv?: Record<string, string>;
|
||||
}): 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 +362,21 @@ 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 (logCapture.sources.includes('device-logcat')) {
|
||||
logcatOutputPath = path.join(logcatDirectory, `${outputName}-logcat.log`);
|
||||
}
|
||||
if (logCapture.sources.includes('emulator-output')) {
|
||||
emulatorOutputPath = path.join(logcatDirectory, `${outputName}-emulator.log`);
|
||||
}
|
||||
} 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,66 +384,54 @@ export namespace AndroidEmulatorUtils {
|
||||
'-writable-system',
|
||||
'-noaudio',
|
||||
'-no-snapshot-save',
|
||||
'-logcat',
|
||||
'*:v',
|
||||
'-logcat-output',
|
||||
logcatOutputPath,
|
||||
...(logcatOutput ? ['-logcat', '*:v', '-logcat-output', logcatOutput.path] : []),
|
||||
'-avd',
|
||||
deviceName,
|
||||
'-accel',
|
||||
'on',
|
||||
...(consolePort !== undefined ? ['-port', String(consolePort)] : []),
|
||||
...extraArgs,
|
||||
...(typeof env.ANDROID_EMULATOR_EXTRA_ARGS === 'string'
|
||||
? env.ANDROID_EMULATOR_EXTRA_ARGS.split(' ')
|
||||
: []),
|
||||
];
|
||||
const emulatorEnv = {
|
||||
...env,
|
||||
...extraEnv,
|
||||
// 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 +481,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({
|
||||
@@ -623,6 +631,30 @@ export namespace AndroidEmulatorUtils {
|
||||
return { outputPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* A free console port: an even port from 5554 to 5584, the range that adb scans, whose next
|
||||
* port, for adb, is free too.
|
||||
*/
|
||||
export async function findFreeConsolePortAsync(): Promise<number> {
|
||||
for (let port = FIRST_CONSOLE_PORT; port <= LAST_CONSOLE_PORT; port += 2) {
|
||||
if ((await isPortFreeAsync(port)) && (await isPortFreeAsync(port + 1))) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`No emulator console port from ${FIRST_CONSOLE_PORT} to ${LAST_CONSOLE_PORT} is free.`
|
||||
);
|
||||
}
|
||||
|
||||
async function isPortFreeAsync(port: number): Promise<boolean> {
|
||||
return await new Promise(resolve => {
|
||||
const server = createServer();
|
||||
server.unref();
|
||||
server.once('error', () => resolve(false));
|
||||
server.listen(port, '127.0.0.1', () => server.close(() => resolve(true)));
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopAsync({
|
||||
serialId,
|
||||
env,
|
||||
|
||||
@@ -4,6 +4,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { retryAsync } from './retry';
|
||||
@@ -202,7 +203,7 @@ export namespace IosSimulatorUtils {
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<IosSimulatorUuid> {
|
||||
if (UDID_PATTERN.test(deviceIdentifier)) {
|
||||
return deviceIdentifier as IosSimulatorUuid;
|
||||
return deviceIdentifier.toUpperCase() as IosSimulatorUuid;
|
||||
}
|
||||
const devices = await getAvailableDevicesAsync({ env, filter: 'available' });
|
||||
const device = devices.find(candidate => candidate.name === deviceIdentifier);
|
||||
@@ -284,9 +285,23 @@ export namespace IosSimulatorUtils {
|
||||
const readinessScreenshotPath = path.join(os.tmpdir(), 'eas-simulator-readiness.png');
|
||||
await retryAsync(
|
||||
async () => {
|
||||
await spawn('xcrun', ['simctl', 'io', udid, 'screenshot', readinessScreenshotPath], {
|
||||
env,
|
||||
});
|
||||
const { stdout: enumerateStdout } = await spawn(
|
||||
'xcrun',
|
||||
['simctl', 'io', udid, 'enumerate'],
|
||||
{ env }
|
||||
);
|
||||
await spawn(
|
||||
'xcrun',
|
||||
[
|
||||
'simctl',
|
||||
'io',
|
||||
udid,
|
||||
'screenshot',
|
||||
...displayFlagForScreenshot(enumerateStdout),
|
||||
readinessScreenshotPath,
|
||||
],
|
||||
{ env }
|
||||
);
|
||||
},
|
||||
{
|
||||
retryOptions: {
|
||||
@@ -298,22 +313,30 @@ export namespace IosSimulatorUtils {
|
||||
);
|
||||
await fs.promises.rm(readinessScreenshotPath, { force: true });
|
||||
|
||||
// Wait for data migration to complete before declaring the simulator ready
|
||||
// Based on WebKit's approach: https://trac.webkit.org/changeset/231452/webkit
|
||||
await retryAsync(
|
||||
async () => {
|
||||
const isDataMigrating = await isDataMigratorProcessRunning({ env });
|
||||
if (isDataMigrating) {
|
||||
throw new Error('com.apple.datamigrator still running');
|
||||
}
|
||||
},
|
||||
{
|
||||
retryOptions: {
|
||||
retries: 30 * 60,
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
const device = await getDeviceAsync({ udid, env });
|
||||
const runtimes = await getAvailableRuntimesAsync({ env });
|
||||
const iosVersion = semver.coerce(
|
||||
runtimes.find(runtime => runtime.identifier === device?.runtime)?.version
|
||||
);
|
||||
// From iOS 27.2 on, `com.apple.datamigrator` stays resident after the boot's data migration, so
|
||||
// waiting for it to exit never ends; earlier runtimes exit it within about a minute of boot.
|
||||
if (!iosVersion || semver.lt(iosVersion, '27.2.0')) {
|
||||
// Based on WebKit's approach: https://trac.webkit.org/changeset/231452/webkit
|
||||
await retryAsync(
|
||||
async () => {
|
||||
const isDataMigrating = await isDataMigratorProcessRunning({ env });
|
||||
if (isDataMigrating) {
|
||||
throw new Error('com.apple.datamigrator still running');
|
||||
}
|
||||
},
|
||||
{
|
||||
retryOptions: {
|
||||
retries: 30 * 60,
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function disableApsdAsync({
|
||||
@@ -522,6 +545,20 @@ async function isLaunchctlServiceLoadedAsync({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `simctl io screenshot` refuses to choose between the iPhone Duo's two integrated screens on
|
||||
* iOS 27.2, while the simctl in Xcode 15.3 rejects a numeric `--display` ("Device does not have a
|
||||
* '1' display port"), so the flag is passed only when there is a choice to make.
|
||||
*/
|
||||
function displayFlagForScreenshot(enumerateStdout: string): string[] {
|
||||
// `simctl io enumerate` lists each screen as an indented "(n) Name:" header followed by its fields.
|
||||
const screens = enumerateStdout.split(/^ {4}\(\d+\) .*:$/m);
|
||||
const integratedScreenIds = screens
|
||||
.filter(screen => /^\s*Screen Type: Integrated$/m.test(screen))
|
||||
.flatMap(screen => screen.match(/^\s*Screen ID: (\d+)$/m)?.[1] ?? []);
|
||||
return integratedScreenIds.length > 1 ? [`--display=${integratedScreenIds[0]}`] : [];
|
||||
}
|
||||
|
||||
function parseUdidFromBootstatusStdout(stdout: string): IosSimulatorUuid | null {
|
||||
const matches = stdout.match(/^Monitoring boot status for .+ \((.+)\)\.$/m);
|
||||
if (!matches) {
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
import { AndroidEmulatorLogFiles } from '../androidEmulatorLogFiles';
|
||||
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,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
({ serialId } = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES }),
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
@@ -105,6 +108,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES }),
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
@@ -135,6 +139,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
|
||||
const { serialId: serialIdClone, emulatorPromise: emulatorPromiseClone } =
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES }),
|
||||
deviceName: cloneDeviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
@@ -172,6 +177,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
|
||||
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES }),
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
@@ -226,6 +232,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES }),
|
||||
deviceName,
|
||||
env: envForAttempt,
|
||||
logcatDirectory,
|
||||
@@ -309,6 +316,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
logger,
|
||||
});
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES }),
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory: testLogcatDirectory,
|
||||
@@ -319,7 +327,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 +357,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 +410,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
|
||||
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES }),
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
logcatDirectory,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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 { type Server, Socket, createServer } from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
@@ -14,6 +14,17 @@ import {
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
import { AndroidEmulatorLogRing } from '../androidEmulatorLogCapture';
|
||||
import { AndroidEmulatorLogFiles } from '../androidEmulatorLogFiles';
|
||||
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 +46,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 +68,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 +96,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 +154,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 +170,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 +185,24 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.startAsync, () => {
|
||||
let buildCapture: AndroidEmulatorLogFiles;
|
||||
beforeEach(() => {
|
||||
buildCapture = new AndroidEmulatorLogFiles({ sources: ALL_ANDROID_EMULATOR_LOG_SOURCES });
|
||||
});
|
||||
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 +233,31 @@ 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({
|
||||
logCapture: buildCapture,
|
||||
deviceName,
|
||||
env: process.env,
|
||||
logcatDirectory: outputDir,
|
||||
});
|
||||
child.emit('close');
|
||||
await buildCapture.finishAsync();
|
||||
|
||||
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 +269,91 @@ 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,
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
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 capture = new AndroidEmulatorLogFiles({
|
||||
sources: AndroidEmulatorLogSourcesSchema.parse(sources),
|
||||
});
|
||||
const prepare = jest.spyOn(capture, 'prepareLogcatOutputAsync');
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: capture,
|
||||
deviceName,
|
||||
env: {},
|
||||
logcatDirectory: outputDir,
|
||||
});
|
||||
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 capture.finishAsync();
|
||||
if (capturesOutput) {
|
||||
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 AndroidEmulatorLogRing({ sources: ['device-logcat'] });
|
||||
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,
|
||||
logCapture: capture,
|
||||
});
|
||||
expect(createPipe).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
new RegExp(`^${outputDir}/bounded-emulator-[a-f0-9]{8}-[a-f0-9]{4}-logcat\\.log$`)
|
||||
)
|
||||
);
|
||||
const emulatorOutputStreamClosed = once(emulatorOutputStream, 'close');
|
||||
expect(mockedSpawn.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining(['-logcat-output', '/logcat.pipe'])
|
||||
);
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
child.emit('close');
|
||||
await emulatorOutputStreamClosed;
|
||||
await capture.finishAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(await fs.promises.readdir(outputDir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('spawns the emulator directly with the same arguments when there is no launch gate', async () => {
|
||||
@@ -273,7 +363,8 @@ describe('AndroidEmulatorUtils', () => {
|
||||
mockSuccessfulStart(deviceName);
|
||||
const env = { ANDROID_EMULATOR_EXTRA_ARGS: '-gpu swiftshader_indirect' };
|
||||
|
||||
const result = await AndroidEmulatorUtils.startAsync({
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: buildCapture,
|
||||
deviceName,
|
||||
env,
|
||||
logcatDirectory: outputDir,
|
||||
@@ -294,7 +385,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
'-logcat',
|
||||
'*:v',
|
||||
'-logcat-output',
|
||||
result.logcatOutputPath,
|
||||
await logFile(outputDir, 'logcat'),
|
||||
'-avd',
|
||||
deviceName,
|
||||
'-accel',
|
||||
@@ -317,6 +408,43 @@ describe('AndroidEmulatorUtils', () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('adds the console port and extra arguments before $ANDROID_EMULATOR_EXTRA_ARGS', async () => {
|
||||
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'logcat-staging-'));
|
||||
temporaryDirectories.push(outputDir);
|
||||
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
|
||||
mockSuccessfulStart(deviceName);
|
||||
const env = { ANDROID_EMULATOR_EXTRA_ARGS: '-gpu swiftshader_indirect' };
|
||||
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: buildCapture,
|
||||
deviceName,
|
||||
env,
|
||||
logcatDirectory: outputDir,
|
||||
consolePort: 5556,
|
||||
extraArgs: ['-gpu', 'lavapipe'],
|
||||
extraEnv: { LD_PRELOAD: '/tmp/libexpo_emulator_capture.so' },
|
||||
});
|
||||
|
||||
const [, args, options] = mockedSpawn.mock.calls.find(([command]) =>
|
||||
command.endsWith('/emulator/emulator')
|
||||
)!;
|
||||
expect(args.slice(-8)).toEqual([
|
||||
'-accel',
|
||||
'on',
|
||||
'-port',
|
||||
'5556',
|
||||
'-gpu',
|
||||
'lavapipe',
|
||||
'-gpu',
|
||||
'swiftshader_indirect',
|
||||
]);
|
||||
expect(options?.env).toEqual({
|
||||
...env,
|
||||
LD_PRELOAD: '/tmp/libexpo_emulator_capture.so',
|
||||
ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1',
|
||||
});
|
||||
});
|
||||
|
||||
it('starts the emulator behind the launch gate and releases it once admitted', async () => {
|
||||
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'logcat-staging-'));
|
||||
temporaryDirectories.push(outputDir);
|
||||
@@ -331,7 +459,8 @@ describe('AndroidEmulatorUtils', () => {
|
||||
: gatedSpawn(command, args, options as any)) as any);
|
||||
const admitAsync = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const result = await AndroidEmulatorUtils.startAsync({
|
||||
await AndroidEmulatorUtils.startAsync({
|
||||
logCapture: buildCapture,
|
||||
deviceName,
|
||||
env: {},
|
||||
logcatDirectory: outputDir,
|
||||
@@ -358,7 +487,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
'-logcat',
|
||||
'*:v',
|
||||
'-logcat-output',
|
||||
result.logcatOutputPath,
|
||||
await logFile(outputDir, 'logcat'),
|
||||
'-avd',
|
||||
deviceName,
|
||||
'-accel',
|
||||
@@ -395,6 +524,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
|
||||
await expect(
|
||||
AndroidEmulatorUtils.startAsync({
|
||||
logCapture: buildCapture,
|
||||
deviceName,
|
||||
env: {},
|
||||
logcatDirectory: outputDir,
|
||||
@@ -419,6 +549,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
try {
|
||||
await expect(
|
||||
AndroidEmulatorUtils.startAsync({
|
||||
logCapture: buildCapture,
|
||||
deviceName,
|
||||
env: process.env,
|
||||
logcatDirectory: outputDir,
|
||||
@@ -435,6 +566,60 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.findFreeConsolePortAsync, () => {
|
||||
/** Listens on the port, or returns null when another process already uses it. */
|
||||
async function occupyAsync(port: number): Promise<Server | null> {
|
||||
const server = createServer();
|
||||
return await new Promise(resolve => {
|
||||
server.once('error', () => resolve(null));
|
||||
server.listen(port, '127.0.0.1', () => resolve(server));
|
||||
});
|
||||
}
|
||||
|
||||
async function closeAsync(servers: (Server | null)[]): Promise<void> {
|
||||
await Promise.all(
|
||||
servers.map(server => (server ? new Promise(resolve => server.close(resolve)) : null))
|
||||
);
|
||||
}
|
||||
|
||||
it('returns an even console port that adb scans', async () => {
|
||||
const port = await AndroidEmulatorUtils.findFreeConsolePortAsync();
|
||||
|
||||
expect(port % 2).toBe(0);
|
||||
expect(port).toBeGreaterThanOrEqual(5554);
|
||||
expect(port).toBeLessThanOrEqual(5584);
|
||||
});
|
||||
|
||||
it('skips a console port whose adb port is in use', async () => {
|
||||
const port = await AndroidEmulatorUtils.findFreeConsolePortAsync();
|
||||
const adbServer = await occupyAsync(port + 1);
|
||||
try {
|
||||
expect(adbServer).not.toBeNull();
|
||||
const next = await AndroidEmulatorUtils.findFreeConsolePortAsync();
|
||||
|
||||
expect(next).not.toBe(port);
|
||||
expect(next % 2).toBe(0);
|
||||
} finally {
|
||||
await closeAsync([adbServer]);
|
||||
}
|
||||
});
|
||||
|
||||
it('throws when no console port is free', async () => {
|
||||
const servers: (Server | null)[] = [];
|
||||
try {
|
||||
for (let port = 5555; port <= 5585; port += 2) {
|
||||
servers.push(await occupyAsync(port));
|
||||
}
|
||||
|
||||
await expect(AndroidEmulatorUtils.findFreeConsolePortAsync()).rejects.toThrow(
|
||||
'No emulator console port from 5554 to 5584 is free.'
|
||||
);
|
||||
} finally {
|
||||
await closeAsync(servers);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.waitForReadyAsync, () => {
|
||||
it('checks boot completion and verifies network with netcat to 1.1.1.1:443', async () => {
|
||||
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
|
||||
|
||||
Loaded 100 of 236 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user