Compare commits

..
320 changed files with 5928 additions and 32160 deletions

No files matched your search

@@ -1,5 +0,0 @@
---
'@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
---
Bump `@expo/apple-utils` to `2.3.0` to fix iOS capability sync when App Store Connect rejects enabling, disabling or changing a capability.
@@ -0,0 +1,5 @@
---
'eas-cli': patch
---
Keep already linked App Groups, Merchant IDs and iCloud containers when capability sync links another identifier.
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/worker': patch
---
Mask secrets in every record the worker process writes to its stdout: the build log records and the worker's own records.
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Add a cleanup step that uploads Android emulator output and logcat as simulator session artifacts.
-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
Mark the default device for each platform in `eas simulator:devices` and include `isDefault` in JSON output.
@@ -1,5 +0,0 @@
---
'@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.
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Stop sending the SSH host when registering an SSH session.
+26 -195
View File
@@ -1,208 +1,39 @@
name: build-and-deploy-worker
# Assumes main branch always specifies an environment (staging/production).
# If environment is empty: PR testing build (no upload, no deploy)
# If environment is set: Build (unless cached on main), upload, and deploy
name: Build Android worker for manual testing
on:
workflow_dispatch:
inputs:
environment:
description: '"staging", "production" or empty'
type: string
description: Build a Linux artifact for manual staging testing; does not deploy
type: choice
options:
- staging
default: staging
required: true
workflow_call:
inputs:
environment:
description: '"staging", "production" or empty'
type: string
required: true
permissions:
contents: read
jobs:
build-and-deploy:
build-android-staging-test:
if: inputs.environment == 'staging'
runs-on: ubuntu-latest
permissions:
id-token: write
timeout-minutes: 25
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: ./.github/actions/setup-mise
- name: Install eas-cli
run: npm install --global eas-cli@latest
- uses: ./.github/internal-actions/setup-gcloud
- name: Check if worker already exists
id: check_worker_exists
# Check if tarballs for this SHA already exist
# If they do, we can skip building regardless of branch
if: inputs.environment != ''
run: |
for platform in android ios; do
GCP_TARBALL="worker-$platform-sha1-$GITHUB_SHA.tar.gz"
if ! gsutil stat "gs://eas-build-worker-tarballs/$GCP_TARBALL"; then
echo "worker_exists=false" >> "$GITHUB_OUTPUT"
exit 0
fi
done
echo "worker_exists=true" >> "$GITHUB_OUTPUT"
- name: Start EAS Workflow to build worker tarballs
id: eas_workflow
# Build if: PR testing (no env), or deploying but tarballs don't exist
if: inputs.environment == '' || steps.check_worker_exists.outputs.worker_exists == 'false'
working-directory: packages/worker
- run: yarn install --immutable
- name: Package Linux worker from this checkout
env:
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
OUTPUT_DIR: ${{ runner.temp }}/android-worker-staging-test
run: |
set -euo pipefail
# Only upload artifacts from EAS if GHA will upload them to GCS
# This avoids unnecessary artifact uploads for PR builds
SHOULD_UPLOAD_ARTIFACT="${{ inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false' }}"
# Run workflow and capture output (includes run ID)
WORKFLOW_OUTPUT=$(eas workflow:run build-worker.yml \
--ref ${{ github.sha }} \
--input upload_artifact=$SHOULD_UPLOAD_ARTIFACT \
--non-interactive \
--no-wait \
--json)
WORKFLOW_RUN_ID=$(echo "$WORKFLOW_OUTPUT" | jq -r '.id')
if [ -z "$WORKFLOW_RUN_ID" ] || [ "$WORKFLOW_RUN_ID" = "null" ]; then
echo "Error: Could not determine workflow run ID"
echo "Workflow output:"
echo "$WORKFLOW_OUTPUT" | jq .
exit 1
fi
echo "workflow_run_id=$WORKFLOW_RUN_ID" >> "$GITHUB_OUTPUT"
- name: Wait for EAS workflow and capture artifacts
id: eas_artifacts
if: inputs.environment == '' || steps.check_worker_exists.outputs.worker_exists == 'false'
working-directory: packages/worker
env:
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
run: |
set -euo pipefail
WORKFLOW_RUN_ID="${{ steps.eas_workflow.outputs.workflow_run_id }}"
if [ -z "$WORKFLOW_RUN_ID" ]; then
echo "Error: Missing workflow run ID"
exit 1
fi
SHOULD_UPLOAD_ARTIFACT="${{ inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false' }}"
while true; do
VIEW_OUTPUT=$(eas workflow:view "$WORKFLOW_RUN_ID" \
--non-interactive \
--json)
STATUS=$(echo "$VIEW_OUTPUT" | jq -r '.status')
case "$STATUS" in
SUCCESS)
break
;;
FAILURE)
echo "EAS workflow failed."
echo "$VIEW_OUTPUT" | jq .
exit 1
;;
CANCELED)
echo "EAS workflow canceled."
echo "$VIEW_OUTPUT" | jq .
exit 1
;;
ACTION_REQUIRED)
echo "EAS workflow requires action."
echo "$VIEW_OUTPUT" | jq .
exit 1
;;
NEW|IN_PROGRESS)
sleep 7
;;
*)
echo "Unexpected workflow status: $STATUS"
echo "$VIEW_OUTPUT" | jq .
exit 1
;;
esac
done
# Skip artifact extraction if we didn't request uploads
if [ "$SHOULD_UPLOAD_ARTIFACT" == "false" ]; then
echo "Skipping artifact download - upload was disabled for this run"
exit 0
fi
# Extract artifact download URLs from workflow output
# Artifacts are in jobs[].artifacts[] for custom jobs
DOWNLOAD_IOS_URL=$(echo "$VIEW_OUTPUT" | jq -r '
.jobs[]
| .artifacts[]?
| select(.name == "worker-ios-tarball")
| .downloadUrl
' | head -1)
DOWNLOAD_ANDROID_URL=$(echo "$VIEW_OUTPUT" | jq -r '
.jobs[]
| .artifacts[]?
| select(.name == "worker-android-tarball")
| .downloadUrl
' | head -1)
if [ -z "$DOWNLOAD_IOS_URL" ] || [ "$DOWNLOAD_IOS_URL" = "null" ]; then
echo "Error: Could not find iOS artifact download URL"
echo "$VIEW_OUTPUT" | jq .
exit 1
fi
if [ -z "$DOWNLOAD_ANDROID_URL" ] || [ "$DOWNLOAD_ANDROID_URL" = "null" ]; then
echo "Error: Could not find Android artifact download URL"
echo "$VIEW_OUTPUT" | jq .
exit 1
fi
# Mask the download URLs
echo "::add-mask::$DOWNLOAD_IOS_URL"
echo "::add-mask::$DOWNLOAD_ANDROID_URL"
echo "download_ios_url=$DOWNLOAD_IOS_URL" >> "$GITHUB_OUTPUT"
echo "download_android_url=$DOWNLOAD_ANDROID_URL" >> "$GITHUB_OUTPUT"
- name: Cancel EAS workflow on GitHub cancellation
if: cancelled()
working-directory: packages/worker
env:
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
run: |
set -euo pipefail
WORKFLOW_RUN_ID="${{ steps.eas_workflow.outputs.workflow_run_id }}"
if [ -n "$WORKFLOW_RUN_ID" ]; then
eas workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive
fi
- name: Upload worker tarballs to GCP
# Upload when deploying if we built something new
# Don't upload PR builds (no environment) or when using cached tarballs
if: inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false'
run: |
IOS_GCP_TARBALL="worker-ios-sha1-$GITHUB_SHA.tar.gz"
ANDROID_GCP_TARBALL="worker-android-sha1-$GITHUB_SHA.tar.gz"
curl -L "${{ steps.eas_artifacts.outputs.download_ios_url }}" | gsutil cp - "gs://eas-build-worker-tarballs/$IOS_GCP_TARBALL"
curl -L "${{ steps.eas_artifacts.outputs.download_android_url }}" | gsutil cp - "gs://eas-build-worker-tarballs/$ANDROID_GCP_TARBALL"
- name: Promote worker to ${{ inputs.environment }}
if: inputs.environment != ''
run: |
for platform in android ios; do
GCP_TARBALL="worker-$platform-sha1-$GITHUB_SHA.tar.gz"
WORKER_STAGING_TARBALL="worker-$platform-${{ inputs.environment }}.tar.gz"
gsutil cp "gs://eas-build-worker-tarballs/$GCP_TARBALL" "gs://eas-build-worker-tarballs/$WORKER_STAGING_TARBALL"
done
mkdir -p "$OUTPUT_DIR"
bash packages/worker/package.sh "$OUTPUT_DIR/worker-android.tar.gz" android
tar -xOf "$OUTPUT_DIR/worker-android.tar.gz" ./packages/build-tools/dist/steps/utils/androidEmulatorLogs.js > "$OUTPUT_DIR/androidEmulatorLogs.js"
cd "$OUTPUT_DIR"
sha256sum worker-android.tar.gz androidEmulatorLogs.js > SHA256SUMS
printf '%s\n' "$GITHUB_SHA" > build-revision.txt
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: android-worker-staging-test
path: ${{ runner.temp }}/android-worker-staging-test/
retention-days: 7
if-no-files-found: error
+1 -3
View File
@@ -28,9 +28,7 @@ jobs:
contains(github.event.pull_request.labels.*.name, 'no changelog') ||
github.event.pull_request.user.login == 'dependabot[bot]'
}}
# 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' }}
BASE_REF: ${{ github.event.pull_request.base.sha || 'origin/main' }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
-159
View File
@@ -2,165 +2,6 @@
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
- [eas-cli] Import ordered action configurations with `eas simulator:control --import` and show progress for each action. ([#4686](https://github.com/expo/eas-cli/pull/4686) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Reuse simulator control connections with `--keep-alive` or `simulator:control:connection open`. Set an idle timeout, inspect the connection, or close it without stopping the simulator session. Keep-alive currently supports macOS and Linux. ([#4791](https://github.com/expo/eas-cli/pull/4791) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Start simulator sessions from saved settings and apply control actions after readiness. ([#4687](https://github.com/expo/eas-cli/pull/4687) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Run `eas simulator` commands also as `eas simulators`, `eas emulator` and `eas emulators`, for example `eas emulators:start` or `eas simulators:list`. These aliases are hidden from help. ([#4794](https://github.com/expo/eas-cli/pull/4794) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] List `eas workflow:ssh` in `eas --help`. ([#4793](https://github.com/expo/eas-cli/pull/4793) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### 🐛 Bug fixes
- [build-tools] Stop an agent-device session that is still starting up when it is stopped. ([#4666](https://github.com/expo/eas-cli/pull/4666) by [@gwdp](https://github.com/gwdp))
- [build-tools] Stop an Appium session that is still starting up when it is stopped. ([#4668](https://github.com/expo/eas-cli/pull/4668) by [@gwdp](https://github.com/gwdp))
Report temporary-directory cleanup failures when Stop interrupts Appium installation.
- [build-tools] Stop an Argent session that is still starting up when it is stopped. ([#4667](https://github.com/expo/eas-cli/pull/4667) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Find the personal account by its owner instead of assuming that its name equals the username. ([#3922](https://github.com/expo/eas-cli/pull/3922) by [@byronkarlen](https://github.com/byronkarlen))
- [build-tools][worker] Use the sandbox worker token for sandbox jobs when www provides one, and keep Expo credentials out of the commands of those jobs. ([#4807](https://github.com/expo/eas-cli/pull/4807) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [eas-cli] Show the Expo Simulators hint after a successful build from November 12 instead of November 10. ([#4817](https://github.com/expo/eas-cli/pull/4817) by [@williamgrosset](https://github.com/williamgrosset))
- [build-tools] Stop a web-preview session that is still starting up when it is stopped. ([#4669](https://github.com/expo/eas-cli/pull/4669) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [build-tools] Extract the worker API client core from the device run session worker API client. ([#4806](https://github.com/expo/eas-cli/pull/4806) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## [27.4.1](https://github.com/expo/eas-cli/releases/tag/v27.4.1) - 2026-11-04
### 🐛 Bug fixes
- [simulator-launcher][build-tools] Wait for a starting warm Simulator launcher before claiming its session, instead of falling back to a cold start. ([#4810](https://github.com/expo/eas-cli/pull/4810) by [@gwdp](https://github.com/gwdp))
## [27.4.0](https://github.com/expo/eas-cli/releases/tag/v27.4.0) - 2026-11-03
### 🎉 New features
- [simulator-launcher][build-tools] Add `eas/start_warm_appium_remote_session`, which runs the Appium session on the warm Simulator, or starts cold. An `appium` claim stops the unused tools first, so WDA does not share the Simulator with the agent-device runner. ([#4735](https://github.com/expo/eas-cli/pull/4735) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add warm iOS session steps (`eas/start_warm_serve_sim_remote_session`, `eas/start_warm_agent_device_session`, `eas/start_warm_argent_remote_session`). They run the existing session on the warm Simulator, or start cold. `eas/start_agent_device_session` also accepts `install_app_path` and `launch_app_identifier`. ([#4723](https://github.com/expo/eas-cli/pull/4723) by [@gwdp](https://github.com/gwdp))
- [build-tools] Update the app version and build number from the final application archive, so the build shows the values the native build resolved. ([#3499](https://github.com/expo/eas-cli/pull/3499) by [@kadikraman](https://github.com/kadikraman))
### 🐛 Bug fixes
- [build-tools] Upload the final agent-device session artifacts before stopping the daemon, including sessions that reuse a warm Simulator. ([#4750](https://github.com/expo/eas-cli/pull/4750) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add internal helpers that claim the warm Simulator through the launcher and adopt it on the warm serve-sim. Skip the Expo Go install when the warm Simulator already has the same Expo Go version. ([#4724](https://github.com/expo/eas-cli/pull/4724) by [@gwdp](https://github.com/gwdp))
- [build-tools] Recover warm Simulator recordings when their MP4 exists before the shutdown manifest is ready. ([#4789](https://github.com/expo/eas-cli/pull/4789) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Show the Expo Simulators hint after a successful build from November 10 instead of November 5. ([#4801](https://github.com/expo/eas-cli/pull/4801) by [@williamgrosset](https://github.com/williamgrosset))
### 🧹 Chores
- [build-tools] Move iOS Simulator startup behind a shared session host contract. ([#4752](https://github.com/expo/eas-cli/pull/4752) by [@gwdp](https://github.com/gwdp))
## [27.3.0](https://github.com/expo/eas-cli/releases/tag/v27.3.0) - 2026-11-02
### 🎉 New features
- [eas-build-job] Add optional Observe error context to workflow jobs. ([#4787](https://github.com/expo/eas-cli/pull/4787) by [@AbbanMustafa](https://github.com/AbbanMustafa))
### 🐛 Bug fixes
- [eas-cli] Bump `@expo/apple-utils` to `2.3.0` to fix iOS capability sync when App Store Connect rejects enabling, disabling or changing a capability. ([#4790](https://github.com/expo/eas-cli/pull/4790) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Keep already linked App Groups, Merchant IDs and iCloud containers when capability sync links another identifier. ([#4584](https://github.com/expo/eas-cli/pull/4584) by [@ceydaakin](https://github.com/ceydaakin))
- [worker] Mask secrets in every record the worker process writes to its stdout: the build log records and the worker's own records. ([#4782](https://github.com/expo/eas-cli/pull/4782) by [@hSATAC](https://github.com/hSATAC))
- [worker][build-tools] Stop warmup in the background for jobs that do not use the warm Simulator, so job setup does not wait for it. Before build-tools boots or clones a Simulator, it waits until the launcher's state file records that cleanup finished, and fails if cleanup does not finish in time. ([#4721](https://github.com/expo/eas-cli/pull/4721) by [@gwdp](https://github.com/gwdp))
- [build-tools] Recover unhealthy device-session tunnels without changing their URLs and block further traffic when shutdown starts, even if ngrok cleanup times out. ([#4526](https://github.com/expo/eas-cli/pull/4526) by [@gwdp](https://github.com/gwdp))
- [build-tools] Stop sending the SSH host when registering an SSH session. ([#4784](https://github.com/expo/eas-cli/pull/4784) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## [27.2.0](https://github.com/expo/eas-cli/releases/tag/v27.2.0) - 2026-10-29
### 🎉 New features
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "27.10.0",
"PRODUCTION": "27.10.0"
"STAGING": "27.2.0",
"PRODUCTION": "27.2.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "27.10.0",
"version": "27.2.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+1 -1
View File
@@ -6,4 +6,4 @@ MISE_NODE_COREPACK = "true"
actionlint = "1.7.12"
gcloud = "570.0.0"
jq = "1.8.1"
node = "22.23.5"
node = "22.23.4"
-100
View File
@@ -1,105 +1,5 @@
# @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
- Stop an agent-device session that is still starting up when it is stopped. ([#4666](https://github.com/expo/eas-cli/pull/4666) by [@gwdp](https://github.com/gwdp))
- Stop an Appium session that is still starting up when it is stopped. ([#4668](https://github.com/expo/eas-cli/pull/4668) by [@gwdp](https://github.com/gwdp))
Report temporary-directory cleanup failures when Stop interrupts Appium installation.
- Stop an Argent session that is still starting up when it is stopped. ([#4667](https://github.com/expo/eas-cli/pull/4667) by [@gwdp](https://github.com/gwdp))
- Use the sandbox worker token for sandbox jobs when www provides one, and keep Expo credentials out of the commands of those jobs. ([#4807](https://github.com/expo/eas-cli/pull/4807) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- Stop a web-preview session that is still starting up when it is stopped. ([#4669](https://github.com/expo/eas-cli/pull/4669) by [@gwdp](https://github.com/gwdp))
## 27.4.1
### Patch Changes
- Wait for a starting warm Simulator launcher before claiming its session, instead of falling back to a cold start. ([#4810](https://github.com/expo/eas-cli/pull/4810) by [@gwdp](https://github.com/gwdp))
## 27.4.0
### Minor Changes
- Add `eas/start_warm_appium_remote_session`, which runs the Appium session on the warm Simulator, or starts cold. An `appium` claim stops the unused tools first, so WDA does not share the Simulator with the agent-device runner. ([#4735](https://github.com/expo/eas-cli/pull/4735) by [@gwdp](https://github.com/gwdp))
- Add warm iOS session steps (`eas/start_warm_serve_sim_remote_session`, `eas/start_warm_agent_device_session`, `eas/start_warm_argent_remote_session`). They run the existing session on the warm Simulator, or start cold. `eas/start_agent_device_session` also accepts `install_app_path` and `launch_app_identifier`. ([#4723](https://github.com/expo/eas-cli/pull/4723) by [@gwdp](https://github.com/gwdp))
- Update the app version and build number from the final application archive, so the build shows the values the native build resolved. ([#3499](https://github.com/expo/eas-cli/pull/3499) by [@kadikraman](https://github.com/kadikraman))
### Patch Changes
- Upload the final agent-device session artifacts before stopping the daemon, including sessions that reuse a warm Simulator. ([#4750](https://github.com/expo/eas-cli/pull/4750) by [@gwdp](https://github.com/gwdp))
- Add internal helpers that claim the warm Simulator through the launcher and adopt it on the warm serve-sim. Skip the Expo Go install when the warm Simulator already has the same Expo Go version. ([#4724](https://github.com/expo/eas-cli/pull/4724) by [@gwdp](https://github.com/gwdp))
- Recover warm Simulator recordings when their MP4 exists before the shutdown manifest is ready. ([#4789](https://github.com/expo/eas-cli/pull/4789) by [@gwdp](https://github.com/gwdp))
## 27.3.0
### Patch Changes
- Stop warmup in the background for jobs that do not use the warm Simulator, so job setup does not wait for it. Before build-tools boots or clones a Simulator, it waits until the launcher's state file records that cleanup finished, and fails if cleanup does not finish in time. ([#4721](https://github.com/expo/eas-cli/pull/4721) by [@gwdp](https://github.com/gwdp))
- Recover unhealthy device-session tunnels without changing their URLs and block further traffic when shutdown starts, even if ngrok cleanup times out. ([#4526](https://github.com/expo/eas-cli/pull/4526) by [@gwdp](https://github.com/gwdp))
- Stop sending the SSH host when registering an SSH session. ([#4784](https://github.com/expo/eas-cli/pull/4784) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## 27.2.0
### Minor Changes
+13 -13
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "27.10.0",
"version": "27.2.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].5 -- node scripts/generate-appium-commands.js",
"generate-appium-commands": "mise exec [email protected].4 -- 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.10.0",
"@expo/eas-build-job": "27.10.0",
"@expo/downloader": "27.2.0",
"@expo/eas-build-job": "27.2.0",
"@expo/env": "^0.4.0",
"@expo/logger": "27.10.0",
"@expo/logger": "27.2.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.10.0",
"@expo/template-file": "27.10.0",
"@expo/turtle-spawn": "27.10.0",
"@expo/steps": "27.2.0",
"@expo/template-file": "27.2.0",
"@expo/turtle-spawn": "27.2.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.9",
"joi": "^17.13.1",
"jose": "5.10.0",
"lodash": "^4.18.1",
"node-fetch": "^2.7.0",
"node-forge": "^1.4.3",
"node-forge": "^1.3.1",
"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.22",
"tar": "7.5.19",
"uuid": "9.0.1",
"ws": "8.21.1",
"yaml": "^2.9.1",
"zod": "^4.6.7"
"yaml": "^2.8.1",
"zod": "^4.3.5"
},
"devDependencies": {
"@expo/repack-app": "~0.9.0",
@@ -2,7 +2,6 @@ import {
BuildPhase,
BuildPhaseResult,
BuildTrigger,
EnvironmentSecretType,
Job,
Metadata,
Platform,
@@ -32,34 +31,6 @@ describe('BuildContext', () => {
(fs.readdir as unknown as jest.Mock).mockResolvedValue([]);
});
it('keeps the sandbox worker token out of the env and keeps the other secrets', () => {
const ctx = new BuildContext(
{
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
secrets: {
environmentSecrets: [
{
name: 'EAS_SANDBOX_WORKER_TOKEN',
value: 'sandbox-worker-token',
type: EnvironmentSecretType.STRING,
},
{ name: 'KEEP', value: 'value', type: EnvironmentSecretType.STRING },
],
},
} as Job,
{
env: { __API_SERVER_URL: 'https://api.expo.test' },
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
expect(ctx.env.EAS_SANDBOX_WORKER_TOKEN).toBeUndefined();
expect(ctx.env.KEEP).toBe('value');
});
it('should merge secrets', async () => {
const robotAccessToken = randomUUID();
await vol.promises.mkdir('/workingdir/eas-environment-secrets/', { recursive: true });
@@ -361,67 +361,6 @@ describe('Datadog singleton', () => {
expect(turtleFetchMock).not.toHaveBeenCalled();
});
it('POSTs distribution metrics to the sandbox worker API with the sandbox worker token', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
sandboxWorkerToken: 'sandbox-token',
target: { kind: 'sandbox' },
});
Datadog.distribution('eas.workflow.hook', 1, { hook: 'on_start' });
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/sandbox-worker/metrics',
'POST',
{
json: {
metrics: [
{
name: 'eas.workflow.hook',
type: 'distribution',
value: 1,
tags: { hook: 'on_start', target: 'job_run' },
},
],
},
headers: { 'expo-sandbox-worker-token': 'sandbox-token' },
retries: 2,
}
);
});
it('labels metric upload failures for sandbox targets with the sandbox kind', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
sandboxWorkerToken: 'sandbox-token',
target: { kind: 'sandbox' },
});
Datadog.distribution('eas.workflow.hook', 1);
await flushPromises();
expect(sentryCaptureMock).toHaveBeenCalledWith(
'Failed to report turtle sandbox metric',
expect.any(Error),
expect.anything()
);
});
it('does not report logs for sandbox targets', () => {
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
sandboxWorkerToken: 'sandbox-token',
target: { kind: 'sandbox' },
});
Datadog.log('Custom build saw user-provided function');
expect(turtleFetchMock).not.toHaveBeenCalled();
});
it('flushes pending metric uploads', async () => {
let resolveUpload!: (response: Response) => void;
turtleFetchMock.mockReturnValueOnce(
@@ -1,22 +0,0 @@
import { type watchDeviceRunSessionStartup } from '../../steps/utils/deviceRunSessionStartup';
import { DeviceRunSessionStoppedError } from '../../steps/utils/remoteDeviceRunSession';
/**
* A `watchDeviceRunSessionStartup` stand-in that never polls. A test calls `stop` on the
* returned watch (`jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value`) to act like
* a Stop request during startup.
*/
export function createStartupWatchMock({
startup,
}: Parameters<typeof watchDeviceRunSessionStartup>[0]): ReturnType<
typeof watchDeviceRunSessionStartup
> & { stop: () => void } {
const stopped = new DeviceRunSessionStoppedError('Stopped during startup.');
return {
stopped,
stop: () => startup.abort(stopped),
isStopped: () => startup.signal.reason === stopped,
completeAsync: jest.fn(async () => startup.signal.throwIfAborted()),
finishAsync: jest.fn(async () => {}),
};
}
@@ -53,7 +53,7 @@ jest.mock('../../steps/utils/android/gradleConfig', () => ({
injectCredentialsGradleConfig: jest.fn(),
}));
jest.mock('../../utils/artifacts', () => ({
uploadApplicationArchive: jest.fn(async () => []),
uploadApplicationArchive: jest.fn(),
}));
jest.mock('../../utils/expoUpdates', () => ({
configureExpoUpdatesIfInstalledAsync: jest.fn(),
+1 -24
View File
@@ -17,10 +17,6 @@ import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
import { prebuildAsync } from '../common/prebuild';
import { JobHooksRef, setupAsync } from '../common/setup';
import { Artifacts, BuildContext, SkipNativeBuildError } from '../context';
import {
extractAndroidVersionAsync,
updateResolvedVersionAsync,
} from '../steps/functions/updateResolvedVersion';
import {
cacheStatsAsync,
restoreCcacheAsync,
@@ -224,30 +220,11 @@ async function buildInnerAsync(
});
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
const [archivePath] = await uploadApplicationArchive(ctx, {
await uploadApplicationArchive(ctx, {
patternOrPath: resolveArtifactPath(ctx.job),
rootDir: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
});
const buildId = ctx.env.EAS_BUILD_ID;
if (!archivePath || !buildId) {
return;
}
try {
const { appVersion, appBuildVersion } = await extractAndroidVersionAsync(archivePath);
if (appVersion || appBuildVersion) {
await updateResolvedVersionAsync(ctx.graphqlClient, buildId, {
appVersion,
appBuildVersion,
});
ctx.logger.info(
`Updated resolved version: ${appVersion ?? 'N/A'} (${appBuildVersion ?? 'N/A'})`
);
}
} catch (err: any) {
Sentry.capture('Failed to update resolved version', err);
ctx.logger.warn({ err }, 'Failed to update resolved version (non-fatal)');
}
});
if (shouldUploadEmbeddedBundle(ctx)) {
+1 -24
View File
@@ -18,10 +18,6 @@ import { downloadApplicationArchiveAsync } from '../ios/resign';
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
import { Sentry } from '../sentry';
import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
import {
extractIosVersionAsync,
updateResolvedVersionAsync,
} from '../steps/functions/updateResolvedVersion';
import {
cacheStatsAsync,
restoreCcacheAsync,
@@ -240,30 +236,11 @@ async function buildInnerAsync(
});
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
const [archivePath] = await uploadApplicationArchive(ctx, {
await uploadApplicationArchive(ctx, {
patternOrPath: resolveArtifactPath(ctx.job),
rootDir: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
});
const buildId = ctx.env.EAS_BUILD_ID;
if (!archivePath || !buildId) {
return;
}
try {
const { appVersion, appBuildVersion } = await extractIosVersionAsync(archivePath);
if (appVersion || appBuildVersion) {
await updateResolvedVersionAsync(ctx.graphqlClient, buildId, {
appVersion,
appBuildVersion,
});
ctx.logger.info(
`Updated resolved version: ${appVersion ?? 'N/A'} (${appBuildVersion ?? 'N/A'})`
);
}
} catch (err: any) {
Sentry.capture('Failed to update resolved version', err);
ctx.logger.warn({ err }, 'Failed to update resolved version (non-fatal)');
}
});
if (shouldUploadEmbeddedBundle(ctx)) {
@@ -1,8 +1,8 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { UserError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import { fetchAndCheckoutRefAsync, stripRepositoryCredentialsAsync } from '../git';
import { fetchAndCheckoutRefAsync } from '../git';
jest.mock('@expo/turtle-spawn');
@@ -147,64 +147,3 @@ 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);
});
});
@@ -2,7 +2,6 @@ import {
ArchiveSourceType,
BuildMode,
BuildTrigger,
EnvironmentSecretType,
ErrorCode,
Job,
Platform,
@@ -117,73 +116,6 @@ describe('projectSources', () => {
);
});
it('fetches a sandbox project source from the sandbox worker router with only the worker token', async () => {
const gitCommitHash = randomBytes(20).toString('hex');
const ctx = new BuildContext(
{
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
projectArchive: {
type: ArchiveSourceType.GIT,
gitRef: 'refs/heads/main',
gitCommitHash,
},
secrets: {
robotAccessToken: 'sandbox-worker-token',
environmentSecrets: [
{
name: 'EAS_SANDBOX_WORKER_TOKEN',
type: EnvironmentSecretType.STRING,
value: 'sandbox-worker-token',
},
],
},
} as unknown as Job,
{
env: {
__API_SERVER_URL: 'https://api.expo.dev',
EAS_BUILD_ID: randomUUID(),
EAS_BUILD_RUNNER: 'eas-build',
},
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
const fetchMock = jest.mocked(fetch);
fetchMock.mockImplementation(
async () =>
({
ok: true,
json: async () => ({
data: {
gitRef: 'refs/heads/main',
gitCommitHash,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
type: ArchiveSourceType.GIT,
},
}),
}) as Response
);
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
expect(fetchMock).toHaveBeenCalledWith(
'https://api.expo.dev/v2/sandbox-worker/project-archive',
expect.objectContaining({
method: 'POST',
headers: { 'expo-sandbox-worker-token': 'sandbox-worker-token' },
})
);
expect(shallowCloneRepositoryAsync).toHaveBeenCalledWith(
expect.objectContaining({
archiveSource: expect.objectContaining({
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
}),
})
);
});
it.each(['http', 'network', 'json', 'schema', 'missing-url', 'PATH', 'GCS', 'R2'])(
'throws a system error if refresh fails (%s)',
async failure => {
+1 -40
View File
@@ -1,4 +1,4 @@
import { ArchiveSource, ArchiveSourceType, SystemError, UserError } from '@expo/eas-build-job';
import { ArchiveSource, ArchiveSourceType, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
@@ -99,45 +99,6 @@ 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);
@@ -1,15 +0,0 @@
import { ArchiveSourceType } from '@expo/eas-build-job';
import { z } from 'zod';
export const ProjectArchiveSourceSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal(ArchiveSourceType.GIT),
repositoryUrl: z.string().url(),
gitRef: z.string().nullable(),
gitCommitHash: z.string(),
}),
z.object({ type: z.literal(ArchiveSourceType.URL), url: z.string().url() }),
z.object({ type: z.literal(ArchiveSourceType.NONE) }),
]);
export type ProjectArchiveSource = z.infer<typeof ProjectArchiveSourceSchema>;
@@ -10,9 +10,7 @@ import path from 'path';
import { z } from 'zod';
import { shallowCloneRepositoryAsync } from './git';
import { type ProjectArchiveSource, ProjectArchiveSourceSchema } from './projectArchiveSource';
import { BuildContext } from '../context';
import { getSandboxWorkerApi } from '../steps/utils/sandboxWorkerApi';
import { uploadContentWithSignedUrl } from '../storage/uploadWithSignedUrl';
import { turtleFetch } from '../utils/turtleFetch';
@@ -247,18 +245,7 @@ async function uploadProjectMetadataAsync(
}
}
async function fetchProjectArchiveSourceAsync(
ctx: BuildContext<Job>
): Promise<ProjectArchiveSource> {
const sandboxWorkerApi = getSandboxWorkerApi(ctx);
if (sandboxWorkerApi) {
return await sandboxWorkerApi.getProjectArchiveSourceAsync({
timeoutMs: 20000,
retries: 3,
logger: ctx.logger,
});
}
async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>) {
const taskId = nullthrows(ctx.env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
const expoApiServerURL = nullthrows(ctx.env.__API_SERVER_URL, '__API_SERVER_URL is not set');
const robotAccessToken = nullthrows(
@@ -296,7 +283,20 @@ async function fetchProjectArchiveSourceAsync(
);
}
const dataResult = z.object({ data: ProjectArchiveSourceSchema }).safeParse(jsonResult.value);
const dataResult = z
.object({
data: z.discriminatedUnion('type', [
z.object({
type: z.literal(ArchiveSourceType.GIT),
repositoryUrl: z.string().url(),
gitRef: z.string().nullable(),
gitCommitHash: z.string(),
}),
z.object({ type: z.literal(ArchiveSourceType.URL), url: z.string().url() }),
z.object({ type: z.literal(ArchiveSourceType.NONE) }),
]),
})
.safeParse(jsonResult.value);
if (!dataResult.success) {
throw new Error(
`Unexpected data from server (${response.status}): ${z.prettifyError(dataResult.error)}`
+1 -9
View File
@@ -3,7 +3,6 @@ import {
BuildPhase,
BuildPhaseResult,
Env,
type EnvironmentSecret,
EnvironmentSecretType,
Job,
LogMarker,
@@ -19,7 +18,6 @@ import path from 'path';
import { resolveBuildPhaseErrorAsync } from './buildErrors/detectError';
import { Datadog } from './datadog';
import { SANDBOX_WORKER_TOKEN_ENV } from './steps/utils/sandboxWorkerApi';
import { readAppConfig } from './utils/appConfig';
import { createTemporaryEnvironmentSecretFile } from './utils/environmentSecrets';
import { PackageManager, resolvePackageManager } from './utils/packageManager';
@@ -410,7 +408,7 @@ export class BuildContext<TJob extends Job = Job> {
const environmentSecretsDirectory = path.join(this.workingdir, 'eas-environment-secrets');
const environmentSecrets: Record<string, string> = {};
for (const { name, type, value } of job.secrets.environmentSecrets.filter(isJobSecret)) {
for (const { name, type, value } of job.secrets.environmentSecrets) {
if (type === EnvironmentSecretType.STRING) {
environmentSecrets[name] = value;
} else {
@@ -424,9 +422,3 @@ export class BuildContext<TJob extends Job = Job> {
return environmentSecrets;
}
}
const EXCLUDED_JOB_SECRET_NAMES: ReadonlySet<string> = new Set([SANDBOX_WORKER_TOKEN_ENV]);
function isJobSecret({ name }: EnvironmentSecret): boolean {
return !EXCLUDED_JOB_SECRET_NAMES.has(name);
}
+2 -15
View File
@@ -1,30 +1,23 @@
import { Sentry } from './sentry';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from './steps/utils/deviceRunSessionWorkerApi';
import { SANDBOX_WORKER_TOKEN_HEADER } from './steps/utils/sandboxWorkerApi';
import { turtleFetch } from './utils/turtleFetch';
type MetricsTarget =
| { kind: 'build'; turtleBuildId: string }
| { kind: 'jobRun'; turtleJobRunId: string }
// A device run session job with a session token. The token names the session and its job run.
| { kind: 'deviceRunSession' }
| { kind: 'sandbox' };
| { kind: 'deviceRunSession' };
type DatadogSetupOptions =
| {
expoApiV2BaseUrl: string;
robotAccessToken: string;
target: Exclude<MetricsTarget, { kind: 'deviceRunSession' | 'sandbox' }>;
target: Exclude<MetricsTarget, { kind: 'deviceRunSession' }>;
}
| {
expoApiV2BaseUrl: string;
deviceRunSessionToken: string;
target: Extract<MetricsTarget, { kind: 'deviceRunSession' }>;
}
| {
expoApiV2BaseUrl: string;
sandboxWorkerToken: string;
target: Extract<MetricsTarget, { kind: 'sandbox' }>;
};
let setupOptions: DatadogSetupOptions | null = null;
@@ -48,12 +41,6 @@ function getMetricsRequest(options: DatadogSetupOptions): {
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: options.deviceRunSessionToken },
};
}
if ('sandboxWorkerToken' in options) {
return {
path: 'sandbox-worker/metrics',
headers: { [SANDBOX_WORKER_TOKEN_HEADER]: options.sandboxWorkerToken },
};
}
const { target, robotAccessToken } = options;
return {
path:
-1
View File
@@ -43,6 +43,5 @@ export * from './generic';
export { Datadog } from './datadog';
export { getDeviceRunSessionTokenFromEnvironmentSecrets } from './steps/utils/deviceRunSessionWorkerApi';
export { getSandboxWorkerTokenFromEnvironmentSecrets } from './steps/utils/sandboxWorkerApi';
export { Sentry } from './sentry';
export * from './runtimeSettings';
@@ -1,18 +0,0 @@
import { type CustomBuildContext } from '../../customBuildContext';
import { getEasFunctions } from '../easFunctions';
it('registers opt-in session functions with the existing inputs', () => {
const functions = getEasFunctions({ hasBuildJob: () => false } as CustomBuildContext);
for (const name of ['serve_sim_remote', 'agent_device', 'argent_remote', 'appium_remote']) {
const existing = functions.find(fn => fn.id === `start_${name}_session`)!;
const warm = functions.find(fn => fn.id === `start_warm_${name}_session`)!;
expect(warm.getFullId()).toBe(`eas/start_warm_${name}_session`);
const warmInputs = warm.inputProviders!.map(input => input(undefined as never, 'warm').id);
const existingInputs = existing.inputProviders!.map(
input => input(undefined as never, 'existing').id
);
expect(warmInputs).toEqual(existingInputs);
}
const ids = functions.map(fn => fn.getFullId());
expect(new Set(ids).size).toBe(ids.length);
});
@@ -33,7 +33,6 @@ import { createSubmitToGooglePlayBuildFunction } from './functions/submitToGoogl
import { createReadPackageJsonBuildFunction } from './functions/readPackageJson';
import { createRepackBuildFunction } from './functions/repack';
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
import { createUpdateResolvedVersionBuildFunction } from './functions/updateResolvedVersion';
import { resolveAppleTeamIdFromCredentialsFunction } from './functions/resolveAppleTeamIdFromCredentials';
import { createResolveBuildConfigBuildFunction } from './functions/resolveBuildConfig';
import {
@@ -113,11 +112,8 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
runFastlaneFunction(),
parseXcactivitylogFunction(),
createStartAgentDeviceSessionBuildFunction(ctx),
createStartAgentDeviceSessionBuildFunction(ctx, { warm: true }),
createStartArgentRemoteSessionBuildFunction(ctx),
createStartArgentRemoteSessionBuildFunction(ctx, { warm: true }),
createStartAppiumRemoteSessionBuildFunction(ctx),
createStartAppiumRemoteSessionBuildFunction(ctx, { warm: true }),
createStartAndroidEmulatorBuildFunction(),
createStartCuttlefishDeviceBuildFunction(),
createStartIosSimulatorBuildFunction(),
@@ -126,7 +122,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createFinishIosSimulatorRecordingsBuildFunction(),
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
createStartWebPreviewRemoteSessionBuildFunction(ctx),
createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true }),
createStartServeSimMetricsBuildFunction(),
createStartServeSimLogsBuildFunction(),
createCollectServeSimLogsBuildFunction(ctx),
@@ -161,7 +156,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
functions.push(
...[
createFindAndUploadBuildArtifactsBuildFunction(ctx),
createUpdateResolvedVersionBuildFunction(ctx),
createUploadEmbeddedBundleBuildFunction(ctx),
createResolveBuildConfigBuildFunction(ctx),
createGetCredentialsForBuildTriggeredByGithubIntegration(ctx),
@@ -130,35 +130,6 @@ describe(createEasBuildBuildFunctionGroup, () => {
);
});
it.each([
['Android without credentials', { platform: Platform.ANDROID }],
['Android with credentials', { platform: Platform.ANDROID, buildCredentials: { test: {} } }],
['iOS simulator', { platform: Platform.IOS, simulator: true }],
['iOS with credentials', { platform: Platform.IOS, buildCredentials: { test: {} } }],
])('wires the uploaded archive path into the update step (%s)', (_label, overrides) => {
const buildToolsContext = createMockBuildToolsContext(overrides);
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
const updateStep = steps.find(s => s.displayName === 'Update resolved version');
const archivePathInput = updateStep?.inputs?.find(
input => input.id === 'application_archive_path'
);
expect(updateStep).toBeDefined();
expect(archivePathInput?.rawValue).toBe(
'${ steps.find_and_upload_build_artifacts.application_archive_path }'
);
// The step ID must be assigned explicitly: an auto-generated ID makes the input
// interpolation throw "Step ... does not exist" before the function body runs.
const uploadStepIndex = steps.findIndex(s => s.id === 'find_and_upload_build_artifacts');
expect(uploadStepIndex).toBeGreaterThan(-1);
expect(steps[uploadStepIndex].displayName).toBe('Find and upload build artifacts');
expect(uploadStepIndex).toBeLessThan(steps.indexOf(updateStep!));
});
it('composes working directory with installPods step-level ./ios dir (iOS)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
@@ -19,7 +19,6 @@ import { configureIosCredentialsFunction } from '../functions/configureIosCreden
import { configureIosVersionFunction } from '../functions/configureIosVersion';
import { eagerBundleBuildFunction } from '../functions/eagerBundle';
import { createFindAndUploadBuildArtifactsBuildFunction } from '../functions/findAndUploadBuildArtifacts';
import { createUpdateResolvedVersionBuildFunction } from '../functions/updateResolvedVersion';
import { generateGymfileFromTemplateFunction } from '../functions/generateGymfileFromTemplate';
import { injectAndroidCredentialsFunction } from '../functions/injectAndroidCredentials';
import { createInstallNodeModulesBuildFunction } from '../functions/installNodeModules';
@@ -184,20 +183,7 @@ function createStepsForIosSimulatorBuild({
runFastlane,
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, {
id: 'find_and_upload_build_artifacts',
workingDirectory,
}),
createUpdateResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
application_archive_path:
'${ steps.find_and_upload_build_artifacts.application_archive_path }',
},
}
),
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
@@ -318,10 +304,7 @@ function createStepsForIosBuildWithCredentials({
runFastlane,
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, {
id: 'find_and_upload_build_artifacts',
workingDirectory,
}),
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
...(shouldUploadEmbeddedBundle({
env: globalCtx.env,
metadata: globalCtx.staticContext.metadata,
@@ -335,16 +318,6 @@ function createStepsForIosBuildWithCredentials({
}),
]
: []),
createUpdateResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
application_archive_path:
'${ steps.find_and_upload_build_artifacts.application_archive_path }',
},
}
),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
@@ -431,10 +404,7 @@ function createStepsForAndroidBuildWithoutCredentials({
runGradle,
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, {
id: 'find_and_upload_build_artifacts',
workingDirectory,
}),
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
...(shouldUploadEmbeddedBundle({
env: globalCtx.env,
metadata: globalCtx.staticContext.metadata,
@@ -448,16 +418,6 @@ function createStepsForAndroidBuildWithoutCredentials({
}),
]
: []),
createUpdateResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
application_archive_path:
'${ steps.find_and_upload_build_artifacts.application_archive_path }',
},
}
),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
@@ -550,10 +510,7 @@ function createStepsForAndroidBuildWithCredentials({
: []),
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, {
id: 'find_and_upload_build_artifacts',
workingDirectory,
}),
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
...(shouldUploadEmbeddedBundle({
env: globalCtx.env,
metadata: globalCtx.staticContext.metadata,
@@ -567,16 +524,6 @@ function createStepsForAndroidBuildWithCredentials({
}),
]
: []),
createUpdateResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
application_archive_path:
'${ steps.find_and_upload_build_artifacts.application_archive_path }',
},
}
),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
@@ -1,10 +1,7 @@
import { BuildRuntimePlatform } from '@expo/steps';
import { BuildRuntimePlatform, type BuildStepContext, type BuildStepEnv } from '@expo/steps';
import {
createCustomBuildContextMock,
createGlobalContextMock,
} from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { type CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { collectAndroidEmulatorLogsAsync } from '../../utils/androidEmulatorLogs';
import { createCollectAndroidEmulatorLogsBuildFunction } from '../collectAndroidEmulatorLogs';
@@ -12,26 +9,20 @@ import { createCollectAndroidEmulatorLogsBuildFunction } from '../collectAndroid
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 };
}
const logger = createMockLogger();
const ctx = {} as CustomBuildContext;
const global = {} as BuildStepContext['global'];
const step = { logger, global } as BuildStepContext;
const args = { inputs: {}, outputs: {}, env: { DEVICE_RUN_SESSION_ID: 'session' } as BuildStepEnv };
it('collects captured logs without a second source selection', async () => {
const { ctx, global, fn, step } = createStep();
it('collects logs for its own workflow and session without inputs', async () => {
const fn = createCollectAndroidEmulatorLogsBuildFunction(ctx);
expect(fn.supportedRuntimePlatforms).toEqual([BuildRuntimePlatform.LINUX]);
await step.executeAsync();
await fn.fn?.(step, args);
expect(collectAndroidEmulatorLogsAsync).toHaveBeenCalledWith(ctx, {
global,
deviceRunSessionId: 'session',
logger: expect.anything(),
logger,
});
});
@@ -39,8 +30,10 @@ 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();
await expect(
createCollectAndroidEmulatorLogsBuildFunction(ctx).fn?.(step, args)
).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not collect Android emulator logs',
expect.any(Error)
@@ -2,12 +2,10 @@ import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import type { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
import { turtleFetch } from '../../../utils/turtleFetch';
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
@@ -46,26 +44,6 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
waitForFileAsync: jest.fn(),
}));
// Agent-device also polls for Stop while it starts up. That watch sets neither an idle timeout nor
// a duration; keep it pending until startup retires it, and give each lifetime wait its own mock.
const lifetimeWait = jest.fn<
ReturnType<typeof waitForDeviceRunSessionStoppedAsync>,
Parameters<typeof waitForDeviceRunSessionStoppedAsync>
>();
function routeStartupWatch(): void {
lifetimeWait.mockReset();
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async args => {
if ('idleTimeout' in args || 'maxDurationSeconds' in args) {
return await lifetimeWait(args);
}
if (!args.signal?.aborted) {
await new Promise<void>(resolve =>
args.signal?.addEventListener('abort', () => resolve(), { once: true })
);
}
});
}
const finishHost = jest.fn();
const closePreview = jest.fn();
const stopTool = jest.fn();
@@ -76,8 +54,6 @@ const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnTh
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockResolvedValue();
routeStartupWatch();
// The daemon package is external. No installation or daemon process runs in these tests.
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
// Only the emulator logcat folder: the Appium step writes into its own temp folder.
@@ -123,7 +99,7 @@ beforeEach(() => {
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue(events);
jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue(events);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
lifetimeWait.mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
});
afterEach(() => {
@@ -140,20 +116,13 @@ 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,
{
// Match step execution: omitted inputs use their declared defaults.
// Inputs a step does not get default to undefined, like in a real step call.
inputs: new Proxy(
{
public_preview: { value: publicPreview },
@@ -161,7 +130,7 @@ describe.each([
cors_origins: { value: corsOrigins },
} as Record<string, { value: unknown }>,
{
get: (target, id: string) => target[id] ?? { value: defaults.get(id) },
get: (target, id: string) => target[id] ?? { value: undefined },
}
),
outputs: {},
@@ -176,11 +145,7 @@ describe.each([
it('holds the host until session completion', async () => {
await runAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ preview: { baseDomain: 'example.test', authtoken: 'token' } })
);
expect(openPreview).toHaveBeenCalledWith();
expect(openPreview).toHaveBeenCalledWith({ baseDomain: 'example.test', authtoken: 'token' });
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
@@ -192,7 +157,7 @@ describe.each([
expect(finishHost).toHaveBeenCalledTimes(1);
expect(closePreview).not.toHaveBeenCalled();
expect(finishHost.mock.invocationCallOrder[0]).toBeGreaterThan(
lifetimeWait.mock.invocationCallOrder[0]
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
);
expect(stopTool).toHaveBeenCalledTimes(1);
expect(stopEvents).toHaveBeenCalledTimes(1);
@@ -337,7 +302,7 @@ describe.each([
} else if (phase === 'config') {
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
} else {
lifetimeWait.mockRejectedValueOnce(error);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
}
await expect(runAsync()).rejects.toBe(error);
expect(finishHost).toHaveBeenCalledTimes(1);
@@ -385,10 +350,6 @@ describe('Appium ffmpeg setup', () => {
}
function holdFfmpegInstall({ appiumError }: { appiumError?: Error } = {}) {
let markStarted!: () => void;
const started = new Promise<void>(resolve => {
markStarted = resolve;
});
let finish!: (outcome: 'installed' | 'failed') => void;
const install = new Promise((resolve, reject) => {
finish = outcome =>
@@ -399,26 +360,25 @@ describe('Appium ffmpeg setup', () => {
return Promise.reject(new Error('ffmpeg missing'));
}
if (args.includes('install') && args.includes('ffmpeg')) {
markStarted();
return install;
}
return command === 'npm' && appiumError
? Promise.reject(appiumError)
: Promise.resolve({ stdout: '{}' });
}) as never);
return { finish, started };
return finish;
}
it.each(['installed', 'failed'] as const)(
'starts Appium after the ffmpeg install settles (%s)',
async outcome => {
const { finish, started } = holdFfmpegInstall();
const finishFfmpegInstall = holdFfmpegInstall();
const run = runAppiumAsync();
await started;
await new Promise(resolve => setImmediate(resolve));
expect(spawnDetached).not.toHaveBeenCalled();
finish(outcome);
finishFfmpegInstall(outcome);
await run;
expect(spawnDetached).toHaveBeenCalledTimes(1);
}
@@ -426,16 +386,16 @@ describe('Appium ffmpeg setup', () => {
it('waits for the ffmpeg install before failing on an Appium install error', async () => {
const appiumError = new Error('npm failed');
const { finish, started } = holdFfmpegInstall({ appiumError });
const finishFfmpegInstall = holdFfmpegInstall({ appiumError });
let settled = false;
const run = runAppiumAsync().finally(() => {
settled = true;
});
await started;
await new Promise(resolve => setImmediate(resolve));
expect(settled).toBe(false);
finish('installed');
finishFfmpegInstall('installed');
await expect(run).rejects.toBe(appiumError);
});
});
@@ -51,24 +51,6 @@ describe(createFindAndUploadBuildArtifactsBuildFunction, () => {
await expect(buildStep.executeAsync()).resolves.not.toThrow();
});
it('sets application_archive_path output to first archive', async () => {
const globalContext = createGlobalContextMock({});
vol.fromJSON(
{
'ios/build/test.ipa': '',
},
globalContext.defaultWorkingDirectory
);
const buildStep = findAndUploadBuildArtifacts.createBuildStepFromFunctionCall(
globalContext,
{}
);
await buildStep.executeAsync();
expect(buildStep.outputById.application_archive_path.value).toMatch(/ios\/build\/test\.ipa$/);
});
it('throws build artifacts error', async () => {
const globalContext = createGlobalContextMock({});
ctx.job.buildArtifactPaths = ['worker.log'];
@@ -15,7 +15,6 @@ import {
parseMaestroResults,
parseMaestroResultsFromFileAttrs,
parseMaestroRunnerReport,
parseMaestroRunnerResults,
} from '../maestroResultParser';
describe(parseFailedFlowsFromMaestroRunnerReport, () => {
@@ -484,271 +483,6 @@ 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({
@@ -7,7 +7,6 @@ import { type CustomBuildContext } from '../../../customBuildContext';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { isProcessDescendantOfAsync } from '../../../utils/processes';
import { turtleFetch } from '../../../utils/turtleFetch';
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import {
monitorLocalEgressAsync,
@@ -83,26 +82,6 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
// Agent-device also polls for Stop while it starts up. That watch sets neither an idle timeout nor
// a duration; keep it pending until startup retires it, and give each lifetime wait its own mock.
const lifetimeWait = jest.fn<
ReturnType<typeof waitForDeviceRunSessionStoppedAsync>,
Parameters<typeof waitForDeviceRunSessionStoppedAsync>
>();
function routeStartupWatch(): void {
lifetimeWait.mockReset();
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async args => {
if ('idleTimeout' in args || 'maxDurationSeconds' in args) {
return await lifetimeWait(args);
}
if (!args.signal?.aborted) {
await new Promise<void>(resolve =>
args.signal?.addEventListener('abort', () => resolve(), { once: true })
);
}
});
}
const logger = {
info: jest.fn(),
warn: jest.fn(),
@@ -145,8 +124,6 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
}
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockResolvedValue();
routeStartupWatch();
lifetime = registerLocalEgressResources(stopEgress);
stopEgress.mockResolvedValue(undefined);
stopPreview.mockResolvedValue(undefined);
@@ -189,7 +166,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
finishAsync: stopPreview,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
lifetimeWait.mockImplementation(async () => {
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
expect(lifetime.aborted).toBe(false);
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(1);
});
@@ -202,7 +179,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
it('releases resources without monitoring when the session stopped during startup', async () => {
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValueOnce('stopped');
await run();
expect(lifetimeWait).not.toHaveBeenCalled();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
expect(stopPreview).toHaveBeenCalledTimes(1);
expect(stopEgress).toHaveBeenCalledTimes(1);
@@ -211,18 +188,16 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
it('reports credentials and starts monitoring before waiting, then releases resources', async () => {
await run();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
ctx,
deviceRunSessionId: 'session-id',
logger,
remoteConfig: expect.objectContaining({
...fields,
[controllerField]: expect.any(String),
[controllerField === 'previewUrl' ? 'previewToken' : 'webPreviewToken']: 'preview-secret',
}),
})
);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
ctx,
deviceRunSessionId: 'session-id',
logger,
remoteConfig: expect.objectContaining({
...fields,
[controllerField]: expect.any(String),
[controllerField === 'previewUrl' ? 'previewToken' : 'webPreviewToken']: 'preview-secret',
}),
});
expect(monitorLocalEgressAsync).toHaveBeenCalledWith({
port: handoff.port,
env,
@@ -236,7 +211,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
await stopLocalEgressResourcesAsync(logger);
stopEgress.mockClear();
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(null);
lifetimeWait.mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
await run();
const { remoteConfig } = jest.mocked(uploadRemoteSessionConfigAsync).mock.calls[0][0];
expect(remoteConfig).toHaveProperty(controllerField);
@@ -255,7 +230,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
} else if (phase === 'report') {
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValue(error);
} else if (phase === 'wait') {
lifetimeWait.mockRejectedValue(error);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValue(error);
} else {
stopPreview.mockRejectedValue(error);
}
@@ -269,10 +244,11 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
);
it('passes cancellation to the monitor and releases the lifetime', async () => {
const abort = new AbortController();
lifetimeWait.mockImplementation(async () => abort.abort());
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => abort.abort());
await run(abort.signal);
// Agent-device hands the monitor its startup signal, which follows the step signal.
expect(jest.mocked(monitorLocalEgressAsync).mock.calls[0][0].signal?.aborted).toBe(true);
expect(monitorLocalEgressAsync).toHaveBeenCalledWith(
expect.objectContaining({ signal: abort.signal })
);
expect(lifetime.aborted).toBe(true);
expect(stopEgress).toHaveBeenCalledTimes(1);
});
@@ -358,51 +358,6 @@ 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,
@@ -1,18 +1,14 @@
import { BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fetch from 'node-fetch';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Readable } from 'node:stream';
import { type CustomBuildContext } from '../../../customBuildContext';
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
import { uploadDeviceRunSessionArtifactAsync } from '../../utils/deviceRunSessionArtifacts';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import {
DeviceRunSessionStoppedError,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
@@ -25,8 +21,6 @@ import {
} from '../../utils/remoteDeviceRunSession';
import { createProcessOutput } from '../../utils/processOutput';
import { type StartupTasks, createStartupTasks } from '../../utils/startupTasks';
import { createWarmDeviceSessionServer } from '../../utils/warmDeviceSessionHost';
import { type WarmIosSession } from '../../utils/warmIosSession';
import { runAgentDeviceRemoteSessionAsync } from '../startAgentDeviceRemoteSession';
// The daemon entry path and the state directory are resolved from the home directory when
@@ -44,14 +38,10 @@ jest.mock('../../../sentry');
jest.mock('../../utils/agentDeviceArtifacts', () => ({
pollAgentDeviceArtifactsForUploadAsync: jest.fn(),
}));
jest.mock('../../utils/deviceRunSessionArtifacts', () => ({
uploadDeviceRunSessionArtifactAsync: jest.fn(),
}));
jest.mock('../../utils/agentDeviceEvents', () => ({
startAgentDeviceEventCollectionAsync: jest.fn(),
}));
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/warmDeviceSessionHost');
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
getDeviceRunSessionIdOrThrow: jest.fn(),
@@ -84,7 +74,10 @@ const NO_CAPTURE: CaptureInputs = { networkCapture: false, networkCaptureFields:
async function runAsync(
logger: { info: jest.Mock; warn: jest.Mock; child: jest.Mock },
runtimePlatform: BuildRuntimePlatform,
options: Partial<Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]> = {}
capture: CaptureInputs = NO_CAPTURE,
disableShare = false,
privatePreviewUrl?: string,
maxIdleTimeMinutes?: number
): Promise<void> {
await runAgentDeviceRemoteSessionAsync(ctx, {
env: {},
@@ -96,15 +89,16 @@ async function runAsync(
ngrokAuthtoken: 'ngrok-token',
},
packageVersion: undefined,
maxIdleTimeMinutes: undefined,
maxIdleTimeMinutes,
maxDurationSeconds: undefined,
capture: NO_CAPTURE,
tasks: createStartupTasks(logger as never, options.signal),
capture,
disableShare,
privatePreviewUrl,
tasks: createStartupTasks(logger as never),
device:
runtimePlatform === BuildRuntimePlatform.DARWIN
? { iosSimulatorUdid: 'selected-udid', application: Promise.resolve() }
: { booted: Promise.resolve(), ready: Promise.resolve() },
...options,
});
}
@@ -201,228 +195,6 @@ 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;
jest.mocked(createWarmDeviceSessionServer).mockReturnValue(warmServer);
const warm = {
agentDeviceStateDir: '/warm/agent',
tools: { pid: 20, port: 4310, token: 'warm-token' },
} as WarmIosSession;
await runAgentDeviceRemoteSessionAsync(ctx, {
env: {},
logger: logger as never,
runtimePlatform: BuildRuntimePlatform.DARWIN,
sessionEnv: {
deviceRunSessionId: 'device-run-session-id',
ngrokTunnelDomain: 'tunnel.example.com',
ngrokAuthtoken: 'ngrok-token',
},
packageVersion: undefined,
maxIdleTimeMinutes: undefined,
maxDurationSeconds: undefined,
capture: NO_CAPTURE,
tasks: createStartupTasks(logger as never),
warm,
device: { iosSimulatorUdid: 'UDID', application: Promise.resolve() },
});
expect(spawnDetached).not.toHaveBeenCalled();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(ctx, expect.anything(), warmServer);
expect(startNgrokTunnelAsync).toHaveBeenCalledWith(expect.objectContaining({ port: 4310 }));
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({ agentDeviceRemoteSessionToken: 'warm-token' }),
})
);
expect(startAgentDeviceEventCollectionAsync).toHaveBeenCalledWith(
expect.objectContaining({ stateDir: '/warm/agent' })
);
expect(mockEventCollectionStopAsync).toHaveBeenCalledTimes(1);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
it.each([false, true])(
'finishes the final artifact upload before stopping its owner (warm=%s)',
async warm => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const { pollAgentDeviceArtifactsForUploadAsync: pollActual } = jest.requireActual<
typeof import('../../utils/agentDeviceArtifacts')
>('../../utils/agentDeviceArtifacts');
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockImplementation(pollActual);
const { Response } = jest.requireActual<typeof import('node-fetch')>('node-fetch');
const artifacts: { id: string; filename: string }[] = [];
let releaseUpload = () => {};
const pendingUpload = new Promise<void>(resolve => {
releaseUpload = resolve;
});
let markUploadStarted = () => {};
const uploadStarted = new Promise<void>(resolve => {
markUploadStarted = resolve;
});
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementationOnce(async () => {
markUploadStarted();
await pendingUpload;
});
let inventories = 0;
jest.mocked(fetch).mockImplementation(async url => {
if (String(url).endsWith('/artifacts')) {
inventories += 1;
return new Response(JSON.stringify({ artifacts }));
}
return new Response(Readable.from([Buffer.from('screenshot')]));
});
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
while (inventories === 0) {
await new Promise(resolve => setTimeout(resolve, 5));
}
// The next periodic inventory is 5 seconds away, so only the final one can see this file.
artifacts.push({ id: 'late', filename: 'late.png' });
});
const running = runAsync(
logger,
warm ? BuildRuntimePlatform.DARWIN : BuildRuntimePlatform.LINUX,
{
warm: warm
? ({
agentDeviceStateDir: '/warm/agent',
tools: { port: 4310, token: 'warm-token' },
} as WarmIosSession)
: undefined,
}
);
try {
await uploadStarted;
expect(mockDaemonStopAsync).not.toHaveBeenCalled();
if (warm) {
expect(mockPreviewStopAsync).not.toHaveBeenCalled();
}
} finally {
releaseUpload();
await running;
}
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ artifactId: 'late', filename: 'late.png' })
);
expect(inventories).toBe(2);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(warm ? 0 : 1);
}
);
it('stops the daemon when the final artifact drain fails', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const failure = new Error('drain failed');
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockRejectedValue(failure);
await expect(runAsync(logger, BuildRuntimePlatform.LINUX)).rejects.toBe(failure);
expect(pollAgentDeviceArtifactsForUploadAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ stopPollingSignal: expect.any(AbortSignal) })
);
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
});
it.each([false, true])(
'stops the warm host after a failed drain (collector startup fails=%s)',
async startupFails => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const drainFailure = new Error('drain failed');
const startupFailure = new Error('event collector failed');
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockRejectedValueOnce(drainFailure);
if (startupFails) {
jest.mocked(startAgentDeviceEventCollectionAsync).mockRejectedValueOnce(startupFailure);
}
await expect(
runAsync(logger, BuildRuntimePlatform.DARWIN, {
warm: {
agentDeviceStateDir: '/warm/agent',
tools: { port: 4310, token: 'warm-token' },
} as WarmIosSession,
})
).rejects.toBe(startupFails ? startupFailure : drainFailure);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(mockDaemonStopAsync).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
{ err: drainFailure },
'Could not stop the agent-device daemon during remote session teardown.'
);
}
);
it('cancels a warm upload and drains its poller before stopping the host', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const controller = new AbortController();
const cancellation = new Error('job cancelled');
const { pollAgentDeviceArtifactsForUploadAsync: pollActual } = jest.requireActual<
typeof import('../../utils/agentDeviceArtifacts')
>('../../utils/agentDeviceArtifacts');
const pollingFinished = jest.fn();
jest
.mocked(pollAgentDeviceArtifactsForUploadAsync)
.mockImplementation((...args) => pollActual(...args).finally(pollingFinished));
const { Response } = jest.requireActual<typeof import('node-fetch')>('node-fetch');
jest
.mocked(fetch)
.mockImplementation(async url =>
String(url).endsWith('/artifacts')
? new Response(JSON.stringify({ artifacts: [{ id: 'active', filename: 'active.png' }] }))
: new Response(Readable.from(['png']))
);
let markUploadStarted = () => {};
const uploadStarted = new Promise<void>(resolve => {
markUploadStarted = resolve;
});
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { signal }) => {
markUploadStarted();
await new Promise<void>((_resolve, reject) => {
signal?.addEventListener('abort', () => reject(signal.reason), { once: true });
});
});
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementationOnce(async () => {
await uploadStarted;
controller.abort(cancellation);
throw cancellation;
});
await expect(
runAsync(logger, BuildRuntimePlatform.DARWIN, {
signal: controller.signal,
warm: {
agentDeviceStateDir: '/warm/agent',
tools: { port: 4310, token: 'warm-token' },
} as WarmIosSession,
})
).rejects.toBe(cancellation);
expect(pollingFinished).toHaveBeenCalledTimes(1);
expect(pollingFinished.mock.invocationCallOrder[0]).toBeLessThan(
mockPreviewStopAsync.mock.invocationCallOrder[0]
);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(mockDaemonStopAsync).not.toHaveBeenCalled();
});
it('redacts learned daemon credentials in arbitrary output and retained diagnostics', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const token = 'daemon secret/+';
@@ -452,7 +224,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
} as never);
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(previewInputAt);
await runAsync(logger, BuildRuntimePlatform.LINUX, { maxIdleTimeMinutes: 5 });
await runAsync(logger, BuildRuntimePlatform.LINUX, NO_CAPTURE, false, undefined, 5);
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
@@ -463,7 +235,8 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
capture: { networkCapture: true, networkCaptureFields: ['header'] },
networkCapture: true,
networkCaptureFields: ['header'],
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
@@ -474,10 +247,13 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
it('hands a private preview choice to the session host', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
disableShare: true,
privatePreviewUrl: 'https://expo.dev/private-preview',
});
await runAsync(
logger,
BuildRuntimePlatform.DARWIN,
NO_CAPTURE,
true,
'https://expo.dev/private-preview'
);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
@@ -573,8 +349,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
type Device = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['device'];
function startSession(
device: Device | ((tasks: StartupTasks) => Device),
signal?: AbortSignal,
onStarted?: () => Promise<void>
signal?: AbortSignal
) {
const logger = {
info: jest.fn(),
@@ -602,7 +377,6 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
capture: NO_CAPTURE,
tasks,
device: sessionDevice,
onStarted,
});
}
@@ -841,7 +615,10 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
ctx,
{} as never
);
const preview = await host.openPreviewAsync();
const preview = await host.openPreviewAsync({
baseDomain: 'tunnel.example.com',
authtoken: 'ngrok-token',
});
jest.mocked(host.openPreviewAsync).mockImplementation(async () => {
await previewReady.promise;
return preview;
@@ -875,121 +652,6 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
});
it('reports late host cleanup failure after startup Stop', async () => {
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
const failed = new AggregateError([stopped, new Error('host cleanup failed')]);
let entered!: () => void;
const starting = new Promise<void>(resolve => {
entered = resolve;
});
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
entered();
return await new Promise((_resolve, reject) => {
options.signal!.addEventListener('abort', () => setImmediate(() => reject(failed)), {
once: true,
});
});
});
let tasks!: StartupTasks;
const running = startSession(startup => {
tasks = startup;
return { booted: Promise.resolve(), ready: Promise.resolve() };
});
running.catch(() => {});
await starting;
tasks.abort(stopped);
await expect(running).rejects.toBe(failed);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('drains resources and preserves the explicit startup Stop reason', async () => {
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
let tasks!: StartupTasks;
const session = startSession(startup => {
tasks = startup;
return { booted: Promise.resolve(), ready: tasks.untilAborted(new Promise(() => {})) };
});
session.catch(() => {});
await flushAsync();
tasks.abort(stopped);
await expect(session).rejects.toBe(stopped);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
});
it('does not hide a teardown failure after an explicit startup Stop', async () => {
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
const failed = new Error('daemon teardown failed');
let tasks!: StartupTasks;
const session = startSession(startup => {
tasks = startup;
return { booted: Promise.resolve(), ready: tasks.untilAborted(new Promise(() => {})) };
});
session.catch(() => {});
await flushAsync();
mockDaemonStopAsync.mockRejectedValueOnce(failed);
tasks.abort(stopped);
await expect(session).rejects.toBe(failed);
});
it('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);
const session = startSession(
{ booted: Promise.resolve(), ready: Promise.resolve() },
undefined,
retiring
);
await flushAsync();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
expect(retiring).toHaveBeenCalledTimes(1);
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
retired.resolve();
await session;
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledTimes(1);
});
it('aborts a pending ready report when startup is stopped', async () => {
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
let tasks!: StartupTasks;
jest.mocked(uploadRemoteSessionConfigAsync).mockImplementation(
async ({ signal }) =>
new Promise((_resolve, reject) => {
signal!.addEventListener('abort', () => reject(new Error('request aborted')), {
once: true,
});
})
);
const session = startSession(startup => {
tasks = startup;
return { booted: Promise.resolve(), ready: Promise.resolve() };
});
session.catch(() => {});
await flushAsync();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({ signal: tasks.signal })
);
tasks.abort(stopped);
await expect(session).rejects.toBe(stopped);
expect(startAgentDeviceEventCollectionAsync).not.toHaveBeenCalled();
});
it('starts the Android host before application readiness and waits before reporting ready', async () => {
const ready = deferred();
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
@@ -4,18 +4,15 @@ 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 { DeviceRunSessionWorkerApi } from '../../utils/deviceRunSessionWorkerApi';
import { registerAndroidEmulatorLogDirectory } from '../../utils/androidEmulatorLogs';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { stopLocalEgressResourcesAsync } from '../../utils/localEgress';
import {
resolveLocalEgressServeSimBootEnvironmentAsync,
startLocalEgressGuardRelayAsync,
} from '../../utils/localEgressGuard';
import {
selectXcodeDeveloperDirectoryAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
import { downloadBuildAsync } from '../downloadBuild';
import { installBuildAsync } from '../installBuild';
import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact';
@@ -24,7 +21,6 @@ import {
getAgentDeviceRemoteSessionEnvOrThrow,
runAgentDeviceRemoteSessionAsync,
} from '../startAgentDeviceRemoteSession';
import { runOnWarmIosSimulatorAsync } from '../../utils/warmDeviceSessionHost';
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
import { startAndroidEmulatorAsync } from '../startAndroidEmulator';
import { bootIosSimulatorAsync } from '../startIosSimulator';
@@ -42,14 +38,12 @@ jest.mock('../../../utils/IosSimulatorUtils');
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
jest.mock('../startIosSimulator', () => ({
...jest.requireActual('../startIosSimulator'),
bootIosSimulatorAsync: jest.fn(),
}));
jest.mock('../startAndroidEmulator', () => ({ startAndroidEmulatorAsync: jest.fn() }));
jest.mock('../../utils/warmDeviceSessionHost');
jest.mock('../downloadBuild', () => ({ downloadBuildAsync: jest.fn() }));
jest.mock('../installBuild', () => ({ installBuildAsync: jest.fn() }));
jest.mock('../../utils/iosAppArtifact', () => ({
@@ -110,29 +104,41 @@ function runStep(
runtimePlatform: BuildRuntimePlatform,
inputValues: Record<string, unknown> = {},
signal?: AbortSignal,
stepCtx: CustomBuildContext = ctx,
warm = false,
env: NodeJS.ProcessEnv = {}
stepCtx: CustomBuildContext = ctx
): Promise<void> {
const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx, { warm });
const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx);
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const global = createGlobalContextMock({
runtimePlatform,
staticContextContent: { job: { secrets: { robotAccessToken: 'robot-token' } } },
});
const inputs = Object.fromEntries(
(buildFunction.inputProviders ?? []).map(provider => {
const input = provider(global, 'test');
input.set(inputValues[input.id]);
return [input.id, { value: input.rawValue }];
})
[
'device_identifier',
'system_image_package',
'lcd_width',
'lcd_height',
'lcd_density',
'build_id',
'application_archive_url',
'launch_args',
'open_url',
'network_capture',
'network_capture_fields',
'public_preview',
'private_preview_url',
'frame_ancestors',
'cors_origins',
'package_version',
'max_idle_time_minutes',
'max_duration_seconds',
].map(id => [id, { value: inputValues[id] }])
);
return buildFunction.fn!(
{
logger,
global,
global: {
runtimePlatform,
staticContext: { job: { secrets: { robotAccessToken: 'robot-token' } } },
},
} as unknown as BuildStepContext,
{ inputs, outputs: {}, env, signal } as never
{ inputs, outputs: {}, env: {}, signal } as never
) as Promise<void>;
}
@@ -141,31 +147,7 @@ function sessionDevice(): Device {
}
describe(createStartAgentDeviceSessionBuildFunction, () => {
function mockStalledDownload(): { aborted: () => boolean; settled: () => boolean } {
let aborted = false;
let settled = false;
jest.mocked(downloadBuildAsync).mockImplementation(
({ signal }) =>
new Promise((_resolve, reject) => {
signal!.addEventListener('abort', () => {
aborted = true;
// Settles a bit later, like a request that winds down.
setImmediate(() => {
settled = true;
reject(new Error('The user aborted a request.'));
});
});
})
);
return { aborted: () => aborted, settled: () => settled };
}
beforeEach(() => {
jest
.mocked(getAndroidEmulatorLogCapture)
.mockImplementation(
jest.requireActual('../../utils/androidEmulatorLogs').getAndroidEmulatorLogCapture
);
deviceLaunch = undefined;
jest.clearAllMocks();
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(null);
@@ -177,13 +159,6 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ name: 'iPhone 17', udid: 'SELECTED-UDID' }] as never);
jest.mocked(getAgentDeviceRemoteSessionEnvOrThrow).mockReturnValue(sessionEnv);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async ({ signal }) => {
if (!signal?.aborted) {
await new Promise<void>(resolve =>
signal?.addEventListener('abort', () => resolve(), { once: true })
);
}
});
jest
.mocked(runAgentDeviceRemoteSessionAsync)
.mockImplementation(async (_ctx, { device, tasks }) => {
@@ -199,6 +174,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
emulatorPromise: Promise.resolve(),
shouldAdjustAnimationScale: true,
});
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/App.app' });
jest
.mocked(installBuildAsync)
.mockResolvedValue({ applicationIdentifier: 'dev.example.app', activityName: '.Main' });
@@ -206,43 +182,6 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
jest.mocked(readIosApplicationIdentifierAsync).mockResolvedValue('dev.example.app');
});
it.each([false, true])(
'observes Stop during warm claim and preserves cleanup failure=%s',
async failCleanup => {
const stopped = deferred();
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise);
let entered!: () => void;
const claiming = new Promise<void>(resolve => {
entered = resolve;
});
const cleanup = new Error('warm cleanup failed');
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async claim => {
entered();
await new Promise<void>((_resolve, reject) => {
claim.signal!.addEventListener(
'abort',
() =>
reject(
failCleanup
? new AggregateError([claim.signal!.reason, cleanup])
: claim.signal!.reason
),
{ once: true }
);
});
});
const running = runStep(BuildRuntimePlatform.DARWIN, {}, undefined, ctx, true);
running.catch(() => {});
await claiming;
stopped.resolve();
if (failCleanup) {
await expect(running).rejects.toMatchObject({ errors: expect.arrayContaining([cleanup]) });
} else {
await expect(running).resolves.toBeUndefined();
}
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
}
);
it('hands guarded iOS boot to serve-sim and starts reporting before host startup', async () => {
const bootEnv = {
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
@@ -251,7 +190,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
};
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(bootEnv);
await runStep(BuildRuntimePlatform.DARWIN, {
install_app_path: '/tmp/App.app',
build_id: 'build-id',
launch_args: ['-flag'],
open_url: 'exp://example.test',
});
@@ -265,7 +204,14 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
jest.mocked(runAgentDeviceRemoteSessionAsync).mock.invocationCallOrder[0]
);
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({
buildId: 'build-id',
extensions: ['app'],
graphqlClient,
robotAccessToken: 'robot-token',
})
);
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(1);
const device = sessionDevice();
expect(device).toEqual({
@@ -309,48 +255,63 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
);
});
it('fails before app installation or host startup when guarded device shutdown fails', async () => {
it('downloads build_id through the device run session worker API when the job has a session token', async () => {
const sessionCtx = {
graphqlClient,
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
} as unknown as CustomBuildContext;
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' }, undefined, sessionCtx);
expect(downloadBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({
buildId: 'build-id',
deviceRunSessionWorkerApi: expect.any(DeviceRunSessionWorkerApi),
})
);
});
it('does not use the worker API without a session token or for an archive URL', async () => {
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' });
expect(downloadBuildAsync).toHaveBeenLastCalledWith(
expect.objectContaining({ buildId: 'build-id', deviceRunSessionWorkerApi: null })
);
const sessionCtx = {
graphqlClient,
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
} as unknown as CustomBuildContext;
await runStep(
BuildRuntimePlatform.LINUX,
{ application_archive_url: 'https://example.test/app.tar.gz' },
undefined,
sessionCtx
);
expect(downloadBuildAsync).toHaveBeenLastCalledWith(
expect.objectContaining({
applicationArchiveUrl: 'https://example.test/app.tar.gz',
deviceRunSessionWorkerApi: null,
})
);
});
it('fails before download or host startup when guarded device shutdown fails', async () => {
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
});
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Booted' } as never);
jest.mocked(spawn).mockRejectedValue(new Error('shutdown failed'));
await expect(
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app' })
).rejects.toThrow('shutdown failed');
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
});
it('treats Stop during guarded device shutdown as normal cancellation', async () => {
const stopped = deferred();
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise);
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
});
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Booted' } as never);
jest.mocked(spawn).mockImplementation(
(_command, _args, options) =>
new Promise((_resolve, reject) => {
options!.signal!.addEventListener('abort', () => reject(new Error('shutdown aborted')), {
once: true,
});
}) as ReturnType<typeof spawn>
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'shutdown failed'
);
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' });
step.catch(() => {});
await flushAsync();
expect(spawn).toHaveBeenCalled();
stopped.resolve();
await expect(step).resolves.toBeUndefined();
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
expect(startLocalEgressGuardRelayAsync).not.toHaveBeenCalled();
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
});
it('provides Simulator preparation and local app startup options to the remote session', async () => {
it('provides Simulator preparation and downloaded startup options to the remote session', async () => {
const hostReady = deferred();
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
@@ -365,7 +326,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
});
const step = runStep(BuildRuntimePlatform.DARWIN, {
device_identifier: 'iPhone 17',
install_app_path: '/tmp/App.app',
build_id: 'build-id',
launch_args: ['-flag'],
open_url: 'exp://example.test',
});
@@ -375,7 +336,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
env: {},
});
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).toHaveBeenCalledTimes(1);
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
artifactPath: '/tmp/App.app',
env: {},
@@ -393,15 +354,15 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
});
});
it('fails a guarded session before app installation or host startup if its boot files are missing', async () => {
it('fails a guarded session before download or host startup if its boot files are missing', async () => {
jest
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
.mockRejectedValue(new Error('missing bin/egress-guard-check'));
await expect(
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app' })
).rejects.toThrow('missing bin/egress-guard-check');
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'missing bin/egress-guard-check'
);
expect(startLocalEgressGuardRelayAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
expect(spawn).not.toHaveBeenCalled();
@@ -416,21 +377,38 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
jest
.mocked(runAgentDeviceRemoteSessionAsync)
.mockRejectedValueOnce(new Error('guard check failed'));
await expect(
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app' })
).rejects.toThrow('guard check failed');
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'guard check failed'
);
expect(startLocalEgressGuardRelayAsync).toHaveBeenCalledTimes(1);
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
});
it('waits for the download before handing the app to the host', async () => {
const download = deferred<{ artifactPath: string }>();
jest.mocked(downloadBuildAsync).mockReturnValue(download.promise);
const step = runStep(BuildRuntimePlatform.DARWIN, {
application_archive_url: 'https://example.test/app.tar.gz',
});
await flushAsync();
expect(installBuildAsync).not.toHaveBeenCalled();
download.resolve({ artifactPath: '/tmp/App.app' });
await step;
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
expect(installBuildAsync).not.toHaveBeenCalled();
});
it('passes the selected Simulator to the host even when there is no app', async () => {
await runStep(BuildRuntimePlatform.DARWIN);
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
expect(getAndroidEmulatorLogCapture).not.toHaveBeenCalled();
expect(registerAndroidEmulatorLogDirectory).not.toHaveBeenCalled();
expect(sessionDevice()).toEqual({
iosSimulatorUdid: 'SELECTED-UDID',
bootEnv: undefined,
@@ -438,61 +416,6 @@ 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',
@@ -500,10 +423,14 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
lcd_width: 720,
lcd_height: 1600,
lcd_density: 300,
install_app_path: '/tmp/App.app',
build_id: 'build-id',
});
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(registerAndroidEmulatorLogDirectory).toHaveBeenCalledWith(
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX }),
jest.mocked(startAndroidEmulatorAsync).mock.calls[0][0].logcatDirectory
);
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({
deviceIdentifier: 'pixel_7',
@@ -513,26 +440,102 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
lcdDensity: 300,
})
);
expect(downloadBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({ extensions: ['apk'] })
);
expect(launchApplicationAsync).toHaveBeenCalledWith(
expect.objectContaining({ applicationIdentifier: 'dev.example.app', activityName: '.Main' })
);
});
it('fails the session when the download fails, without an unhandled rejection', async () => {
jest.mocked(downloadBuildAsync).mockRejectedValue(new Error('download failed'));
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'download failed'
);
expect(installBuildAsync).not.toHaveBeenCalled();
});
/** A download that runs until its abort signal fires, like a stalled one. */
function mockStalledDownload(): { aborted: () => boolean; settled: () => boolean } {
let aborted = false;
let settled = false;
jest.mocked(downloadBuildAsync).mockImplementation(
({ signal }) =>
new Promise((_resolve, reject) => {
signal!.addEventListener('abort', () => {
aborted = true;
// Settles a bit later, like a request that winds down.
setImmediate(() => {
settled = true;
reject(new Error('The user aborted a request.'));
});
});
})
);
return { aborted: () => aborted, settled: () => settled };
}
/** Runs the session like the real one: fails with the daemon, then waits for every part. */
function mockSessionWithFailingDaemon(beforeFailure?: () => Promise<void>): void {
jest
.mocked(runAgentDeviceRemoteSessionAsync)
.mockImplementation(async (_ctx, { tasks, device }) => {
const ready = runDevicePreparationAsync(device);
await beforeFailure?.();
const daemon = tasks.run('agent-device daemon', async () => {
throw new Error('daemon failed');
});
try {
await Promise.all([daemon, ready]);
} finally {
await Promise.allSettled([daemon, ready]);
}
});
}
it('stops the download and waits for it when Android boot fails', async () => {
const download = mockStalledDownload();
jest.mocked(startAndroidEmulatorAsync).mockRejectedValue(new Error('boot failed'));
await expect(
runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' }, undefined, ctx, false, {
DEVICE_RUN_SESSION_ID: 'session',
})
).rejects.toThrow('boot failed');
await expect(runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' })).rejects.toThrow(
'boot failed'
);
expect(download.aborted()).toBe(true);
// The step returned only after the download stopped.
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
expect(registerAndroidEmulatorLogDirectory).toHaveBeenCalledTimes(1);
expect(
jest.mocked(registerAndroidEmulatorLogDirectory).mock.invocationCallOrder[0]
).toBeLessThan(jest.mocked(startAndroidEmulatorAsync).mock.invocationCallOrder[0]);
});
it('stops a stalled download and does not install when the daemon fails', async () => {
const download = mockStalledDownload();
mockSessionWithFailingDaemon();
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'daemon failed'
);
expect(download.aborted()).toBe(true);
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
});
it('drains the download when host startup fails before app preparation', async () => {
const download = mockStalledDownload();
jest.mocked(runAgentDeviceRemoteSessionAsync).mockRejectedValue(new Error('host failed'));
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'host failed'
);
expect(download.aborted()).toBe(true);
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
});
it('does not start work for an already cancelled step', async () => {
@@ -540,102 +543,9 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
controller.abort(new Error('cancelled'));
await expect(
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app' }, controller.signal)
runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal)
).rejects.toThrow('cancelled');
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'observes Stop during a held download on %s and drains startup normally',
async runtimePlatform => {
const download = mockStalledDownload();
const stopped = deferred();
const controller = new AbortController();
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise);
const step = runStep(runtimePlatform, { build_id: 'build-id' }, controller.signal);
step.catch(() => {});
try {
await flushAsync();
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
expect.objectContaining({
deviceRunSessionId: sessionEnv.deviceRunSessionId,
signal: expect.any(AbortSignal),
})
);
stopped.resolve();
await expect(step).resolves.toBeUndefined();
expect(download.aborted()).toBe(true);
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
expect(readIosApplicationIdentifierAsync).not.toHaveBeenCalled();
} finally {
controller.abort(new Error('test cleanup'));
stopped.resolve();
await step.catch(() => {});
}
}
);
it('waits for an in-flight Android boot after Stop without installing or launching', async () => {
const boot = deferred<Awaited<ReturnType<typeof startAndroidEmulatorAsync>>>();
const stopped = deferred();
jest.mocked(startAndroidEmulatorAsync).mockReturnValue(boot.promise);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise);
let settled = false;
const step = runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' });
void step
.finally(() => {
settled = true;
})
.catch(() => {});
try {
await flushAsync();
expect(startAndroidEmulatorAsync).toHaveBeenCalled();
stopped.resolve();
await flushAsync();
expect(settled).toBe(false);
} finally {
boot.resolve({} as Awaited<ReturnType<typeof startAndroidEmulatorAsync>>);
stopped.resolve();
await step;
}
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
});
it('retires the startup watch without treating its abort as user Stop', async () => {
jest
.mocked(runAgentDeviceRemoteSessionAsync)
.mockImplementation(async (_ctx, { onStarted, tasks }) => {
await onStarted!();
expect(tasks.signal.aborted).toBe(false);
});
await expect(runStep(BuildRuntimePlatform.DARWIN)).resolves.toBeUndefined();
const options = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
expect(options.maxDurationSeconds).toBeUndefined();
expect(options.idleTimeout).toBeUndefined();
expect(options.signal!.aborted).toBe(true);
});
it('preserves a download failure that wins before Stop', async () => {
const failed = new Error('download failed first');
const stopped = deferred();
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise);
jest.mocked(downloadBuildAsync).mockRejectedValue(failed);
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' });
step.catch(() => {});
await flushAsync();
stopped.resolve();
await expect(step).rejects.toBe(failed);
});
it('fails when the startup status watch rejects', async () => {
const failed = new Error('session errored');
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValue(failed);
await expect(runStep(BuildRuntimePlatform.DARWIN)).rejects.toBe(failed);
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
@@ -654,15 +564,11 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
expect(installBuildAsync).not.toHaveBeenCalled();
});
it('does not start device or app work after cancellation during Xcode selection', async () => {
it('does not start device or download work after cancellation during Xcode selection', async () => {
const controller = new AbortController();
const selected = deferred();
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockReturnValue(selected.promise);
const step = runStep(
BuildRuntimePlatform.DARWIN,
{ install_app_path: '/tmp/App.app' },
controller.signal
);
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal);
const failure = expect(step).rejects.toThrow('cancelled');
await flushAsync();
controller.abort(new Error('cancelled'));
@@ -671,7 +577,42 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
await failure;
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('drains app preparation when the daemon fails', async () => {
const metadata = deferred<string>();
const metadataStarted = deferred();
jest.mocked(readIosApplicationIdentifierAsync).mockImplementation(() => {
metadataStarted.resolve();
return metadata.promise;
});
mockSessionWithFailingDaemon(async () => {
await metadataStarted.promise;
setImmediate(() => metadata.resolve('dev.example.app'));
});
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'daemon failed'
);
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
expect(deviceLaunch).toBeUndefined();
});
it('rejects conflicting application inputs before it boots anything', async () => {
await expect(
runStep(BuildRuntimePlatform.DARWIN, {
build_id: 'build-id',
application_archive_url: 'https://example.test/app.tar.gz',
})
).rejects.toThrow('Pass only one of build_id or application_archive_url.');
await expect(
runStep(BuildRuntimePlatform.DARWIN, { open_url: 'exp://example.test' })
).rejects.toThrow('launch_args and open_url need an application');
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
@@ -682,16 +623,13 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
expect(
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual([
'log_sources',
'device_identifier',
'system_image_package',
'lcd_width',
'lcd_height',
'lcd_density',
'install_app_path',
'build_id',
'application_archive_url',
'launch_app_identifier',
'launch_args',
'open_url',
'network_capture',
@@ -751,15 +689,12 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
['NUL URL', { open_url: 'example:\u0000screen' }],
['URL length', { open_url: `example://${'a'.repeat(8192)}` }],
])('rejects iOS launch %s before startup work', async (_name, inputs) => {
const step = runStep(BuildRuntimePlatform.DARWIN, {
install_app_path: '/tmp/App.app',
...inputs,
});
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id', ...inputs });
await expect(step).rejects.toBeInstanceOf(UserError);
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
@@ -768,22 +703,16 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
['command length', { launch_args: ['--es', 'quotes', "'".repeat(20_000)] }],
['NUL URL', { open_url: 'example:\u0000screen' }],
])('rejects Android launch %s before startup work', async (_name, inputs) => {
const step = runStep(BuildRuntimePlatform.LINUX, {
install_app_path: '/tmp/App.app',
...inputs,
});
const step = runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', ...inputs });
await expect(step).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('keeps Android launch inputs on its existing direct path', async () => {
const launchArgs = ['a'.repeat(9000)];
await runStep(BuildRuntimePlatform.LINUX, {
install_app_path: '/tmp/App.app',
launch_args: launchArgs,
});
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', launch_args: launchArgs });
expect(launchApplicationAsync).toHaveBeenCalledWith(
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, launchArgs })
);
@@ -810,181 +739,4 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('requires an application for launch options before starting the device', async () => {
await expect(
runStep(BuildRuntimePlatform.DARWIN, { open_url: 'fixture://route' })
).rejects.toThrow('pass install_app_path or launch_app_identifier');
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('launches an installed iOS application without reading an app artifact', async () => {
await runStep(BuildRuntimePlatform.DARWIN, {
launch_app_identifier: 'dev.installed.app',
launch_args: ['--test'],
open_url: 'fixture://route',
});
expect(readIosApplicationIdentifierAsync).not.toHaveBeenCalled();
expect(deviceLaunch).toEqual({
installAppPath: undefined,
launchAppIdentifier: 'dev.installed.app',
launchArgs: ['--test'],
openUrl: 'fixture://route',
});
});
it('keeps explicit application identifier launch iOS-only', async () => {
await expect(
runStep(BuildRuntimePlatform.LINUX, { launch_app_identifier: 'dev.app' })
).rejects.toThrow('Pass install_app_path for Android');
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
});
it('still downloads a build_id app and launches it on iOS', async () => {
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/Downloaded.app' });
await runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id', launch_args: ['-flag'] });
expect(downloadBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({ buildId: 'build-id', extensions: ['app'] })
);
expect(deviceLaunch).toEqual({
installAppPath: '/tmp/Downloaded.app',
launchAppIdentifier: 'dev.example.app',
launchArgs: ['-flag'],
openUrl: undefined,
});
});
it('still downloads an application_archive_url app and installs it on Android', async () => {
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/Downloaded.apk' });
await runStep(BuildRuntimePlatform.LINUX, {
application_archive_url: 'https://example.test/app.tar.gz',
});
expect(downloadBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({
applicationArchiveUrl: 'https://example.test/app.tar.gz',
extensions: ['apk'],
})
);
expect(installBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({ artifactPath: '/tmp/Downloaded.apk' })
);
expect(launchApplicationAsync).toHaveBeenCalledTimes(1);
});
it('runs the warm step on the warm Simulator for a local app only', async () => {
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation((_claim, run) => run());
const runWarm = (inputs: Record<string, unknown>) =>
runStep(BuildRuntimePlatform.DARWIN, inputs, undefined, ctx, true);
await runWarm({ install_app_path: '/tmp/App.app', device_identifier: 'iPhone 17' });
expect(runOnWarmIosSimulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({ engine: 'agent-device', requestedDevice: 'iPhone 17' }),
expect.any(Function)
);
jest.mocked(runOnWarmIosSimulatorAsync).mockClear();
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/Downloaded.app' });
await runWarm({ build_id: 'build-id' });
expect(runOnWarmIosSimulatorAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(2);
});
it('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' })
).rejects.toThrow('Pass only one of install_app_path, build_id or application_archive_url.');
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('waits for Android boot before installing the supplied local APK', async () => {
const boot = deferred();
jest.mocked(startAndroidEmulatorAsync).mockReturnValue(boot.promise as never);
const step = runStep(BuildRuntimePlatform.LINUX, { install_app_path: '/tmp/App.apk' });
await flushAsync();
expect(installBuildAsync).not.toHaveBeenCalled();
boot.resolve();
await step;
expect(installBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({ artifactPath: '/tmp/App.apk' })
);
expect(launchApplicationAsync).toHaveBeenCalledTimes(1);
});
it('drains an in-flight Android install on cancellation and never launches it', async () => {
const controller = new AbortController();
const installation = deferred<{ applicationIdentifier: string; activityName: string }>();
jest.mocked(installBuildAsync).mockReturnValue(installation.promise);
const step = runStep(
BuildRuntimePlatform.LINUX,
{ install_app_path: '/tmp/App.apk' },
controller.signal
);
let settled = false;
void step.then(
() => (settled = true),
() => (settled = true)
);
const failure = expect(step).rejects.toThrow('cancelled');
await flushAsync();
expect(installBuildAsync).toHaveBeenCalledTimes(1);
controller.abort(new Error('cancelled'));
await flushAsync();
expect(settled).toBe(false);
installation.resolve({ applicationIdentifier: 'dev.app', activityName: '.Main' });
await failure;
expect(launchApplicationAsync).not.toHaveBeenCalled();
});
it('drains an in-flight Android install when session startup fails', async () => {
const entered = deferred();
const installation = deferred<{ applicationIdentifier: string; activityName: string }>();
jest.mocked(installBuildAsync).mockImplementation(() => {
entered.resolve();
return installation.promise;
});
jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async () => {
await entered.promise;
throw new Error('session startup failed');
});
const step = runStep(BuildRuntimePlatform.LINUX, { install_app_path: '/tmp/App.apk' });
let settled = false;
void step.then(
() => (settled = true),
() => (settled = true)
);
const failure = expect(step).rejects.toThrow('session startup failed');
await entered.promise;
await flushAsync();
expect(settled).toBe(false);
installation.resolve({ applicationIdentifier: 'dev.app', activityName: '.Main' });
await failure;
expect(launchApplicationAsync).not.toHaveBeenCalled();
});
it('drains iOS metadata reading on cancellation without starting the session', async () => {
const controller = new AbortController();
const metadata = deferred<string>();
jest.mocked(readIosApplicationIdentifierAsync).mockReturnValue(metadata.promise);
const step = runStep(
BuildRuntimePlatform.DARWIN,
{ install_app_path: '/tmp/App.app' },
controller.signal
);
const failure = expect(step).rejects.toThrow('cancelled');
await flushAsync();
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
controller.abort(new Error('cancelled'));
metadata.resolve('dev.app');
await failure;
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
});
@@ -9,9 +9,7 @@ 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 { registerAndroidEmulatorLogDirectory } from '../../utils/androidEmulatorLogs';
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
jest.mock('@expo/turtle-spawn', () => ({
@@ -29,11 +27,6 @@ jest.mock('../../utils/androidLocalEgress', () => ({
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',
@@ -44,7 +37,6 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
waitForReadyAsync: jest.fn(),
disableWindowAndTransitionAnimationsAsync: jest.fn(),
deleteAsync: jest.fn(),
findFreeConsolePortAsync: jest.fn(),
},
}));
@@ -61,12 +53,7 @@ function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.
const step = fn.createBuildStepFromFunctionCall(globalCtx, {
callInputs,
});
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;
return Object.assign(step, { logger });
}
function createStartResult(serialId: string) {
@@ -78,11 +65,6 @@ 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([]);
@@ -111,30 +93,6 @@ 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 {
@@ -156,7 +114,6 @@ 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),
@@ -394,6 +351,13 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledTimes(3);
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledTimes(3);
expect(registerAndroidEmulatorLogDirectory).toHaveBeenCalledWith(
expect.anything(),
'/tmp/logcat-directory'
);
expect(
jest.mocked(registerAndroidEmulatorLogDirectory).mock.invocationCallOrder[0]
).toBeLessThan(mockedAndroidUtils.startAsync.mock.invocationCallOrder[0]);
});
it('outputs and uses a temporary logcat directory', async () => {
@@ -406,6 +370,10 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
})
);
expect(step.getOutputValueByName('logcat_directory')).toBe('/tmp/logcat-directory');
expect(registerAndroidEmulatorLogDirectory).toHaveBeenCalledWith(
step.ctx.global,
'/tmp/logcat-directory'
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-default',
@@ -482,170 +450,4 @@ 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();
});
});
});
File diff suppressed because it is too large. Load diff
@@ -24,13 +24,6 @@ import {
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import {
createWarmDeviceSessionServer,
runOnWarmIosSimulatorAsync,
} from '../../utils/warmDeviceSessionHost';
import { type WarmIosSession } from '../../utils/warmIosSession';
import { watchDeviceRunSessionStartup } from '../../utils/deviceRunSessionStartup';
import { createStartupWatchMock } from '../../../__tests__/utils/startupWatch';
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact';
@@ -57,7 +50,6 @@ jest.mock('../../utils/argentEvents', () => ({
startArgentEventCollectionAsync: jest.fn(),
}));
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/warmDeviceSessionHost');
jest.mock('../../utils/iosAppArtifact', () => ({
...jest.requireActual('../../utils/iosAppArtifact'),
readIosApplicationIdentifierAsync: jest.fn().mockResolvedValue('dev.example.app'),
@@ -88,9 +80,6 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
jest.mock('../../utils/deviceRunSessionStartup', () => ({
watchDeviceRunSessionStartup: jest.fn(),
}));
const TEST_HOME = path.join(os.tmpdir(), 'eas-argent-orchestration-home');
const ARGENT_STATE_DIR = path.join(TEST_HOME, '.argent');
@@ -141,7 +130,6 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock);
// The (real) tool-server state wait reads this file from the redirected ~/.argent.
await fs.promises.mkdir(ARGENT_STATE_DIR, { recursive: true });
@@ -155,386 +143,6 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
await fs.promises.rm(TEST_HOME, { recursive: true, force: true });
});
it.each([
[BuildRuntimePlatform.DARWIN, false],
[BuildRuntimePlatform.LINUX, false],
[BuildRuntimePlatform.DARWIN, true],
])(
'handles Stop during %s setup, keeping teardown failure=%s visible',
async (runtimePlatform, teardownFails) => {
let rejectSetup!: (error: Error) => void;
const setup = Object.assign(
new Promise<never>((_resolve, reject) => {
rejectSetup = reject;
}),
{ child: { pid: 12345 } }
);
jest.mocked(spawn).mockReturnValueOnce(setup as unknown as ReturnType<typeof spawn>);
jest
.mocked(killProcessGroup)
.mockImplementationOnce(() => rejectSetup(new Error('setup killed')));
const teardownError = new Error('host teardown failed');
if (teardownFails) {
mockPreviewStopAsync.mockRejectedValueOnce(teardownError);
}
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
const running = Promise.resolve(
buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
boot_simulator: {
value: runtimePlatform === BuildRuntimePlatform.DARWIN ? true : undefined,
},
},
outputs: {},
env: {},
} as never
)
);
running.catch(() => {});
try {
for (let i = 0; i < 20 && !jest.mocked(spawn).mock.calls.length; i++) {
await new Promise(resolve => setImmediate(resolve));
}
expect(spawn).toHaveBeenCalledTimes(1);
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
watch.stop();
if (teardownFails) {
await expect(running).rejects.toBe(teardownError);
} else {
await expect(running).resolves.toBeUndefined();
}
expect(killProcessGroup).toHaveBeenCalledTimes(1);
expect(spawnDetached).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
expect(watch.finishAsync).toHaveBeenCalled();
} finally {
rejectSetup(new Error('test cleanup'));
await running.catch(() => {});
}
}
);
it('does not allocate Argent or a host after parent cancellation during Xcode selection', async () => {
const logger = { info: jest.fn(), warn: jest.fn() };
const controller = new AbortController();
const failure = new Error('parent step cancelled');
jest
.mocked(selectXcodeDeveloperDirectoryAsync)
.mockImplementationOnce(async () => controller.abort(failure));
await expect(
createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
signal: controller.signal,
} as never
)
).rejects.toBe(failure);
expect(spawnDetached).not.toHaveBeenCalled();
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
});
it('preserves an aggregate host startup failure', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const failure = new AggregateError([
new Error('host failed'),
new Error('host cleanup failed'),
]);
jest.mocked(startDeviceSessionHostAsync).mockRejectedValueOnce(failure);
await expect(
createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
boot_simulator: { value: true },
},
outputs: {},
env: {},
} as never
)
).rejects.toBe(failure);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('reports late host cleanup failure after startup Stop', async () => {
const ctx = {} as CustomBuildContext;
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
let entered!: () => void;
const starting = new Promise<void>(resolve => {
entered = resolve;
});
let failed!: AggregateError;
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
entered();
return await new Promise((_resolve, reject) => {
options.signal!.addEventListener(
'abort',
() => {
failed = new AggregateError([options.signal!.reason, new Error('host cleanup failed')]);
setImmediate(() => reject(failed));
},
{ once: true }
);
});
});
const running = createStartArgentRemoteSessionBuildFunction(ctx).fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
{
inputs: {
boot_simulator: { value: true },
device_identifier: { value: 'selected-ios-udid' },
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
) as Promise<void>;
running.catch(() => {});
await starting;
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
const failure = await running.then(
() => undefined,
error => error
);
expect(failure).toBe(failed);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('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() };
const adoption = new Error('warm adoption failed');
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async (_claim, run) => {
try {
return await run({
device: { udid: 'selected-ios-udid', name: 'iPhone 17' },
} as WarmIosSession);
} catch (error) {
if (error !== adoption) {
throw error;
}
return await run(undefined);
}
});
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
options.onStartupError?.(adoption);
throw adoption;
});
await expect(
Promise.resolve(
createStartArgentRemoteSessionBuildFunction(ctx, { warm: true }).fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
{
inputs: {
boot_simulator: { value: true },
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
)
)
).resolves.toBeUndefined();
expect(watchDeviceRunSessionStartup).toHaveBeenCalledTimes(1);
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(2);
});
it.each([false, true])(
'observes Stop during warm claim and preserves cleanup failure=%s',
async failCleanup => {
const ctx = {} as CustomBuildContext;
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
let entered!: () => void;
const claiming = new Promise<void>(resolve => {
entered = resolve;
});
const cleanup = new Error('warm cleanup failed');
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async claim => {
entered();
await new Promise<void>((_resolve, reject) => {
claim.signal!.addEventListener(
'abort',
() =>
reject(
failCleanup
? new AggregateError([claim.signal!.reason, cleanup])
: claim.signal!.reason
),
{ once: true }
);
});
});
const running = createStartArgentRemoteSessionBuildFunction(ctx, { warm: true }).fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
) as Promise<void>;
running.catch(() => {});
await claiming;
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
if (failCleanup) {
await expect(running).rejects.toMatchObject({ errors: expect.arrayContaining([cleanup]) });
} else {
await expect(running).resolves.toBeUndefined();
}
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
}
);
it('does not open the preview after Stop while the tools tunnel is opening', async () => {
let tunnelEntered!: () => void;
const entered = new Promise<void>(resolve => {
tunnelEntered = resolve;
});
let finishTunnel!: () => void;
const pendingTunnel = new Promise<void>(resolve => {
finishTunnel = resolve;
});
jest.mocked(startNgrokTunnelAsync).mockImplementationOnce(async () => {
tunnelEntered();
await pendingTunnel;
return {
url: 'https://argent.example.com',
subdomainId: 'argent',
stopAsync: mockTunnelStopAsync,
};
});
const openPreviewAsync = jest.fn();
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync,
finishAsync: mockPreviewStopAsync,
getLastPreviewActivityAt: jest.fn(),
});
const running = Promise.resolve(
createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
boot_simulator: { value: true },
},
outputs: {},
env: {},
} as never
)
);
running.catch(() => {});
try {
await entered;
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
} finally {
finishTunnel();
}
await expect(running).resolves.toBeUndefined();
expect(openPreviewAsync).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'preserves an earlier prepared %s host failure when Stop arrives during cleanup',
async runtimePlatform => {
const failure = new Error('Host became ready without a session token');
let cleanupEntered!: () => void;
const entered = new Promise<void>(resolve => {
cleanupEntered = resolve;
});
let finishCleanup!: () => void;
const cleanup = new Promise<void>(resolve => {
finishCleanup = resolve;
});
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
options.onStartupError?.(failure);
cleanupEntered();
await cleanup;
throw failure;
});
const running = Promise.resolve(
createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
)
);
running.catch(() => {});
try {
await entered;
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
} finally {
finishCleanup();
}
await expect(running).rejects.toBe(failure);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
}
);
it('reports an early exit with output and stops Argent before opening tunnels', async () => {
const stopError = new Error('drain timed out');
const stopServer = jest.fn().mockRejectedValue(stopError);
@@ -667,7 +275,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
['succeeded', false],
['failed', true],
])(
'stops the host and tools when the tools tunnel cannot close and the session %s',
'fails after stopping 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');
@@ -677,26 +285,23 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
}
const logger = { info: jest.fn(), warn: jest.fn() };
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
// 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();
}
// 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);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockStopAsync).toHaveBeenCalledTimes(1);
@@ -705,13 +310,17 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
{ err: closeError },
'Could not stop the Argent tunnel during remote session teardown.'
);
expect(jest.mocked(Sentry.capture).mock.calls).toEqual([
[
`Could not stop the Argent tunnel after the remote session ${sessionFails ? 'failed' : 'ended'}`,
closeError,
{ level: 'warning' },
],
]);
expect(jest.mocked(Sentry.capture).mock.calls).toEqual(
sessionFails
? [
[
'Could not stop the Argent tunnel after the remote session failed',
closeError,
{ level: 'warning' },
],
]
: []
);
}
);
@@ -996,72 +605,6 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
expect(JSON.stringify(logger.info.mock.calls)).not.toContain('secret');
});
it('uses the warm Argent server and drains its artifacts before stopping the host', async () => {
const ctx = {} as unknown as CustomBuildContext;
const warmServer = { port: 3200 } as never;
const warm = {
device: { udid: 'WARM-UDID', name: 'iPhone 17' },
argentEventLogPath: '/warm/argent-events',
tools: { pid: 30, port: 3201, token: 'warm-token' },
} as WarmIosSession;
jest.mocked(createWarmDeviceSessionServer).mockReturnValue(warmServer);
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation((_claim, run) => run(warm));
let releaseArtifacts = () => {};
const artifactsDrained = new Promise<void>(resolve => {
releaseArtifacts = resolve;
});
let pollingStarted = () => {};
const polling = new Promise<void>(resolve => {
pollingStarted = resolve;
});
jest.mocked(pollArgentArtifactsForUploadAsync).mockImplementationOnce(async () => {
pollingStarted();
await artifactsDrained;
});
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx, { warm: true });
expect(buildFunction.id).toBe('start_warm_argent_remote_session');
if (!buildFunction.fn) {
throw new Error('Expected the Argent session function.');
}
const running = buildFunction.fn(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
max_duration_seconds: { value: undefined },
},
outputs: {},
env: {},
} as never
);
try {
await polling;
await new Promise<void>(resolve => setImmediate(resolve));
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalled();
expect(mockPreviewStopAsync).not.toHaveBeenCalled();
} finally {
releaseArtifacts();
await running;
}
expect(spawn).not.toHaveBeenCalled();
expect(spawnDetached).not.toHaveBeenCalled();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(ctx, expect.anything(), warmServer);
expect(startNgrokTunnelAsync).toHaveBeenCalledWith(expect.objectContaining({ port: 3201 }));
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({ toolsAuthToken: 'warm-token' }),
})
);
expect(startArgentEventCollectionAsync).toHaveBeenCalledWith(
expect.objectContaining({ eventLogPath: '/warm/argent-events' })
);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
const ctx = {} as unknown as CustomBuildContext;
const logger = { info: jest.fn(), warn: jest.fn() };
@@ -1349,7 +892,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
});
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'makes %s prepared setup commands cancellable',
'keeps %s prepared setup commands outside the new cancellation path',
async runtimePlatform => {
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
await buildFunction.fn!(
@@ -1368,7 +911,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
);
expect(spawn).toHaveBeenCalledTimes(3);
for (const [, , options] of jest.mocked(spawn).mock.calls) {
expect(options).toHaveProperty('detached', true);
expect(options).not.toHaveProperty('detached');
expect(options).not.toHaveProperty('signal');
}
expect(killProcessGroup).not.toHaveBeenCalled();
@@ -1,3 +1,4 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import fs from 'node:fs';
import os from 'node:os';
@@ -5,12 +6,79 @@ import path from 'node:path';
import { Sentry } from '../../../sentry';
import {
MIN_ARGENT_REMOTE_SESSION_VERSION,
stopArgentEventCollectionSafelyAsync,
waitForArgentToolServerStateAsync,
warnIfArgentPackageVersionCannotBeVerified,
} from '../startArgentRemoteSession';
jest.mock('../../../sentry');
describe(warnIfArgentPackageVersionCannotBeVerified, () => {
const warn = jest.fn();
const logger = { warn } as unknown as bunyan;
const supportedVersion = '999.0.0';
const oldVersion = '0.0.0';
beforeEach(() => {
warn.mockClear();
});
it('allows the default latest package version', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: undefined, logger })
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: 'latest', logger })
).not.toThrow();
expect(warn).not.toHaveBeenCalled();
});
it('allows exact supported semver versions', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({
packageVersion: MIN_ARGENT_REMOTE_SESSION_VERSION,
logger,
})
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: supportedVersion, logger })
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({
packageVersion: `v${supportedVersion}`,
logger,
})
).not.toThrow();
expect(warn).not.toHaveBeenCalled();
});
it('rejects exact semver versions that are too old', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: oldVersion, logger })
).toThrow(SystemError);
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: oldVersion, logger })
).toThrow(`Use "latest" or pass an exact version >= ${MIN_ARGENT_REMOTE_SESSION_VERSION}`);
});
it('warns for package tags or ranges that cannot be verified', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: 'next', logger })
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({
packageVersion: `^${MIN_ARGENT_REMOTE_SESSION_VERSION}`,
logger,
})
).not.toThrow();
expect(warn).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Continuing and letting the package manager resolve it.')
);
});
});
describe(waitForArgentToolServerStateAsync, () => {
let stateDir: string;
@@ -1,51 +1,11 @@
import {
ArchiveSourceType,
EnvironmentSecretType,
SystemError,
UserError,
} from '@expo/eas-build-job';
import fetch from 'node-fetch';
import { SystemError, UserError } from '@expo/eas-build-job';
import { Client, fetchExchange } from '@urql/core';
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: [
{
name: 'EAS_SANDBOX_WORKER_TOKEN',
type: EnvironmentSecretType.STRING,
value: 'worker-token',
},
],
},
},
env: {},
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
mcpServerUrl: 'ws://localhost:8787',
};
const inputs = { sandbox_id: { value: 'sandbox-id' } };
import { createStartSandboxBuildFunction, markSandboxReadyAsync } from '../startSandbox';
describe('sandbox build functions', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it.each([
{ completion: 'duration', duration: 0.02 },
{ completion: 'cancellation', duration: 60 },
@@ -56,41 +16,49 @@ describe('sandbox build functions', () => {
const controller = new AbortController();
let ready!: () => void;
const stopAsync = jest.fn(async () => {});
jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockResolvedValue({
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockResolvedValue({
ready: new Promise<void>(resolve => {
ready = resolve;
}),
stopAsync,
});
const markReady = jest
.spyOn(SandboxWorkerApi.prototype, 'markReadyAsync')
.mockResolvedValue(undefined);
const mutation = jest.fn(() => ({ toPromise: async () => ({ data: {} }) }));
const logger = { info: jest.fn(), child: jest.fn() };
logger.child.mockReturnValue(logger);
const fn = createStartSandboxBuildFunction(context as any);
const pending = fn.fn!({ logger } as any, {
inputs: { ...inputs, max_duration_seconds: { value: duration } },
outputs: {},
env: {},
signal: controller.signal,
});
await new Promise(resolve => setTimeout(resolve, 40));
expect(markReady).not.toHaveBeenCalled();
expect(stopAsync).not.toHaveBeenCalled();
ready();
if (completion === 'cancellation') {
await new Promise(resolve => setImmediate(resolve));
const fn = createStartSandboxBuildFunction({
mcpServerUrl: 'ws://localhost:8787',
graphqlClient: { mutation },
} as any);
try {
const pending = fn.fn!({ logger } as any, {
inputs: {
sandbox_id: { value: 'sandbox-id' },
max_duration_seconds: { value: duration },
},
outputs: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
signal: controller.signal,
});
await new Promise(resolve => setTimeout(resolve, 40));
expect(mutation).not.toHaveBeenCalled();
expect(stopAsync).not.toHaveBeenCalled();
controller.abort();
}
await pending;
expect(markReady).toHaveBeenCalledTimes(1);
expect(stopAsync).toHaveBeenCalledTimes(1);
const timeoutMessage = `Sandbox reached its maximum runtime of ${duration} seconds. Stopping.`;
if (completion === 'duration') {
expect(logger.info).toHaveBeenCalledWith(timeoutMessage);
} else {
expect(logger.info).not.toHaveBeenCalledWith(timeoutMessage);
ready();
if (completion === 'cancellation') {
await new Promise(resolve => setImmediate(resolve));
expect(stopAsync).not.toHaveBeenCalled();
controller.abort();
}
await pending;
expect(mutation).toHaveBeenCalledTimes(1);
expect(stopAsync).toHaveBeenCalledTimes(1);
const timeoutMessage = `Sandbox reached its maximum runtime of ${duration} seconds. Stopping.`;
if (completion === 'duration') {
expect(logger.info).toHaveBeenCalledWith(timeoutMessage);
} else {
expect(logger.info).not.toHaveBeenCalledWith(timeoutMessage);
}
} finally {
start.mockRestore();
}
}
);
@@ -99,221 +67,177 @@ describe('sandbox build functions', () => {
'rejects invalid maximum duration %s before starting the daemon',
async duration => {
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync');
const fn = createStartSandboxBuildFunction(context as any);
const result = fn.fn!({} as any, {
inputs: { ...inputs, max_duration_seconds: { value: duration } },
outputs: {},
env: {},
});
await expect(result).rejects.toBeInstanceOf(UserError);
await expect(result).rejects.toThrow(
`Invalid sandbox maximum duration: ${duration}. Provide a finite number greater than zero, or omit max_duration_seconds.`
);
expect(start).not.toHaveBeenCalled();
const fn = createStartSandboxBuildFunction({
mcpServerUrl: 'ws://localhost:8787',
} as any);
try {
const result = fn.fn!({} as any, {
inputs: {
sandbox_id: { value: 'sandbox-id' },
max_duration_seconds: { value: duration },
},
outputs: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
});
await expect(result).rejects.toBeInstanceOf(UserError);
await expect(result).rejects.toThrow(
`Invalid sandbox maximum duration: ${duration}. Provide a finite number greater than zero, or omit max_duration_seconds.`
);
expect(start).not.toHaveBeenCalled();
} finally {
start.mockRestore();
}
}
);
it('connects with the worker token from the job secrets, which the step env does not have', async () => {
const env = { PREPARED_BY_EARLIER_STEP: 'value' };
const startupError = new Error('stop after checking options');
const start = jest
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
.mockRejectedValue(startupError);
const fn = createStartSandboxBuildFunction(context as any);
await expect(fn.fn!({ logger: {} } as any, { inputs, outputs: {}, env })).rejects.toBe(
startupError
);
expect(start.mock.calls[0][0]).toMatchObject({ credential: 'worker-token', env });
});
it('does not mark the sandbox ready if startup was canceled', async () => {
it('aborts an active ready request and keeps its authentication headers', async () => {
const controller = new AbortController();
const stopAsync = jest.fn(async () => {});
jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockImplementation(async options => {
expect(options.signal).toBe(controller.signal);
controller.abort();
return { ready: Promise.resolve(), stopAsync };
});
const fn = createStartSandboxBuildFunction(context as any);
await expect(
fn.fn!({ logger: {} } as any, { inputs, outputs: {}, env: {}, signal: controller.signal })
).rejects.toMatchObject({ name: 'AbortError' });
expect(fetch).not.toHaveBeenCalled();
expect(stopAsync).toHaveBeenCalledTimes(1);
});
it('marks the sandbox ready with only the worker token and aborts the request when canceled', async () => {
const controller = new AbortController();
const stopAsync = jest.fn(async () => {});
jest
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
.mockResolvedValue({ ready: Promise.resolve(), stopAsync });
let requestStarted!: () => void;
const started = new Promise<void>(resolve => {
requestStarted = resolve;
});
jest.mocked(fetch).mockImplementationOnce(async (url, init) => {
expect(url).toBe('https://api.expo.test/v2/sandbox-worker/ready');
expect(init?.headers).toEqual({ 'expo-sandbox-worker-token': 'worker-token' });
return await new Promise((_resolve, reject) => {
init!.signal!.addEventListener('abort', () => reject(new AbortError('Aborted')));
const fetchMock = jest.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => {
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer test-token');
return await new Promise<Response>((_resolve, reject) => {
init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason), { once: true });
requestStarted();
});
});
const fn = createStartSandboxBuildFunction(context as any);
const pending = fn.fn!({ logger: {} } as any, {
inputs,
outputs: {},
env: {},
signal: controller.signal,
const graphqlClient = new Client({
url: 'https://api.expo.test/graphql',
exchanges: [fetchExchange],
fetchOptions: { headers: { Authorization: 'Bearer test-token' } },
});
const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' });
await started;
controller.abort();
await rejected;
expect(stopAsync).toHaveBeenCalledTimes(1);
try {
const pending = markSandboxReadyAsync(
{ graphqlClient } as any,
'sandbox-id',
controller.signal
);
const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' });
await started;
controller.abort();
await rejected;
} finally {
fetchMock.mockRestore();
}
});
it('throws a system error and stops the daemon when readiness fails', async () => {
it('does not mark the sandbox ready if startup was canceled', async () => {
const controller = new AbortController();
const stopAsync = jest.fn(async () => {});
jest
const start = jest
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
.mockResolvedValue({ ready: Promise.resolve(), stopAsync });
jest.mocked(fetch).mockResolvedValueOnce(
new Response(JSON.stringify({ errors: [{ message: 'Sandbox has stopped.' }] }), {
status: 400,
})
);
const fn = createStartSandboxBuildFunction(context as any);
const result = fn.fn!({ logger: {} } as any, { inputs, outputs: {}, env: {} });
await expect(result).rejects.toBeInstanceOf(SystemError);
await expect(result).rejects.toMatchObject({
message: 'Failed to mark sandbox sandbox-id as ready.',
cause: expect.objectContaining({ status: 400 }),
});
expect(stopAsync).toHaveBeenCalledTimes(1);
.mockImplementation(async options => {
expect(options.signal).toBe(controller.signal);
controller.abort();
return { ready: Promise.resolve(), stopAsync };
});
const mutation = jest.fn();
const fn = createStartSandboxBuildFunction({
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
mcpServerUrl: 'ws://localhost:8787',
graphqlClient: { mutation },
} as any);
try {
await expect(
fn.fn!({ logger: {} } as any, {
inputs: { sandbox_id: { value: 'sandbox-id' } },
outputs: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
signal: controller.signal,
})
).rejects.toMatchObject({ name: 'AbortError' });
expect(mutation).not.toHaveBeenCalled();
expect(stopAsync).toHaveBeenCalledTimes(1);
} finally {
start.mockRestore();
}
});
it('strips the repository credentials before starting the daemon for git sources', async () => {
it('excludes the MCP token from the command environment', async () => {
const env = {
PREPARED_BY_EARLIER_STEP: 'value',
EXPO_TOKEN: 'expo-token',
__EAS_SANDBOX_MCP_TOKEN: 'token',
};
const startupError = new Error('stop after checking options');
const start = jest
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
.mockRejectedValue(startupError);
const fn = createStartSandboxBuildFunction({
...context,
job: {
...context.job,
projectArchive: {
type: ArchiveSourceType.GIT,
gitRef: 'refs/heads/main',
gitCommitHash: '0123456789abcdef0123456789abcdef01234567',
},
},
projectTargetDirectory: '/workingdir/build',
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
mcpServerUrl: 'ws://localhost:8787',
} 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]
);
try {
await expect(
fn.fn!({ logger: {} } as any, {
inputs: { sandbox_id: { value: 'sandbox-id' } },
outputs: {},
env,
})
).rejects.toBe(startupError);
expect(start.mock.calls[0][0]).toMatchObject({ credential: 'token' });
expect(start.mock.calls[0][0].env).toEqual({
PREPARED_BY_EARLIER_STEP: 'value',
EXPO_TOKEN: 'expo-token',
});
expect(env.__EAS_SANDBOX_MCP_TOKEN).toBe('token');
} finally {
start.mockRestore();
}
});
it('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);
it('throws a system error when the sandbox token is missing', async () => {
const fn = createStartSandboxBuildFunction({
...context,
job: {
...context.job,
projectArchive: {
type: ArchiveSourceType.GIT,
gitRef: null,
gitCommitHash: '0123456789abcdef0123456789abcdef01234567',
},
},
projectSourceDirectory: '/workingdir/temporary-custom-build',
projectTargetDirectory: '/workingdir/build',
job: {},
env: {},
mcpServerUrl: 'ws://localhost:8787',
} as any);
await expect(fn.fn!(notCheckedOutStepCtx, { inputs, outputs: {}, env: {} })).rejects.toBe(
startupError
);
expect(stripRepositoryCredentialsAsync).toHaveBeenCalledWith({
repositoryDirectory: '/workingdir/temporary-custom-build',
});
await expect(
fn.fn!({} as any, {
inputs: { sandbox_id: { value: 'sandbox-id' } },
outputs: {},
env: {},
})
).rejects.toBeInstanceOf(SystemError);
});
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');
it('throws a system error when the MCP server URL is missing', async () => {
const fn = createStartSandboxBuildFunction({
...context,
job: {
...context.job,
projectArchive: {
type: ArchiveSourceType.GIT,
gitRef: null,
gitCommitHash: '0123456789abcdef0123456789abcdef01234567',
},
},
projectTargetDirectory: '/workingdir/build',
job: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'sandbox-token' },
} as any);
await expect(fn.fn!(checkedOutStepCtx, { inputs, outputs: {}, env: {} })).rejects.toBe(
stripError
);
expect(start).not.toHaveBeenCalled();
await expect(
fn.fn!({} as any, {
inputs: { sandbox_id: { value: 'sandbox-id' } },
outputs: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'sandbox-token' },
})
).rejects.toThrow('MCP server URL is required to start the sandbox daemon.');
});
it('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);
it('marks the sandbox as ready', async () => {
const mutation = jest.fn(() => ({
toPromise: async () => ({ data: { sandbox: { markSandboxReady: { id: 'sandbox-id' } } } }),
}));
await expect(fn.fn!({ logger: {} } as any, { inputs, outputs: {}, env: {} })).rejects.toBe(
startupError
await markSandboxReadyAsync({ graphqlClient: { mutation } } as any, 'sandbox-id');
expect(mutation).toHaveBeenCalledWith(
expect.anything(),
{ sandboxId: 'sandbox-id' },
undefined
);
expect(stripRepositoryCredentialsAsync).not.toHaveBeenCalled();
});
it.each([
{
missing: 'the sandbox worker token',
overrides: { job: {} },
message: 'This sandbox job has no EAS_SANDBOX_WORKER_TOKEN secret.',
},
{
missing: 'the MCP server URL',
overrides: { mcpServerUrl: undefined },
message: 'MCP server URL is required to start the sandbox daemon.',
},
{
missing: 'the API server URL',
overrides: { expoApiV2BaseUrl: undefined },
message: 'The API server URL is not set',
},
])('throws a system error without $missing', async ({ overrides, message }) => {
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync');
const fn = createStartSandboxBuildFunction({ ...context, ...overrides } as any);
it('throws a system error when marking the sandbox as ready fails', async () => {
const mutation = jest.fn(() => ({
toPromise: async () => ({ error: new Error('request failed') }),
}));
const result = fn.fn!({} as any, { inputs, outputs: {}, env: {} });
await expect(result).rejects.toBeInstanceOf(SystemError);
await expect(result).rejects.toThrow(message);
expect(start).not.toHaveBeenCalled();
await expect(
markSandboxReadyAsync({ graphqlClient: { mutation } } as any, 'sandbox-id')
).rejects.toThrow('Failed to mark sandbox sandbox-id as ready.');
});
});
@@ -12,17 +12,9 @@ import {
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import {
createWarmDeviceSessionServer,
runOnWarmIosSimulatorAsync,
} from '../../utils/warmDeviceSessionHost';
import { type WarmIosSession } from '../../utils/warmIosSession';
import { watchDeviceRunSessionStartup } from '../../utils/deviceRunSessionStartup';
import { createStartupWatchMock } from '../../../__tests__/utils/startupWatch';
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/warmDeviceSessionHost');
jest.mock('../../../utils/IosSimulatorUtils', () => ({
IosSimulatorUtils: {
resolveUdidAsync: jest.fn().mockResolvedValue('selected-ios-udid'),
@@ -47,9 +39,6 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
jest.mock('../../utils/deviceRunSessionStartup', () => ({
watchDeviceRunSessionStartup: jest.fn(),
}));
const ctx = {} as CustomBuildContext;
const env = { NGROK_AUTHTOKEN: 'ngrok-token', PATH: '/usr/bin' } as BuildStepEnv;
@@ -59,8 +48,7 @@ const stopAsync = jest.fn();
async function runAsync(
runtimePlatform: BuildRuntimePlatform,
launchInputs: Record<string, { value: unknown }> = {},
signal?: AbortSignal
launchInputs: Record<string, { value: unknown }> = {}
): Promise<void> {
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx);
await buildFunction.fn!(
@@ -75,7 +63,6 @@ async function runAsync(
...launchInputs,
},
outputs: {},
signal,
env,
} as never
);
@@ -99,168 +86,21 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock);
stopAsync.mockResolvedValue(undefined);
});
it('does not start a host when the parent aborts during Xcode selection', async () => {
const controller = new AbortController();
const failure = new Error('parent step cancelled');
jest
.mocked(selectXcodeDeveloperDirectoryAsync)
.mockImplementationOnce(async () => controller.abort(failure));
await expect(runAsync(BuildRuntimePlatform.DARWIN, {}, controller.signal)).rejects.toBe(
failure
);
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
expect(watchDeviceRunSessionStartup).toHaveBeenCalledTimes(1);
expect(
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.finishAsync
).toHaveBeenCalled();
it('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('keeps watching startup when warm adoption retries cold', async () => {
const adoption = new Error('warm adoption failed');
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async (_claim, run) => {
try {
return await run({
device: { udid: 'selected-ios-udid', name: 'iPhone 17' },
} as WarmIosSession);
} catch (error) {
if (error !== adoption) {
throw error;
}
return await run(undefined);
}
});
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
options.onStartupError?.(adoption);
throw adoption;
});
await expect(
Promise.resolve(
createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true }).fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
{
inputs: {
boot_simulator: { value: true },
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env,
} as never
)
)
).resolves.toBeUndefined();
expect(watchDeviceRunSessionStartup).toHaveBeenCalledTimes(1);
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(2);
});
it.each([false, true])(
'observes Stop during warm claim and preserves cleanup failure=%s',
async failCleanup => {
let entered!: () => void;
const claiming = new Promise<void>(resolve => {
entered = resolve;
});
const cleanup = new Error('warm cleanup failed');
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async claim => {
entered();
await new Promise<void>((_resolve, reject) => {
claim.signal!.addEventListener(
'abort',
() =>
reject(
failCleanup
? new AggregateError([claim.signal!.reason, cleanup])
: claim.signal!.reason
),
{ once: true }
);
});
});
const running = createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true }).fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env,
} as never
) as Promise<void>;
running.catch(() => {});
await claiming;
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
if (failCleanup) {
await expect(running).rejects.toMatchObject({ errors: expect.arrayContaining([cleanup]) });
} else {
await expect(running).resolves.toBeUndefined();
}
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
}
);
it('adopts the claimed warm Simulator when the warm step runs', async () => {
const warmServer = { port: 3200 } as never;
const warm = { device: { udid: 'SELECTED-IOS-UDID', name: 'iPhone 17' } } as WarmIosSession;
jest.mocked(createWarmDeviceSessionServer).mockReturnValue(warmServer);
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation((_claim, run) => run(warm));
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true });
expect(buildFunction.id).toBe('start_warm_serve_sim_remote_session');
await buildFunction.fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
{ inputs: { device_identifier: { value: 'iPhone 17' } }, outputs: {}, env } as never
);
expect(runOnWarmIosSimulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({ engine: 'serve-sim', requestedDevice: 'iPhone 17' }),
expect.any(Function)
);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ iosSimulatorUdid: 'SELECTED-IOS-UDID' }),
warmServer
);
});
it('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 => {
@@ -280,106 +120,14 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
);
});
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'handles Stop while the %s host starts',
async runtimePlatform => {
let hostEntered!: () => void;
const entered = new Promise<void>(resolve => {
hostEntered = resolve;
});
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce((_ctx, { signal }) => {
hostEntered();
return new Promise((_resolve, reject) =>
signal!.addEventListener('abort', () => reject(new Error('host aborted')), { once: true })
);
});
const running = runAsync(runtimePlatform);
running.catch(() => {});
await entered;
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
watch.stop();
await expect(running).resolves.toBeUndefined();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
expect(watch.finishAsync).toHaveBeenCalled();
}
);
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'preserves an earlier %s host failure when Stop arrives during cleanup',
async runtimePlatform => {
const failure = new Error('Host became ready without a session token');
let cleanupEntered!: () => void;
const entered = new Promise<void>(resolve => {
cleanupEntered = resolve;
});
let finishCleanup!: () => void;
const cleanup = new Promise<void>(resolve => {
finishCleanup = resolve;
});
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
options.onStartupError?.(failure);
cleanupEntered();
await cleanup;
throw failure;
});
const running = runAsync(runtimePlatform);
running.catch(() => {});
try {
await entered;
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
} finally {
finishCleanup();
}
await expect(running).rejects.toBe(failure);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
}
);
it('keeps a teardown failure visible after Stop during preview setup', async () => {
let previewEntered!: () => void;
let finishPreview!: (value: {
previewPageUrl: string;
apiUrl: string;
closeAsync: () => Promise<void>;
}) => void;
const entered = new Promise<void>(resolve => {
previewEntered = resolve;
});
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: () => {
previewEntered();
return new Promise(resolve => {
finishPreview = resolve;
});
},
finishAsync: stopAsync,
getLastPreviewActivityAt: jest.fn(),
});
const failed = new Error('host teardown failed');
stopAsync.mockRejectedValueOnce(failed);
const running = runAsync(BuildRuntimePlatform.LINUX);
running.catch(() => {});
await entered;
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
watch.stop();
finishPreview({
previewPageUrl: 'https://preview.test',
apiUrl: 'https://server.test',
closeAsync: async () => {},
});
await expect(running).rejects.toBe(failed);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(stopAsync).toHaveBeenCalledTimes(1);
});
it('retires the startup watcher before lifetime waiting', async () => {
await runAsync(BuildRuntimePlatform.LINUX);
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
expect(watch.completeAsync.mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
);
it('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('reports the session token when serve-sim minted one', async () => {
@@ -498,9 +246,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
env: childEnv,
logger,
timeoutMs: 60_000,
preview: { baseDomain: 'tunnel.example.com', authtoken: 'ngrok-token' },
signal: expect.any(AbortSignal),
onStartupError: expect.any(Function),
signal: undefined,
...(selectsXcode
? {
iosSimulatorUdid: 'SELECTED-IOS-UDID',
@@ -509,7 +255,6 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
}
: {}),
packageVersion: '1.2.3',
emulatorCapture: true,
disableShare: false,
frameAncestors: [],
corsOrigins: [],
@@ -520,7 +265,10 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
networkCaptureFields: [],
});
const host = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
expect(host.openPreviewAsync).toHaveBeenCalledWith();
expect(host.openPreviewAsync).toHaveBeenCalledWith({
baseDomain: 'tunnel.example.com',
authtoken: 'ngrok-token',
});
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
ctx,
deviceRunSessionId: 'device-run-session-id',
@@ -530,7 +278,6 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
previewApiUrl: 'https://web-preview.example.test',
},
logger,
signal: expect.any(AbortSignal),
});
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith({
ctx,
@@ -1,407 +0,0 @@
import { BuildStep, spawnAsync } from '@expo/steps';
import fs from 'node:fs';
import path from 'node:path';
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
jest.mock('@expo/steps', () => ({
...jest.requireActual('@expo/steps'),
spawnAsync: jest.fn(),
}));
const mockedSpawnAsync = jest.mocked(spawnAsync);
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createTestAndroidJob } from '../../../__tests__/utils/job';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { BuildContext } from '../../../context';
import { CustomBuildContext } from '../../../customBuildContext';
import { parseInfoPlistBuffer } from '../readIpaInfo';
import {
createUpdateResolvedVersionBuildFunction,
extractAndroidVersionAsync,
extractIosVersionAsync,
parseAaptOutput,
parseManifestXml,
} from '../updateResolvedVersion';
describe(parseAaptOutput, () => {
it('extracts versionName and versionCode from aapt2 output', () => {
const output = `package: name='com.example.app' versionCode='42' versionName='2.5.0' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34' compileSdkVersionCodename='14'
sdkVersion:'21'
targetSdkVersion:'34'
uses-permission: name='android.permission.INTERNET'`;
expect(parseAaptOutput(output)).toEqual({
appVersion: '2.5.0',
appBuildVersion: '42',
});
});
it('handles output with no version info', () => {
expect(parseAaptOutput('some unrelated output')).toEqual({
appVersion: undefined,
appBuildVersion: undefined,
});
});
it('handles versionName with prerelease suffix', () => {
const output = `package: name='com.example' versionCode='1' versionName='1.0.0-beta.1'`;
expect(parseAaptOutput(output)).toEqual({
appVersion: '1.0.0-beta.1',
appBuildVersion: '1',
});
});
it('keeps an unescaped apostrophe in versionName', () => {
const output = `package: name='com.example' versionCode='1' versionName='1.0-O'Brien' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34'
sdkVersion:'21'`;
expect(parseAaptOutput(output)).toEqual({
appVersion: "1.0-O'Brien",
appBuildVersion: '1',
});
});
it('keeps a versionName that contains a quote and an equals sign', () => {
const output = `package: name='com.example' versionCode='1' versionName='1.0' build=beta'
sdkVersion:'21'`;
expect(parseAaptOutput(output)).toEqual({
appVersion: "1.0' build=beta",
appBuildVersion: '1',
});
});
it('stops versionName at the split field of a split APK', () => {
const output = `package: name='com.example' versionCode='42' versionName='2.5.0' split='feature' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34'
sdkVersion:'21'`;
expect(parseAaptOutput(output)).toEqual({
appVersion: '2.5.0',
appBuildVersion: '42',
});
});
it('treats an empty versionName as missing', () => {
const output = `package: name='com.example' versionCode='42' versionName='' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34'
sdkVersion:'21'`;
expect(parseAaptOutput(output)).toEqual({
appVersion: undefined,
appBuildVersion: '42',
});
});
});
describe(parseManifestXml, () => {
it('extracts versionName and versionCode from bundletool manifest XML', () => {
const xml = `<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:versionCode="10"
android:versionName="3.1.0"
package="com.example.app">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
</manifest>`;
expect(parseManifestXml(xml)).toEqual({
appVersion: '3.1.0',
appBuildVersion: '10',
});
});
it('handles XML with no version attributes', () => {
const xml = `<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.app">
</manifest>`;
expect(parseManifestXml(xml)).toEqual({
appVersion: undefined,
appBuildVersion: undefined,
});
});
});
describe('simulator .app Info.plist parsing', () => {
const FIXTURES_DIR = path.join(__dirname, 'fixtures');
const SIMULATOR_APP_DIR = path.join(FIXTURES_DIR, 'TestSimulator.app');
const INFO_PLIST_PATH = path.join(SIMULATOR_APP_DIR, 'Info.plist');
beforeAll(async () => {
// Create a minimal XML Info.plist fixture
await fs.promises.mkdir(SIMULATOR_APP_DIR, { recursive: true });
await fs.promises.writeFile(
INFO_PLIST_PATH,
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>com.example.test</string>
<key>CFBundleShortVersionString</key>
<string>4.2.0</string>
<key>CFBundleVersion</key>
<string>99</string>
</dict>
</plist>`
);
});
afterAll(async () => {
await fs.promises.rm(SIMULATOR_APP_DIR, { recursive: true, force: true });
});
it('reads version from a .app Info.plist', async () => {
const buffer = await fs.promises.readFile(INFO_PLIST_PATH);
const infoPlist = parseInfoPlistBuffer(buffer);
expect(infoPlist.CFBundleShortVersionString).toBe('4.2.0');
expect(infoPlist.CFBundleVersion).toBe('99');
});
});
describe(extractIosVersionAsync, () => {
const FIXTURES_DIR = path.join(__dirname, 'fixtures');
const SIMULATOR_APP_DIR = path.join(FIXTURES_DIR, 'ExtractVersion.app');
const INFO_PLIST_PATH = path.join(SIMULATOR_APP_DIR, 'Info.plist');
const IPA_FIXTURE_PATH = path.join(FIXTURES_DIR, 'SmallestAppExample.ipa');
beforeAll(async () => {
await fs.promises.mkdir(SIMULATOR_APP_DIR, { recursive: true });
await fs.promises.writeFile(
INFO_PLIST_PATH,
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleShortVersionString</key>
<string>2.0.1</string>
<key>CFBundleVersion</key>
<string>55</string>
</dict>
</plist>`
);
});
afterAll(async () => {
await fs.promises.rm(SIMULATOR_APP_DIR, { recursive: true, force: true });
});
it('extracts version from a .app directory', async () => {
const result = await extractIosVersionAsync(SIMULATOR_APP_DIR);
expect(result).toEqual({
appVersion: '2.0.1',
appBuildVersion: '55',
});
});
it('extracts version from an .ipa file', async () => {
const result = await extractIosVersionAsync(IPA_FIXTURE_PATH);
expect(result).toEqual({
appVersion: '1.0',
appBuildVersion: '1',
});
});
});
describe(extractAndroidVersionAsync, () => {
afterEach(() => {
mockedSpawnAsync.mockReset();
});
it('extracts version from an .apk via aapt2', async () => {
mockedSpawnAsync.mockResolvedValue({
stdout: `package: name='com.example.app' versionCode='42' versionName='2.5.0'`,
stderr: '',
} as any);
const result = await extractAndroidVersionAsync('/path/to/app.apk');
expect(mockedSpawnAsync).toHaveBeenCalledWith(
'aapt2',
['dump', 'badging', '/path/to/app.apk'],
{
stdio: 'pipe',
}
);
expect(result).toEqual({
appVersion: '2.5.0',
appBuildVersion: '42',
});
});
it('extracts version from an .aab via bundletool', async () => {
mockedSpawnAsync.mockResolvedValue({
stdout: `<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:versionCode="10"
android:versionName="3.1.0"
package="com.example.app">
</manifest>`,
stderr: '',
} as any);
const result = await extractAndroidVersionAsync('/path/to/app.aab');
expect(mockedSpawnAsync).toHaveBeenCalledWith(
'bundletool',
['dump', 'manifest', '--bundle', '/path/to/app.aab'],
{ stdio: 'pipe' }
);
expect(result).toEqual({
appVersion: '3.1.0',
appBuildVersion: '10',
});
});
it('returns empty object for unknown extension', async () => {
const result = await extractAndroidVersionAsync('/path/to/archive.zip');
expect(mockedSpawnAsync).not.toHaveBeenCalled();
expect(result).toEqual({});
});
});
describe(createUpdateResolvedVersionBuildFunction, () => {
afterEach(() => {
mockedSpawnAsync.mockReset();
});
function createStep({
env,
archivePath,
workingDirectory,
mutationResult = { data: {}, error: undefined },
}: {
env: Record<string, string>;
archivePath?: string;
workingDirectory?: string;
mutationResult?: { data?: unknown; error?: Error };
}): { step: BuildStep; mutation: jest.Mock } {
const mutation = jest.fn(() => ({ toPromise: async () => mutationResult }));
const ctx = new BuildContext(createTestAndroidJob({}), {
env: { __API_SERVER_URL: 'http://api.expo.test', ...env },
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
uploadArtifact: jest.fn(),
workingdir: '',
});
ctx.graphqlClient.mutation = mutation as any;
const step = createUpdateResolvedVersionBuildFunction(
new CustomBuildContext(ctx)
).createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs: archivePath ? { application_archive_path: archivePath } : {},
workingDirectory,
});
return { step, mutation };
}
it('resolves a relative archive path against the step working directory', async () => {
mockedSpawnAsync.mockResolvedValue({
stdout: `package: name='com.example.app' versionCode='42' versionName='2.5.0'`,
stderr: '',
} as any);
const { step, mutation } = createStep({
env: { EAS_BUILD_ID: 'build-id' },
archivePath: 'android/app/build/outputs/apk/release/app-release.apk',
workingDirectory: './my-app',
});
await step.executeAsync();
const expectedPath = path.join(
step.ctx.workingDirectory,
'android/app/build/outputs/apk/release/app-release.apk'
);
expect(path.isAbsolute(expectedPath)).toBe(true);
expect(mockedSpawnAsync).toHaveBeenCalledWith('aapt2', ['dump', 'badging', expectedPath], {
stdio: 'pipe',
});
expect(mutation).toHaveBeenCalledTimes(1);
});
it('updates the resolved versions for the build', async () => {
mockedSpawnAsync.mockResolvedValue({
stdout: `package: name='com.example.app' versionCode='42' versionName='2.5.0'`,
stderr: '',
} as any);
const { step, mutation } = createStep({
env: { EAS_BUILD_ID: 'build-id' },
archivePath: '/path/to/app.apk',
});
await step.executeAsync();
expect(mutation).toHaveBeenCalledTimes(1);
expect(mutation.mock.calls[0][1]).toEqual({
buildId: 'build-id',
appVersion: '2.5.0',
appBuildVersion: '42',
});
});
it('omits a missing value instead of sending null', async () => {
mockedSpawnAsync.mockResolvedValue({
stdout: `package: name='com.example.app' versionCode='42'`,
stderr: '',
} as any);
const { step, mutation } = createStep({
env: { EAS_BUILD_ID: 'build-id' },
archivePath: '/path/to/app.apk',
});
await step.executeAsync();
const variables = mutation.mock.calls[0][1];
expect(variables.appBuildVersion).toBe('42');
expect(variables.appVersion).toBeUndefined();
expect(variables.appVersion).not.toBeNull();
});
it('does nothing without a build ID', async () => {
const { step, mutation } = createStep({ env: {}, archivePath: '/path/to/app.apk' });
await step.executeAsync();
expect(mockedSpawnAsync).not.toHaveBeenCalled();
expect(mutation).not.toHaveBeenCalled();
});
it('does nothing without an archive path', async () => {
const { step, mutation } = createStep({ env: { EAS_BUILD_ID: 'build-id' } });
await step.executeAsync();
expect(mockedSpawnAsync).not.toHaveBeenCalled();
expect(mutation).not.toHaveBeenCalled();
});
it('does not fail the step when extraction or the mutation fails', async () => {
mockedSpawnAsync.mockRejectedValueOnce(new Error('aapt2: command not found'));
const failingExtraction = createStep({
env: { EAS_BUILD_ID: 'build-id' },
archivePath: '/path/to/app.apk',
});
await expect(failingExtraction.step.executeAsync()).resolves.not.toThrow();
expect(failingExtraction.mutation).not.toHaveBeenCalled();
mockedSpawnAsync.mockResolvedValue({
stdout: `package: name='com.example.app' versionCode='42' versionName='2.5.0'`,
stderr: '',
} as any);
const failingMutation = createStep({
env: { EAS_BUILD_ID: 'build-id' },
archivePath: '/path/to/app.apk',
mutationResult: { error: new Error('GraphQL error') },
});
await expect(failingMutation.step.executeAsync()).resolves.not.toThrow();
expect(failingMutation.mutation).toHaveBeenCalledTimes(1);
});
});
@@ -1,4 +1,4 @@
import { SystemError, formatBytes } from '@expo/eas-build-job';
import { SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -15,6 +15,7 @@ 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, formatBytes } from '@expo/eas-build-job';
import { SystemError, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -23,6 +23,7 @@ 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 {
@@ -34,9 +35,9 @@ import { retryOnNetworkFailureAsync } from '../../utils/retryOnNetworkFailure';
import { pluralize } from '../../utils/strings';
import {
type DeviceRunSessionWorkerApi,
DeviceRunSessionWorkerApiError,
getDeviceRunSessionWorkerApi,
} from '../utils/deviceRunSessionWorkerApi';
import { WorkerApiError } from '../utils/workerApi';
const streamPipeline = promisify(stream.pipeline);
@@ -218,7 +219,7 @@ async function fetchDeviceRunSessionApplicationArchiveUrlAsync({
{
operation: 'download_build.session_build_lookup',
signal,
isRetryable: error => error instanceof WorkerApiError && error.isTransient,
isRetryable: error => error instanceof DeviceRunSessionWorkerApiError && error.isTransient,
}
);
} catch (err) {
@@ -1,5 +1,5 @@
import { BuildJob, Ios, ManagedArtifactType, Platform } from '@expo/eas-build-job';
import { BuildFunction, BuildStepContext, BuildStepOutput } from '@expo/steps';
import { BuildFunction, BuildStepContext } from '@expo/steps';
import { CustomBuildContext } from '../../customBuildContext';
import { findXcodeBuildLogsPathAsync } from '../../ios/xcodeBuildLogs';
@@ -13,13 +13,7 @@ export function createFindAndUploadBuildArtifactsBuildFunction(
id: 'find_and_upload_build_artifacts',
name: 'Find and upload build artifacts',
__metricsId: 'eas/find_and_upload_build_artifacts',
outputProviders: [
BuildStepOutput.createProvider({
id: 'application_archive_path',
required: false,
}),
],
fn: async (stepCtx, { outputs }) => {
fn: async stepCtx => {
// We want each upload to print logs on its own
// and we don't want to interleave logs from different uploads
// so we execute uploads consecutively.
@@ -30,10 +24,7 @@ export function createFindAndUploadBuildArtifactsBuildFunction(
let firstError: any = null;
try {
const [firstArchivePath] = await uploadApplicationArchivesAsync({ ctx, stepCtx });
if (firstArchivePath) {
outputs.application_archive_path.set(firstArchivePath);
}
await uploadApplicationArchivesAsync({ ctx, stepCtx });
} catch (err: unknown) {
stepCtx.logger.error({ err }, `Failed to upload application archives.`);
firstError ||= err;
@@ -77,7 +68,7 @@ async function uploadApplicationArchivesAsync({
}: {
ctx: CustomBuildContext<BuildJob>;
stepCtx: BuildStepContext;
}): Promise<string[]> {
}): Promise<void> {
const applicationArchivePatternOrPath =
ctx.job.platform === Platform.ANDROID
? (ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}')
@@ -108,8 +99,6 @@ async function uploadApplicationArchivesAsync({
logger,
});
logger.info('Done.');
return applicationArchives;
}
async function uploadBuildArtifacts({
@@ -211,9 +211,7 @@ 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(
@@ -381,111 +379,6 @@ 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,7 +18,6 @@ import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
import {
copyLatestAttemptXml,
junitFileHasFileAttrs,
maestroRunnerAttemptDirectoryName,
mergeJUnitReports,
parseFailedFlowNamesFromJUnitFile,
parseFailedFlowsFromFileAttrs,
@@ -354,7 +353,7 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
// maestro-runner writes all report formats and screenshot metadata here.
const runnerOutputDirectory = path.join(
testsDirectory,
maestroRunnerAttemptDirectoryName({ platform, attempt })
`${platform}-maestro-runner-attempt-${attempt}`
);
const outputPath = mayHaveJUnitReports
? path.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
@@ -117,7 +117,7 @@ export async function readIpaInfoAsync(ipaPath: string): Promise<IpaInfo> {
}
}
export function parseInfoPlistBuffer(data: Buffer): Record<string, unknown> {
function parseInfoPlistBuffer(data: Buffer): Record<string, unknown> {
const isBinaryPlist = data.subarray(0, 8).toString('ascii') === 'bplist00';
if (isBinaryPlist) {
const parsedBinaryPlists = bplistParser.parseBuffer(data);
@@ -5,12 +5,10 @@ import { z } from 'zod';
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
import {
MaestroFlowResult,
hasMaestroRunnerAttemptDirectories,
isFileAttrRun,
parseJUnitTestCases,
parseMaestroResults,
parseMaestroResultsFromFileAttrs,
parseMaestroRunnerResults,
} from './maestroResultParser';
import { CustomBuildContext } from '../../customBuildContext';
@@ -75,15 +73,8 @@ 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 && (await hasMaestroRunnerAttemptDirectories(testsDirectory))) {
flowResults = await parseMaestroRunnerResults({
junitDirectory,
testsDirectory,
workingDirectory: stepsCtx.workingDirectory,
});
} else if (usedFileAttrs) {
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, formatBytes } from '@expo/eas-build-job';
import { Platform } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -17,6 +17,7 @@ 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, formatBytes } from '@expo/eas-build-job';
import { Platform } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -14,6 +14,7 @@ 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, formatBytes } from '@expo/eas-build-job';
import { Platform } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
@@ -12,6 +12,7 @@ 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';
@@ -9,8 +9,6 @@ import path from 'node:path';
import { type CustomBuildContext } from '../../customBuildContext';
import { uploadRemoteSessionConfigWithLocalEgressAsync } from '../utils/localEgressSession';
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import { createWarmDeviceSessionServer } from '../utils/warmDeviceSessionHost';
import { type WarmIosSession } from '../utils/warmIosSession';
import { Sentry } from '../../sentry';
import {
PackageManager,
@@ -25,8 +23,6 @@ import { type ServeSimApplicationOptions } from '../utils/remoteDeviceRunSession
import { type StartupTasks } from '../utils/startupTasks';
import {
type DetachedProcessHandle,
DeviceRunSessionStoppedError,
TOOL_TUNNEL_TEARDOWN_OPTIONS,
finishRemoteSessionAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
@@ -102,8 +98,6 @@ export async function runAgentDeviceRemoteSessionAsync(
frameAncestors,
corsOrigins,
tasks,
warm,
onStarted,
device,
}: {
env: BuildStepEnv;
@@ -120,9 +114,6 @@ export async function runAgentDeviceRemoteSessionAsync(
frameAncestors?: string[];
corsOrigins?: string[];
tasks: StartupTasks;
/** A claimed warm Simulator: adopt it and use its agent-device daemon. */
warm?: WarmIosSession;
onStarted?: () => Promise<void>;
device:
| {
booted: Promise<unknown>;
@@ -146,9 +137,6 @@ export async function runAgentDeviceRemoteSessionAsync(
let agentDeviceTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let eventCollection: Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>> | undefined;
let artifactUploads: Promise<void> | undefined;
// Ends periodic polling; the poller then runs one bounded final inventory and upload drain.
const stopArtifactPolling = new AbortController();
let sessionFailed = false;
const daemonSecrets = [ngrokAuthtoken];
const hostBootedDevice = 'iosSimulatorUdid' in device ? device : undefined;
@@ -156,71 +144,7 @@ export async function runAgentDeviceRemoteSessionAsync(
// Each task stores what it started, so the teardown below can stop it even when
// another task failed first.
const sessionHostStartup = tasks.run('session host', async taskLogger => {
if (externallyBootedDevice) {
await tasks.untilAborted(externallyBootedDevice.booted);
}
tasks.signal.throwIfAborted();
sessionHost = await startDeviceSessionHostAsync(
ctx,
{
separateLogPhase: true,
runtimePlatform,
env,
logger: taskLogger,
timeoutMs: STARTUP_TIMEOUT_MS,
preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken },
disableShare,
privatePreviewUrl,
frameAncestors,
corsOrigins,
signal: tasks.signal,
onStartupError: tasks.abort,
networkCapture: capture.networkCapture,
networkCaptureFields: capture.networkCaptureFields,
iosSimulatorUdid: hostBootedDevice?.iosSimulatorUdid,
bootEnv: hostBootedDevice?.bootEnv,
application: hostBootedDevice?.application,
},
...(warm ? [createWarmDeviceSessionServer(ctx, taskLogger, warm)] : [])
);
tasks.signal.throwIfAborted();
const webPreview = await sessionHost.openPreviewAsync();
tasks.signal.throwIfAborted();
taskLogger.info(
webPreview.sharePageUrl
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
return webPreview;
});
const agentDeviceStartup = tasks.run('agent-device daemon', async taskLogger => {
let daemonInfo: { port: number; token: string };
if (warm) {
// Adoption prepares the launcher's daemon for the claimed device.
await tasks.untilAborted(sessionHostStartup);
if (!warm.tools) {
throw new Error('The warm agent-device daemon was not prepared.');
}
daemonInfo = warm.tools;
} else {
daemonInfo = await startDaemonAsync(taskLogger);
}
tasks.signal.throwIfAborted();
agentDeviceTunnel = await startNgrokTunnelAsync({
port: daemonInfo.port,
subdomainPrefix: 'agent-device',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
healthCheck: { path: '/health' },
logger: taskLogger,
signal: tasks.signal,
});
tasks.signal.throwIfAborted();
taskLogger.info(`Tunnel is ready at ${agentDeviceTunnel.url}.`);
return { ...daemonInfo, remoteSessionUrl: agentDeviceTunnel.url };
});
async function startDaemonAsync(taskLogger: bunyan) {
taskLogger.info('Launching agent-device daemon.');
daemonProcess = await startAgentDeviceDaemonAsync({
packageVersion,
@@ -236,8 +160,55 @@ export async function runAgentDeviceRemoteSessionAsync(
const daemonInfo = await tasks.untilAborted(waitForDaemonInfoAsync({ daemonProcess }));
daemonSecrets.push(daemonInfo.token);
taskLogger.info(`Daemon is listening on port ${daemonInfo.port}; loaded auth token.`);
return daemonInfo;
}
tasks.signal.throwIfAborted();
agentDeviceTunnel = await startNgrokTunnelAsync({
port: daemonInfo.port,
subdomainPrefix: 'agent-device',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
logger: taskLogger,
});
tasks.signal.throwIfAborted();
taskLogger.info(`Tunnel is ready at ${agentDeviceTunnel.url}.`);
return { ...daemonInfo, remoteSessionUrl: agentDeviceTunnel.url };
});
const sessionHostStartup = tasks.run('session host', async taskLogger => {
if (externallyBootedDevice) {
await tasks.untilAborted(externallyBootedDevice.booted);
}
tasks.signal.throwIfAborted();
sessionHost = await startDeviceSessionHostAsync(ctx, {
separateLogPhase: true,
runtimePlatform,
env,
logger: taskLogger,
timeoutMs: STARTUP_TIMEOUT_MS,
disableShare,
privatePreviewUrl,
frameAncestors,
corsOrigins,
signal: tasks.signal,
onStartupError: tasks.abort,
networkCapture: capture.networkCapture,
networkCaptureFields: capture.networkCaptureFields,
iosSimulatorUdid: hostBootedDevice?.iosSimulatorUdid,
bootEnv: hostBootedDevice?.bootEnv,
application: hostBootedDevice?.application,
});
tasks.signal.throwIfAborted();
const webPreview = await sessionHost.openPreviewAsync({
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
});
tasks.signal.throwIfAborted();
taskLogger.info(
webPreview.sharePageUrl
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
return webPreview;
});
const deviceReady = externallyBootedDevice?.ready;
try {
@@ -256,7 +227,7 @@ export async function runAgentDeviceRemoteSessionAsync(
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal: tasks.signal,
signal,
ctx,
deviceRunSessionId,
remoteConfig: {
@@ -271,23 +242,18 @@ export async function runAgentDeviceRemoteSessionAsync(
if (sessionStart === 'stopped') {
return;
}
await onStarted?.();
tasks.signal.throwIfAborted();
artifactUploads = pollAgentDeviceArtifactsForUploadAsync(ctx, {
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
daemonUrl: `http://127.0.0.1:${daemonPort}`,
daemonToken,
logger,
signal,
stopPollingSignal: stopArtifactPolling.signal,
});
// Attach a handler at once; the teardown still awaits and reports a rejection.
void artifactUploads.catch(() => {});
eventCollection = await startAgentDeviceEventCollectionAsync({
ctx,
deviceRunSessionId,
stateDir: warm?.agentDeviceStateDir ?? AGENT_DEVICE_STATE_DIR,
stateDir: AGENT_DEVICE_STATE_DIR,
logger,
});
@@ -307,64 +273,37 @@ export async function runAgentDeviceRemoteSessionAsync(
: undefined,
});
} catch (error) {
if (
tasks.signal.reason instanceof DeviceRunSessionStoppedError &&
!(error instanceof AggregateError)
) {
throw tasks.signal.reason;
}
sessionFailed = true;
throw error;
} finally {
// Promise.all rejects on the first failure while other tasks can still be starting.
// They stop at their next abort check. Wait for all of them, so the teardown sees
// everything that was started.
const startupResults = await Promise.allSettled([
agentDeviceStartup,
sessionHostStartup,
deviceReady,
]);
await Promise.allSettled([agentDeviceStartup, sessionHostStartup, deviceReady]);
const startedDaemon = daemonProcess;
const toolTeardown = (async () => {
try {
if (eventCollection) {
await stopAgentDeviceEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
}
} finally {
try {
// The final inventory reads from the daemon, so it finishes before the daemon stops.
stopArtifactPolling.abort();
await artifactUploads;
} finally {
await startedDaemon?.stopAsync();
}
}
})();
await finishRemoteSessionAsync({
logger,
sessionFailed,
teardown: [
...startupResults.flatMap((result): [string, Promise<unknown>][] =>
result.status === 'rejected' && result.reason instanceof AggregateError
? [['startup cleanup', Promise.reject(result.reason)]]
: []
),
['agent-device tunnel', agentDeviceTunnel?.stopAsync(), TOOL_TUNNEL_TEARDOWN_OPTIONS],
['agent-device daemon', toolTeardown],
['agent-device tunnel', agentDeviceTunnel?.stopAsync()],
[
'session host',
(async () => {
if (warm) {
// The warm host owns the daemon too; keep it alive through the final downloads.
await Promise.allSettled([toolTeardown]);
}
await sessionHost?.finishAsync();
})(),
'agent-device daemon',
startedDaemon &&
(async () => {
try {
if (eventCollection) {
await stopAgentDeviceEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
}
} finally {
await startedDaemon.stopAsync();
}
})(),
],
['session host', sessionHost?.finishAsync()],
],
});
}
@@ -15,33 +15,28 @@ import {
AndroidEmulatorUtils,
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';
import { runOnWarmIosSimulatorAsync } from '../utils/warmDeviceSessionHost';
import { type WarmIosSession } from '../utils/warmIosSession';
import { registerAndroidEmulatorLogDirectory } from '../utils/androidEmulatorLogs';
import { withLocalEgressSession } from '../utils/localEgressSession';
import {
type ServeSimApplicationOptions,
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createPrivatePreviewUrlInputProvider,
createPublicPreviewInputProvider,
createServeSimLaunchInputProviders,
omitNgrokAuthtoken,
parseCorsOriginsInput,
parseFrameAncestorsInput,
selectXcodeDeveloperDirectoryAsync,
validateServeSimLaunchOptions,
} from '../utils/remoteDeviceRunSession';
import {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
} from '../utils/networkCaptureFields';
import { createStartupTasks } from '../utils/startupTasks';
import {
type ServeSimApplicationOptions,
validateServeSimLaunchOptions,
} from '../utils/remoteDeviceRunSession';
import { downloadBuildAsync } from './downloadBuild';
import { installBuildAsync } from './installBuild';
@@ -55,30 +50,23 @@ import {
runAgentDeviceRemoteSessionAsync,
} from './startAgentDeviceRemoteSession';
import { startAndroidEmulatorAsync } from './startAndroidEmulator';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
import { readIosApplicationIdentifierAsync } from '../utils/iosAppArtifact';
const ANDROID_DEVICE_NAME = 'EasAndroidDevice01' as AndroidVirtualDeviceName;
/**
* Starts a device session using an optional local app artifact, or one it downloads.
* Starts a device session, downloading the app alongside daemon startup.
* On iOS, serve-sim boots the selected Simulator, then installs and launches
* the app. Build-tools verifies any egress guard before install and launch.
* the downloaded app. Build-tools verifies any egress guard before install and launch.
*/
export function createStartAgentDeviceSessionBuildFunction(
ctx: CustomBuildContext,
{ warm = false }: { warm?: boolean } = {}
): BuildFunction {
export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: warm ? 'start_warm_agent_device_session' : 'start_agent_device_session',
id: 'start_agent_device_session',
name: 'Start agent-device session',
__metricsId: warm ? 'eas/start_warm_agent_device_session' : 'eas/start_agent_device_session',
__metricsId: 'eas/start_agent_device_session',
inputProviders: [
// 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',
@@ -106,13 +94,7 @@ export function createStartAgentDeviceSessionBuildFunction(
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
// Application. Pass at most one of install_app_path, build_id and application_archive_url.
BuildStepInput.createProvider({
id: 'install_app_path',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
// Deprecated: remove once workflows pass install_app_path.
// Application. Pass at most one of build_id and application_archive_url.
BuildStepInput.createProvider({
id: 'build_id',
required: false,
@@ -123,7 +105,16 @@ export function createStartAgentDeviceSessionBuildFunction(
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
...createServeSimLaunchInputProviders(),
BuildStepInput.createProvider({
id: 'launch_args',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
BuildStepInput.createProvider({
id: 'open_url',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
// Session.
...createNetworkCaptureInputProviders(),
createPublicPreviewInputProvider(),
@@ -154,41 +145,22 @@ export function createStartAgentDeviceSessionBuildFunction(
// Validate everything before any expensive work starts.
const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(stepEnv);
const env = omitNgrokAuthtoken(stepEnv);
const installAppPath = inputs.install_app_path.value as string | undefined;
const buildId = inputs.build_id.value as string | undefined;
const applicationArchiveUrl = inputs.application_archive_url.value as string | undefined;
const launchAppIdentifier = inputs.launch_app_identifier.value as string | undefined;
if (buildId && applicationArchiveUrl) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
'Pass only one of build_id or application_archive_url.'
);
}
const hasApplication = Boolean(buildId || applicationArchiveUrl);
const launchArgs = parseLaunchArgsInput(inputs.launch_args.value);
const openUrl =
inputs.open_url.value === undefined ? undefined : parseOpenUrlInput(inputs.open_url.value);
const download = buildId || applicationArchiveUrl;
if ([installAppPath, buildId, applicationArchiveUrl].filter(Boolean).length > 1) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
'Pass only one of install_app_path, build_id or application_archive_url.'
);
}
if (download && launchAppIdentifier !== undefined) {
if (!hasApplication && (launchArgs.length > 0 || openUrl)) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
'launch_app_identifier cannot be combined with build_id or application_archive_url.'
);
}
if (
!installAppPath &&
!download &&
!launchAppIdentifier &&
(launchArgs.length > 0 || openUrl)
) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
'launch_args and open_url need an application: pass install_app_path or launch_app_identifier.'
);
}
if (!isIos && launchAppIdentifier !== undefined) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
'launch_app_identifier is only supported for iOS Simulator sessions. Pass install_app_path for Android.'
'launch_args and open_url need an application: pass build_id or application_archive_url.'
);
}
if (isIos) {
@@ -196,10 +168,6 @@ 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(
{
@@ -209,125 +177,88 @@ export function createStartAgentDeviceSessionBuildFunction(
{ runtimePlatform }
);
const startup = new AbortController();
const startupSignal = signal ? AbortSignal.any([signal, startup.signal]) : startup.signal;
const startupWatch = watchDeviceRunSessionStartup({
ctx,
deviceRunSessionId: sessionEnv.deviceRunSessionId,
logger,
startup: { signal: startupSignal, abort: reason => startup.abort(reason) },
});
const startSessionAsync = async (
warmSession?: WarmIosSession,
resolvedPackageVersion = inputs.package_version.value as string | undefined
): Promise<void> => {
const tasks = createStartupTasks(logger, startupSignal);
let booted: Promise<void> | undefined;
let sessionOwnsTeardown = false;
let downloaded: Promise<string> | undefined;
let applicationReady: Promise<ServeSimApplicationOptions | void> | undefined;
const tasks = createStartupTasks(logger, signal);
let downloaded: Promise<string> | undefined;
let applicationReady: Promise<ServeSimApplicationOptions | void> | undefined;
try {
if (isIos) {
// Select Xcode before serve-sim boots the device or any Simulator tools run.
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
tasks.signal.throwIfAborted();
const { launch, iosStartup } = isIos
? await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: true,
deviceIdentifier: warmSession?.device.udid ?? deviceIdentifier,
installAppPath,
launchAppIdentifier,
// A downloaded app gets its launch options after the download.
launchArgs: download ? [] : launchArgs,
openUrl: download ? undefined : openUrl,
try {
if (isIos) {
// Select Xcode before serve-sim boots the device or any Simulator tools run.
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
tasks.signal.throwIfAborted();
const { iosStartup } = isIos
? await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: true,
deviceIdentifier,
env,
logger,
signal: tasks.signal,
})
: {};
const iosSimulatorUdid = iosStartup?.iosSimulatorUdid;
if (iosSimulatorUdid) {
logger.info(`Selected iOS Simulator: ${iosSimulatorUdid}.`);
}
const booted = iosSimulatorUdid
? undefined
: tasks.run('Android Emulator boot', async taskLogger => {
const logcatDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
);
registerAndroidEmulatorLogDirectory(global, logcatDirectory);
tasks.signal.throwIfAborted();
await startAndroidEmulatorAsync({
deviceName: ANDROID_DEVICE_NAME,
systemImagePackage: `${inputs.system_image_package.value}`,
deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined,
lcdWidth: inputs.lcd_width.value as number | undefined,
lcdHeight: inputs.lcd_height.value as number | undefined,
lcdDensity: inputs.lcd_density.value as number | undefined,
logcatDirectory,
env,
logger,
logger: taskLogger,
});
});
downloaded = hasApplication
? tasks.run('build download', async taskLogger => {
const { artifactPath } = await downloadBuildAsync({
logger: taskLogger,
...(buildId ? { buildId } : { applicationArchiveUrl: applicationArchiveUrl! }),
graphqlClient: ctx.graphqlClient,
deviceRunSessionWorkerApi: buildId ? getDeviceRunSessionWorkerApi(ctx) : null,
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
extensions: isIos ? ['app'] : ['apk'],
signal: tasks.signal,
})
: {};
const iosSimulatorUdid = iosStartup?.iosSimulatorUdid;
if (iosSimulatorUdid) {
logger.info(`Selected iOS Simulator: ${iosSimulatorUdid}.`);
}
booted = iosSimulatorUdid
? Promise.resolve()
: tasks.run('Android Emulator boot', async taskLogger => {
const logcatDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
);
tasks.signal.throwIfAborted();
await startAndroidEmulatorAsync({
deviceName: ANDROID_DEVICE_NAME,
systemImagePackage: `${inputs.system_image_package.value}`,
deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined,
lcdWidth: inputs.lcd_width.value as number | undefined,
lcdHeight: inputs.lcd_height.value as number | undefined,
lcdDensity: inputs.lcd_density.value as number | undefined,
logcatDirectory,
logCapture: getAndroidEmulatorLogCapture(global, env, logSources),
env,
logger: taskLogger,
});
});
return artifactPath;
})
: undefined;
downloaded = download
? tasks.run('build download', async taskLogger => {
const source = buildId
? { buildId }
: applicationArchiveUrl
? { applicationArchiveUrl }
: undefined;
if (!source) {
throw new Error('No app source was provided for download.');
}
const { artifactPath } = await downloadBuildAsync({
logger: taskLogger,
...source,
graphqlClient: ctx.graphqlClient,
deviceRunSessionWorkerApi: buildId ? getDeviceRunSessionWorkerApi(ctx) : null,
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
extensions: isIos ? ['app'] : ['apk'],
signal: tasks.signal,
});
return artifactPath;
})
: undefined;
const downloadedApp = downloaded;
if (isIos && downloadedApp) {
applicationReady = tasks.run('app preparation', async () => {
const artifactPath = await downloadedApp;
tasks.signal.throwIfAborted();
const applicationIdentifier = await readIosApplicationIdentifierAsync({
artifactPath,
env,
});
tasks.signal.throwIfAborted();
return {
installAppPath: artifactPath,
launchAppIdentifier: applicationIdentifier,
launchArgs,
openUrl,
};
});
} else if (isIos && launch?.launchAppIdentifier) {
applicationReady = Promise.resolve({
...launch,
launchAppIdentifier: launch.launchAppIdentifier,
installAppPath,
});
} else if (!isIos && (installAppPath || downloadedApp)) {
applicationReady = tasks.run('app install and launch', async taskLogger => {
await tasks.untilAborted(booted!);
const artifactPath = installAppPath ?? (await downloadedApp);
if (!artifactPath) {
throw new Error('No app artifact was prepared.');
const download = downloaded;
applicationReady = download
? tasks.run(isIos ? 'app preparation' : 'app install and launch', async taskLogger => {
if (booted) {
await tasks.untilAborted(booted);
}
const artifactPath = await download;
tasks.signal.throwIfAborted();
if (isIos) {
const applicationIdentifier = await readIosApplicationIdentifierAsync({
artifactPath,
env,
});
tasks.signal.throwIfAborted();
return {
installAppPath: artifactPath,
launchAppIdentifier: applicationIdentifier,
launchArgs,
openUrl,
};
}
const { applicationIdentifier, activityName } = await installBuildAsync({
artifactPath,
runtimePlatform,
@@ -344,76 +275,39 @@ export function createStartAgentDeviceSessionBuildFunction(
env,
logger: taskLogger,
});
});
}
})
: undefined;
sessionOwnsTeardown = true;
await runAgentDeviceRemoteSessionAsync(ctx, {
env,
logger,
signal,
runtimePlatform,
sessionEnv,
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,
capture,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
tasks,
warm: warmSession,
onStarted: () => startupWatch.completeAsync(),
device: iosSimulatorUdid
? {
iosSimulatorUdid,
bootEnv: iosStartup?.bootEnv,
application: applicationReady,
}
: {
booted,
ready: applicationReady ?? tasks.untilAborted(booted),
},
});
} catch (error) {
if (
!sessionOwnsTeardown &&
tasks.signal.reason === startupWatch.stopped &&
!(error instanceof AggregateError)
) {
throw startupWatch.stopped;
}
throw error;
} finally {
tasks.abort(new Error('Agent-device session ended.'));
await Promise.allSettled([booted, downloaded, applicationReady]);
}
};
try {
startupSignal.throwIfAborted();
if (warm && isIos && !download) {
await runOnWarmIosSimulatorAsync(
{
engine: 'agent-device',
requestedDevice: deviceIdentifier,
packageVersion: inputs.package_version.value as string | undefined,
env,
logger,
signal: startupSignal,
},
startSessionAsync
);
} else {
await startSessionAsync();
}
} catch (error) {
if (error !== startupWatch.stopped) {
throw error;
}
await runAgentDeviceRemoteSessionAsync(ctx, {
env,
logger,
signal,
runtimePlatform,
sessionEnv,
packageVersion: inputs.package_version.value as string | undefined,
// A missing or non-positive value disables the idle timeout (opt-in feature).
maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined,
maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined,
capture,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
tasks,
device: iosSimulatorUdid
? {
...iosStartup,
iosSimulatorUdid,
application: applicationReady,
}
: {
booted: booted!,
ready: applicationReady ?? tasks.untilAborted(booted!),
},
});
} finally {
await startupWatch.finishAsync();
tasks.abort(new Error('Agent-device session ended.'));
await Promise.allSettled([downloaded, applicationReady]);
}
}),
});
@@ -20,17 +20,9 @@ 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';
import { registerAndroidEmulatorLogDirectory } from '../utils/androidEmulatorLogs';
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
@@ -42,12 +34,6 @@ 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,
@@ -86,12 +72,6 @@ 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({
@@ -100,40 +80,14 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
}),
],
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);
registerAndroidEmulatorLogDirectory(global, logcatDirectory);
outputs.logcat_directory.set(logcatDirectory);
const deviceName = `${inputs.device_name.value}` as AndroidVirtualDeviceName;
const { serialId, emulatorPromise, shouldAdjustAnimationScale, emulatorCaptureSocketPath } =
const { serialId, emulatorPromise, shouldAdjustAnimationScale } =
await startAndroidEmulatorAsync({
deviceName,
systemImagePackage: `${inputs.system_image_package.value}`,
@@ -143,24 +97,11 @@ 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'], {
@@ -194,7 +135,6 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
deviceName: cloneIdentifier,
env,
logcatDirectory,
logCapture,
});
cloneSerialId = startResult.serialId;
@@ -264,10 +204,8 @@ export async function startAndroidEmulatorAsync({
lcdHeight,
lcdDensity,
logcatDirectory,
logCapture,
env,
logger,
emulatorCaptureLibrary,
}: {
deviceName: AndroidVirtualDeviceName;
systemImagePackage: string;
@@ -276,17 +214,12 @@ 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 });
@@ -334,16 +267,8 @@ 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];
@@ -366,37 +291,11 @@ 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({
@@ -418,7 +317,6 @@ 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 {
@@ -452,19 +350,7 @@ export async function startAndroidEmulatorAsync({
if (!serialId || !emulatorPromise) {
throw new Error(`Failed to start emulator ${deviceName}.`);
}
// 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,
};
return { serialId, emulatorPromise, shouldAdjustAnimationScale };
}
async function assertAndroidEmulatorHostSupportAsync({
@@ -14,8 +14,6 @@ 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';
@@ -24,12 +22,6 @@ import {
withLocalEgressSession,
} from '../utils/localEgressSession';
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import {
WarmSessionAdoptionError,
createWarmDeviceSessionServer,
runOnWarmIosSimulatorAsync,
} from '../utils/warmDeviceSessionHost';
import { type WarmIosSession } from '../utils/warmIosSession';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
import {
createNetworkCaptureInputProviders,
@@ -45,14 +37,9 @@ 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,
@@ -84,22 +71,15 @@ 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,
{ warm = false }: { warm?: boolean } = {}
ctx: CustomBuildContext
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: warm ? 'start_warm_appium_remote_session' : 'start_appium_remote_session',
id: 'start_appium_remote_session',
name: 'Start Appium remote session',
__metricsId: warm ? 'eas/start_warm_appium_remote_session' : 'eas/start_appium_remote_session',
__metricsId: 'eas/start_appium_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
...createIosSessionStartupInputProviders(),
@@ -127,347 +107,252 @@ export function createStartAppiumRemoteSessionBuildFunction(
const packageVersion = inputs.package_version.value as string | undefined;
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
const { runtimePlatform } = global;
const startupStop = new AbortController();
const watchSignal = signal
? AbortSignal.any([signal, startupStop.signal])
: startupStop.signal;
const startupWatch = watchDeviceRunSessionStartup({
ctx,
deviceRunSessionId,
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
installAppPath: inputs.install_app_path?.value as string | undefined,
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
env,
logger,
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
signal,
});
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]);
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
{ runtimePlatform }
);
const versionSpec = resolveAppium3VersionSpec(packageVersion);
logger.info(
`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
const device: AppiumDevice = iosStartup
? {
platformName: 'iOS',
automationName: 'XCUITest',
driverName: 'xcuitest',
udid: iosStartup.iosSimulatorUdid,
}
: await resolveAppiumDeviceAsync({ runtimePlatform, env });
const startupAbortController = new AbortController();
const startupSignal = signal
? AbortSignal.any([signal, startupAbortController.signal])
: startupAbortController.signal;
const setupSignal = iosStartup ? startupSignal : undefined;
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let appiumProcess: ReturnType<typeof spawnDetached> | undefined;
let appiumHome: string | undefined;
let eventCollection: Awaited<ReturnType<typeof startAppiumEventCollectionAsync>> | undefined;
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let sessionFailed = false;
const startHost = () =>
startDeviceSessionHostAsync(ctx, {
...iosStartup,
previewDeviceId: device.udid,
separateLogPhase: true,
runtimePlatform,
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
deviceIdentifier: warmSession?.device.udid ?? deviceIdentifier,
installAppPath: inputs.install_app_path?.value as string | undefined,
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
env,
logger,
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
signal: startupSignal,
});
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
{ runtimePlatform }
);
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}).`
);
const device: AppiumDevice = iosStartup
? {
platformName: 'iOS',
automationName: 'XCUITest',
driverName: 'xcuitest',
udid: iosStartup.iosSimulatorUdid,
}
: await resolveAppiumDeviceAsync({ runtimePlatform, env });
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let appiumProcess: ReturnType<typeof spawnDetached> | undefined;
let appiumHome: string | undefined;
let eventCollection:
| Awaited<ReturnType<typeof startAppiumEventCollectionAsync>>
| undefined;
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let sessionFailed = false;
let sessionError: unknown;
const startHost = () =>
startDeviceSessionHostAsync(
ctx,
{
...iosStartup,
previewDeviceId: device.udid,
separateLogPhase: true,
runtimePlatform,
env,
logger,
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken },
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
signal: startupSignal,
onStartupError: error => startupAbortController.abort(error),
...launch,
networkCapture,
networkCaptureFields,
},
...(warmSession ? [createWarmDeviceSessionServer(ctx, logger, warmSession)] : [])
).catch(error => {
startupAbortController.abort(error);
throw error;
});
try {
onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined,
...launch,
networkCapture,
networkCaptureFields,
}).catch(error => {
if (iosStartup) {
hostStartup = asyncResult(startHost());
startupAbortController.abort(error);
}
// Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the
// simulator stream with it, and UiAutomator2 merges long recordings with it. The
// macOS session image does not ship ffmpeg, so install it concurrently with Appium.
// Both owned installs settle before proceeding; a shared FFmpeg install keeps its owner.
const [appiumInstall] = await Promise.all([
asyncResult(
installAppiumAsync({
versionSpec,
resolveRequestedVersionAsync,
driverName: device.driverName,
env,
logger,
signal: startupSignal,
onError: error => startupAbortController.abort(error),
}).catch(error => {
startupAbortController.abort(error);
throw error;
})
),
asyncResult(
ensureFfmpegInstalledOnceAsync({
runtimePlatform,
env,
logger,
signal: startupSignal,
})
),
]);
const installation = appiumInstall.enforceValue();
// 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({
runtimeIdentifier: iosStartup
? (
await IosSimulatorUtils.getDeviceAsync({
udid: iosStartup.iosSimulatorUdid as IosSimulatorUuid,
env,
})
)?.runtime
: device.runtimeIdentifier,
appiumHome: installation.appiumHome,
prepared: installation.prepared,
appiumBinPath,
appiumEnv,
logger,
signal: startupSignal,
});
startupSignal?.throwIfAborted();
const useFdaHelper =
runtimePlatform === BuildRuntimePlatform.DARWIN && fs.existsSync(FDA_HELPER);
appiumProcess = spawnDetached({
command: useFdaHelper ? FDA_HELPER : appiumBinPath,
args: [
...(useFdaHelper ? [appiumBinPath] : []),
'--address',
APPIUM_HOST,
'--port',
String(APPIUM_PORT),
'--base-path',
'/',
'--log-level',
'error',
// Appium 3 gates session listing (GET /appium/sessions) behind the
// session_discovery insecure feature. We rely on it to poll for
// Appium Event Timings, so enable it for all drivers.
'--allow-insecure',
'*:session_discovery',
'--default-capabilities',
JSON.stringify({
'appium:eventTimings': true,
// Without it, XCUITest restarted the booted Simulator at session start, which
// closed the app serve-sim had launched. EAS VMs never show the Simulator window.
...(device.platformName === 'iOS' ? { 'appium:isHeadless': true } : {}),
...wdaCapabilities,
}),
],
env: appiumEnv,
logger,
});
await waitForAppiumReadyAsync({ appiumProcess, logger, signal: startupSignal });
if (hostStartup) {
sessionHost = (await hostStartup).enforceValue();
}
startupSignal?.throwIfAborted();
eventCollection = await startAppiumEventCollectionAsync({
ctx,
deviceRunSessionId,
appiumUrl: `http://${APPIUM_HOST}:${APPIUM_PORT}/`,
logger,
});
appiumTunnel = await startNgrokTunnelAsync({
port: APPIUM_PORT,
subdomainPrefix: 'appium',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
healthCheck: { path: '/status' },
logger,
signal: startupSignal,
});
// The preview host uses the same device selected for Appium.
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
startupSignal.throwIfAborted();
const webPreview = await sessionHost.openPreviewAsync();
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal: startupSignal,
ctx,
deviceRunSessionId,
remoteConfig: {
appiumUrl: appiumTunnel.url,
capabilities: {
platformName: device.platformName,
'appium:automationName': device.automationName,
'appium:udid': device.udid,
},
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
await startupWatch.completeAsync();
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
}
: undefined,
});
} catch (error) {
if (
error === startupSignal.reason &&
startupSignal.reason instanceof DeviceRunSessionStoppedError
) {
throw startupSignal.reason;
}
sessionFailed = true;
sessionError =
error instanceof AggregateError
? error
: startupSignal.aborted &&
!(startupSignal.reason instanceof DeviceRunSessionStoppedError)
? startupSignal.reason
: error;
throw sessionError;
} finally {
startupAbortController.abort(new Error('Appium session ended.'));
const appiumStopErrors: unknown[] = [];
const appiumTeardown = (async () => {
try {
await eventCollection?.stopAsync();
} finally {
try {
await appiumProcess?.stopAsync();
} catch (error) {
appiumStopErrors.push(error);
throw error;
} finally {
if (appiumHome) {
await fs.promises.rm(appiumHome, { recursive: true, force: true });
}
}
}
})();
await finishRemoteSessionAsync({
logger,
sessionFailed,
teardown: [
['Appium tunnel', appiumTunnel?.stopAsync(), TOOL_TUNNEL_TEARDOWN_OPTIONS],
['Appium server', appiumTeardown],
[
'session host',
hostStartup?.then(async result => {
if (warmSession) {
await Promise.allSettled([appiumTeardown]);
}
if (result.reason instanceof AggregateError) {
throw result.reason;
}
await result.value?.finishAsync();
}),
],
],
});
if (sessionError instanceof WarmSessionAdoptionError && appiumStopErrors.length) {
throw new AggregateError(
[sessionError, ...appiumStopErrors],
'Could not stop Appium after warm adoption failed; a cold session cannot start safely.'
);
}
}
};
throw error;
});
try {
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
if (iosStartup) {
hostStartup = asyncResult(startHost());
}
watchSignal.throwIfAborted();
if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) {
await runOnWarmIosSimulatorAsync(
// 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,
// Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the
// simulator stream with it, and UiAutomator2 merges long recordings with it. The
// macOS session image does not ship ffmpeg, so install it concurrently with Appium.
// Both owned installs settle before proceeding; a shared FFmpeg install keeps its owner.
const [appiumInstall] = await Promise.all([
asyncResult(
installAppiumAsync({
versionSpec,
driverName: device.driverName,
env,
logger,
signal: watchSignal,
signal: setupSignal,
onError: iosStartup ? error => startupAbortController.abort(error) : undefined,
}).catch(error => {
if (iosStartup) {
startupAbortController.abort(error);
}
throw error;
})
),
asyncResult(
ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger, signal: setupSignal })
),
]);
const installation = appiumInstall.enforceValue();
appiumHome = installation.appiumHome;
setupSignal?.throwIfAborted();
const { appiumBinPath, appiumEnv } = installation;
const wdaCapabilities = await preparePreinstalledWdaAsync({
runtimeIdentifier: iosStartup
? (
await IosSimulatorUtils.getDeviceAsync({
udid: iosStartup.iosSimulatorUdid as IosSimulatorUuid,
env,
})
)?.runtime
: device.runtimeIdentifier,
appiumHome,
appiumBinPath,
appiumEnv,
logger,
signal: setupSignal,
});
setupSignal?.throwIfAborted();
const useFdaHelper =
runtimePlatform === BuildRuntimePlatform.DARWIN && fs.existsSync(FDA_HELPER);
appiumProcess = spawnDetached({
command: useFdaHelper ? FDA_HELPER : appiumBinPath,
args: [
...(useFdaHelper ? [appiumBinPath] : []),
'--address',
APPIUM_HOST,
'--port',
String(APPIUM_PORT),
'--base-path',
'/',
'--log-level',
'error',
// Appium 3 gates session listing (GET /appium/sessions) behind the
// session_discovery insecure feature. We rely on it to poll for
// Appium Event Timings, so enable it for all drivers.
'--allow-insecure',
'*:session_discovery',
'--default-capabilities',
JSON.stringify({
'appium:eventTimings': true,
// Without it, XCUITest restarted the booted Simulator at session start, which
// closed the app serve-sim had launched. EAS VMs never show the Simulator window.
...(device.platformName === 'iOS' ? { 'appium:isHeadless': true } : {}),
...wdaCapabilities,
}),
],
env: appiumEnv,
logger,
});
await waitForAppiumReadyAsync({ appiumProcess, logger, signal: setupSignal });
if (hostStartup) {
sessionHost = (await hostStartup).enforceValue();
}
setupSignal?.throwIfAborted();
eventCollection = await startAppiumEventCollectionAsync({
ctx,
deviceRunSessionId,
appiumUrl: `http://${APPIUM_HOST}:${APPIUM_PORT}/`,
logger,
});
appiumTunnel = await startNgrokTunnelAsync({
port: APPIUM_PORT,
subdomainPrefix: 'appium',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
logger,
});
// The preview host uses the same device selected for Appium.
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
const webPreview = await sessionHost.openPreviewAsync({
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
});
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
deviceRunSessionId,
remoteConfig: {
appiumUrl: appiumTunnel.url,
capabilities: {
platformName: device.platformName,
'appium:automationName': device.automationName,
'appium:udid': device.udid,
},
startSessionAsync
);
} else {
await startSessionAsync();
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
}
: undefined,
});
} catch (error) {
if (error !== startupWatch.stopped) {
throw error;
}
sessionFailed = true;
throw setupSignal?.aborted ? setupSignal.reason : error;
} finally {
await startupWatch.finishAsync();
startupAbortController.abort(new Error('Appium session ended.'));
await finishRemoteSessionAsync({
logger,
sessionFailed,
teardown: [
['Appium tunnel', appiumTunnel?.stopAsync()],
[
'Appium server',
(async () => {
try {
await eventCollection?.stopAsync();
} finally {
try {
await appiumProcess?.stopAsync();
} finally {
if (appiumHome) {
await fs.promises.rm(appiumHome, { recursive: true, force: true });
}
}
}
})(),
],
['session host', hostStartup?.then(result => result.value?.finishAsync())],
],
});
}
}),
});
@@ -534,7 +419,6 @@ export async function resolveAppiumDeviceAsync({
export async function installAppiumAsync({
versionSpec,
resolveRequestedVersionAsync,
driverName,
env,
logger,
@@ -542,53 +426,13 @@ 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;
prepared: boolean;
}> {
}): Promise<{ appiumHome: string; appiumBinPath: string; appiumEnv: BuildStepEnv }> {
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();
@@ -641,7 +485,7 @@ export async function installAppiumAsync({
signal
);
}
return { appiumHome, appiumBinPath, appiumEnv, prepared: false };
return { appiumHome, appiumBinPath, appiumEnv };
} catch (error) {
onError?.(error);
if (signal) {
@@ -652,67 +496,6 @@ 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[],
@@ -745,11 +528,11 @@ async function waitForAppiumReadyAsync({
}: {
appiumProcess: { getOutput: () => string };
logger: bunyan;
signal: AbortSignal;
signal?: AbortSignal;
}): Promise<void> {
const deadline = Date.now() + APPIUM_STARTUP_TIMEOUT_MS;
while (Date.now() < deadline) {
signal.throwIfAborted();
signal?.throwIfAborted();
try {
const response = await turtleFetch(`http://${APPIUM_HOST}:${APPIUM_PORT}/status`, 'GET', {
timeout: 2_000,
@@ -757,17 +540,16 @@ async function waitForAppiumReadyAsync({
logger,
signal,
});
signal.throwIfAborted();
signal?.throwIfAborted();
if (response.ok) {
return;
}
} catch {}
signal.throwIfAborted();
try {
signal?.throwIfAborted();
if (signal) {
await setTimeoutAsync(1_000, undefined, { signal });
} catch (error) {
signal.throwIfAborted();
throw error;
} else {
await sleepAsync(1_000);
}
}
const output = appiumProcess.getOutput();
@@ -12,6 +12,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import semver from 'semver';
import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
@@ -20,12 +21,6 @@ import {
withLocalEgressSession,
} from '../utils/localEgressSession';
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import {
createWarmDeviceSessionServer,
runOnWarmIosSimulatorAsync,
} from '../utils/warmDeviceSessionHost';
import { type WarmIosSession } from '../utils/warmIosSession';
import { validateControllerVersion } from '../utils/controllerVersion';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
import {
createNetworkCaptureInputProviders,
@@ -41,10 +36,7 @@ import { isProcessDescendantOfAsync, killProcessGroup } from '../../utils/proces
import { sleepAsync } from '../../utils/retry';
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents';
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
import {
DeviceRunSessionStoppedError,
TOOL_TUNNEL_TEARDOWN_OPTIONS,
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createIosSessionStartupInputProviders,
@@ -92,14 +84,13 @@ const ArgentToolServerStateSchema = z.object({
type ArgentToolServerState = z.infer<typeof ArgentToolServerStateSchema>;
export function createStartArgentRemoteSessionBuildFunction(
ctx: CustomBuildContext,
{ warm = false }: { warm?: boolean } = {}
ctx: CustomBuildContext
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: warm ? 'start_warm_argent_remote_session' : 'start_argent_remote_session',
id: 'start_argent_remote_session',
name: 'Start argent remote session',
__metricsId: warm ? 'eas/start_warm_argent_remote_session' : 'eas/start_argent_remote_session',
__metricsId: 'eas/start_argent_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
...createIosSessionStartupInputProviders(),
@@ -138,396 +129,313 @@ export function createStartArgentRemoteSessionBuildFunction(
// A missing or non-positive value disables the idle timeout (opt-in feature).
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
const versionSpec = packageVersion ?? 'latest';
const { runtimePlatform } = global;
const startupStop = new AbortController();
const watchSignal = signal
? AbortSignal.any([signal, startupStop.signal])
: startupStop.signal;
const startupWatch = watchDeviceRunSessionStartup({
ctx,
deviceRunSessionId,
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
installAppPath: inputs.install_app_path?.value as string | undefined,
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
env,
logger,
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
signal,
});
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
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({
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
{ runtimePlatform }
);
logger.info(
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
// Start the potentially slow installation while Argent is being prepared.
// On Linux expo-device-hub calls this again and awaits the same in-flight
// setup before launching. On macOS this remains non-blocking, so only a
// recording started in the first moments may miss ffmpeg.
// Never rejects, so `void` is safe.
void ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
const argentExec = (args: string[]): { command: string; args: string[] } =>
resolvePackageExec(packageManager, args);
const startupAbortController = new AbortController();
const startupSignal = signal
? AbortSignal.any([signal, startupAbortController.signal])
: startupAbortController.signal;
const setupSignal = iosStartup ? startupSignal : undefined;
const artifactPollAbortController = new AbortController();
let artifactPollingPromise: Promise<void> | undefined;
let eventCollection: Awaited<ReturnType<typeof startArgentEventCollectionAsync>> | undefined;
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let argentServer: ReturnType<typeof spawnDetached> | undefined;
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let sessionFailed = false;
const startHost = () =>
startDeviceSessionHostAsync(ctx, {
...iosStartup,
separateLogPhase: true,
runtimePlatform,
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
deviceIdentifier: warmSession?.device.udid ?? deviceIdentifier,
installAppPath: inputs.install_app_path?.value as string | undefined,
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
env,
logger,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
timeoutMs: STARTUP_TIMEOUT_MS,
signal: startupSignal,
});
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
{ runtimePlatform }
);
logger.info(
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
// Start the potentially slow installation while Argent is being prepared.
// On Linux expo-device-hub calls this again and awaits the same in-flight
// setup before launching. On macOS this remains non-blocking, so only a
// recording started in the first moments may miss ffmpeg.
// Never rejects, so `void` is safe.
void ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
const argentExec = (args: string[]): { command: string; args: string[] } =>
resolvePackageExec(packageManager, args);
const artifactPollAbortController = new AbortController();
let artifactPollingPromise: Promise<void> | undefined;
let eventCollection:
| Awaited<ReturnType<typeof startArgentEventCollectionAsync>>
| undefined;
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let argentServer: ReturnType<typeof spawnDetached> | undefined;
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let sessionFailed = false;
const startHost = () =>
startDeviceSessionHostAsync(
ctx,
{
...iosStartup,
separateLogPhase: true,
runtimePlatform,
env,
logger,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
timeoutMs: STARTUP_TIMEOUT_MS,
preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken },
signal: startupSignal,
onStartupError: error => startupAbortController.abort(error),
...launch,
networkCapture,
networkCaptureFields,
},
...(warmSession ? [createWarmDeviceSessionServer(ctx, logger, warmSession)] : [])
).catch(error => {
startupAbortController.abort(error);
throw error;
});
try {
onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined,
...launch,
networkCapture,
networkCaptureFields,
}).catch(error => {
if (iosStartup) {
hostStartup = asyncResult(startHost());
startupAbortController.abort(error);
}
let toolServerPort: number;
let toolServerToken: string | undefined;
if (warmSession) {
// Adoption prepares the launcher's Argent server for the claimed device.
sessionHost = (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
const tools = warmSession.tools;
if (!tools) {
throw new Error('The warm Argent server was not prepared.');
}
toolServerPort = tools.port;
toolServerToken = tools.token;
} else {
logger.info('Enabling the Argent artifacts list endpoint flag.');
const enableArtifacts = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
]);
await runArgentSetupCommandAsync(
enableArtifacts.command,
enableArtifacts.args,
{ env, logger },
startupSignal
);
logger.info('Enabling the Argent tool-server event log flag.');
const enableEventLog = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_EVENT_LOG_FLAG,
]);
await runArgentSetupCommandAsync(
enableEventLog.command,
enableEventLog.args,
{ env, logger },
startupSignal
);
logger.info('Disabling the Argent video watermark flag.');
const disableVideoWatermark = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'disable',
ARGENT_VIDEO_WATERMARK_FLAG,
]);
// The watermark is cosmetic, so a failure here must not fail the session.
try {
await runArgentSetupCommandAsync(
disableVideoWatermark.command,
disableVideoWatermark.args,
{ env, logger },
startupSignal
);
} catch (err) {
startupSignal?.throwIfAborted();
logger.warn(
{ err },
'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.'
);
}
startupSignal?.throwIfAborted();
const startServer = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'server',
'start',
'--port',
'0',
'--idle-timeout',
'0',
'--force',
]);
logger.info(
`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.`
);
// Keep Argent itself in foreground mode under the detached process. This preserves
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
const secrets: string[] = [];
argentServer = spawnDetached({
command: startServer.command,
args: startServer.args,
env: {
...env,
ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH,
ARGENT_EMULATOR_NO_WINDOW: '1',
},
logger,
secrets,
});
if (argentServer.pid === undefined) {
throw new SystemError(
'Failed to start Argent: could not determine the PID of the launched process.'
);
}
logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`);
try {
const toolServerState = await waitForArgentToolServerStateAsync({
stateDir: ARGENT_STATE_DIR,
ancestorPid: argentServer.pid,
timeoutMs: STARTUP_TIMEOUT_MS,
getExitError: argentServer.getExitError,
signal: startupSignal,
});
toolServerPort = toolServerState.port;
toolServerToken = toolServerState.token;
if (toolServerToken) {
secrets.push(toolServerToken);
}
} catch (err) {
const output = argentServer.getOutput();
throw new SystemError(
`${
err instanceof Error
? err.message
: `Timed out waiting for argent tool-server state.`
}${output ? `\nArgent tool-server output:\n${output}` : ''}`
);
}
if (hostStartup) {
sessionHost = (await hostStartup).enforceValue();
}
startupSignal?.throwIfAborted();
}
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
const artifactPollSignal = signal
? AbortSignal.any([signal, artifactPollAbortController.signal])
: artifactPollAbortController.signal;
artifactPollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
toolsUrl: `http://127.0.0.1:${toolServerPort}`,
toolsAuthToken: toolServerToken,
logger,
signal: artifactPollSignal,
});
eventCollection = await startArgentEventCollectionAsync({
ctx,
deviceRunSessionId,
eventLogPath: warmSession?.argentEventLogPath ?? ARGENT_EVENT_LOG_PATH,
logger,
});
toolsTunnel = await startNgrokTunnelAsync({
port: toolServerPort,
subdomainPrefix: 'argent',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
rewriteHostHeader: true,
healthCheck: { path: '/tools', statuses: [200, 401] },
logger,
signal: startupSignal,
});
const publicToolsUrl = toolsTunnel.url;
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
startupSignal.throwIfAborted();
const webPreview = await sessionHost.openPreviewAsync();
logger.info(
webPreview.sharePageUrl
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal: startupSignal,
ctx,
deviceRunSessionId,
remoteConfig: {
toolsUrl: publicToolsUrl,
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
await startupWatch.completeAsync();
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
maxDurationSeconds,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
}
: undefined,
});
} catch (error) {
if (
startupSignal.reason instanceof DeviceRunSessionStoppedError &&
!(error instanceof AggregateError)
) {
throw startupSignal.reason;
}
sessionFailed = true;
throw error instanceof AggregateError
? error
: startupSignal.aborted &&
!(startupSignal.reason instanceof DeviceRunSessionStoppedError)
? startupSignal.reason
: error;
} finally {
startupAbortController.abort(new Error('Argent session ended.'));
const toolTeardown = (async () => {
try {
if (eventCollection) {
await stopArgentEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
}
artifactPollAbortController.abort();
try {
await artifactPollingPromise;
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not finish Argent remote session artifact polling', error);
logger.warn(
{ err: error },
'Could not finish Argent remote session artifact polling.'
);
}
} finally {
await argentServer?.stopAsync();
}
})();
await finishRemoteSessionAsync({
logger,
sessionFailed,
teardown: [
['Argent tunnel', toolsTunnel?.stopAsync(), TOOL_TUNNEL_TEARDOWN_OPTIONS],
['Argent tool-server', toolTeardown],
[
'session host',
hostStartup?.then(async result => {
if (warmSession) {
// The warm host owns Argent too; leave it alive until the final downloads finish.
await Promise.allSettled([toolTeardown]);
}
if (result.reason instanceof AggregateError) {
throw result.reason;
}
await result.value?.finishAsync();
}),
],
],
});
}
};
try {
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
watchSignal.throwIfAborted();
if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) {
await runOnWarmIosSimulatorAsync(
{
engine: 'argent',
requestedDevice: deviceIdentifier,
packageVersion,
env,
logger,
signal: watchSignal,
},
startSessionAsync
);
} else {
await startSessionAsync();
}
} catch (error) {
if (error !== startupWatch.stopped) {
throw error;
});
try {
if (iosStartup) {
hostStartup = asyncResult(startHost());
}
logger.info('Enabling the Argent artifacts list endpoint flag.');
const enableArtifacts = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
]);
await runArgentSetupCommandAsync(
enableArtifacts.command,
enableArtifacts.args,
{ env, logger },
setupSignal
);
logger.info('Enabling the Argent tool-server event log flag.');
const enableEventLog = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_EVENT_LOG_FLAG,
]);
await runArgentSetupCommandAsync(
enableEventLog.command,
enableEventLog.args,
{ env, logger },
setupSignal
);
logger.info('Disabling the Argent video watermark flag.');
const disableVideoWatermark = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'disable',
ARGENT_VIDEO_WATERMARK_FLAG,
]);
// The watermark is cosmetic, so a failure here must not fail the session.
try {
await runArgentSetupCommandAsync(
disableVideoWatermark.command,
disableVideoWatermark.args,
{ env, logger },
setupSignal
);
} catch (err) {
setupSignal?.throwIfAborted();
logger.warn(
{ err },
'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.'
);
}
setupSignal?.throwIfAborted();
const startServer = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'server',
'start',
'--port',
'0',
'--idle-timeout',
'0',
'--force',
]);
logger.info(
`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.`
);
// Keep Argent itself in foreground mode under the detached process. This preserves
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
const secrets: string[] = [];
argentServer = spawnDetached({
command: startServer.command,
args: startServer.args,
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH, ARGENT_EMULATOR_NO_WINDOW: '1' },
logger,
secrets,
});
if (argentServer.pid === undefined) {
throw new SystemError(
'Failed to start Argent: could not determine the PID of the launched process.'
);
}
logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`);
let toolServerPort: number;
let toolServerToken: string | undefined;
try {
const toolServerState = await waitForArgentToolServerStateAsync({
stateDir: ARGENT_STATE_DIR,
ancestorPid: argentServer.pid,
timeoutMs: STARTUP_TIMEOUT_MS,
getExitError: argentServer.getExitError,
signal: setupSignal,
});
toolServerPort = toolServerState.port;
toolServerToken = toolServerState.token;
if (toolServerToken) {
secrets.push(toolServerToken);
}
} catch (err) {
const output = argentServer.getOutput();
throw new SystemError(
`${
err instanceof Error ? err.message : `Timed out waiting for argent tool-server state.`
}${output ? `\nArgent tool-server output:\n${output}` : ''}`
);
}
if (hostStartup) {
sessionHost = (await hostStartup).enforceValue();
}
setupSignal?.throwIfAborted();
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
const artifactPollSignal = signal
? AbortSignal.any([signal, artifactPollAbortController.signal])
: artifactPollAbortController.signal;
artifactPollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
toolsUrl: `http://127.0.0.1:${toolServerPort}`,
toolsAuthToken: toolServerToken,
logger,
signal: artifactPollSignal,
});
eventCollection = await startArgentEventCollectionAsync({
ctx,
deviceRunSessionId,
eventLogPath: ARGENT_EVENT_LOG_PATH,
logger,
});
toolsTunnel = await startNgrokTunnelAsync({
port: toolServerPort,
subdomainPrefix: 'argent',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
rewriteHostHeader: true,
logger,
});
const publicToolsUrl = toolsTunnel.url;
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
const webPreview = await sessionHost.openPreviewAsync({
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
});
logger.info(
webPreview.sharePageUrl
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
deviceRunSessionId,
remoteConfig: {
toolsUrl: publicToolsUrl,
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
maxDurationSeconds,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
}
: undefined,
});
} catch (error) {
sessionFailed = true;
throw setupSignal?.aborted ? setupSignal.reason : error;
} finally {
await startupWatch.finishAsync();
startupAbortController.abort(new Error('Argent session ended.'));
await finishRemoteSessionAsync({
logger,
sessionFailed,
teardown: [
['Argent tunnel', toolsTunnel?.stopAsync()],
[
'Argent tool-server',
(async () => {
try {
if (eventCollection) {
await stopArgentEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
}
artifactPollAbortController.abort();
try {
await artifactPollingPromise;
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture(
'Could not finish Argent remote session artifact polling',
error
);
logger.warn(
{ err: error },
'Could not finish Argent remote session artifact polling.'
);
}
} finally {
await argentServer?.stopAsync();
}
})(),
],
['session host', hostStartup?.then(result => result.value?.finishAsync())],
],
});
}
}),
});
@@ -555,6 +463,36 @@ export async function stopArgentEventCollectionSafelyAsync({
}
}
export function warnIfArgentPackageVersionCannotBeVerified({
packageVersion,
logger,
}: {
packageVersion: string | undefined;
logger: bunyan;
}): void {
if (!packageVersion || packageVersion === 'latest') {
return;
}
const validVersion = semver.valid(packageVersion);
if (!validVersion) {
logger.warn(
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer, ` +
`but package_version "${packageVersion}" is not an exact semver version that EAS can verify. ` +
`Continuing and letting the package manager resolve it.`
);
return;
}
if (semver.lt(validVersion, MIN_ARGENT_REMOTE_SESSION_VERSION)) {
throw new SystemError(
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer. ` +
`The requested package_version "${packageVersion}" is too old for the EAS remote-session API. ` +
`Use "latest" or pass an exact version >= ${MIN_ARGENT_REMOTE_SESSION_VERSION}.`
);
}
}
async function runArgentSetupCommandAsync(
command: string,
args: string[],
@@ -170,7 +170,6 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
logger,
signal: startupSignal,
}),
startupSignal,
async listener => await listener?.stopAsync(),
@@ -1,11 +1,21 @@
import { ArchiveSourceType, SystemError, UserError } from '@expo/eas-build-job';
import { SystemError, UserError } from '@expo/eas-build-job';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { graphql } from 'gql.tada';
import { stripRepositoryCredentialsAsync } from '../../common/git';
import { CustomBuildContext } from '../../customBuildContext';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import { withLogPhaseAsync } from '../../utils/logPhase';
import { startSandboxDaemonAsync } from '../utils/sandboxDaemon';
import { requireSandboxWorkerApi } from '../utils/sandboxWorkerApi';
const MARK_SANDBOX_READY_MUTATION = graphql(`
mutation MarkSandboxReady($sandboxId: ID!) {
sandbox {
markSandboxReady(sandboxId: $sandboxId) {
id
}
}
}
`);
const RECONNECT_DELAY_MS = 1_000;
const AWAIT_SANDBOX_STEP_NAME = 'Await sandbox completion';
@@ -29,11 +39,16 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
}),
],
fn: async (stepCtx, { inputs, signal, env }) => {
const workerApi = requireSandboxWorkerApi(ctx);
// The daemon needs this credential, but shell commands do not.
const { __EAS_SANDBOX_MCP_TOKEN: sandboxToken, ...commandEnv } = env;
if (!sandboxToken) {
throw new SystemError('__EAS_SANDBOX_MCP_TOKEN is required to start the sandbox daemon.');
}
const mcpServerUrl = ctx.mcpServerUrl;
if (!mcpServerUrl) {
throw new SystemError('MCP server URL is required to start the sandbox daemon.');
}
const sandboxId = String(inputs.sandbox_id.value);
const maxDurationSeconds = inputs.max_duration_seconds?.value;
if (
maxDurationSeconds !== undefined &&
@@ -46,35 +61,21 @@ 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: workerApi.token,
credential: sandboxToken,
serverUrl: mcpServerUrl,
reconnectDelayMs: RECONNECT_DELAY_MS,
logger: stepCtx.logger,
signal,
workingDirectory: stepCtx.workingDirectory,
env,
workerApi,
env: commandEnv,
graphqlClient: ctx.graphqlClient,
sandboxId,
});
try {
await daemon.ready;
signal?.throwIfAborted();
await workerApi.markReadyAsync({ signal }).catch((error: unknown) => {
signal?.throwIfAborted();
throw new SystemError(
`Failed to mark sandbox ${String(inputs.sandbox_id.value)} as ready.`,
{ cause: error }
);
});
await markSandboxReadyAsync(ctx, sandboxId, signal);
signal?.throwIfAborted();
stepCtx.logger.info('Sandbox daemon started.');
@@ -102,6 +103,23 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
});
}
export async function markSandboxReadyAsync(
ctx: CustomBuildContext,
sandboxId: string,
signal?: AbortSignal
): Promise<void> {
signal?.throwIfAborted();
const result = await ctx.graphqlClient
.mutation(MARK_SANDBOX_READY_MUTATION, { sandboxId }, graphqlAbortContext(signal))
.toPromise();
signal?.throwIfAborted();
if (result.error) {
throw new SystemError(`Failed to mark sandbox ${sandboxId} as ready.`, {
cause: result.error,
});
}
}
async function waitForCompletionAsync(signal: AbortSignal): Promise<void> {
if (signal.aborted) {
return;
@@ -1,3 +1,4 @@
import { UserError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
@@ -6,13 +7,7 @@ import {
} from '@expo/steps';
import { CustomBuildContext } from '../../customBuildContext';
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
import { startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import {
createWarmDeviceSessionServer,
runOnWarmIosSimulatorAsync,
} from '../utils/warmDeviceSessionHost';
import { type WarmIosSession } from '../utils/warmIosSession';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
import {
createNetworkCaptureInputProviders,
@@ -43,16 +38,13 @@ import {
const STARTUP_TIMEOUT_MS = 60_000;
export function createStartWebPreviewRemoteSessionBuildFunction(
ctx: CustomBuildContext,
{ warm = false }: { warm?: boolean } = {}
ctx: CustomBuildContext
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: warm ? 'start_warm_serve_sim_remote_session' : 'start_serve_sim_remote_session',
id: 'start_serve_sim_remote_session',
name: 'Start web preview remote session',
__metricsId: warm
? 'eas/start_warm_serve_sim_remote_session'
: 'eas/start_serve_sim_remote_session',
__metricsId: 'eas/start_serve_sim_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
...createIosSessionStartupInputProviders(),
@@ -86,159 +78,113 @@ 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;
const startupStop = new AbortController();
const watchSignal = signal
? AbortSignal.any([signal, startupStop.signal])
: startupStop.signal;
const startupWatch = watchDeviceRunSessionStartup({
ctx,
deviceRunSessionId,
logger,
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
});
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
const startSessionAsync = async (
warmSession?: WarmIosSession,
resolvedPackageVersion = packageVersion
): Promise<void> => {
const startupAbortController = new AbortController();
const startupSignal = AbortSignal.any([watchSignal, startupAbortController.signal]);
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
deviceIdentifier: warmSession?.device.udid ?? deviceIdentifier,
installAppPath: inputs.install_app_path?.value as string | undefined,
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
env,
logger,
signal: startupSignal,
});
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
{ runtimePlatform }
if (
maxIdleTimeMinutes !== undefined &&
maxIdleTimeMinutes > 0 &&
runtimePlatform !== BuildRuntimePlatform.DARWIN
) {
throw new UserError(
'UNSUPPORTED_WEB_PREVIEW_IDLE_TIMEOUT',
'Web preview idle timeouts require iOS preview activity reporting. Omit max_idle_time_minutes for Android sessions.'
);
}
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
installAppPath: inputs.install_app_path?.value as string | undefined,
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
env,
logger,
signal,
});
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
{ runtimePlatform }
);
logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`);
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`);
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
const sessionHost = await startDeviceSessionHostAsync(
ctx,
{
...iosStartup,
runtimePlatform,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken },
signal: startupSignal,
onStartupError: error => startupAbortController.abort(error),
packageVersion: resolvedPackageVersion,
emulatorCapture: true,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
networkCapture,
networkCaptureFields,
},
...(warmSession ? [createWarmDeviceSessionServer(ctx, logger, warmSession)] : [])
).catch(error => {
if (startupSignal.reason === startupWatch.stopped && !(error instanceof AggregateError)) {
throw startupWatch.stopped;
}
throw error;
});
const sessionHost = await startDeviceSessionHostAsync(ctx, {
...iosStartup,
runtimePlatform,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
signal,
packageVersion,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
networkCapture,
networkCaptureFields,
});
try {
startupSignal.throwIfAborted();
const webPreview = await sessionHost.openPreviewAsync();
logger.info(
webPreview.sharePageUrl
? `Preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal: startupSignal,
ctx,
deviceRunSessionId,
// WEB_PREVIEW_ONLY sessions read webPreviewUrl and webPreviewToken. Legacy SERVE_SIM
// sessions read previewUrl and previewToken. The server drops the keys that the
// session type does not use.
remoteConfig: {
webPreviewUrl: webPreview.previewPageUrl,
previewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken
? {
webPreviewToken: webPreview.previewToken,
previewToken: webPreview.previewToken,
}
: {}),
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
await startupWatch.completeAsync();
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
maxDurationSeconds,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: () => undefined,
getLastPreviewActivityAt: () => sessionHost.getLastPreviewActivityAt(),
}
: undefined,
});
} finally {
await sessionHost.finishAsync();
}
};
try {
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
watchSignal.throwIfAborted();
if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) {
await runOnWarmIosSimulatorAsync(
{
engine: 'serve-sim',
requestedDevice: deviceIdentifier,
packageVersion,
env,
logger,
signal: watchSignal,
},
startSessionAsync
);
} else {
await startSessionAsync();
}
} catch (error) {
if (error !== startupWatch.stopped) {
throw error;
const webPreview = await sessionHost.openPreviewAsync({
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
});
logger.info(
webPreview.sharePageUrl
? `Preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
deviceRunSessionId,
// WEB_PREVIEW_ONLY sessions read webPreviewUrl and webPreviewToken. Legacy SERVE_SIM
// sessions read previewUrl and previewToken. The server drops the keys that the
// session type does not use.
remoteConfig: {
webPreviewUrl: webPreview.previewPageUrl,
previewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken
? { webPreviewToken: webPreview.previewToken, previewToken: webPreview.previewToken }
: {}),
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
maxDurationSeconds,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: () => undefined,
getLastPreviewActivityAt: () => sessionHost.getLastPreviewActivityAt(),
}
: undefined,
});
} finally {
await startupWatch.finishAsync();
await sessionHost.finishAsync();
}
}),
});
@@ -1,224 +0,0 @@
import { BuildJob, Platform } from '@expo/eas-build-job';
import {
BuildFunction,
BuildStepInput,
BuildStepInputValueTypeName,
spawnAsync,
} from '@expo/steps';
import { XMLParser } from 'fast-xml-parser';
import fs from 'fs-extra';
import { Client } from '@urql/core';
import { graphql } from 'gql.tada';
import path from 'node:path';
import { parseInfoPlistBuffer, readIpaInfoAsync } from './readIpaInfo';
import { CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
export function createUpdateResolvedVersionBuildFunction(
ctx: CustomBuildContext<BuildJob>
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'update_resolved_version',
name: 'Update resolved version',
__metricsId: 'eas/update_resolved_version',
inputProviders: [
BuildStepInput.createProvider({
id: 'application_archive_path',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async (stepCtx, { inputs }) => {
try {
const archivePathInput = inputs.application_archive_path.value as string | undefined;
if (!archivePathInput) {
stepCtx.logger.info('No application archive path provided, skipping.');
return;
}
const archivePath = path.resolve(stepCtx.workingDirectory, archivePathInput);
const buildId = ctx.env.EAS_BUILD_ID;
if (!buildId) {
stepCtx.logger.info('EAS_BUILD_ID not set, skipping.');
return;
}
const { appVersion, appBuildVersion } =
ctx.job.platform === Platform.IOS
? await extractIosVersionAsync(archivePath)
: await extractAndroidVersionAsync(archivePath);
if (!appVersion && !appBuildVersion) {
stepCtx.logger.info('No resolved version found, skipping.');
return;
}
stepCtx.logger.info(
`Resolved version: ${appVersion ?? 'N/A'} (${appBuildVersion ?? 'N/A'})`
);
await updateResolvedVersionAsync(ctx.graphqlClient, buildId, {
appVersion,
appBuildVersion,
});
stepCtx.logger.info('Updated resolved version on EAS.');
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Failed to update resolved version', error);
stepCtx.logger.warn({ err: error }, 'Failed to update resolved version (non-fatal)');
}
},
});
}
export async function extractIosVersionAsync(
archivePath: string
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
const ext = path.extname(archivePath).toLowerCase();
if (ext === '.app') {
return await extractSimulatorAppVersionAsync(archivePath);
}
const ipaInfo = await readIpaInfoAsync(archivePath);
return {
appVersion: ipaInfo.bundleShortVersion,
appBuildVersion: ipaInfo.bundleVersion,
};
}
async function extractSimulatorAppVersionAsync(
appPath: string
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
const infoPlistPath = path.join(appPath, 'Info.plist');
if (!(await fs.pathExists(infoPlistPath))) {
return {};
}
const infoPlistBuffer = await fs.readFile(infoPlistPath);
const infoPlist = parseInfoPlistBuffer(infoPlistBuffer);
return {
appVersion:
typeof infoPlist.CFBundleShortVersionString === 'string'
? infoPlist.CFBundleShortVersionString
: undefined,
appBuildVersion:
typeof infoPlist.CFBundleVersion === 'string' ? infoPlist.CFBundleVersion : undefined,
};
}
export async function extractAndroidVersionAsync(
archivePath: string
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
const ext = path.extname(archivePath).toLowerCase();
if (ext === '.apk') {
return await extractVersionFromApkAsync(archivePath);
} else if (ext === '.aab') {
return await extractVersionFromAabAsync(archivePath);
}
return {};
}
async function extractVersionFromApkAsync(
apkPath: string
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
const result = await spawnAsync('aapt2', ['dump', 'badging', apkPath], {
stdio: 'pipe',
});
return parseAaptOutput(result.stdout);
}
async function extractVersionFromAabAsync(
aabPath: string
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
const result = await spawnAsync('bundletool', ['dump', 'manifest', '--bundle', aabPath], {
stdio: 'pipe',
});
return parseManifestXml(result.stdout);
}
export function parseAaptOutput(output: string): { appVersion?: string; appBuildVersion?: string } {
// aapt2 does not escape apostrophes inside versionName, so stop at the next known
// badging field (or the end of the line) rather than at the first closing quote.
const versionNameMatch = output.match(
/versionName='(.*?)'(?= split=| platformBuildVersionName=| platformBuildVersionCode=| compileSdkVersion=|\s*$)/m
);
const versionCodeMatch = output.match(/versionCode='([^']+)'/);
return {
appVersion: versionNameMatch?.[1] || undefined,
appBuildVersion: versionCodeMatch?.[1],
};
}
export function parseManifestXml(xml: string): { appVersion?: string; appBuildVersion?: string } {
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_' });
const parsed = parser.parse(xml);
const manifest = parsed?.manifest;
if (!manifest) {
return {};
}
const versionName = manifest['@_android:versionName'];
const versionCode = manifest['@_android:versionCode'];
return {
appVersion: versionName != null ? String(versionName) : undefined,
appBuildVersion: versionCode != null ? String(versionCode) : undefined,
};
}
export async function updateResolvedVersionAsync(
graphqlClient: Client,
buildId: string,
{
appVersion,
appBuildVersion,
}: {
appVersion: string | undefined;
appBuildVersion: string | undefined;
}
): Promise<void> {
const result = await graphqlClient
.mutation(
graphql(`
mutation UpdateResolvedVersionMutation(
$buildId: ID!
$appVersion: String
$appBuildVersion: String
) {
build {
updateBuildMetadata(
buildId: $buildId
metadata: {
appVersion: $appVersion
appBuildVersion: $appBuildVersion
}
) {
id
}
}
}
`),
{
buildId,
appVersion,
appBuildVersion,
}
)
.toPromise();
if (result.error) {
throw result.error;
}
}
@@ -57,19 +57,11 @@ type ActiveIosSimulatorRecording = IosSimulatorRecording & {
hasExited: () => boolean;
};
type AdoptedIosSimulatorRecording = {
udid: string;
serverToken: string;
collectAsync(): Promise<void>;
};
type IosSimulatorRecordingSession = {
env: Env;
logger: bunyan;
recordingsRootDirectory: string;
activeRecordings: Map<IosSimulatorUuid, ActiveIosSimulatorRecording>;
adoptedRecordings: Map<string, AdoptedIosSimulatorRecording>;
runtimeDisplayNames: Map<string, string>;
completedRecordings: IosSimulatorRecording[];
recordingFailureCounts: Map<IosSimulatorUuid, number>;
/** Failed attempts for the whole session; unlike recordingFailureCounts, never reset. */
@@ -89,52 +81,6 @@ export namespace IosSimulatorRecordingUtils {
serveSimPackageSpec ??= packageSpec;
}
/**
* Reserves this job's recording output before warm serve-sim adoption, which starts the
* recording. The poller skips that device, so it never starts a second recorder.
*/
export async function prepareAdoptedRecordingAsync({
udid,
deviceName,
serverToken,
}: {
udid: string;
deviceName: string;
serverToken: string;
}): Promise<{ output: string; recordingId: string } | undefined> {
const session = activeIosSimulatorRecordingSession;
if (!session) {
return undefined;
}
udid = udid.toUpperCase();
const recordingId = randomUUID();
const output = path.join(session.recordingsRootDirectory, recordingId);
const startedAt = new Date();
await mkdir(output, { recursive: true });
session.adoptedRecordings.set(recordingId, {
udid,
serverToken,
async collectAsync() {
// Keep real footage eligible for the manifest wait if serve-sim shutdown was unconfirmed.
// The uploader preserves the manifest's partial or failed status.
await access(path.join(output, 'session.json')).catch(() =>
access(path.join(output, 'recording.mp4'))
);
session.completedRecordings.push({
id: recordingId,
udid: udid as IosSimulatorUuid,
deviceName,
runtimeDisplayName: session.runtimeDisplayNames.get(udid) ?? 'Unknown iOS runtime',
outputDirectory: output,
startedAt,
getOutput: () => '',
hasStarted: () => true,
});
},
});
return { output, recordingId };
}
export async function startAsync({ env, logger }: { env: Env; logger: bunyan }): Promise<void> {
if (activeIosSimulatorRecordingSession) {
logger.info('iOS Simulator screen recording polling is already running.');
@@ -149,8 +95,6 @@ export namespace IosSimulatorRecordingUtils {
logger,
recordingsRootDirectory,
activeRecordings: new Map(),
adoptedRecordings: new Map(),
runtimeDisplayNames: new Map(),
completedRecordings: [],
recordingFailureCounts: new Map(),
failedAttemptCounts: new Map(),
@@ -246,17 +190,6 @@ export namespace IosSimulatorRecordingUtils {
})
);
await Promise.all(
[...session.adoptedRecordings.values()].map(recording =>
recording.collectAsync().catch(err => {
logger.warn(
{ err },
'Could not collect the warm Simulator video recording after shutdown.'
);
})
)
);
const completedRecordings = [...session.completedRecordings].sort(
(a, b) => a.startedAt.getTime() - b.startedAt.getTime()
);
@@ -310,9 +243,6 @@ async function pollIosSimulatorRecordingsAsync(
break;
}
listDevicesErrorCount = 0;
for (const device of bootedDevices) {
session.runtimeDisplayNames.set(device.udid.toUpperCase(), device.runtimeDisplayName);
}
const readyServers = new Map(
(await readServeSimServersAsync(SERVE_SIM_STATE_DIR)).flatMap(server =>
server.token ? [[server.udid, server.token] as const] : []
@@ -332,14 +262,7 @@ async function pollIosSimulatorRecordingsAsync(
for (const device of bootedDevices) {
const token = readyServers.get(device.udid);
const packageSpec = serveSimPackageSpec;
if (
!token ||
!packageSpec ||
[...session.adoptedRecordings.values()].some(
recording =>
recording.udid === device.udid.toUpperCase() && recording.serverToken === token
)
) {
if (!token || !packageSpec) {
continue;
}
if (session.serveBearerTokens.get(device.udid) !== token) {
@@ -3,20 +3,18 @@ import { type bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import { type ChildProcess } from 'node:child_process';
import { EventEmitter } from 'node:events';
import { readFile, rm, writeFile } from 'node:fs/promises';
import { rm, writeFile } from 'node:fs/promises';
import { PassThrough } from 'node:stream';
import { setTimeout } from 'node:timers/promises';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils';
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
jest.mock('@expo/turtle-spawn');
jest.mock('../serveSimMetricsRecorder');
jest.mock('../../../sentry');
jest.mock('../../../utils/turtleFetch');
const logger = {
info: jest.fn(),
@@ -568,171 +566,3 @@ test('records every booted device with the first serve-sim package', async () =>
currentTime += 311_000;
await IosSimulatorRecordingUtils.finishAsync({ logger });
});
test('does not request adopted video when job recording is disabled', async () => {
await expect(
IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
udid,
deviceName: 'iPhone 17 Pro',
serverToken: 'job-token',
})
).resolves.toBeUndefined();
expect(turtleFetch).not.toHaveBeenCalled();
});
test.each(['complete', 'failed'])(
'collects an adopted %s recording unchanged after host shutdown without a control request',
async status => {
let releaseDevices!: (devices: never[]) => void;
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockImplementation(
() =>
new Promise(resolve => {
releaseDevices = resolve as never;
})
);
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:3200', token: 'job-token' }]);
await IosSimulatorRecordingUtils.startAsync({ env, logger });
const recording = await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
udid,
deviceName: 'iPhone 17 Pro',
serverToken: 'job-token',
});
if (!recording) {
throw new Error('Expected the adopted recording output.');
}
releaseDevices([{ udid, name: 'iPhone 17 Pro', runtimeDisplayName: 'iOS 26.4' }] as never);
await setTimeout(20);
expect(spawn).not.toHaveBeenCalled();
const manifest = JSON.stringify({
recording: 'recording.mp4',
status,
...(status === 'failed' && { error: 'Recording interrupted; partial footage preserved.' }),
});
await writeFile(`${recording.output}/session.json`, manifest);
jest.mocked(turtleFetch).mockRejectedValue(new Error('serve-sim has stopped'));
expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toEqual([
{
udid,
deviceName: 'iPhone 17 Pro',
runtimeDisplayName: 'iOS 26.4',
directory: recording.output,
},
]);
expect(await readFile(`${recording.output}/session.json`, 'utf8')).toBe(manifest);
expect(turtleFetch).not.toHaveBeenCalled();
}
);
test('waits for an adopted recording manifest when its MP4 already exists', async () => {
jest.mocked(readServeSimServersAsync).mockResolvedValue([]);
await IosSimulatorRecordingUtils.startAsync({ env, logger });
const recording = await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
udid,
deviceName: 'iPhone 17 Pro',
serverToken: 'job-token',
});
if (!recording) {
throw new Error('Expected the adopted recording output.');
}
await writeFile(`${recording.output}/recording.mp4`, 'partial footage');
let finished = false;
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger }).then(recordings => {
finished = true;
return recordings;
});
await setTimeout(20);
expect(finished).toBe(false);
const manifest = JSON.stringify({ recording: 'recording.mp4' });
await writeFile(`${recording.output}/session.json`, manifest);
expect(await finishing).toEqual([
{
udid,
deviceName: 'iPhone 17 Pro',
runtimeDisplayName: 'iOS 26.4',
directory: recording.output,
},
]);
expect(await readFile(`${recording.output}/session.json`, 'utf8')).toBe(manifest);
expect(logger.warn).not.toHaveBeenCalled();
expect(turtleFetch).not.toHaveBeenCalled();
expect(spawn).not.toHaveBeenCalled();
});
test('bounds the manifest wait for an adopted MP4 and reports missing metadata', async () => {
let currentTime = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
jest.mocked(readServeSimServersAsync).mockResolvedValue([]);
await IosSimulatorRecordingUtils.startAsync({ env, logger });
const recording = await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
udid,
deviceName: 'iPhone 17 Pro',
serverToken: 'job-token',
});
if (!recording) {
throw new Error('Expected the adopted recording output.');
}
await writeFile(`${recording.output}/recording.mp4`, 'partial footage');
let finished = false;
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger }).then(recordings => {
finished = true;
return recordings;
});
await setTimeout(50);
expect(finished).toBe(false);
currentTime += 311_000;
expect(await finishing).toEqual([]);
expect(logger.warn).toHaveBeenCalledWith(
{ recorderOutput: '' },
'Screen recording for iPhone 17 Pro ended without a manifest; its footage cannot be uploaded.'
);
expect(Sentry.capture).toHaveBeenCalledWith(
'iOS Simulator screen recording ended without a manifest',
{ level: 'warning', extras: { deviceName: 'iPhone 17 Pro' } }
);
expect(turtleFetch).not.toHaveBeenCalled();
expect(spawn).not.toHaveBeenCalled();
});
test('does not wait for an empty adopted recording reservation', async () => {
jest.mocked(readServeSimServersAsync).mockResolvedValue([]);
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
udid,
deviceName: 'iPhone',
serverToken: 'job-token',
});
expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toEqual([]);
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'Could not collect the warm Simulator video recording after shutdown.'
);
expect(turtleFetch).not.toHaveBeenCalled();
});
test('a reserved adopted recording does not suppress recording a replacement server token', async () => {
let releaseDevices!: (devices: never[]) => void;
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockImplementationOnce(
() =>
new Promise(resolve => {
releaseDevices = resolve as never;
})
);
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:3200', token: 'replacement-token' }]);
mockFailingRecorder();
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
udid,
deviceName: 'iPhone',
serverToken: 'old-token',
});
jest.mocked(turtleFetch).mockRejectedValue(new Error('old server stopped'));
releaseDevices([{ udid, name: 'iPhone', runtimeDisplayName: 'iOS 26.4' }] as never);
await waitForRecorderSpawnAsync();
expect(spawn).toHaveBeenCalledTimes(1);
});
@@ -1,7 +1,6 @@
import { bunyan } from '@expo/logger';
import fetch from 'node-fetch';
import { Readable } from 'node:stream';
import timers from 'node:timers/promises';
import { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
@@ -16,7 +15,6 @@ jest.mock('../deviceRunSessionArtifacts');
jest.mock('../../../sentry');
jest.mock('node-fetch');
const sleepWithSignal = timers.setTimeout;
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
async function readStreamAsync(stream: NodeJS.ReadableStream): Promise<void> {
@@ -217,12 +215,8 @@ describe(uploadAgentDeviceArtifactAsync, () => {
);
});
it('uploads the screenshot size, source and display rotation as artifact metadata', async () => {
const data = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aF9sAAAAASUVORK5CYII=',
'base64'
);
const logger = createLoggerMock();
it('uploads a screenshot display rotation as artifact metadata', async () => {
const data = Buffer.from('png-data');
const ctx = {} as unknown as CustomBuildContext;
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
@@ -236,7 +230,7 @@ describe(uploadAgentDeviceArtifactAsync, () => {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'daemon-token',
logger,
logger: createLoggerMock(),
artifact: {
id: 'artifact-id',
artifactType: 'screenshot',
@@ -249,48 +243,12 @@ describe(uploadAgentDeviceArtifactAsync, () => {
ctx,
expect.objectContaining({
kind: 'screenshot',
metadata: {
source: 'agent-device',
displayRotation: 'landscape-right',
width: 1,
height: 1,
},
metadata: { displayRotation: 'landscape-right' },
})
);
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 for a non-screenshot artifact without a display rotation', async () => {
it('uploads no metadata when the artifact has no display rotation', async () => {
const data = Buffer.from('artifact-data');
const ctx = {} as unknown as CustomBuildContext;
@@ -306,7 +264,7 @@ describe(uploadAgentDeviceArtifactAsync, () => {
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'daemon-token',
logger: createLoggerMock(),
artifact: { id: 'artifact-id', artifactType: 'screen-recording', filename: 'session.mp4' },
artifact: { id: 'artifact-id', artifactType: 'screenshot', filename: 'shot.png' },
});
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls[0][1]).not.toHaveProperty(
@@ -318,14 +276,6 @@ describe(uploadAgentDeviceArtifactAsync, () => {
describe(pollAgentDeviceArtifactsForUploadAsync, () => {
beforeEach(() => {
jest.useFakeTimers();
// Keep fake-clock tests deterministic; cancellation tests use the native abortable timer.
jest
.spyOn(timers, 'setTimeout')
.mockImplementation((delay, value, options) =>
options?.signal
? sleepWithSignal(delay, value, options)
: new Promise(resolve => setTimeout(resolve, delay, value))
);
jest.mocked(fetch).mockReset();
jest.mocked(Sentry.capture).mockReset();
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockReset();
@@ -334,257 +284,6 @@ describe(pollAgentDeviceArtifactsForUploadAsync, () => {
afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
jest.restoreAllMocks();
});
it('cancels a warm poller during its wait without another inventory request', async () => {
jest.useRealTimers();
const controller = new AbortController();
jest.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ artifacts: [] })));
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'token',
logger: createLoggerMock(),
signal: controller.signal,
});
await new Promise(resolve => setImmediate(resolve));
controller.abort();
await polling;
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ signal: controller.signal })
);
});
it('cancels an in-flight warm artifact transfer and drains the poller', async () => {
jest.useRealTimers();
const controller = new AbortController();
let downloading!: () => void;
const started = new Promise<void>(resolve => {
downloading = resolve;
});
jest
.mocked(fetch)
.mockResolvedValueOnce(
new Response(JSON.stringify({ artifacts: [{ id: 'new-artifact', filename: 'new.png' }] }))
)
.mockImplementationOnce(async (_url, options) => {
const signal = options?.signal as AbortSignal;
expect(signal).toBe(controller.signal);
downloading();
return await new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
});
});
const logger = createLoggerMock();
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'token',
logger,
signal: controller.signal,
});
await started;
controller.abort(new Error('session ended'));
await polling;
expect(fetch).toHaveBeenCalledTimes(2);
expect(logger.warn).not.toHaveBeenCalled();
expect(uploadDeviceRunSessionArtifactAsync).not.toHaveBeenCalled();
});
it('finishes an active cloud upload and collects final artifacts after normal stop', async () => {
jest.useRealTimers();
const stopPolling = new AbortController();
const cancellation = new AbortController();
let releaseUpload!: () => void;
let uploadStarted!: () => void;
const pendingUpload = new Promise<void>(resolve => {
releaseUpload = resolve;
});
const started = new Promise<void>(resolve => {
uploadStarted = resolve;
});
let inventories = 0;
const first = { id: 'first', filename: 'first.png' };
const last = { id: 'last', filename: 'last.png' };
jest
.mocked(fetch)
.mockImplementation(async url =>
String(url).endsWith('/artifacts')
? new Response(
JSON.stringify({ artifacts: ++inventories === 1 ? [first] : [first, last] })
)
: new Response(Readable.from(['png']))
);
let transferSignal: AbortSignal | undefined;
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_ctx, options) => {
await readStreamAsync(options.openStream());
if (options.artifactId === 'first') {
transferSignal = options.signal;
uploadStarted();
await pendingUpload;
}
});
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'token',
logger: createLoggerMock(),
signal: cancellation.signal,
stopPollingSignal: stopPolling.signal,
});
let finished = false;
void polling.then(() => {
finished = true;
});
try {
await started;
stopPolling.abort();
await new Promise(resolve => setImmediate(resolve));
expect(transferSignal?.aborted).toBe(false);
expect(finished).toBe(false);
releaseUpload();
await polling;
expect(inventories).toBe(2);
expect(
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mock.calls.map(([, options]) => options.artifactId)
).toEqual(['first', 'last']);
} finally {
releaseUpload();
cancellation.abort();
await polling;
}
});
it('checks for an artifact created between the last poll and normal stop', async () => {
jest.useRealTimers();
const stopPolling = new AbortController();
jest
.mocked(fetch)
.mockResolvedValueOnce(new Response(JSON.stringify({ artifacts: [] })))
.mockResolvedValueOnce(
new Response(JSON.stringify({ artifacts: [{ id: 'last', filename: 'last.png' }] }))
)
.mockResolvedValueOnce(new Response(Readable.from(['png'])));
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_ctx, options) => {
await readStreamAsync(options.openStream());
});
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'token',
logger: createLoggerMock(),
stopPollingSignal: stopPolling.signal,
});
await new Promise(resolve => setImmediate(resolve));
stopPolling.abort();
await polling;
expect(fetch).toHaveBeenCalledTimes(3);
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ artifactId: 'last' })
);
});
it('reports a failed final inventory after normal stop', async () => {
jest.useRealTimers();
const stopPolling = new AbortController();
const logger = createLoggerMock();
const error = new Error('daemon is not ready');
jest
.mocked(fetch)
.mockResolvedValueOnce(new Response(JSON.stringify({ artifacts: [] })))
.mockRejectedValueOnce(error);
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'token',
logger,
stopPollingSignal: stopPolling.signal,
});
await new Promise(resolve => setImmediate(resolve));
stopPolling.abort();
await polling;
expect(fetch).toHaveBeenCalledTimes(2);
expect(logger.warn).toHaveBeenCalledWith(
{ err: error, failedArtifactListCount: 1 },
'Could not list agent-device remote session artifacts.'
);
expect(Sentry.capture).toHaveBeenCalledTimes(1);
});
it('cancels a graceful collector immediately when the job is cancelled', async () => {
jest.useRealTimers();
const stopPolling = new AbortController();
const cancellation = new AbortController();
let entered!: () => void;
const started = new Promise<void>(resolve => {
entered = resolve;
});
jest.mocked(fetch).mockImplementation(async (_url, options) => {
const signal = options?.signal as AbortSignal;
entered();
return await new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
});
});
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'token',
logger: createLoggerMock(),
signal: cancellation.signal,
stopPollingSignal: stopPolling.signal,
});
await started;
cancellation.abort();
await polling;
expect(stopPolling.signal.aborted).toBe(false);
expect(fetch).toHaveBeenCalledTimes(1);
});
it('cancels a stuck transfer after the normal-stop drain budget expires', async () => {
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
const stopPolling = new AbortController();
const logger = createLoggerMock();
let entered!: () => void;
const started = new Promise<void>(resolve => {
entered = resolve;
});
jest
.mocked(fetch)
.mockResolvedValueOnce(
new Response(JSON.stringify({ artifacts: [{ id: 'large', filename: 'large.mp4' }] }))
)
.mockResolvedValueOnce(new Response(Readable.from(['video'])));
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_ctx, options) => {
await readStreamAsync(options.openStream());
entered();
return await new Promise((_resolve, reject) => {
options.signal!.addEventListener('abort', () => reject(options.signal!.reason), {
once: true,
});
});
});
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'token',
logger,
stopPollingSignal: stopPolling.signal,
});
await started;
stopPolling.abort();
await jest.advanceTimersByTimeAsync(30_000);
await polling;
expect(fetch).toHaveBeenCalledTimes(2);
expect(logger.warn).toHaveBeenCalledWith(
'Timed out draining agent-device artifacts; some session artifacts may not have been uploaded.'
);
});
it('reports artifact listing errors on every fifth consecutive failure', async () => {
@@ -9,8 +9,10 @@ import RemoteLoggerStream from '../../../logging/RemoteLoggerStream';
import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
import { startAgentDeviceEventCollectionAsync } from '../agentDeviceEvents';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
import { WorkerApiError } from '../workerApi';
import {
DEVICE_RUN_SESSION_TOKEN_HEADER,
DeviceRunSessionWorkerApiError,
} from '../deviceRunSessionWorkerApi';
const mockEventLogStream = {
init: jest.fn(async () => undefined),
@@ -575,7 +577,7 @@ describe(startAgentDeviceEventCollectionAsync, () => {
'Failed to create device run session event log upload session: ' +
'POST /v2/device-run-session-worker/event-log-upload-session failed with status 503: ' +
'WWW unavailable',
cause: expect.any(WorkerApiError),
cause: expect.any(DeviceRunSessionWorkerApiError),
}),
},
'Could not persist device run session events to the artifact.'
@@ -587,7 +589,7 @@ describe(startAgentDeviceEventCollectionAsync, () => {
'Failed to create device run session event log upload session: ' +
'POST /v2/device-run-session-worker/event-log-upload-session failed with status 503: ' +
'WWW unavailable',
cause: expect.any(WorkerApiError),
cause: expect.any(DeviceRunSessionWorkerApiError),
}),
{
level: 'warning',
@@ -1,45 +1,24 @@
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 fs from 'node:fs';
import fsPromises, { mkdir, readFile, readdir, 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,
registerAndroidEmulatorLogDirectory,
} 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 directory = path.join(os.tmpdir(), 'android-logs');
const emulatorPath = path.join(directory, 'device-attempt-1-emulator.log');
const logcatPath = path.join(directory, 'device-attempt-1-logcat.log');
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>;
@@ -48,11 +27,7 @@ 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');
await mkdir(directory, { recursive: true });
upload.mockReset().mockImplementation(async (_ctx, { filename, openStream }) => {
const chunks: Buffer[] = [];
for await (const chunk of openStream()) {
@@ -62,87 +37,40 @@ beforeEach(async () => {
});
});
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> {
function collect(maxBytes?: number): Promise<void> {
return collectAndroidEmulatorLogsAsync(ctx, {
global,
deviceRunSessionId: 'session',
logger,
maxBytes,
});
}
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');
it('uploads both logs for every attempt from registered directories only', async () => {
await writeFile(emulatorPath, 'host output');
await writeFile(logcatPath, 'Android output');
await writeFile(path.join(directory, 'device-attempt-2-emulator.log'), 'retry output');
await writeFile(path.join(directory, 'unrelated.txt'), 'not a log');
const elsewhere = path.join(os.tmpdir(), 'unregistered');
await mkdir(elsewhere);
await writeFile(path.join(elsewhere, 'other-emulator.log'), 'another job');
await symlink(path.join(elsewhere, 'other-emulator.log'), logcatPath);
await symlink(
path.join(elsewhere, 'other-emulator.log'),
path.join(directory, 'link-logcat.log')
);
registerAndroidEmulatorLogDirectory(global, directory);
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([...uploadedContents.values()]).toEqual(['host output', 'Android output', 'retry output']);
expect(upload).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
filename: 'emulator-output.log',
filename: 'device-attempt-1-emulator.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),
@@ -152,28 +80,48 @@ it('uploads one artifact per source and ignores files outside the capture invent
expect(upload).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
filename: 'device-logcat.log',
filename: 'device-attempt-1-logcat.log',
metadata: expect.objectContaining({ source: 'device-logcat', scope: 'device' }),
})
);
expect(await readdir(os.tmpdir())).not.toEqual(
expect.arrayContaining([expect.stringMatching(/^eas-android-log-snapshot-/)])
);
});
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
it('keeps the latest bytes and records truncation', async () => {
await writeFile(emulatorPath, 'old output\ncrash\n');
registerAndroidEmulatorLogDirectory(global, directory);
await collect(6);
expect(uploadedContents.get(path.basename(emulatorPath))).toBe('crash\n');
expect(upload).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
size: 6,
name: expect.stringContaining('truncated, 6 bytes retained'),
metadata: expect.objectContaining({
originalSizeBytes: 17,
omittedBytes: 11,
truncated: true,
}),
})
);
capture.attach(input, emulatorPath, 'emulator-output');
input.write('before');
expect(await readFile(emulatorPath, 'utf8')).toBe('old output\ncrash\n');
});
it('fixes the snapshot range before append and keeps upload retries identical', async () => {
await writeFile(emulatorPath, 'before');
registerAndroidEmulatorLogDirectory(global, directory);
const createReadStream = fs.createReadStream;
const read = jest.spyOn(fs, 'createReadStream').mockImplementationOnce((filePath, options) => {
fs.appendFileSync(emulatorPath, ' during snapshot');
return createReadStream(filePath, options);
});
const contents: string[] = [];
upload.mockImplementationOnce(async (_ctx, { size, openStream, metadata }) => {
expect(metadata).toEqual(
expect.objectContaining({ originalSizeBytes: size, truncated: false })
);
upload.mockImplementationOnce(async (_ctx, { size, openStream }) => {
expect(size).toBe(6);
for (let attempt = 0; attempt < 2; attempt++) {
input.write(' ignored after finalization');
await writeFile(emulatorPath, 'rewritten after snapshot');
const chunks: Buffer[] = [];
for await (const chunk of openStream()) {
chunks.push(Buffer.from(chunk));
@@ -183,40 +131,75 @@ it('seals a live producer before upload and keeps retry bodies identical without
});
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' });
expect(contents).toEqual(['before', 'before']);
} finally {
input.end();
read.mockRestore();
}
});
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');
it('continues after a failed upload and retries only unsuccessful files on another collection', async () => {
await writeFile(emulatorPath, 'host output');
await writeFile(logcatPath, 'Android output');
registerAndroidEmulatorLogDirectory(global, directory);
upload.mockRejectedValueOnce(new Error('storage unavailable'));
await Promise.all([collect(), collect()]);
await collect();
await expect(collect()).resolves.toBeUndefined();
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')
);
await collect();
expect(upload).toHaveBeenCalledTimes(3);
expect(upload.mock.calls[2][1].filename).toBe(path.basename(emulatorPath));
expect(upload.mock.calls[0][1].signal).not.toBe(upload.mock.calls[1][1].signal);
});
it('shares concurrent collection and skips successful uploads on repeated cleanup', async () => {
await writeFile(emulatorPath, 'host output');
registerAndroidEmulatorLogDirectory(global, directory);
registerAndroidEmulatorLogDirectory(global, directory);
await Promise.all([collect(), collect()]);
await collect();
expect(upload).toHaveBeenCalledTimes(1);
});
it('keeps successful uploads deduplicated when snapshot cleanup fails', async () => {
await writeFile(emulatorPath, 'host output');
registerAndroidEmulatorLogDirectory(global, directory);
const open = fsPromises.open;
const openFile = jest.spyOn(fsPromises, 'open').mockImplementationOnce(async (...args) => {
const file = await open(...args);
const close = file.close.bind(file);
file.close = async () => {
await close();
throw new Error('close failed');
};
return file;
});
const remove = jest.spyOn(fsPromises, 'rm').mockRejectedValueOnce(new Error('remove failed'));
try {
await expect(collect()).resolves.toBeUndefined();
await collect();
expect(upload).toHaveBeenCalledTimes(1);
expect(remove).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining('Could not close')
);
expect(logger.warn).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining('Could not remove')
);
} finally {
openFile.mockRestore();
remove.mockRestore();
}
});
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');
await writeFile(emulatorPath, 'host output');
await writeFile(logcatPath, 'Android output');
registerAndroidEmulatorLogDirectory(global, directory);
const controllers: AbortController[] = [];
const timeout = jest.spyOn(AbortSignal, 'timeout').mockImplementation(() => {
const controller = new AbortController();
@@ -227,53 +210,69 @@ it('uses a fresh 30 second budget for the next file after an upload times out',
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);
try {
await expect(collect()).resolves.toBeUndefined();
expect(uploadedContents.get(path.basename(logcatPath))).toBe('Android output');
expect(timeout.mock.calls).toEqual([[30_000], [30_000]]);
expect(upload.mock.calls[1][1].signal?.aborted).toBe(false);
} finally {
timeout.mockRestore();
}
});
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);
it('does not collect another workflow or empty files and tolerates missing directories', async () => {
registerAndroidEmulatorLogDirectory({} as BuildStepGlobalContext, directory);
await writeFile(emulatorPath, 'other workflow');
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 writeFile(emulatorPath, '');
registerAndroidEmulatorLogDirectory(global, path.join(os.tmpdir(), 'missing'));
registerAndroidEmulatorLogDirectory(global, directory);
await expect(collect()).resolves.toBeUndefined();
expect(upload).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining('emulator-output.log')
expect.stringContaining('Could not read')
);
});
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 }),
})
it('handles a snapshot read failure and still uploads the other log', async () => {
await writeFile(emulatorPath, 'host output');
await writeFile(logcatPath, 'Android output');
registerAndroidEmulatorLogDirectory(global, directory);
const createReadStream = fs.createReadStream;
const read = jest.spyOn(fs, 'createReadStream').mockImplementationOnce((filePath, options) => {
const stream = createReadStream(filePath, options);
stream.destroy(new Error('source read failed'));
return stream;
});
try {
await expect(collect()).resolves.toBeUndefined();
expect(uploadedContents.get(path.basename(logcatPath))).toBe('Android output');
expect(upload).toHaveBeenCalledTimes(1);
} finally {
read.mockRestore();
}
});
it('aborts an upload on a genuine snapshot file read error and continues with the other log', async () => {
await writeFile(emulatorPath, 'host output');
await writeFile(logcatPath, 'Android output');
registerAndroidEmulatorLogDirectory(global, directory);
const readError = Object.assign(new Error('snapshot read failed'), { code: 'EIO' });
upload.mockImplementationOnce(async (_ctx, { openStream, signal }) => {
const stream = openStream();
await new Promise<void>(resolve => {
stream.once('error', () => resolve());
stream.destroy(readError);
});
expect(signal?.reason).toBe(readError);
signal?.throwIfAborted();
});
await expect(collect()).resolves.toBeUndefined();
expect(uploadedContents.get(path.basename(logcatPath))).toBe('Android output');
expect(logger.warn).toHaveBeenCalledWith(
{ err: readError },
expect.stringContaining('other logs will continue')
);
});
@@ -0,0 +1,149 @@
import { type BuildStepGlobalContext } from '@expo/steps';
import fs from 'node:fs';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import os from 'node:os';
import path from 'node:path';
import {
createCustomBuildContextMock,
createGlobalContextMock,
} from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { type CustomBuildContext } from '../../../customBuildContext';
import {
collectAndroidEmulatorLogsAsync,
registerAndroidEmulatorLogDirectory,
} from '../androidEmulatorLogs';
import { requireDeviceRunSessionWorkerApi } from '../deviceRunSessionWorkerApi';
jest.mock('../deviceRunSessionWorkerApi');
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
jest.unmock('node-fetch');
it('does not close a reused descriptor after a snapshot read failure', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'android-log-descriptor-test-'));
const unrelatedPath = path.join(directory, 'unrelated.txt');
const readError = Object.assign(new Error('source read failed'), { code: 'EIO' });
const createReadStream = fs.createReadStream;
let sourceFd: number | undefined;
let reusedFd: number | undefined;
const read = jest.spyOn(fs, 'createReadStream').mockImplementationOnce((filePath, options) => {
if (!options || typeof options === 'string' || options.fd == null) {
throw new Error('Expected an open source file');
}
sourceFd = typeof options.fd === 'number' ? options.fd : options.fd.fd;
const stream = createReadStream(filePath, options);
stream.once('close', () => {
// Reuse the descriptor before the collector's final FileHandle.close().
reusedFd = fs.openSync(unrelatedPath, 'w');
});
stream.destroy(readError);
return stream;
});
try {
await writeFile(path.join(directory, 'device-emulator.log'), 'host output');
const global = createGlobalContextMock();
const logger = createMockLogger();
registerAndroidEmulatorLogDirectory(global, directory);
await collectAndroidEmulatorLogsAsync(createCustomBuildContextMock(), {
global,
deviceRunSessionId: 'session',
logger,
});
expect(reusedFd).toBe(sourceFd);
if (reusedFd === undefined) {
throw new Error('Expected the source descriptor to be reused');
}
expect(fs.writeSync(reusedFd, 'still open')).toBe(10);
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: readError },
expect.stringContaining('other logs will continue')
);
expect(requireDeviceRunSessionWorkerApi).not.toHaveBeenCalled();
} finally {
read.mockRestore();
if (reusedFd !== undefined) {
try {
fs.closeSync(reusedFd);
} catch {
// The regression closes this descriptor before the test can clean it up.
}
}
await rm(directory, { recursive: true, force: true });
}
});
it('retries a capped log after an early storage error with real file streams and one artifact', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'android-log-upload-test-'));
const maximumBytes = 20 * 1024 * 1024;
const payload = Buffer.alloc(maximumBytes + 6, 'a');
await writeFile(path.join(directory, 'device-emulator.log'), payload);
const global = {} as BuildStepGlobalContext;
const logger = createMockLogger();
registerAndroidEmulatorLogDirectory(global, directory);
let requests = 0;
let receivedBytes = 0;
const server = createServer((request, response) => {
requests++;
if (requests === 1) {
response.statusCode = 503;
response.flushHeaders();
return;
}
request.on('data', chunk => {
receivedBytes += chunk.length;
});
request.on('end', () => response.end());
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
try {
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Missing test server address');
}
const createArtifactUploadSessionAsync = jest.fn().mockResolvedValue({
url: `http://127.0.0.1:${address.port}/log`,
headers: { 'Content-Length': String(maximumBytes) },
});
jest.mocked(requireDeviceRunSessionWorkerApi).mockReturnValue({
createArtifactUploadSessionAsync,
} as unknown as ReturnType<typeof requireDeviceRunSessionWorkerApi>);
const collect = async (): Promise<void> =>
await collectAndroidEmulatorLogsAsync({} as CustomBuildContext, {
global,
deviceRunSessionId: 'session',
logger,
});
await collect();
await collect();
expect(logger.warn).not.toHaveBeenCalled();
expect(requests).toBe(2);
expect(receivedBytes).toBe(maximumBytes);
expect(createArtifactUploadSessionAsync).toHaveBeenCalledTimes(1);
expect(createArtifactUploadSessionAsync).toHaveBeenCalledWith(
expect.objectContaining({
name: expect.stringContaining('truncated, 20 MiB retained'),
size: maximumBytes,
metadata: expect.objectContaining({
originalSizeBytes: payload.length,
omittedBytes: 6,
truncated: true,
}),
}),
expect.objectContaining({ signal: expect.any(AbortSignal) })
);
} finally {
server.closeAllConnections();
await new Promise<void>(resolve => server.close(() => resolve()));
await rm(directory, { recursive: true, force: true });
}
});
@@ -155,18 +155,17 @@ describe(uploadArgentArtifactAsync, () => {
});
});
async function uploadAsync(
artifact: {
id: string;
kind?: string;
filename: string;
mimeType: string;
isDirectory?: boolean;
},
data = Buffer.from('artifact-data')
): Promise<CustomBuildContext> {
async function uploadAsync(artifact: {
id: string;
kind?: string;
filename: string;
mimeType: string;
isDirectory?: boolean;
}): Promise<CustomBuildContext> {
const ctx = {} as unknown as CustomBuildContext;
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
jest
.mocked(fetch)
.mockResolvedValueOnce(new Response(Readable.from([Buffer.from('artifact-data')])));
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { openStream }) => {
@@ -201,45 +200,6 @@ 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',
@@ -1,57 +0,0 @@
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs/promises';
import { createColdIosSessionAsync } from '../coldIosSession';
import { spawnDetached } from '../remoteDeviceRunSession';
jest.mock('@expo/turtle-spawn');
jest.mock('../remoteDeviceRunSession', () => ({
fetchWebPreviewTurnArgsAsync: jest.fn(async () => []),
findAvailablePortAsync: jest.fn(async () => 3200),
spawnDetached: jest.fn(() => ({ stopAsync: jest.fn() })),
}));
const STATE_FILE = '/tmp/expo-simulator-startup.json';
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
const logger = { info: jest.fn(), warn: jest.fn() } as any;
async function startServerAsync(signal?: AbortSignal) {
const server = await createColdIosSessionAsync({} as any, { env: {}, logger });
return await server.startAsync({
command: 'serve-sim',
args: [],
env: {},
logger,
secrets: [],
stopGracePeriodMs: 1000,
signal,
});
}
beforeEach(async () => {
jest.mocked(spawn).mockResolvedValue({ stdout: 'current-boot\n' } as never);
Object.defineProperty(process, 'platform', { value: 'darwin' });
await fs.mkdir('/tmp', { recursive: true });
await fs.writeFile(STATE_FILE, JSON.stringify({ status: 'stopping' }));
});
afterEach(() => {
Object.defineProperty(process, 'platform', platform);
});
it('starts serve-sim only after warm Simulator cleanup finished', async () => {
const starting = startServerAsync();
await new Promise(resolve => setTimeout(resolve, 600));
expect(spawnDetached).not.toHaveBeenCalled();
await fs.writeFile(STATE_FILE, JSON.stringify({ status: 'disabled', bootUuid: 'current-boot' }));
await starting;
expect(spawnDetached).toHaveBeenCalledTimes(1);
});
it('does not start serve-sim when the job is cancelled while cleanup runs', async () => {
const controller = new AbortController();
const starting = startServerAsync(controller.signal);
// Cleanup is still running; the cancellation alone must end the wait.
controller.abort(new Error('cancelled'));
await expect(starting).rejects.toThrow('cancelled');
expect(spawnDetached).not.toHaveBeenCalled();
});
@@ -1,62 +0,0 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { validateControllerVersion } from '../controllerVersion';
describe(validateControllerVersion, () => {
const minimumVersion = '0.16.0';
const controller = { packageName: 'example-controller', minimumVersion };
const warn = jest.fn();
const logger = { warn } as unknown as bunyan;
const supportedVersion = '999.0.0';
const oldVersion = '0.0.0';
beforeEach(() => {
warn.mockClear();
});
it('allows the default latest package version', () => {
expect(() =>
validateControllerVersion({ ...controller, packageVersion: undefined, logger })
).not.toThrow();
expect(() =>
validateControllerVersion({ ...controller, packageVersion: 'latest', logger })
).not.toThrow();
expect(warn).not.toHaveBeenCalled();
});
it('allows exact supported semver versions', () => {
expect(() =>
validateControllerVersion({ ...controller, packageVersion: minimumVersion, logger })
).not.toThrow();
expect(() =>
validateControllerVersion({ ...controller, packageVersion: supportedVersion, logger })
).not.toThrow();
expect(() =>
validateControllerVersion({ ...controller, packageVersion: `v${supportedVersion}`, logger })
).not.toThrow();
expect(warn).not.toHaveBeenCalled();
});
it('rejects exact semver versions that are too old', () => {
expect(() =>
validateControllerVersion({ ...controller, packageVersion: oldVersion, logger })
).toThrow(SystemError);
expect(() =>
validateControllerVersion({ ...controller, packageVersion: oldVersion, logger })
).toThrow(`Use "latest" or pass an exact version >= ${minimumVersion}`);
});
it('warns for package tags or ranges that cannot be verified', () => {
expect(() =>
validateControllerVersion({ ...controller, packageVersion: 'next', logger })
).not.toThrow();
expect(() =>
validateControllerVersion({ ...controller, packageVersion: `^${minimumVersion}`, logger })
).not.toThrow();
expect(warn).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Continuing and letting the package manager resolve it.')
);
});
});
@@ -45,17 +45,6 @@ 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' },
@@ -237,89 +226,6 @@ 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: {},
@@ -409,8 +315,10 @@ describe(captureDeviceRunSessionPreviewAsync, () => {
}) as ReturnType<typeof spawn>;
}
return (async () => {
expect(command).not.toBe('xcrun');
if (command === 'sips') {
if (command === 'xcrun') {
expect(args.slice(0, 4)).toEqual(['simctl', 'io', 'selected-device', 'screenshot']);
await writeFile(args[4], Uint8Array.from(png));
} else if (command === 'sips') {
expect(args.slice(0, 2)).toEqual(['-Z', '320']);
expect(await readFile(args[2])).toEqual(png);
await writeFile(args[args.indexOf('--out') + 1], Uint8Array.from(resized));
@@ -432,10 +340,6 @@ describe(captureDeviceRunSessionPreviewAsync, () => {
await captureDeviceRunSessionPreviewAsync({
runtimePlatform,
device,
captureIosScreenshotAsync: async (file, signal) => {
signal.throwIfAborted();
await writeFile(file, Uint8Array.from(png));
},
env: {},
signal: new AbortController().signal,
})
@@ -468,9 +372,6 @@ describe(captureDeviceRunSessionPreviewAsync, () => {
captureDeviceRunSessionPreviewAsync({
runtimePlatform: BuildRuntimePlatform.DARWIN,
device: 'device',
captureIosScreenshotAsync: async () => {
throw new Error('capture failed');
},
env: {},
signal: new AbortController().signal,
})
@@ -1,205 +0,0 @@
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,14 +8,12 @@ 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;
@@ -29,9 +27,6 @@ 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 }) => {
@@ -73,13 +68,9 @@ 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', source: 'manual', width: 1179, height: 2556 },
metadata: { __eas_type: 'screenshot' },
})
);
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,9 +152,6 @@ 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' });
@@ -1,102 +0,0 @@
import { bunyan } from '@expo/logger';
import { CustomBuildContext } from '../../../customBuildContext';
import { watchDeviceRunSessionStartup } from '../deviceRunSessionStartup';
import {
DeviceRunSessionStoppedError,
waitForDeviceRunSessionStoppedAsync,
} from '../remoteDeviceRunSession';
import { createStartupTasks } from '../startupTasks';
jest.mock('../remoteDeviceRunSession', () => ({
...jest.requireActual('../remoteDeviceRunSession'),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
const ctx = {} as CustomBuildContext;
const logger = {
info: jest.fn(),
warn: jest.fn(),
child: jest.fn().mockReturnThis(),
} as unknown as bunyan;
/** Makes the status poll settle only when the test says so, or end when its signal aborts. */
function holdStatusPoll(): { stop: () => void; fail: (error: Error) => void } {
let stop!: () => void;
let fail!: (error: Error) => void;
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(
({ signal }) =>
new Promise<void>((resolve, reject) => {
stop = resolve;
fail = reject;
signal?.addEventListener('abort', () => resolve(), { once: true });
})
);
return { stop: () => stop(), fail: error => fail(error) };
}
function watch(startup = createStartupTasks(logger)) {
return {
startup,
watch: watchDeviceRunSessionStartup({ ctx, deviceRunSessionId: 'drs-id', logger, startup }),
};
}
describe(watchDeviceRunSessionStartup, () => {
beforeEach(() => jest.clearAllMocks());
it('aborts startup when the session is stopped', async () => {
const poll = holdStatusPoll();
const { startup, watch: startupWatch } = watch();
poll.stop();
await new Promise(resolve => setImmediate(resolve));
expect(startupWatch.stopped).toBeInstanceOf(DeviceRunSessionStoppedError);
expect(startup.signal.reason).toBe(startupWatch.stopped);
expect(startupWatch.isStopped()).toBe(true);
expect(logger.info).toHaveBeenCalledWith(
'Device run session drs-id was stopped during startup.'
);
await expect(startupWatch.completeAsync()).rejects.toBe(startupWatch.stopped);
});
it('ends the poll when startup completes, without aborting startup', async () => {
holdStatusPoll();
const { startup, watch: startupWatch } = watch();
await expect(startupWatch.completeAsync()).resolves.toBeUndefined();
const [{ signal }] = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0];
expect(signal?.aborted).toBe(true);
expect(startup.signal.aborted).toBe(false);
expect(startupWatch.isStopped()).toBe(false);
});
it('aborts startup with a status poll failure', async () => {
const poll = holdStatusPoll();
const { startup, watch: startupWatch } = watch();
const failed = new Error('status poll failed');
poll.fail(failed);
await new Promise(resolve => setImmediate(resolve));
expect(startup.signal.reason).toBe(failed);
expect(startupWatch.isStopped()).toBe(false);
});
it('keeps the first abort reason when startup ends for another reason', async () => {
holdStatusPoll();
const { startup, watch: startupWatch } = watch();
const failed = new Error('host startup failed');
startup.abort(failed);
await startupWatch.finishAsync();
expect(startup.signal.reason).toBe(failed);
expect(startupWatch.isStopped()).toBe(false);
expect(logger.info).not.toHaveBeenCalled();
});
it('follows the step signal', async () => {
holdStatusPoll();
const step = new AbortController();
const { startup, watch: startupWatch } = watch(createStartupTasks(logger, step.signal));
step.abort(new Error('step cancelled'));
await startupWatch.finishAsync();
expect(startup.signal.aborted).toBe(true);
expect(startupWatch.isStopped()).toBe(false);
});
});
@@ -13,7 +13,6 @@ 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');
@@ -124,41 +123,6 @@ 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;
@@ -1,10 +1,12 @@
import { SystemError } from '@expo/eas-build-job';
import { FetchError } from 'node-fetch';
import { CustomBuildContext } from '../../../customBuildContext';
import { turtleFetch } from '../../../utils/turtleFetch';
import {
DEVICE_RUN_SESSION_TOKEN_HEADER,
DeviceRunSessionWorkerApi,
DeviceRunSessionWorkerApiError,
getDeviceRunSessionWorkerApi,
requireDeviceRunSessionWorkerApi,
} from '../deviceRunSessionWorkerApi';
@@ -63,6 +65,23 @@ describe(getDeviceRunSessionWorkerApi, () => {
);
});
it('derives the API v2 base URL from __API_SERVER_URL when the context has none', async () => {
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
const api = getDeviceRunSessionWorkerApi({
env: { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN, __API_SERVER_URL: 'https://api.expo.test' },
} as unknown as CustomBuildContext);
await api!.getSessionAsync();
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
'https://api.expo.test/v2/device-run-session-worker/session',
'GET',
expect.anything()
);
});
it('throws a system error when the token is set but no API URL is known', () => {
expect(() =>
getDeviceRunSessionWorkerApi({
@@ -176,18 +195,6 @@ 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(),
@@ -261,4 +268,273 @@ describe(DeviceRunSessionWorkerApi, () => {
'GET /v2/device-run-session-worker/application returned an unexpected response'
);
});
it('passes the signal and timeout without enabling nested retries', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { iceServers: [] } }));
const controller = new AbortController();
await createApi().fetchTurnIceServersAsync({
signal: controller.signal,
timeoutMs: 5000,
retries: 1,
});
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
expect.any(String),
'POST',
expect.objectContaining({ signal: controller.signal, timeout: 5000, retries: 0 })
);
});
it('reports the API v2 error message and status without the token', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(
jsonResponse(
{
errors: [
{
code: 'UNAUTHORIZED_ERROR',
message: 'This device run session no longer accepts worker requests.',
},
],
},
403
)
);
const error = await createApi()
.stopAsync()
.catch(err => err);
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
expect(error.status).toBe(403);
expect(error.message).toBe(
'POST /v2/device-run-session-worker/stop failed with status 403: This device run session no longer accepts worker requests.'
);
expect(error.message).not.toContain(TOKEN);
});
it('reports the status text when the error response is not an API v2 error', async () => {
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(
new Response('<html>bad gateway</html>', { status: 502, statusText: 'Bad Gateway' })
);
const error = await createApi()
.getSessionAsync({ retries: 0 })
.catch(err => err);
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
expect(error.status).toBe(502);
expect(error.message).toBe(
'GET /v2/device-run-session-worker/session failed with status 502: Bad Gateway'
);
});
it('reports a request that failed before www responded', async () => {
const reason = new Error('socket hang up');
jest.mocked(turtleFetch).mockRejectedValueOnce(reason);
const error = await createApi()
.getSessionAsync({ retries: 0 })
.catch(err => err);
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
expect(error.status).toBeUndefined();
expect(error.cause).toBe(reason);
expect(error.message).toContain('failed before www responded: socket hang up');
});
it('rejects a response that does not match the route contract', async () => {
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'STOPPED' } }));
await expect(createApi().getSessionAsync({ retries: 3 })).rejects.toThrow(
'GET /v2/device-run-session-worker/session returned an unexpected response'
);
});
it('rejects a successful response that is not JSON', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('ok', { status: 200 }));
await expect(createApi().getSessionAsync({ retries: 3 })).rejects.toThrow(
'GET /v2/device-run-session-worker/session returned a response that is not JSON.'
);
});
});
describe('device run session worker API retries', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.mocked(turtleFetch).mockReset();
});
afterEach(() => {
jest.useRealTimers();
});
it.each([429, 500, 502, 503, 504])(
'recovers from HTTP %s when starting a session',
async status => {
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(new Response('unavailable', { status }))
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
const result = createApi().startAsync(
{ remoteConfig: { url: 'https://preview.test' } },
{ retries: 3, timeoutMs: 30_000 }
);
const assertion = expect(result).resolves.toEqual({ id: 'drs-id', status: 'in-progress' });
await jest.runAllTimersAsync();
await assertion;
expect(turtleFetch).toHaveBeenCalledTimes(2);
expect(jest.mocked(turtleFetch).mock.calls[1]).toEqual(
jest.mocked(turtleFetch).mock.calls[0]
);
expect(jest.mocked(turtleFetch).mock.calls[0][2]).toMatchObject({
retries: 0,
timeout: 30_000,
});
}
);
it('recovers from a network failure', async () => {
jest
.mocked(turtleFetch)
.mockRejectedValueOnce(new Error('socket hang up'))
.mockResolvedValueOnce(jsonResponse({ data: { build: null } }));
const assertion = expect(createApi().getApplicationAsync({ retries: 3 })).resolves.toEqual({
build: null,
});
await jest.runAllTimersAsync();
await assertion;
expect(turtleFetch).toHaveBeenCalledTimes(2);
});
it('exhausts three retries with exponential backoff', async () => {
jest
.mocked(turtleFetch)
.mockImplementation(async () => new Response('unavailable', { status: 503 }));
const assertion = expect(
createApi().startAsync({ remoteConfig: {} }, { retries: 3 })
).rejects.toMatchObject({
status: 503,
});
await jest.advanceTimersByTimeAsync(0);
expect(turtleFetch).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1000);
expect(turtleFetch).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(2000);
expect(turtleFetch).toHaveBeenCalledTimes(3);
await jest.advanceTimersByTimeAsync(4000);
await assertion;
expect(turtleFetch).toHaveBeenCalledTimes(4);
});
it.each([400, 401, 403, 404, 409])('does not retry HTTP %s', async status => {
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('refused', { status }));
await expect(
createApi().startAsync({ remoteConfig: {} }, { retries: 3 })
).rejects.toMatchObject({ status });
expect(turtleFetch).toHaveBeenCalledTimes(1);
});
it.each([
['status polling', (api: DeviceRunSessionWorkerApi) => api.getSessionAsync()],
['stop', (api: DeviceRunSessionWorkerApi) => api.stopAsync()],
[
'screenshot application metadata',
(api: DeviceRunSessionWorkerApi) => api.getApplicationAsync(),
],
['event log setup', (api: DeviceRunSessionWorkerApi) => api.createEventLogUploadSessionAsync()],
['preview setup', (api: DeviceRunSessionWorkerApi) => api.createPreviewUploadSessionAsync()],
[
'TURN without an explicit retry option',
(api: DeviceRunSessionWorkerApi) => api.fetchTurnIceServersAsync(),
],
])('does not retry %s by default', async (_name, call) => {
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('unavailable', { status: 503 }));
await expect(call(createApi())).rejects.toMatchObject({ status: 503 });
expect(turtleFetch).toHaveBeenCalledTimes(1);
});
it('honors an explicit retry count', async () => {
jest
.mocked(turtleFetch)
.mockImplementation(async () => new Response('unavailable', { status: 503 }));
const assertion = expect(
createApi().fetchTurnIceServersAsync({ retries: 1 })
).rejects.toMatchObject({ status: 503 });
await jest.runAllTimersAsync();
await assertion;
expect(turtleFetch).toHaveBeenCalledTimes(2);
});
it('does not send another request after cancellation during backoff', async () => {
const controller = new AbortController();
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('unavailable', { status: 503 }));
const assertion = expect(
createApi().getApplicationAsync({ signal: controller.signal, retries: 3 })
).rejects.toMatchObject({ name: 'AbortError' });
await jest.advanceTimersByTimeAsync(0);
controller.abort();
await jest.runAllTimersAsync();
await assertion;
expect(turtleFetch).toHaveBeenCalledTimes(1);
});
});
describe('device run session retry safety', () => {
beforeEach(() => {
jest.mocked(turtleFetch).mockReset();
});
it('does not replay artifact creation after a transient failure', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('unavailable', { status: 503 }));
await expect(
createApi().createArtifactUploadSessionAsync({
name: 'report',
filename: 'report.json',
size: 1,
})
).rejects.toMatchObject({ status: 503 });
expect(turtleFetch).toHaveBeenCalledTimes(1);
});
it('does not send an already cancelled request', async () => {
const controller = new AbortController();
controller.abort();
await expect(
createApi().getApplicationAsync({ signal: controller.signal, retries: 3 })
).rejects.toMatchObject({
name: 'AbortError',
});
expect(turtleFetch).not.toHaveBeenCalled();
});
it.each(['system', 'body-timeout'])(
'retries a response body transport failure (%s)',
async type => {
jest.useFakeTimers();
try {
const response = jsonResponse({});
jest
.spyOn(response, 'json')
.mockRejectedValueOnce(new FetchError('connection interrupted', type));
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(response)
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
const assertion = expect(
createApi().startAsync({ remoteConfig: {} }, { retries: 3 })
).resolves.toMatchObject({ status: 'in-progress' });
await jest.runAllTimersAsync();
await assertion;
expect(turtleFetch).toHaveBeenCalledTimes(2);
} finally {
jest.useRealTimers();
}
}
);
});
File diff suppressed because it is too large. Load diff
@@ -1,122 +0,0 @@
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'));
});
});
@@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { isPreinstalledExpoGoAsync, readIosApplicationIdentifierAsync } from '../iosAppArtifact';
import { readIosApplicationIdentifierAsync } from '../iosAppArtifact';
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.unmock('fs');
@@ -61,59 +61,3 @@ describe(readIosApplicationIdentifierAsync, () => {
).rejects.toMatchObject({ errorCode: 'EAS_INSTALL_BUILD_MISSING_IDENTIFIER' });
});
});
describe(isPreinstalledExpoGoAsync, () => {
const preinstalled = {
bundleIdentifier: 'host.exp.Exponent',
shortVersion: '58.0.7',
version: '58.0.7',
};
function plist(values: Record<string, string>) {
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => ({
stdout: `${values[args[1]]}\n`,
})) as never);
}
const app = (shortVersion: string, bundleIdentifier = 'host.exp.Exponent') => ({
CFBundleIdentifier: bundleIdentifier,
CFBundleShortVersionString: shortVersion,
CFBundleVersion: shortVersion,
});
beforeEach(() => {
jest.mocked(spawn).mockReset();
});
it('matches the preinstalled Expo Go without asking the Simulator', async () => {
plist(app('58.0.7'));
await expect(
isPreinstalledExpoGoAsync({ appPath: '/job/Expo Go.app', preinstalled, env: {} })
).resolves.toBe(true);
expect(jest.mocked(spawn).mock.calls.every(([command]) => command === 'plutil')).toBe(true);
});
it('does not match a different Expo Go version', async () => {
plist(app('58.0.6'));
await expect(
isPreinstalledExpoGoAsync({ appPath: '/job/Expo Go.app', preinstalled, env: {} })
).resolves.toBe(false);
});
it('never matches a custom build, even with the same version', async () => {
plist(app('58.0.7', 'com.example.app'));
await expect(
isPreinstalledExpoGoAsync({
appPath: '/job/Example.app',
preinstalled: { ...preinstalled, bundleIdentifier: 'com.example.app' },
env: {},
})
).resolves.toBe(false);
expect(spawn).not.toHaveBeenCalled();
});
it('does not match an app it cannot read', async () => {
jest.mocked(spawn).mockRejectedValue(new Error('plutil failed'));
await expect(
isPreinstalledExpoGoAsync({ appPath: '/job/Expo Go.app', preinstalled, env: {} })
).resolves.toBe(false);
});
});
@@ -155,53 +155,6 @@ describe('serve-sim Simulator preparation', () => {
}
);
it('preserves the Stop reason when Simulator shutdown rejects after cancellation', async () => {
const controller = new AbortController();
const stopped = new Error('Session stopped');
const wrapped = new Error('Shutdown process aborted', { cause: stopped });
let shutdownEntered!: () => void;
const entered = new Promise<void>(resolve => {
shutdownEntered = resolve;
});
jest
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
.mockResolvedValue({ SERVE_SIM_ADDITIONAL_DYLIBS: '/guard.dylib' });
mockedUtils.getDeviceAsync.mockResolvedValue({ state: 'Booted' } as never);
mockedSpawn.mockImplementationOnce((_command, _args, options) => {
shutdownEntered();
return new Promise((_resolve, reject) =>
options!.signal!.addEventListener('abort', () => reject(wrapped), { once: true })
) as ReturnType<typeof spawn>;
});
const running = resolveIosSessionStartupAsync({
...hostOptions,
bootSimulator: true,
signal: controller.signal,
});
running.catch(() => {});
await entered;
controller.abort(stopped);
await expect(running).rejects.toBe(stopped);
expect(startLocalEgressGuardRelayAsync).not.toHaveBeenCalled();
});
it('preserves a genuine Simulator shutdown failure', async () => {
const failure = new Error('Could not shut down Simulator');
jest
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
.mockResolvedValue({ SERVE_SIM_ADDITIONAL_DYLIBS: '/guard.dylib' });
mockedUtils.getDeviceAsync.mockResolvedValue({ state: 'Booted' } as never);
mockedSpawn.mockRejectedValueOnce(failure);
await expect(
resolveIosSessionStartupAsync({
...hostOptions,
bootSimulator: true,
signal: new AbortController().signal,
})
).rejects.toBe(failure);
expect(startLocalEgressGuardRelayAsync).not.toHaveBeenCalled();
});
it('resolves an explicit Simulator without booting or configuring it', async () => {
const startup = await resolveIosSessionStartupAsync({
...hostOptions,
File diff suppressed because it is too large. Load diff
@@ -54,23 +54,6 @@ 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',
@@ -1,527 +0,0 @@
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,7 +29,6 @@ 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,
@@ -39,7 +38,6 @@ import {
describeServeSimLaunch,
ensureFfmpegInstalledOnceAsync,
fetchWebPreviewTurnArgsAsync,
finishRemoteSessionAsync,
parseCorsOriginsInput,
parseFrameAncestorsInput,
parseServeSimLaunchInputs,
@@ -228,89 +226,6 @@ 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 =>
@@ -1017,7 +932,7 @@ describe(waitForWebPreviewReadyAsync, () => {
describe(startNgrokTunnelAsync, () => {
it.each(['rejected', 'stalled'] as const)(
'bounds %s listener retirement and reuses the stop promise',
'retains a %s close failure on repeated stops',
async mode => {
jest.useFakeTimers();
try {
@@ -1039,12 +954,14 @@ describe(startNgrokTunnelAsync, () => {
logger: createLoggerMock(),
});
const stopping = tunnel.stopAsync();
const assertion = expect(stopping).rejects.toThrow(/stop timed out after 4000ms/);
const assertion = expect(stopping).rejects.toThrow(
mode === 'rejected' ? 'close failed' : 'Ngrok tunnel stop timed out after 4000ms.'
);
expect(tunnel.stopAsync()).toBe(stopping);
await jest.advanceTimersByTimeAsync(4_000);
await assertion;
await expect(tunnel.stopAsync()).rejects.toThrow();
expect(close).toHaveBeenCalled();
expect(close).toHaveBeenCalledTimes(1);
} finally {
jest.useRealTimers();
}
@@ -1068,7 +985,7 @@ describe(startNgrokTunnelAsync, () => {
expect(ngrok.forward).toHaveBeenCalledWith(
expect.objectContaining({
addr: expect.any(Number),
addr: 4321,
authtoken: 'token',
domain: expect.stringMatching(/^web-preview-[a-f0-9]{32}\.eas-simulator\.ngrok\.dev$/),
})
@@ -1172,13 +1089,7 @@ describe(startDeviceSessionHostAsync, () => {
beforeEach(() => {
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockReset();
jest.mocked(spawn).mockReset();
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValue({
url: () => 'https://preview.example.test',
close: jest.fn().mockResolvedValue(undefined),
} as never);
jest.mocked(ngrok.forward).mockReset();
jest.mocked(turtleFetch).mockReset();
jest
.mocked(readServeSimServersAsync)
@@ -1241,7 +1152,6 @@ describe(startDeviceSessionHostAsync, () => {
it('gives serve-sim its recording grace period on shutdown', async () => {
const spawnDetachedSpy = jest.spyOn(remoteDeviceRunSession, 'spawnDetached');
const host = await startDeviceSessionHostAsync(createCtxMock(), {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger: createLoggerMock(),
@@ -1259,7 +1169,6 @@ describe(startDeviceSessionHostAsync, () => {
'passes private sharing flags directly to serve-sim version %s',
async packageVersion => {
const host = await startDeviceSessionHostAsync(createCtxMock(), {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger: createLoggerMock(),
@@ -1288,7 +1197,6 @@ describe(startDeviceSessionHostAsync, () => {
const privatePreviewUrl =
'https://expo.dev/accounts/owner/projects/app/simulator-sessions/session-id/preview';
const host = await startDeviceSessionHostAsync(createCtxMock(), {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger: createLoggerMock(),
@@ -1296,7 +1204,7 @@ describe(startDeviceSessionHostAsync, () => {
disableShare: true,
privatePreviewUrl,
});
const preview = await host.openPreviewAsync();
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
const serveSimArgs = jest
.mocked(spawn)
@@ -1316,14 +1224,13 @@ describe(startDeviceSessionHostAsync, () => {
close: jest.fn().mockResolvedValue(undefined),
} as never);
const host = await startDeviceSessionHostAsync(createCtxMock(), {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
disableShare: true,
});
const preview = await host.openPreviewAsync();
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
const serveSimArgs = jest
.mocked(spawn)
@@ -1350,14 +1257,13 @@ describe(startDeviceSessionHostAsync, () => {
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>);
const host = await startDeviceSessionHostAsync(createCtxMock(), {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.LINUX,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
packageVersion,
});
const preview = await host.openPreviewAsync();
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
const spawnCalls = jest.mocked(spawn).mock.calls;
expect(spawnCalls[0]).toEqual([
@@ -1396,10 +1302,7 @@ describe(startDeviceSessionHostAsync, () => {
recordingDirectory,
}),
]);
expect(ngrok.forward).toHaveBeenCalledWith(
expect.objectContaining({ addr: expect.any(Number) })
);
expect(ngrok.forward).not.toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(preview.apiUrl).toBe('https://android-preview.example.test');
await host.finishAsync();
@@ -1414,13 +1317,12 @@ describe(startDeviceSessionHostAsync, () => {
} as never);
const host = await startDeviceSessionHostAsync(createCtxMock(), {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
});
const preview = await host.openPreviewAsync();
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
expect(preview.previewToken).toBe('tok-1');
expect(preview.apiUrl).toBe('https://preview.example.test');
@@ -1450,13 +1352,12 @@ describe(startDeviceSessionHostAsync, () => {
} as never);
const host = await startDeviceSessionHostAsync(createCtxMock(), {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: { ...env, EXPO_STAGING: '1' },
logger: createLoggerMock(),
timeoutMs: 10_000,
});
const preview = await host.openPreviewAsync();
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
expect(preview.previewPageUrl).toMatch(
/^https:\/\/staging\.expo\.dev\/simulator-preview\/[a-f0-9]{32}$/
@@ -1476,7 +1377,6 @@ describe(startDeviceSessionHostAsync, () => {
await expect(
startDeviceSessionHostAsync(createCtxMock(), {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger: createLoggerMock(),
@@ -1494,13 +1394,12 @@ describe(startDeviceSessionHostAsync, () => {
} as never);
const host = await startDeviceSessionHostAsync(createCtxMock(), {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.LINUX,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
});
const preview = await host.openPreviewAsync();
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
expect(preview.previewToken).toBe('hub-token');
const [, args] = jest.mocked(spawn).mock.calls.find(([command]) => command === 'npx')!;
@@ -1517,14 +1416,13 @@ describe(startDeviceSessionHostAsync, () => {
} as never);
const host = await startDeviceSessionHostAsync(createCtxMock(), {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
packageVersion,
});
const preview = await host.openPreviewAsync();
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
@@ -1540,10 +1438,7 @@ describe(startDeviceSessionHostAsync, () => {
packageVersion,
}),
]);
expect(ngrok.forward).toHaveBeenCalledWith(
expect.objectContaining({ addr: expect.any(Number) })
);
expect(ngrok.forward).not.toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(preview.apiUrl).toBe('https://ios-preview.example.test');
await host.finishAsync();
@@ -1560,14 +1455,13 @@ describe(startDeviceSessionHostAsync, () => {
const bunEnv = { ...env, EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' };
const host = await startDeviceSessionHostAsync(createCtxMock(), {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: bunEnv,
logger: createLoggerMock(),
timeoutMs: 10_000,
packageVersion: '4.5.6',
});
const preview = await host.openPreviewAsync();
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
@@ -1596,13 +1490,12 @@ describe(startDeviceSessionHostAsync, () => {
} as never);
const host = await startDeviceSessionHostAsync(createCtxMock(), {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: { ...env, EAS_FALLBACK_PACKAGE_MANAGER: 'bun' },
logger: createLoggerMock(),
timeoutMs: 10_000,
});
const preview = await host.openPreviewAsync();
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
@@ -1628,7 +1521,6 @@ describe(startDeviceSessionHostAsync, () => {
} as never);
const host = await startDeviceSessionHostAsync(createCtxMock(), {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger: createLoggerMock(),
@@ -1655,7 +1547,6 @@ describe(startDeviceSessionHostAsync, () => {
it('refuses to launch an application on Linux, where expo-device-hub cannot', async () => {
await expect(
startDeviceSessionHostAsync(createCtxMock(), {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.LINUX,
env,
logger: createLoggerMock(),
@@ -1936,23 +1827,6 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
});
it('preserves an errored status when the duration timer fires during the response', async () => {
const ctx = createStatusCtxMock([]);
jest.mocked(turtleFetch).mockImplementationOnce(async () => {
(jest.mocked(setTimeoutCallback).mock.calls[0][0] as () => void)();
return workerApiResponse({ id: 'drs-id', status: 'errored' });
});
await expect(
waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
logger: createLoggerMock(),
maxDurationSeconds: 30,
})
).rejects.toThrow('Device run session drs-id errored.');
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
});
it('clears the duration timeout when the build step is aborted', async () => {
const ctx = createStatusCtxMock([{ status: 'in-progress' }]);
const abortController = new AbortController();
@@ -1971,33 +1845,6 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
});
it('does not warn when retiring a watch aborts its pending status request', async () => {
const controller = new AbortController();
const logger = createLoggerMock();
const ctx = createCtxMock();
ctx.env.EAS_DEVICE_RUN_SESSION_TOKEN = 'worker-token';
jest.mocked(turtleFetch).mockImplementation(
async (_url, _method, options) =>
new Promise((_resolve, reject) => {
options!.signal!.addEventListener('abort', () => reject(new Error('request aborted')), {
once: true,
});
})
);
const waiting = waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
logger,
signal: controller.signal,
});
controller.abort();
await waiting;
expect(logger.warn).not.toHaveBeenCalled();
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('logs and retries transient polling errors', async () => {
const ctx = createStatusCtxMock([{ error: new Error('network down') }, { status: 'stopped' }]);
const logger = createLoggerMock();
@@ -2179,65 +2026,6 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
});
});
describe('remote configuration cancellation', () => {
beforeEach(() => jest.mocked(turtleFetch).mockReset());
it('does not report readiness for an already stopped startup', async () => {
const controller = new AbortController();
const stopped = new Error('startup stopped');
controller.abort(stopped);
await expect(
remoteDeviceRunSession.uploadRemoteSessionConfigAsync({
ctx: createCtxMock(),
deviceRunSessionId: 'drs-id',
remoteConfig: {},
logger: createLoggerMock(),
signal: controller.signal,
})
).rejects.toBe(stopped);
expect(turtleFetch).not.toHaveBeenCalled();
});
it('forwards cancellation to the in-flight readiness HTTP request', async () => {
const controller = new AbortController();
const ctx = createCtxMock();
ctx.env.EAS_DEVICE_RUN_SESSION_TOKEN = 'worker-token';
const stopped = new Error('session cancelled');
let entered!: () => void;
const requested = new Promise<void>(resolve => {
entered = resolve;
});
let requestAborted = false;
jest.mocked(turtleFetch).mockImplementation(
async (_url, _method, options) =>
new Promise((_resolve, reject) => {
entered();
options!.signal!.addEventListener(
'abort',
() => {
requestAborted = true;
reject(new Error('ready request aborted'));
},
{ once: true }
);
})
);
const reporting = remoteDeviceRunSession.uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId: 'drs-id',
remoteConfig: {},
logger: createLoggerMock(),
signal: controller.signal,
});
await requested;
controller.abort(stopped);
await expect(reporting).rejects.toBe(stopped);
expect(requestAborted).toBe(true);
});
});
describe(ensureFfmpegInstalledOnceAsync, () => {
const spawnMock = jest.mocked(spawn);
let binDirectory: string;
@@ -11,8 +11,9 @@ jest.mock('node-fetch', () => {
};
});
import { SandboxDaemonErrorCode } from '@expo/eas-build-job';
import { SandboxDaemonErrorCode, SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { type Client, CombinedError } from '@urql/core';
import fetch, { type RequestInit } from 'node-fetch';
import fs from 'node:fs/promises';
import os from 'node:os';
@@ -23,8 +24,6 @@ import { setTimeout as delay } from 'node:timers/promises';
import { inspect } from 'node:util';
import { SandboxArtifactUploadManager, startSandboxArtifactUploadAsync } from '../sandboxArtifacts';
import { type SandboxWorkerApi } from '../sandboxWorkerApi';
import { WorkerApiError } from '../workerApi';
const { AbortError, FetchError, Response } = jest.requireActual(
'node-fetch'
@@ -34,18 +33,25 @@ const ARTIFACT_ID = '0199c0de-7b3a-7c1e-8f00-1234567890ab';
const SIGNED_URL = 'https://uploads.expo.test/artifact?X-Amz-Signature=secret-signature';
const DOWNLOAD_URL = 'https://downloads.expo.test/artifact?X-Amz-Signature=download-signature';
const createdSession = {
artifact: { id: ARTIFACT_ID },
uploadSession: { url: SIGNED_URL, headers: { 'If-None-Match': '*' } },
const createdResult = {
data: {
sandbox: {
createArtifactUploadSession: {
artifact: { id: ARTIFACT_ID },
uploadSession: { url: SIGNED_URL, headers: { 'If-None-Match': '*' } },
},
},
},
};
const finalizedResult = {
data: { sandbox: { finalizeArtifact: { id: ARTIFACT_ID, downloadUrl: DOWNLOAD_URL } } },
};
describe('sandbox artifact uploads', () => {
let directory: string;
let filePath: string;
let workerApi: {
createArtifactUploadSessionAsync: jest.Mock;
finalizeArtifactAsync: jest.Mock;
};
let mutation: jest.Mock;
let graphqlClient: Client;
let logger: { info: jest.Mock; error: jest.Mock };
let uploadArtifactAsync: (options: {
filePath: string;
@@ -58,14 +64,13 @@ describe('sandbox artifact uploads', () => {
filePath = path.join(directory, 'crash.log');
await fs.writeFile(filePath, 'crash');
jest.mocked(fetch).mockReset();
workerApi = {
createArtifactUploadSessionAsync: jest.fn(),
finalizeArtifactAsync: jest.fn(),
};
mutation = jest.fn();
logger = { info: jest.fn(), error: jest.fn() };
graphqlClient = { mutation } as unknown as Client;
uploadArtifactAsync = async options =>
await startSandboxArtifactUploadAsync({
workerApi: workerApi as unknown as SandboxWorkerApi,
graphqlClient,
sandboxId: 'sandbox-id',
logger: logger as unknown as bunyan,
...options,
});
@@ -76,7 +81,7 @@ describe('sandbox artifact uploads', () => {
});
it('returns the artifact id before the PUT completes', async () => {
mockWorkerApi({ finalize: DOWNLOAD_URL });
mockMutationResults(createdResult, finalizedResult);
let respond!: (response: InstanceType<typeof Response>) => void;
jest.mocked(fetch).mockReturnValueOnce(
new Promise(resolve => {
@@ -91,29 +96,34 @@ describe('sandbox artifact uploads', () => {
});
expect(upload.id).toBe(ARTIFACT_ID);
expect(workerApi.createArtifactUploadSessionAsync).toHaveBeenCalledWith(
{ name: 'Crash log', filename: 'crash.log', size: 5 },
expect(mutation).toHaveBeenCalledTimes(1);
expect(mutation).toHaveBeenCalledWith(
expect.anything(),
{ sandboxId: 'sandbox-id', input: { name: 'Crash log', filename: 'crash.log', size: 5 } },
expect.anything()
);
expect(workerApi.finalizeArtifactAsync).not.toHaveBeenCalled();
respond(new Response('', { status: 200 }));
await expect(upload.completed).resolves.toEqual({
status: 'uploaded',
downloadUrl: DOWNLOAD_URL,
});
expect(workerApi.finalizeArtifactAsync).toHaveBeenCalledWith(
ARTIFACT_ID,
expect.objectContaining({ retries: 2 })
expect(mutation).toHaveBeenLastCalledWith(
expect.anything(),
{ artifactId: ARTIFACT_ID },
expect.anything()
);
expect(logger.error).not.toHaveBeenCalled();
});
it('sends exactly the stat size on every attempt when the file grows', async () => {
workerApi.createArtifactUploadSessionAsync.mockImplementationOnce(async () => {
await fs.appendFile(filePath, ' and more');
return createdSession;
});
workerApi.finalizeArtifactAsync.mockResolvedValueOnce(DOWNLOAD_URL);
mutation
.mockReturnValueOnce({
toPromise: async () => {
await fs.appendFile(filePath, ' and more');
return createdResult;
},
})
.mockReturnValueOnce({ toPromise: async () => finalizedResult });
const bodies: string[] = [];
const respondAfterReadingBody =
(status: number) =>
@@ -143,7 +153,7 @@ describe('sandbox artifact uploads', () => {
it('uploads an empty file with an empty body', async () => {
await fs.writeFile(filePath, '');
mockWorkerApi({ finalize: DOWNLOAD_URL });
mockMutationResults(createdResult, finalizedResult);
let body: string | undefined;
jest.mocked(fetch).mockImplementationOnce((async (_url: unknown, init?: RequestInit) => {
const chunks: Buffer[] = [];
@@ -161,8 +171,9 @@ describe('sandbox artifact uploads', () => {
});
await upload.completed;
expect(workerApi.createArtifactUploadSessionAsync).toHaveBeenCalledWith(
{ name: 'Empty log', filename: 'crash.log', size: 0 },
expect(mutation).toHaveBeenCalledWith(
expect.anything(),
{ sandboxId: 'sandbox-id', input: { name: 'Empty log', filename: 'crash.log', size: 0 } },
expect.anything()
);
expect(body).toBe('');
@@ -170,7 +181,7 @@ describe('sandbox artifact uploads', () => {
});
it('finalizes when a retry fails the upload precondition', async () => {
mockWorkerApi({ finalize: DOWNLOAD_URL });
mockMutationResults(createdResult, finalizedResult);
jest
.mocked(fetch)
.mockRejectedValueOnce(
@@ -188,20 +199,33 @@ describe('sandbox artifact uploads', () => {
await upload.completed;
expect(fetch).toHaveBeenCalledTimes(2);
expect(workerApi.finalizeArtifactAsync).toHaveBeenCalledWith(ARTIFACT_ID, expect.anything());
expect(mutation).toHaveBeenLastCalledWith(
expect.anything(),
{ artifactId: ARTIFACT_ID },
expect.anything()
);
expect(logger.error).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenLastCalledWith(
`Uploaded sandbox artifact "Crash log" (${ARTIFACT_ID}, 5 bytes).`
);
});
it('logs a finalize failure and reports the upload as failed', async () => {
mockWorkerApi({
finalize: new WorkerApiError(
'POST /v2/sandbox-worker/artifacts/x/finalize failed with status 502: Bad Gateway',
502
),
});
it('retries finalize after a server error and logs a failure that persists', async () => {
mockMutationResults(
createdResult,
{
error: new CombinedError({
networkError: new Error('Bad Gateway'),
response: { status: 502 },
}),
},
{
error: new CombinedError({
graphQLErrors: ['Sandbox artifact "Crash log" has no uploaded file.'],
response: { status: 200 },
}),
}
);
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
const upload = await uploadArtifactAsync({
@@ -211,23 +235,21 @@ describe('sandbox artifact uploads', () => {
});
await expect(upload.completed).resolves.toEqual({ status: 'failed' });
expect(mutation).toHaveBeenCalledTimes(3);
expect(logger.error).toHaveBeenCalledWith(
expect.anything(),
`Uploaded sandbox artifact "Crash log" (${ARTIFACT_ID}), but could not finalize it.`
);
});
it.each([
{ status: 400, code: 'VALIDATION_ERROR' },
{ status: 403, code: 'UNAUTHORIZED' },
])(
'rejects a $code upload session error as a bad request with the API message',
async ({ status, code }) => {
it.each(['VALIDATION_ERROR', 'UNAUTHORIZED'])(
'rejects a %s upload session error as a bad request with the GraphQL message',
async errorCode => {
const message =
'POST /v2/sandbox-worker/artifact-upload-sessions failed with status 400: Sandbox already has an artifact named "Crash log". Use a different name for this artifact.';
workerApi.createArtifactUploadSessionAsync.mockRejectedValueOnce(
new WorkerApiError(message, status, undefined, code)
);
'Sandbox already has an artifact named "Crash log". Use a different name for this artifact.';
mockMutationResults({
error: new CombinedError({ graphQLErrors: [{ message, extensions: { errorCode } }] }),
});
const upload = uploadArtifactAsync({
filePath,
@@ -243,16 +265,23 @@ describe('sandbox artifact uploads', () => {
}
);
it.each([
{ status: 500, code: 'UNEXPECTED_ERROR' },
{ status: 401, code: 'AUTHENTICATION_ERROR' },
])('rethrows a $code upload session error unchanged', async ({ status, code }) => {
const error = new WorkerApiError('Request failed.', status, undefined, code);
workerApi.createArtifactUploadSessionAsync.mockRejectedValueOnce(error);
it('rejects an unexpected upload session error as a system error', async () => {
mockMutationResults({
error: new CombinedError({
graphQLErrors: [
{ message: 'Request failed.', extensions: { errorCode: 'UNEXPECTED_ERROR' } },
],
}),
});
await expect(
uploadArtifactAsync({ filePath, name: 'Crash log', signal: new AbortController().signal })
).rejects.toBe(error);
const upload = uploadArtifactAsync({
filePath,
name: 'Crash log',
signal: new AbortController().signal,
});
await expect(upload).rejects.toBeInstanceOf(SystemError);
await expect(upload).rejects.toMatchObject({ message: 'Request failed.' });
});
it('rejects a missing file as a bad request without creating an upload session', async () => {
@@ -268,11 +297,11 @@ describe('sandbox artifact uploads', () => {
code: SandboxDaemonErrorCode.BAD_REQUEST,
message: `File does not exist: ${missingPath}`,
});
expect(workerApi.createArtifactUploadSessionAsync).not.toHaveBeenCalled();
expect(mutation).not.toHaveBeenCalled();
});
it('keeps the signed URL out of logs', async () => {
mockWorkerApi({});
mockMutationResults(createdResult);
jest.mocked(fetch).mockRejectedValue(
new FetchError(`request to ${SIGNED_URL} failed, reason: socket hang up`, 'system', {
code: 'ECONNRESET',
@@ -294,7 +323,7 @@ describe('sandbox artifact uploads', () => {
});
it('waits for an aborted upload before the manager reports that it stopped', async () => {
mockWorkerApi({});
mockMutationResults(createdResult);
mockPutUntilAborted();
const controller = new AbortController();
const manager = createManager(controller.signal);
@@ -317,24 +346,36 @@ describe('sandbox artifact uploads', () => {
).rejects.toMatchObject({
name: 'AbortError',
});
expect(workerApi.createArtifactUploadSessionAsync).toHaveBeenCalledTimes(1);
expect(mutation).toHaveBeenCalledTimes(1);
});
it.each([
{
case: 'a finalized upload as uploaded with its download URL',
finalize: DOWNLOAD_URL,
finalizeResult: finalizedResult,
outcome: { status: 'uploaded', downloadUrl: DOWNLOAD_URL },
},
{
case: 'a failed finalization as failed',
finalize: new WorkerApiError('Sandbox artifact "Crash log" has no uploaded file.', 400),
finalizeResult: {
error: new CombinedError({
graphQLErrors: ['Sandbox artifact "Crash log" has no uploaded file.'],
response: { status: 200 },
}),
},
outcome: { status: 'failed' },
},
{
case: 'a finalized artifact without a download URL as failed',
finalizeResult: {
data: { sandbox: { finalizeArtifact: { id: ARTIFACT_ID, downloadUrl: null } } },
},
outcome: { status: 'failed' },
},
])(
'waits for an upload that ends within the yield time and reports $case',
async ({ finalize, outcome }) => {
mockWorkerApi({ finalize });
async ({ finalizeResult, outcome }) => {
mockMutationResults(createdResult, finalizeResult);
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
const manager = createManager(new AbortController().signal);
@@ -349,10 +390,12 @@ describe('sandbox artifact uploads', () => {
const finalizeCalledPromise = new Promise<void>(resolve => {
finalizeCalled = resolve;
});
mockWorkerApi({});
workerApi.finalizeArtifactAsync.mockImplementationOnce(async () => {
finalizeCalled();
return DOWNLOAD_URL;
mockMutationResults(createdResult);
mutation.mockReturnValueOnce({
toPromise: async () => {
finalizeCalled();
return finalizedResult;
},
});
let respond!: (response: InstanceType<typeof Response>) => void;
jest.mocked(fetch).mockReturnValueOnce(
@@ -370,9 +413,11 @@ describe('sandbox artifact uploads', () => {
});
it('counts upload session creation toward the yield time', async () => {
workerApi.createArtifactUploadSessionAsync.mockImplementationOnce(async () => {
await delay(400);
return createdSession;
mutation.mockReturnValueOnce({
toPromise: async () => {
await delay(400);
return createdResult;
},
});
mockPutUntilAborted();
const controller = new AbortController();
@@ -389,7 +434,8 @@ describe('sandbox artifact uploads', () => {
function createManager(signal: AbortSignal): SandboxArtifactUploadManager {
return new SandboxArtifactUploadManager({
workerApi: workerApi as unknown as SandboxWorkerApi,
graphqlClient,
sandboxId: 'sandbox-id',
logger: logger as unknown as bunyan,
signal,
});
@@ -405,12 +451,9 @@ describe('sandbox artifact uploads', () => {
}) as unknown as typeof fetch);
}
function mockWorkerApi({ finalize }: { finalize?: string | Error }): void {
workerApi.createArtifactUploadSessionAsync.mockResolvedValueOnce(createdSession);
if (finalize instanceof Error) {
workerApi.finalizeArtifactAsync.mockRejectedValueOnce(finalize);
} else if (finalize !== undefined) {
workerApi.finalizeArtifactAsync.mockResolvedValueOnce(finalize);
function mockMutationResults(...results: unknown[]): void {
for (const result of results) {
mutation.mockReturnValueOnce({ toPromise: async () => result });
}
}
});
@@ -1,12 +1,12 @@
import { SandboxDaemonResponseZ, SystemError } from '@expo/eas-build-job';
import Log from '@expo/logger';
import { type Client } from '@urql/core';
import http from 'node:http';
import net, { AddressInfo } from 'node:net';
import WebSocket, { WebSocketServer } from 'ws';
import * as sandboxCommandImplementations from '../sandboxCommandImplementations';
import { startSandboxDaemonAsync } from '../sandboxDaemon';
import { type SandboxWorkerApi } from '../sandboxWorkerApi';
jest.unmock('@expo/logger');
jest.unmock('fs');
@@ -16,7 +16,7 @@ jest.unmock('node:fs/promises');
describe(startSandboxDaemonAsync.name, () => {
const logger = Log.child({ buildStepId: 'sandbox-test' });
const workerApi = {} as SandboxWorkerApi;
const graphqlClient = {} as Client;
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({
@@ -26,7 +26,8 @@ describe(startSandboxDaemonAsync.name, () => {
logger,
workingDirectory: process.cwd(),
env: process.env,
workerApi,
graphqlClient,
sandboxId: 'sandbox-id',
});
try {
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
@@ -52,7 +53,8 @@ describe(startSandboxDaemonAsync.name, () => {
logger,
workingDirectory: process.cwd(),
env: process.env,
workerApi,
graphqlClient,
sandboxId: 'sandbox-id',
});
try {
const socket = await connection;
@@ -87,7 +89,8 @@ describe(startSandboxDaemonAsync.name, () => {
logger,
workingDirectory: process.cwd(),
env: process.env,
workerApi,
graphqlClient,
sandboxId: 'sandbox-id',
});
try {
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
@@ -118,7 +121,8 @@ describe(startSandboxDaemonAsync.name, () => {
signal: controller.signal,
workingDirectory: process.cwd(),
env: process.env,
workerApi,
graphqlClient,
sandboxId: 'sandbox-id',
});
const connectionError = daemon.ready.catch(error => error);
@@ -147,7 +151,8 @@ describe(startSandboxDaemonAsync.name, () => {
logger,
workingDirectory: process.cwd(),
env: process.env,
workerApi,
graphqlClient,
sandboxId: 'sandbox-id',
});
await expect(daemon.ready).rejects.toThrow('Sandbox MCP server connection failed');
@@ -181,7 +186,8 @@ describe(startSandboxDaemonAsync.name, () => {
logger,
workingDirectory: process.cwd(),
env: process.env,
workerApi,
graphqlClient,
sandboxId: 'sandbox-id',
});
const socket = await connection;
const daemon = await daemonPromise;
@@ -213,7 +219,8 @@ describe(startSandboxDaemonAsync.name, () => {
logger,
workingDirectory: process.cwd(),
env: process.env,
workerApi,
graphqlClient,
sandboxId: 'sandbox-id',
});
const socket = await connection;
await daemon.ready;
@@ -257,7 +264,8 @@ describe(startSandboxDaemonAsync.name, () => {
logger,
workingDirectory: process.cwd(),
env: process.env,
workerApi,
graphqlClient,
sandboxId: 'sandbox-id',
});
const socket = await connection;
await daemon.ready;
@@ -342,7 +350,8 @@ describe(startSandboxDaemonAsync.name, () => {
logger,
workingDirectory: process.cwd(),
env: process.env,
workerApi,
graphqlClient,
sandboxId: 'sandbox-id',
});
const socket = await connection;
await daemon.ready;
@@ -387,7 +396,8 @@ describe(startSandboxDaemonAsync.name, () => {
logger,
workingDirectory: process.cwd(),
env: process.env,
workerApi,
graphqlClient,
sandboxId: 'sandbox-id',
});
const socket = await connection;
const daemon = await daemonPromise;
@@ -5,6 +5,7 @@ jest.unmock('node:fs/promises');
import { type SandboxDaemonCommandResult, SandboxDaemonErrorCode } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { type Client } from '@urql/core';
import fetch from 'node-fetch';
import { readFileSync, statSync } from 'node:fs';
import fs, { type FileHandle } from 'node:fs/promises';
@@ -18,7 +19,6 @@ import {
type SandboxDaemonCommandImplementations,
createSandboxCommandImplementations,
} from '../sandboxCommandImplementations';
import { type SandboxWorkerApi } from '../sandboxWorkerApi';
const READ_FILE_LIMITS = { maxTextBytes: 40_000, maxImageBytes: 3_000_000 };
const FIXTURES_DIRECTORY = path.join(__dirname, 'fixtures');
@@ -31,25 +31,22 @@ describe('sandbox daemon commands', () => {
let commandImplementations: SandboxDaemonCommandImplementations;
let stopAsync: () => Promise<void>;
let workingDirectory: string;
let workerApi: {
createArtifactUploadSessionAsync: jest.Mock;
finalizeArtifactAsync: jest.Mock;
};
let mutation: jest.Mock;
let graphqlClient: Client;
let logger: bunyan;
beforeEach(async () => {
workingDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'sandbox-command-executor-'));
workerApi = {
createArtifactUploadSessionAsync: jest.fn(),
finalizeArtifactAsync: jest.fn(),
};
mutation = jest.fn();
graphqlClient = { mutation } as unknown as Client;
logger = { info: jest.fn(), error: jest.fn() } as unknown as bunyan;
const controller = new AbortController();
const commands = createSandboxCommandImplementations({
workingDirectory,
env: process.env,
signal: controller.signal,
workerApi: workerApi as unknown as SandboxWorkerApi,
graphqlClient,
sandboxId: 'sandbox-id',
logger,
});
commandImplementations = commands.commandImplementations;
@@ -78,7 +75,8 @@ describe('sandbox daemon commands', () => {
workingDirectory,
env: process.env,
signal: controller.signal,
workerApi: workerApi as unknown as SandboxWorkerApi,
graphqlClient,
sandboxId: 'sandbox-id',
logger,
});
const command = commands.commandImplementations.execCommand({ cmd: 'printf should-not-run' });
@@ -432,17 +430,37 @@ describe('sandbox daemon commands', () => {
`${path.join(workingDirectory, 'dist')} is not a regular file. To upload a directory, create an archive first`
),
});
expect(workerApi.createArtifactUploadSessionAsync).not.toHaveBeenCalled();
expect(mutation).not.toHaveBeenCalled();
});
it('uploads an artifact from a path relative to the working directory and waits for it by default', async () => {
await fs.mkdir(path.join(workingDirectory, 'logs'));
await fs.writeFile(path.join(workingDirectory, 'logs', 'crash.log'), 'crash');
workerApi.createArtifactUploadSessionAsync.mockResolvedValueOnce({
artifact: { id: ARTIFACT_ID },
uploadSession: { url: 'https://uploads.expo.test/artifact', headers: {} },
});
workerApi.finalizeArtifactAsync.mockResolvedValueOnce('https://downloads.expo.test/artifact');
mutation
.mockReturnValueOnce({
toPromise: async () => ({
data: {
sandbox: {
createArtifactUploadSession: {
artifact: { id: ARTIFACT_ID },
uploadSession: { url: 'https://uploads.expo.test/artifact', headers: {} },
},
},
},
}),
})
.mockReturnValueOnce({
toPromise: async () => ({
data: {
sandbox: {
finalizeArtifact: {
id: ARTIFACT_ID,
downloadUrl: 'https://downloads.expo.test/artifact',
},
},
},
}),
});
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
const result = await commandImplementations.uploadArtifact({
@@ -455,8 +473,9 @@ describe('sandbox daemon commands', () => {
status: 'uploaded',
downloadUrl: 'https://downloads.expo.test/artifact',
});
expect(workerApi.createArtifactUploadSessionAsync).toHaveBeenCalledWith(
{ name: 'Crash log', filename: 'crash.log', size: 5 },
expect(mutation).toHaveBeenCalledWith(
expect.anything(),
{ sandboxId: 'sandbox-id', input: { name: 'Crash log', filename: 'crash.log', size: 5 } },
expect.anything()
);
});
@@ -1,132 +0,0 @@
import { ArchiveSourceType, EnvironmentSecretType, type Job } from '@expo/eas-build-job';
import { turtleFetch } from '../../../utils/turtleFetch';
import { SandboxWorkerApi, getSandboxWorkerApi } from '../sandboxWorkerApi';
jest.mock('../../../utils/turtleFetch');
jest.unmock('node-fetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
const BASE_URL = 'https://api.expo.test/v2/';
const TOKEN = 'sandbox-worker-token-value';
const ARTIFACT_ID = '0199c0de-7b3a-7c1e-8f00-1234567890ab';
function jsonResponse(body: unknown): InstanceType<typeof Response> {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
describe(getSandboxWorkerApi, () => {
beforeEach(() => {
jest.mocked(turtleFetch).mockReset();
});
it('returns null when the job has no sandbox worker token', () => {
expect(
getSandboxWorkerApi({
job: { secrets: { environmentSecrets: [] } } as unknown as Job,
env: { EAS_SANDBOX_WORKER_TOKEN: TOKEN },
expoApiV2BaseUrl: BASE_URL,
})
).toBeNull();
});
it('reads the token from the job secrets', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { id: 'sandbox-id' } }));
const api = getSandboxWorkerApi({
job: {
secrets: {
environmentSecrets: [
{ name: 'EAS_SANDBOX_WORKER_TOKEN', type: EnvironmentSecretType.STRING, value: TOKEN },
],
},
} as unknown as Job,
env: {},
expoApiV2BaseUrl: BASE_URL,
});
await api!.markReadyAsync();
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
'https://api.expo.test/v2/sandbox-worker/ready',
'POST',
expect.objectContaining({ headers: { 'expo-sandbox-worker-token': TOKEN } })
);
});
});
describe(SandboxWorkerApi, () => {
beforeEach(() => {
jest.mocked(turtleFetch).mockReset();
});
it.each([
{
name: 'getProjectArchiveSourceAsync',
call: (api: SandboxWorkerApi) => api.getProjectArchiveSourceAsync(),
route: 'project-archive',
json: undefined,
data: { type: ArchiveSourceType.URL, url: 'https://storage.expo.test/project.tar.gz' },
expected: { type: ArchiveSourceType.URL, url: 'https://storage.expo.test/project.tar.gz' },
},
{
name: 'markReadyAsync',
call: (api: SandboxWorkerApi) => api.markReadyAsync(),
route: 'ready',
json: undefined,
data: { id: 'sandbox-id' },
expected: undefined,
},
{
name: 'createArtifactUploadSessionAsync',
call: (api: SandboxWorkerApi) =>
api.createArtifactUploadSessionAsync({ name: 'Crash log', filename: 'crash.log', size: 5 }),
route: 'artifact-upload-sessions',
json: { name: 'Crash log', filename: 'crash.log', size: 5 },
data: {
artifact: { id: ARTIFACT_ID },
uploadSession: { url: 'https://uploads.expo.test/a', headers: { 'If-None-Match': '*' } },
},
expected: {
artifact: { id: ARTIFACT_ID },
uploadSession: { url: 'https://uploads.expo.test/a', headers: { 'If-None-Match': '*' } },
},
},
{
name: 'finalizeArtifactAsync',
call: (api: SandboxWorkerApi) => api.finalizeArtifactAsync(ARTIFACT_ID),
route: `artifacts/${ARTIFACT_ID}/finalize`,
json: undefined,
data: { id: ARTIFACT_ID, downloadUrl: 'https://downloads.expo.test/a' },
expected: 'https://downloads.expo.test/a',
},
])(
'$name calls POST /v2/sandbox-worker/$route with the sandbox worker token header',
async ({ call, route, json, data, expected }) => {
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data }));
await expect(call(new SandboxWorkerApi(BASE_URL, TOKEN))).resolves.toEqual(expected);
const [url, method, options] = jest.mocked(turtleFetch).mock.calls[0];
expect(url).toBe(`https://api.expo.test/v2/sandbox-worker/${route}`);
expect(method).toBe('POST');
expect(options.headers).toEqual({ 'expo-sandbox-worker-token': TOKEN });
expect(options.json).toEqual(json);
}
);
it('escapes the artifact ID in the finalize route', async () => {
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ data: { id: 'a', downloadUrl: 'https://d.test/a' } }));
await new SandboxWorkerApi(BASE_URL, TOKEN).finalizeArtifactAsync('../ready');
expect(jest.mocked(turtleFetch).mock.calls[0][0]).toBe(
'https://api.expo.test/v2/sandbox-worker/artifacts/..%2Fready/finalize'
);
});
});
@@ -16,11 +16,7 @@ import os from 'node:os';
import path from 'node:path';
import { WebSocketServer } from 'ws';
import {
runServeSimActionAsync,
runServeSimActionWithResultAsync,
stageServeSimAppAsync,
} from '../serveSimActions';
import { runServeSimActionAsync, stageServeSimAppAsync } from '../serveSimActions';
jest.unmock('fs');
jest.unmock('node:fs');
@@ -71,18 +67,6 @@ 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'],
@@ -153,55 +137,6 @@ 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);
@@ -0,0 +1,196 @@
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,17 +22,29 @@ 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('warns and resolves when the upload fails', async () => {
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 () => {
await writeFile(filePath, '{}\n');
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockRejectedValueOnce(new Error('storage unavailable'));
.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;
});
await expect(uploadServeSimLogsFileAsync(ctx, args)).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
@@ -1,153 +0,0 @@
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();
}
);
Loaded 100 of 320 files, more files were not shown because too many files have changed in this diff. Show more