Compare commits

...
Author SHA1 Message Date
Expo CI 01e3be71aa v24.12.1 2026-10-08 21:49:04 +00:00
SJ Kim 991bbeae8c [eas-cli] Move the #4522 and #4483 changelog entries to their releases (#4576) 2026-10-08 17:43:35 -04:00
SJ Kim 6c342abb97 [eas-cli] Stop a simulator session created after Ctrl+C during create (ENG-27606) (#4522)
* [eas-cli] Log client request events for simulator sessions (ENG-27138)

* [eas-cli] Flush analytics and exit with 130 when Ctrl+C cancels a simulator session request (ENG-27138)

* [eas-cli] Add changelog entry for simulator request events (ENG-27138)

* [eas-cli] Bound the Ctrl+C analytics flush and tighten its tests (ENG-27138)

* [eas-cli] Stop a session created after Ctrl+C during create (ENG-27138)

* [eas-cli] Don't count an HTTP error answer as a failed simulator session request (ENG-27138)

* [eas-cli] Add changelog entry for #4522 (ENG-27606)

* [eas-cli] Keep the simulator request analytics wrapper with its only caller (ENG-27138)

* [eas-cli] Keep a failed Ctrl+C stop test from hanging later simulator tests (ENG-27606)

* [eas-cli] Accept an unsent create request when stopping a session after Ctrl+C
2026-10-08 16:28:09 -04:00
Stanisław Chmiela 1016aa35f3 [build-tools] Skip WDA cache for DeviceLab Runner versions (#4560)
# Why

<img width="487" height="329" alt="Zrzut ekranu 2026-10-7 o 10 29 04" src="https://github.com/user-attachments/assets/a63f0550-bf93-46ed-83c7-187faa2ac20b" />

# How

Skip WDA cache preparation for detected Runner versions 1.1.28 and later.

# Test Plan

CI should pass.
2026-10-08 14:16:53 +02:00
Stanisław Chmiela ceb8f846c2 [build-tools] Address Android app info review feedback (#4567)
# Why

I merged pull request without noticing review comments. #4514.

# How

Adhered to review comments. Added integration tests using real APK/AAB and real tools, fixed error code.

# Test Plan

CI should pass. Validated locally.
2026-10-08 12:10:43 +02:00
Stanisław Chmiela 53cb3a7263 [build-tools] Add typed Google Play API client (#4515)
* [build-tools] Add authenticated Google Play API client

* [build-tools] Remove Android fixture dependency from client tests

* [build-tools] Add typed Google Play API methods with separate OAuth transport

* [build-tools] Simplify Google Play client and remove retry changes

* [build-tools] Improve Google Play client error details

* [build-tools] Load Google Play tokens with service account keys

* [build-tools] Extract Google Play assertion helper

* [build-tools] Move token creation into GooglePlayAuthUtils

* [build-tools] Make assertion helper private and capitalize utility filename

* [build-tools] Remove upload support from Google Play client PR

* [build-tools] Improve Google Play client schema spacing

* [build-tools] Reuse Google Play release schema
2026-10-08 10:39:56 +02:00
Stanisław Chmiela 09188ab271 [build-tools] Add typed Google Play OAuth API client (#4542)
* [build-tools] Add typed Google Play OAuth API client

* [build-tools] Align Google Play OAuth errors and response parsing

* [build-tools] Include Google Play OAuth error diagnostics

* [build-tools] Keep OAuth diagnostics without response secrets
2026-10-08 10:30:18 +02:00
Stanisław Chmiela c4ffc1605e [build-tools] Add Android artifact inspection function (#4514)
* [build-tools] Add Android artifact inspection function

* [build-tools] Use Android tools to inspect app artifacts

* [build-tools] Read Android info from downloaded binary paths

* [build-tools] Detect Android type before reading package name

* [build-tools] Read APK package name with aapt2 packagename

* [build-tools] Keep Android package readers explicit

* [build-tools] Pass step cancellation to Android app inspection

* [build-tools] Simplify Android type dispatch and ZIP lookup

* [build-tools] use caller cancellation for Android artifact inspection

* [build-tools] Run checks after rebasing Android artifact inspection
2026-10-08 10:23:15 +02:00
Expo CI d90f022966 bump STAGING and PRODUCTION to 24.12.0 in cli-versions.json 2026-10-07 21:20:27 +00:00
Expo CI 8b8362e4ae update CHANGELOG.md for v24.12.0 2026-10-07 21:17:55 +00:00
Expo CI b5ac163b37 v24.12.0 2026-10-07 21:15:21 +00:00
Douglas LowderandClaude Opus 5 92c8944cda [eas-cli] Include sourceMapUrl and assetMapUrl in eas update:view --json (#4534)
For update:view, add both asset map and source map URLs to the returned results when --json is used. This allows CLI users to retrieve source maps needed for symbolicating JS crashes.

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-10-07 21:06:12 +00:00
Douglas LowderandClaude Opus 5.5 96581b8094 [ci] Move ffmpeg-dependent tests to a separate workflow (#4565)
For better performance of the main test workflow, move ffmpeg-dependent tests to a separate workflow, with a restricted set of source files in the workflow trigger, and ensure that this workflow is not required for PRs.

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 14:00:32 -07:00
Gabe Debes e229f5378f [build-tools] Show eas update:embedded:upload output in build logs (#4557) 2026-10-07 13:38:05 -07:00
Gabe Debes 19657cc7ff [build-tools] Fix unnotarized mitmproxy cask install on iOS network capture sessions (#4556) 2026-10-07 11:04:55 -07:00
Tomasz Mazur be7acb7ab5 [eas-cli] Fix workflow:ssh lookup for job run ids (#4529) 2026-10-07 17:42:49 +02:00
Gabe DebesandClaude 3c302a055d [build-tools] Upload embedded bundle from custom builds (#4494)
* [build-tools] Upload embedded bundle from eas/build custom builds

Co-Authored-By: Claude  <[email protected]>

* [build-tools] Add ignore_error to eas/upload_embedded_bundle and share archive path resolvers

Co-Authored-By: Claude <[email protected]>
2026-10-06 15:06:48 -07:00
Samuel Newman 449bd4981e Select containing iOS app when archive includes an App Clip (#4541)
For build:download, exclude nested app bundles (like app clips) from the installable candidates. Return the sole outer app without prompting, and keep the selection prompt when there are genuinely separate apps.
2026-10-06 09:58:17 -07:00
Szymon Dziedzic 4fd36987ba [build-tools] Encode macOS session previews with cwebp (#4549)
* [build-tools] Encode macOS session previews with cwebp

Homebrew's FFmpeg is built without libwebp, so every macOS preview failed
with "Encoder not found" (exit code 8). Resize the screenshot with sips and
encode it with cwebp from Homebrew's webp package instead. Android keeps
using FFmpeg.

* [build-tools] Add changelog entry for macOS session preview encoding

* [build-tools] Format the changelog entry
2026-10-06 18:14:16 +02:00
Szymon Dziedzic 01c1ffcaf4 [build-tools] Fix the session preview device reference on main (#4548)
#4398 was based on #4499, which renamed device to readyDevice in
startDeviceSessionHostAsync. #4540 reverted #4499 before #4398 merged, so
main referred to a readyDevice name that no longer exists and the
build-tools typecheck failed. Use device instead.
2026-10-06 14:10:28 +02:00
Szymon Dziedzic b10cc829bd [build-tools] Refresh simulator session preview artifacts (#4398)
Capture a WebP thumbnail of the session device every minute while the
session host runs, and upload it to one reusable preview artifact. Upload
request errors no longer include the signed upload URL in logs.
2026-10-06 13:54:42 +02:00
SJ Kim 3d8ed4c81f [eas-cli] Log client request events for simulator sessions (#4483)
* [eas-cli] Log client request events for simulator sessions (ENG-27138)

* [eas-cli] Flush analytics and exit with 130 when Ctrl+C cancels a simulator session request (ENG-27138)

* [eas-cli] Add changelog entry for simulator request events (ENG-27138)

* [eas-cli] Bound the Ctrl+C analytics flush and tighten its tests (ENG-27138)

* [eas-cli] Don't count an HTTP error answer as a failed simulator session request (ENG-27138)

* [eas-cli] Keep the simulator request analytics wrapper with its only caller (ENG-27138)
2026-10-05 23:10:34 -04:00
Expo CI a344c0e1e8 bump STAGING and PRODUCTION to 24.11.0 in cli-versions.json 2026-10-05 22:05:17 +00:00
Expo CI f80f548e2f update CHANGELOG.md for v24.11.0 2026-10-05 22:03:02 +00:00
Expo CI c72ae9f9f6 v24.11.0 2026-10-05 22:00:12 +00:00
SJ Kim bf0420cd29 [eas-cli] Send the request origin and coding agent when creating a simulator session (#4523)
* [eas-cli] Regenerate GraphQL types (ENG-27135)

* [eas-cli] Send the request origin when eas simulator creates a session (ENG-26853)

* [eas-cli] Send the coding agent when eas simulator creates a session (ENG-27135)

* [eas-cli] Add the PR number to the changelog entry (ENG-27135)

* [eas-cli] Leave out the coding agent when analytics are disabled (ENG-27135)
2026-10-05 13:49:07 -04:00
Szymon Dziedzic 738ccb1ff6 Revert "[build-tools] Confine agent-device sessions to the session device (#4…" (#4540)
This reverts commit c487bcf1df.
2026-10-05 19:03:12 +02:00
Szymon DziedzicandClaude Opus 5.5 c487bcf1df [build-tools] Confine agent-device sessions to the session device (#4499)
* [build-tools] Confine agent-device sessions to the session device

Launch the agent-device daemon with a daemon policy
(AGENT_DEVICE_DAEMON_POLICY, agent-device 0.21.17 and later) that allows
only the booted device and denies booting and shutting down devices.

agent-device still installs during the boot. The daemon launches when
the boot reports the device. The policy file is removed when the daemon
stops or fails to launch. A daemon without `policyDigest` in daemon.json
does not enforce the policy, so the step logs a warning.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [build-tools] Serve the booted Simulator in agent-device sessions

Pass the booted UDID to serve-sim, so the web preview shows the device
that the daemon policy allows. Without it, serve-sim picks a simulator
itself and can show another booted one. The session host fails when
serve-sim reports another device.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [build-tools] Launch the agent-device daemon during the iOS Simulator boot

Look up the Simulator UDID before the boot and boot that UDID. The
daemon policy needs only the UDID, so on iOS the daemon launches during
the boot again, as before the policy. On Android the emulator serial is
known only after the boot, so the daemon still launches after it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 16:25:51 +02:00
Szymon DziedzicandClaude Opus 5.5 bcaaaddab8 [build-tools] Report the web preview as webPreviewUrl and webPreviewToken (#4467)
* [build-tools] Report the web preview as webPreviewUrl and webPreviewToken

Also keep the legacy previewUrl and previewToken keys, because SERVE_SIM
sessions still read them.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* [build-tools] Add changelog entry

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-05 16:23:24 +02:00
Szymon DziedzicandClaude Opus 5.5 4e432fa537 [build-tools] Upload the agent-device screenshot display rotation as artifact metadata (#4508)
* [build-tools] Upload the agent-device screenshot display rotation as artifact metadata

agent-device reports displayRotation on screenshot entries in its /artifacts
inventory. Read it and pass it to the device run session artifact upload as
metadata.displayRotation. An unknown value is dropped so it cannot block the
upload, and an artifact without the field uploads no metadata, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [changelog] Add entry for #4508

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 15:49:47 +02:00
Szymon DziedzicandClaude Opus 5.5 817d06e66e [build-tools] Remove the eas/start_agent_device_remote_session step (#4518)
* [build-tools] Remove the eas/start_agent_device_remote_session step

The server now submits the single `eas/start_agent_device_session` step
for every agent-device session (expo/universe#31785), so remove the old
`eas/start_agent_device_remote_session` step.

- `runAgentDeviceRemoteSessionAsync` and the daemon helpers stay; the
  single step uses them. Its `launch` option goes away: only the old step
  passed serve-sim launch inputs, and the single step launches the app
  with simctl / adb.
- The tests that ran the shared session code through the old step now
  call `runAgentDeviceRemoteSessionAsync`, or run the single step with a
  mocked boot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [build-tools] Add changelog entry

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 15:19:54 +02:00
Stanisław Chmiela 76a9d17ea7 [build-tools] Diagnose TestFlight group assignment failures (#4521)
## Why

Group assignment can fail after upload processing completes because the build needs export compliance information. The error should identify the Apple state and explain how to resolve it on the already-uploaded build.

Example: https://expo.dev/uuid/01a0f7f6-42df-7673-af23-da067af13847

## How

After any failed group assignment, call `verifyInternalBuildStateValidForGroupAssignmentAsync` to fetch `buildBetaDetail`, log the internal TestFlight state, and capture a Sentry event. Cache its promise so subsequent failures do not repeat the lookup, log, or capture. The function throws a specific error for missing or pending export compliance. Otherwise it returns, and each group retains its own assignment error and the existing internal-group guidance.

Capture all internal states before throwing. Include the state in the Sentry message title so states have separate message groups, with the build ID and original assignment error as context. Use UNKNOWN when the diagnostic lookup fails.

Continue trying other groups before failing the job. Successful assignments do not fetch the state. There is no polling loop.

## Test Plan

- All 32 metadata tests passed, including two failed assignments followed by a successful assignment with one shared lookup, log, and capture. Export-compliance failures share the rejection; other failures retain each group's original error.
- Build-tools typecheck, repository lint, formatting, and diff checks passed.
- No new live Apple test for this change.
2026-10-05 11:36:22 +02:00
Hirbod 8ce87bfa56 [build-tools] allow expo.test subdomains for simulator previews (#4480) 2026-10-03 14:31:52 +02:00
2b7e8454e3 [build-tools] Replace record-sim with serve-sim recording (#4474)
* [build-tools] Replace record-sim with serve-sim recording

Co-authored-by: Codex <[email protected]>
Co-Authored-By: Claude <[email protected]>

* [build-tools] Avoid failure warnings for saved iOS recordings
2026-10-02 15:33:19 -07:00
Stanisław Chmiela 962a2a733e [build-tools] Retry temporary Apple request failures safely (#4520)
* [build-tools] retry temporary Apple request failures safely

* [build-tools] distinguish complete assets from uploaded chunks

* [build-tools] document Apple request recovery

* [build-tools] use connection retry policy and isolate commit recovery

* [build-tools] simplify upload commit recovery and limit reset retries to GET

* [build-tools] accept object-only retry options

* [build-tools] define simple explicit retry options

* [build-tools] adapt commit tests to the key-based ASC client

* [build-tools] keep Apple retry changes in the unreleased changelog

* [build-tools] recover uncertain UPLOAD_COMPLETE commits

* [build-tools] require upload commit logger

* [build-tools] retry uncertain upload commits through the outer retry

* [build-tools] check upload state after the final uncertain commit

* [build-tools] use try catch and classify pending commits as system errors

* [build-tools] limit upload recovery to interrupted connections
2026-10-02 21:40:18 +00:00
Eliot Hertenstein 9e13c1e820 [eas-cli] Add resumable device login (#4502) 2026-10-02 14:37:02 -07:00
Expo CI cadb7b2d1e bump STAGING and PRODUCTION to 24.10.0 in cli-versions.json 2026-10-02 20:58:58 +00:00
Expo CI 718c82d8e1 update CHANGELOG.md for v24.10.0 2026-10-02 20:55:55 +00:00
Expo CI 5d31ecc2bc v24.10.0 2026-10-02 20:53:41 +00:00
Gabe DebesandClaude Opus 5.5 890286a08d [eas-cli] add --network-capture to eas simulator (#4308)
* [eas-cli] add network capture flags to eas simulator

* [eas-cli] regenerate GraphQL for network capture inputs

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* [eas-cli] move the network capture changelog entry to main

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-02 13:31:43 -07:00
Expo CI 33ff59a75e bump STAGING and PRODUCTION to 24.9.0 in cli-versions.json 2026-10-02 17:41:52 +00:00
Expo CI c2bbba6e78 update CHANGELOG.md for v24.9.0 2026-10-02 17:35:45 +00:00
Expo CI 86b85e7d8d v24.9.0 2026-10-02 17:32:47 +00:00
Gabe DebesandClaude 03a7d3a6ee [eas-cli] Only forward --non-interactive from update:rollback in non-interactive mode (#4504)
* [eas-cli] only forward --non-interactive from update:rollback when it was passed

* [eas-cli] fix the changelog entry

Co-Authored-By: Claude <[email protected]>
2026-10-02 10:15:42 -07:00
Gabe DebesandClaude Opus 5.5 a21db49184 [eas-cli] Allow new updates to end active rollouts (#4233)
* [eas-cli] allow publishing over an in-progress rollout

* [eas-cli] support superseding a rollout with a new rollout

* [eas-cli] prompt before starting the publish spinner

* [eas-cli] group the active rollout warning by runtime version

* [eas-cli] cover the active rollout paths

* [eas-cli] explain why the superseded rollout serves more users

* [eas-cli] cover publishing without the rollout check

* [eas-cli] refuse rolling out over a rollout in progress

* [eas-cli] rewrite the rollout publish errors as what, why, how

* [eas-cli] render the active rollout warning with renderTextTable

* [eas-cli] regenerate the GraphQL schema against the deployed API

* [eas-cli] add the new required event name fields to observe test fixtures

* [eas-cli] preserve interactive rollout confirmation during rollback

* [eas-cli] tweak changelog

* [eas-cli] require --force-end-active-rollout instead of prompting

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-02 10:04:41 -07:00
Stanisław Chmiela 6dd6da083d [build-tools] Pass cancellation signal to download_build (#4511)
## Why

Just a small improvement.

## How

Added `signal` from step to `downloadBuild` func.

## Test Plan

CI should pass.
2026-10-02 16:39:52 +00:00
Stanisław Chmiela 656921d776 [build-tools] Cache and refresh ASC tokens for long submissions (#4497)
* [build-tools] refresh ASC tokens for long submissions

* [build-tools] link token fix changelog

* [build-tools] own ASC token caching in the API client

* [build-tools] load ASC keys outside the API client

* [build-tools] extract metadata key and client variables

* [build-tools] extract upload API key variable

* [build-tools] name ASC token lifetime and refresh margin
2026-10-02 18:34:24 +02:00
Szymon Świerk fabde0c2bb [build-tools] ASC API client: in beta group response, allow hasAccessToAllBuilds to be nullish (#4528)
* [build-tools] ASC API client: in beta group response, allow hasAccessToAllBuilds to be nullish

* [build-tools] Fill in PR number in changelog
2026-10-02 15:42:24 +02:00
Stanisław Chmiela 7680f33879 [build-tools] Explain missing App Store Connect agreements (#4513)
* [build-tools] Explain missing App Store Connect agreements

* [build-tools] Preserve extra Apple error fields and add changelog

* [build-tools] Handle aggregate App Store Connect errors in callers

* [build-tools] Give each App Store Connect request error a single code
2026-10-02 12:39:13 +02:00
Stanisław Chmiela 199a996b38 [eas-cli] Fix simulator status compatibility with the live GraphQL schema (#4527)
GraphQL got outdated.
2026-10-02 12:28:27 +02:00
Brent Vatne 4728c64d3f [eas-cli] Correct simulator:start --max-duration-minutes help text (#4524) 2026-10-01 19:27:23 -07:00
Brent Vatne 0fdf96e795 Support --egress local for Android emulator sessions (#4519) 2026-10-01 18:14:11 -07:00
Gabe DebesandClaude 04f3bee0c4 [build-tools] install mitmproxy and pass network capture options to iOS simulator sessions (#4307)
Co-authored-by: Claude <[email protected]>
2026-10-01 16:56:04 -07:00
Gabe DebesandClaude 140cfe445a [build-tools] Poll the simulator sessions web preview readiness every 250ms (#4496)
The host answers /readyz locally within milliseconds, but the step
slept 1 s between polls, so a ready host waited up to a second more
before the session went live.

Co-authored-by: Claude <[email protected]>
2026-10-01 16:30:53 -07:00
Yuval GnessinandClaude Opus 5.5 da55f26cac [eas-cli] Fix eas workflow:validate crashes and exit code (#4507)
* [eas-cli] Fix eas workflow:validate crashes and exit code

- Skip job-schema variants without a `type` (reusable-workflow calls)
  when listing valid job types; the live schema now includes one, which
  crashed every validation with "reading 'const'".
- Check `repack` job profiles only when `params.profile` is set; it is
  optional, and the unset case crashed with "reading 'includes'".
- Set a non-zero exit code when validation fails.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015v5pSST6sHZiUiPkU7sq47
2026-10-01 15:00:18 -07:00
Douglas LowderandClaude Opus 5 e24130c6e0 [eas-cli] Add --upload-source-maps to eas update (#4436)
* [eas-cli] Add --upload-source-maps to eas update

Upload the source map that expo export emits for each native platform,
so EAS Observe can symbolicate stack traces from an update.

The maps are not listed in metadata.json, so each one is located from the
bundle that metadata.json does name, falling back to a single sibling
.map file. sourcesContent is always stripped, which keeps application
source text out of EAS storage and gives the map a chance of fitting
under the 5 MiB upload limit. A map that is still too large is skipped
with a warning; publishing never fails because of a source map.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-01 14:05:49 -07:00
Brent Vatne 2a0fe12836 [build-tools] Upload embedded bundle by default on SDK 58+ and rename env var (#4433) 2026-10-01 20:16:44 +00:00
SJ Kim 4c153da14d [eas-cli] Keep following simulator sessions in any status that isn't final (#4506)
* [eas-cli] Keep following simulator sessions in any status that isn't final (ENG-26991)

* [eas-cli] Add changelog entry for #4506 (ENG-26991)
2026-10-01 15:56:24 -04:00
Vojtech NovakandClaude Opus 5.5 81166df987 [build-tools] Upload manual preview screenshots as session artifacts (#4481)
* [build-tools] Upload manual preview screenshots as session artifacts

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Address review findings on preview screenshot uploads

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Report retained preview screenshots to Sentry once and throttle retry warnings

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Remove duplicated preview screenshot tests

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Give the host test's spawn mock the getExitError member main added

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Report preview-side screenshot save failures to Sentry

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Report preview screenshot save failures from the host's failure records

The host now writes a structured record into the watched directory instead of
the worker scanning its stderr at shutdown.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Name the patterns used to build screenshot artifact names

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Split the preview screenshot collector into single-purpose functions

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Keep the preview screenshot directory while the session host still runs

The collector finished and removed an empty directory even when the host could not be stopped,
so later captures went into a directory no one watched. It now takes hostStopped, warns and keeps
the directory when the host is still running, and uses rmdir so a capture that lands after the
final scan is retained instead of deleted.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Upload fresh preview screenshots before retrying failed ones

Each pass uploaded files in readdir order, so on macOS the oldest failed capture went first every
time and a stalled one held back newer captures and could use the whole shutdown budget. Captures
that never failed now go first, oldest first. The symlink test uses fixed names so the link sorts
before its target and a symlink-following regression always fails it.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Keep signed upload URLs out of device run session artifact errors

node-fetch puts the request URL in its error message, and the artifact PUT goes to a signed URL,
so a dropped connection wrote the signature to the job log and, for retained screenshots, to
Sentry. Network errors from the PUT are now rethrown with the artifact ID and error code only,
without the original as the cause. They stay retryable.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Tag preview screenshot artifacts with __eas_type

Recordings and performance metrics set __eas_type to their kind; screenshots did not.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Make two preview screenshot tests assert what their names claim

The session-lookup fallback test now checks the fallback name and metadata, and the host test
checks that the screenshot directory still exists when the host is stopped, which fails if the
collector finishes first.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Format the preview screenshot changes

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Call leftover preview screenshots "not uploaded"

"Retained" suggested the files were kept safe, but they are destroyed with the worker. The
shutdown report now says "N preview screenshots were not uploaded.".

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-01 14:23:59 +02:00
Stanisław Chmiela ef189b00be [build-tools] Nicer TestFlight metadata update output (#4503)
# Why

While expanding `eas/update_testflight_metadata` to more users I kept a close eye on the rollout and the results. I think there's a few improvements we can make to the output of metadata updates.

# How

- moved group verification into the p-limited group-managing function so it doesn't block changelog from being updated
- made lack of primary locale NOT a blocker -- we now fall back to en-US
- changed processing logic so one failure does not abort all updates
- if a user lists a group that is going to get the build automatically distributed to them, we mark it as "ok" instead of throwing

# Test Plan

Tested live.
2026-10-01 14:20:50 +02:00
Szymon Świerk 27e56afd12 [eas-build-job] Add schema for workflow Sentry interpolation context (#4453)
* [eas-build-job] Add schema for workflow Sentry interpolation context

* [eas-build-job] Add assignee, actor, priority, and count fields to Sentry context schema
2026-10-01 14:18:29 +02:00
Szymon DziedzicandClaude Opus 5.5 1c342621c0 [build-tools] Add a single step for agent-device sessions that starts independent parts in parallel (#4491)
* [build-tools] Add a single step for agent-device sessions that starts independent parts in parallel

Add `eas/start_agent_device_session`. It replaces the boot step,
`eas/download_build`, `eas/install_build`, `eas/launch_application` and
`eas/start_agent_device_remote_session` for agent-device sessions, so the
parts that do not depend on each other run at the same time: the device
boot, the build download, ffmpeg on Android, and the agent-device daemon
and its tunnel. The session host starts after the boot, and the install
after the boot and the download. Each part logs its duration, and a
summary line is logged at ready.

The old steps keep their behavior and share the code:
- `bootIosSimulatorAsync` and `startAndroidEmulatorAsync` are extracted
  from the boot steps.
- `runAgentDeviceRemoteSessionAsync` is extracted from
  `eas/start_agent_device_remote_session`. It also starts the daemon and
  the session host in parallel there, and it now stops the daemon when
  its credentials never appear.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [build-tools] Add changelog entry

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [build-tools] Abort agent-device session startup on the first failure

Review follow-ups for `eas/start_agent_device_session`:

- `StartupTasks` now has an abort signal. The first failed task aborts
  it. The download passes it to the request and the file stream, the
  install task checks it before the install and the launch, and the
  session host stops waiting for the boot when it fires. So a failed
  startup stops quickly, its teardown cannot wait for a stalled download,
  and nothing installs or launches after a failure.
- The install task waits for the download to settle, so a failed step no
  longer returns while the download still runs.
- Remove the ffmpeg prefetch: the Linux image now ships ffmpeg, and the
  session host still checks for it.
- Each startup task logs through a child logger with a `startupTask`
  field, so the lines of parallel tasks can be told apart.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [build-tools] Cancel the build lookup, extraction and daemon setup on startup abort

More review follow-ups for `eas/start_agent_device_session`. The startup
abort signal now reaches the work that can stall, not only the checks
between stages:

- The build-ID GraphQL lookup gets it through `graphqlAbortContext`.
- `decompressTarAsync` takes an optional signal. With one, it feeds the
  archive to a `tar.Unpack` that the abort stops. Without one, callers
  keep `tar.extract`. The archive scan does not start after an abort.
- The agent-device package install, the git clone and the git install
  get it, so an abort kills them. An abort does not trigger the git
  fallback, and the daemon does not start after one.
- The session stops waiting for the daemon credentials on an abort. The
  daemon is already stored, so the teardown stops it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [build-tools] Let a started build extraction finish before reporting the abort

`tar.Unpack.abort()` rejects at once but cannot stop the file writes that
the extraction already started, so the download could settle while tar
still wrote files. `decompressTarAsync` goes back to `tar.extract`
unchanged. `downloadBuildAsync` checks the startup signal right before
and right after the extraction instead: an abort during the extraction
lets this local, bounded work finish and is reported right after it, so
no file is written after the download settles.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-01 12:52:20 +02:00
Expo Bot 9b7098cb58 [license] Update BSL Change Date to 2029-10-01 2026-10-01 00:59:39 +00:00
SJ Kim 9b247a6197 [build-tools] Collect shared simulator logs (#4463)
* [build-tools] Collect shared simulator logs (ENG-26642)

* [build-tools] Link simulator log changelog entry (ENG-26642)

* [build-tools] Require user-app scope for session logs (ENG-26642)

* [build-tools] Name simulator log artifacts app-logs.ndjson (ENG-26642)

* [build-tools] Time out simulator log uploads per device (ENG-26642)

* [build-tools] Report simulator log failures to Sentry (ENG-26642)

* [build-tools] Use a type import for the log stream body (ENG-26642)

* [build-tools] Validate serve-sim log envelopes and report malformed records (ENG-26642)
2026-09-30 16:25:48 -04:00
Stanisław Chmiela 862d0a8f09 [build-tools] Clear old Maestro HTML reports before each attempt (#4409)
It seems when auto-retrying an old HTML report may be uploaded instead of a more recent one. This clears reports between attempts.

(I think behavior of auto-retry + relying on HTML reports is a bit wonky so I guess users should then use the "retry-all" retry behavior.)
2026-09-30 09:15:04 +02:00
Szymon Dziedzic 417996ade0 [eas-cli] Keep the simulator session spinner on one line (#4488) 2026-09-29 21:41:58 +02:00
Stanisław Chmiela 1c6161432a [build-tools] Add "native" TestFlight metadata step (#4475)
# Why

While working on moving off of Submission Service to job runs I found a feature discrepancy — Submission Service supported changelog and assigning to groups, but job runs did not.

# How

Added an EAS function that uses ASC API to add changelog to build and build to groups. It more or less follows what Fastlane does.

See https://github.com/expo/universe/pull/31473.

# Test Plan

- The deployed worker ran the built-in step against an existing Apple build: [staging workflow](https://staging.expo.dev/accounts/sjchmiela/projects/staging-app/workflows/01a0d929-8130-74b2-b014-0b95431907c2).
- Full end-to-end staging test passed with a [fresh signed IPA](https://staging.expo.dev/accounts/sjchmiela/projects/staging-app/builds/45c69b90-649f-4908-a50a-49044e92b18d): [workflow](https://staging.expo.dev/accounts/sjchmiela/projects/staging-app/workflows/01a0d934-b692-71ad-9aba-f9acec8d3888). `eas/upload_to_asc` returned upload ID `8ee25971-3463-4d6d-856f-947645988a7a`. The built-in step selected Apple build `3880e9fc-bf7b-430c-a865-e399a1398667`. Separate Apple API reads confirmed the exact changelog, test-group membership, and no beta review submission.
2026-09-29 13:51:40 +00:00
Stanisław Chmiela 3f667ae4a8 [build-tools] Support maestro-runner HTML and Allure reports (#4406)
We're seeing customers have good experience with `maestro-runner`. Let's add some support for exporting HTML and Allure reports from it.

Runner report generation: https://github.com/devicelab-dev/maestro-runner/blob/main/pkg/cli/test.go
2026-09-29 15:17:03 +02:00
Szymon DziedzicandClaude Opus 5.5 4fb6de564c [build-tools] Disable agent-device internal idle timeouts in remote sessions (#4485)
* [build-tools] Disable agent-device internal idle timeouts in remote sessions

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [build-tools] Add changelog entry

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-09-29 12:29:30 +02:00
SJ Kim 975693e96e [build-tools] Detect early Argent startup exits (#4454)
* [build-tools] Detect early Argent startup exits (ENG-22026)

* [build-tools] Observe startup exit before pipe closure (ENG-22026)
2026-09-28 23:10:40 -04:00
Brent Vatne b84233bf07 [eas-cli] Recount spinner lines when the terminal resizes (#4470) 2026-09-28 18:14:11 -07:00
Stanisław Chmiela 6dec2f9a13 [eas-cli] Allow preparing submission credentials without loading app config (#4471)
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Having to load app config requires installing node modules which takes time and requires thinking about Node.js version, images etc.

# How

Added two optional parameters to `submit:internal` — `project-id` and `application-identifier`. With them provided we don't need to load project config, so we don't need to install dependencies!

# Test Plan

I'll land this, then land https://github.com/expo/universe/pull/31467 in staging and see if it works!
2026-09-28 21:15:19 +02:00
Vojtech NovakandClaude Fable 5.1 c15e184bb8 [ci] Install ffmpeg so the recording recovery test runs (#4441)
* [ci] Install ffmpeg so the recording recovery test runs

The build-tools test that recovers unlisted recordings from a killed
host cuts a real MP4 and needs ffprobe. It skipped on every CI run.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D29AEWN9ajZXbzobHUH4fF

* [build-tools] Fail the recording recovery test loudly without ffmpeg

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D29AEWN9ajZXbzobHUH4fF

* [ci] Update the ffmpeg install comment

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D29AEWN9ajZXbzobHUH4fF

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-28 12:57:58 +02:00
Vojtech NovakandClaude Fable 5.1 89740dbbfd [build-tools] Share the urql abort-signal wrapper (#4435)
* [build-tools] Add one helper that joins a caller's signal into urql fetch

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D29AEWN9ajZXbzobHUH4fF

* [build-tools] Use the shared urql abort helper in both call sites

The artifact upload-session mutation and the sandbox-ready mutation each
wrapped fetch by hand to join a cancellation signal after urql set its own.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D29AEWN9ajZXbzobHUH4fF

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-28 12:57:58 +02:00
Vojtech NovakandClaude Fable 5.1 390a165c57 [build-tools] Share remote session teardown across the automation steps (#4431)
* [build-tools] Cover a teardown failure after the session body failed

The error that ended the session must reach the step, not the teardown error.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D29AEWN9ajZXbzobHUH4fF

* [build-tools] Share remote session teardown across the automation steps

The Agent Device, Appium and Argent steps each ran Promise.allSettled over
their teardown and rethrew the first rejection from a finally block, which
replaced the error that had ended the session. One helper now runs every
teardown, logs each failure, and rethrows only when the session body succeeded.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D29AEWN9ajZXbzobHUH4fF

* [build-tools] Attempt every remote session teardown step independently

A rejected event collection stop no longer leaves the automation process
running. The tunnel close, the collector-then-process pair and the host
finish are separate teardown entries; the process stop sits in a finally.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D29AEWN9ajZXbzobHUH4fF

* [build-tools] Name each remote session teardown step in its failure log

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Report teardown failures swallowed after a failed session to Sentry

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-28 12:57:57 +02:00
Vojtech NovakandClaude Fable 5.1 f7fb43b974 [build-tools] Only stop a session host that never became ready (#4430)
A readiness timeout ran the full finish pipeline: a stop request to a server
that never came up and a read of a recordings.json that did not exist, each
logging a warning unrelated to the real cause. finishAsync now skips
finalization and upload for a host that never answered /readyz. Finalization
and upload failures are reported to Sentry like the per-recording upload
failures one layer down, and the finishAsync contract states that it never
rejects.


Claude-Session: https://claude.ai/code/session_01D29AEWN9ajZXbzobHUH4fF

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-28 12:57:57 +02:00
Vojtech NovakandClaude Fable 5.1 a32080fadf [build-tools] Bound recording shutdown and artifact uploads (#4420)
* [build-tools] Bound session shutdown and recording uploads

* [build-tools] Close HTTP requests after each artifact PUT

* [build-tools] Separate artifact retries from PUT cleanup

* [build-tools] Cover a session host that ignores SIGTERM

stopAsync must not resolve while the killed process still answers kill(pid, 0).

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Wait for a killed session host to be reaped

After SIGKILL the child is a zombie until libuv reaps it on a later loop turn,
so the running check right after stopAsync always saw it alive and the
recording upload was skipped.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Cover uploads that progress past the PUT deadline

A body that keeps flowing for longer than the per-attempt cap must still upload.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Bound artifact upload stalls instead of total time

The 90 s PUT deadline now restarts on every chunk the request consumes, and the
300 s artifact and 305 s recording deadlines are gone. Upload time scales with
file size, so a whole-request cap failed every recording a slow link could not
send in time, including iOS and agent artifacts through the shared uploader.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Cover uploading the partial recording a killed Hub leaves

A killed Hub lists no recording; the host must find the fragmented partial,
skip one ffprobe cannot read, upload it flagged partial and remove the root.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Upload the partial recording a killed Hub leaves behind

When recordings.json is empty after the host stops, look for session
directories whose manifest is still at "recording" and whose fragmented
.partial ffprobe can read, then upload them through the shared recording
uploader with partial: true in the artifact metadata. The Hub writes that
manifest at the first keyframe, so the website can still place the video.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Log the Hub's reason when recording finalization is refused

The Hub answers 409 with why nothing was recorded, such as the booted-emulator
count. Read the body instead of throwing on the status so the reason reaches
the job log.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Cover recovering a failed partial recording with its reason

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Upload failed partial recordings with the Hub's reason

A Hub that failed or timed out while finalizing leaves the same fragmented
partial as a killed one, and its manifest now names the file and the error.
Recover "failed" manifests whose file decodes and pass the reason to the
website as partialReason.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Type the deadline error and simplify artifact PUT cleanup

The retry predicate matched the PUT deadline by message text and carried an
unreachable AbortError branch. A DeviceRunSessionTimeoutError replaces both.
node-fetch destroys the request body on abort and the retry loop destroys the
stream on every exit, so the extra abort listener and destroy call go. The
PUT error names the artifact and the status text again.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D29AEWN9ajZXbzobHUH4fF

* [build-tools] Do not warn about a preview tunnel that never opened

A preview whose open failed, or that its opener closed after finish began,
rejects previewTask. Retirement awaited that rejection and logged a deadline
warning for a tunnel that no longer existed.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D29AEWN9ajZXbzobHUH4fF

* [build-tools] Recover completed recordings a killed Hub never listed

recordings.json is written at finish. A recording that reached its duration
limit is complete on disk but unlisted until the session ends, so a Hub killed
in that window left a finished video the scan skipped. The scan now accepts
"complete" manifests as well and uploads them without the partial flag.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D29AEWN9ajZXbzobHUH4fF

* [build-tools] Log the session host's output when a recording is lost

The Hub reports capture failures on stderr, but the worker logged only the
stop route's one-line summary. When finalization fails or nothing is
uploaded, log the last 8 000 characters of the host's output.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D29AEWN9ajZXbzobHUH4fF

* [build-tools] Tell a Hub that never recorded apart from a failed finalization

A 409 from the stop route means nothing was captured, and the Hub says why.
Log that reason and skip the upload and the host output dump, which stay
for real capture failures.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Bound the finalization request once, through the deadline wrapper

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Ignore a deadline reset after the bounded operation settled

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Say when ffprobe is missing instead of calling a recording undecodable

Also log the recording by its session directory name rather than the full
temp path.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-28 12:57:57 +02:00
Vojtech NovakandClaude Fable 5.1 55017fbf70 [build-tools] Record Android sessions by default through a session host (#4418)
* [build-tools] Add opt-in Android session recording upload

* [build-tools] Document opt-in Android session recording

* [build-tools] Record Android device sessions by default

* [build-tools] Separate session recording from preview lifetime

* [build-tools] Stop automation alongside recording cleanup

* [build-tools] Fix session host formatting and add lifecycle release note

* [build-tools] Keep session host guidance in the README

* [build-tools] Extract session host shutdown operations

* [build-tools] Keep one changelog entry for the recording stack

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D29AEWN9ajZXbzobHUH4fF

* [build-tools] Type the session host variables with DeviceSessionHost

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Throw SystemError when recording finalization fails

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Keep the recording upload step Darwin-only

Android recordings are uploaded by the session host, and nothing on Linux
produces recordings_json.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Drop the session host section from the README

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Name the recording stop grace period after the Hub's SIGTERM deadline

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-28 12:57:56 +02:00
Byron Karlen 7fd8a3a75b [eas-cli] Remove unnecessary confirm_account query param (#4368) 2026-09-24 19:40:24 -07:00
Szymon DziedzicandClaude Opus 5.5 0f38c2ad82 [eas-cli] Include legacy sessions in the web-preview-only filter (#4466)
* [eas-cli] Include legacy sessions in the web-preview-only filter

`simulator:list --type web-preview-only` now also matches sessions with the
deprecated SERVE_SIM type. The --type flags list each value once.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* [eas-cli] Add changelog entry

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 00:21:04 +02:00
204 changed files with 21760 additions and 3861 deletions

No files matched your search

@@ -0,0 +1,43 @@
name: Test recording recovery
on:
push:
branches: [main]
paths: &recording-paths
- packages/build-tools/src/steps/utils/deviceRunSessionScreenRecordings.ts
- packages/build-tools/src/steps/utils/deviceRunSessionArtifacts.ts
- packages/build-tools/src/steps/utils/__tests__/*.test.ffmpeg.ts
- packages/build-tools/jest/ffmpeg-config.ts
- .github/workflows/test-recording-recovery.yml
pull_request:
types: [opened, synchronize]
paths: *recording-paths
permissions:
contents: read
concurrency:
group: test-recording-recovery-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
name: Test recording recovery with ffmpeg
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
with:
persist-credentials: false
- name: Setup tools
uses: ./.github/actions/setup-mise
- name: Install ffmpeg
# The recording recovery test cuts and decodes real MP4 files with ffmpeg and ffprobe.
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
- name: Install dependencies
run: yarn install --immutable
- name: Build build-tools dependencies
# tsc emits the dist files that the test imports from workspace packages such as @expo/logger.
run: yarn run -T lerna run typecheck --scope @expo/build-tools --include-dependencies
- name: Run recording recovery test
working-directory: packages/build-tools
run: yarn jest-ffmpeg
+88 -1
View File
@@ -10,8 +10,90 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 Bug fixes
- [eas-cli] Stop a simulator session that the server creates after Ctrl+C lands during `eas simulator` create. ([#4522](https://github.com/expo/eas-cli/pull/4522) by [@sjkim-expo](https://github.com/sjkim-expo))
### 🧹 Chores
## [24.12.0](https://github.com/expo/eas-cli/releases/tag/v24.12.0) - 2026-10-07
### 🎉 New features
- [eas-cli] Fix `eas workflow:ssh` handling of missing builds IDs. ([#4529](https://github.com/expo/eas-cli/pull/4529) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [build-tools] Upload the embedded bundle from custom builds that use `eas/build`. ([#4494](https://github.com/expo/eas-cli/pull/4494) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add the `eas/upload_embedded_bundle` step for custom build configs. ([#4494](https://github.com/expo/eas-cli/pull/4494) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Include `assetMapUrl` and `sourceMapUrl` in the `eas update:view --json` output. Each value is a signed download URL, or null when the update was published without that file. ([#4534](https://github.com/expo/eas-cli/pull/4534) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- [build-tools] Improve the embedded bundle upload output in build logs. ([#4557](https://github.com/expo/eas-cli/pull/4557) by [@gwdp](https://github.com/gwdp))
- [build-tools] Allow the unnotarized mitmproxy cask to run in iOS network capture sessions. ([#4556](https://github.com/expo/eas-cli/pull/4556) by [@gwdp](https://github.com/gwdp))
- [build-tools] Encode macOS simulator session previews with cwebp, because Homebrew's FFmpeg cannot encode WebP. ([#4549](https://github.com/expo/eas-cli/pull/4549) by [@szdziedzic](https://github.com/szdziedzic))
### 🧹 Chores
- [eas-cli] Log analytics events when an `eas simulator` session request is sent, cancelled, or fails before the server answers. ([#4483](https://github.com/expo/eas-cli/pull/4483) by [@sjkim-expo](https://github.com/sjkim-expo))
## [24.11.0](https://github.com/expo/eas-cli/releases/tag/v24.11.0) - 2026-10-05
### 🎉 New features
- [build-tools] Record iOS Simulator sessions through serve-sim. ([#4474](https://github.com/expo/eas-cli/pull/4474) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add browser device login with resumable commands for chat agents. ([#4502](https://github.com/expo/eas-cli/pull/4502) by [@eiiot](https://github.com/eiiot))
- [build-tools] Upload the display rotation agent-device reports for a screenshot as device run session artifact metadata. ([#4508](https://github.com/expo/eas-cli/pull/4508) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Refresh a WebP thumbnail of the simulator session screen every minute as a reusable session preview artifact. ([#4398](https://github.com/expo/eas-cli/pull/4398) by [@szdziedzic](https://github.com/szdziedzic))
### 🐛 Bug fixes
- [eas-cli] Select the containing iOS app when its archive also includes a nested App Clip. ([#4541](https://github.com/expo/eas-cli/pull/4541) by [@mozzius](https://github.com/mozzius))
- [build-tools] Allow expo.test subdomains to open local and staging simulator previews. ([#4480](https://github.com/expo/eas-cli/pull/4480) by [@hirbod](https://github.com/hirbod))
- [build-tools] Retry App Store Connect connection failures and safely recover interrupted status reads and upload commits. ([#4520](https://github.com/expo/eas-cli/pull/4520) by [@sjchmiela](https://github.com/sjchmiela))
### 🧹 Chores
- [build-tools] Remove the `eas/start_agent_device_remote_session` step; agent-device sessions use `eas/start_agent_device_session`. ([#4518](https://github.com/expo/eas-cli/pull/4518) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Report `eas-cli` as the request origin, and the coding agent when one is detected, when `eas simulator` creates a session, for analytics. ([#4523](https://github.com/expo/eas-cli/pull/4523) by [@sjkim-expo](https://github.com/sjkim-expo))
## [24.10.0](https://github.com/expo/eas-cli/releases/tag/v24.10.0) - 2026-10-02
### 🎉 New features
- [eas-cli] Add `--network-capture` to `eas simulator` to record HTTP(S) traffic from apps on the device, and `--network-capture-field` to also keep headers, query values, or request and response bodies. ([#4308](https://github.com/expo/eas-cli/pull/4308) by [@gwdp](https://github.com/gwdp))
## [24.9.0](https://github.com/expo/eas-cli/releases/tag/v24.9.0) - 2026-10-02
### 🎉 New features
- [eas-cli] Add `--force-end-active-rollout` to `eas update`, `eas update:republish`, `eas update:roll-back-to-embedded` and `eas update:rollback`. ([#4233](https://github.com/expo/eas-cli/pull/4233) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Allow `eas simulator:start --platform android --egress local`. On Android, `--egress-allow localhost:<port>` also covers `10.0.2.2:<port>`, and refused destinations are shown as warnings. ([#4519](https://github.com/expo/eas-cli/pull/4519) by [@brentvatne](https://github.com/brentvatne))
- [build-tools] Support `eas/start_local_egress` on Linux for Android emulators: an always-listening relay for the emulator's proxy, a systemd scope and nftables fence that refuse traffic that cannot use the tunnel, and fence refusals in the session log. ([#4519](https://github.com/expo/eas-cli/pull/4519) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Add `--upload-source-maps` to `eas update`, which uploads the generated source maps to EAS so that stack traces from an update can be symbolicated. Source maps are stripped of `sourcesContent` before upload. ([#4436](https://github.com/expo/eas-cli/pull/4436) by [@douglowder](https://github.com/douglowder))
- [build-tools] Upload the embedded bundle after build by default for projects on SDK 58 and later. Set `EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE` to `0` to disable it. On SDK 57 and below the feature remains experimental and opt-in via `EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE`. ([#4433](https://github.com/expo/eas-cli/pull/4433) by [@brentvatne](https://github.com/brentvatne))
- [build-tools] Save manual iOS and Android live preview screenshots as session artifacts when supported by the preview server. ([#4481](https://github.com/expo/eas-cli/pull/4481) by [@vonovak](https://github.com/vonovak))
- [build-tools] Add the `eas/install_mitmproxy` step. ([#4307](https://github.com/expo/eas-cli/pull/4307) by [@gwdp](https://github.com/gwdp))
- [build-tools] Start serve-sim with `--network-capture`, and with `--network-capture-field` for each requested field, when a simulator session requests network capture. ([#4307](https://github.com/expo/eas-cli/pull/4307) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Allow workflow submissions to skip loading app config when project and application identifiers are supplied. ([#4471](https://github.com/expo/eas-cli/pull/4471) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Record Android device sessions and upload the screen recording, including the partial recording a killed Device Hub leaves behind. ([#4418](https://github.com/expo/eas-cli/pull/4418) by [@vonovak](https://github.com/vonovak))
- [build-tools] Add start and collect steps that attach bounded simulator logs from serve-sim to device run sessions. ([#4463](https://github.com/expo/eas-cli/pull/4463) by [@sjkim-expo](https://github.com/sjkim-expo))
- [build-tools] Add `eas/start_agent_device_session`, one step for agent-device sessions that boots the device, downloads, installs and launches the app, and starts the session, with the independent parts in parallel. ([#4491](https://github.com/expo/eas-cli/pull/4491) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-build-job] Add `sentry` workflow interpolation context schema for Sentry-triggered workflow runs. ([#4453](https://github.com/expo/eas-cli/pull/4453) by [@sswrk](https://github.com/sswrk))
### 🐛 Bug fixes
- [eas-cli] Stop `eas update:rollback` from always forcing non-interactive mode. ([#4504](https://github.com/expo/eas-cli/pull/4504) by [@gwdp](https://github.com/gwdp))
- [build-tools] Pass the step cancellation signal to `eas/download_build`. ([#4511](https://github.com/expo/eas-cli/pull/4511) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Accept `null` for the TestFlight group `hasAccessToAllBuilds` attribute from App Store Connect. ([#4528](https://github.com/expo/eas-cli/pull/4528) by [@sswrk](https://github.com/sswrk))
- [eas-cli] Support `queued` and `starting` status filters in `eas simulator:list` and keep simulator commands compatible with the current GraphQL schema. ([#4527](https://github.com/expo/eas-cli/pull/4527) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Explain how to resolve missing or expired Apple agreements when App Store Connect rejects a request. ([#4513](https://github.com/expo/eas-cli/pull/4513) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Correct the `eas simulator:start --max-duration-minutes` help text: every plan can set a custom duration up to the plan's maximum, which is also the default. ([#4524](https://github.com/expo/eas-cli/pull/4524) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Fix `eas workflow:validate` crashing with certain workflow schemas, and exit with a non-zero code when validation fails. ([#4507](https://github.com/expo/eas-cli/pull/4507) by [@yuvalgnessin-qz](https://github.com/yuvalgnessin-qz))
- [build-tools] Reuse App Store Connect tokens and refresh them before expiry so long uploads and processing waits remain authenticated. ([#4497](https://github.com/expo/eas-cli/pull/4497) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Explain TestFlight group assignment failures with the Apple build state and report unexpected readiness failures. ([#4521](https://github.com/expo/eas-cli/pull/4521) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Clear old Maestro CLI HTML reports before each test attempt so retries cannot upload an earlier report. ([#4409](https://github.com/expo/eas-cli/pull/4409) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Poll the web preview readiness every 250 ms instead of every second, so a session goes live sooner after its host is ready. ([#4496](https://github.com/expo/eas-cli/pull/4496) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Keep the `eas simulator` session spinner on one line, so resizing the terminal no longer leaves copies of it behind. The `eas simulator:stop` command is now printed once before the spinner starts. ([#4488](https://github.com/expo/eas-cli/pull/4488) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Disable agent-device's internal idle timeouts in remote sessions, so only the session's own idle and duration limits end it. ([#4485](https://github.com/expo/eas-cli/pull/4485) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Keep `eas simulator:events --follow` running while a session is queued or starting. ([#4506](https://github.com/expo/eas-cli/pull/4506) by [@sjkim-expo](https://github.com/sjkim-expo))
## [24.8.0](https://github.com/expo/eas-cli/releases/tag/v24.8.0) - 2026-09-24
### 🎉 New features
@@ -19,6 +101,7 @@ This is the log of notable changes to EAS CLI and related packages.
- [eas-cli] Add `--build-fingerprint` to `eas simulator` to install the newest build with a given fingerprint. ([#4472](https://github.com/expo/eas-cli/pull/4472) by [@tchayen](https://github.com/tchayen))
- [eas-cli] Accept build IDs in `workflow:ssh <id>`. ([#4452](https://github.com/expo/eas-cli/pull/4452) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [eas-cli] Make `eas simulator:*` commands visible in help output. ([#4456](https://github.com/expo/eas-cli/pull/4456) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Add `eas/update_testflight_metadata` to set changelogs and TestFlight groups for an uploaded build with an App Store Connect API key. The step fails if a requested group does not exist. ([#4475](https://github.com/expo/eas-cli/pull/4475) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Pass `--share-url` when launching serve-sim so Share copies the expo.dev preview page instead of the tunnel URL. ([#4434](https://github.com/expo/eas-cli/pull/4434) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas integrations:supabase:advisors` to list unresolved Supabase Security and Performance Advisor findings for the linked Supabase project. ([#4342](https://github.com/expo/eas-cli/pull/4342) by [@fiberjw](https://github.com/fiberjw))
- [eas-cli] Accept individual App Store Connect API keys for submissions, TestFlight setup, and metadata. ([#4249](https://github.com/expo/eas-cli/pull/4249) by [@sswrk](https://github.com/sswrk))
@@ -27,16 +110,20 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 Bug fixes
- [build-tools] Report early Argent server exits during simulator startup instead of waiting for the full readiness timeout. ([#4454](https://github.com/expo/eas-cli/pull/4454) by [@sjkim-expo](https://github.com/sjkim-expo))
- [eas-cli] Stop spinners from printing a new line on every frame after the terminal gets narrower. ([#4470](https://github.com/expo/eas-cli/pull/4470) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Exclude time waiting for simulator concurrency from the session startup timeout and show queue progress. ([#4411](https://github.com/expo/eas-cli/pull/4411) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] List `eas simulator` only once in `eas --help`. The `sim` shorthand is now a hidden alias, so it still runs but no longer duplicates the entry. ([#4457](https://github.com/expo/eas-cli/pull/4457) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [build-tools] Start the Argent tool-server with `ARGENT_EMULATOR_NO_WINDOW=1`, so Argent can boot the Android emulator again after it stops. Without it, the windowed emulator fails on the Linux image because `libpulse.so.0` is missing. ([#4465](https://github.com/expo/eas-cli/pull/4465) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Fix `eas account:view` printing `Role: undefined` for the new Release Manager role. ([#4300](https://github.com/expo/eas-cli/pull/4300) by [@byronkarlen](https://github.com/byronkarlen))
- [eas-cli] Include legacy serve-sim sessions in `eas simulator:list --type web-preview-only`, and list `web-preview-only` only once in the `--type` options. ([#4466](https://github.com/expo/eas-cli/pull/4466) by [@szdziedzic](https://github.com/szdziedzic))
### 🧹 Chores
- [eas-cli] Stop creating `CLAUDE.md` in new apps; use `AGENTS.md` for agent instructions. ([#4446](https://github.com/expo/eas-cli/pull/4446) by [@davidmokos](https://github.com/davidmokos))
- Upgrade `typescript` to 6.0.3, `ts-jest` to 29.4.12, `@types/node` to 20.19.43, `gql.tada` to 1.11.3, and `https-proxy-agent` to 7.0.6. ([#XXXX](https://github.com/expo/eas-cli/pull/XXXX) by [@wschurman](https://github.com/wschurman))
- Upgrade `typescript` to 6.0.3, `ts-jest` to 29.4.12, `@types/node` to 20.19.43, `gql.tada` to 1.11.3, and `https-proxy-agent` to 7.0.6. ([#4400](https://github.com/expo/eas-cli/pull/4400) by [@wschurman](https://github.com/wschurman))
- [build-tools] Use the website origin for simulator preview CORS and framing, and allow deploy previews and local website dev servers on staging. ([#4437](https://github.com/expo/eas-cli/pull/4437) by [@gwdp](https://github.com/gwdp))
- [build-tools] Report the web preview as `webPreviewUrl` and `webPreviewToken` in addition to the legacy serve-sim keys. ([#4467](https://github.com/expo/eas-cli/pull/4467) by [@szdziedzic](https://github.com/szdziedzic))
## [24.7.0](https://github.com/expo/eas-cli/releases/tag/v24.7.0) - 2026-09-16
+1 -1
View File
@@ -12,7 +12,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that you d
to access the functionality of and directly benefit from the
functionality of the Licensed Work.
Change Date: 2029-07-15
Change Date: 2029-10-01
Change License: MIT
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "24.8.0",
"PRODUCTION": "24.8.0"
"STAGING": "24.12.0",
"PRODUCTION": "24.12.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "24.8.0",
"version": "24.12.1",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
-1
View File
@@ -1,6 +1,5 @@
node_modules/
dist/
bin/record-sim
.secrets*
!.secrets.example
@@ -0,0 +1,13 @@
import type { Config } from '@jest/types';
import baseConfig from '../../../jest/jest.shared.config';
const config: Config.InitialOptions = {
...baseConfig,
rootDir: '../src',
testMatch: ['**/__tests__/*.test.ffmpeg.ts'],
clearMocks: true,
setupFilesAfterEnv: ['<rootDir>/../jest/setup-tests.ts'],
};
export default config;
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "24.8.0",
"version": "24.12.1",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -23,7 +23,6 @@
"watch": "tsc --watch --preserveWatchOutput",
"prebuild": "yarn gql",
"build": "tsc",
"build:record-sim": "mkdir -p bin && record_sim_bin_path=$(swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build --show-bin-path) && swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build && cp \"$record_sim_bin_path/record-sim\" bin/record-sim && chmod +x bin/record-sim",
"build:egress-guard": "resources/egress-guard/build.sh",
"test:egress-guard": "resources/egress-guard/tests/run-policy-tests.sh && resources/egress-guard/tests/run-guard-tests.sh",
"typecheck": "tsc",
@@ -31,6 +30,7 @@
"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",
"jest-ffmpeg": "jest --config jest/ffmpeg-config.ts",
"jest-unit-watch": "jest --config jest/unit-config.ts --watch",
"jest-integration-watch": "jest --config jest/integration-config.ts --watch",
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\"",
@@ -42,14 +42,14 @@
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "24.8.0",
"@expo/eas-build-job": "24.8.0",
"@expo/eas-build-job": "24.9.0",
"@expo/env": "^0.4.0",
"@expo/logger": "24.8.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "24.8.0",
"@expo/steps": "24.9.0",
"@expo/template-file": "24.8.0",
"@expo/turtle-spawn": "24.8.0",
"@expo/xcpretty": "^4.3.1",
@@ -1,4 +0,0 @@
.build/
.swiftpm/
DerivedData/
.DS_Store
@@ -1,28 +0,0 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "record-sim",
platforms: [
.macOS(.v14),
],
products: [
.library(name: "RecordSim", targets: ["RecordSim"]),
.executable(name: "record-sim", targets: ["record-sim"]),
],
targets: [
.target(
name: "RecordSim",
linkerSettings: [
.linkedFramework("AVFoundation"),
.linkedFramework("CoreGraphics"),
.linkedFramework("CoreMedia"),
.linkedFramework("CoreVideo"),
.linkedFramework("IOSurface"),
.linkedFramework("UniformTypeIdentifiers"),
]
),
.executableTarget(name: "record-sim", dependencies: ["RecordSim"]),
]
)
@@ -1,79 +0,0 @@
# RecordSim
Small Swift library for non-exclusive iOS Simulator screen recording.
It implements one recording path:
1. Listen to SimRenderServer framebuffer callbacks.
2. Copy the live `IOSurface` immediately into an owned `CVPixelBuffer`.
3. Feed one continuous `AVAssetWriter`.
4. Emit Apple HLS/CMAF-style fragmented MP4 segments.
The output is suitable for uploading while recording. `session.json` contains
the ordered segment metadata needed to build a playlist later.
## Output
```text
session/
init.mp4
session.json
segments/
segment-000000.m4s
segment-000001.m4s
```
`init.mp4` is the fMP4 initialization segment. Individual `.m4s` files are not
standalone MP4 files. `session.json` stores the wall-clock timestamp for the
first video frame plus the metadata needed to build a playlist: HLS version,
target duration, media sequence, `initSegment`, and the ordered `segments` array
with file path and duration for each media segment.
## CLI
```bash
swift run record-sim \
--udid <BOOTED_SIMULATOR_UDID> \
--output /tmp/sim-recording \
--segment-duration 120 \
--fps 30 \
--bitrate 30000000 \
--codec h264
```
For sparse 2h sessions, `--segment-duration 120` is a reasonable default: about
60 media objects plus the init segment. The recorder appends a single duplicate
hold frame near each segment boundary when the simulator is idle, so long idle
periods still produce uploadable segments without encoding idle frames at 60fps.
Use `--segment-duration 0` to write one continuous `recording.mp4` instead of
`init.mp4` plus media segments.
Frame timestamps use monotonic host time, not wall-clock time. The recorder also
probes the framebuffer seed once per second; if the surface changes without
callbacks for 5 seconds, it captures that frame and rewires the private callback
registration.
## Library
```swift
let recorder = SimulatorRecorder(
configuration: SimulatorRecordingConfiguration(
deviceUDID: udid,
outputDirectory: sessionDirectory,
segmentDuration: 120
)
)
recorder.onSegment = { segment in
// Enqueue upload of segment.url.
}
try recorder.start()
try recorder.waitUntilFirstFrame()
// ...
let manifest = try recorder.stop()
```
`session.json` contains the precise wall-clock time for video PTS zero. In
segmented mode it also contains the init segment and ordered media segment
file/duration entries; in single-file mode it points at `recording.mp4`.
@@ -1,192 +0,0 @@
import Foundation
import IOSurface
import ObjectiveC
final class FramebufferDisplaySource {
struct SurfaceSnapshot {
let surface: IOSurface
let width: Int
let height: Int
let seed: UInt32
}
private let deviceUDID: String
private let callbackQueue: DispatchQueue
private let onFrame: () -> Void
private let onSurfaceChange: () -> Void
private var descriptors: [NSObject] = []
private var callbackUUIDs: [ObjectIdentifier: NSUUID] = [:]
private var retainedBlocks: [AnyObject] = []
private var ioClient: NSObject?
init(
deviceUDID: String,
callbackQueue: DispatchQueue,
onFrame: @escaping () -> Void,
onSurfaceChange: @escaping () -> Void
) {
self.deviceUDID = deviceUDID
self.callbackQueue = callbackQueue
self.onFrame = onFrame
self.onSurfaceChange = onSurfaceChange
}
func start() throws {
try PrivateSimulatorFrameworks.load()
guard let device = SimulatorDeviceLookup.find(udid: deviceUDID) else {
throw RecorderError.make(1, "Simulator \(deviceUDID) not found")
}
let state = device.value(forKey: "stateString") as? String ?? "unknown"
guard state == "Booted" else {
throw RecorderError.make(2, "Simulator \(deviceUDID) is not booted (state: \(state))")
}
guard
let io = device.perform(NSSelectorFromString("io"))?.takeUnretainedValue() as? NSObject
else {
throw RecorderError.make(3, "Failed to get simulator IO client")
}
ioClient = io
try wireUpFramebuffer()
}
func stop() {
unregisterCallbacks()
descriptors.removeAll()
retainedBlocks.removeAll()
ioClient = nil
}
func surfaceSnapshot() -> SurfaceSnapshot? {
let surfaceSelector = NSSelectorFromString("framebufferSurface")
var bestSnapshot: SurfaceSnapshot?
var bestArea = 0
for descriptor in descriptors {
guard let surfaceObject = descriptor.perform(surfaceSelector)?.takeUnretainedValue()
else {
continue
}
let surface = unsafeBitCast(surfaceObject, to: IOSurface.self)
let width = IOSurfaceGetWidth(surface)
let height = IOSurfaceGetHeight(surface)
let area = width * height
if area > bestArea {
bestSnapshot = SurfaceSnapshot(
surface: surface,
width: width,
height: height,
seed: IOSurfaceGetSeed(surface)
)
bestArea = area
}
}
return bestSnapshot
}
func rewireFramebuffer() throws {
try wireUpFramebuffer()
}
private func wireUpFramebuffer() throws {
guard let io = ioClient else {
throw RecorderError.make(3, "No simulator IO client")
}
io.perform(NSSelectorFromString("updateIOPorts"))
let nextDescriptors = try findFramebufferDescriptors(io: io)
unregisterCallbacks()
descriptors = nextDescriptors
retainedBlocks.removeAll()
do {
for descriptor in descriptors {
try registerCallbacks(descriptor: descriptor)
}
} catch {
unregisterCallbacks()
descriptors.removeAll()
retainedBlocks.removeAll()
throw error
}
}
private func findFramebufferDescriptors(io: NSObject) throws -> [NSObject] {
guard let ports = io.value(forKey: "deviceIOPorts") as? [NSObject] else {
throw RecorderError.make(4, "Failed to read simulator IO ports")
}
let portIdentifierSelector = NSSelectorFromString("portIdentifier")
let descriptorSelector = NSSelectorFromString("descriptor")
let surfaceSelector = NSSelectorFromString("framebufferSurface")
var candidates: [NSObject] = []
for port in ports {
guard port.responds(to: portIdentifierSelector),
let portIdentifier = port.perform(portIdentifierSelector)?.takeUnretainedValue(),
"\(portIdentifier)" == "com.apple.framebuffer.display",
port.responds(to: descriptorSelector),
let descriptor = port.perform(descriptorSelector)?.takeUnretainedValue()
as? NSObject,
descriptor.responds(to: surfaceSelector)
else {
continue
}
candidates.append(descriptor)
}
if candidates.isEmpty {
throw RecorderError.make(5, "No framebuffer display descriptor found")
}
return candidates
}
private func registerCallbacks(descriptor: NSObject) throws {
let selector = NSSelectorFromString(
"registerScreenCallbacksWithUUID:callbackQueue:frameCallback:surfacesChangedCallback:propertiesChangedCallback:"
)
guard descriptor.responds(to: selector) else {
throw RecorderError.make(6, "Framebuffer descriptor does not support screen callbacks")
}
guard let msgSendPointer = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "objc_msgSend")
else {
throw RecorderError.make(7, "objc_msgSend not found")
}
typealias MsgSend =
@convention(c) (
AnyObject, Selector, AnyObject, AnyObject, AnyObject, AnyObject, AnyObject
) -> Void
let msgSend = unsafeBitCast(msgSendPointer, to: MsgSend.self)
let uuid = NSUUID()
callbackUUIDs[ObjectIdentifier(descriptor)] = uuid
let frameCallback: @convention(block) () -> Void = { [weak self] in
self?.onFrame()
}
let surfacesCallback: @convention(block) (AnyObject?, AnyObject?) -> Void = {
[weak self] _, _ in
self?.onSurfaceChange()
}
let propertiesCallback: @convention(block) () -> Void = {}
retainedBlocks.append(frameCallback as AnyObject)
retainedBlocks.append(surfacesCallback as AnyObject)
retainedBlocks.append(propertiesCallback as AnyObject)
msgSend(
descriptor,
selector,
uuid,
callbackQueue as AnyObject,
frameCallback as AnyObject,
surfacesCallback as AnyObject,
propertiesCallback as AnyObject
)
}
private func unregisterCallbacks() {
let selector = NSSelectorFromString("unregisterScreenCallbacksWithUUID:")
for descriptor in descriptors {
if let uuid = callbackUUIDs[ObjectIdentifier(descriptor)],
descriptor.responds(to: selector)
{
descriptor.perform(selector, with: uuid)
}
}
callbackUUIDs.removeAll()
}
}
@@ -1,96 +0,0 @@
import Foundation
import ObjectiveC
enum XcodeDeveloperDirectory {
static let current: String = {
if let developerDir = ProcessInfo.processInfo.environment["DEVELOPER_DIR"],
!developerDir.isEmpty
{
return developerDir
}
let fallback = "/Applications/Xcode.app/Contents/Developer"
let pipe = Pipe()
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/xcode-select")
process.arguments = ["-p"]
process.standardOutput = pipe
do {
try process.run()
} catch {
return fallback
}
process.waitUntilExit()
return String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? fallback
}()
}
enum PrivateSimulatorFrameworks {
static func load() throws {
try loadResult.get()
}
private static let loadResult: Result<Void, Error> = {
let developerDir = XcodeDeveloperDirectory.current
let candidates = [
"/Library/Developer/PrivateFrameworks/CoreSimulator.framework/CoreSimulator",
"\(developerDir)/Library/PrivateFrameworks/CoreSimulator.framework/CoreSimulator",
"\(developerDir)/../SharedFrameworks/SimulatorKit.framework/SimulatorKit",
"\(developerDir)/Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit",
]
var failures: [String] = []
for path in candidates {
if dlopen(path, RTLD_NOW) == nil {
let message = dlerror().map { String(cString: $0) } ?? "unknown error"
failures.append("\(path): \(message)")
}
}
guard NSClassFromString("SimServiceContext") != nil else {
let tried = candidates.joined(separator: ", ")
let errors = failures.isEmpty ? "no dlopen errors" : failures.joined(separator: "; ")
return .failure(
RecorderError.make(
8,
"Failed to load CoreSimulator/SimulatorKit (tried: \(tried); errors: \(errors))"
)
)
}
return .success(())
}()
}
enum SimulatorDeviceLookup {
private static let context: NSObject? = {
guard let contextClass = NSClassFromString("SimServiceContext") as? NSObject.Type else {
return nil
}
let sharedSelector = NSSelectorFromString("sharedServiceContextForDeveloperDir:error:")
return contextClass.perform(
sharedSelector, with: XcodeDeveloperDirectory.current, with: nil)?
.takeUnretainedValue() as? NSObject
}()
static func find(udid: String) -> NSObject? {
guard let context else {
return nil
}
let deviceSetSelector = NSSelectorFromString("defaultDeviceSetWithError:")
guard
let deviceSet = context.perform(deviceSetSelector, with: nil)?
.takeUnretainedValue() as? NSObject
else {
return nil
}
guard let devices = deviceSet.value(forKey: "devices") as? [NSObject] else {
return nil
}
return devices.first {
($0.value(forKey: "UDID") as? NSUUID)?.uuidString == udid
}
}
static func stateString(udid: String) -> String? {
find(udid: udid)?.value(forKey: "stateString") as? String
}
}
@@ -1,77 +0,0 @@
import Foundation
public enum RecorderCodec: String, Codable, Sendable {
case h264
case hevc
}
public struct SimulatorRecordingConfiguration: Sendable {
public var deviceUDID: String
public var outputDirectory: URL
public var fps: Int
public var bitrate: Int
public var codec: RecorderCodec
public var segmentDuration: TimeInterval
public var maxPendingFrames: Int
public var overwrite: Bool
public init(
deviceUDID: String,
outputDirectory: URL,
fps: Int = 30,
bitrate: Int = 30_000_000,
codec: RecorderCodec = .h264,
segmentDuration: TimeInterval = 120,
maxPendingFrames: Int = 8,
overwrite: Bool = true
) {
self.deviceUDID = deviceUDID
self.outputDirectory = outputDirectory
self.fps = fps
self.bitrate = bitrate
self.codec = codec
self.segmentDuration = segmentDuration
self.maxPendingFrames = maxPendingFrames
self.overwrite = overwrite
}
}
public enum SegmentKind: String, Codable, Sendable {
case initialization
case media
}
public struct SegmentOutput: Sendable {
public let kind: SegmentKind
public let relativePath: String
public let url: URL
public let byteCount: Int
public let durationSeconds: Double?
}
public struct MediaSegmentRecord: Codable, Sendable {
public let file: String
public let durationSeconds: Double
}
public struct FirstFrameWallClock: Codable, Sendable {
public let unixMs: Int64
public let iso8601: String
}
public enum RecordingFinalizationStage: Sendable {
case captureStopped
case videoSaved
}
public struct RecordingManifest: Codable, Sendable {
public let firstFrameWallClock: FirstFrameWallClock
public let width: Int
public let height: Int
public let hlsVersion: Int?
public let hlsTargetDurationSeconds: Int?
public let hlsMediaSequence: Int?
public let recording: String?
public let initSegment: String?
public let segments: [MediaSegmentRecord]
}
@@ -1,140 +0,0 @@
import AVFoundation
import Foundation
final class RecordingOutputWriter: NSObject, AVAssetWriterDelegate {
private let rootDirectory: URL
private let segmentsDirectory: URL
let singleRecordingURL: URL
private let stateQueue = DispatchQueue(label: "record-sim.segment-writer")
private let notificationQueue = DispatchQueue(label: "record-sim.segment-notifications")
private let onSegment: ((SegmentOutput) -> Void)?
private var initSegmentPath: String?
private var segments: [MediaSegmentRecord] = []
private var nextMediaSegmentIndex = 0
private var firstError: Error?
init(rootDirectory: URL, onSegment: ((SegmentOutput) -> Void)?) {
self.rootDirectory = rootDirectory
self.segmentsDirectory = rootDirectory.appendingPathComponent("segments", isDirectory: true)
self.singleRecordingURL = rootDirectory.appendingPathComponent("recording.mp4")
self.onSegment = onSegment
super.init()
}
func prepare(overwrite: Bool, segmented: Bool) throws {
if overwrite {
try? FileManager.default.removeItem(at: rootDirectory)
}
try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true)
if segmented {
try FileManager.default.createDirectory(at: segmentsDirectory, withIntermediateDirectories: true)
}
}
var error: Error? {
stateQueue.sync { firstError }
}
func assetWriter(
_ writer: AVAssetWriter,
didOutputSegmentData segmentData: Data,
segmentType: AVAssetSegmentType,
segmentReport: AVAssetSegmentReport?
) {
var notification: SegmentOutput?
stateQueue.sync {
if firstError != nil {
return
}
do {
switch segmentType {
case .initialization:
let relativePath = "init.mp4"
let url = rootDirectory.appendingPathComponent(relativePath)
try segmentData.write(to: url, options: .atomic)
initSegmentPath = relativePath
notification = SegmentOutput(
kind: .initialization,
relativePath: relativePath,
url: url,
byteCount: segmentData.count,
durationSeconds: nil
)
case .separable:
let index = nextMediaSegmentIndex
let relativePath = String(format: "segments/segment-%06d.m4s", index)
let url = rootDirectory.appendingPathComponent(relativePath)
try segmentData.write(to: url, options: .atomic)
let videoReport = segmentReport?.trackReports.first(where: { $0.mediaType == .video })
let duration = videoReport.flatMap { timeSeconds($0.duration) } ?? 0
let record = MediaSegmentRecord(
file: relativePath,
durationSeconds: duration
)
nextMediaSegmentIndex += 1
segments.append(record)
notification = SegmentOutput(
kind: .media,
relativePath: relativePath,
url: url,
byteCount: segmentData.count,
durationSeconds: duration
)
default:
break
}
} catch {
firstError = error
}
}
if let notification, let onSegment {
notificationQueue.async {
onSegment(notification)
}
}
}
@discardableResult
func writeManifest(
configuration: SimulatorRecordingConfiguration,
firstFrameWallClock: Date,
width: Int,
height: Int
) throws -> RecordingManifest {
try stateQueue.sync {
if let firstError {
throw firstError
}
let segmented = configuration.segmentDuration > 0
if segmented, initSegmentPath == nil {
throw RecorderError.make(50, "Missing initialization segment")
}
if segmented, segments.isEmpty {
throw RecorderError.make(51, "No media segments were written")
}
let targetDuration = segmented
? max(1, Int(ceil(segments.map(\.durationSeconds).max() ?? configuration.segmentDuration)))
: nil
let manifest = RecordingManifest(
firstFrameWallClock: FirstFrameWallClock(
unixMs: unixMs(firstFrameWallClock),
iso8601: iso8601(firstFrameWallClock)
),
width: width,
height: height,
hlsVersion: segmented ? 7 : nil,
hlsTargetDurationSeconds: targetDuration,
hlsMediaSequence: segmented ? 0 : nil,
recording: segmented ? nil : "recording.mp4",
initSegment: segmented ? initSegmentPath : nil,
segments: segments
)
let data = try JSONEncoder.prettySorted.encode(manifest)
try data.write(to: rootDirectory.appendingPathComponent("session.json"), options: .atomic)
return manifest
}
}
}
@@ -1,644 +0,0 @@
import AVFoundation
import CoreVideo
import Foundation
import IOSurface
import UniformTypeIdentifiers
public final class SimulatorRecorder {
public var onSegment: ((SegmentOutput) -> Void)?
public var onSimulatorStopped: ((String) -> Void)?
public var onFinalizationStage: ((RecordingFinalizationStage) -> Void)?
private static let callbackStalenessTimeout: TimeInterval = 5
private static let firstFrameRewireInterval: TimeInterval = 1
private let configuration: SimulatorRecordingConfiguration
private let callbackQueue = DispatchQueue(
label: "record-sim.frame-callbacks", qos: .userInteractive)
private let writerQueue = DispatchQueue(label: "record-sim.asset-writer", qos: .userInitiated)
private let eventQueue = DispatchQueue(label: "record-sim.events", qos: .utility)
private let pendingLock = NSLock()
private var pendingFrames = 0
private var displaySource: FramebufferDisplaySource?
private var outputWriter: RecordingOutputWriter?
private var writer: AVAssetWriter?
private var input: AVAssetWriterInput?
private var adaptor: AVAssetWriterInputPixelBufferAdaptor?
private var boundaryTimer: DispatchSourceTimer?
private var monotonicClock = MonotonicClock()
private var firstAcceptedCaptureTime: CMTime?
private var firstAcceptedWallClock: Date?
private var recordingWidth: Int?
private var recordingHeight: Int?
private var lastPTS: CMTime?
private var lastSeed: UInt32?
private var lastFrameCallbackElapsed: TimeInterval?
private var lastFirstFrameRewireElapsed: TimeInterval = 0
private var lastAppendedPixelBuffer: CVPixelBuffer?
private var nextBoundaryElapsed: TimeInterval = 0
private var simulatorStoppedReason: String?
private var stopped = false
private var firstFrameReady = false
private let firstFrameSemaphore = DispatchSemaphore(value: 0)
private var firstError: Error?
public init(configuration: SimulatorRecordingConfiguration) {
self.configuration = configuration
}
public func start() throws {
try validateConfiguration()
let outputWriter = RecordingOutputWriter(
rootDirectory: configuration.outputDirectory,
onSegment: { [weak self] segment in
self?.onSegment?(segment)
})
try outputWriter.prepare(
overwrite: configuration.overwrite, segmented: configuration.segmentDuration > 0)
self.outputWriter = outputWriter
monotonicClock = MonotonicClock()
nextBoundaryElapsed = configuration.segmentDuration
lastFirstFrameRewireElapsed = 0
stopped = false
let source = FramebufferDisplaySource(
deviceUDID: configuration.deviceUDID,
callbackQueue: callbackQueue,
onFrame: { [weak self] in
self?.captureFrame(force: false, reason: .callback)
},
onSurfaceChange: { [weak self] in
self?.captureFrame(force: true, reason: .surfaceChange)
}
)
try source.start()
displaySource = source
startBoundaryTimer()
}
public func waitUntilFirstFrame(timeoutSeconds: TimeInterval = 15) throws {
let alreadyReady = try writerQueue.sync {
if let firstError {
throw firstError
}
return firstFrameReady
}
if alreadyReady {
return
}
if firstFrameSemaphore.wait(timeout: .now() + timeoutSeconds) == .timedOut {
throw RecorderError.make(20, "Timed out waiting for first frame")
}
try writerQueue.sync {
if let firstError {
throw firstError
}
if !firstFrameReady {
throw RecorderError.make(21, "Recorder stopped before first frame")
}
}
}
public func simulatorStopReason() -> String? {
callbackQueue.sync {
simulatorStoppedReason
}
}
@discardableResult
public func stop() throws -> RecordingManifest {
callbackQueue.sync {
stopped = true
boundaryTimer?.cancel()
boundaryTimer = nil
displaySource?.stop()
displaySource = nil
}
onFinalizationStage?(.captureStopped)
return try writerQueue.sync {
if let firstError {
throw firstError
}
guard let writer, let input, let outputWriter else {
let error = RecorderError.make(22, "No frames were captured")
firstError = error
signalFirstFrameReadyIfNeeded()
throw error
}
try appendTailFrameIfNeeded(finalPTS: monotonicClock.elapsedTime())
input.markAsFinished()
let semaphore = DispatchSemaphore(value: 0)
writer.finishWriting {
semaphore.signal()
}
if semaphore.wait(timeout: .now() + 60) == .timedOut {
throw RecorderError.make(23, "Timed out finishing AVAssetWriter")
}
if writer.status == .failed {
throw writer.error ?? RecorderError.make(24, "AVAssetWriter failed")
}
if let error = outputWriter.error {
throw error
}
onFinalizationStage?(.videoSaved)
guard let firstAcceptedWallClock else {
throw RecorderError.make(25, "Missing first frame wall-clock timestamp")
}
guard let recordingWidth, let recordingHeight else {
throw RecorderError.make(27, "Missing recording dimensions")
}
return try outputWriter.writeManifest(
configuration: configuration,
firstFrameWallClock: firstAcceptedWallClock,
width: recordingWidth,
height: recordingHeight
)
}
}
private enum CaptureReason {
case callback
case surfaceChange
case boundary
case healthProbe
}
private func validateConfiguration() throws {
guard !configuration.deviceUDID.isEmpty else {
throw RecorderError.make(30, "deviceUDID must not be empty")
}
guard configuration.fps > 0, configuration.fps <= 120 else {
throw RecorderError.make(31, "fps must be between 1 and 120")
}
guard configuration.bitrate > 0 else {
throw RecorderError.make(32, "bitrate must be positive")
}
guard configuration.segmentDuration >= 0 else {
throw RecorderError.make(33, "segmentDuration must be non-negative")
}
guard configuration.maxPendingFrames > 0 else {
throw RecorderError.make(34, "maxPendingFrames must be positive")
}
}
private func startBoundaryTimer() {
let timer = DispatchSource.makeTimerSource(queue: callbackQueue)
timer.schedule(deadline: .now() + 1, repeating: 1, leeway: .milliseconds(100))
timer.setEventHandler { [weak self] in
self?.probeSimulatorState()
self?.probeFramebufferHealth()
self?.appendBoundaryFrameIfNeeded()
}
timer.resume()
boundaryTimer = timer
}
private func appendBoundaryFrameIfNeeded() {
if stopped {
return
}
guard configuration.segmentDuration > 0 else {
return
}
writerQueue.sync {
guard firstAcceptedCaptureTime != nil else {
return
}
let currentPTSSeconds = CMTimeGetSeconds(
normalizedPresentationTime(for: monotonicClock.elapsedTime()))
guard currentPTSSeconds >= nextBoundaryElapsed else {
return
}
let boundary = nextBoundaryElapsed
guard let lastPTS else {
return
}
do {
if CMTimeGetSeconds(lastPTS) < boundary {
let boundaryPTS = CMTime(seconds: boundary, preferredTimescale: 1_000_000_000)
guard try appendHeldFrame(at: boundaryPTS) else {
return
}
}
nextBoundaryElapsed = boundary + configuration.segmentDuration
} catch {
firstError = error
signalFirstFrameReadyIfNeeded()
}
}
}
private func captureFrame(force: Bool, reason: CaptureReason) {
if stopped {
return
}
if reason == .callback {
lastFrameCallbackElapsed = monotonicClock.elapsedSeconds()
}
guard let snapshot = displaySource?.surfaceSnapshot() else {
return
}
let surface = snapshot.surface
let surfaceWidth = snapshot.width
let surfaceHeight = snapshot.height
guard surfaceWidth > 0, surfaceHeight > 0 else {
return
}
let seed = snapshot.seed
let matchesLastAppendedSeed = writerQueue.sync { lastSeed == seed }
if !force, matchesLastAppendedSeed {
return
}
guard reservePendingFrame() else {
return
}
let capturedAt = monotonicClock.elapsedTime()
let capturedAtWallClock = Date()
do {
let pixelBuffer = try copySurfaceToOwnedPixelBuffer(
surface,
width: surfaceWidth,
height: surfaceHeight
)
appendOwned(
pixelBuffer: pixelBuffer,
seed: seed,
capturedAt: capturedAt,
capturedAtWallClock: capturedAtWallClock
)
} catch {
releasePendingFrame()
writerQueue.async {
self.firstError = error
self.signalFirstFrameReadyIfNeeded()
}
}
}
private func probeFramebufferHealth() {
guard !stopped, let displaySource else {
return
}
let elapsed = monotonicClock.elapsedSeconds()
if !isFirstFrameReady(),
elapsed - lastFirstFrameRewireElapsed >= Self.firstFrameRewireInterval
{
lastFirstFrameRewireElapsed = elapsed
rewireFramebuffer()
captureFrame(force: true, reason: .healthProbe)
return
}
guard let snapshot = displaySource.surfaceSnapshot(),
snapshot.width > 0,
snapshot.height > 0
else {
rewireFramebuffer()
return
}
let observedSeed = snapshot.seed
let lastAppendedSeed = writerQueue.sync { lastSeed }
guard let lastAppendedSeed, observedSeed != lastAppendedSeed else {
return
}
let lastCallback = lastFrameCallbackElapsed ?? 0
if elapsed - lastCallback >= Self.callbackStalenessTimeout {
captureFrame(force: false, reason: .healthProbe)
rewireFramebuffer()
}
}
private func probeSimulatorState() {
guard !stopped, simulatorStoppedReason == nil else {
return
}
let state = SimulatorDeviceLookup.stateString(udid: configuration.deviceUDID) ?? "missing"
guard state != "Booted" else {
return
}
simulatorStoppedReason = state
let error = RecorderError.make(26, "Simulator stopped before first frame (state: \(state))")
writerQueue.async {
if self.firstError == nil, !self.firstFrameReady {
self.firstError = error
self.signalFirstFrameReadyIfNeeded()
}
}
let onSimulatorStopped = onSimulatorStopped
eventQueue.async {
onSimulatorStopped?(state)
}
}
private func rewireFramebuffer() {
guard let displaySource else {
return
}
do {
try displaySource.rewireFramebuffer()
} catch {
writerQueue.async {
self.firstError = error
self.signalFirstFrameReadyIfNeeded()
}
}
}
private func reservePendingFrame() -> Bool {
pendingLock.lock()
defer { pendingLock.unlock() }
if pendingFrames >= configuration.maxPendingFrames {
return false
}
pendingFrames += 1
return true
}
private func releasePendingFrame() {
pendingLock.lock()
pendingFrames = max(0, pendingFrames - 1)
pendingLock.unlock()
}
private func copySurfaceToOwnedPixelBuffer(
_ surface: IOSurface,
width: Int,
height: Int
) throws -> CVPixelBuffer {
let pool = try writerQueue.sync {
if let firstError {
throw firstError
}
if writer == nil {
try startWriter(width: width, height: height)
}
guard let pool = adaptor?.pixelBufferPool else {
throw RecorderError.make(40, "AVAssetWriter pixel buffer pool is unavailable")
}
return pool
}
var output: CVPixelBuffer?
let status = CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &output)
guard status == kCVReturnSuccess, let destination = output else {
throw RecorderError.make(41, "Failed to allocate pixel buffer from pool: \(status)")
}
let destinationLockStatus = CVPixelBufferLockBaseAddress(destination, [])
guard destinationLockStatus == kCVReturnSuccess else {
throw RecorderError.make(50, "Failed to lock pixel buffer: \(destinationLockStatus)")
}
let surfaceLockStatus = IOSurfaceLock(surface, .readOnly, nil)
guard surfaceLockStatus == KERN_SUCCESS else {
CVPixelBufferUnlockBaseAddress(destination, [])
throw RecorderError.make(49, "Failed to lock IOSurface: \(surfaceLockStatus)")
}
defer {
IOSurfaceUnlock(surface, .readOnly, nil)
CVPixelBufferUnlockBaseAddress(destination, [])
}
guard let destinationAddress = CVPixelBufferGetBaseAddress(destination) else {
throw RecorderError.make(42, "Pixel buffer has no base address")
}
let sourceAddress = IOSurfaceGetBaseAddress(surface)
let sourceStride = IOSurfaceGetBytesPerRow(surface)
let destinationStride = CVPixelBufferGetBytesPerRow(destination)
let copyWidth = min(width, IOSurfaceGetWidth(surface), CVPixelBufferGetWidth(destination))
let copyHeight = min(
height, IOSurfaceGetHeight(surface), CVPixelBufferGetHeight(destination))
let copyBytes = min(copyWidth * 4, sourceStride, destinationStride)
for row in 0..<copyHeight {
memcpy(
destinationAddress.advanced(by: row * destinationStride),
sourceAddress.advanced(by: row * sourceStride),
copyBytes
)
}
return destination
}
private func appendOwned(
pixelBuffer: CVPixelBuffer,
seed: UInt32,
capturedAt: CMTime,
capturedAtWallClock: Date
) {
writerQueue.async {
defer { self.releasePendingFrame() }
if self.firstError != nil {
return
}
do {
try self.appendOnWriterQueue(
pixelBuffer: pixelBuffer,
seed: seed,
capturedAt: capturedAt,
capturedAtWallClock: capturedAtWallClock
)
} catch {
self.firstError = error
self.signalFirstFrameReadyIfNeeded()
}
}
}
private func appendOnWriterQueue(
pixelBuffer: CVPixelBuffer,
seed: UInt32,
capturedAt: CMTime,
capturedAtWallClock: Date
) throws {
guard let writer, let input, let adaptor else {
throw RecorderError.make(43, "AVAssetWriter is not initialized")
}
let isFirstFrame = firstAcceptedCaptureTime == nil
let pts = isFirstFrame ? .zero : normalizedPresentationTime(for: capturedAt)
if let lastPTS, CMTimeCompare(pts, lastPTS) <= 0 {
return
}
guard try appendPendingBoundaryFrames(before: pts) else {
return
}
guard input.isReadyForMoreMediaData else {
return
}
if !adaptor.append(pixelBuffer, withPresentationTime: pts) {
throw writer.error ?? RecorderError.make(44, "Failed to append pixel buffer")
}
if isFirstFrame {
firstAcceptedCaptureTime = capturedAt
firstAcceptedWallClock = capturedAtWallClock
}
lastPTS = pts
lastSeed = seed
lastAppendedPixelBuffer = pixelBuffer
signalFirstFrameReadyIfNeeded()
}
private func appendTailFrameIfNeeded(finalPTS: CMTime) throws {
let deadline = Date().addingTimeInterval(5)
while true {
if try appendTailFrame(finalPTS: finalPTS) {
return
}
if Date() >= deadline {
throw RecorderError.make(45, "Timed out waiting to append tail frame")
}
Thread.sleep(forTimeInterval: 0.05)
}
}
private func appendTailFrame(finalPTS: CMTime) throws -> Bool {
let pts = normalizedPresentationTime(for: finalPTS)
return try appendHeldFrame(at: pts)
}
private func appendPendingBoundaryFrames(before pts: CMTime) throws -> Bool {
guard configuration.segmentDuration > 0,
firstAcceptedCaptureTime != nil
else {
return true
}
while CMTimeGetSeconds(pts) > nextBoundaryElapsed {
if let lastPTS, CMTimeGetSeconds(lastPTS) >= nextBoundaryElapsed {
nextBoundaryElapsed += configuration.segmentDuration
continue
}
let boundaryPTS = CMTime(
seconds: nextBoundaryElapsed, preferredTimescale: 1_000_000_000)
guard try appendHeldFrame(at: boundaryPTS) else {
return false
}
nextBoundaryElapsed += configuration.segmentDuration
}
return true
}
private func appendHeldFrame(at pts: CMTime) throws -> Bool {
guard let input,
let adaptor,
let lastPTS,
let lastAppendedPixelBuffer
else {
return true
}
if CMTimeCompare(pts, lastPTS) <= 0 {
return true
}
guard input.isReadyForMoreMediaData else {
return false
}
if !adaptor.append(lastAppendedPixelBuffer, withPresentationTime: pts) {
throw writer?.error ?? RecorderError.make(45, "Failed to append held frame")
}
self.lastPTS = pts
return true
}
private func normalizedPresentationTime(for capturedAt: CMTime) -> CMTime {
let start = firstAcceptedCaptureTime ?? capturedAt
return CMTimeSubtract(capturedAt, start)
}
private func startWriter(width: Int, height: Int) throws {
guard let outputWriter else {
throw RecorderError.make(46, "Missing output writer")
}
let writer: AVAssetWriter
if configuration.segmentDuration > 0 {
writer = AVAssetWriter(contentType: .mpeg4Movie)
writer.delegate = outputWriter
writer.outputFileTypeProfile = .mpeg4AppleHLS
writer.preferredOutputSegmentInterval = CMTime(
seconds: configuration.segmentDuration,
preferredTimescale: 600
)
} else {
writer = try AVAssetWriter(outputURL: outputWriter.singleRecordingURL, fileType: .mp4)
}
writer.initialSegmentStartTime = .zero
let input = AVAssetWriterInput(
mediaType: .video,
outputSettings: assetWriterSettings(width: width, height: height)
)
input.expectsMediaDataInRealTime = true
let attributes: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
kCVPixelBufferWidthKey as String: width,
kCVPixelBufferHeightKey as String: height,
kCVPixelBufferIOSurfacePropertiesKey as String: [:],
]
let adaptor = AVAssetWriterInputPixelBufferAdaptor(
assetWriterInput: input,
sourcePixelBufferAttributes: attributes
)
guard writer.canAdd(input) else {
throw RecorderError.make(47, "AVAssetWriter cannot add video input")
}
writer.add(input)
guard writer.startWriting() else {
throw writer.error ?? RecorderError.make(48, "AVAssetWriter failed to start")
}
writer.startSession(atSourceTime: .zero)
self.writer = writer
self.input = input
self.adaptor = adaptor
recordingWidth = width
recordingHeight = height
}
private func assetWriterSettings(width: Int, height: Int) -> [String: Any] {
let codecType: AVVideoCodecType = configuration.codec == .hevc ? .hevc : .h264
var compression: [String: Any] = [
AVVideoAverageBitRateKey: configuration.bitrate,
AVVideoExpectedSourceFrameRateKey: configuration.fps,
AVVideoAllowFrameReorderingKey: true,
]
if configuration.segmentDuration > 0 {
compression[AVVideoMaxKeyFrameIntervalKey] = max(
1,
Int((configuration.segmentDuration * Double(configuration.fps)).rounded())
)
}
if configuration.codec == .h264 {
compression[AVVideoProfileLevelKey] = AVVideoProfileLevelH264HighAutoLevel
}
return [
AVVideoCodecKey: codecType,
AVVideoWidthKey: width,
AVVideoHeightKey: height,
AVVideoCompressionPropertiesKey: compression,
]
}
private func signalFirstFrameReadyIfNeeded() {
if firstFrameReady {
return
}
firstFrameReady = true
firstFrameSemaphore.signal()
}
private func isFirstFrameReady() -> Bool {
writerQueue.sync {
firstFrameReady
}
}
}
@@ -1,60 +0,0 @@
import CoreMedia
import Foundation
enum RecorderError {
static func make(_ code: Int, _ message: String) -> NSError {
NSError(
domain: "RecordSim",
code: code,
userInfo: [NSLocalizedDescriptionKey: message]
)
}
}
extension JSONEncoder {
static var prettySorted: JSONEncoder {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return encoder
}
}
func timeSeconds(_ time: CMTime) -> Double? {
guard time.isValid, !time.isIndefinite, !time.isNegativeInfinity, !time.isPositiveInfinity else {
return nil
}
return CMTimeGetSeconds(time)
}
func iso8601(_ date: Date) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
formatter.timeZone = TimeZone(secondsFromGMT: 0)
return formatter.string(from: date)
}
func unixMs(_ date: Date) -> Int64 {
Int64((date.timeIntervalSince1970 * 1000).rounded())
}
struct MonotonicClock {
private static let timescale: CMTimeScale = 1_000_000_000
let startedAtUptimeNanoseconds: UInt64
init() {
startedAtUptimeNanoseconds = DispatchTime.now().uptimeNanoseconds
}
func elapsedSeconds() -> TimeInterval {
TimeInterval(elapsedNanoseconds()) / TimeInterval(Self.timescale)
}
func elapsedTime() -> CMTime {
CMTime(value: CMTimeValue(elapsedNanoseconds()), timescale: Self.timescale)
}
private func elapsedNanoseconds() -> UInt64 {
DispatchTime.now().uptimeNanoseconds - startedAtUptimeNanoseconds
}
}
@@ -1,159 +0,0 @@
import Foundation
import RecordSim
struct CLIOptions {
var udid: String?
var output: String?
var segmentDuration: TimeInterval = 120
var fps = 30
var bitrate = 30_000_000
var codec: RecorderCodec = .h264
}
private var requestedStopSignal: CInt = 0
private var simulatorStopped: CInt = 0
private func handleStopSignal(_ signal: CInt) {
requestedStopSignal = signal
}
private func isStopRequested() -> Bool {
requestedStopSignal != 0 || simulatorStopped != 0
}
func usage() -> String {
"""
Usage:
record-sim --udid <UDID> --output <session-dir> [options]
Options:
--segment-duration <seconds> Target fMP4 media segment duration. Use 0 for one MP4. Default: 120
--fps <fps> Expected source frame rate. Default: 30
--bitrate <bits/sec> AVAssetWriter average bitrate. Default: 30000000
--codec <h264|hevc> Video codec. Default: h264
-h, --help Show this help
"""
}
func parseOptions(_ args: [String]) throws -> CLIOptions {
var options = CLIOptions()
var i = 0
func next(_ flag: String) throws -> String {
i += 1
guard i < args.count else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing value for \(flag)"])
}
return args[i]
}
while i < args.count {
let arg = args[i]
switch arg {
case "--udid", "-u":
options.udid = try next(arg)
case "--output", "--output-dir", "--out", "-o":
options.output = try next(arg)
case "--segment-duration":
options.segmentDuration = Double(try next(arg)) ?? -1
case "--fps":
options.fps = Int(try next(arg)) ?? 0
case "--bitrate":
options.bitrate = Int(try next(arg)) ?? 0
case "--codec":
let raw = try next(arg)
guard let codec = RecorderCodec(rawValue: raw) else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--codec must be h264 or hevc"])
}
options.codec = codec
case "--help", "-h":
print(usage())
exit(0)
default:
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Unknown argument: \(arg)"])
}
i += 1
}
guard options.udid != nil else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing required --udid"])
}
guard options.output != nil else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing required --output"])
}
guard options.segmentDuration >= 0 else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--segment-duration must be non-negative"])
}
guard options.fps > 0, options.fps <= 120 else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--fps must be between 1 and 120"])
}
guard options.bitrate > 0 else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--bitrate must be positive"])
}
return options
}
do {
signal(SIGINT, handleStopSignal)
signal(SIGTERM, handleStopSignal)
let options = try parseOptions(Array(CommandLine.arguments.dropFirst()))
let configuration = SimulatorRecordingConfiguration(
deviceUDID: options.udid!,
outputDirectory: URL(fileURLWithPath: options.output!),
fps: options.fps,
bitrate: options.bitrate,
codec: options.codec,
segmentDuration: options.segmentDuration
)
let recorder = SimulatorRecorder(configuration: configuration)
recorder.onSimulatorStopped = { state in
simulatorStopped = 1
fputs("[record-sim] simulator stopped state=\(state); finalizing recording\n", stderr)
}
recorder.onFinalizationStage = { stage in
switch stage {
case .captureStopped:
fputs("[record-sim] screen capture stopped; saving final video\n", stderr)
case .videoSaved:
fputs("[record-sim] final video saved; writing recording details\n", stderr)
}
}
recorder.onSegment = { segment in
switch segment.kind {
case .initialization:
fputs("[record-sim] init \(segment.relativePath) bytes=\(segment.byteCount)\n", stderr)
case .media:
let duration = segment.durationSeconds ?? 0
fputs(String(format: "[record-sim] segment %@ duration=%.3fs bytes=%d\n", segment.relativePath, duration, segment.byteCount), stderr)
}
}
try recorder.start()
try recorder.waitUntilFirstFrame()
fputs("[record-sim] recording -> \(options.output!)\n", stderr)
while !isStopRequested() {
Thread.sleep(forTimeInterval: 0.1)
}
if requestedStopSignal != 0 {
let signalName = switch requestedStopSignal {
case SIGINT: "SIGINT"
case SIGTERM: "SIGTERM"
default: "signal \(requestedStopSignal)"
}
fputs("[record-sim] received \(signalName); finishing recording\n", stderr)
}
let manifest = try recorder.stop()
if let recording = manifest.recording {
fputs("[record-sim] done recording=\(recording)\n", stderr)
} else {
fputs("[record-sim] done segments=\(manifest.segments.count)\n", stderr)
}
fputs("[record-sim] manifest \(URL(fileURLWithPath: options.output!).appendingPathComponent("session.json").path)\n", stderr)
} catch {
fputs("error: \(error.localizedDescription)\n", stderr)
exit(1)
}
@@ -0,0 +1,5 @@
import { Android } from '@expo/eas-build-job';
export function resolveArtifactPath(job: Android.Job): string {
return job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}';
}
@@ -60,7 +60,8 @@ jest.mock('../../utils/expoUpdates', () => ({
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync: jest.fn(async () => null),
}));
jest.mock('../../utils/expoUpdatesEmbedded', () => ({
uploadEmbeddedBundleAsync: jest.fn(),
...jest.requireActual('../../utils/expoUpdatesEmbedded'),
uploadEmbeddedBundleAsync: jest.fn(async () => ({ status: 'uploaded' })),
}));
jest.mock('../../utils/hooks', () => ({
Hook: {
@@ -317,36 +318,108 @@ describe(androidBuilder, () => {
expect(runBuilderWithHooksAsync).toHaveBeenCalledWith(ctx, expect.any(Function));
});
it('runs the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is set', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1',
},
uploadArtifact: jest.fn(),
describe('embedded bundle upload phase', () => {
function createContext({
env = {},
sdkVersion,
}: {
env?: Record<string, string>;
sdkVersion?: string;
}): BuildContext<Android.Job> {
return new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
...env,
},
uploadArtifact: jest.fn(),
metadata: sdkVersion ? { sdkVersion } : undefined,
});
}
it('runs by default on SDK 58 and later', async () => {
const ctx = createContext({ sdkVersion: '58.0.0' });
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
expect.objectContaining({ job: ctx.job, env: ctx.env, projectDir: '/workingdir/build' })
);
});
await androidBuilder(ctx);
it.each([
['uploaded', BuildPhaseResult.SUCCESS],
['skipped', BuildPhaseResult.SKIPPED],
['failed', BuildPhaseResult.WARNING],
] as const)('marks the phase from the %s result', async (uploadResult, phaseResult) => {
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValueOnce({ status: uploadResult });
const ctx = createContext({ sdkVersion: '58.0.0' });
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
});
await androidBuilder(ctx);
it('skips the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is not set', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect.objectContaining({
build_phase: BuildPhase.UPLOAD_EMBEDDED_BUNDLE.toLowerCase(),
result: phaseResult,
})
);
});
await androidBuilder(ctx);
it('skips by default on SDK 57 and earlier', async () => {
const ctx = createContext({ sdkVersion: '57.0.0' });
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
it('skips by default when the SDK version is unknown', async () => {
const ctx = createContext({});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
it('runs on SDK 57 and earlier when EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE is 1', async () => {
const ctx = createContext({
sdkVersion: '57.0.0',
env: { EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE: '1' },
});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
expect.objectContaining({ job: ctx.job })
);
});
it('skips on SDK 58 and later when EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE is 0', async () => {
const ctx = createContext({
sdkVersion: '58.0.0',
env: { EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE: '0' },
});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
it('runs on SDK 57 and earlier when the experimental EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is 1', async () => {
const ctx = createContext({
sdkVersion: '57.0.0',
env: { EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1' },
});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
expect.objectContaining({ job: ctx.job })
);
});
});
});
+19 -4
View File
@@ -11,6 +11,7 @@ import {
runGradleCommand,
} from '../android/gradle';
import { formatGradleProfileReport, parseGradleProfile } from '../android/gradleProfile';
import { resolveArtifactPath } from '../android/resolve';
import { Sentry } from '../sentry';
import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
import { prebuildAsync } from '../common/prebuild';
@@ -32,7 +33,10 @@ import {
configureExpoUpdatesIfInstalledAsync,
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
} from '../utils/expoUpdates';
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
import {
shouldUploadEmbeddedBundle,
uploadEmbeddedBundleAsync,
} from '../utils/expoUpdatesEmbedded';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
@@ -217,15 +221,26 @@ async function buildInnerAsync(
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
await uploadApplicationArchive(ctx, {
patternOrPath: ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}',
patternOrPath: resolveArtifactPath(ctx.job),
rootDir: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
});
});
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
if (shouldUploadEmbeddedBundle(ctx)) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
await uploadEmbeddedBundleAsync(ctx);
const { status } = await uploadEmbeddedBundleAsync({
job: ctx.job,
env: ctx.env,
logger: ctx.logger,
projectDir: ctx.getReactNativeProjectDirectory(),
appConfig: await ctx.appConfig,
});
if (status === 'skipped') {
ctx.markBuildPhaseSkipped();
} else if (status === 'failed') {
ctx.markBuildPhaseHasWarnings();
}
});
}
+18 -4
View File
@@ -29,7 +29,10 @@ import {
configureExpoUpdatesIfInstalledAsync,
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
} from '../utils/expoUpdates';
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
import {
shouldUploadEmbeddedBundle,
uploadEmbeddedBundleAsync,
} from '../utils/expoUpdatesEmbedded';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
@@ -234,15 +237,26 @@ async function buildInnerAsync(
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
await uploadApplicationArchive(ctx, {
patternOrPath: resolveArtifactPath(ctx),
patternOrPath: resolveArtifactPath(ctx.job),
rootDir: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
});
});
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
if (shouldUploadEmbeddedBundle(ctx)) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
await uploadEmbeddedBundleAsync(ctx);
const { status } = await uploadEmbeddedBundleAsync({
job: ctx.job,
env: ctx.env,
logger: ctx.logger,
projectDir: ctx.getReactNativeProjectDirectory(),
appConfig: await ctx.appConfig,
});
if (status === 'skipped') {
ctx.markBuildPhaseSkipped();
} else if (status === 'failed') {
ctx.markBuildPhaseHasWarnings();
}
});
}
+4 -4
View File
@@ -15,10 +15,10 @@ export function resolveScheme(ctx: BuildContext<Ios.Job>): string {
return schemes[0];
}
export function resolveArtifactPath(ctx: BuildContext<Ios.Job>): string {
if (ctx.job.applicationArchivePath) {
return ctx.job.applicationArchivePath;
} else if (ctx.job.simulator) {
export function resolveArtifactPath(job: Ios.Job): string {
if (job.applicationArchivePath) {
return job.applicationArchivePath;
} else if (job.simulator) {
return 'ios/build/Build/Products/*simulator/*.app';
} else {
return 'ios/build/*.ipa';
+14 -2
View File
@@ -19,6 +19,7 @@ import { generateGymfileFromTemplateFunction } from './functions/generateGymfile
import { createGetCredentialsForBuildTriggeredByGithubIntegration } from './functions/getCredentialsForBuildTriggeredByGitHubIntegration';
import { injectAndroidCredentialsFunction } from './functions/injectAndroidCredentials';
import { createInstallMaestroBuildFunction } from './functions/installMaestro';
import { createInstallMitmproxyBuildFunction } from './functions/installMitmproxy';
import { createInstallBuildFunction } from './functions/installBuild';
import { createInstallNodeModulesBuildFunction } from './functions/installNodeModules';
import { createInstallPodsBuildFunction } from './functions/installPods';
@@ -26,6 +27,7 @@ import { createLaunchApplicationFunction } from './functions/launchApplication';
import { createPrebuildBuildFunction } from './functions/prebuild';
import { createReadAppConfigBuildFunction } from './functions/readAppConfig';
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
import { createReadAndroidAppInfoBuildFunction } from './functions/readAndroidAppInfo';
import { createReadPackageJsonBuildFunction } from './functions/readPackageJson';
import { createRepackBuildFunction } from './functions/repack';
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
@@ -44,7 +46,7 @@ import { createSaveBuildCacheFunction } from './functions/saveBuildCache';
import { createRolloutPosthogFlagFunction } from './functions/rolloutPosthogFlag';
import { createSaveCacheFunction } from './functions/saveCache';
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
import { createStartAgentDeviceSessionBuildFunction } from './functions/startAgentDeviceSession';
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
import { createStartArgentRemoteSessionBuildFunction } from './functions/startArgentRemoteSession';
import { createStartAppiumRemoteSessionBuildFunction } from './functions/startAppiumRemoteSession';
@@ -54,11 +56,15 @@ import { createStartIosSimulatorRecordingsBuildFunction } from './functions/star
import { createStartLocalEgressBuildFunction } from './functions/startLocalEgress';
import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/startWebPreviewRemoteSession';
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
import { createStartServeSimLogsBuildFunction } from './functions/startServeSimLogs';
import { createCollectServeSimLogsBuildFunction } from './functions/collectServeSimLogs';
import { createStartSandboxBuildFunction } from './functions/startSandbox';
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from './functions/uploadDeviceRunSessionScreenRecordings';
import { createUploadEmbeddedBundleBuildFunction } from './functions/uploadEmbeddedBundle';
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
import { createUpdateTestFlightMetadataBuildFunction } from './functions/updateTestFlightMetadata';
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
import { createWaitForPosthogMetricFunction } from './functions/waitForPosthogMetric';
import { createUploadPosthogSourcemapsFunction } from './functions/uploadPosthogSourcemaps';
@@ -77,6 +83,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createInstallNodeModulesBuildFunction(),
createPrebuildBuildFunction(),
createReadIpaInfoBuildFunction(),
createReadAndroidAppInfoBuildFunction(),
createDownloadBuildFunction(ctx),
createInstallBuildFunction(),
createLaunchApplicationFunction(),
@@ -99,7 +106,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
generateGymfileFromTemplateFunction(),
runFastlaneFunction(),
parseXcactivitylogFunction(),
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
createStartAgentDeviceSessionBuildFunction(ctx),
createStartArgentRemoteSessionBuildFunction(ctx),
createStartAppiumRemoteSessionBuildFunction(ctx),
createStartAndroidEmulatorBuildFunction(),
@@ -111,9 +118,12 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
createStartWebPreviewRemoteSessionBuildFunction(ctx),
createStartServeSimMetricsBuildFunction(),
createStartServeSimLogsBuildFunction(),
createCollectServeSimLogsBuildFunction(ctx),
createStartSandboxBuildFunction(ctx),
createCollectServeSimMetricsBuildFunction(ctx),
createInstallMaestroBuildFunction(),
createInstallMitmproxyBuildFunction(),
createInstallPodsBuildFunction(),
createSendSlackMessageFunction(),
@@ -128,6 +138,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createSubmissionEntityFunction(),
createUploadToAscBuildFunction(),
createUpdateTestFlightMetadataBuildFunction(),
createReportMaestroTestResultsFunction(ctx),
createMaestroTestsBuildFunction(ctx),
@@ -137,6 +148,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
functions.push(
...[
createFindAndUploadBuildArtifactsBuildFunction(ctx),
createUploadEmbeddedBundleBuildFunction(ctx),
createResolveBuildConfigBuildFunction(ctx),
createGetCredentialsForBuildTriggeredByGithubIntegration(ctx),
]
@@ -219,4 +219,71 @@ describe(createEasBuildBuildFunctionGroup, () => {
'Build function group is not supported in generic jobs.'
);
});
describe('embedded bundle upload', () => {
function getStepNames(
options: Parameters<typeof createMockBuildToolsContext>[0],
{ sdkVersion, env = {} }: { sdkVersion?: string; env?: Record<string, string> }
): string[] {
const functionGroup = createEasBuildBuildFunctionGroup(createMockBuildToolsContext(options));
const globalCtx = createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: { metadata: sdkVersion ? { sdkVersion } : null },
});
globalCtx.updateEnv(env);
return functionGroup
.createBuildStepsFromFunctionGroupCall(globalCtx)
.map(step => step.displayName);
}
it.each([
['Android', { platform: Platform.ANDROID }],
['Android with credentials', { platform: Platform.ANDROID, buildCredentials: { test: {} } }],
['iOS with credentials', { platform: Platform.IOS, buildCredentials: { test: {} } }],
])('uploads the embedded bundle after the build artifacts on SDK 58 (%s)', (_, options) => {
const stepNames = getStepNames(options, { sdkVersion: '58.0.0' });
expect(stepNames.indexOf('Upload embedded bundle')).toBe(
stepNames.indexOf('Find and upload build artifacts') + 1
);
});
it.each([
['Android', { platform: Platform.ANDROID }],
['Android with credentials', { platform: Platform.ANDROID, buildCredentials: { test: {} } }],
['iOS with credentials', { platform: Platform.IOS, buildCredentials: { test: {} } }],
])('passes ignore_error to the embedded bundle step (%s)', (_, options) => {
const functionGroup = createEasBuildBuildFunctionGroup(createMockBuildToolsContext(options));
const globalCtx = createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: { metadata: { sdkVersion: '58.0.0' } },
});
const uploadStep = functionGroup
.createBuildStepsFromFunctionGroupCall(globalCtx)
.find(step => step.displayName === 'Upload embedded bundle');
expect(uploadStep).toBeDefined();
expect(uploadStep!.inputs?.find(input => input.id === 'ignore_error')?.rawValue).toBe(true);
});
it('does not upload the embedded bundle on SDK 57 without the opt-in', () => {
expect(getStepNames({}, { sdkVersion: '57.0.0' })).not.toContain('Upload embedded bundle');
});
it('uploads the embedded bundle on SDK 57 with the opt-in', () => {
expect(
getStepNames(
{},
{ sdkVersion: '57.0.0', env: { EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1' } }
)
).toContain('Upload embedded bundle');
});
it('does not upload the embedded bundle for iOS simulator builds', () => {
expect(
getStepNames({ platform: Platform.IOS, simulator: true }, { sdkVersion: '58.0.0' })
).not.toContain('Upload embedded bundle');
});
});
});
@@ -10,6 +10,7 @@ import path from 'path';
import { shouldUseEagerBundle } from '../../common/eagerBundle';
import { CustomBuildContext } from '../../customBuildContext';
import { shouldUploadEmbeddedBundle } from '../../utils/expoUpdatesEmbedded';
import { calculateEASUpdateRuntimeVersionFunction } from '../functions/calculateEASUpdateRuntimeVersion';
import { createCheckoutBuildFunction } from '../functions/checkout';
import { configureAndroidVersionFunction } from '../functions/configureAndroidVersion';
@@ -32,6 +33,7 @@ import {
import { runFastlaneFunction } from '../functions/runFastlane';
import { runGradleFunction } from '../functions/runGradle';
import { createSaveBuildCacheFunction } from '../functions/saveBuildCache';
import { createUploadEmbeddedBundleBuildFunction } from '../functions/uploadEmbeddedBundle';
import { createSetUpNpmrcBuildFunction } from '../functions/useNpmToken';
interface HelperFunctionsInput {
@@ -303,6 +305,19 @@ function createStepsForIosBuildWithCredentials({
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
...(shouldUploadEmbeddedBundle({
env: globalCtx.env,
metadata: globalCtx.staticContext.metadata,
})
? [
createUploadEmbeddedBundleBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: { ignore_error: true },
}),
]
: []),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
@@ -390,6 +405,19 @@ function createStepsForAndroidBuildWithoutCredentials({
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
...(shouldUploadEmbeddedBundle({
env: globalCtx.env,
metadata: globalCtx.staticContext.metadata,
})
? [
createUploadEmbeddedBundleBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: { ignore_error: true },
}),
]
: []),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
@@ -483,6 +511,19 @@ function createStepsForAndroidBuildWithCredentials({
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
...(shouldUploadEmbeddedBundle({
env: globalCtx.env,
metadata: globalCtx.staticContext.metadata,
})
? [
createUploadEmbeddedBundleBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: { ignore_error: true },
}),
]
: []),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
@@ -0,0 +1,47 @@
import { UserError } from '@expo/eas-build-job';
import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { readAndroidArtifactInfoAsync } from '../../utils/android/appArtifact';
import { createReadAndroidAppInfoBuildFunction } from '../readAndroidAppInfo';
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
jest.unmock('@expo/spawn-async');
const fixturesDirectory = path.join(__dirname, 'fixtures/android-app-info');
it.each(['apk', 'aab'])('reads a real %s and publishes build step outputs', async type => {
const step = createReadAndroidAppInfoBuildFunction().createBuildStepFromFunctionCall(
createGlobalContextMock({
projectTargetDirectory: fixturesDirectory,
staticContextContent: { job: {} },
}),
{ callInputs: { artifact_path: `app.${type}` } }
);
await step.executeAsync();
expect(step.outputById.artifact_type.value).toBe(type);
expect(step.outputById.package_name.value).toBe('com.example.app');
});
it('reports an invalid ZIP as a user error', async () => {
await expect(readAndroidArtifactInfoAsync(__filename)).rejects.toMatchObject({
constructor: UserError,
errorCode: 'EAS_READ_ANDROID_APP_INFO_INVALID_ARTIFACT',
cause: expect.any(Error),
});
});
it.each(['apk', 'aab'])('retains the native tool error for an invalid %s manifest', async type => {
await expect(
readAndroidArtifactInfoAsync(path.join(fixturesDirectory, `invalid-manifest-${type}.zip`))
).rejects.toMatchObject({
constructor: UserError,
errorCode: 'EAS_READ_ANDROID_APP_INFO_INVALID_MANIFEST',
cause: expect.any(Error),
});
});
@@ -0,0 +1,262 @@
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import type { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
import { turtleFetch } from '../../../utils/turtleFetch';
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import {
spawnDetached,
startNgrokTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
jest.mock('@expo/turtle-spawn');
jest.mock('../../../sentry');
jest.mock('../../../utils/turtleFetch');
jest.mock('../../../utils/AndroidEmulatorUtils');
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/agentDeviceEvents');
jest.mock('../../utils/appiumEvents');
jest.mock('../../utils/agentDeviceArtifacts');
// The agent-device session step boots the emulator itself; this test covers the session host.
jest.mock('../startAndroidEmulator', () => ({
startAndroidEmulatorAsync: jest.fn().mockResolvedValue({
serialId: 'emulator-5554',
emulatorPromise: Promise.resolve(),
shouldAdjustAnimationScale: true,
}),
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
spawnDetached: jest.fn(),
startNgrokTunnelAsync: jest.fn(),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
waitForFileAsync: jest.fn(),
}));
const finishHost = jest.fn();
const closePreview = jest.fn();
const stopTool = jest.fn();
const stopTunnel = jest.fn();
const stopEvents = jest.fn();
const openPreview = jest.fn();
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
beforeEach(() => {
jest.clearAllMocks();
// The daemon package is external. No installation or daemon process runs in these tests.
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
// Only the emulator logcat folder: the Appium step writes into its own temp folder.
const mkdtemp = fs.promises.mkdtemp.bind(fs.promises);
jest
.spyOn(fs.promises, 'mkdtemp')
.mockImplementation(async prefix =>
String(prefix).includes('eas-android-emulator-logcat-')
? '/tmp/eas-android-emulator-logcat-test'
: mkdtemp(prefix)
);
jest.mocked(spawn).mockResolvedValue({ stdout: '{}' } as never);
jest
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
.mockResolvedValue([{ serialId: 'emulator-5554', state: 'device' } as never]);
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 4567, token: 'daemon-token' });
for (const stop of [finishHost, closePreview, stopTool, stopTunnel, stopEvents]) {
stop.mockResolvedValue(undefined);
}
openPreview.mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://preview.example.test',
closeAsync: closePreview,
});
jest
.mocked(startDeviceSessionHostAsync)
.mockResolvedValue({ openPreviewAsync: openPreview, finishAsync: finishHost });
jest.mocked(spawnDetached).mockReturnValue({
pid: undefined,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: stopTool,
});
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
url: 'https://tool.example.test',
subdomainId: 'tool-id',
stopAsync: stopTunnel,
});
const events = { stopAsync: stopEvents, getLastEventObservedAt: () => undefined };
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue(events);
jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue(events);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe.each([
['Appium', createStartAppiumRemoteSessionBuildFunction],
['Agent Device', createStartAgentDeviceSessionBuildFunction],
] as const)('%s host ownership', (name, createFunction) => {
async function runAsync() {
const fn = createFunction({} as CustomBuildContext);
await fn.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
// Inputs a step does not get default to undefined, like in a real step call.
inputs: new Proxy({} as Record<string, { value: unknown }>, {
get: (target, id: string) => target[id] ?? { value: undefined },
}),
outputs: {},
env: {
DEVICE_RUN_SESSION_ID: 'session-id',
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
NGROK_AUTHTOKEN: 'token',
},
} as never
);
}
it('holds the host until session completion', async () => {
await runAsync();
expect(openPreview).toHaveBeenCalledWith({ baseDomain: 'example.test' });
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewApiUrl: 'https://preview.example.test',
}),
})
);
expect(finishHost).toHaveBeenCalledTimes(1);
expect(closePreview).not.toHaveBeenCalled();
expect(finishHost.mock.invocationCallOrder[0]).toBeGreaterThan(
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
);
expect(stopTool).toHaveBeenCalledTimes(1);
expect(stopEvents).toHaveBeenCalledTimes(1);
expect(stopTunnel).toHaveBeenCalledTimes(1);
});
it('stops automation while recording finalization or upload is pending', async () => {
let release!: () => void;
let stopped!: () => void;
const pendingFinish = new Promise<void>(resolve => {
release = resolve;
});
const toolStopped = new Promise<void>(resolve => {
stopped = resolve;
});
finishHost.mockReturnValueOnce(pendingFinish);
stopTool.mockImplementationOnce(async () => {
stopped();
});
let completed = false;
const running = runAsync().then(() => {
completed = true;
});
try {
await toolStopped;
expect(stopTunnel).toHaveBeenCalledTimes(1);
expect(finishHost).toHaveBeenCalledTimes(1);
expect(completed).toBe(false);
} finally {
release();
await running;
}
});
it('finalizes recording and stops automation while the tunnel close is pending', async () => {
let release!: () => void;
let finished!: () => void;
const pendingClose = new Promise<void>(resolve => {
release = resolve;
});
const hostFinished = new Promise<void>(resolve => {
finished = resolve;
});
const toolStopped = new Promise<void>(resolve => {
stopTool.mockImplementationOnce(async () => resolve());
});
stopTunnel.mockReturnValueOnce(pendingClose);
finishHost.mockImplementationOnce(async () => {
finished();
});
const running = runAsync();
try {
// Both settle while the tunnel close is still held open.
await Promise.all([hostFinished, toolStopped]);
expect(stopTunnel).toHaveBeenCalledTimes(1);
} finally {
release();
await running;
}
});
it('still stops the automation process when event collection stop rejects', async () => {
stopEvents.mockRejectedValueOnce(new Error('collector failed'));
// Agent Device wraps its collector stop in a catch-all helper; Appium does not.
const outcome = await runAsync().then(
() => 'resolved',
(err: Error) => err.message
);
expect(outcome).toBe(name === 'Appium' ? 'collector failed' : 'resolved');
expect(stopTool).toHaveBeenCalledTimes(1);
expect(stopTunnel).toHaveBeenCalledTimes(1);
expect(finishHost).toHaveBeenCalledTimes(1);
});
it('still stops automation when recording finalization rejects', async () => {
finishHost.mockRejectedValueOnce(new Error('recording cleanup failed'));
await expect(runAsync()).rejects.toThrow('recording cleanup failed');
expect(stopTool).toHaveBeenCalledTimes(1);
expect(stopTunnel).toHaveBeenCalledTimes(1);
expect(Sentry.capture).not.toHaveBeenCalled();
});
it.each(['preview', 'config', 'wait'])('finishes the host after %s fails', async phase => {
const error = new Error(`${phase} failed`);
// A teardown failure must not replace the error that ended the session.
finishHost.mockRejectedValueOnce(new Error('recording cleanup failed'));
if (phase === 'preview') {
openPreview.mockRejectedValueOnce(error);
} else if (phase === 'config') {
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
} else {
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
}
await expect(runAsync()).rejects.toBe(error);
expect(finishHost).toHaveBeenCalledTimes(1);
// An agent-device preview failure aborts the daemon task, which then may not start its
// daemon or tunnel. Everything that started must be stopped.
const abortsDaemonTask = name === 'Agent Device' && phase === 'preview';
expect(stopTool).toHaveBeenCalledTimes(
abortsDaemonTask ? jest.mocked(spawnDetached).mock.calls.length : 1
);
expect(stopTunnel).toHaveBeenCalledTimes(
abortsDaemonTask ? jest.mocked(startNgrokTunnelAsync).mock.calls.length : 1
);
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.objectContaining({ message: 'recording cleanup failed' }) },
'Could not stop the session host during remote session teardown.'
);
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not stop the session host after the remote session failed',
expect.objectContaining({ message: 'recording cleanup failed' }),
{ level: 'warning' }
);
});
});
@@ -11,6 +11,7 @@ import * as tar from 'tar';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import * as files from '../../../utils/files';
import { createDownloadBuildFunction, downloadBuildAsync } from '../downloadBuild';
// contains a 'TestApp.app/TestApp' file with 'i am executable' content
@@ -255,6 +256,144 @@ describe('downloadBuild', () => {
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
});
it('passes the abort signal to the build lookup', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest
.mocked(fetch)
.mockResolvedValue(
createSuccessfulResponse({ body: APP_TAR_GZ_BUFFER, url: APPLICATION_ARCHIVE_URL })
);
const buildId = randomUUID();
await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
signal: new AbortController().signal,
});
expect(graphqlClient.query).toHaveBeenCalledWith(
expect.anything(),
{ buildId },
{ fetch: expect.any(Function) }
);
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ signal: expect.any(Object) })
);
});
it('stops a pending build lookup when aborted', async () => {
// The lookup request runs through the abort-aware fetch from graphqlAbortContext and
// hangs until its signal fires, like a stalled API call.
const realFetch = globalThis.fetch;
globalThis.fetch = jest.fn(
(_input: unknown, init?: RequestInit) =>
new Promise<never>((_resolve, reject) => {
init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason));
})
) as typeof globalThis.fetch;
const graphqlClient = {
query: jest.fn((_query, _variables, context: { fetch: typeof globalThis.fetch }) => ({
toPromise: () =>
context.fetch('https://api.expo.dev/graphql', {}).then(
() => ({ data: undefined }),
(networkError: Error) => ({ error: new CombinedError({ networkError }) })
),
})),
} as unknown as Client;
const controller = new AbortController();
const failure = new Error('daemon failed');
try {
const download = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
signal: controller.signal,
});
await new Promise(resolve => setImmediate(resolve));
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
controller.abort(failure);
await expect(download).rejects.toBe(failure);
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
} finally {
globalThis.fetch = realFetch;
}
});
it('lets a started extraction finish before it reports an abort', async () => {
jest
.mocked(fetch)
.mockResolvedValue(
createSuccessfulResponse({ body: APP_TAR_GZ_BUFFER, url: APPLICATION_ARCHIVE_URL })
);
const { decompressTarAsync } = jest.requireActual<typeof files>('../../../utils/files');
const controller = new AbortController();
const failure = new Error('daemon failed');
let extractionDirectory: string | undefined;
let extractionFinished = false;
const decompressSpy = jest.spyOn(files, 'decompressTarAsync').mockImplementation(async args => {
extractionDirectory = args.destinationDirectory;
// The startup fails while the archive is being extracted.
controller.abort(failure);
await decompressTarAsync(args);
extractionFinished = true;
});
try {
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient: createMockGraphqlClient({}),
robotAccessToken: null,
extensions: ['app'],
signal: controller.signal,
})
).rejects.toBe(failure);
// The download settled only after the extraction wrote its last file.
expect(extractionFinished).toBe(true);
expect(
await fs.promises.readFile(
path.join(extractionDirectory!, 'TestApp.app', 'TestApp'),
'utf8'
)
).toBe('i am executable\n');
} finally {
decompressSpy.mockRestore();
}
});
it('does not look up the build when already aborted', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
const failure = new Error('boot failed');
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
signal: AbortSignal.abort(failure),
})
).rejects.toBe(failure);
expect(graphqlClient.query).not.toHaveBeenCalled();
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('rejects missing or ambiguous build sources', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
@@ -424,6 +563,34 @@ describe('downloadBuild', () => {
});
describe('createDownloadBuildFunction', () => {
it('aborts a pending archive request when the step times out', async () => {
let requestAborted = false;
jest.mocked(fetch).mockImplementation(
async (_url, options) =>
await new Promise<Response>((_resolve, reject) => {
options?.signal?.addEventListener('abort', () => {
requestAborted = true;
reject(new Error('Archive request aborted'));
});
})
);
const graphqlClient = createMockGraphqlClient({});
const step = createDownloadBuildFunction({
graphqlClient,
} as any).createBuildStepFromFunctionCall(
createGlobalContextMock({ staticContextContent: { job: {} } }),
{
callInputs: { application_archive_url: APPLICATION_ARCHIVE_URL, extensions: ['ipa'] },
timeoutMs: 100,
}
);
await expect(step.executeAsync()).rejects.toThrow('timed out');
expect(fetch).toHaveBeenCalledTimes(1);
expect(requestAborted).toBe(true);
expect(graphqlClient.query).not.toHaveBeenCalled();
});
it('should download a build', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
@@ -463,114 +463,127 @@ describe('createInstallMaestroBuildFunction', () => {
}
});
it('installs the prebuilt WDA cache for each available iOS runtime', async () => {
const homeDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'install-maestro-test-')
);
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
if (command === 'maestro-runner') {
return { stdout: 'maestro-runner 1.2.3\n' };
}
if (command === 'xcrun') {
return {
stdout: JSON.stringify({
runtimes: [
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
isAvailable: true,
version: '18.3.1',
},
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-0',
isAvailable: true,
version: '26.0',
},
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-17-5',
isAvailable: false,
version: '17.5',
},
],
}),
};
}
if (command === 'xcodebuild') {
return { stdout: 'Xcode 26.0\nBuild version 17A324\n' };
}
if (command === 'tar') {
const maestroRunnerHome = args[args.indexOf('-C') + 1];
const productsDirectory = path.join(
maestroRunnerHome,
'cache',
'wda-builds',
'generic',
'DerivedData',
'Build',
'Products'
);
await fs.promises.mkdir(productsDirectory, { recursive: true });
await fs.promises.writeFile(
path.join(productsDirectory, 'WebDriverAgentRunner.xctestrun'),
'cached'
);
}
return { stdout: '' };
}) as any);
try {
await writeInstalledWdaVersion(homeDirectory, '11.1.3');
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock({
runtimePlatform: BuildRuntimePlatform.DARWIN,
});
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com',
HOME: homeDirectory,
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner' },
});
await step.executeAsync();
expect(mockedDownloadFile).toHaveBeenCalledWith(
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.0-wda-11.1.3.tar.gz',
expect.stringMatching(/install_maestro_runner_wda_cache.*\/wda-cache\.tar\.gz$/),
{ retry: 3, timeout: 20_000 }
it.each(['1.1.27', '1.1.28', '1.2.3'])(
'prepares the WDA cache only for legacy Runner versions (%s)',
async version => {
const homeDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'install-maestro-test-')
);
for (const runtimeVersion of ['18.3', '26.0']) {
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
if (command === 'maestro-runner') {
return { stdout: `maestro-runner ${version}\n` };
}
if (command === 'xcrun') {
return {
stdout: JSON.stringify({
runtimes: [
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
isAvailable: true,
version: '18.3.1',
},
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-0',
isAvailable: true,
version: '26.0',
},
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-17-5',
isAvailable: false,
version: '17.5',
},
],
}),
};
}
if (command === 'xcodebuild') {
return { stdout: 'Xcode 26.0\nBuild version 17A324\n' };
}
if (command === 'tar') {
const maestroRunnerHome = args[args.indexOf('-C') + 1];
const productsDirectory = path.join(
maestroRunnerHome,
'cache',
'wda-builds',
'generic',
'DerivedData',
'Build',
'Products'
);
await fs.promises.mkdir(productsDirectory, { recursive: true });
await fs.promises.writeFile(
path.join(productsDirectory, 'WebDriverAgentRunner.xctestrun'),
'cached'
);
}
return { stdout: '' };
}) as any);
try {
await writeInstalledWdaVersion(homeDirectory, '11.1.3');
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock({
runtimePlatform: BuildRuntimePlatform.DARWIN,
});
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com',
HOME: homeDirectory,
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner' },
});
await step.executeAsync();
expect(step.getOutputValueByName('maestro_version')).toBe(version);
if (version !== '1.1.27') {
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(mockedSpawn.mock.calls.map(([command]) => command)).not.toContain('xcrun');
await expect(
fs.promises.access(path.join(homeDirectory, '.maestro-runner', 'cache'))
).rejects.toThrow();
return;
}
expect(mockedDownloadFile).toHaveBeenCalledWith(
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.0-wda-11.1.3.tar.gz',
expect.stringMatching(/install_maestro_runner_wda_cache.*\/wda-cache\.tar\.gz$/),
{ retry: 3, timeout: 20_000 }
);
for (const runtimeVersion of ['18.3', '26.0']) {
await expect(
fs.promises.readFile(
path.join(
homeDirectory,
'.maestro-runner',
'cache',
'wda-builds',
`sim-ios${runtimeVersion}-iphone`,
'DerivedData',
'Build',
'Products',
'WebDriverAgentRunner.xctestrun'
),
'utf8'
)
).resolves.toBe('cached');
}
await expect(
fs.promises.readFile(
path.join(
homeDirectory,
'.maestro-runner',
'cache',
'wda-builds',
`sim-ios${runtimeVersion}-iphone`,
'DerivedData',
'Build',
'Products',
'WebDriverAgentRunner.xctestrun'
),
'utf8'
fs.promises.access(
path.join(homeDirectory, '.maestro-runner', 'cache', 'wda-builds', 'sim-ios17.5-iphone')
)
).resolves.toBe('cached');
).rejects.toThrow();
} finally {
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
}
await expect(
fs.promises.access(
path.join(homeDirectory, '.maestro-runner', 'cache', 'wda-builds', 'sim-ios17.5-iphone')
)
).rejects.toThrow();
} finally {
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
}
});
);
it('does not download the WDA cache when the installed WDA version is unknown', async () => {
mockedSpawn.mockImplementation((async (command: string) => ({
stdout:
command === 'xcodebuild' ? 'Xcode 26.5\nBuild version 17F90\n' : 'maestro-runner 1.2.3\n',
command === 'xcodebuild' ? 'Xcode 26.5\nBuild version 17F90\n' : 'maestro-runner 1.1.27\n',
})) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock({
@@ -593,7 +606,7 @@ describe('createInstallMaestroBuildFunction', () => {
mockedSpawn.mockImplementation((async (command: string) => {
switch (command) {
case 'maestro-runner':
return { stdout: 'maestro-runner 1.2.3\n' };
return { stdout: 'maestro-runner 1.1.27\n' };
case 'xcodebuild':
return { stdout: 'Xcode 26.5\nBuild version 17F90\n' };
case 'xcrun':
@@ -633,7 +646,7 @@ describe('createInstallMaestroBuildFunction', () => {
});
await expect(step.executeAsync()).resolves.toBeUndefined();
expect(step.getOutputValueByName('maestro_version')).toBe('1.2.3');
expect(step.getOutputValueByName('maestro_version')).toBe('1.1.27');
expect(mockedDownloadFile.mock.calls.map(([url]) => url)).toEqual([
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.5-wda-11.1.3.tar.gz',
'https://storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.5-wda-11.1.3.tar.gz',
@@ -0,0 +1,151 @@
import { BuildRuntimePlatform, BuildStep } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { fs, vol } from 'memfs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createInstallMitmproxyBuildFunction } from '../installMitmproxy';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockedSpawn = jest.mocked(spawn);
// An x86 Homebrew prefix, to check that the app is found from PATH and not from process.arch.
const APP = '/usr/local/Caskroom/mitmproxy/12.2.3/mitmproxy.app';
const LINK = '/usr/local/bin/mitmdump';
function installCask(): void {
vol.fromJSON({ [`${APP}/Contents/MacOS/mitmdump`]: '', '/usr/local/bin': null });
fs.symlinkSync(`${APP}/Contents/MacOS/mitmdump`, LINK);
}
/**
* `which` finds mitmdump once the cask is installed, `brew` installs it, `xattr -p` reports
* quarantine while `stuck` is set, and each `mitmdump --version` takes the next result.
*/
function mockCommands({
installed = false,
stuck = false,
runs = [],
}: {
installed?: boolean;
stuck?: boolean;
runs?: boolean[];
}): void {
if (installed) {
installCask();
}
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
if (command === 'which' && !installed) {
throw new Error('not found');
}
if (command === 'which') {
return { stdout: `${LINK}\n` };
}
if (command === 'brew' && !installed) {
installed = true;
installCask();
}
if (command === 'xattr' && args[0] === '-p' && !stuck) {
throw new Error('No such xattr: com.apple.quarantine');
}
if (command === 'mitmdump' && !runs.shift()) {
throw new Error('not found');
}
return { stdout: '' };
}) as never);
}
function spawnedCommands(): string[] {
return mockedSpawn.mock.calls.map(([command, args]) =>
command === 'xattr' ? `xattr ${args?.[0]}` : command
);
}
describe('createInstallMitmproxyBuildFunction', () => {
beforeEach(() => {
jest.clearAllMocks();
});
function createStep(env: Record<string, string>): BuildStep {
const globalCtx = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.DARWIN });
globalCtx.updateEnv(env);
return createInstallMitmproxyBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: {},
});
}
it('takes the app behind mitmdump on PATH out of quarantine before running it', async () => {
mockCommands({ installed: true, runs: [true] });
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'xattr',
['-dr', 'com.apple.quarantine', APP],
expect.anything()
);
expect(spawnedCommands()).toEqual(['which', 'xattr -dr', 'xattr -p', 'mitmdump']);
});
it('kills a mitmdump --version that does not return', async () => {
mockCommands({ installed: true, runs: [true] });
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'mitmdump',
['--version'],
expect.objectContaining({ stdio: 'ignore', timeout: 60_000, killSignal: 'SIGKILL' })
);
});
it('does not run a mitmdump that stays quarantined', async () => {
mockCommands({ installed: true, stuck: true });
await expect(
createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync()
).resolves.toBeUndefined();
expect(spawnedCommands()).not.toContain('mitmdump');
});
it('does not change quarantine outside EAS Build VMs', async () => {
mockCommands({ installed: true, stuck: true, runs: [true] });
await createStep({}).executeAsync();
expect(spawnedCommands()).toEqual(['mitmdump']);
});
it('installs the cask with Homebrew and takes it out of quarantine', async () => {
mockCommands({ runs: [false, true] });
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'brew',
['install', '--cask', 'mitmproxy'],
expect.objectContaining({ env: expect.objectContaining({ HOMEBREW_NO_AUTO_UPDATE: '1' }) })
);
expect(spawnedCommands()).toEqual([
'which',
'mitmdump',
'brew',
'which',
'xattr -dr',
'xattr -p',
'mitmdump',
]);
});
it('does not fail the job when mitmdump is still not runnable', async () => {
mockCommands({ runs: [false, false] });
await expect(
createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync()
).resolves.toBeUndefined();
});
});
@@ -271,20 +271,124 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockedSpawn.mock.calls[0][1]).toContain('--parallel=2');
});
it('rejects a non-junit output_format with maestro-runner', async () => {
it.each(['html', 'allure'])(
'accepts %s output_format with maestro-runner and keeps JUnit',
async format => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const copyFileSpy = jest.spyOn(fs, 'copyFile').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
backend: 'maestro-runner',
output_format: format,
});
await step.executeAsync();
expect(mockedSpawn.mock.calls[0][1]).toEqual(
expect.arrayContaining([
'--output=/home/expo/.maestro/tests/android-maestro-runner-attempt-0',
])
);
expect(copyFileSpy).toHaveBeenCalledWith(
'/home/expo/.maestro/tests/android-maestro-runner-attempt-0/junit-report.xml',
'/home/expo/.maestro/tests/junit-reports/android-maestro-junit-attempt-0.xml'
);
expect(parser.mergeJUnitReports).not.toHaveBeenCalled();
expect(step.getOutputValueByName('final_report_path')).toBe(
format === 'html'
? '/home/expo/.maestro/tests/android-maestro-runner-attempt-0/report.html'
: '/home/expo/.maestro/tests/android-maestro-runner-attempt-0/allure-results'
);
expect(mockedRunnerHarvest).not.toHaveBeenCalled();
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name:
format === 'html' ? 'Maestro Runner HTML Report' : 'Maestro Runner Allure Results',
paths: [
format === 'html'
? '/home/expo/.maestro/tests/android-maestro-runner-attempt-0'
: '/home/expo/.maestro/tests/android-maestro-runner-attempt-0/allure-results',
],
}),
})
);
}
);
it('does not expose a selected runner report before an attempt starts', async () => {
const step = createStep({
flow_path: ['flows/a.yaml'],
flow_path: [],
platform: 'android',
backend: 'maestro-runner',
output_format: 'html',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(step.getOutputValueByName('final_report_path')).toBeUndefined();
expect(step.getOutputValueByName('junit_report_directory')).toBe(
'/home/expo/.maestro/tests/junit-reports'
);
});
it('rejects an unknown output_format with maestro-runner', async () => {
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
backend: 'maestro-runner',
output_format: 'csv',
});
await expect(step.executeAsync()).rejects.toThrow(
'maestro-runner only supports the "junit" output_format'
'maestro-runner supports "junit", "html", and "allure" output_format values'
);
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('uploads the last runner HTML report even when the test fails', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
jest.spyOn(fs, 'copyFile').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
backend: 'maestro-runner',
output_format: 'html',
retries: 1,
retry_failed_only: false,
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
name: 'Maestro Runner HTML Report',
paths: ['/home/expo/.maestro/tests/android-maestro-runner-attempt-1'],
}),
})
);
expect(step.getOutputValueByName('final_report_path')).toBe(
'/home/expo/.maestro/tests/android-maestro-runner-attempt-1/report.html'
);
});
it('does not fail a passing test when the runner report upload fails', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest.spyOn(fs, 'copyFile').mockResolvedValue();
mockUploadArtifact.mockRejectedValue(new Error('upload failed'));
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
backend: 'maestro-runner',
output_format: 'allure',
});
await expect(step.executeAsync()).resolves.toBeUndefined();
});
it('logs that maestro-runner does not support direct DADB', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest.spyOn(fs, 'copyFile').mockResolvedValue();
@@ -966,7 +1070,7 @@ describe('createMaestroTestsBuildFunction', () => {
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('uses $HOME/.maestro/tests output path for non-junit formats (e.g. html)', async () => {
it('uses $HOME/.maestro/tests output path and uploads the Maestro CLI HTML report', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
@@ -977,11 +1081,119 @@ describe('createMaestroTestsBuildFunction', () => {
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests so the
// whole-directory upload picks it up.
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests.
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(/\.maestro\/tests\/android-maestro-html\.html$/);
expect(outputArg).not.toMatch(/junit-reports/);
expect(step.getOutputValueByName('final_report_path')).toBe(
'/home/expo/.maestro/tests/android-maestro-html.html'
);
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name: 'Maestro HTML Report',
paths: ['/home/expo/.maestro/tests/android-maestro-html.html'],
}),
})
);
});
it('uploads the Maestro CLI HTML report even when tests fail', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'html',
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
name: 'Maestro HTML Report',
paths: ['/home/expo/.maestro/tests/android-maestro-html.html'],
}),
})
);
});
it('warns that Maestro CLI does not support Allure reports', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
const logger = createMockLogger();
jest.mocked(logger.child).mockReturnValue(logger);
const step = createStep(
{
flow_path: ['flows/a.yaml'],
output_format: 'allure',
platform: 'android',
},
{ logger }
);
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(logger.warn).toHaveBeenCalledWith(
'Maestro CLI does not support Allure reports; no Allure artifact was uploaded.'
);
expect(mockUploadArtifact).not.toHaveBeenCalled();
});
it('clears the Maestro CLI HTML report before every retry attempt', async () => {
const removeSpy = jest.spyOn(fs, 'rm').mockResolvedValue();
mockedSpawn.mockRejectedValue(rejectExit1());
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'html',
platform: 'android',
retries: 1,
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(
removeSpy.mock.calls.filter(
([target]) => target === '/home/expo/.maestro/tests/android-maestro-html.html'
)
).toHaveLength(2);
expect(removeSpy).toHaveBeenNthCalledWith(
1,
'/home/expo/.maestro/tests/android-maestro-html.html',
{
force: true,
}
);
expect(removeSpy).toHaveBeenNthCalledWith(
2,
'/home/expo/.maestro/tests/android-maestro-html.html',
{
force: true,
}
);
expect(removeSpy.mock.invocationCallOrder[0]).toBeLessThan(
mockedSpawn.mock.invocationCallOrder[0]
);
expect(removeSpy.mock.invocationCallOrder[1]).toBeLessThan(
mockedSpawn.mock.invocationCallOrder[1]
);
});
it('does not run Maestro CLI or upload old HTML if the report cannot be cleared', async () => {
jest.spyOn(fs, 'rm').mockImplementation(async target => {
if (target === '/home/expo/.maestro/tests/android-maestro-html.html') {
throw new Error('permission denied');
}
});
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'html',
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(SystemError);
expect(mockedSpawn).not.toHaveBeenCalled();
expect(mockUploadArtifact).not.toHaveBeenCalled();
});
it('uses lowercase extension for non-junit formats regardless of input casing', async () => {
@@ -1216,28 +1428,31 @@ describe('createMaestroTestsBuildFunction', () => {
);
});
it('discovers maestro-runner flow results from the attempt report directory', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const shot = makeShot(0);
jest.spyOn(fs, 'copyFile').mockResolvedValue();
mockedRunnerHarvest.mockResolvedValue([shot]);
const parseFlowResultsSpy = jest.spyOn(parser, 'parseMaestroRunnerReport').mockResolvedValue({
flows: [{ name: 'Login', sourceFile: 'a.yaml', status: 'failed' }],
});
it.each(['junit', undefined])(
'uploads runner screenshots with output_format=%s',
async format => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const shot = makeShot(0);
jest.spyOn(fs, 'copyFile').mockResolvedValue();
mockedRunnerHarvest.mockResolvedValue([shot]);
const parseFlowResultsSpy = jest.spyOn(parser, 'parseMaestroRunnerReport').mockResolvedValue({
flows: [{ name: 'Login', sourceFile: 'a.yaml', status: 'failed' }],
});
const step = createStep({
flow_path: ['a.yaml'],
platform: 'ios',
output_format: 'junit',
backend: 'maestro-runner',
});
await step.executeAsync();
const step = createStep({
flow_path: ['a.yaml'],
platform: 'ios',
...(format === undefined ? {} : { output_format: format }),
backend: 'maestro-runner',
});
await step.executeAsync();
expect(parseFlowResultsSpy).toHaveBeenCalledWith(
'/home/expo/.maestro/tests/ios-maestro-runner-attempt-0'
);
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
});
expect(parseFlowResultsSpy).toHaveBeenCalledWith(
'/home/expo/.maestro/tests/ios-maestro-runner-attempt-0'
);
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
}
);
it('uploads screenshots even when all attempts fail, before throwing ERR_MAESTRO_TESTS_FAILED', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
@@ -1301,14 +1516,15 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockUploadArtifact).toHaveBeenCalledTimes(30);
});
it('does not harvest or upload screenshots when output_format is not junit', async () => {
it('does not harvest or upload screenshots for Maestro CLI HTML', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'html' });
await step.executeAsync();
expect(mockedHarvest).not.toHaveBeenCalled();
expect(mockUploadArtifact).not.toHaveBeenCalled();
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
expect(mockUploadArtifact.mock.calls[0][0].artifact.name).toBe('Maestro HTML Report');
});
it('uploads only the final attempt for a pure-failure flow, but every attempt for a flaky flow', async () => {
@@ -7,6 +7,7 @@ import { type CustomBuildContext } from '../../../customBuildContext';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { isProcessDescendantOfAsync } from '../../../utils/processes';
import { turtleFetch } from '../../../utils/turtleFetch';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import {
monitorLocalEgressAsync,
readLocalEgressHandoffAsync,
@@ -14,11 +15,10 @@ import {
stopLocalEgressResourcesAsync,
} from '../../utils/localEgress';
import {
startDeviceWebPreviewWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
@@ -27,7 +27,14 @@ jest.mock('@expo/turtle-spawn');
jest.mock('../../../utils/IosSimulatorUtils');
jest.mock('../../../utils/processes');
jest.mock('../../../utils/turtleFetch');
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/agentDeviceArtifacts');
// The agent-device session step boots the Simulator itself; this test covers local egress.
jest.mock('../startIosSimulator', () => ({
bootIosSimulatorAsync: jest
.fn()
.mockResolvedValue({ deviceIdentifier: 'sim', udid: 'sim', displayName: 'iPhone' }),
}));
jest.mock('../../utils/argentArtifacts');
jest.mock('../../utils/agentDeviceEvents', () => ({
startAgentDeviceEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
@@ -58,12 +65,15 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
subdomainId: 'controller-id',
stopAsync: jest.fn(),
}),
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const logger = {
info: jest.fn(),
warn: jest.fn(),
child: jest.fn().mockReturnThis(),
} as unknown as bunyan;
const ctx = {} as CustomBuildContext;
const env = { DEVICE_RUN_SESSION_ID: 'session-id' };
const handoff = { url: 'https://egress.test', token: 'secret', fingerprint: 'key=', port: 8899 };
@@ -74,7 +84,7 @@ const fields = {
egressPort: handoff.port,
};
const controllers = [
['agent-device', createStartAgentDeviceRemoteSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
['agent-device', createStartAgentDeviceSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
['Appium', createStartAppiumRemoteSessionBuildFunction, 'appiumUrl'],
['Argent', createStartArgentRemoteSessionBuildFunction, 'toolsUrl'],
['web preview', createStartWebPreviewRemoteSessionBuildFunction, 'previewUrl'],
@@ -82,16 +92,17 @@ const controllers = [
describe.each(controllers)('%s local egress', (_name, createFunction, controllerField) => {
const stopPreview = jest.fn();
const openPreview = jest.fn();
const stopEgress = jest.fn();
let lifetime: AbortSignal;
async function run(signal?: AbortSignal): Promise<void> {
await createFunction(ctx).fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
// Inputs a step does not get default to undefined, like in a real step call.
inputs: new Proxy({} as Record<string, { value: unknown }>, {
get: (target, id: string) => target[id] ?? { value: undefined },
}),
outputs: {},
env,
signal,
@@ -119,11 +130,15 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(handoff);
jest.mocked(monitorLocalEgressAsync).mockResolvedValue(undefined);
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
openPreview.mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://preview.test',
previewToken: 'preview-secret',
stopAsync: stopPreview,
closeAsync: jest.fn(),
});
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: openPreview,
finishAsync: stopPreview,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
@@ -169,12 +184,14 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
expect(stopEgress).not.toHaveBeenCalled();
});
it.each(['startup', 'report', 'wait', 'teardown'])(
it.each(['startup', 'preview', 'report', 'wait', 'teardown'])(
'releases egress after %s fails',
async phase => {
const error = new Error(`${phase} failed`);
if (phase === 'startup') {
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockRejectedValue(error);
jest.mocked(startDeviceSessionHostAsync).mockRejectedValue(error);
} else if (phase === 'preview') {
openPreview.mockRejectedValue(error);
} else if (phase === 'report') {
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValue(error);
} else if (phase === 'wait') {
@@ -186,7 +203,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
expect(lifetime.aborted).toBe(true);
expect(stopEgress).toHaveBeenCalledTimes(1);
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(
phase === 'startup' || phase === 'report' ? 0 : 1
phase === 'startup' || phase === 'preview' || phase === 'report' ? 0 : 1
);
}
);
@@ -0,0 +1,70 @@
import { type bunyan } from '@expo/logger';
import { type BuildStepContext, type BuildStepEnv } from '@expo/steps';
import { type CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { getDeviceRunSessionIdOrThrow } from '../../utils/remoteDeviceRunSession';
import { uploadServeSimLogsFileAsync } from '../../utils/serveSimLogsArtifacts';
import { ServeSimLogsRecorder } from '../../utils/serveSimLogsRecorder';
import { createCollectServeSimLogsBuildFunction } from '../collectServeSimLogs';
import { createStartServeSimLogsBuildFunction } from '../startServeSimLogs';
jest.mock('../../../sentry');
jest.mock('../../utils/serveSimLogsRecorder');
jest.mock('../../utils/serveSimLogsArtifacts');
jest.mock('../../utils/remoteDeviceRunSession');
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const ctx = {} as CustomBuildContext;
const step = { logger } as BuildStepContext;
const args = { inputs: {}, outputs: {}, env: {} as BuildStepEnv };
beforeEach(() => {
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('session-id');
jest.mocked(ServeSimLogsRecorder.finishAsync).mockResolvedValue([]);
});
it('starts collection and tolerates startup failures', async () => {
jest.mocked(ServeSimLogsRecorder.startAsync).mockRejectedValueOnce(new Error('disk failure'));
await expect(createStartServeSimLogsBuildFunction().fn?.(step, args)).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not start serve-sim simulator logs',
expect.any(Error)
);
});
it('uploads each device with its own bounded signal', async () => {
jest.mocked(ServeSimLogsRecorder.finishAsync).mockResolvedValue([
{ udid: 'A', filePath: '/tmp/A.ndjson' },
{ udid: 'B', filePath: '/tmp/B.ndjson' },
]);
await createCollectServeSimLogsBuildFunction(ctx).fn?.(step, args);
expect(uploadServeSimLogsFileAsync).toHaveBeenCalledTimes(2);
expect(uploadServeSimLogsFileAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
deviceRunSessionId: 'session-id',
udid: 'A',
filePath: '/tmp/A.ndjson',
signal: expect.any(AbortSignal),
})
);
expect(jest.mocked(uploadServeSimLogsFileAsync).mock.calls[0][1].signal).not.toBe(
jest.mocked(uploadServeSimLogsFileAsync).mock.calls[1][1].signal
);
});
it('skips empty collection and warns on finalization failure', async () => {
await createCollectServeSimLogsBuildFunction(ctx).fn?.(step, args);
expect(uploadServeSimLogsFileAsync).not.toHaveBeenCalled();
jest.mocked(ServeSimLogsRecorder.finishAsync).mockRejectedValueOnce(new Error('failure'));
await expect(
createCollectServeSimLogsBuildFunction(ctx).fn?.(step, args)
).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not finalize serve-sim simulator logs',
expect.any(Error)
);
});
@@ -1,26 +1,26 @@
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import { BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import {
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
import { type StartupTasks, createStartupTasks } from '../../utils/startupTasks';
import { runAgentDeviceRemoteSessionAsync } from '../startAgentDeviceRemoteSession';
// The daemon entry path and the state directory are resolved from the home directory when
// the module loads, so point it at a temp home we can populate.
@@ -40,6 +40,7 @@ jest.mock('../../utils/agentDeviceArtifacts', () => ({
jest.mock('../../utils/agentDeviceEvents', () => ({
startAgentDeviceEventCollectionAsync: jest.fn(),
}));
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
getDeviceRunSessionIdOrThrow: jest.fn(),
@@ -47,7 +48,6 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
getNgrokTunnelDomainOrThrow: jest.fn(),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
spawnDetached: jest.fn(),
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
startNgrokTunnelAsync: jest.fn(),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
@@ -66,28 +66,34 @@ const mockTunnelStopAsync = jest.fn();
const mockDaemonStopAsync = jest.fn();
const mockEventCollectionStopAsync = jest.fn();
type CaptureInputs = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['capture'];
const NO_CAPTURE: CaptureInputs = { networkCapture: false, networkCaptureFields: [] };
/** Runs the session for a device that is already booted, with the app (if any) launched. */
async function runAsync(
logger: { info: jest.Mock; warn: jest.Mock },
logger: { info: jest.Mock; warn: jest.Mock; child: jest.Mock },
runtimePlatform: BuildRuntimePlatform,
launchInputs: Record<string, { value: unknown }> = {}
capture: CaptureInputs = NO_CAPTURE
): Promise<void> {
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
await buildFunction.fn!(
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
max_duration_seconds: { value: undefined },
...launchInputs,
},
outputs: {},
env: {},
} as never
);
await runAgentDeviceRemoteSessionAsync(ctx, {
env: {},
logger: logger as never,
runtimePlatform,
sessionEnv: {
deviceRunSessionId: 'device-run-session-id',
ngrokTunnelDomain: 'tunnel.example.com',
ngrokAuthtoken: 'ngrok-token',
},
packageVersion: undefined,
maxIdleTimeMinutes: undefined,
maxDurationSeconds: undefined,
capture,
tasks: createStartupTasks(logger as never),
device: { booted: Promise.resolve(), ready: Promise.resolve() },
});
}
describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () => {
describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
beforeEach(async () => {
jest.clearAllMocks();
@@ -104,6 +110,7 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
jest.mocked(spawnDetached).mockReturnValue({
pid: 4242,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: mockDaemonStopAsync,
});
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 5678, token: 'daemon-token' });
@@ -112,10 +119,13 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
subdomainId: 'agent-device-abc',
stopAsync: mockTunnelStopAsync,
});
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.tunnel.example.com',
stopAsync: mockPreviewStopAsync,
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.tunnel.example.com',
closeAsync: jest.fn(),
}),
finishAsync: mockPreviewStopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
@@ -129,12 +139,11 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
});
it('reports the preview URL and tears every resource down', async () => {
const logger = { info: jest.fn(), warn: jest.fn() };
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
await runAsync(logger, BuildRuntimePlatform.LINUX);
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
);
@@ -154,47 +163,212 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
});
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
const logger = { info: jest.fn(), warn: jest.fn() };
it('hands network capture to serve-sim', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
launch_app_identifier: { value: 'host.exp.Exponent' },
launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] },
open_url: { value: 'exp://127.0.0.1:8081' },
networkCapture: true,
networkCaptureFields: ['header'],
});
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ networkCapture: true, networkCaptureFields: ['header'] })
);
});
it('starts the session host without waiting for the agent-device daemon', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const sessionHost = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
ctx,
{} as never
);
let markHostStarted!: () => void;
const hostStarted = new Promise<void>(resolve => {
markHostStarted = resolve;
});
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async () => {
markHostStarted();
return sessionHost;
});
// The daemon credentials only appear after the session host started. A sequential
// startup would wait here forever.
jest.mocked(waitForFileAsync).mockImplementation(async () => {
await hostStarted;
return { port: 5678, token: 'daemon-token' };
});
await runAsync(logger, BuildRuntimePlatform.DARWIN);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
agentDeviceRemoteSessionUrl: 'https://agent-device-abc.tunnel.example.com',
agentDeviceRemoteSessionToken: 'daemon-token',
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
}),
})
);
});
it('stops the daemon and its tunnel when the session host fails to start', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
jest
.mocked(startDeviceSessionHostAsync)
.mockRejectedValue(new Error('serve-sim did not start'));
await expect(runAsync(logger, BuildRuntimePlatform.DARWIN)).rejects.toThrow(
'serve-sim did not start'
);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
// The failure aborts the daemon task, which then may not start its daemon or tunnel.
// Whatever started is stopped.
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(jest.mocked(spawnDetached).mock.calls.length);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(
jest.mocked(startNgrokTunnelAsync).mock.calls.length
);
});
it('stops the session host and the daemon when the daemon credentials never appear', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
jest.mocked(waitForFileAsync).mockRejectedValue(new Error('no daemon credentials'));
await expect(runAsync(logger, BuildRuntimePlatform.DARWIN)).rejects.toThrow(
'no daemon credentials'
);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
});
describe('runAgentDeviceRemoteSessionAsync with a device that is still starting', () => {
function deferred(): {
promise: Promise<void>;
resolve: () => void;
reject: (e: Error) => void;
} {
let resolve!: () => void;
let reject!: (e: Error) => void;
const promise = new Promise<void>((res, rej) => {
resolve = res;
reject = rej;
});
promise.catch(() => {});
return { promise, resolve, reject };
}
type Device = { booted: Promise<unknown>; ready: Promise<unknown> };
function startSession(device: Device | ((tasks: StartupTasks) => Device)) {
const logger = {
info: jest.fn(),
warn: jest.fn(),
child: jest.fn().mockReturnThis(),
} as never;
const tasks = createStartupTasks(logger);
return runAgentDeviceRemoteSessionAsync(ctx, {
env: {},
logger,
runtimePlatform: BuildRuntimePlatform.DARWIN,
launchAppIdentifier: 'host.exp.Exponent',
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
openUrl: 'exp://127.0.0.1:8081',
})
);
expect(logger.info).toHaveBeenCalledWith(
'serve-sim will launch host.exp.Exponent with arguments ' +
'["-EXDevMenuIsOnboardingFinished","1"], then open exp://127.0.0.1:8081.'
);
});
sessionEnv: {
deviceRunSessionId: 'device-run-session-id',
ngrokTunnelDomain: 'tunnel.example.com',
ngrokAuthtoken: 'ngrok-token',
},
packageVersion: undefined,
maxIdleTimeMinutes: undefined,
maxDurationSeconds: undefined,
capture: NO_CAPTURE,
tasks,
device: typeof device === 'function' ? device(tasks) : device,
});
}
it('fails before starting the daemon when a launch is asked for on Android', async () => {
const logger = { info: jest.fn(), warn: jest.fn() };
async function flushAsync(): Promise<void> {
for (let i = 0; i < 20; i++) {
await new Promise(resolve => setImmediate(resolve));
}
}
await expect(
runAsync(logger, BuildRuntimePlatform.LINUX, {
launch_app_identifier: { value: 'host.exp.Exponent' },
})
).rejects.toThrow('runs on linux');
expect(spawnDetached).not.toHaveBeenCalled();
});
it('starts the daemon during the boot and the session host after it', async () => {
const booted = deferred();
const session = startSession({ booted: booted.promise, ready: booted.promise });
await flushAsync();
it('declares the launch inputs', () => {
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
const globalCtx = createGlobalContextMock();
expect(spawnDetached).toHaveBeenCalledTimes(1);
expect(startNgrokTunnelAsync).toHaveBeenCalledTimes(1);
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
expect(
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
booted.resolve();
await session;
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
});
it('reports the session as ready only after the app is launched', async () => {
const ready = deferred();
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
await flushAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
ready.resolve();
await session;
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
});
it('stops waiting for the boot when the daemon fails', async () => {
jest.mocked(waitForFileAsync).mockRejectedValue(new Error('no daemon credentials'));
const neverBooted = new Promise<void>(() => {});
// In the combined step, `ready` stops waiting for the boot on an abort, like this.
await expect(
startSession(tasks => ({ booted: neverBooted, ready: tasks.untilAborted(neverBooted) }))
).rejects.toThrow('no daemon credentials');
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('stops the session host when the app fails while the daemon install hangs', async () => {
jest.mocked(spawn).mockImplementation(((
command: string,
args: string[],
options?: { signal?: AbortSignal }
) =>
command === 'bun' && args[0] === 'add'
? new Promise((_resolve, reject) => {
options!.signal!.addEventListener('abort', () => reject(options!.signal!.reason));
})
: Promise.resolve(undefined)) as never);
const ready = deferred();
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
await flushAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
ready.reject(new Error('simctl install failed'));
await expect(session).rejects.toThrow('simctl install failed');
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(spawnDetached).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('stops everything it started when the app install fails', async () => {
const ready = deferred();
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
await flushAsync();
ready.reject(new Error('simctl install failed'));
await expect(session).rejects.toThrow('simctl install failed');
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
});
});
@@ -75,6 +75,7 @@ describe(startAgentDeviceDaemonAsync, () => {
jest.mocked(spawnDetached).mockReturnValue({
pid: 1234,
getOutput: () => '',
getExitError: () => undefined,
stopAsync,
});
jest.mocked(spawn).mockImplementation((async (
@@ -108,7 +109,12 @@ describe(startAgentDeviceDaemonAsync, () => {
expect(spawnDetached).toHaveBeenCalledWith({
command: 'node',
args: [path.join(addCwd, 'node_modules/agent-device/dist/src/internal/daemon.js')],
env: expect.objectContaining({ AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' }),
env: expect.objectContaining({
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0',
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS: '0',
AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS: '0',
}),
});
await handle.stopAsync();
@@ -129,6 +135,36 @@ describe(startAgentDeviceDaemonAsync, () => {
);
});
it('kills the install and does not fall back to git when aborted', async () => {
jest.mocked(spawn).mockImplementation(
((_command: string, _args: string[], options?: { signal?: AbortSignal }) =>
new Promise((_resolve, reject) => {
options!.signal!.addEventListener('abort', () => reject(options!.signal!.reason));
})) as never
);
const controller = new AbortController();
const failure = new Error('app install failed');
const daemon = startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
env: {},
logger,
signal: controller.signal,
});
await new Promise(resolve => setImmediate(resolve));
controller.abort(failure);
await expect(daemon).rejects.toBe(failure);
expect(spawn).toHaveBeenCalledTimes(1);
expect(spawn).toHaveBeenCalledWith(
'bun',
['add', '[email protected]'],
expect.objectContaining({ signal: controller.signal })
);
expect(spawnDetached).not.toHaveBeenCalled();
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('falls back to git clone when the published daemon is missing', async () => {
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => {
if (command === 'bun' && args[0] === '--version') {
@@ -165,7 +201,12 @@ describe(startAgentDeviceDaemonAsync, () => {
command: 'bun',
args: ['run', 'src/daemon.ts'],
cwd: '/tmp/agent-device-src',
env: expect.objectContaining({ AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' }),
env: expect.objectContaining({
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0',
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS: '0',
AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS: '0',
}),
});
expect(Sentry.capture).toHaveBeenCalledWith(
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
@@ -0,0 +1,400 @@
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
import { downloadBuildAsync } from '../downloadBuild';
import { installBuildAsync } from '../installBuild';
import { launchApplicationAsync } from '../launchApplication';
import {
getAgentDeviceRemoteSessionEnvOrThrow,
runAgentDeviceRemoteSessionAsync,
} from '../startAgentDeviceRemoteSession';
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
import { startAndroidEmulatorAsync } from '../startAndroidEmulator';
import { bootIosSimulatorAsync } from '../startIosSimulator';
jest.mock('../../utils/localEgressSession', () => ({
withLocalEgressSession: (fn: unknown) => fn,
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
}));
jest.mock('../startIosSimulator', () => ({ bootIosSimulatorAsync: jest.fn() }));
jest.mock('../startAndroidEmulator', () => ({ startAndroidEmulatorAsync: jest.fn() }));
jest.mock('../downloadBuild', () => ({ downloadBuildAsync: jest.fn() }));
jest.mock('../installBuild', () => ({ installBuildAsync: jest.fn() }));
jest.mock('../launchApplication', () => ({
...jest.requireActual('../launchApplication'),
launchApplicationAsync: jest.fn(),
}));
jest.mock('../startAgentDeviceRemoteSession', () => ({
getAgentDeviceRemoteSessionEnvOrThrow: jest.fn(),
runAgentDeviceRemoteSessionAsync: jest.fn(),
}));
const graphqlClient = { query: jest.fn() };
const ctx = { graphqlClient } as unknown as CustomBuildContext;
const sessionEnv = {
deviceRunSessionId: 'device-run-session-id',
ngrokTunnelDomain: 'tunnel.example.com',
ngrokAuthtoken: 'ngrok-token',
};
type Device = { booted: Promise<unknown>; ready: Promise<unknown> };
function deferred<T = void>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (err: Error) => void;
} {
let resolve!: (value: T) => void;
let reject!: (err: Error) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
promise.catch(() => {});
return { promise, resolve, reject };
}
async function flushAsync(): Promise<void> {
for (let i = 0; i < 20; i++) {
await new Promise(resolve => setImmediate(resolve));
}
}
function runStep(
runtimePlatform: BuildRuntimePlatform,
inputValues: Record<string, unknown> = {}
): Promise<void> {
const buildFunction = createStartAgentDeviceSessionBuildFunction(ctx);
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const inputs = Object.fromEntries(
[
'device_identifier',
'system_image_package',
'lcd_width',
'lcd_height',
'lcd_density',
'build_id',
'application_archive_url',
'launch_args',
'open_url',
'network_capture',
'network_capture_fields',
'package_version',
'max_idle_time_minutes',
'max_duration_seconds',
].map(id => [id, { value: inputValues[id] }])
);
return buildFunction.fn!(
{
logger,
global: {
runtimePlatform,
staticContext: { job: { secrets: { robotAccessToken: 'robot-token' } } },
},
} as unknown as BuildStepContext,
{ inputs, outputs: {}, env: {}, signal: undefined } as never
) as Promise<void>;
}
/** The `device` promises handed to the shared agent-device session code. */
function sessionDevice(): Device {
return jest.mocked(runAgentDeviceRemoteSessionAsync).mock.calls[0][1].device;
}
describe(createStartAgentDeviceSessionBuildFunction, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(getAgentDeviceRemoteSessionEnvOrThrow).mockReturnValue(sessionEnv);
jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async (_ctx, { device }) => {
await device.ready;
});
jest.mocked(bootIosSimulatorAsync).mockResolvedValue({
deviceIdentifier: 'iPhone 17' as never,
udid: 'udid' as never,
displayName: 'iPhone 17',
});
jest.mocked(startAndroidEmulatorAsync).mockResolvedValue({
serialId: 'emulator-5554' as never,
emulatorPromise: Promise.resolve(),
shouldAdjustAnimationScale: true,
});
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/App.app' });
jest
.mocked(installBuildAsync)
.mockResolvedValue({ applicationIdentifier: 'dev.example.app', activityName: '.Main' });
jest.mocked(launchApplicationAsync).mockResolvedValue(undefined);
});
it('downloads the build and starts the session while the iOS Simulator boots', async () => {
const boot = deferred<Awaited<ReturnType<typeof bootIosSimulatorAsync>>>();
jest.mocked(bootIosSimulatorAsync).mockReturnValue(boot.promise);
const step = runStep(BuildRuntimePlatform.DARWIN, {
build_id: 'build-id',
launch_args: ['-flag'],
open_url: 'exp://example.test',
});
await flushAsync();
expect(
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
).toBeLessThan(jest.mocked(bootIosSimulatorAsync).mock.invocationCallOrder[0]);
expect(downloadBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({
buildId: 'build-id',
extensions: ['app'],
graphqlClient,
robotAccessToken: 'robot-token',
})
);
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(1);
// The install needs the booted Simulator.
expect(installBuildAsync).not.toHaveBeenCalled();
boot.resolve({
deviceIdentifier: 'iPhone 17' as never,
udid: 'udid' as never,
displayName: '',
});
await step;
expect(installBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({ artifactPath: '/tmp/App.app' })
);
expect(launchApplicationAsync).toHaveBeenCalledWith(
expect.objectContaining({
applicationIdentifier: 'dev.example.app',
launchArgs: ['-flag'],
openUrl: 'exp://example.test',
})
);
expect(jest.mocked(installBuildAsync).mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(launchApplicationAsync).mock.invocationCallOrder[0]
);
});
it('waits for the download when the Simulator boots first', async () => {
const download = deferred<{ artifactPath: string }>();
jest.mocked(downloadBuildAsync).mockReturnValue(download.promise);
const step = runStep(BuildRuntimePlatform.DARWIN, {
application_archive_url: 'https://example.test/app.tar.gz',
});
await flushAsync();
expect(installBuildAsync).not.toHaveBeenCalled();
download.resolve({ artifactPath: '/tmp/App.app' });
await step;
expect(installBuildAsync).toHaveBeenCalledTimes(1);
});
it('makes the session ready when the device boots if there is no app', async () => {
await runStep(BuildRuntimePlatform.DARWIN);
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
await expect(sessionDevice().ready).resolves.toBeUndefined();
});
it('boots the Android Emulator with the device inputs', async () => {
await runStep(BuildRuntimePlatform.LINUX, {
device_identifier: 'pixel_7',
system_image_package: 'system-images;android-35-ext15;google_apis_playstore;x86_64',
lcd_width: 720,
lcd_height: 1600,
lcd_density: 300,
build_id: 'build-id',
});
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({
deviceIdentifier: 'pixel_7',
systemImagePackage: 'system-images;android-35-ext15;google_apis_playstore;x86_64',
lcdWidth: 720,
lcdHeight: 1600,
lcdDensity: 300,
})
);
expect(downloadBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({ extensions: ['apk'] })
);
expect(launchApplicationAsync).toHaveBeenCalledWith(
expect.objectContaining({ applicationIdentifier: 'dev.example.app', activityName: '.Main' })
);
});
it('fails the session when the download fails, without an unhandled rejection', async () => {
const boot = deferred<Awaited<ReturnType<typeof bootIosSimulatorAsync>>>();
jest.mocked(bootIosSimulatorAsync).mockReturnValue(boot.promise);
jest.mocked(downloadBuildAsync).mockRejectedValue(new Error('download failed'));
const stepFailure = expect(
runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })
).rejects.toThrow('download failed');
// The download fails while the boot still runs. Nothing inside the step may leave
// that rejection unhandled; Jest fails the test if it does.
await flushAsync();
boot.resolve({
deviceIdentifier: 'iPhone 17' as never,
udid: 'udid' as never,
displayName: '',
});
await stepFailure;
expect(installBuildAsync).not.toHaveBeenCalled();
});
/** A download that runs until its abort signal fires, like a stalled one. */
function mockStalledDownload(): { aborted: () => boolean; settled: () => boolean } {
let aborted = false;
let settled = false;
jest.mocked(downloadBuildAsync).mockImplementation(
({ signal }) =>
new Promise((_resolve, reject) => {
signal!.addEventListener('abort', () => {
aborted = true;
// Settles a bit later, like a request that winds down.
setImmediate(() => {
settled = true;
reject(new Error('The user aborted a request.'));
});
});
})
);
return { aborted: () => aborted, settled: () => settled };
}
/** Runs the session like the real one: fails with the daemon, then waits for every part. */
function mockSessionWithFailingDaemon(beforeFailure?: () => Promise<void>): void {
jest
.mocked(runAgentDeviceRemoteSessionAsync)
.mockImplementation(async (_ctx, { tasks, device }) => {
await beforeFailure?.();
const daemon = tasks.run('agent-device daemon', async () => {
throw new Error('daemon failed');
});
try {
await Promise.all([daemon, device.ready]);
} finally {
await Promise.allSettled([daemon, device.ready]);
}
});
}
it('stops the download and waits for it when the boot fails', async () => {
const download = mockStalledDownload();
jest.mocked(bootIosSimulatorAsync).mockRejectedValue(new Error('boot failed'));
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'boot failed'
);
expect(download.aborted()).toBe(true);
// The step returned only after the download stopped.
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
});
it('stops a stalled download and does not install when the daemon fails', async () => {
const download = mockStalledDownload();
mockSessionWithFailingDaemon();
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'daemon failed'
);
expect(download.aborted()).toBe(true);
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
});
it('does not launch the app when startup fails during the install', async () => {
const install = deferred<Awaited<ReturnType<typeof installBuildAsync>>>();
const installStarted = deferred();
jest.mocked(installBuildAsync).mockImplementation(() => {
installStarted.resolve();
return install.promise;
});
mockSessionWithFailingDaemon(async () => {
await installStarted.promise;
// The install finishes only after the daemon failed.
setImmediate(() => install.resolve({ applicationIdentifier: 'dev.example.app' }));
});
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'daemon failed'
);
expect(installBuildAsync).toHaveBeenCalledTimes(1);
expect(launchApplicationAsync).not.toHaveBeenCalled();
});
it('rejects conflicting application inputs before it boots anything', async () => {
await expect(
runStep(BuildRuntimePlatform.DARWIN, {
build_id: 'build-id',
application_archive_url: 'https://example.test/app.tar.gz',
})
).rejects.toThrow('Pass only one of build_id or application_archive_url.');
await expect(
runStep(BuildRuntimePlatform.DARWIN, { open_url: 'exp://example.test' })
).rejects.toThrow('launch_args and open_url need an application');
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('declares the step inputs', () => {
const buildFunction = createStartAgentDeviceSessionBuildFunction(ctx);
const globalCtx = createGlobalContextMock();
expect(
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual([
'device_identifier',
'system_image_package',
'lcd_width',
'lcd_height',
'lcd_density',
'build_id',
'application_archive_url',
'launch_args',
'open_url',
'network_capture',
'network_capture_fields',
'package_version',
'max_idle_time_minutes',
'max_duration_seconds',
]);
});
it('passes network capture to the session', async () => {
await runStep(BuildRuntimePlatform.DARWIN, {
network_capture: true,
network_capture_fields: ['header', 'response-body'],
});
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
capture: { networkCapture: true, networkCaptureFields: ['header', 'response-body'] },
})
);
});
it('rejects network capture on Android before it boots anything', async () => {
await expect(
runStep(BuildRuntimePlatform.LINUX, { network_capture: true, system_image_package: 'x' })
).rejects.toThrow('records traffic through serve-sim on an iOS simulator');
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
});
@@ -8,6 +8,7 @@ import {
AndroidVirtualDeviceName,
} from '../../../utils/AndroidEmulatorUtils';
import { retryAsync } from '../../../utils/retry';
import { resolveAndroidEmulatorLocalEgressAsync } from '../../utils/androidLocalEgress';
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
jest.mock('@expo/turtle-spawn', () => ({
@@ -19,6 +20,10 @@ jest.mock('../../../utils/retry', () => ({
retryAsync: jest.fn(),
}));
jest.mock('../../utils/androidLocalEgress', () => ({
resolveAndroidEmulatorLocalEgressAsync: jest.fn(),
}));
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
AndroidEmulatorUtils: {
defaultSystemImagePackage: 'system-images;android-30;default;x86_64',
@@ -35,6 +40,7 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
const mockedResolveLocalEgress = jest.mocked(resolveAndroidEmulatorLocalEgressAsync);
const mockedMkdtemp = jest.spyOn(fs.promises, 'mkdtemp');
function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.ProcessEnv) {
const logger = createMockLogger();
@@ -65,6 +71,7 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined);
mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync.mockResolvedValue(undefined);
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined);
mockedResolveLocalEgress.mockResolvedValue(null);
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
let lastErr: unknown;
@@ -83,6 +90,82 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
mockedMkdtemp.mockRestore();
});
describe('local egress', () => {
function createLocalEgress() {
return {
launchGate: {
emulatorArgs: ['-http-proxy', '127.0.0.1:8898', '-no-metrics'],
wrapperCommand: 'bash',
wrapperArgs: ['-c', 'gate', 'eas-egress-gate'],
admitAsync: jest.fn(),
},
networkReadyTarget: { host: '192.0.2.1', port: 443 },
configureBootedEmulatorAsync: jest.fn().mockResolvedValue(undefined),
};
}
it('starts the emulator exactly as before without a local egress session', async () => {
await createStep().executeAsync();
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith({
deviceName: 'EasAndroidDevice01',
env: expect.any(Object),
logcatDirectory: '/tmp/logcat-directory',
});
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith({
env: expect.any(Object),
serialId: 'emulator-default',
timeoutMs: 60_000,
logger: expect.anything(),
});
expect(
mockedSpawn.mock.calls.filter(
([command, args]) =>
['sudo', 'nft', 'systemd-run'].includes(command) ||
(command === 'adb' && (args.includes('reverse') || args.includes('http_proxy')))
)
).toEqual([]);
});
it('fences, gates and configures the emulator', async () => {
const localEgress = createLocalEgress();
mockedResolveLocalEgress.mockResolvedValue(localEgress);
mockedAndroidUtils.startAsync.mockResolvedValueOnce(createStartResult('emulator-base'));
await createStep().executeAsync();
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
expect.objectContaining({ launchGate: localEgress.launchGate })
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith(
expect.objectContaining({ networkReadyTarget: { host: '192.0.2.1', port: 443 } })
);
expect(localEgress.configureBootedEmulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({ serialId: 'emulator-base' })
);
});
it('retries a boot whose local egress configuration fails', async () => {
const localEgress = createLocalEgress();
localEgress.configureBootedEmulatorAsync
.mockRejectedValueOnce(new Error('netsimd outside the fence'))
.mockResolvedValueOnce(undefined);
mockedResolveLocalEgress.mockResolvedValue(localEgress);
mockedAndroidUtils.startAsync
.mockResolvedValueOnce(createStartResult('emulator-1111'))
.mockResolvedValueOnce(createStartResult('emulator-2222'));
await createStep().executeAsync();
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({ serialId: 'emulator-1111' })
);
expect(localEgress.configureBootedEmulatorAsync).toHaveBeenLastCalledWith(
expect.objectContaining({ serialId: 'emulator-2222' })
);
});
});
it('passes profile and LCD inputs to emulator creation', async () => {
const systemImagePackage = 'system-images;android-35;default;x86_64';
await createStep({
@@ -6,6 +6,7 @@ import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import { isProcessDescendantOfAsync } from '../../../utils/processes';
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
import { startArgentEventCollectionAsync } from '../../utils/argentEvents';
@@ -16,7 +17,6 @@ import {
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
@@ -42,15 +42,17 @@ jest.mock('../../utils/argentEvents', () => ({
...jest.requireActual('../../utils/argentEvents'),
startArgentEventCollectionAsync: jest.fn(),
}));
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
ensureFfmpegInstalledOnceAsync: jest.fn(),
finishRemoteSessionAsync: jest.requireActual('../../utils/remoteDeviceRunSession')
.finishRemoteSessionAsync,
getDeviceRunSessionIdOrThrow: jest.fn(),
getNgrokAuthtokenOrThrow: jest.fn(),
getNgrokTunnelDomainOrThrow: jest.fn(),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
spawnDetached: jest.fn(),
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
startNgrokTunnelAsync: jest.fn(),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
@@ -86,6 +88,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
jest.mocked(spawnDetached).mockReturnValue({
pid: 4242,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: jest.fn(),
});
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
@@ -93,10 +96,13 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
subdomainId: 'argent-abc',
stopAsync: mockTunnelStopAsync,
});
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.tunnel.example.com',
stopAsync: mockPreviewStopAsync,
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.tunnel.example.com',
closeAsync: jest.fn(),
}),
finishAsync: mockPreviewStopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
@@ -113,6 +119,75 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
await fs.promises.rm(TEST_HOME, { recursive: true, force: true });
});
it('reports an early exit with output before opening tunnels or publishing readiness', async () => {
jest.mocked(spawnDetached).mockReturnValue({
pid: 4242,
getOutput: () => 'could not bind server port',
getExitError: () => new Error('process exited with code 1'),
stopAsync: jest.fn(),
});
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
await expect(
buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
)
).rejects.toThrow(
'Argent exited before becoming ready: process exited with code 1\nArgent tool-server output:\ncould not bind server port'
);
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
});
it.each(['preview', 'config', 'wait'])(
'finishes the host and tools after %s fails',
async phase => {
const error = new Error(`${phase} failed`);
if (phase === 'preview') {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: jest.fn().mockRejectedValue(error),
finishAsync: mockPreviewStopAsync,
});
} else if (phase === 'config') {
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
} else {
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
}
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
await expect(
buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
)
).rejects.toBe(error);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(mockStopAsync).toHaveBeenCalledTimes(1);
expect(jest.mocked(spawnDetached).mock.results[0].value.stopAsync).toHaveBeenCalledTimes(1);
}
);
it('enables the event log flag, shares one path, and starts/stops the collector', async () => {
const ctx = {} as unknown as CustomBuildContext;
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
@@ -177,7 +252,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
);
@@ -216,7 +291,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
} as never
);
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
runtimePlatform: BuildRuntimePlatform.DARWIN,
@@ -266,4 +341,49 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
});
it('stops automation without waiting for recording upload', async () => {
let release!: () => void;
let stopped!: () => void;
const pendingFinish = new Promise<void>(resolve => {
release = resolve;
});
const toolStopped = new Promise<void>(resolve => {
stopped = resolve;
});
mockPreviewStopAsync.mockReturnValueOnce(pendingFinish);
const stopServer = jest.fn(async () => {
stopped();
});
jest.mocked(spawnDetached).mockReturnValueOnce({
pid: 4242,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: stopServer,
});
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
const running = buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
);
try {
await toolStopped;
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(stopServer).toHaveBeenCalledTimes(1);
} finally {
release();
await running;
}
});
});
@@ -94,6 +94,59 @@ describe(waitForArgentToolServerStateAsync, () => {
return JSON.stringify({ port, pid, token });
}
it('reports process exit instead of waiting for the startup timeout', async () => {
const getExitError = jest.fn(() => new Error('server exited with code 1'));
await expect(
waitForArgentToolServerStateAsync({
stateDir,
ancestorPid: process.pid,
timeoutMs: 100,
pollIntervalMs: 1,
getExitError,
})
).rejects.toThrow('server exited with code 1');
expect(getExitError).toHaveBeenCalledTimes(1);
});
it('detects an exit after polling has begun', async () => {
const getExitError = jest
.fn<Error | undefined, []>()
.mockReturnValueOnce(undefined)
.mockReturnValueOnce(undefined)
.mockReturnValue(new Error('server stopped'));
await expect(
waitForArgentToolServerStateAsync({
stateDir,
ancestorPid: process.pid,
timeoutMs: 100,
pollIntervalMs: 1,
getExitError,
})
).rejects.toThrow('server stopped');
expect(getExitError).toHaveBeenCalledTimes(3);
});
it('rejects an exit observed while reading a matching state file', async () => {
await fs.promises.writeFile(
path.join(stateDir, 'tool-server.json'),
stateJson({ port: 4321, pid: process.pid })
);
const getExitError = jest
.fn<Error | undefined, []>()
.mockReturnValueOnce(undefined)
.mockReturnValue(new Error('server exited during startup'));
await expect(
waitForArgentToolServerStateAsync({
stateDir,
ancestorPid: process.ppid,
timeoutMs: 100,
pollIntervalMs: 1,
getExitError,
})
).rejects.toThrow('server exited during startup');
expect(getExitError).toHaveBeenCalledTimes(2);
});
it.each(['tool-server.json', 'tool-server-012345abcdef.json'])(
'reads a matching process from %s',
async fileName => {
@@ -1,7 +1,8 @@
import { type bunyan } from '@expo/logger';
import { type BuildStepContext } from '@expo/steps';
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import fs from 'node:fs';
import { startAndroidLocalEgressAsync } from '../../utils/androidLocalEgress';
import {
LOCAL_EGRESS_HANDOFF_PATH,
configureSystemProxyAsync,
@@ -20,6 +21,9 @@ jest.mock('../../utils/localEgress', () => ({
startChiselServerAsync: jest.fn(),
writeLocalEgressHandoffAsync: jest.fn(),
}));
jest.mock('../../utils/androidLocalEgress', () => ({
startAndroidLocalEgressAsync: jest.fn(),
}));
jest.mock('../../utils/localEgressGuard', () => ({
stopLocalEgressGuardRelaysAsync: jest.fn().mockResolvedValue(undefined),
}));
@@ -31,7 +35,12 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
}));
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const server = { pid: 12345, getOutput: () => '', stopAsync: jest.fn() };
const server = {
pid: 12345,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: jest.fn(),
};
const tunnel = {
url: 'https://egress.example.com',
subdomainId: 'egress-id',
@@ -47,6 +56,82 @@ async function start(signal?: AbortSignal): Promise<void> {
});
}
async function startOn(runtimePlatform: BuildRuntimePlatform): Promise<void> {
await createStartLocalEgressBuildFunction().fn!(
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
{
env: {},
inputs: {},
outputs: {},
} as any
);
}
describe('local egress platforms', () => {
const androidStop = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(fs.promises, 'mkdtemp').mockResolvedValue('/tmp/egress-acquisition-test');
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
jest.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
server.stopAsync.mockResolvedValue(undefined);
tunnel.stopAsync.mockResolvedValue(undefined);
androidStop.mockResolvedValue(undefined);
jest.mocked(findAvailablePortAsync).mockResolvedValue(52001);
jest.mocked(downloadChiselAsync).mockResolvedValue('/tmp/egress-acquisition-test/chisel');
jest.mocked(startChiselServerAsync).mockResolvedValue({ process: server, fingerprint: 'key=' });
jest.mocked(startNgrokTunnelAsync).mockResolvedValue(tunnel);
jest.mocked(configureSystemProxyAsync).mockResolvedValue({ service: 'Ethernet' });
jest.mocked(writeLocalEgressHandoffAsync).mockResolvedValue(undefined);
jest.mocked(startAndroidLocalEgressAsync).mockResolvedValue({ stopAsync: androidStop });
});
afterEach(async () => {
await stopLocalEgressResourcesAsync(logger);
jest.restoreAllMocks();
});
it('sets the macOS system proxy and writes the same handoff as before', async () => {
await startOn(BuildRuntimePlatform.DARWIN);
expect(configureSystemProxyAsync).toHaveBeenCalled();
expect(startAndroidLocalEgressAsync).not.toHaveBeenCalled();
expect(writeLocalEgressHandoffAsync).toHaveBeenCalledWith({
url: tunnel.url,
token: expect.any(String),
fingerprint: 'key=',
port: 8899,
});
});
it('starts the Android relay and fence on Linux instead of the system proxy', async () => {
await startOn(BuildRuntimePlatform.LINUX);
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
expect(startAndroidLocalEgressAsync).toHaveBeenCalledWith(
expect.objectContaining({
chiselPid: server.pid,
proxyPort: 8899,
controlPort: 52001,
workDir: '/tmp/egress-acquisition-test',
})
);
expect(writeLocalEgressHandoffAsync).toHaveBeenCalledWith(
expect.objectContaining({ platform: 'android' })
);
await stopLocalEgressResourcesAsync(logger);
expect(androidStop).toHaveBeenCalledTimes(1);
expect(server.stopAsync).toHaveBeenCalledTimes(1);
});
it('releases the tunnel when the Android fence cannot start', async () => {
jest.mocked(startAndroidLocalEgressAsync).mockRejectedValue(new Error('nft failed'));
await expect(startOn(BuildRuntimePlatform.LINUX)).rejects.toThrow('nft failed');
expect(server.stopAsync).toHaveBeenCalledTimes(1);
expect(tunnel.stopAsync).toHaveBeenCalledTimes(1);
expect(writeLocalEgressHandoffAsync).not.toHaveBeenCalled();
});
});
describe('local egress acquisition', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -3,22 +3,22 @@ import { BuildRuntimePlatform, type BuildStepContext, type BuildStepEnv } from '
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import {
getDeviceRunSessionIdOrThrow,
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
startDeviceWebPreviewWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
getDeviceRunSessionIdOrThrow: jest.fn(),
getNgrokTunnelDomainOrThrow: jest.fn(),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
@@ -56,10 +56,13 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('device-run-session-id');
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockResolvedValue(undefined);
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
stopAsync,
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
closeAsync: jest.fn(),
}),
finishAsync: stopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
@@ -67,11 +70,14 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
});
it('reports the session token when serve-sim minted one', async () => {
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
previewToken: 'tok-1',
stopAsync,
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
previewToken: 'tok-1',
closeAsync: jest.fn(),
}),
finishAsync: stopAsync,
});
await runAsync(BuildRuntimePlatform.DARWIN);
@@ -79,14 +85,32 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: {
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewApiUrl: 'https://web-preview.example.test',
webPreviewToken: 'tok-1',
previewToken: 'tok-1',
},
})
);
});
it.each(['preview', 'config', 'wait'])('finishes the session after %s fails', async phase => {
const error = new Error(`${phase} failed`);
if (phase === 'preview') {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: jest.fn().mockRejectedValue(error),
finishAsync: stopAsync,
});
} else if (phase === 'config') {
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
} else {
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
}
await expect(runAsync(BuildRuntimePlatform.LINUX)).rejects.toBe(error);
expect(stopAsync).toHaveBeenCalledTimes(1);
});
it.each([
[BuildRuntimePlatform.DARWIN, true],
[BuildRuntimePlatform.LINUX, false],
@@ -94,9 +118,8 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
await runAsync(runtimePlatform);
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(selectsXcode ? 1 : 0);
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(ctx, {
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(ctx, {
runtimePlatform,
baseDomain: 'tunnel.example.com',
env,
logger,
timeoutMs: 60_000,
@@ -104,11 +127,14 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
launchAppIdentifier: undefined,
launchArgs: [],
openUrl: undefined,
networkCapture: false,
networkCaptureFields: [],
});
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
ctx,
deviceRunSessionId: 'device-run-session-id',
remoteConfig: {
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewApiUrl: 'https://web-preview.example.test',
},
@@ -134,19 +160,21 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
'launch_app_identifier',
'launch_args',
'open_url',
'network_capture',
'network_capture_fields',
'package_version',
'max_duration_seconds',
]);
});
it('hands the launch inputs to the web preview and announces them', async () => {
it('hands the launch inputs to the session host and announces them', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
launch_app_identifier: { value: 'host.exp.Exponent' },
launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] },
open_url: { value: 'exp://127.0.0.1:8081' },
});
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
launchAppIdentifier: 'host.exp.Exponent',
@@ -160,12 +188,36 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
);
});
it('hands network capture to the session host', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
network_capture: { value: true },
network_capture_fields: { value: ['header', 'response-body'] },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
networkCapture: true,
networkCaptureFields: ['header', 'response-body'],
})
);
});
it('fails before starting anything when network capture is asked for on Android', async () => {
await expect(
runAsync(BuildRuntimePlatform.LINUX, {
network_capture: { value: true },
})
).rejects.toThrow('this session runs on linux');
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
});
it('fails before starting anything when a launch is asked for on Android', async () => {
await expect(
runAsync(BuildRuntimePlatform.LINUX, {
launch_app_identifier: { value: 'host.exp.Exponent' },
})
).rejects.toThrow('runs on linux');
expect(startDeviceWebPreviewWithTunnelAsync).not.toHaveBeenCalled();
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,621 @@
import nock from 'nock';
import { generateKeyPairSync } from 'node:crypto';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { Sentry } from '../../../sentry';
import { AscApiClient } from '../../utils/ios/AscApiClient';
import {
createUpdateTestFlightMetadataBuildFunction,
updateTestFlightMetadataAsync,
} from '../updateTestFlightMetadata';
jest.unmock('node-fetch');
jest.mock('../../../sentry', () => ({ Sentry: { capture: jest.fn() } }));
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
const key = { keyId: 'TESTKEY', privateKey };
const changelog = 'Test "quotes"\n$(not-a-command)';
const options = {
client: new AscApiClient({ key }),
buildUploadId: 'upload',
changelog,
groups: [] as string[],
logger: createMockLogger(),
};
const api = () => nock('https://api.appstoreconnect.apple.com');
function mockBuild(
state = 'COMPLETE',
buildId: string | null = 'build',
primaryLocale: string | null = 'en-US'
): void {
api()
.get('/v1/buildUploads/upload')
.query(true)
.reply(200, {
data: {
type: 'buildUploads',
id: 'upload',
attributes: { state: { state } },
relationships: { build: { data: buildId ? { type: 'builds', id: buildId } : null } },
},
});
if (state === 'COMPLETE' && buildId) {
api()
.get(`/v1/builds/${buildId}/app`)
.query(true)
.reply(200, {
data: { id: 'app', attributes: primaryLocale ? { primaryLocale } : {} },
});
}
}
function mockAssignedGroups(ids: string[] = []): void {
api()
.get('/v1/betaGroups')
.query({ 'filter[builds]': 'build', limit: '200' })
.reply(200, { data: ids.map(id => ({ id })) });
}
function mockInternalBuildState(state: string | null = 'READY_FOR_BETA_TESTING'): void {
api()
.get('/v1/builds/build/buildBetaDetail')
.query(true)
.reply(
state ? 200 : 503,
state
? { data: { id: 'detail', attributes: { internalBuildState: state } } }
: { errors: [{ code: 'UNAVAILABLE' }] }
);
}
beforeAll(() => nock.disableNetConnect());
beforeEach(() => {
jest.mocked(Sentry.capture).mockClear();
options.logger = createMockLogger();
options.client = new AscApiClient({ key });
});
afterAll(() => nock.enableNetConnect());
afterEach(() => {
try {
expect(nock.pendingMocks()).toEqual([]);
} finally {
nock.cleanAll();
}
});
it('accepts group arrays and preserves changelog input without shell parsing', async () => {
const fn = createUpdateTestFlightMetadataBuildFunction();
const spy = jest.spyOn(fn, 'fn').mockImplementation(async (_ctx, { inputs }) => {
expect(inputs.groups.value).toEqual(['A, "B"']);
expect(inputs.changelog.value).toBe(changelog);
});
const step = fn.createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: {
asc_api_key_path: '/tmp/key.json',
build_upload_id: 'upload',
groups: ['A, "B"'],
changelog,
},
});
await step.executeAsync();
expect(spy).toHaveBeenCalled();
});
it('uses empty metadata defaults', async () => {
const fn = createUpdateTestFlightMetadataBuildFunction();
const spy = jest.spyOn(fn, 'fn').mockImplementation(async (_ctx, { inputs }) => {
expect(inputs.groups.value).toEqual([]);
expect(inputs.changelog.value).toBe('');
});
const step = fn.createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { asc_api_key_path: '/tmp/key.json', build_upload_id: 'upload' },
});
await step.executeAsync();
expect(spy).toHaveBeenCalled();
});
it('skips automatic internal groups and groups that already contain the build', async () => {
mockBuild();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, {
data: [
{
id: 'automatic',
attributes: { name: 'Internal', isInternalGroup: true, hasAccessToAllBuilds: true },
},
{ id: 'assigned', attributes: { name: 'QA' } },
{
id: 'manual',
attributes: { name: 'External', isInternalGroup: false, hasAccessToAllBuilds: true },
},
],
});
api()
.get('/v1/betaGroups')
.query({ 'filter[builds]': 'build', limit: '200' })
.reply(200, { data: [{ id: 'assigned', attributes: { name: 'QA' } }] });
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'manual' }],
})
.reply(204);
await updateTestFlightMetadataAsync({
...options,
changelog: '',
groups: ['Internal', 'QA', 'External'],
});
expect(options.logger.info).toHaveBeenCalledWith(
expect.stringContaining('"Internal" (automatic): automatic access')
);
expect(options.logger.info).toHaveBeenCalledWith(
expect.stringContaining('"QA" (assigned): build already assigned')
);
expect(options.logger.info).toHaveBeenCalledWith(
expect.stringContaining('"External" (manual): assignment completed')
);
});
it.each(['en-US', null])(
'creates a localization when none exist (primary locale: %s)',
async primaryLocale => {
mockBuild('COMPLETE', 'build', primaryLocale);
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
api()
.post('/v1/betaBuildLocalizations', {
data: {
type: 'betaBuildLocalizations',
attributes: { locale: 'en-US', whatsNew: changelog },
relationships: { build: { data: { type: 'builds', id: 'build' } } },
},
})
.reply(201, { data: { id: 'locale' } });
await updateTestFlightMetadataAsync(options);
expect(options.logger.info).toHaveBeenCalledWith(
`Updating changelog: ${JSON.stringify(changelog)}`
);
expect(options.logger.info).toHaveBeenCalledWith(
expect.stringContaining('Locale "en-US": localization created')
);
}
);
it('reads all localization pages and updates existing localizations without replacing them', async () => {
mockBuild();
api()
.get('/v1/builds/build/betaBuildLocalizations')
.query(true)
.reply(200, {
data: [{ id: 'en-US', attributes: { locale: 'en-US' } }],
links: {
next: 'https://api.appstoreconnect.apple.com/v1/builds/build/betaBuildLocalizations?limit=200&cursor=next',
},
});
api()
.get('/v1/builds/build/betaBuildLocalizations')
.query({ limit: '200', cursor: 'next' })
.reply(200, { data: [{ id: 'pl', attributes: { locale: 'pl' } }] });
for (const id of ['en-US', 'pl']) {
api()
.patch(`/v1/betaBuildLocalizations/${id}`, {
data: { type: 'betaBuildLocalizations', id, attributes: { whatsNew: changelog } },
})
.reply(200, { data: { id } });
}
await updateTestFlightMetadataAsync(options);
for (const id of ['en-US', 'pl']) {
expect(options.logger.info).toHaveBeenCalledWith(
expect.stringContaining(`Locale "${id}" (${id})`)
);
}
});
it('updates by ID when Apple omits a localization locale and creates the primary locale', async () => {
mockBuild();
api()
.get('/v1/builds/build/betaBuildLocalizations')
.query(true)
.reply(200, { data: [{ id: 'localization' }] });
api()
.patch('/v1/betaBuildLocalizations/localization', {
data: {
type: 'betaBuildLocalizations',
id: 'localization',
attributes: { whatsNew: changelog },
},
})
.reply(200, { data: { id: 'localization' } });
api()
.post('/v1/betaBuildLocalizations', {
data: {
type: 'betaBuildLocalizations',
attributes: { locale: 'en-US', whatsNew: changelog },
relationships: { build: { data: { type: 'builds', id: 'build' } } },
},
})
.reply(201, { data: { id: 'primary' } });
await updateTestFlightMetadataAsync(options);
});
it('assigns requested groups when hasAccessToAllBuilds is null', async () => {
mockBuild();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, {
data: [
{
id: 'internal',
attributes: { name: 'Internal', isInternalGroup: true, hasAccessToAllBuilds: false },
},
{
id: 'external',
attributes: {
name: 'test external',
isInternalGroup: false,
hasAccessToAllBuilds: null,
publicLinkEnabled: false,
publicLink: 'https://testflight.apple.com/join/abc',
},
},
{
id: 'requested-external',
attributes: { name: 'Beta', isInternalGroup: false, hasAccessToAllBuilds: null },
},
],
});
for (const id of ['internal', 'requested-external']) {
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id }],
})
.reply(204);
}
await expect(
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['Internal', 'Beta'] })
).resolves.toBeUndefined();
});
it('adds groups without changing changelog or submitting beta review', async () => {
mockBuild();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, { data: [{ id: 'group', attributes: { name: 'A, "B"' } }] });
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'group' }],
})
.reply(204);
await updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A, "B"', 'A, "B"'] });
});
it('reports missing groups independently of the changelog task', async () => {
mockBuild();
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
api()
.post('/v1/betaBuildLocalizations')
.reply(201, { data: { id: 'locale' } });
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, { data: [] });
await expect(updateTestFlightMetadataAsync({ ...options, groups: ['missing'] })).rejects.toThrow(
'The following TestFlight group was not found in App Store Connect: "missing".'
);
});
it('reads all pages and adds every group with a requested name', async () => {
mockBuild();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, {
data: [{ id: 'first', attributes: { name: 'A' } }],
links: {
next: 'https://api.appstoreconnect.apple.com/v1/betaGroups?filter%5Bapp%5D=app&limit=200&cursor=page-2',
},
});
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200', cursor: 'page-2' })
.reply(200, {
data: [
{ id: 'second', attributes: { name: 'A' } },
{ id: 'other', attributes: { name: 'B' } },
],
});
for (const id of ['first', 'second']) {
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id }],
})
.reply(204);
}
await updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A'] });
});
it('assigns found groups before reporting missing requested names', async () => {
mockBuild();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'group' }],
})
.reply(204);
await expect(
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A', 'B', 'C'] })
).rejects.toThrow(
'The following TestFlight groups were not found in App Store Connect: "B", "C".'
);
});
it('attempts all localization changes and reports every failed update', async () => {
mockBuild();
api()
.get('/v1/builds/build/betaBuildLocalizations')
.query(true)
.reply(200, {
data: ['pl', 'fr', 'de'].map(locale => ({ id: locale, attributes: { locale } })),
});
for (const id of ['pl', 'fr', 'de']) {
api()
.patch(`/v1/betaBuildLocalizations/${id}`)
.reply(
id === 'de' ? 200 : 403,
id === 'de' ? { data: { id } } : { errors: [{ code: `${id}_FAILED` }] }
);
}
api()
.post('/v1/betaBuildLocalizations', {
data: {
type: 'betaBuildLocalizations',
attributes: { locale: 'en-US', whatsNew: changelog },
relationships: { build: { data: { type: 'builds', id: 'build' } } },
},
})
.reply(201, { data: { id: 'locale' } });
await expect(updateTestFlightMetadataAsync(options)).rejects.toThrow(/pl_FAILED.*fr_FAILED/);
expect(options.logger.error).toHaveBeenCalledWith(expect.stringContaining('"pl" (pl)'));
expect(options.logger.error).toHaveBeenCalledWith(expect.stringContaining('"fr" (fr)'));
});
it.each([1, 2])('explains automatic internal group rejection (%s errors)', async errorCount => {
mockBuild();
mockInternalBuildState();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
api()
.post('/v1/builds/build/relationships/betaGroups')
.reply(422, {
errors: Array.from({ length: errorCount }, () => ({
code: 'ENTITY_UNPROCESSABLE',
title: 'Builds cannot be assigned to this internal group.',
detail: 'Cannot add internal group to a build.',
})),
});
await expect(
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A'] })
).rejects.toThrow(
"App Store Connect can't add this build to a requested internal TestFlight group."
);
});
it('stops after 20 group pages', async () => {
mockBuild();
const next =
'https://api.appstoreconnect.apple.com/v1/betaGroups?filter%5Bapp%5D=app&limit=200&cursor=next';
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, { data: [], links: { next } });
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200', cursor: 'next' })
.times(19)
.reply(200, { data: [], links: { next } });
await expect(
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A'] })
).rejects.toThrow('We only support TestFlight group lists with up to 20 pages');
});
it('tries group assignment if the changelog update fails', async () => {
mockBuild();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
api()
.post('/v1/betaBuildLocalizations')
.reply(403, { errors: [{ code: 'FORBIDDEN', detail: 'Not allowed' }] });
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'group' }],
})
.reply(204);
await expect(updateTestFlightMetadataAsync({ ...options, groups: ['A'] })).rejects.toThrow('403');
});
it('reports both write failures', async () => {
mockBuild();
mockInternalBuildState();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
api()
.post('/v1/betaBuildLocalizations')
.reply(403, { errors: [{ code: 'CHANGELOG_FAILED' }] });
api()
.post('/v1/builds/build/relationships/betaGroups')
.reply(403, { errors: [{ code: 'GROUPS_FAILED' }] });
await expect(updateTestFlightMetadataAsync({ ...options, groups: ['A'] })).rejects.toThrow(
/CHANGELOG_FAILED.*GROUPS_FAILED/
);
});
it.each([false, true])(
'attempts all groups and reports assignment errors (multiple: %s)',
async multiple => {
mockBuild();
mockInternalBuildState();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, { data: ['A', 'B', 'C'].map(id => ({ id, attributes: { name: id } })) });
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'A' }],
})
.reply(
multiple ? 403 : 204,
multiple ? { errors: [{ code: 'FIRST_GROUP_FAILED' }] } : undefined
);
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'B' }],
})
.reply(422, { errors: [{ code: 'ENTITY_UNPROCESSABLE', title: 'Partial assignment' }] });
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'C' }],
})
.reply(204);
await expect(
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A', 'B', 'C'] })
).rejects.toThrow(multiple ? 'FIRST_GROUP_FAILED' : 'Partial assignment');
expect(options.logger.error).toHaveBeenCalledWith(
expect.stringContaining('Group "B" (B): assignment failed.')
);
}
);
it('reads all membership pages before assigning groups', async () => {
mockBuild();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
api()
.get('/v1/betaGroups')
.query({ 'filter[builds]': 'build', limit: '200' })
.reply(200, {
data: [],
links: {
next: 'https://api.appstoreconnect.apple.com/v1/betaGroups?filter%5Bbuilds%5D=build&limit=200&cursor=next',
},
});
api()
.get('/v1/betaGroups')
.query({ 'filter[builds]': 'build', limit: '200', cursor: 'next' })
.reply(200, { data: [{ id: 'group' }] });
await updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A'] });
});
it.each(['PROCESSING', 'FAILED'])('rejects a %s upload', async state => {
mockBuild(state);
await expect(updateTestFlightMetadataAsync(options)).rejects.toThrow('not ready');
});
it('rejects a completed upload without its build relationship', async () => {
mockBuild('COMPLETE', null);
await expect(updateTestFlightMetadataAsync(options)).rejects.toThrow('not ready');
});
it('propagates Apple write failures', async () => {
mockBuild();
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
api()
.post('/v1/betaBuildLocalizations')
.reply(403, { errors: [{ code: 'FORBIDDEN', detail: 'Not allowed' }] });
await expect(updateTestFlightMetadataAsync(options)).rejects.toThrow('403');
});
it.each([
['MISSING_EXPORT_COMPLIANCE', 'Complete the export compliance questions'],
['IN_EXPORT_COMPLIANCE_REVIEW', 'Wait for Apple to approve'],
['EXPIRED', 'ASSIGNMENT_FAILED'],
['PROCESSING', 'ASSIGNMENT_FAILED'],
['READY_FOR_BETA_TESTING', 'ASSIGNMENT_FAILED'],
['IN_BETA_TESTING', 'ASSIGNMENT_FAILED'],
[null, 'ASSIGNMENT_FAILED'],
] as const)(
'shares one diagnostic lookup across failed assignments (state: %s)',
async (state, guidance) => {
mockBuild();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
.query({ 'filter[app]': 'app', limit: '200' })
.reply(200, {
data: ['A', 'B', 'C'].map(id => ({ id, attributes: { name: id } })),
});
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'A' }],
})
.reply(403, { errors: [{ code: 'ASSIGNMENT_FAILED' }] });
mockInternalBuildState(state);
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'B' }],
})
.reply(422, { errors: [{ code: 'ASSIGNMENT_FAILED' }] });
// A failed assignment must not prevent the next group from being assigned.
api()
.post('/v1/builds/build/relationships/betaGroups', {
data: [{ type: 'betaGroups', id: 'C' }],
})
.reply(204);
const failure = await updateTestFlightMetadataAsync({
...options,
changelog: '',
groups: ['A', 'B', 'C'],
}).catch(error => error);
expect(failure).toBeInstanceOf(AggregateError);
expect(failure.errors).toHaveLength(2);
if (state === 'MISSING_EXPORT_COMPLIANCE' || state === 'IN_EXPORT_COMPLIANCE_REVIEW') {
expect(failure.errors[0]).toBe(failure.errors[1]);
} else {
expect(failure.errors[0]).toMatchObject({ status: 403 });
expect(failure.errors[1]).toMatchObject({ status: 422 });
}
expect(failure.errors[0].message).toContain(guidance);
expect(
jest
.mocked(options.logger.info)
.mock.calls.filter(([message]) => String(message).includes('internal TestFlight state ='))
).toEqual([[`Apple build build: internal TestFlight state = ${state ?? 'UNKNOWN'}.`]]);
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
`TestFlight group assignment failed (state: ${state ?? 'UNKNOWN'})`,
{
level: 'error',
tags: { step: 'eas/update_testflight_metadata', internal_build_state: state ?? 'UNKNOWN' },
extras: {
buildId: 'build',
assignmentError: expect.stringContaining('ASSIGNMENT_FAILED'),
},
}
);
}
);
@@ -29,7 +29,20 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
});
});
it('uploads simulator device metadata', async () => {
it.each([
{
platform: BuildRuntimePlatform.DARWIN,
udid: SIMULATOR_UDID,
name: 'iPhone 16',
runtime: 'iOS 18.6',
},
{
platform: BuildRuntimePlatform.LINUX,
udid: 'emulator-5554',
name: 'Pixel',
runtime: 'Android 16',
},
])('uploads device metadata on $platform', async ({ platform, udid, name, runtime }) => {
const recordingDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'ios-recording-test-'));
const recordingPath = path.join(recordingDirectory, 'recording.mp4');
await fs.writeFile(recordingPath, 'recording');
@@ -45,7 +58,7 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
try {
const globalContext = createGlobalContextMock({
runtimePlatform: BuildRuntimePlatform.DARWIN,
runtimePlatform: platform,
});
const buildStep = createUploadDeviceRunSessionScreenRecordingsBuildFunction(
{} as CustomBuildContext
@@ -54,9 +67,9 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
callInputs: {
recordings_json: [
{
udid: SIMULATOR_UDID,
deviceName: 'iPhone 16',
runtimeDisplayName: 'iOS 18.6',
udid,
deviceName: name,
runtimeDisplayName: runtime,
directory: recordingDirectory,
},
],
@@ -68,13 +81,13 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
name: 'iPhone 16 screen recording (01234567-…, started at Jul 10, 2026, 10:00:00.000 UTC)',
name: `${name} screen recording (${udid.slice(0, 8)}-…, started at Jul 10, 2026, 10:00:00.000 UTC)`,
metadata: {
__eas_type: 'screen-recording',
__eas_screen_recording: '1',
udid: SIMULATOR_UDID,
deviceName: 'iPhone 16',
runtimeDisplayName: 'iOS 18.6',
udid,
deviceName: name,
runtimeDisplayName: runtime,
firstFrameAt: '2026-07-10T10:00:00.000Z',
width: 1179,
height: 2556,
@@ -0,0 +1,129 @@
import { ProjectConfig } from '@expo/config';
import { BuildJob } from '@expo/eas-build-job';
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 { readAppConfig } from '../../../utils/appConfig';
import { uploadEmbeddedBundleAsync } from '../../../utils/expoUpdatesEmbedded';
import { createUploadEmbeddedBundleBuildFunction } from '../uploadEmbeddedBundle';
jest.mock('../../../utils/appConfig');
jest.mock('../../../utils/expoUpdatesEmbedded');
function mockAppConfig(updatesUrl: string | undefined): void {
jest.mocked(readAppConfig).mockResolvedValue({
exp: { name: 'my-app', slug: 'my-app', updates: updatesUrl ? { url: updatesUrl } : undefined },
} as ProjectConfig);
}
function createCustomContext(job: BuildJob): CustomBuildContext<BuildJob> {
return new CustomBuildContext(
new BuildContext(job, {
env: { __API_SERVER_URL: 'http://api.expo.test' },
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
uploadArtifact: jest.fn(),
workingdir: '',
})
);
}
describe(createUploadEmbeddedBundleBuildFunction, () => {
const job = createTestAndroidJob({});
const customContext = createCustomContext(job);
beforeEach(() => {
jest.resetAllMocks();
});
it('uploads the embedded bundle from the working directory', async () => {
mockAppConfig('https://u.expo.dev/project-id');
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValue({ status: 'uploaded' });
const globalContext = createGlobalContextMock({});
globalContext.updateEnv({ EAS_BUILD_ID: 'build-123' });
const buildStep =
createUploadEmbeddedBundleBuildFunction(customContext).createBuildStepFromFunctionCall(
globalContext
);
await buildStep.executeAsync();
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
expect.objectContaining({
job,
env: expect.objectContaining({ EAS_BUILD_ID: 'build-123' }),
projectDir: globalContext.defaultWorkingDirectory,
appConfig: expect.objectContaining({ updates: { url: 'https://u.expo.dev/project-id' } }),
})
);
});
it('logs when the embedded bundle upload is skipped', async () => {
mockAppConfig('https://u.expo.dev/project-id');
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValue({ status: 'skipped' });
const buildStep = createUploadEmbeddedBundleBuildFunction(
customContext
).createBuildStepFromFunctionCall(createGlobalContextMock({}));
await buildStep.executeAsync();
expect(buildStep.ctx.logger.info).toHaveBeenCalledWith('Skipping embedded bundle upload.');
});
it('fails the step when the upload fails', async () => {
mockAppConfig('https://u.expo.dev/project-id');
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValue({ status: 'failed' });
const buildStep = createUploadEmbeddedBundleBuildFunction(
customContext
).createBuildStepFromFunctionCall(createGlobalContextMock({}));
await expect(buildStep.executeAsync()).rejects.toThrow('Failed to upload embedded bundle.');
});
it('does not fail the step when the upload fails and ignore_error is set', async () => {
mockAppConfig('https://u.expo.dev/project-id');
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValue({ status: 'failed' });
const buildStep = createUploadEmbeddedBundleBuildFunction(
customContext
).createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs: { ignore_error: true },
});
await buildStep.executeAsync();
expect(buildStep.ctx.logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to upload embedded bundle. Ignoring error.'
);
});
it('fails the step when the app config cannot be read', async () => {
jest.mocked(readAppConfig).mockRejectedValue(new Error('Invalid app config'));
const buildStep = createUploadEmbeddedBundleBuildFunction(
customContext
).createBuildStepFromFunctionCall(createGlobalContextMock({}));
await expect(buildStep.executeAsync()).rejects.toThrow('Invalid app config');
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
it('does not fail the step when the app config cannot be read and ignore_error is set', async () => {
jest.mocked(readAppConfig).mockRejectedValue(new Error('Invalid app config'));
const buildStep = createUploadEmbeddedBundleBuildFunction(
customContext
).createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs: { ignore_error: true },
});
await buildStep.executeAsync();
expect(buildStep.ctx.logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to upload embedded bundle. Ignoring error.'
);
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,46 @@
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
import { type CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { getDeviceRunSessionIdOrThrow } from '../utils/remoteDeviceRunSession';
import { uploadServeSimLogsFileAsync } from '../utils/serveSimLogsArtifacts';
import { ServeSimLogsRecorder } from '../utils/serveSimLogsRecorder';
const UPLOAD_TIMEOUT_MS = 30_000;
export function createCollectServeSimLogsBuildFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'collect_serve_sim_logs',
name: 'Collect serve-sim simulator logs',
__metricsId: 'eas/collect_serve_sim_logs',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
fn: async ({ logger }, { env }) => {
try {
const collected = await ServeSimLogsRecorder.finishAsync({ logger });
if (collected.length === 0) {
logger.info('No simulator logs collected; skipping upload.');
return;
}
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
for (const { udid, filePath } of collected) {
// Each device gets its own budget so one stuck upload does not cancel the rest.
await uploadServeSimLogsFileAsync(ctx, {
deviceRunSessionId,
udid,
filePath,
logger,
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
});
}
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not finalize serve-sim simulator logs', error);
logger.warn(
{ err: error },
'Could not finalize simulator logs; the session result is unchanged.'
);
}
},
});
}
@@ -25,6 +25,7 @@ import plist from 'plist';
import { CustomBuildContext } from '../../customBuildContext';
import { formatBytes } from '../../utils/artifacts';
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
import { pluralize } from '../../utils/strings';
@@ -78,7 +79,7 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
required: true,
}),
],
fn: async (stepsCtx, { inputs, outputs }) => {
fn: async (stepsCtx, { inputs, outputs, signal }) => {
const { logger } = stepsCtx;
const extensions = z.array(z.string()).parse(inputs.extensions.value);
@@ -119,6 +120,7 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
graphqlClient: ctx.graphqlClient,
robotAccessToken: stepsCtx.global.staticContext.job.secrets?.robotAccessToken ?? null,
extensions,
signal,
});
outputs.artifact_path.set(artifactPath);
@@ -129,11 +131,17 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
async function fetchApplicationArchiveUrlAsync({
buildId,
graphqlClient,
signal,
}: {
buildId: string;
graphqlClient: Client;
signal?: AbortSignal;
}): Promise<string> {
const result = await graphqlClient.query(BUILD_BY_ID_QUERY, { buildId }).toPromise();
signal?.throwIfAborted();
const result = await graphqlClient
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
.toPromise();
signal?.throwIfAborted();
if (result.error) {
const { error } = result;
@@ -161,9 +169,11 @@ export async function downloadBuildAsync(
graphqlClient: Client;
robotAccessToken: string | null;
extensions: string[];
/** Stops the build lookup, the request and the file write when aborted, and skips the rest. */
signal?: AbortSignal;
}
): Promise<{ artifactPath: string }> {
const { logger, graphqlClient, robotAccessToken, extensions } = params;
const { logger, graphqlClient, robotAccessToken, extensions, signal } = params;
let downloadUrl: string;
let headers: { Authorization: string } | undefined;
@@ -178,7 +188,7 @@ export async function downloadBuildAsync(
headers = undefined;
} else if (params.buildId) {
const buildId = z.string().uuid().parse(params.buildId);
downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient });
downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient, signal });
headers = robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined;
} else {
throw new UserError(
@@ -191,7 +201,8 @@ export async function downloadBuildAsync(
path.join(os.tmpdir(), 'download_build-downloaded-')
);
const response = await retryOnDNSFailure(fetch)(downloadUrl, { headers });
signal?.throwIfAborted();
const response = await retryOnDNSFailure(fetch)(downloadUrl, { headers, signal });
if (!response.ok) {
const textResult = await asyncResult(response.text());
@@ -201,7 +212,9 @@ export async function downloadBuildAsync(
const archiveFilename = resolveArchiveFilename({ response, extensions });
const archivePath = path.join(downloadDestinationDirectory, archiveFilename);
// On abort, node-fetch emits an error on the body stream, which fails the pipeline.
await streamPipeline(response.body, fs.createWriteStream(archivePath));
signal?.throwIfAborted();
const { size } = await fs.promises.stat(archivePath);
@@ -217,10 +230,15 @@ export async function downloadBuildAsync(
const extractionDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'download_build-extracted-')
);
// Not interrupted on abort: tar cannot stop its in-flight file writes, so an abort during
// the extraction lets it finish (local, bounded work) and is reported right after it.
// This way no file is written after this function settles.
signal?.throwIfAborted();
await decompressTarAsync({
archivePath,
destinationDirectory: extractionDirectory,
});
signal?.throwIfAborted();
const matchingFiles = await glob(`**/*.(${extensions.join('|')})`, {
absolute: true,
@@ -199,10 +199,15 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
}
if (backend === 'maestro-runner' && global.runtimePlatform === BuildRuntimePlatform.DARWIN) {
await installMaestroRunnerWdaCache({
logger,
env,
});
// Runner 1.1.28 switched the default iOS driver to the bundled DeviceLab agent.
// Use the detected version after installation, including when `latest` was requested.
const installedRunnerVersion = semver.coerce(maestroVersionResult.value);
if (!installedRunnerVersion || semver.lt(installedRunnerVersion, '1.1.28')) {
await installMaestroRunnerWdaCache({
logger,
env,
});
}
}
logger.info(`${backend} ${maestroVersionResult.value} is ready.`);
@@ -0,0 +1,104 @@
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { BuildFunction, BuildRuntimePlatform, BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import { Sentry } from '../../sentry';
export function createInstallMitmproxyBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'install_mitmproxy',
name: 'Install mitmproxy',
__metricsId: 'eas/install_mitmproxy',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
fn: async ({ logger }, { env }) => {
// Some images ship the cask, and its quarantined first launch can hang.
const canLaunch =
env.EAS_BUILD_RUNNER !== 'eas-build' || (await removeMitmproxyQuarantineAsync(env));
if (canLaunch && (await isMitmproxyAvailableAsync(env))) {
logger.info('mitmproxy is already installed.');
return;
}
if (env.EAS_BUILD_RUNNER !== 'eas-build') {
logger.warn(
'mitmproxy is not installed and network capture needs it. Install it with `brew install --cask mitmproxy` and rerun the job.'
);
return;
}
try {
logger.info('Installing mitmproxy with Homebrew.');
await installMitmproxyWithHomebrewAsync({ env, logger });
if (!(await removeMitmproxyQuarantineAsync(env))) {
throw new Error('Could not launch mitmproxy because it is still quarantined.');
}
if (!(await isMitmproxyAvailableAsync(env))) {
throw new Error('`brew install --cask mitmproxy` succeeded but mitmdump did not run.');
}
logger.info('Installed mitmproxy.');
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not install mitmproxy for the device session', error, {
level: 'warning',
});
logger.warn(
{ err: error },
'Could not install mitmproxy, so network capture will record nothing in this session. Re-run the session to retry the install, or run it without network capture.'
);
}
},
});
}
async function isMitmproxyAvailableAsync(env: BuildStepEnv): Promise<boolean> {
// Kills a first launch that Gatekeeper still holds, if the quarantine check missed it. Without
// pipes, a child process that outlives mitmdump cannot hold the call open past the timeout.
return (
await asyncResult(
spawn('mitmdump', ['--version'], {
env,
stdio: 'ignore',
timeout: 60_000,
killSignal: 'SIGKILL',
})
)
).ok;
}
async function installMitmproxyWithHomebrewAsync({
env,
logger,
}: {
env: BuildStepEnv;
logger: bunyan;
}): Promise<void> {
await spawn('brew', ['install', '--cask', 'mitmproxy'], {
env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1' },
logger,
});
}
/**
* Gatekeeper has rejected the mitmproxy 12.2.3 cask as unnotarized since 2026-10-06, and a
* quarantined first launch then never returns. Takes the app behind the `mitmdump` on PATH out of
* quarantine, and returns false when it stays quarantined, so it is not launched.
*/
async function removeMitmproxyQuarantineAsync(env: BuildStepEnv): Promise<boolean> {
const which = await asyncResult(spawn('which', ['mitmdump'], { env, stdio: 'pipe' }));
const mitmdump = which.ok
? await fs.promises.realpath(which.value.stdout.trim()).catch(() => null)
: null;
if (!mitmdump) {
return true;
}
const appEnd = mitmdump.lastIndexOf('.app/');
const app = appEnd === -1 ? mitmdump : mitmdump.slice(0, appEnd + '.app'.length);
await asyncResult(spawn('xattr', ['-dr', 'com.apple.quarantine', app], { env, stdio: 'pipe' }));
const quarantined = await asyncResult(
spawn('xattr', ['-p', 'com.apple.quarantine', mitmdump], { env, stdio: 'pipe' })
);
return !quarantined.ok;
}
@@ -107,6 +107,8 @@ function buildMaestroArgs({
return { executable: 'maestro', args };
}
case 'maestro-runner': {
// The runner writes every report format to one directory. output_format only
// selects which extra report artifact EAS uploads after the test.
const args = [`--platform=${platform}`, 'test', `--output=${output}`, '--flatten'];
if (shards !== undefined && shards > 1) {
args.push(`--parallel=${shards}`);
@@ -213,23 +215,20 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
}
const testsDirectory = path.join(home, '.maestro', 'tests');
const junitReportDirectory = path.join(testsDirectory, 'junit-reports');
const finalReportPath =
outputFormat === 'junit'
? path.join(testsDirectory, `${platform}-maestro-junit.xml`)
: undefined;
const junitFinalReportPath = path.join(testsDirectory, `${platform}-maestro-junit.xml`);
// Public docs (EAS workflows pre-packaged-jobs) document
// `${MAESTRO_TESTS_DIR}` for users to save screenshots/recordings into
// the uploaded dir.
const spawnEnv: NodeJS.ProcessEnv = { ...env, MAESTRO_TESTS_DIR: testsDirectory };
// Outputs are published BEFORE any throw below so downstream
// `if: always()` upload steps still see populated values when this
// step fails early.
// Publish known paths before backend validation so `if: always()` upload
// steps can use them after an early failure. final_report_path names the
// report selected by output_format, not every report the backend produces.
outputs.tests_directory.set(testsDirectory);
outputs.junit_report_directory.set(junitReportDirectory);
if (finalReportPath !== undefined) {
outputs.final_report_path.set(finalReportPath);
if (outputFormat === 'junit') {
outputs.final_report_path.set(junitFinalReportPath);
}
// Resolved after the output assignments above: an invalid backend input or
@@ -239,6 +238,16 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
input: inputs.backend.value,
env,
});
// The runner can write JUnit regardless of output_format; Maestro CLI only
// writes it when JUnit is selected. Either command may fail before it writes a file.
const mayHaveJUnitReports = backend === 'maestro-runner' || outputFormat === 'junit';
const maestroCliOutputPath =
backend === 'maestro' && outputFormat && outputFormat !== 'junit'
? path.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
: null;
if (backend === 'maestro' && outputFormat === 'html' && maestroCliOutputPath) {
outputs.final_report_path.set(maestroCliOutputPath);
}
const flowPaths = parseInput(
FlowPathSchema,
@@ -262,10 +271,14 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
undefined,
'android_connection_mode and EAS_MAESTRO_ANDROID_CONNECTION_MODE must be either "adb" or "dadb".'
);
if (backend === 'maestro-runner' && outputFormat !== undefined && outputFormat !== 'junit') {
if (
backend === 'maestro-runner' &&
outputFormat !== undefined &&
!['junit', 'html', 'allure'].includes(outputFormat)
) {
throw new UserError(
'ERR_MAESTRO_INVALID_INPUT',
`maestro-runner only supports the "junit" output_format, but received "${outputFormat}".`
`maestro-runner supports "junit", "html", and "allure" output_format values, but received "${outputFormat}".`
);
}
const retryFailedOnly = inputs.retry_failed_only.value as boolean;
@@ -337,17 +350,14 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
}
for (let attempt = 0; attempt <= retries; attempt++) {
// maestro-runner writes its JUnit report and screenshot metadata to this directory.
// maestro-runner writes all report formats and screenshot metadata here.
const runnerOutputDirectory = path.join(
testsDirectory,
`${platform}-maestro-runner-attempt-${attempt}`
);
const outputPath =
outputFormat === 'junit'
? path.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
: backend === 'maestro' && outputFormat
? path.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
: null;
const outputPath = mayHaveJUnitReports
? path.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
: maestroCliOutputPath;
const { executable, args: maestroArgs } = buildMaestroArgs({
backend,
platform,
@@ -371,6 +381,17 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
logger.warn({ err }, `Failed to clear ${runnerOutputDirectory} before the attempt.`);
}
}
// Maestro CLI uses the same HTML path for every retry. Remove the previous report
// before spawning so an attempt that fails before report generation cannot upload it.
if (backend === 'maestro' && outputFormat === 'html' && maestroCliOutputPath) {
try {
await fs.rm(maestroCliOutputPath, { force: true });
} catch (err) {
throw new SystemError('Failed to clear the previous Maestro HTML report', {
cause: err,
});
}
}
const attemptStartedAtMs = Date.now();
@@ -412,9 +433,8 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
reportDirectories.push(runnerOutputDirectory);
}
// Harvest this attempt's failure screenshots before any retry subsetting. Gated on
// junit: test-case-result rows (and therefore the summary icons) only exist for junit
// runs, so harvesting other formats would just create orphan artifacts the website hides.
// The pre-packaged job reports test cases only for JUnit (the default format).
// Individual screenshots need those test cases to be visible on the website.
if (outputFormat === 'junit') {
let screenshots: HarvestedScreenshot[];
switch (backend) {
@@ -504,14 +524,15 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
await sleepAsync(2000);
}
// Smart merge first; on data errors (bad XML, missing input) fall back
// to copy-latest so the caller still gets a single JUnit file.
// Only JUnit is merged into final_report_path. Runner HTML/Allure keeps its
// per-attempt JUnit files, but final_report_path names the selected report.
// On data errors (bad XML, missing input), fall back to copy-latest.
// Filesystem errors short-circuit straight to SystemError.
if (finalReportPath !== undefined) {
if (outputFormat === 'junit') {
try {
await mergeJUnitReports({
sourceDir: junitReportDirectory,
outputPath: finalReportPath,
outputPath: junitFinalReportPath,
});
} catch (mergeErr: any) {
if (isFilesystemError(mergeErr)) {
@@ -521,7 +542,7 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
try {
await copyLatestAttemptXml({
sourceDir: junitReportDirectory,
outputPath: finalReportPath,
outputPath: junitFinalReportPath,
});
} catch (copyErr: any) {
// Swallow: a copy failure here usually means maestro itself failed
@@ -529,14 +550,13 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
// the real reason and cancel billing for a user-side failure — let
// the lastAttemptExitCode check below surface ERR_MAESTRO_TESTS_FAILED.
logger.warn(
`Failed to produce final_report_path at ${finalReportPath}: ${copyErr?.message ?? copyErr}`
`Failed to produce final_report_path at ${junitFinalReportPath}: ${copyErr?.message ?? copyErr}`
);
}
}
}
// Upload before the ERR_MAESTRO_TESTS_FAILED throw below so fully-failed runs (which need
// screenshots most) still upload. Harvest only ran for junit, so guard the same way.
// Upload before the failure verdict so fully-failed runs still get screenshots.
if (outputFormat === 'junit') {
await uploadFailureScreenshotsAsync({
harvested,
@@ -547,6 +567,81 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
});
}
// Upload the selected non-JUnit report before the failure verdict. The pre-packaged
// job also uploads testsDirectory, but a named artifact makes the report easy to find.
let selectedReport:
| { name: string; artifactPath: string; finalReportPath: string }
| undefined;
switch (outputFormat) {
case 'html': {
switch (backend) {
case 'maestro': {
if (!maestroCliOutputPath) {
break;
}
selectedReport = {
name: 'Maestro HTML Report',
artifactPath: maestroCliOutputPath,
finalReportPath: maestroCliOutputPath,
};
break;
}
case 'maestro-runner': {
const reportDirectory = reportDirectories.at(-1);
if (!reportDirectory) {
break;
}
// HTML references nearby screenshots, so keep its whole attempt directory.
selectedReport = {
name: 'Maestro Runner HTML Report',
artifactPath: reportDirectory,
finalReportPath: path.join(reportDirectory, 'report.html'),
};
break;
}
}
break;
}
case 'allure': {
switch (backend) {
case 'maestro':
logger.warn(
'Maestro CLI does not support Allure reports; no Allure artifact was uploaded.'
);
break;
case 'maestro-runner': {
const reportDirectory = reportDirectories.at(-1);
if (!reportDirectory) {
break;
}
const allureResultsDirectory = path.join(reportDirectory, 'allure-results');
selectedReport = {
name: 'Maestro Runner Allure Results',
artifactPath: allureResultsDirectory,
finalReportPath: allureResultsDirectory,
};
break;
}
}
break;
}
}
if (selectedReport) {
outputs.final_report_path.set(selectedReport.finalReportPath);
try {
await ctx.runtimeApi.uploadArtifact({
artifact: {
type: GenericArtifactType.OTHER,
name: selectedReport.name,
paths: [selectedReport.artifactPath],
},
logger,
});
} catch (err: any) {
logger.warn({ err }, `Failed to upload ${selectedReport.name}.`);
}
}
// The retry loop exits via success (0), numeric status (retryable),
// or throw (infra). A non-null non-zero status means the user's tests
// failed every attempt.
@@ -0,0 +1,42 @@
import {
BuildFunction,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import path from 'node:path';
import { z } from 'zod';
import { readAndroidArtifactInfoAsync } from '../utils/android/appArtifact';
export function createReadAndroidAppInfoBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'read_android_app_info',
name: 'Read Android app info',
__metricsId: 'eas/read_android_app_info',
inputProviders: [
BuildStepInput.createProvider({
id: 'artifact_path',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
outputProviders: [
BuildStepOutput.createProvider({ id: 'artifact_type', required: true }),
BuildStepOutput.createProvider({ id: 'package_name', required: true }),
],
fn: async (ctx, { inputs, outputs, signal }) => {
const artifactPath = path.resolve(
ctx.workingDirectory,
z.string().min(1).parse(inputs.artifact_path.value)
);
ctx.logger.info(`Reading Android artifact: ${artifactPath}.`);
const artifact = await readAndroidArtifactInfoAsync(artifactPath, signal);
ctx.logger.info(`Android artifact type: ${artifact.artifactType}.`);
ctx.logger.info(`Android package name: ${artifact.packageName}.`);
outputs.artifact_type.set(artifact.artifactType);
outputs.package_name.set(artifact.packageName);
},
});
}
@@ -1,22 +1,14 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import {
BuildFunction,
BuildRuntimePlatform,
type BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import { type BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { type CustomBuildContext } from '../../customBuildContext';
import {
uploadRemoteSessionConfigWithLocalEgressAsync,
withLocalEgressSession,
} from '../utils/localEgressSession';
import { uploadRemoteSessionConfigWithLocalEgressAsync } from '../utils/localEgressSession';
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import { Sentry } from '../../sentry';
import {
PackageManager,
@@ -25,18 +17,16 @@ import {
resolvePackageInstall,
} from '../../utils/packageManager';
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
import { type parseNetworkCaptureInputs } from '../utils/networkCaptureFields';
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
import { type StartupTasks } from '../utils/startupTasks';
import {
type DetachedProcessHandle,
createServeSimLaunchInputProviders,
describeServeSimLaunch,
finishRemoteSessionAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
@@ -51,174 +41,238 @@ const STARTUP_TIMEOUT_MS = 60_000;
const AGENT_DEVICE_DAEMON_ENV = {
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
AGENT_DEVICE_RETAIN_ARTIFACTS: '1',
// The session lifetime is owned by max_idle_time_minutes / max_duration_seconds
// and the device run session stop, so disable agent-device's own idle timers.
// Without this the daemon exits after 5 minutes with no open session, and the
// tunnel is left without a daemon behind it.
AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0',
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS: '0',
AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS: '0',
};
export function createStartAgentDeviceRemoteSessionBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_agent_device_remote_session',
name: 'Start agent device remote session',
__metricsId: 'eas/start_agent_device_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'max_idle_time_minutes',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'max_duration_seconds',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
// Fail fast before any expensive setup if the injected env
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
// for our ngrok tunnels), and NGROK_AUTHTOKEN (to authenticate them).
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
export type AgentDeviceRemoteSessionEnv = {
deviceRunSessionId: string;
ngrokTunnelDomain: string;
ngrokAuthtoken: string;
};
const packageVersion = inputs.package_version.value as string | undefined;
// A missing or non-positive value disables the idle timeout (opt-in feature).
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
const { runtimePlatform } = global;
const launch = parseServeSimLaunchInputs(
{
launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value as string | undefined,
},
{ runtimePlatform }
);
logger.info(
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
);
/**
* Reads the env vars that the API server injects into a device run session job:
* DEVICE_RUN_SESSION_ID (to report the remote config back to the API server),
* EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain for our ngrok tunnels), and
* NGROK_AUTHTOKEN (to authenticate them).
*/
export function getAgentDeviceRemoteSessionEnvOrThrow(
env: BuildStepEnv
): AgentDeviceRemoteSessionEnv {
return {
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
ngrokTunnelDomain: getNgrokTunnelDomainOrThrow(env),
ngrokAuthtoken: getNgrokAuthtokenOrThrow(env),
};
}
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
/**
* Starts the agent-device daemon, its tunnel, the session host and the web preview,
* reports the session as ready, and keeps it alive until it stops.
*
* The daemon does not need the device, so it starts at once. The session host starts
* when `device.booted` resolves. The session is reported as ready only when both are
* up and `device.ready` (the app is installed and launched) resolved too.
*
* The first failure aborts `tasks.signal`: each part stops before its next stage, and
* the teardown then stops whatever was started. `device.ready` must settle soon after
* an abort, because the teardown waits for it.
*/
export async function runAgentDeviceRemoteSessionAsync(
ctx: CustomBuildContext,
{
env,
logger,
signal,
runtimePlatform,
sessionEnv: { deviceRunSessionId, ngrokTunnelDomain, ngrokAuthtoken },
packageVersion,
maxIdleTimeMinutes,
maxDurationSeconds,
capture,
tasks,
device,
}: {
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
runtimePlatform: BuildRuntimePlatform;
sessionEnv: AgentDeviceRemoteSessionEnv;
packageVersion: string | undefined;
maxIdleTimeMinutes: number | undefined;
maxDurationSeconds: number | undefined;
capture: ReturnType<typeof parseNetworkCaptureInputs>;
tasks: StartupTasks;
device: { booted: Promise<unknown>; ready: Promise<unknown> };
}
): Promise<void> {
logger.info(
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
);
logger.info('Launching agent-device daemon.');
const daemonProcess = await startAgentDeviceDaemonAsync({ packageVersion, env, logger });
let daemonProcess: DetachedProcessHandle | undefined;
let agentDeviceTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let eventCollection: Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>> | undefined;
let sessionFailed = false;
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
const { port: daemonPort, token: daemonToken } = await waitForDaemonInfoAsync({
daemonProcess,
});
logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
// Each task stores what it started, so the teardown below can stop it even when
// another task failed first.
const agentDeviceStartup = tasks.run('agent-device daemon', async taskLogger => {
taskLogger.info('Launching agent-device daemon.');
daemonProcess = await startAgentDeviceDaemonAsync({
packageVersion,
env,
logger: taskLogger,
signal: tasks.signal,
});
const agentDeviceTunnel = await startNgrokTunnelAsync({
port: daemonPort,
subdomainPrefix: 'agent-device',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
logger,
});
const agentDeviceRemoteSessionUrl = agentDeviceTunnel.url;
logger.info(`Tunnel is ready at ${agentDeviceRemoteSessionUrl}.`);
taskLogger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
// The daemon is stored above, so the teardown stops it. The wait itself is bounded
// (STARTUP_TIMEOUT_MS) and only reads a file, so it may finish in the background.
const daemonInfo = await tasks.untilAborted(waitForDaemonInfoAsync({ daemonProcess }));
taskLogger.info(`Daemon is listening on port ${daemonInfo.port}; loaded auth token.`);
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
let eventCollection:
| Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>>
| undefined;
try {
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
runtimePlatform,
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
});
logger.info(
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
);
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
deviceRunSessionId,
remoteConfig: {
agentDeviceRemoteSessionUrl,
agentDeviceRemoteSessionToken: daemonToken,
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
});
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
daemonUrl: `http://127.0.0.1:${daemonPort}`,
daemonToken,
logger,
});
eventCollection = await startAgentDeviceEventCollectionAsync({
ctx,
deviceRunSessionId,
stateDir: AGENT_DEVICE_STATE_DIR,
logger,
});
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
maxDurationSeconds,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
}
: undefined,
});
} finally {
if (webPreview) {
await webPreview.stopAsync();
}
await agentDeviceTunnel.stopAsync();
if (eventCollection) {
await stopAgentDeviceEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
}
await daemonProcess.stopAsync();
}
}),
tasks.signal.throwIfAborted();
agentDeviceTunnel = await startNgrokTunnelAsync({
port: daemonInfo.port,
subdomainPrefix: 'agent-device',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
logger: taskLogger,
});
taskLogger.info(`Tunnel is ready at ${agentDeviceTunnel.url}.`);
return { ...daemonInfo, remoteSessionUrl: agentDeviceTunnel.url };
});
const sessionHostStartup = tasks.run('session host', async taskLogger => {
// A boot cannot be cancelled, so stop waiting for it when startup is aborted.
await tasks.untilAborted(device.booted);
tasks.signal.throwIfAborted();
sessionHost = await startDeviceSessionHostAsync(ctx, {
runtimePlatform,
env,
logger: taskLogger,
timeoutMs: STARTUP_TIMEOUT_MS,
networkCapture: capture.networkCapture,
networkCaptureFields: capture.networkCaptureFields,
});
tasks.signal.throwIfAborted();
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
taskLogger.info(
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
);
return webPreview;
});
try {
const [
{ port: daemonPort, token: daemonToken, remoteSessionUrl: agentDeviceRemoteSessionUrl },
webPreview,
] = await Promise.all([agentDeviceStartup, sessionHostStartup, device.ready]).catch(
(err: unknown) => {
// Also aborts for a `device.ready` that does not come from `tasks.run`.
tasks.abort(err);
throw err;
}
);
logger.info(tasks.summary());
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
deviceRunSessionId,
remoteConfig: {
agentDeviceRemoteSessionUrl,
agentDeviceRemoteSessionToken: daemonToken,
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
});
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
daemonUrl: `http://127.0.0.1:${daemonPort}`,
daemonToken,
logger,
});
eventCollection = await startAgentDeviceEventCollectionAsync({
ctx,
deviceRunSessionId,
stateDir: AGENT_DEVICE_STATE_DIR,
logger,
});
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
maxDurationSeconds,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
}
: undefined,
});
} catch (error) {
sessionFailed = true;
throw error;
} finally {
// Promise.all rejects on the first failure while other tasks can still be starting.
// They stop at their next abort check. Wait for all of them, so the teardown sees
// everything that was started.
await Promise.allSettled([agentDeviceStartup, sessionHostStartup, device.ready]);
const startedDaemon = daemonProcess;
await finishRemoteSessionAsync({
logger,
sessionFailed,
teardown: [
['agent-device tunnel', agentDeviceTunnel?.stopAsync()],
[
'agent-device daemon',
startedDaemon &&
(async () => {
try {
if (eventCollection) {
await stopAgentDeviceEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
}
} finally {
await startedDaemon.stopAsync();
}
})(),
],
['session host', sessionHost?.finishAsync()],
],
});
}
}
export async function startAgentDeviceDaemonAsync({
packageVersion,
env,
logger,
signal,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
/** Kills the install and stops before the daemon starts, when aborted. No git fallback then. */
signal?: AbortSignal;
}): Promise<DetachedProcessHandle> {
const packageSpec = createAgentDevicePackageSpec(packageVersion);
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
@@ -231,13 +285,14 @@ export async function startAgentDeviceDaemonAsync({
try {
const add = resolvePackageAdd(packageManager, packageSpec);
logger.info(`Installing ${packageSpec} with ${add.command}.`);
await spawn(add.command, add.args, { cwd: installDir, env, logger });
await spawn(add.command, add.args, { cwd: installDir, env, logger, signal });
const daemonPath = getInstalledAgentDeviceDaemonPath(installDir);
if (!fs.existsSync(daemonPath)) {
throw new SystemError(`Expected agent-device daemon entry at ${daemonPath}.`);
}
signal?.throwIfAborted();
logger.info(`Launching daemon from ${daemonPath} after ${add.command} install.`);
const daemonProcess = spawnDetached({
command: 'node',
@@ -253,6 +308,10 @@ export async function startAgentDeviceDaemonAsync({
};
} catch (err) {
await fs.promises.rm(installDir, { recursive: true, force: true });
// An abort is not an install problem: stop instead of falling back to git.
if (signal?.aborted) {
throw err;
}
const error = err instanceof Error ? err : new Error(String(err));
const bunVersion = await getBunVersionForDiagnosticsAsync(env);
Sentry.capture(
@@ -275,7 +334,7 @@ export async function startAgentDeviceDaemonAsync({
logger.warn(
`Failed to start daemon from ${packageSpec} via ${packageManager}; falling back to git clone: ${error.message}`
);
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger });
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger, signal });
}
}
@@ -305,17 +364,19 @@ async function startAgentDeviceDaemonFromGitAsync({
packageVersion,
env,
logger,
signal,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
}): Promise<DetachedProcessHandle> {
logger.info(
packageVersion
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
: `Cloning agent-device (latest) into ${SRC_DIR}.`
);
await cloneAgentDeviceAsync({ packageVersion, env, logger });
await cloneAgentDeviceAsync({ packageVersion, env, logger, signal });
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
const install = resolvePackageInstall(packageManager, { production: true });
@@ -324,8 +385,10 @@ async function startAgentDeviceDaemonFromGitAsync({
cwd: SRC_DIR,
env,
logger,
signal,
});
signal?.throwIfAborted();
logger.info('Launching daemon from cloned agent-device source.');
// Git fallback is TypeScript source. The published path runs node on dist JS.
return spawnDetached({
@@ -340,15 +403,18 @@ async function cloneAgentDeviceAsync({
packageVersion,
env,
logger,
signal,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
}): Promise<void> {
const branchArgs = packageVersion ? ['--branch', `v${packageVersion}`] : [];
await spawn('git', ['clone', '--depth', '1', ...branchArgs, AGENT_DEVICE_REPO_URL, SRC_DIR], {
env,
logger,
signal,
});
}
@@ -0,0 +1,267 @@
import { UserError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { type CustomBuildContext } from '../../customBuildContext';
import {
AndroidDeviceName,
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorName, IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
import { withLocalEgressSession } from '../utils/localEgressSession';
import { selectXcodeDeveloperDirectoryAsync } from '../utils/remoteDeviceRunSession';
import {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
} from '../utils/networkCaptureFields';
import { createStartupTasks } from '../utils/startupTasks';
import { downloadBuildAsync } from './downloadBuild';
import { installBuildAsync } from './installBuild';
import {
launchApplicationAsync,
parseLaunchArgsInput,
parseOpenUrlInput,
} from './launchApplication';
import {
getAgentDeviceRemoteSessionEnvOrThrow,
runAgentDeviceRemoteSessionAsync,
} from './startAgentDeviceRemoteSession';
import { startAndroidEmulatorAsync } from './startAndroidEmulator';
import { bootIosSimulatorAsync } from './startIosSimulator';
const ANDROID_DEVICE_NAME = 'EasAndroidDevice01' as AndroidVirtualDeviceName;
/**
* One step for a whole agent-device session: boot the device, download, install and
* launch the app, and start the agent-device daemon and the web preview.
*
* It does the work of eas/start_ios_simulator or eas/start_android_emulator,
* eas/download_build, eas/install_build and eas/launch_application, plus the agent-device
* session, so the parts that do not depend on each other can run at the same time:
*
* boot ─────────────┬─► install ─► launch ─┐
* download ─────────┘ │
* boot ─► session host ─► web preview ─────┼─► ready
* agent-device daemon ─► tunnel ───────────┘
*
* The first failure aborts the rest: the download stops, and nothing installs, launches
* or starts after it. A boot cannot be cancelled, so it can still run when a failed
* step returns.
*/
export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_agent_device_session',
name: 'Start agent-device session',
__metricsId: 'eas/start_agent_device_session',
inputProviders: [
// Device. On iOS a Simulator name or UDID, on Android an AVD hardware profile.
BuildStepInput.createProvider({
id: 'device_identifier',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'system_image_package',
required: false,
defaultValue: AndroidEmulatorUtils.defaultSystemImagePackage,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'lcd_width',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'lcd_height',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'lcd_density',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
// Application. Pass at most one of build_id and application_archive_url.
BuildStepInput.createProvider({
id: 'build_id',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'application_archive_url',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'launch_args',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
BuildStepInput.createProvider({
id: 'open_url',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
// Session.
...createNetworkCaptureInputProviders(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'max_idle_time_minutes',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'max_duration_seconds',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: withLocalEgressSession(async (stepsCtx, { inputs, env, signal }) => {
const { logger, global } = stepsCtx;
const { runtimePlatform } = global;
const isIos = runtimePlatform === BuildRuntimePlatform.DARWIN;
// Validate everything before any expensive work starts.
const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(env);
const buildId = inputs.build_id.value as string | undefined;
const applicationArchiveUrl = inputs.application_archive_url.value as string | undefined;
if (buildId && applicationArchiveUrl) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
'Pass only one of build_id or application_archive_url.'
);
}
const hasApplication = Boolean(buildId || applicationArchiveUrl);
const launchArgs = parseLaunchArgsInput(inputs.launch_args.value);
const openUrl =
inputs.open_url.value === undefined ? undefined : parseOpenUrlInput(inputs.open_url.value);
if (!hasApplication && (launchArgs.length > 0 || openUrl)) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
'launch_args and open_url need an application: pass build_id or application_archive_url.'
);
}
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
const capture = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture.value,
networkCaptureFields: inputs.network_capture_fields.value,
},
{ runtimePlatform }
);
if (isIos) {
// Before the boot, so every Xcode tool below uses the same developer directory.
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const tasks = createStartupTasks(logger);
const booted = tasks.run(
isIos ? 'iOS Simulator boot' : 'Android Emulator boot',
async taskLogger => {
if (isIos) {
await bootIosSimulatorAsync({
deviceIdentifier: deviceIdentifier as IosSimulatorUuid | IosSimulatorName | undefined,
env,
logger: taskLogger,
});
return;
}
await startAndroidEmulatorAsync({
deviceName: ANDROID_DEVICE_NAME,
systemImagePackage: `${inputs.system_image_package.value}`,
deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined,
lcdWidth: inputs.lcd_width.value as number | undefined,
lcdHeight: inputs.lcd_height.value as number | undefined,
lcdDensity: inputs.lcd_density.value as number | undefined,
logcatDirectory: await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
),
env,
logger: taskLogger,
});
}
);
const downloaded = hasApplication
? tasks.run('build download', async taskLogger => {
const { artifactPath } = await downloadBuildAsync({
logger: taskLogger,
...(buildId ? { buildId } : { applicationArchiveUrl: applicationArchiveUrl! }),
graphqlClient: ctx.graphqlClient,
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
extensions: isIos ? ['app'] : ['apk'],
signal: tasks.signal,
});
return artifactPath;
})
: undefined;
// `ready` settles only after everything it started has settled, so the session
// teardown, which waits for it, never leaves a download running. It must also settle
// soon after an abort: it stops waiting for the boot then, and the download stops.
const ready = downloaded
? tasks.run('app install and launch', async taskLogger => {
const [download, boot] = await Promise.allSettled([
downloaded,
tasks.untilAborted(booted),
]);
// After a failure elsewhere, report that failure, not the abort it caused here.
tasks.signal.throwIfAborted();
if (download.status === 'rejected') {
throw download.reason;
}
if (boot.status === 'rejected') {
throw boot.reason;
}
const { applicationIdentifier, activityName } = await installBuildAsync({
artifactPath: download.value,
runtimePlatform,
env,
logger: taskLogger,
});
tasks.signal.throwIfAborted();
await launchApplicationAsync({
applicationIdentifier,
activityName,
launchArgs,
openUrl,
runtimePlatform,
env,
logger: taskLogger,
});
})
: tasks.untilAborted(booted);
await runAgentDeviceRemoteSessionAsync(ctx, {
env,
logger,
signal,
runtimePlatform,
sessionEnv,
packageVersion: inputs.package_version.value as string | undefined,
// A missing or non-positive value disables the idle timeout (opt-in feature).
maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined,
maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined,
capture,
tasks,
device: { booted, ready },
});
}),
});
}
@@ -1,8 +1,10 @@
import { UserError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
BuildFunction,
BuildRuntimePlatform,
type BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
@@ -14,10 +16,12 @@ import path from 'node:path';
import {
AndroidDeviceName,
AndroidDeviceSerialId,
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../../utils/AndroidEmulatorUtils';
import { retryAsync } from '../../utils/retry';
import { resolveAndroidEmulatorLocalEgressAsync } from '../utils/androidLocalEgress';
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
@@ -80,140 +84,20 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
);
outputs.logcat_directory.set(logcatDirectory);
if (env.EAS_NO_EMULATOR_HOST_SUPPORT_CHECK !== '1') {
await assertAndroidEmulatorHostSupportAsync({ env });
} else {
logger.info(
'Skipping Android emulator host support check because $EAS_NO_EMULATOR_HOST_SUPPORT_CHECK is enabled.'
);
}
try {
const availableDevices = await AndroidEmulatorUtils.getAvailableDevicesAsync({ env });
logger.info(`Available Android devices:\n- ${availableDevices.join(`\n- `)}`);
} catch (error) {
logger.info('Failed to list available Android devices.', error);
} finally {
logger.info('');
}
const deviceName = `${inputs.device_name.value}` as AndroidVirtualDeviceName;
const systemImagePackage = `${inputs.system_image_package.value}`;
// We can cast because allowedValueTypeName validated this is a string.
const deviceIdentifier = inputs.device_identifier.value as AndroidDeviceName | undefined;
const lcdWidth = inputs.lcd_width.value as number | undefined;
const lcdHeight = inputs.lcd_height.value as number | undefined;
const lcdDensity = inputs.lcd_density.value as number | undefined;
const shouldAdjustAnimationScale =
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== 'false' &&
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== '0';
if (!shouldAdjustAnimationScale) {
logger.info(
'Skipping Android emulator animation scale adjustments because $ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE is disabled.'
);
}
logger.info('Making sure system image is installed');
await retryAsync(
async () => {
await spawn('sdkmanager', [systemImagePackage], {
env,
logger,
});
},
{
const { serialId, emulatorPromise, shouldAdjustAnimationScale } =
await startAndroidEmulatorAsync({
deviceName,
systemImagePackage: `${inputs.system_image_package.value}`,
// We can cast because allowedValueTypeName validated this is a string.
deviceIdentifier: inputs.device_identifier.value as AndroidDeviceName | undefined,
lcdWidth: inputs.lcd_width.value as number | undefined,
lcdHeight: inputs.lcd_height.value as number | undefined,
lcdDensity: inputs.lcd_density.value as number | undefined,
logcatDirectory,
env,
logger,
retryOptions: {
retries: 3, // Retry 3 times
retryIntervalMs: 1_000,
},
}
);
let emulatorPromise = null;
let serialId = null;
await retryAsync(
async attemptCount => {
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
const attempt = attemptCount + 1;
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
const attemptSuffix = attempt > 1 ? ` (attempt ${attempt}/${maxAttempts})` : '';
let attemptSerialId = null;
try {
logger.info(`Creating emulator device${attemptSuffix}.`);
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage,
deviceIdentifier: deviceIdentifier ?? null,
lcdWidth: lcdWidth ?? null,
lcdHeight: lcdHeight ?? null,
lcdDensity: lcdDensity ?? null,
env,
logger,
});
logger.info(`Starting emulator device${attemptSuffix}.`);
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName,
env,
logcatDirectory,
});
attemptSerialId = startResult.serialId;
await AndroidEmulatorUtils.waitForReadyAsync({
env,
serialId: attemptSerialId,
timeoutMs,
logger,
});
if (shouldAdjustAnimationScale) {
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
env,
logger,
serialId: attemptSerialId,
});
}
logger.info(`${deviceName} is ready.`);
serialId = attemptSerialId;
emulatorPromise = startResult.emulatorPromise;
} catch (err) {
logger.warn(
{ err },
`${deviceName} failed to start on attempt ${attempt}/${maxAttempts}.`
);
try {
if (attemptSerialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId: attemptSerialId,
deviceName,
env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName,
env,
});
}
} catch (cleanupErr) {
logger.warn({ err: cleanupErr }, `Failed to clean up ${deviceName}.`);
}
throw err;
}
},
{
logger,
retryOptions: {
retries: ANDROID_STARTUP_RETRIES_COUNT,
retryIntervalMs: 1_000,
},
}
);
if (!serialId || !emulatorPromise) {
throw new Error(`Failed to start emulator ${deviceName}.`);
}
});
const count = Number(inputs.count.value ?? 1);
if (count > 1) {
@@ -306,6 +190,167 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
});
}
/**
* Checks the host, installs the system image, then creates and starts one emulator and
* waits until it is ready, with retries.
*/
export async function startAndroidEmulatorAsync({
deviceName,
systemImagePackage,
deviceIdentifier,
lcdWidth,
lcdHeight,
lcdDensity,
logcatDirectory,
env,
logger,
}: {
deviceName: AndroidVirtualDeviceName;
systemImagePackage: string;
deviceIdentifier?: AndroidDeviceName;
lcdWidth?: number;
lcdHeight?: number;
lcdDensity?: number;
logcatDirectory: string;
env: BuildStepEnv;
logger: bunyan;
}): Promise<{
serialId: AndroidDeviceSerialId;
emulatorPromise: Promise<unknown>;
shouldAdjustAnimationScale: boolean;
}> {
if (env.EAS_NO_EMULATOR_HOST_SUPPORT_CHECK !== '1') {
await assertAndroidEmulatorHostSupportAsync({ env });
} else {
logger.info(
'Skipping Android emulator host support check because $EAS_NO_EMULATOR_HOST_SUPPORT_CHECK is enabled.'
);
}
try {
const availableDevices = await AndroidEmulatorUtils.getAvailableDevicesAsync({ env });
logger.info(`Available Android devices:\n- ${availableDevices.join(`\n- `)}`);
} catch (error) {
logger.info('Failed to list available Android devices.', error);
} finally {
logger.info('');
}
const shouldAdjustAnimationScale =
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== 'false' &&
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== '0';
if (!shouldAdjustAnimationScale) {
logger.info(
'Skipping Android emulator animation scale adjustments because $ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE is disabled.'
);
}
const localEgress = await resolveAndroidEmulatorLocalEgressAsync();
logger.info('Making sure system image is installed');
await retryAsync(
async () => {
await spawn('sdkmanager', [systemImagePackage], {
env,
logger,
});
},
{
logger,
retryOptions: {
retries: 3, // Retry 3 times
retryIntervalMs: 1_000,
},
}
);
let emulatorPromise = null;
let serialId = null;
await retryAsync(
async attemptCount => {
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
const attempt = attemptCount + 1;
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
const attemptSuffix = attempt > 1 ? ` (attempt ${attempt}/${maxAttempts})` : '';
let attemptSerialId = null;
try {
logger.info(`Creating emulator device${attemptSuffix}.`);
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage,
deviceIdentifier: deviceIdentifier ?? null,
lcdWidth: lcdWidth ?? null,
lcdHeight: lcdHeight ?? null,
lcdDensity: lcdDensity ?? null,
env,
logger,
});
logger.info(`Starting emulator device${attemptSuffix}.`);
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName,
env,
logcatDirectory,
...(localEgress ? { launchGate: localEgress.launchGate } : {}),
});
attemptSerialId = startResult.serialId;
await AndroidEmulatorUtils.waitForReadyAsync({
env,
serialId: attemptSerialId,
timeoutMs,
logger,
...(localEgress ? { networkReadyTarget: localEgress.networkReadyTarget } : {}),
});
if (shouldAdjustAnimationScale) {
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
env,
logger,
serialId: attemptSerialId,
});
}
await localEgress?.configureBootedEmulatorAsync({ serialId: attemptSerialId, logger });
logger.info(`${deviceName} is ready.`);
serialId = attemptSerialId;
emulatorPromise = startResult.emulatorPromise;
} catch (err) {
logger.warn({ err }, `${deviceName} failed to start on attempt ${attempt}/${maxAttempts}.`);
try {
if (attemptSerialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId: attemptSerialId,
deviceName,
env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName,
env,
});
}
} catch (cleanupErr) {
logger.warn({ err: cleanupErr }, `Failed to clean up ${deviceName}.`);
}
throw err;
}
},
{
logger,
retryOptions: {
retries: ANDROID_STARTUP_RETRIES_COUNT,
retryIntervalMs: 1_000,
},
}
);
if (!serialId || !emulatorPromise) {
throw new Error(`Failed to start emulator ${deviceName}.`);
}
return { serialId, emulatorPromise, shouldAdjustAnimationScale };
}
async function assertAndroidEmulatorHostSupportAsync({
env,
}: {
@@ -19,6 +19,11 @@ import {
uploadRemoteSessionConfigWithLocalEgressAsync,
withLocalEgressSession,
} from '../utils/localEgressSession';
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
} from '../utils/networkCaptureFields';
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
import {
@@ -32,13 +37,13 @@ import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
import {
createServeSimLaunchInputProviders,
describeServeSimLaunch,
finishRemoteSessionAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
@@ -63,6 +68,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
__metricsId: 'eas/start_appium_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
...createNetworkCaptureInputProviders(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -89,6 +95,13 @@ export function createStartAppiumRemoteSessionBuildFunction(
},
{ runtimePlatform }
);
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
{ runtimePlatform }
);
const versionSpec = resolveAppium3VersionSpec(packageVersion);
logger.info(
@@ -138,7 +151,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
logger,
});
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let sessionFailed = false;
try {
appiumTunnel = await startNgrokTunnelAsync({
port: APPIUM_PORT,
@@ -154,16 +168,18 @@ export function createStartAppiumRemoteSessionBuildFunction(
if (launchDescription) {
logger.info(launchDescription);
}
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
sessionHost = await startDeviceSessionHostAsync(ctx, {
runtimePlatform,
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
networkCapture,
networkCaptureFields,
});
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
@@ -197,16 +213,29 @@ export function createStartAppiumRemoteSessionBuildFunction(
}
: undefined,
});
} catch (error) {
sessionFailed = true;
throw error;
} finally {
if (webPreview) {
await webPreview.stopAsync();
}
if (appiumTunnel) {
await appiumTunnel.stopAsync();
}
await eventCollection.stopAsync();
await appiumProcess.stopAsync();
await fs.promises.rm(appiumHome, { recursive: true, force: true });
await finishRemoteSessionAsync({
logger,
sessionFailed,
teardown: [
['Appium tunnel', appiumTunnel?.stopAsync()],
[
'Appium server',
(async () => {
try {
await eventCollection.stopAsync();
} finally {
await appiumProcess.stopAsync();
await fs.promises.rm(appiumHome, { recursive: true, force: true });
}
})(),
],
['session host', sessionHost?.finishAsync()],
],
});
}
}),
});
@@ -18,6 +18,11 @@ import {
uploadRemoteSessionConfigWithLocalEgressAsync,
withLocalEgressSession,
} from '../utils/localEgressSession';
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
} from '../utils/networkCaptureFields';
import { Sentry } from '../../sentry';
import {
PackageManager,
@@ -32,13 +37,13 @@ import {
createServeSimLaunchInputProviders,
describeServeSimLaunch,
ensureFfmpegInstalledOnceAsync,
finishRemoteSessionAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
@@ -75,6 +80,7 @@ export function createStartArgentRemoteSessionBuildFunction(
__metricsId: 'eas/start_argent_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
...createNetworkCaptureInputProviders(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -115,6 +121,13 @@ export function createStartArgentRemoteSessionBuildFunction(
},
{ runtimePlatform }
);
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
{ runtimePlatform }
);
logger.info(
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
@@ -184,6 +197,7 @@ export function createStartArgentRemoteSessionBuildFunction(
stateDir: ARGENT_STATE_DIR,
ancestorPid: argentServer.pid,
timeoutMs: STARTUP_TIMEOUT_MS,
getExitError: argentServer.getExitError,
});
toolServerPort = toolServerState.port;
toolServerToken = toolServerState.token;
@@ -216,7 +230,8 @@ export function createStartArgentRemoteSessionBuildFunction(
});
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let sessionFailed = false;
try {
toolsTunnel = await startNgrokTunnelAsync({
port: toolServerPort,
@@ -233,16 +248,18 @@ export function createStartArgentRemoteSessionBuildFunction(
if (launchDescription) {
logger.info(launchDescription);
}
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
sessionHost = await startDeviceSessionHostAsync(ctx, {
runtimePlatform,
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
networkCapture,
networkCaptureFields,
});
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
logger.info(
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
);
@@ -276,23 +293,46 @@ export function createStartArgentRemoteSessionBuildFunction(
}
: undefined,
});
} catch (error) {
sessionFailed = true;
throw error;
} finally {
if (webPreview) {
await webPreview.stopAsync();
}
if (toolsTunnel) {
await toolsTunnel.stopAsync();
}
await stopArgentEventCollectionSafelyAsync({ eventCollection, deviceRunSessionId, logger });
artifactPollAbortController.abort();
try {
await artifactPollingPromise;
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not finish Argent remote session artifact polling', error);
logger.warn({ err: error }, 'Could not finish Argent remote session artifact polling.');
}
await argentServer.stopAsync();
await finishRemoteSessionAsync({
logger,
sessionFailed,
teardown: [
['Argent tunnel', toolsTunnel?.stopAsync()],
[
'Argent tool-server',
(async () => {
try {
await stopArgentEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
artifactPollAbortController.abort();
try {
await artifactPollingPromise;
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture(
'Could not finish Argent remote session artifact polling',
error
);
logger.warn(
{ err: error },
'Could not finish Argent remote session artifact polling.'
);
}
} finally {
await argentServer.stopAsync();
}
})(),
],
['session host', sessionHost?.finishAsync()],
],
});
}
}),
});
@@ -371,16 +411,26 @@ export async function waitForArgentToolServerStateAsync({
ancestorPid,
timeoutMs,
pollIntervalMs = 1_000,
getExitError,
}: {
stateDir: string;
ancestorPid: number;
timeoutMs: number;
pollIntervalMs?: number;
getExitError?: () => Error | undefined;
}): Promise<ArgentToolServerState> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
const throwIfExited = (): void => {
const error = getExitError?.();
if (error) {
throw new SystemError(`Argent exited before becoming ready: ${error.message}`);
}
};
while (Date.now() < deadline) {
throwIfExited();
let matchingState: ArgentToolServerState | undefined;
try {
const stateFileNames = (await fs.promises.readdir(stateDir)).filter(
name => name.startsWith('tool-server') && name.endsWith('.json')
@@ -391,7 +441,8 @@ export async function waitForArgentToolServerStateAsync({
await fs.promises.readFile(path.join(stateDir, stateFileName), 'utf8')
);
if (await isProcessDescendantOfAsync(state.pid, ancestorPid)) {
return state;
matchingState = state;
break;
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT' && !(err instanceof SystemError)) {
@@ -402,6 +453,11 @@ export async function waitForArgentToolServerStateAsync({
} catch (err) {
lastError = err;
}
// Keep process failures outside the retryable state-file read errors.
throwIfExited();
if (matchingState) {
return matchingState;
}
await sleepAsync(pollIntervalMs);
}
@@ -47,59 +47,18 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
}),
],
fn: async ({ logger }, { inputs, env }) => {
try {
const availableDevices = await IosSimulatorUtils.getAvailableDevicesAsync({
env,
filter: 'available',
});
logger.info(
`Available Simulator devices:\n- ${availableDevices
.map(device => device.displayName)
.join(`\n- `)}`
);
} catch (error) {
logger.info('Failed to list available Simulator devices.', error);
} finally {
logger.info('');
}
const deviceIdentifierInput = inputs.device_identifier.value?.toString() as
| IosSimulatorUuid
| IosSimulatorName
| undefined;
const originalDeviceIdentifier =
deviceIdentifierInput ?? (await findMostGenericIphoneUuidAsync({ env }));
const enableAccessibilitySettings = Boolean(inputs.enable_accessibility_settings.value);
if (!originalDeviceIdentifier) {
throw new Error('Could not find an iPhone among available simulator devices.');
}
if (enableAccessibilitySettings) {
await IosSimulatorUtils.enableAccessibilitySettingsAsync({
deviceIdentifier: originalDeviceIdentifier,
const { deviceIdentifier: originalDeviceIdentifier, displayName: formattedDevice } =
await bootIosSimulatorAsync({
deviceIdentifier: deviceIdentifierInput,
enableAccessibilitySettings,
env,
logger,
});
}
const udid = await bootWithLocalEgressAsync({
deviceIdentifier: originalDeviceIdentifier,
env,
logger,
});
try {
await IosSimulatorUtils.disableApsdAsync({ udid, env });
} catch (err) {
logger.warn({ err }, 'Failed to disable apsd in the Simulator.');
}
await IosSimulatorUtils.waitForReadyAsync({ udid, env });
logger.info('');
const device = await IosSimulatorUtils.getDeviceAsync({ udid, env });
const formattedDevice = device?.displayName ?? originalDeviceIdentifier;
logger.info(`${formattedDevice} is ready.`);
const count = Number(inputs.count.value ?? 1);
if (count > 1) {
@@ -150,6 +109,67 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
});
}
/**
* Boots one iOS Simulator and waits until it is ready: the requested device, or the
* most generic iPhone when none is given.
*/
export async function bootIosSimulatorAsync({
deviceIdentifier: deviceIdentifierInput,
enableAccessibilitySettings = false,
env,
logger,
}: {
deviceIdentifier?: IosSimulatorUuid | IosSimulatorName;
enableAccessibilitySettings?: boolean;
env: BuildStepEnv;
logger: bunyan;
}): Promise<{
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
udid: IosSimulatorUuid;
displayName: string;
}> {
try {
const availableDevices = await IosSimulatorUtils.getAvailableDevicesAsync({
env,
filter: 'available',
});
logger.info(
`Available Simulator devices:\n- ${availableDevices
.map(device => device.displayName)
.join(`\n- `)}`
);
} catch (error) {
logger.info('Failed to list available Simulator devices.', error);
} finally {
logger.info('');
}
const deviceIdentifier = deviceIdentifierInput ?? (await findMostGenericIphoneUuidAsync({ env }));
if (!deviceIdentifier) {
throw new Error('Could not find an iPhone among available simulator devices.');
}
if (enableAccessibilitySettings) {
await IosSimulatorUtils.enableAccessibilitySettingsAsync({ deviceIdentifier, env });
}
const udid = await bootWithLocalEgressAsync({ deviceIdentifier, env, logger });
try {
await IosSimulatorUtils.disableApsdAsync({ udid, env });
} catch (err) {
logger.warn({ err }, 'Failed to disable apsd in the Simulator.');
}
await IosSimulatorUtils.waitForReadyAsync({ udid, env });
logger.info('');
const device = await IosSimulatorUtils.getDeviceAsync({ udid, env });
const displayName = device?.displayName ?? deviceIdentifier;
logger.info(`${displayName} is ready.`);
return { deviceIdentifier, udid, displayName };
}
/**
* Boot a device with the local egress environment in place before anything
* inside it starts. `simctl boot` returns once the simulator's launchd is up
@@ -4,6 +4,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { startAndroidLocalEgressAsync } from '../utils/androidLocalEgress';
import {
CHISEL_VERSION,
LOCAL_EGRESS_HANDOFF_PATH,
@@ -79,8 +80,9 @@ function awaitLocalEgressAcquisitionAsync<T>(
* at boot. Once a simulator boots, `eas/start_ios_simulator` also sets proxy
* environment variables inside it for clients that read them (gRPC, libcurl)
* and installs the local egress guard, which refuses connections that ignore
* both. The shared session cleanup releases the resources started here when
* the session ends, with a job finalizer as a fallback.
* both. On Linux it starts the Android emulator relay and fence instead; see
* androidLocalEgress.ts. The shared session cleanup releases the resources
* started here when the session ends, with a job finalizer as a fallback.
*/
export function createStartLocalEgressBuildFunction(): BuildFunction {
return new BuildFunction({
@@ -88,13 +90,15 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
id: 'start_local_egress',
name: 'Start local egress',
__metricsId: 'eas/start_local_egress',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
fn: async ({ logger }, { env, signal }) => {
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX],
fn: async ({ logger, global }, { env, signal }) => {
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
const isAndroid = global?.runtimePlatform === BuildRuntimePlatform.LINUX;
let workDir: string | undefined;
let server: DetachedProcessHandle | undefined;
let tunnel: NgrokTunnelHandle | undefined;
let android: { stopAsync: () => Promise<void> } | undefined;
let finishSetup!: () => void;
const setupFinished = new Promise<void>(resolve => {
finishSetup = resolve;
@@ -105,6 +109,7 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
await setupFinished;
const results = await Promise.allSettled([
Promise.resolve().then(() => stopLocalEgressGuardRelaysAsync(logger)),
Promise.resolve().then(() => android?.stopAsync()),
Promise.resolve().then(() => tunnel?.stopAsync()),
Promise.resolve().then(() => server?.stopAsync()),
]);
@@ -169,6 +174,35 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
);
startupSignal.throwIfAborted();
if (isAndroid) {
if (server.pid === undefined) {
throw new Error('The reverse tunnel server has no process id.');
}
android = await startAndroidLocalEgressAsync({
env,
logger,
workDir,
chiselPid: server.pid,
proxyPort: LOCAL_EGRESS_PROXY_PORT,
controlPort,
});
startupSignal.throwIfAborted();
await writeLocalEgressHandoffAsync({
url: tunnel.url,
token: credentials.password,
fingerprint: started.fingerprint,
port: LOCAL_EGRESS_PROXY_PORT,
platform: 'android',
});
startupSignal.throwIfAborted();
logger.info(
"Local egress is configured for the Android emulator. Its TCP traffic goes through the emulator's " +
'proxy and fails until the EAS CLI egress client connects, then exits from that machine. ' +
'Traffic that cannot use the tunnel, such as UDP, is refused and reported here.'
);
return;
}
const { service } = await configureSystemProxyAsync({
env,
logger,
@@ -3,6 +3,7 @@ import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@exp
import { graphql } from 'gql.tada';
import { CustomBuildContext } from '../../customBuildContext';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import { withLogPhaseAsync } from '../../utils/logPhase';
import { startSandboxDaemonAsync } from '../utils/sandboxDaemon';
@@ -76,19 +77,7 @@ export async function markSandboxReadyAsync(
): Promise<void> {
signal?.throwIfAborted();
const result = await ctx.graphqlClient
.mutation(
MARK_SANDBOX_READY_MUTATION,
{ sandboxId },
signal
? {
fetch: (input, init) =>
fetch(input, {
...init,
signal: init?.signal ? AbortSignal.any([signal, init.signal]) : signal,
}),
}
: undefined
)
.mutation(MARK_SANDBOX_READY_MUTATION, { sandboxId }, graphqlAbortContext(signal))
.toPromise();
signal?.throwIfAborted();
if (result.error) {
@@ -0,0 +1,26 @@
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
import { Sentry } from '../../sentry';
import { ServeSimLogsRecorder } from '../utils/serveSimLogsRecorder';
export function createStartServeSimLogsBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_serve_sim_logs',
name: 'Start serve-sim simulator logs',
__metricsId: 'eas/start_serve_sim_logs',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
fn: async ({ logger }) => {
try {
await ServeSimLogsRecorder.startAsync({ logger });
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not start serve-sim simulator logs', error);
logger.warn(
{ err: error },
'Could not start simulator log collection; the session will continue.'
);
}
},
});
}
@@ -6,6 +6,11 @@ import {
} from '@expo/steps';
import { CustomBuildContext } from '../../customBuildContext';
import { startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
} from '../utils/networkCaptureFields';
import {
uploadRemoteSessionConfigWithLocalEgressAsync,
withLocalEgressSession,
@@ -17,7 +22,6 @@ import {
getNgrokTunnelDomainOrThrow,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
startDeviceWebPreviewWithTunnelAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
@@ -33,6 +37,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
__metricsId: 'eas/start_serve_sim_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
...createNetworkCaptureInputProviders(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -58,6 +63,13 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
},
{ runtimePlatform }
);
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
{ runtimePlatform }
);
logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`);
const launchDescription = describeServeSimLaunch(launch);
@@ -69,9 +81,8 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
const sessionHost = await startDeviceSessionHostAsync(ctx, {
runtimePlatform,
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
@@ -79,19 +90,28 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
networkCapture,
networkCaptureFields,
});
logger.info(`Preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`);
try {
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
logger.info(`Preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`);
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
deviceRunSessionId,
// WEB_PREVIEW_ONLY sessions read webPreviewUrl and webPreviewToken. Legacy SERVE_SIM
// sessions read previewUrl and previewToken. The server drops the keys that the
// session type does not use.
remoteConfig: {
webPreviewUrl: webPreview.previewPageUrl,
previewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { previewToken: webPreview.previewToken } : {}),
...(webPreview.previewToken
? { webPreviewToken: webPreview.previewToken, previewToken: webPreview.previewToken }
: {}),
},
logger,
});
@@ -104,7 +124,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
signal,
});
} finally {
await webPreview.stopAsync();
await sessionHost.finishAsync();
}
}),
});
@@ -0,0 +1,340 @@
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import path from 'node:path';
import limitFactory from 'promise-limit';
import { z } from 'zod';
import { Sentry } from '../../sentry';
import { AscApiClient, AscApiRequestError } from '../utils/ios/AscApiClient';
import { AscApiUtils } from '../utils/ios/AscApiUtils';
export function createUpdateTestFlightMetadataBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'update_testflight_metadata',
name: 'Update TestFlight metadata',
__metricsId: 'eas/update_testflight_metadata',
inputProviders: [
BuildStepInput.createProvider({
id: 'asc_api_key_path',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'build_upload_id',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'changelog',
required: false,
defaultValue: '',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'groups',
required: false,
defaultValue: [],
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
],
fn: async (ctx, { inputs }) => {
const parsedInputs = z
.object({
asc_api_key_path: z.string(),
build_upload_id: z.string(),
changelog: z.string(),
groups: z.array(z.string()),
})
.parse({
asc_api_key_path: inputs.asc_api_key_path.value,
build_upload_id: inputs.build_upload_id.value,
changelog: inputs.changelog.value,
groups: inputs.groups.value,
});
const keyPath = path.resolve(ctx.workingDirectory, parsedInputs.asc_api_key_path);
const key = await AscApiUtils.loadApiKeyAsync({ keyPath });
const client = new AscApiClient({ key, logger: ctx.logger });
await updateTestFlightMetadataAsync({
client,
buildUploadId: parsedInputs.build_upload_id,
changelog: parsedInputs.changelog,
groups: parsedInputs.groups,
logger: ctx.logger,
});
ctx.logger.info('TestFlight metadata updated.');
},
});
}
export async function updateTestFlightMetadataAsync({
client,
buildUploadId,
changelog,
groups,
logger,
}: {
client: AscApiClient;
buildUploadId: string;
changelog: string;
groups: string[];
logger: bunyan;
}): Promise<void> {
const { data: upload } = await client.getAsync(
'/v1/buildUploads/:id',
{
'fields[buildUploads]': ['state', 'build'],
include: ['build'],
},
{ id: buildUploadId }
);
const buildId = upload.relationships?.build?.data?.id;
if (upload.attributes?.state?.state !== 'COMPLETE' || !buildId) {
throw new UserError(
'EAS_TESTFLIGHT_BUILD_NOT_READY',
'The uploaded build is not ready for TestFlight metadata. Run eas/upload_to_asc with wait_for_processing: true first.'
);
}
logger.info(`Updating TestFlight metadata for Apple build ${buildId}...`);
const { data: app } = await client.getAsync('/v1/builds/:id/app', {}, { id: buildId });
const limit = limitFactory<void>(1);
const results = await Promise.allSettled([
limit(async () => {
if (!changelog) {
return;
}
logger.info(`Updating changelog: ${JSON.stringify(changelog)}`);
const primaryLocale = app.attributes?.primaryLocale || 'en-US';
if (!app.attributes?.primaryLocale) {
logger.warn('App Store Connect did not return the primary locale; using "en-US".');
}
logger.info(`Primary locale: ${JSON.stringify(primaryLocale)}.`);
const localizations = await AscApiUtils.getAllBetaBuildLocalizationsAsync({
client,
buildId,
});
const localizationLimit = limitFactory<void>(1);
const updates = localizations.map(localization =>
localizationLimit(async () => {
const label = `${JSON.stringify(localization.attributes?.locale ?? 'unknown')} (${localization.id})`;
try {
await client.patchAsync(
'/v1/betaBuildLocalizations/:id',
{
data: {
type: 'betaBuildLocalizations',
id: localization.id,
attributes: { whatsNew: changelog },
},
},
{ id: localization.id }
);
} catch (error) {
logger.error(`❌ Locale ${label}: changelog update failed. ${String(error)}`);
throw error;
}
logger.info(`✅ Locale ${label}: changelog updated.`);
})
);
if (!localizations.some(localization => localization.attributes?.locale === primaryLocale)) {
updates.push(
localizationLimit(async () => {
try {
await client.postAsync('/v1/betaBuildLocalizations', {
data: {
type: 'betaBuildLocalizations',
attributes: { locale: primaryLocale, whatsNew: changelog },
relationships: { build: { data: { type: 'builds', id: buildId } } },
},
});
} catch (error) {
logger.error(
`❌ Locale ${JSON.stringify(primaryLocale)}: localization creation failed. ${String(error)}`
);
throw error;
}
logger.info(`✅ Locale ${JSON.stringify(primaryLocale)}: localization created.`);
})
);
}
const localizationResults = await Promise.allSettled(updates);
const localizationFailures = localizationResults.filter(
result => result.status === 'rejected'
);
if (localizationFailures.length === 1) {
throw localizationFailures[0].reason;
}
if (localizationFailures.length > 1) {
throw new AggregateError(
localizationFailures.map(failure => failure.reason),
`Failed to update TestFlight localizations: ${localizationFailures.map(failure => String(failure.reason)).join('; ')}`
);
}
}),
limit(async () => {
if (!groups.length) {
return;
}
logger.info(`Apple app: ${app.id}. Requested groups: ${JSON.stringify(groups)}.`);
const requestedNames = new Set(groups);
const allGroups = await AscApiUtils.getAllBetaGroupsAsync({ client, appId: app.id });
const requestedGroups = allGroups.filter(group =>
requestedNames.has(group.attributes?.name ?? '')
);
const foundNames = new Set(requestedGroups.map(group => group.attributes?.name));
const missingNames = [...requestedNames].filter(name => !foundNames.has(name));
logger.info(`Found ${requestedGroups.length} TestFlight group(s).`);
const assignedGroups = requestedGroups.length
? await AscApiUtils.getAllBetaGroupsAsync({
client,
buildId,
})
: [];
const assignedIds = new Set(assignedGroups.map(group => group.id));
let internalBuildStateVerification: Promise<void> | undefined;
const groupLimit = limitFactory<void>(1);
const groupResults = await Promise.allSettled(
requestedGroups.map(group =>
groupLimit(async () => {
const label = `${JSON.stringify(group.attributes?.name)} (${group.id})`;
if (assignedIds.has(group.id)) {
logger.info(`✅ Group ${label}: build already assigned; no assignment needed.`);
return;
}
if (
group.attributes?.isInternalGroup === true &&
group.attributes.hasAccessToAllBuilds === true
) {
logger.info(
`✅ Group ${label}: automatic access to all builds; no assignment needed.`
);
return;
}
try {
await client.postAsync(
'/v1/builds/:id/relationships/betaGroups',
{
data: [{ type: 'betaGroups', id: group.id }],
},
{ id: buildId }
);
} catch (error) {
logger.error(`❌ Group ${label}: assignment failed. ${String(error)}`);
internalBuildStateVerification ??=
verifyInternalBuildStateValidForGroupAssignmentAsync({
client,
buildId,
appId: app.id,
logger,
error,
});
await internalBuildStateVerification;
if (isInternalGroupAssignmentError(error)) {
throw new UserError(
'EAS_TESTFLIGHT_INTERNAL_GROUP_ASSIGNMENT_FAILED',
"App Store Connect can't add this build to a requested internal TestFlight group. " +
"Internal groups that automatically receive new builds can't be assigned to manually. " +
'Remove the group from the list, or turn off automatic distribution in App Store Connect. ' +
`Manage groups at https://appstoreconnect.apple.com/apps/${app.id}/testflight`,
{ cause: error }
);
}
throw error;
}
logger.info(`✅ Group ${label}: assignment completed.`);
})
)
);
const groupFailures = groupResults.filter(result => result.status === 'rejected');
if (missingNames.length) {
const error = new UserError(
'EAS_TESTFLIGHT_GROUPS_NOT_FOUND',
`The following TestFlight group${missingNames.length > 1 ? 's were' : ' was'} not found in App Store Connect: ${missingNames.map(name => `"${name}"`).join(', ')}. Check the group names and try again.`
);
logger.error(`❌ ${error.message}`);
groupFailures.push({ status: 'rejected', reason: error });
}
if (groupFailures.length === 1) {
throw groupFailures[0].reason;
}
if (groupFailures.length > 1) {
throw new AggregateError(
groupFailures.map(failure => failure.reason),
`Failed to assign TestFlight groups: ${groupFailures.map(failure => String(failure.reason)).join('; ')}`
);
}
}),
]);
const failures = results.filter(result => result.status === 'rejected');
if (failures.length === 1) {
throw failures[0].reason;
}
if (failures.length > 1) {
throw new AggregateError(
failures.map(failure => failure.reason),
`Failed to update the TestFlight changelog and groups: ${failures
.map(failure => String(failure.reason))
.join('; ')}`
);
}
}
async function verifyInternalBuildStateValidForGroupAssignmentAsync({
client,
appId,
buildId,
logger,
error,
}: {
client: AscApiClient;
appId: string;
buildId: string;
logger: bunyan;
error: unknown;
}): Promise<void> {
let state = 'UNKNOWN';
try {
const { data } = await client.getAsync('/v1/builds/:id/buildBetaDetail', {}, { id: buildId });
state = data.attributes.internalBuildState;
} catch (diagnosticError) {
logger.warn(`Could not read the TestFlight state: ${String(diagnosticError)}`);
}
logger.info(`Apple build ${buildId}: internal TestFlight state = ${state}.`);
Sentry.capture(`TestFlight group assignment failed (state: ${state})`, {
level: 'error',
tags: { step: 'eas/update_testflight_metadata', internal_build_state: state },
extras: { buildId, assignmentError: String(error) },
});
if (state === 'MISSING_EXPORT_COMPLIANCE' || state === 'IN_EXPORT_COMPLIANCE_REVIEW') {
throw new UserError(
'EAS_TESTFLIGHT_GROUP_ASSIGNMENT_FAILED',
`Apple could not add the uploaded build to a TestFlight group (state: ${state}). ` +
(state === 'MISSING_EXPORT_COMPLIANCE'
? 'Complete the export compliance questions for this build in App Store Connect. For future builds, set ITSAppUsesNonExemptEncryption in the iOS Info.plist to the value that matches your app. '
: 'Wait for Apple to approve the export compliance information, then assign the group in App Store Connect. ') +
`The binary is already uploaded; do not upload it again to fix group assignment. Manage this build at https://appstoreconnect.apple.com/apps/${appId}/testflight`,
{ cause: error }
);
}
}
// Apple returns a generic 422 code, so match the title or detail too.
function isInternalGroupAssignmentError(error: unknown): boolean {
if (error instanceof AggregateError) {
return error.errors.some(isInternalGroupAssignmentError);
}
return (
error instanceof AscApiRequestError &&
error.status === 422 &&
error.code === 'ENTITY_UNPROCESSABLE' &&
(error.responseJson.title === 'Builds cannot be assigned to this internal group.' ||
error.responseJson.detail === 'Cannot add internal group to a build.')
);
}
@@ -1,53 +1,17 @@
import { SystemError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import { createReadStream } from 'node:fs';
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import limitFactory from 'promise-limit';
import { z } from 'zod';
import { type CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { formatBytes } from '../../utils/artifacts';
import { uploadDeviceRunSessionArtifactAsync } from '../utils/deviceRunSessionArtifacts';
import {
parseDeviceScreenRecordings,
uploadDeviceRunSessionScreenRecordingsAsync,
} from '../utils/deviceRunSessionScreenRecordings';
import { getDeviceRunSessionIdOrThrow } from '../utils/remoteDeviceRunSession';
const RecordingsSchema = z.array(
z.object({
udid: z.string(),
deviceName: z.string(),
runtimeDisplayName: z.string(),
directory: z.string(),
})
);
const RecordingManifestSchema = z.object({
firstFrameWallClock: z.object({
iso8601: z.string(),
}),
width: z.number().int().positive(),
height: z.number().int().positive(),
recording: z.string(),
});
const recordingStartTimeFormatter = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3,
hourCycle: 'h23',
timeZone: 'UTC',
timeZoneName: 'short',
});
export function createUploadDeviceRunSessionScreenRecordingsBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
@@ -65,69 +29,16 @@ export function createUploadDeviceRunSessionScreenRecordingsBuildFunction(
}),
],
fn: async ({ logger }, { env, inputs }) => {
const result = RecordingsSchema.safeParse(inputs.recordings_json.value ?? []);
if (!result.success) {
throw new SystemError('Invalid iOS Simulator recordings input.', {
cause: result.error,
});
}
const recordings = result.data;
const recordings = parseDeviceScreenRecordings(inputs.recordings_json.value ?? []);
if (recordings.length === 0) {
logger.info('No iOS Simulator recordings found; skipping uploads.');
logger.info('No device screen recordings found; skipping uploads.');
return;
}
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const limit = limitFactory(5);
await Promise.all(
recordings.map(recording =>
limit(async () => {
try {
const metadata = RecordingManifestSchema.parse(
JSON.parse(await readFile(path.join(recording.directory, 'session.json'), 'utf-8'))
);
const startedAt = recordingStartTimeFormatter.format(
new Date(metadata.firstFrameWallClock.iso8601)
);
const shortUdid = `${recording.udid.slice(0, 8)}-…`;
const displayName = `${recording.deviceName} screen recording (${shortUdid}, started at ${startedAt})`;
const recordingPath = path.join(recording.directory, metadata.recording);
const { size } = await stat(recordingPath);
const recordingId = path.basename(recording.directory);
logger.info(
`Uploading screen recording for ${recording.deviceName} (${formatBytes(size)}).`
);
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId,
artifactId: recordingId,
name: displayName,
filename: `${recordingId}.mp4`,
kind: 'screen-recording',
metadata: {
__eas_type: 'screen-recording',
__eas_screen_recording: '1',
udid: recording.udid,
deviceName: recording.deviceName,
runtimeDisplayName: recording.runtimeDisplayName,
firstFrameAt: metadata.firstFrameWallClock.iso8601,
width: metadata.width,
height: metadata.height,
},
size,
stream: createReadStream(recordingPath),
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not upload iOS Simulator screen recording', error);
logger.warn(
{ err: error },
`Could not upload screen recording for ${recording.deviceName}.`
);
}
})
)
);
await uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
logger,
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
recordings,
});
},
});
}
@@ -0,0 +1,57 @@
import { BuildJob, UserError } from '@expo/eas-build-job';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { CustomBuildContext } from '../../customBuildContext';
import { readAppConfig } from '../../utils/appConfig';
import { uploadEmbeddedBundleAsync } from '../../utils/expoUpdatesEmbedded';
export function createUploadEmbeddedBundleBuildFunction(
ctx: CustomBuildContext<BuildJob>
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'upload_embedded_bundle',
name: 'Upload embedded bundle',
__metricsId: 'eas/upload_embedded_bundle',
inputProviders: [
BuildStepInput.createProvider({
id: 'ignore_error',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
}),
],
fn: async (stepCtx, { env, inputs }) => {
try {
const appConfig = (
await readAppConfig({
projectDir: stepCtx.workingDirectory,
env,
logger: stepCtx.logger,
sdkVersion: stepCtx.global.staticContext.metadata?.sdkVersion,
})
).exp;
const { status } = await uploadEmbeddedBundleAsync({
job: ctx.job,
env,
logger: stepCtx.logger,
projectDir: stepCtx.workingDirectory,
appConfig,
});
if (status === 'skipped') {
stepCtx.logger.info('Skipping embedded bundle upload.');
} else if (status === 'failed') {
throw new UserError(
'EAS_UPLOAD_EMBEDDED_BUNDLE_FAILED',
'Failed to upload embedded bundle. See the warning above for details.'
);
}
} catch (err) {
if (inputs.ignore_error.value) {
stepCtx.logger.warn({ err }, 'Failed to upload embedded bundle. Ignoring error.');
return;
}
throw err;
}
},
});
}
@@ -7,7 +7,6 @@ import {
BuildStepOutput,
} from '@expo/steps';
import fs from 'fs-extra';
import * as jose from 'jose';
import fetch from 'node-fetch';
import path from 'node:path';
import { setTimeout } from 'node:timers/promises';
@@ -87,29 +86,8 @@ export function createUploadToAscBuildFunction(): BuildFunction {
throw new Error(`ASC API Key file not found: ${ascApiKeyPath}`);
}
const ascApiKeyJson = await fs.readJson(ascApiKeyPath);
const ascApiKey = z
.object({
issuer_id: z.string().nullish(),
key_id: z.string(),
key: z.string(),
})
.parse(ascApiKeyJson);
const privateKey = await jose.importPKCS8(ascApiKey.key, 'ES256');
const jwt = new jose.SignJWT({})
.setProtectedHeader({ alg: 'ES256', kid: ascApiKey.key_id })
.setAudience('appstoreconnect-v1')
.setExpirationTime('20m');
if (ascApiKey.issuer_id) {
jwt.setIssuer(ascApiKey.issuer_id);
} else {
// Nullish issuer_id means an individual API key
jwt.setSubject('user');
}
const token = await jwt.sign(privateKey);
const client = new AscApiClient({ token, logger: stepsCtx.logger });
const key = await AscApiUtils.loadApiKeyAsync({ keyPath: ascApiKeyPath });
const client = new AscApiClient({ key, logger: stepsCtx.logger });
stepsCtx.logger.info(
`Reading App information for Apple app identifier: ${appleAppIdentifier}...`
@@ -177,21 +155,11 @@ export function createUploadToAscBuildFunction(): BuildFunction {
});
stepsCtx.logger.info('Committing upload...');
await client.patchAsync(
`/v1/buildUploadFiles/:id`,
{
data: {
type: 'buildUploadFiles',
id: buildFileResponse.data.id,
attributes: {
uploaded: true,
},
},
},
{
id: buildFileResponse.data.id,
}
);
await AscApiUtils.commitBuildUploadFileAsync({
client,
fileId: buildFileResponse.data.id,
logger: stepsCtx.logger,
});
stepsCtx.logger.info('Checking upload file status...');
const waitingForFileStartedAt = Date.now();
@@ -10,12 +10,27 @@ import { setTimeout } from 'node:timers/promises';
import { Sentry } from '../../sentry';
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
import {
PackageManager,
resolveConfiguredPackageManager,
resolvePackageExec,
} from '../../utils/packageManager';
import { isProcessGroupRunning, killProcessGroup } from '../../utils/processes';
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
const IOS_SIMULATOR_RECORDING_POLL_INTERVAL_MS = 2_000;
const RECORD_SIM_FINISH_TIMEOUT_MS = 70_000;
const RECORD_SIM_FORCE_STOP_TIMEOUT_MS = 5_000;
const RECORD_SIM_MAX_ATTEMPTS_PER_BOOT = 3;
const RECORD_SIM_COMMAND = 'record-sim';
// Once the poller stops, each recording stops in parallel within 60 + 130 + 5 + 30 = 225 s.
const SERVE_SIM_START_TIMEOUT_MS = 60_000;
/** serve-sim finalizes an active recording within this grace period after SIGTERM. */
export const SERVE_SIM_STOP_GRACE_PERIOD_MS = 90_000;
const SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS = 30_000;
// serve-sim may be stopping at the same time: its grace period, the manifest wait, and 10 s.
const SERVE_SIM_FINISH_TIMEOUT_MS =
SERVE_SIM_STOP_GRACE_PERIOD_MS + SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS + 10_000;
const SERVE_SIM_FORCE_STOP_TIMEOUT_MS = 5_000;
const SERVE_SIM_RECORDING_STARTED = 'serve-sim:recording-started';
// The serve-sim package the session host started; every recorder runs `record-video` from it.
let serveSimPackageSpec: string | null = null;
type IosSimulatorRecording = {
id: string;
@@ -25,6 +40,7 @@ type IosSimulatorRecording = {
outputDirectory: string;
startedAt: Date;
getOutput: () => string;
hasStarted: () => boolean;
};
type ActiveIosSimulatorRecording = IosSimulatorRecording & {
@@ -35,11 +51,15 @@ type ActiveIosSimulatorRecording = IosSimulatorRecording & {
type IosSimulatorRecordingSession = {
env: Env;
logger: bunyan;
recordSimCommand: string;
recordingsRootDirectory: string;
activeRecordings: Map<IosSimulatorUuid, ActiveIosSimulatorRecording>;
completedRecordings: IosSimulatorRecording[];
recordingFailureCounts: Map<IosSimulatorUuid, number>;
/** Failed attempts for the whole session; unlike recordingFailureCounts, never reset. */
failedAttemptCounts: Map<IosSimulatorUuid, number>;
recordingRetryAt: Map<IosSimulatorUuid, number>;
serveBearerTokens: Map<IosSimulatorUuid, string>;
completedServeBearerTokens: Map<IosSimulatorUuid, string>;
pollingPromise: Promise<void>;
abortController: AbortController;
};
@@ -47,31 +67,31 @@ type IosSimulatorRecordingSession = {
let activeIosSimulatorRecordingSession: IosSimulatorRecordingSession | null = null;
export namespace IosSimulatorRecordingUtils {
/** One session host serves every device of a job, so the first package wins. */
export function useServeSimPackage(packageSpec: string): void {
serveSimPackageSpec ??= packageSpec;
}
export async function startAsync({ env, logger }: { env: Env; logger: bunyan }): Promise<void> {
if (activeIosSimulatorRecordingSession) {
logger.info('iOS Simulator screen recording polling is already running.');
return;
}
const recordSimCommand = await resolveRecordSimCommandAsync({ env });
if (!recordSimCommand) {
logger.warn(
'record-sim binary is not available; iOS Simulator screen recordings are disabled.'
);
return;
}
const recordingsRootDirectory = await mkdtemp(
path.join(os.tmpdir(), 'ios-simulator-recordings-')
);
const session: IosSimulatorRecordingSession = {
env,
logger,
recordSimCommand,
recordingsRootDirectory,
activeRecordings: new Map(),
completedRecordings: [],
recordingFailureCounts: new Map(),
failedAttemptCounts: new Map(),
recordingRetryAt: new Map(),
serveBearerTokens: new Map(),
completedServeBearerTokens: new Map(),
pollingPromise: Promise.resolve(),
abortController: new AbortController(),
};
@@ -94,6 +114,7 @@ export namespace IosSimulatorRecordingUtils {
}[]
> {
const session = activeIosSimulatorRecordingSession;
serveSimPackageSpec = null;
if (!session) {
logger.info('No iOS Simulator screen recordings are running.');
return [];
@@ -105,41 +126,55 @@ export namespace IosSimulatorRecordingUtils {
await Promise.all(
[...session.activeRecordings.values()].map(async recording => {
logger.info(`Stopping screen recording for ${recording.deviceName}.`);
recording.recordingProcess.kill('SIGINT');
const startState = await waitForRecorderStartAsync(recording, SERVE_SIM_START_TIMEOUT_MS);
if (startState === 'exited') {
return;
}
if (startState === 'timeout') {
logger.warn(
{ recorderOutput: recording.getOutput().trim() },
`Screen recording for ${recording.deviceName} did not report its start within ${Math.round(
SERVE_SIM_START_TIMEOUT_MS / 1_000
)} seconds and will be stopped.`
);
}
const finishTimeoutMs =
startState === 'started' ? SERVE_SIM_FINISH_TIMEOUT_MS : SERVE_SIM_FORCE_STOP_TIMEOUT_MS;
signalRecorder(recording.recordingProcess, 'SIGINT');
const finished = await Promise.race([
recording.completionPromise.then(() => true),
setTimeout(RECORD_SIM_FINISH_TIMEOUT_MS, false, { ref: false }),
setTimeout(finishTimeoutMs, false, { ref: false }),
]);
if (finished) {
return;
}
const recordSimOutput = recording.getOutput().trim();
const finishTimeoutSeconds = Math.round(RECORD_SIM_FINISH_TIMEOUT_MS / 1_000);
const recorderOutput = recording.getOutput().trim();
const finishTimeoutSeconds = Math.round(finishTimeoutMs / 1_000);
const stopFailure =
startState === 'started'
? `did not finish within ${finishTimeoutSeconds} seconds`
: `did not stop within ${finishTimeoutSeconds} seconds of SIGINT`;
logger.warn(
{ recordSimOutput },
`Screen recording for ${recording.deviceName} did not finish within ${finishTimeoutSeconds} seconds and will be stopped.${
recordSimOutput
? `\nRecent recorder messages:\n${recordSimOutput}`
{ recorderOutput },
`Screen recording for ${recording.deviceName} ${stopFailure} and will be killed.${
recorderOutput
? `\nRecent recorder messages:\n${recorderOutput}`
: '\nNo recorder messages were captured.'
}`
);
recording.recordingProcess.kill('SIGKILL');
signalRecorder(recording.recordingProcess, 'SIGKILL');
const killed = await Promise.race([
recording.completionPromise.then(() => true),
setTimeout(RECORD_SIM_FORCE_STOP_TIMEOUT_MS, false, { ref: false }),
setTimeout(SERVE_SIM_FORCE_STOP_TIMEOUT_MS, false, { ref: false }),
]);
if (!killed) {
logger.warn(
`iOS Simulator recording process for ${recording.deviceName} did not exit after SIGKILL.`
);
// Recorder output stays in the job log: it can contain the serve-sim bearer token.
Sentry.capture(
`iOS Simulator recording process for ${recording.deviceName} did not exit after SIGKILL.`,
{
extras: {
output: recording.getOutput(),
},
}
`iOS Simulator recording process for ${recording.deviceName} did not exit after SIGKILL.`
);
}
})
@@ -148,7 +183,31 @@ export namespace IosSimulatorRecordingUtils {
const completedRecordings = [...session.completedRecordings].sort(
(a, b) => a.startedAt.getTime() - b.startedAt.getTime()
);
return completedRecordings.map(recording => ({
const recordingsWithManifests = await Promise.all(
completedRecordings.map(async recording => {
if (
await waitForRecordingManifestAsync(
recording.outputDirectory,
SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS
)
) {
return recording;
}
reportLostRecording(logger, recording.deviceName, recording.getOutput().trim());
return null;
})
);
const finalizedRecordings = recordingsWithManifests.filter(recording => recording !== null);
const recordedUdids = new Set(finalizedRecordings.map(recording => recording.udid));
for (const [failedUdid, attempts] of session.failedAttemptCounts) {
if (!recordedUdids.has(failedUdid)) {
Sentry.capture('iOS Simulator screen recording failed on every attempt', {
level: 'warning',
extras: { attempts },
});
}
}
return finalizedRecordings.map(recording => ({
udid: recording.udid,
deviceName: recording.deviceName,
runtimeDisplayName: recording.runtimeDisplayName,
@@ -174,18 +233,38 @@ async function pollIosSimulatorRecordingsAsync(
break;
}
listDevicesErrorCount = 0;
const readyServers = new Map(
(await readServeSimServersAsync(SERVE_SIM_STATE_DIR)).flatMap(server =>
server.token ? [[server.udid, server.token] as const] : []
)
);
const bootedUdids = new Set(bootedDevices.map(device => device.udid));
for (const udid of session.recordingFailureCounts.keys()) {
for (const udid of session.serveBearerTokens.keys()) {
if (!bootedUdids.has(udid)) {
session.recordingFailureCounts.delete(udid);
session.recordingRetryAt.delete(udid);
session.serveBearerTokens.delete(udid);
session.completedServeBearerTokens.delete(udid);
}
}
for (const device of bootedDevices) {
const token = readyServers.get(device.udid);
const packageSpec = serveSimPackageSpec;
if (!token || !packageSpec) {
continue;
}
if (session.serveBearerTokens.get(device.udid) !== token) {
session.serveBearerTokens.set(device.udid, token);
session.recordingFailureCounts.delete(device.udid);
session.recordingRetryAt.delete(device.udid);
session.completedServeBearerTokens.delete(device.udid);
}
if (
session.completedServeBearerTokens.get(device.udid) === token ||
session.activeRecordings.has(device.udid) ||
(session.recordingFailureCounts.get(device.udid) ?? 0) >= RECORD_SIM_MAX_ATTEMPTS_PER_BOOT
Date.now() < (session.recordingRetryAt.get(device.udid) ?? 0)
) {
continue;
}
@@ -193,6 +272,7 @@ async function pollIosSimulatorRecordingsAsync(
udid: device.udid,
deviceName: device.name,
runtimeDisplayName: device.runtimeDisplayName,
packageSpec,
});
}
} catch (err) {
@@ -227,50 +307,90 @@ async function startIosSimulatorRecordingAsync(
udid,
deviceName,
runtimeDisplayName,
packageSpec,
}: {
udid: IosSimulatorUuid;
deviceName: string;
runtimeDisplayName: string;
packageSpec: string;
}
): Promise<void> {
const startedAt = new Date();
const serveBearerToken = session.serveBearerTokens.get(udid);
const recordingId = randomUUID();
const outputDirectory = path.join(session.recordingsRootDirectory, recordingId);
await mkdir(outputDirectory, { recursive: true });
session.logger.info(`Starting screen recording for ${deviceName}.`);
const recorderExec = resolvePackageExec(
resolveConfiguredPackageManager(session.env, PackageManager.NPM),
[packageSpec, 'record-video']
);
const recordingSpawn = spawn(
session.recordSimCommand,
['--udid', udid, '--output', outputDirectory, '--segment-duration', '0'],
recorderExec.command,
[...recorderExec.args, '--udid', udid, '--output', outputDirectory],
{
env: session.env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: true,
}
);
const getOutput = captureProcessOutput(recordingSpawn.child);
const completionPromise = recordingSpawn
.then(() => undefined)
.catch((err: unknown) => {
session.recordingFailureCounts.set(udid, (session.recordingFailureCounts.get(udid) ?? 0) + 1);
const { getOutput, hasStarted } = captureProcessOutput(recordingSpawn.child);
const saveFinalizedRecordingAsync = async (): Promise<boolean> => {
if (
!(await waitForRecordingManifestAsync(outputDirectory, SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS))
) {
return false;
}
if (serveBearerToken) {
session.completedServeBearerTokens.set(udid, serveBearerToken);
}
session.completedRecordings.push({
id: recordingId,
udid,
deviceName,
runtimeDisplayName,
outputDirectory,
startedAt,
getOutput,
hasStarted,
});
return true;
};
const completionPromise = (async () => {
let spawnError: unknown;
try {
await recordingSpawn;
} catch (err) {
spawnError = err;
}
// A package-manager wrapper can exit while its record-video child still
// runs in the detached group. Keep this device active until the group exits.
await waitForRecordingProcessGroupExitAsync(recordingSpawn.child);
// Host shutdown can end the recorder's lease after its video has been saved.
if ((hasStarted() || !spawnError) && (await saveFinalizedRecordingAsync())) {
return;
}
if (spawnError) {
const err = spawnError;
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('iOS Simulator screen recording process failed', error);
session.logger.warn(
{ err: error, recordSimOutput: getOutput() },
{ err: error, recorderOutput: getOutput() },
`Screen recording process failed for ${deviceName}.`
);
})
.finally(() => {
session.activeRecordings.delete(udid);
session.completedRecordings.push({
id: recordingId,
udid,
deviceName,
runtimeDisplayName,
outputDirectory,
startedAt,
getOutput,
});
});
}
if (hasStarted()) {
reportLostRecording(session.logger, deviceName, getOutput().trim());
} else if (!spawnError) {
session.logger.warn(
{ recorderOutput: getOutput().trim() },
`Screen recording for ${deviceName} exited before it started; it will be retried.`
);
}
scheduleRecordingRetry(session, udid);
})().finally(() => {
session.activeRecordings.delete(udid);
});
session.activeRecordings.set(udid, {
id: recordingId,
@@ -282,31 +402,103 @@ async function startIosSimulatorRecordingAsync(
completionPromise,
startedAt,
getOutput,
hasStarted,
});
}
async function resolveRecordSimCommandAsync({ env }: { env: Env }): Promise<string | null> {
try {
await spawn('which', [RECORD_SIM_COMMAND], { env });
return RECORD_SIM_COMMAND;
} catch {}
async function waitForRecordingManifestAsync(
outputDirectory: string,
timeoutMs: number
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
await access(path.join(outputDirectory, 'session.json'));
return true;
} catch {
await setTimeout(1_000);
}
}
return false;
}
const packagedRecordSimPath = path.join(__dirname, '..', '..', '..', 'bin', RECORD_SIM_COMMAND);
try {
await access(packagedRecordSimPath);
return packagedRecordSimPath;
} catch {
return null;
async function waitForRecorderStartAsync(
recording: ActiveIosSimulatorRecording,
timeoutMs: number
): Promise<'started' | 'exited' | 'timeout'> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (recording.hasStarted()) {
return 'started';
}
const exited = await Promise.race([
recording.completionPromise.then(() => true),
setTimeout(100, false),
]);
if (exited) {
return 'exited';
}
}
return 'timeout';
}
function reportLostRecording(logger: bunyan, deviceName: string, recorderOutput: string): void {
logger.warn(
{ recorderOutput },
`Screen recording for ${deviceName} ended without a manifest; its footage cannot be uploaded.`
);
Sentry.capture('iOS Simulator screen recording ended without a manifest', {
level: 'warning',
extras: { deviceName },
});
}
function scheduleRecordingRetry(
session: IosSimulatorRecordingSession,
udid: IosSimulatorUuid
): void {
session.failedAttemptCounts.set(udid, (session.failedAttemptCounts.get(udid) ?? 0) + 1);
const failures = (session.recordingFailureCounts.get(udid) ?? 0) + 1;
session.recordingFailureCounts.set(udid, failures);
session.recordingRetryAt.set(
udid,
Date.now() + Math.min(300_000, 25_000 * 2 ** Math.min(failures - 1, 4))
);
}
function signalRecorder(child: ChildProcess, signal: NodeJS.Signals): void {
// killProcessGroup skips a child without a pid.
if (child.pid === undefined) {
child.kill(signal);
return;
}
killProcessGroup(child, signal);
}
async function waitForRecordingProcessGroupExitAsync(child: ChildProcess): Promise<void> {
if (child.pid === undefined) {
return;
}
// Bounded by finishAsync's SIGKILL; unref'd so it cannot keep the worker alive.
while (isProcessGroupRunning(child.pid)) {
await setTimeout(100, undefined, { ref: false });
}
}
function captureProcessOutput(recordingProcess: ChildProcess): () => string {
function captureProcessOutput(recordingProcess: ChildProcess): {
getOutput: () => string;
hasStarted: () => boolean;
} {
let output = '';
let started = false;
const appendChunk = (chunk: Buffer | string): void => {
// Keep enough recorder stderr/stdout for diagnostics without retaining unbounded output.
output = `${output}${chunk.toString()}`.slice(-16_384);
output = `${output}${chunk.toString()}`;
if (output.includes(SERVE_SIM_RECORDING_STARTED)) {
started = true;
}
output = output.slice(-16_384);
};
recordingProcess.stdout?.on('data', appendChunk);
recordingProcess.stderr?.on('data', appendChunk);
return () => output;
return { getOutput: () => output, hasStarted: () => started };
}
@@ -0,0 +1,539 @@
import { type Env } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import { type ChildProcess } from 'node:child_process';
import { EventEmitter } from 'node:events';
import { rm, writeFile } from 'node:fs/promises';
import { PassThrough } from 'node:stream';
import { setTimeout } from 'node:timers/promises';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { Sentry } from '../../../sentry';
import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils';
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
jest.mock('@expo/turtle-spawn');
jest.mock('../serveSimMetricsRecorder');
jest.mock('../../../sentry');
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
} as unknown as bunyan;
const env = {} as Env;
const udid = '06B546AC-7B06-4BED-83DB-83603E8EB537';
beforeEach(() => {
IosSimulatorRecordingUtils.useServeSimPackage('@expo/serve-sim@next');
jest
.spyOn(IosSimulatorUtils, 'getAvailableDevicesAsync')
.mockResolvedValue([{ udid, name: 'iPhone 17 Pro', runtimeDisplayName: 'iOS 26.4' } as never]);
jest.mocked(readServeSimServersAsync).mockReset();
jest.mocked(spawn).mockReset();
});
afterEach(async () => {
await IosSimulatorRecordingUtils.finishAsync({ logger });
jest.restoreAllMocks();
});
async function waitForRecorderSpawnAsync(): Promise<void> {
for (let attempt = 0; attempt < 50; attempt++) {
if (jest.mocked(spawn).mock.calls.length > 0) {
return;
}
await setTimeout(10);
}
throw new Error('serve-sim recorder did not start');
}
// A recorder the test controls: `exit` ends it cleanly, `fail` ends it with an error.
function mockRecorder({ kill }: { kill?: (signal: NodeJS.Signals) => void } = {}): {
child: ChildProcess;
exit: () => void;
fail: (error: Error) => void;
} {
let exit: () => void = () => {};
let fail: (error: Error) => void = () => {};
const child = Object.assign(new EventEmitter(), {
stdout: new PassThrough(),
stderr: new PassThrough(),
kill: jest.fn((signal: NodeJS.Signals) => {
kill?.(signal);
return true;
}),
}) as unknown as ChildProcess;
const done = new Promise<void>((resolve, reject) => {
exit = resolve;
fail = reject;
});
jest.mocked(spawn).mockReturnValue(Object.assign(done, { child }) as never);
return { child, exit: () => exit(), fail: error => fail(error) };
}
function mockFailingRecorder(): void {
jest.mocked(spawn).mockImplementation((_command, args) => {
void writeFile(`${args[6]}/session.json`, JSON.stringify({ recording: 'recording.mp4' }));
return Object.assign(Promise.reject(new Error('recorder exited')), {
child: {
stdout: new PassThrough(),
stderr: new PassThrough(),
kill: jest.fn(),
},
}) as never;
});
}
test('waits for a token-gated serve-sim session before starting a recorder', async () => {
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563' }]);
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await IosSimulatorRecordingUtils.finishAsync({ logger });
expect(jest.mocked(spawn)).not.toHaveBeenCalled();
});
test('starts serve-sim record-video and returns only a finalized recording', async () => {
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
let resolveProcess: () => void = () => {};
const stderr = new PassThrough();
const child = Object.assign(new EventEmitter(), {
stdout: new PassThrough(),
stderr,
kill: jest.fn(),
}) as unknown as ChildProcess;
const processPromise = Object.assign(
new Promise<void>(resolve => {
resolveProcess = resolve;
}),
{
child,
}
);
jest.mocked(spawn).mockReturnValue(processPromise as never);
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await waitForRecorderSpawnAsync();
const [command, args, options] = jest.mocked(spawn).mock.calls[0];
expect(command).toBe('npx');
expect(args.slice(0, 5)).toEqual([
'--yes',
'@expo/serve-sim@next',
'record-video',
'--udid',
udid,
]);
expect(args[5]).toBe('--output');
expect(args).not.toContain('--segment-duration');
expect(options?.detached).toBe(true);
const outputDirectory = args[6];
jest.mocked(child.kill).mockImplementation(() => {
resolveProcess();
void setTimeout(20).then(() =>
writeFile(`${outputDirectory}/session.json`, JSON.stringify({ recording: 'recording.mp4' }))
);
return true;
});
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
await setTimeout(20);
expect(child.kill).not.toHaveBeenCalled();
stderr.write('serve-sim:recording-started\n');
stderr.write('x'.repeat(17_000));
const recordings = await finishing;
expect(child.kill).toHaveBeenCalledWith('SIGINT');
expect(recordings).toEqual([
{
udid,
deviceName: 'iPhone 17 Pro',
runtimeDisplayName: 'iOS 26.4',
directory: outputDirectory,
},
]);
});
test('reports a started recording that ends without a manifest to Sentry', async () => {
let currentTime = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
let exitRecorder: () => void = () => {};
const child = Object.assign(new EventEmitter(), {
stdout: new PassThrough(),
stderr: new PassThrough(),
kill: jest.fn(),
}) as unknown as ChildProcess;
jest.mocked(spawn).mockReturnValue(
Object.assign(
new Promise<void>(resolve => {
exitRecorder = resolve;
}),
{ child }
) as never
);
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await waitForRecorderSpawnAsync();
child.stderr?.emit('data', 'serve-sim:recording-started\n');
child.stderr?.emit('data', 'GET http://127.0.0.1:43563/record?token=session-token failed\n');
exitRecorder();
await setTimeout(50);
currentTime += 31_000;
await setTimeout(1_100);
expect(Sentry.capture).toHaveBeenCalledWith(
'iOS Simulator screen recording ended without a manifest',
{ level: 'warning', extras: { deviceName: 'iPhone 17 Pro' } }
);
expect(JSON.stringify(jest.mocked(Sentry.capture).mock.calls)).not.toContain('session-token');
});
test('says when a recorder never reported its start before it is stopped', async () => {
let currentTime = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
let exitRecorder: () => void = () => {};
const child = Object.assign(new EventEmitter(), {
stdout: new PassThrough(),
stderr: new PassThrough(),
kill: jest.fn(() => {
exitRecorder();
return true;
}),
}) as unknown as ChildProcess;
jest.mocked(spawn).mockReturnValue(
Object.assign(
new Promise<void>(resolve => {
exitRecorder = resolve;
}),
{ child }
) as never
);
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await waitForRecorderSpawnAsync();
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
await setTimeout(50);
currentTime += 61_000;
await setTimeout(200);
currentTime += 31_000;
expect(await finishing).toEqual([]);
expect(child.kill).toHaveBeenCalledWith('SIGINT');
expect(logger.warn).toHaveBeenCalledWith(
expect.anything(),
'Screen recording for iPhone 17 Pro did not report its start within 60 seconds and will be stopped.'
);
});
test('retries after a failed recorder when the serve-sim session token changes', async () => {
jest
.mocked(readServeSimServersAsync)
.mockResolvedValueOnce([{ udid, url: 'http://127.0.0.1:43563', token: 'old-token' }])
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'new-token' }]);
mockFailingRecorder();
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await waitForRecorderSpawnAsync();
await setTimeout(2_100);
expect(spawn).toHaveBeenCalledTimes(2);
expect(Sentry.capture).not.toHaveBeenCalled();
expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toEqual([]);
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
'iOS Simulator screen recording failed on every attempt',
expect.objectContaining({ level: 'warning', extras: { attempts: 2 } })
);
});
test('reports a device that failed and then shut down', async () => {
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
mockFailingRecorder();
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await waitForRecorderSpawnAsync();
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockResolvedValue([]);
await setTimeout(2_100);
expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toEqual([]);
expect(Sentry.capture).toHaveBeenCalledWith(
'iOS Simulator screen recording failed on every attempt',
expect.objectContaining({ extras: { attempts: 1 } })
);
});
test('retries after the server lease expires without a token change', async () => {
const startTime = Date.now();
let currentTime = startTime;
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
mockFailingRecorder();
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await waitForRecorderSpawnAsync();
currentTime += 26_000;
await setTimeout(2_100);
expect(spawn).toHaveBeenCalledTimes(2);
});
test('does not start a second recording for a server token after a late manifest', async () => {
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
jest.mocked(spawn).mockImplementation((_command, args) => {
const child = Object.assign(new EventEmitter(), {
stdout: new PassThrough(),
stderr: new PassThrough(),
kill: jest.fn(),
}) as unknown as ChildProcess;
void setTimeout(20).then(() =>
writeFile(`${args[6]}/session.json`, JSON.stringify({ recording: 'recording.mp4' }))
);
return Object.assign(Promise.resolve(), { child }) as never;
});
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await waitForRecorderSpawnAsync();
await setTimeout(2_100);
expect(spawn).toHaveBeenCalledTimes(1);
expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toHaveLength(1);
});
test('keeps a recorder active after its package-manager wrapper exits', async () => {
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
let groupRunning = true;
const originalKill = process.kill.bind(process);
const signalGroup = jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
if (pid !== -4321) {
return originalKill(pid, signal);
}
if (signal === 0) {
if (groupRunning) {
return true;
}
throw Object.assign(new Error('No such process group'), { code: 'ESRCH' });
}
if (signal === 'SIGINT') {
void setTimeout(20).then(async () => {
const args = jest.mocked(spawn).mock.calls[0][1];
await writeFile(`${args[6]}/session.json`, JSON.stringify({ recording: 'recording.mp4' }));
groupRunning = false;
});
return true;
}
throw new Error(`Unexpected signal: ${signal}`);
});
const child = Object.assign(new EventEmitter(), {
pid: 4321,
stdout: new PassThrough(),
stderr: new PassThrough(),
kill: jest.fn(),
}) as unknown as ChildProcess;
jest.mocked(spawn).mockReturnValue(Object.assign(Promise.resolve(), { child }) as never);
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await waitForRecorderSpawnAsync();
child.stderr?.emit('data', 'serve-sim:recording-started\n');
await setTimeout(2_100);
expect(spawn).toHaveBeenCalledTimes(1);
const recordings = await IosSimulatorRecordingUtils.finishAsync({ logger });
expect(signalGroup).toHaveBeenCalledWith(-4321, 'SIGINT');
expect(child.kill).not.toHaveBeenCalled();
expect(recordings).toHaveLength(1);
});
test('does not signal a recorder that exits before it reports its start', async () => {
let currentTime = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
const recorder = mockRecorder();
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await waitForRecorderSpawnAsync();
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
await setTimeout(50);
recorder.exit();
await setTimeout(50);
// Past the manifest wait, so the recorder's completion settles.
currentTime += 31_000;
expect(await finishing).toEqual([]);
expect(recorder.child.kill).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
expect.anything(),
'Screen recording for iPhone 17 Pro exited before it started; it will be retried.'
);
});
test('kills a recorder that ignores SIGINT and reports one that survives SIGKILL', async () => {
let currentTime = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
const recorder = mockRecorder();
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await waitForRecorderSpawnAsync();
recorder.child.stderr?.emit('data', 'GET /record?token=session-token\n');
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
await setTimeout(50);
currentTime += 61_000;
expect(await finishing).toEqual([]);
expect(recorder.child.kill).toHaveBeenCalledWith('SIGINT');
expect(recorder.child.kill).toHaveBeenCalledWith('SIGKILL');
expect(logger.warn).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining(
'Screen recording for iPhone 17 Pro did not stop within 5 seconds of SIGINT and will be killed.'
)
);
expect(Sentry.capture).toHaveBeenCalledWith(
'iOS Simulator recording process for iPhone 17 Pro did not exit after SIGKILL.'
);
expect(JSON.stringify(jest.mocked(Sentry.capture).mock.calls)).not.toContain('session-token');
}, 20_000);
test('reports a finalized recording whose manifest is gone at finish', async () => {
let currentTime = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
const recorder = mockRecorder();
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await waitForRecorderSpawnAsync();
const manifest = `${jest.mocked(spawn).mock.calls[0][1][6]}/session.json`;
recorder.child.stderr?.emit('data', 'serve-sim:recording-started\n');
await writeFile(manifest, JSON.stringify({ recording: 'recording.mp4' }));
recorder.exit();
await setTimeout(100);
await rm(manifest);
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
await setTimeout(50);
currentTime += 31_000;
expect(await finishing).toEqual([]);
expect(Sentry.capture).toHaveBeenCalledWith(
'iOS Simulator screen recording ended without a manifest',
{ level: 'warning', extras: { deviceName: 'iPhone 17 Pro' } }
);
});
test.each([0, 20])(
'keeps a started recording that exits with an error when its manifest appears after %i ms',
async manifestDelayMs => {
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
const recorder = mockRecorder();
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await waitForRecorderSpawnAsync();
const outputDirectory = jest.mocked(spawn).mock.calls[0][1][6];
recorder.child.stderr?.emit('data', 'serve-sim:recording-started\n');
const manifestWritten = setTimeout(manifestDelayMs).then(() =>
writeFile(`${outputDirectory}/session.json`, JSON.stringify({ recording: 'recording.mp4' }))
);
if (manifestDelayMs === 0) {
await manifestWritten;
}
recorder.fail(new Error('record-video exited with code 1'));
const recordings = await IosSimulatorRecordingUtils.finishAsync({ logger });
await manifestWritten;
expect(recordings).toEqual([
{
udid,
deviceName: 'iPhone 17 Pro',
runtimeDisplayName: 'iOS 26.4',
directory: outputDirectory,
},
]);
expect(logger.warn).not.toHaveBeenCalled();
expect(Sentry.capture).not.toHaveBeenCalled();
}
);
test('reports a started recorder that fails without a manifest', async () => {
let currentTime = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
const recorder = mockRecorder();
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await waitForRecorderSpawnAsync();
recorder.child.stderr?.emit('data', 'serve-sim:recording-started\n');
recorder.fail(new Error('record-video exited with code 1'));
await setTimeout(50);
currentTime += 31_000;
await setTimeout(1_100);
expect(logger.warn).toHaveBeenCalledWith(
expect.anything(),
'Screen recording process failed for iPhone 17 Pro.'
);
expect(Sentry.capture).toHaveBeenCalledWith(
'iOS Simulator screen recording ended without a manifest',
expect.objectContaining({ level: 'warning' })
);
});
test('records every booted device with the first serve-sim package', async () => {
let currentTime = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
const secondUdid = '1C17CD51-5926-4838-951B-6D941C7BEDFF';
IosSimulatorRecordingUtils.useServeSimPackage('@expo/serve-sim@later');
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([
{ udid, name: 'iPhone 17 Pro', runtimeDisplayName: 'iOS 26.4' } as never,
{ udid: secondUdid, name: 'iPhone 17', runtimeDisplayName: 'iOS 26.4' } as never,
]);
jest.mocked(readServeSimServersAsync).mockResolvedValue([
{ udid, url: 'http://127.0.0.1:43563', token: 'first-token' },
{ udid: secondUdid, url: 'http://127.0.0.1:43563', token: 'second-token' },
]);
const recorder = mockRecorder();
await IosSimulatorRecordingUtils.startAsync({ env, logger });
for (let attempt = 0; attempt < 50 && jest.mocked(spawn).mock.calls.length < 2; attempt++) {
await setTimeout(10);
}
const calls = jest.mocked(spawn).mock.calls.map(([, args]) => args);
expect(calls.map(args => args[4]).sort()).toEqual([udid, secondUdid].sort());
expect(calls.every(args => args[1] === '@expo/serve-sim@next')).toBe(true);
recorder.exit();
await setTimeout(50);
currentTime += 31_000;
await IosSimulatorRecordingUtils.finishAsync({ logger });
});
@@ -104,6 +104,39 @@ describe(listAgentDeviceArtifactsAsync, () => {
})
).rejects.toThrow('agent-device daemon does not expose artifact inventory.');
});
it('keeps a screenshot display rotation and drops an unknown one', async () => {
jest.mocked(fetch).mockResolvedValue(
new Response(
JSON.stringify({
artifacts: [
{
id: 'rotated',
artifactType: 'screenshot',
filename: 'rotated.png',
displayRotation: 'landscape-left',
},
{
id: 'future',
artifactType: 'screenshot',
filename: 'future.png',
displayRotation: 'sideways',
},
],
})
)
);
const artifacts = await listAgentDeviceArtifactsAsync({
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'daemon-token',
});
expect(artifacts.map(artifact => artifact.displayRotation)).toEqual([
'landscape-left',
undefined,
]);
});
});
describe(uploadAgentDeviceArtifactAsync, () => {
@@ -181,6 +214,63 @@ describe(uploadAgentDeviceArtifactAsync, () => {
})
);
});
it('uploads a screenshot display rotation as artifact metadata', async () => {
const data = Buffer.from('png-data');
const ctx = {} as unknown as CustomBuildContext;
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { stream }) => {
await readStreamAsync(stream);
});
await uploadAgentDeviceArtifactAsync(ctx, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'daemon-token',
logger: createLoggerMock(),
artifact: {
id: 'artifact-id',
artifactType: 'screenshot',
filename: 'shot.png',
displayRotation: 'landscape-right',
},
});
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
kind: 'screenshot',
metadata: { displayRotation: 'landscape-right' },
})
);
});
it('uploads no metadata when the artifact has no display rotation', async () => {
const data = Buffer.from('artifact-data');
const ctx = {} as unknown as CustomBuildContext;
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { stream }) => {
await readStreamAsync(stream);
});
await uploadAgentDeviceArtifactAsync(ctx, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'daemon-token',
logger: createLoggerMock(),
artifact: { id: 'artifact-id', artifactType: 'screenshot', filename: 'shot.png' },
});
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls[0][1]).not.toHaveProperty(
'metadata'
);
});
});
describe(pollAgentDeviceArtifactsForUploadAsync, () => {
@@ -0,0 +1,319 @@
import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { createMockLogger } from '../../../__tests__/utils/logger';
import {
buildEgressFenceRuleset,
findProcessesOutsideScope,
getCgroupLevel,
listEmulatorProcessesAsync,
parseAdbReversePorts,
parseConnectAuthority,
parseFenceCounters,
parseFenceLogLine,
parseLoopbackListenerPorts,
resolveAndroidEmulatorLocalEgressAsync,
startLocalEgressRelayAsync,
} from '../androidLocalEgress';
import { writeLocalEgressHandoffAsync } from '../localEgress';
describe(parseConnectAuthority, () => {
it('parses IPv4, hostname and bracketed IPv6 authorities', () => {
expect(parseConnectAuthority('CONNECT 192.0.2.1:443 HTTP/1.1\r\nHost: x\r\n')).toEqual({
host: '192.0.2.1',
port: 443,
});
expect(parseConnectAuthority('CONNECT Example.com:8443 HTTP/1.0\r\n')).toEqual({
host: 'example.com',
port: 8443,
});
expect(parseConnectAuthority('CONNECT [2001:db8::1]:443 HTTP/1.1\r\n')).toEqual({
host: '2001:db8::1',
port: 443,
});
});
it('ignores requests that are not CONNECT', () => {
expect(parseConnectAuthority('GET http://example.com/ HTTP/1.1\r\n')).toBeNull();
});
});
describe(buildEgressFenceRuleset, () => {
it('refuses traffic from the session scope that cannot use the tunnel', () => {
const ruleset = buildEgressFenceRuleset({ cgroupLevel: 2 });
expect(ruleset).toContain('socket cgroupv2 level 2 @fenced jump fence');
expect(ruleset).toContain('oif "lo" meta l4proto tcp accept');
expect(ruleset).toContain('oif "lo" ip daddr 127.0.0.53 udp dport 53 accept');
expect(ruleset).toContain('log prefix "eas-egress " level info');
expect(ruleset).toContain('meta l4proto tcp counter name fence_tcp reject with tcp reset');
expect(ruleset).toContain(
'meta l4proto udp counter name fence_udp reject with icmpx admin-prohibited'
);
expect(ruleset).not.toMatch(/fence_\w+ accept/);
});
});
describe(parseFenceLogLine, () => {
it('parses a refused IPv4 TCP connection from the emulator', () => {
expect(
parseFenceLogLine(
'eas-egress IN= OUT=ens3 SRC=10.128.0.5 DST=172.253.155.190 LEN=60 TOS=0x00 PREC=0x00 TTL=64 ID=1 DF PROTO=TCP SPT=42408 DPT=443 WINDOW=64240 RES=0x00 SYN URGP=0'
)
).toEqual({
process: 'Android emulator',
pid: null,
function: 'tcp',
action: 'blocked',
peer: '172.253.155.190:443',
callers: [],
note: expect.stringContaining('the emulator itself opened'),
});
});
it('labels QUIC and compresses IPv6 destinations', () => {
expect(
parseFenceLogLine(
'eas-egress IN= OUT=ens3 SRC=10.128.0.5 DST=142.250.152.94 LEN=1278 PROTO=UDP SPT=51000 DPT=443 LEN=1258'
)
).toMatchObject({
function: 'udp',
peer: '142.250.152.94:443',
note: expect.stringContaining('QUIC'),
});
expect(
parseFenceLogLine(
'eas-egress IN= OUT=ens3 SRC=fe80:0000:0000:0000:0000:0000:0000:0001 DST=ff02:0000:0000:0000:0000:0000:0000:00fb LEN=100 TC=0 HOPLIMIT=255 FLOWLBL=0 PROTO=UDP SPT=5353 DPT=5353 LEN=60'
)
).toMatchObject({ function: 'udp', peer: '[ff02::fb]:5353', note: undefined });
});
it('reports ICMP without a port', () => {
expect(
parseFenceLogLine(
'eas-egress IN= OUT=ens3 SRC=10.128.0.5 DST=8.8.8.8 LEN=84 PROTO=ICMP TYPE=8 CODE=0 ID=7 SEQ=1'
)
).toMatchObject({ function: 'icmp', action: 'blocked', peer: '8.8.8.8' });
});
it('ignores unrelated kernel log lines', () => {
expect(parseFenceLogLine('kvm: some unrelated message')).toBeNull();
expect(parseFenceLogLine('eas-egress IN= OUT=ens3')).toBeNull();
});
});
describe(parseFenceCounters, () => {
it('reads packet counts from nft JSON output', () => {
expect(
parseFenceCounters(
JSON.stringify({
nftables: [
{ metainfo: { version: '1.1.6' } },
{ counter: { family: 'inet', name: 'fence_tcp', table: 'eas_fence', packets: 4 } },
{ counter: { family: 'inet', name: 'fence_udp', table: 'eas_fence', packets: 57 } },
{ counter: { family: 'inet', name: 'fence_other', table: 'eas_fence', packets: 2 } },
],
})
)
).toEqual({ tcp: 4, udp: 57, other: 2 });
});
});
describe('process coverage', () => {
let procRoot: string;
beforeEach(async () => {
procRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'fake-proc-'));
});
afterEach(async () => {
await fs.promises.rm(procRoot, { recursive: true, force: true });
});
async function addProcessAsync(pid: number, comm: string, cgroup: string): Promise<void> {
await fs.promises.mkdir(path.join(procRoot, String(pid)));
await fs.promises.writeFile(path.join(procRoot, String(pid), 'comm'), `${comm}\n`);
await fs.promises.writeFile(path.join(procRoot, String(pid), 'cgroup'), `0::${cgroup}\n`);
}
it('lists emulator processes and finds those outside the session scope', async () => {
const scope = 'system.slice/eas-egress-session.scope';
await addProcessAsync(10, 'qemu-system-x86', `/${scope}`);
await addProcessAsync(11, 'netsimd', `/${scope}`);
await addProcessAsync(12, 'netsimd', '/system.slice/eas-build-worker.service');
await addProcessAsync(13, 'node', '/system.slice/eas-build-worker.service');
await fs.promises.mkdir(path.join(procRoot, 'self'));
const processes = await listEmulatorProcessesAsync(procRoot);
expect(processes.map(({ pid }) => pid).sort()).toEqual([10, 11, 12]);
expect(findProcessesOutsideScope(processes, scope)).toEqual([
{ pid: 12, comm: 'netsimd', cgroup: '/system.slice/eas-build-worker.service' },
]);
expect(getCgroupLevel(scope)).toBe(2);
});
});
describe('adb reverse parsing', () => {
it('finds loopback ports a process listens on', () => {
const ss = [
'LISTEN 0 4096 127.0.0.1:8899 0.0.0.0:* users:(("chisel",pid=4321,fd=9))',
'LISTEN 0 4096 127.0.0.1:8082 0.0.0.0:* users:(("chisel",pid=4321,fd=10))',
'LISTEN 0 4096 127.0.0.1:5037 0.0.0.0:* users:(("adb",pid=999,fd=3))',
'LISTEN 0 4096 127.0.0.1:52001 0.0.0.0:* users:(("chisel",pid=43210,fd=3))',
].join('\n');
expect(parseLoopbackListenerPorts(ss, 4321)).toEqual([8082, 8899]);
});
it('parses adb reverse --list output', () => {
expect(
parseAdbReversePorts('emulator-5554 tcp:8082 tcp:8082\nemulator-5554 tcp:8083 tcp:8083\n')
).toEqual(new Set([8082, 8083]));
});
});
describe(startLocalEgressRelayAsync, () => {
async function getFreePortAsync(): Promise<number> {
const server = net.createServer();
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as net.AddressInfo;
await new Promise<void>(resolve => server.close(() => resolve()));
return port;
}
async function exchangeAsync(port: number, request: string): Promise<string> {
return await new Promise((resolve, reject) => {
const socket = net.connect({ host: '127.0.0.1', port });
let received = '';
socket.on('data', chunk => {
received += chunk.toString();
});
socket.on('error', reject);
socket.on('close', () => resolve(received));
socket.write(request);
});
}
async function startUpstreamAsync(
port: number
): Promise<{ requests: string[]; close: () => Promise<void> }> {
const requests: string[] = [];
const server = net.createServer(socket => {
socket.once('data', chunk => {
requests.push(chunk.toString());
socket.end('upstream reply');
});
});
await new Promise<void>(resolve => server.listen(port, '127.0.0.1', resolve));
return {
requests,
close: async () => await new Promise<void>(resolve => server.close(() => resolve())),
};
}
it('answers the readiness CONNECT itself, even without an egress client', async () => {
const logger = createMockLogger();
const relay = await startLocalEgressRelayAsync({
port: await getFreePortAsync(),
upstreamPort: await getFreePortAsync(),
logger,
});
try {
await expect(
exchangeAsync(relay.port, 'CONNECT 192.0.2.1:443 HTTP/1.1\r\nHost: 192.0.2.1:443\r\n\r\n')
).resolves.toBe('HTTP/1.1 200 Connection Established\r\n\r\n');
expect(logger.warn).not.toHaveBeenCalled();
} finally {
await relay.stopAsync();
}
});
it('refuses while the client is detached and forwards once it attaches', async () => {
const logger = createMockLogger();
const upstreamPort = await getFreePortAsync();
const relay = await startLocalEgressRelayAsync({
port: await getFreePortAsync(),
upstreamPort,
logger,
});
const request = 'CONNECT 93.184.216.34:443 HTTP/1.1\r\nHost: 93.184.216.34:443\r\n\r\n';
try {
await expect(exchangeAsync(relay.port, request)).resolves.toMatch(
/^HTTP\/1\.1 503 Service Unavailable\r\n[\s\S]*not connected/
);
await exchangeAsync(relay.port, request);
expect(logger.warn).toHaveBeenCalledTimes(1);
const upstream = await startUpstreamAsync(upstreamPort);
try {
await expect(exchangeAsync(relay.port, request)).resolves.toBe('upstream reply');
expect(upstream.requests).toEqual([request]);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('2 request(s) from the Android emulator were refused')
);
} finally {
await upstream.close();
}
} finally {
await relay.stopAsync();
}
});
it('unwraps a CONNECT to itself and forwards the inner request', async () => {
const upstreamPort = await getFreePortAsync();
const relay = await startLocalEgressRelayAsync({
port: await getFreePortAsync(),
upstreamPort,
logger: createMockLogger(),
});
const upstream = await startUpstreamAsync(upstreamPort);
const inner = 'CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n';
try {
await expect(
exchangeAsync(
relay.port,
`CONNECT 10.0.2.2:${relay.port} HTTP/1.1\r\nHost: 10.0.2.2:${relay.port}\r\n\r\n${inner}`
)
).resolves.toBe('HTTP/1.1 200 Connection Established\r\n\r\nupstream reply');
expect(upstream.requests).toEqual([inner]);
} finally {
await upstream.close();
await relay.stopAsync();
}
});
});
describe(resolveAndroidEmulatorLocalEgressAsync, () => {
let handoffPath: string;
beforeEach(async () => {
handoffPath = path.join(
await fs.promises.mkdtemp(path.join(os.tmpdir(), 'android-egress-handoff-')),
'handoff.json'
);
});
afterEach(async () => {
await fs.promises.rm(path.dirname(handoffPath), { recursive: true, force: true });
});
it('returns null without a local egress session', async () => {
await expect(resolveAndroidEmulatorLocalEgressAsync({ handoffPath })).resolves.toBeNull();
});
it('fails when a session exists but the fence was not started', async () => {
await writeLocalEgressHandoffAsync(
{
url: 'https://egress.example.com',
token: 'token',
fingerprint: 'key=',
port: 8899,
platform: 'android',
},
handoffPath
);
await expect(resolveAndroidEmulatorLocalEgressAsync({ handoffPath })).rejects.toThrow(
'the Android emulator fence was not started'
);
});
});
@@ -63,7 +63,8 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
metadata: { firstFrameRecordAt: 'test-time' },
size: reportedSize,
},
})
}),
{ fetch: expect.any(Function) }
);
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
'https://uploads.expo.test/artifact',
@@ -73,4 +74,52 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
})
);
});
it('forwards cancellation through upload allocation and skips PUT when allocation aborts', async () => {
const controller = new AbortController();
const mutation = jest.fn().mockReturnValue({
toPromise: async () => {
controller.abort();
return {
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: {
url: 'https://uploads.expo.test/artifact',
headers: {},
},
},
},
},
};
},
});
const ctx = { graphqlClient: { mutation } } as unknown as CustomBuildContext;
await expect(
uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId: 'session',
artifactId: 'id',
name: 'Logs',
filename: 'logs.ndjson',
kind: 'simulator-log',
size: 0,
stream: Readable.from([]),
signal: controller.signal,
})
).rejects.toThrow();
expect(fetch).not.toHaveBeenCalled();
const allocationFetch = mutation.mock.calls[0][2].fetch;
const mockFetch = jest.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('aborted'));
try {
await expect(allocationFetch('https://api.expo.test/graphql', {})).rejects.toThrow('aborted');
expect(mockFetch).toHaveBeenCalledWith(
'https://api.expo.test/graphql',
expect.objectContaining({
signal: expect.objectContaining({ aborted: true }),
})
);
} finally {
mockFetch.mockRestore();
}
});
});
@@ -0,0 +1,400 @@
import { type bunyan } from '@expo/logger';
import { BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { Client, fetchExchange } from '@urql/core';
import { readFile, readdir, writeFile } from 'node:fs/promises';
import { Readable } from 'node:stream';
import os from 'node:os';
import fetch from 'node-fetch';
import { type CustomBuildContext } from '../../../customBuildContext';
import {
captureDeviceRunSessionPreviewAsync,
ensureMacosPreviewEncoderInstalledAsync,
startDeviceRunSessionPreview,
} from '../deviceRunSessionPreview';
jest.mock('node-fetch');
jest.mock('@expo/turtle-spawn');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
describe(startDeviceRunSessionPreview, () => {
const image = Buffer.from('webp-image');
let captureAsync: jest.Mock<Promise<Buffer>, [AbortSignal]>;
let mutation: jest.Mock;
let logger: bunyan;
let ctx: CustomBuildContext;
let preview: ReturnType<typeof startDeviceRunSessionPreview> | undefined;
beforeEach(() => {
jest.useFakeTimers();
captureAsync = jest.fn().mockResolvedValue(image);
mutation = jest.fn().mockReturnValue({
toPromise: async () => ({
data: {
deviceRunSession: {
createPreviewUploadSession: {
uploadSession: {
url: 'https://uploads.expo.test/preview',
headers: { 'content-type': 'image/webp' },
},
},
},
},
}),
});
ctx = { graphqlClient: { mutation } } as unknown as CustomBuildContext;
logger = { warn: jest.fn() } as unknown as bunyan;
jest
.mocked(fetch)
.mockReset()
.mockResolvedValue(new Response('', { status: 200 }));
});
afterEach(async () => {
await preview?.stopAsync();
preview = undefined;
jest.useRealTimers();
});
const start = () => {
preview = startDeviceRunSessionPreview({
ctx,
deviceRunSessionId: 'session-id',
captureAsync,
logger,
});
return preview;
};
it('uploads immediately and reuses the URL every 60 seconds', async () => {
start();
await jest.advanceTimersByTimeAsync(0);
expect(fetch).toHaveBeenCalledWith(
'https://uploads.expo.test/preview',
expect.objectContaining({
method: 'PUT',
body: image,
headers: { 'content-type': 'image/webp' },
})
);
await jest.advanceTimersByTimeAsync(59_999);
expect(captureAsync).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1);
expect(captureAsync).toHaveBeenCalledTimes(2);
expect(mutation).toHaveBeenCalledTimes(1);
await preview!.stopAsync();
await jest.advanceTimersByTimeAsync(120_000);
expect(captureAsync).toHaveBeenCalledTimes(2);
});
it('retries capture errors without creating an empty artifact', async () => {
captureAsync.mockRejectedValueOnce(new Error('device not ready'));
start();
await jest.advanceTimersByTimeAsync(0);
expect(mutation).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(60_000);
expect(fetch).toHaveBeenCalledTimes(1);
});
it('retries a failed upload with the same URL', async () => {
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 403 }));
start();
await jest.advanceTimersByTimeAsync(60_000);
expect(mutation).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledTimes(2);
});
it('does not log the signed upload URL when the upload request fails', async () => {
const { FetchError } = jest.requireActual('node-fetch') as typeof import('node-fetch');
jest
.mocked(fetch)
.mockRejectedValueOnce(
new FetchError('network timeout at: https://uploads.expo.test/preview', 'request-timeout')
);
start();
await jest.advanceTimersByTimeAsync(0);
expect(logger.warn).toHaveBeenCalledTimes(1);
const [fields] = jest.mocked(logger.warn).mock.calls[0] as unknown as [{ err: Error }];
expect(fields.err.message).toBe(
'Session preview upload request failed (name: FetchError, type: request-timeout).'
);
expect(JSON.stringify(jest.mocked(logger.warn).mock.calls)).not.toContain('uploads.expo.test');
});
it('reuses the upload URL for the full session', async () => {
start();
await jest.advanceTimersByTimeAsync(119 * 60_000);
expect(mutation).toHaveBeenCalledTimes(1);
});
it('does not overlap captures and aborts pending work when stopped', async () => {
captureAsync.mockImplementation(
signal =>
new Promise((_, reject) => {
signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
})
);
const handle = start();
await jest.advanceTimersByTimeAsync(60_000);
expect(captureAsync).toHaveBeenCalledTimes(1);
await handle.stopAsync();
expect(captureAsync.mock.calls[0][0].aborted).toBe(true);
expect(fetch).not.toHaveBeenCalled();
});
it('does not upload a capture that finishes after shutdown', async () => {
let finish!: (image: Buffer) => void;
captureAsync.mockImplementation(
() =>
new Promise(resolve => {
finish = resolve;
})
);
const handle = start();
const stopping = handle.stopAsync();
finish(image);
await stopping;
expect(mutation).not.toHaveBeenCalled();
});
});
describe('session preview with the real GraphQL client', () => {
const uploadSession = {
url: 'https://uploads.expo.test/preview',
headers: { 'content-type': 'image/webp' },
};
let graphqlFetch: jest.SpyInstance;
let preview: ReturnType<typeof startDeviceRunSessionPreview> | undefined;
let logger: bunyan;
beforeEach(() => {
jest.useFakeTimers();
graphqlFetch = jest.spyOn(globalThis, 'fetch').mockResolvedValue(
new globalThis.Response(
JSON.stringify({
data: { deviceRunSession: { createPreviewUploadSession: { uploadSession } } },
}),
{ headers: { 'content-type': 'application/json' } }
)
);
logger = { warn: jest.fn() } as unknown as bunyan;
jest
.mocked(fetch)
.mockReset()
.mockResolvedValue(new Response('', { status: 200 }));
});
afterEach(async () => {
await preview?.stopAsync();
preview = undefined;
jest.restoreAllMocks();
jest.useRealTimers();
});
const start = () => {
const graphqlClient = new Client({
url: 'https://api.expo.test/graphql',
exchanges: [fetchExchange],
fetchOptions: { headers: { Authorization: 'Bearer worker-token' } },
});
preview = startDeviceRunSessionPreview({
ctx: { graphqlClient } as CustomBuildContext,
deviceRunSessionId: 'session-id',
captureAsync: async () => Buffer.from('webp-image'),
logger,
});
return preview;
};
it('preserves the worker authentication when creating the upload session', async () => {
start();
await jest.advanceTimersByTimeAsync(0);
const [, options] = graphqlFetch.mock.calls[0];
expect(new Headers(options.headers).get('authorization')).toBe('Bearer worker-token');
expect(fetch).toHaveBeenCalledTimes(1);
});
it.each(['shutdown', 'timeout'])('aborts a stalled GraphQL request on %s', async reason => {
const timeout = new AbortController();
jest.spyOn(AbortSignal, 'timeout').mockReturnValue(timeout.signal);
let requestSignal: AbortSignal | undefined;
let rejectRequest!: (error: Error) => void;
graphqlFetch.mockImplementation(
(_url, options) =>
new Promise((_, reject) => {
rejectRequest = reject;
requestSignal = options.signal;
requestSignal!.addEventListener('abort', () => reject(requestSignal!.reason), {
once: true,
});
})
);
const handle = start();
await jest.advanceTimersByTimeAsync(0);
let stopped = false;
let stopping: Promise<void> | undefined;
if (reason === 'shutdown') {
stopping = handle.stopAsync().then(() => {
stopped = true;
});
} else {
timeout.abort(new Error('preview attempt timed out'));
}
await jest.advanceTimersByTimeAsync(0);
const aborted = requestSignal!.aborted;
const stoppedAfterAbort = stopped;
// Always release the fake transport so a regression cannot hang test cleanup.
rejectRequest(new Error('test cleanup'));
await jest.advanceTimersByTimeAsync(0);
expect(aborted).toBe(true);
expect(fetch).not.toHaveBeenCalled();
if (stopping) {
expect(stoppedAfterAbort).toBe(true);
await stopping;
} else {
expect(logger.warn).toHaveBeenCalled();
graphqlFetch.mockResolvedValue(
new globalThis.Response(
JSON.stringify({
data: { deviceRunSession: { createPreviewUploadSession: { uploadSession } } },
}),
{ headers: { 'content-type': 'application/json' } }
)
);
jest.spyOn(AbortSignal, 'timeout').mockReturnValue(new AbortController().signal);
await jest.advanceTimersByTimeAsync(60_000);
expect(fetch).toHaveBeenCalledTimes(1);
}
});
});
describe(captureDeviceRunSessionPreviewAsync, () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0xff, 0x00]);
const webp = Buffer.from('encoded-webp');
const resized = Buffer.from('resized-png');
it.each([
[BuildRuntimePlatform.DARWIN, 'selected-device'],
[BuildRuntimePlatform.LINUX, 'selected-device'],
[BuildRuntimePlatform.LINUX, 'no-device-id'],
] as const)(
'captures the selected device on %s and removes temporary files',
async (runtimePlatform, device) => {
jest.mocked(spawn).mockImplementation((command, args) => {
if (command === 'adb' && args[0] === 'devices') {
return Promise.resolve({
stdout: 'List of devices attached\nselected-device\tdevice\noffline-device\toffline\n',
}) as ReturnType<typeof spawn>;
}
if (command === 'adb') {
expect(args).toEqual(['-s', 'selected-device', 'exec-out', 'screencap', '-p']);
return Object.assign(Promise.resolve({ stdout: '', stderr: '' }), {
child: { stdout: Readable.from([png]) },
}) as ReturnType<typeof spawn>;
}
return (async () => {
if (command === 'xcrun') {
expect(args.slice(0, 4)).toEqual(['simctl', 'io', 'selected-device', 'screenshot']);
await writeFile(args[4], Uint8Array.from(png));
} else if (command === 'sips') {
expect(args.slice(0, 2)).toEqual(['-Z', '320']);
expect(await readFile(args[2])).toEqual(png);
await writeFile(args[args.indexOf('--out') + 1], Uint8Array.from(resized));
} else if (command === 'cwebp') {
expect(await readFile(args[args.indexOf('-o') - 1])).toEqual(resized);
await writeFile(args[args.indexOf('-o') + 1], Uint8Array.from(webp));
} else {
// Homebrew's FFmpeg cannot encode WebP, so macOS must not use it.
expect(runtimePlatform).toBe(BuildRuntimePlatform.LINUX);
expect(command).toBe('ffmpeg');
expect(await readFile(args[args.indexOf('-i') + 1])).toEqual(png);
expect(args).toContain('libwebp');
await writeFile(args[args.length - 1], Uint8Array.from(webp));
}
return { stdout: '', stderr: '' };
})() as ReturnType<typeof spawn>;
});
expect(
await captureDeviceRunSessionPreviewAsync({
runtimePlatform,
device,
env: {},
signal: new AbortController().signal,
})
).toEqual(webp);
expect(
(await readdir(os.tmpdir())).filter(name => name.startsWith('session-preview-'))
).toEqual([]);
}
);
it('skips ambiguous Android devices instead of capturing the wrong screen', async () => {
jest
.mocked(spawn)
.mockResolvedValue({ stdout: 'device-one\tdevice\ndevice-two\tdevice\n' } as Awaited<
ReturnType<typeof spawn>
>);
await expect(
captureDeviceRunSessionPreviewAsync({
runtimePlatform: BuildRuntimePlatform.LINUX,
device: 'no-device-id',
env: {},
signal: new AbortController().signal,
})
).rejects.toThrow('Expected exactly one connected Android device');
});
it('cleans up temporary files when capture fails', async () => {
jest.mocked(spawn).mockRejectedValue(new Error('capture failed'));
await expect(
captureDeviceRunSessionPreviewAsync({
runtimePlatform: BuildRuntimePlatform.DARWIN,
device: 'device',
env: {},
signal: new AbortController().signal,
})
).rejects.toThrow('capture failed');
expect(
(await readdir(os.tmpdir())).filter(name => name.startsWith('session-preview-'))
).toEqual([]);
});
});
describe(ensureMacosPreviewEncoderInstalledAsync, () => {
const logger = { info: jest.fn() } as unknown as bunyan;
it.each([true, false])(
'installs webp only when cwebp is missing (present: %s)',
async present => {
jest.mocked(spawn).mockReset();
jest
.mocked(spawn)
.mockImplementation(
command =>
(command === 'cwebp' && !present
? Promise.reject(new Error('command not found: cwebp'))
: Promise.resolve({ stdout: '', stderr: '' })) as ReturnType<typeof spawn>
);
await ensureMacosPreviewEncoderInstalledAsync({ env: { HOME: '/Users/expo' }, logger });
const brewCalls = jest.mocked(spawn).mock.calls.filter(([command]) => command === 'brew');
if (present) {
expect(brewCalls).toEqual([]);
} else {
expect(brewCalls).toEqual([
[
'brew',
['install', 'webp'],
{ env: { HOME: '/Users/expo', HOMEBREW_NO_AUTO_UPDATE: '1' }, logger },
],
]);
}
}
);
it('rejects when webp cannot be installed', async () => {
jest.mocked(spawn).mockReset();
jest.mocked(spawn).mockRejectedValue(new Error('brew failed'));
await expect(ensureMacosPreviewEncoderInstalledAsync({ env: {}, logger })).rejects.toThrow(
'brew failed'
);
});
});
@@ -0,0 +1,200 @@
import type { bunyan } from '@expo/logger';
import type { BuildStepEnv } from '@expo/steps';
import { Client, fetchExchange } from '@urql/core';
import { execFileSync } from 'node:child_process';
import { copyFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
import { tmpdir } from 'node:os';
import path from 'node:path';
import type { CustomBuildContext } from '../../../customBuildContext';
import {
findUnlistedDeviceScreenRecordingsAsync,
uploadDeviceRunSessionScreenRecordingsAsync,
} from '../deviceRunSessionScreenRecordings';
// Runs with `yarn jest-ffmpeg`, which needs ffmpeg and ffprobe on PATH.
jest.unmock('node-fetch');
jest.unmock('node:fs');
jest.unmock('node:fs/promises');
let handle: (request: IncomingMessage, response: ServerResponse) => void;
let server: ReturnType<typeof createServer>;
let ctx: CustomBuildContext;
let url: string;
let directory: string;
let creationBodies: string[];
beforeEach(async () => {
creationBodies = [];
directory = await mkdtemp(path.join(tmpdir(), 'recording-recovery-'));
server = createServer((request, response) => {
if (request.url === '/graphql') {
request.setEncoding('utf8');
let body = '';
request.on('data', chunk => (body += chunk));
request.on('end', () => {
creationBodies.push(body);
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify({
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: { url: `${url}/artifact`, headers: {} },
},
},
},
})
);
});
} else {
handle(request, response);
}
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Missing test server address');
}
url = `http://127.0.0.1:${address.port}`;
ctx = {
graphqlClient: new Client({
url: `${url}/graphql`,
exchanges: [fetchExchange],
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
}),
} as CustomBuildContext;
});
afterEach(async () => {
server.closeAllConnections();
await new Promise<void>(resolve => server.close(() => resolve()));
await rm(directory, { recursive: true, force: true });
});
it('recovers unlisted recordings from a killed host and flags the unfinished ones as partial', async () => {
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const env = process.env as BuildStepEnv;
const cut = path.join(directory, 'cut');
const empty = path.join(directory, 'empty');
const failed = path.join(directory, 'failed');
const done = path.join(directory, 'done');
await mkdir(cut);
await mkdir(empty);
await mkdir(failed);
await mkdir(done);
const manifest = {
udid: 'emulator-5554',
deviceName: 'Pixel',
runtimeDisplayName: 'Android 16',
status: 'recording',
recording: 'recording.mp4.partial',
firstFrameWallClock: { iso8601: '2026-07-10T10:00:00.000Z' },
width: 128,
height: 96,
};
execFileSync('ffmpeg', [
'-v',
'error',
'-f',
'lavfi',
'-i',
'color=c=red:s=128x96:r=1',
'-t',
'3',
'-c:v',
'libx264',
'-g',
'1',
'-pix_fmt',
'yuv420p',
'-movflags',
'frag_keyframe+empty_moov',
'-f',
'mp4',
path.join(cut, 'recording.mp4.partial'),
]);
await writeFile(path.join(cut, 'session.json'), JSON.stringify(manifest));
await writeFile(path.join(empty, 'recording.mp4.partial'), new Uint8Array(28));
await writeFile(path.join(empty, 'session.json'), JSON.stringify(manifest));
await copyFile(
path.join(cut, 'recording.mp4.partial'),
path.join(failed, 'recording.mp4.partial')
);
await writeFile(
path.join(failed, 'session.json'),
JSON.stringify({
...manifest,
status: 'failed',
error: 'Recording finalization exceeded 30000 ms.',
})
);
await copyFile(path.join(cut, 'recording.mp4.partial'), path.join(done, 'recording.mp4'));
await writeFile(
path.join(done, 'session.json'),
JSON.stringify({ ...manifest, status: 'complete', recording: 'recording.mp4' })
);
const recordings = await findUnlistedDeviceScreenRecordingsAsync({
root: directory,
env,
logger,
});
expect(recordings).toEqual([
{
udid: 'emulator-5554',
deviceName: 'Pixel',
runtimeDisplayName: 'Android 16',
directory: cut,
},
{
udid: 'emulator-5554',
deviceName: 'Pixel',
runtimeDisplayName: 'Android 16',
directory: done,
},
{
udid: 'emulator-5554',
deviceName: 'Pixel',
runtimeDisplayName: 'Android 16',
directory: failed,
},
]);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('empty/recording.mp4.partial'));
let putBytes = 0;
handle = (request, response) => {
request.on('data', chunk => (putBytes += chunk.length));
request.on('end', () => {
response.statusCode = 200;
response.end();
});
};
await expect(
uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
logger,
deviceRunSessionId: 'drs-id',
recordings,
})
).resolves.toBe(true);
expect(putBytes).toBe(3 * (await stat(path.join(cut, 'recording.mp4.partial'))).size);
const inputs = creationBodies
.map(body => JSON.parse(body).variables.input)
.sort((a, b) => a.filename.localeCompare(b.filename));
expect(inputs.map(input => input.filename)).toEqual(['cut.mp4', 'done.mp4', 'failed.mp4']);
expect(inputs.map(input => input.name.includes(', partial)'))).toEqual([true, false, true]);
expect(inputs[1].metadata).not.toHaveProperty('partial');
expect(inputs[0].metadata).toMatchObject({
partial: true,
partialReason: 'The Device Hub stopped before it could finalize the recording.',
width: 128,
height: 96,
});
expect(inputs[2].metadata).toMatchObject({
partial: true,
partialReason: 'Recording finalization exceeded 30000 ms.',
});
});
@@ -0,0 +1,47 @@
import { screenshotArtifactDetails } from '../deviceRunSessionScreenshotNames';
const timestamp = '2026-09-24T08-45-59-123Z';
const name = 'Screenshot 2026-09-24 08:45:59 UTC';
const gitCommitHash = 'a1b2c3d' + '0'.repeat(33);
const session = {
platform: 'IOS' as const,
app: { slug: 'workflow-project' },
build: { id: 'installed-build', gitCommitHash, app: { slug: 'my-app' } },
};
it('omits the commit for archive and empty sessions', () => {
expect(screenshotArtifactDetails(timestamp, { ...session, build: null })).toEqual({
name,
filename: `workflow-project-ios-${timestamp}.png`,
metadata: { __eas_type: 'screenshot', appSlug: 'workflow-project', platform: 'ios' },
});
});
it.each([null, 'not-a-commit'])('omits a missing or invalid build commit: %s', gitCommitHash => {
const result = screenshotArtifactDetails(timestamp, {
...session,
build: { ...session.build, gitCommitHash },
});
expect(result.filename).toBe(`my-app-ios-${timestamp}.png`);
expect(result.metadata.gitCommitHash).toBeUndefined();
});
it('sanitizes and bounds the app slug for filesystem-safe names', () => {
const result = screenshotArtifactDetails(timestamp, {
...session,
build: { ...session.build, app: { slug: '../../My App/' + 'x'.repeat(100) } },
});
expect(result.filename).toMatch(/^My-App-x+-ios-a1b2c3d-/);
expect(result.filename).not.toMatch(/[/\\]/);
expect(result.filename.length).toBeLessThan(150);
});
it('accepts a full SHA-256 commit hash', () => {
const gitCommitHash = 'b'.repeat(64);
const result = screenshotArtifactDetails(timestamp, {
...session,
build: { ...session.build, gitCommitHash },
});
expect(result.filename).toBe(`my-app-ios-bbbbbbb-${timestamp}.png`);
expect(result.metadata.gitCommitHash).toBe(gitCommitHash);
});
@@ -0,0 +1,398 @@
import type { bunyan } from '@expo/logger';
import fs from 'node:fs';
import { randomBytes } from 'node:crypto';
import { lstat, mkdtemp, readdir, rename, rm, symlink, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import {
startDeviceRunSessionScreenshotsAsync,
uploadDeviceRunSessionScreenshotsAsync,
} from '../deviceRunSessionScreenshots';
jest.mock('../deviceRunSessionArtifacts');
jest.mock('../../../sentry');
const ctx = {} as CustomBuildContext;
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const options = { deviceRunSessionId: 'session-id', logger };
let directory: string;
let uploaded: Buffer[];
let failedUploads: Map<string, { attempts: number; lastError: Error }>;
beforeEach(async () => {
jest.clearAllMocks();
directory = await mkdtemp(path.join(os.tmpdir(), 'screenshots-test-'));
uploaded = [];
failedUploads = new Map();
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_ctx, { stream }) => {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk));
}
uploaded.push(Buffer.concat(chunks));
});
});
afterEach(async () => {
for (const [, { stream }] of jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls) {
stream.destroy();
}
await rm(directory, { recursive: true, force: true });
});
const flush = () =>
uploadDeviceRunSessionScreenshotsAsync(ctx, {
...options,
directory,
failedUploads,
signal: new AbortController().signal,
});
it('uploads completed captures with their bytes and stable IDs, ignoring partial files and symlinks', async () => {
const filename = 'screenshot-2026-09-24T08-45-59-123Z-ffffffffffff.png';
// Sorts before the real file, so following the link would upload it before the target is gone.
const link = 'screenshot-2026-09-24T08-45-59-123Z-000000000000.png';
await writeFile(path.join(directory, filename), 'png-content');
await writeFile(path.join(directory, `${filename}.tmp`), 'partial');
await symlink(path.join(directory, filename), path.join(directory, link));
await flush();
await flush();
expect(uploaded).toEqual([Buffer.from('png-content')]);
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledTimes(1);
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
deviceRunSessionId: 'session-id',
artifactId: filename.slice(0, -4),
name: 'Screenshot 2026-09-24 08:45:59 UTC',
filename: 'screenshot-2026-09-24T08-45-59-123Z.png',
kind: 'screenshot',
size: 11,
metadata: { __eas_type: 'screenshot' },
})
);
expect(await readdir(directory)).not.toContain(filename);
expect((await lstat(path.join(directory, link))).isSymbolicLink()).toBe(true);
});
it('uploads captures that never failed first, oldest first, before retrying failed ones', async () => {
const failed = 'screenshot-2026-09-24T08-45-00-000Z-aaaaaaaaaaaa.png';
const newer = 'screenshot-2026-09-24T08-45-59-123Z-bbbbbbbbbbbb.png';
const older = 'screenshot-2026-09-24T08-45-30-000Z-cccccccccccc.png';
for (const name of [newer, failed, older]) {
await writeFile(path.join(directory, name), name);
}
failedUploads.set(failed, { attempts: 1, lastError: new Error('stalled') });
await flush();
expect(
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mock.calls.map(([, { artifactId }]) => artifactId)
).toEqual([older, newer, failed].map(name => name.slice(0, -4)));
});
it('keeps failed uploads and retries the same artifact ID, logging the failed attempt count', async () => {
const filename = `screenshot-2026-09-24T08-45-59-123Z-${randomBytes(6).toString('hex')}.png`;
await writeFile(path.join(directory, filename), 'png');
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementationOnce(async () => {
throw new Error('offline');
});
await flush();
expect(await readdir(directory)).toContain(filename);
await flush();
expect(uploaded).toEqual([Buffer.from('png')]);
expect(await readdir(directory)).toEqual([]);
expect(
jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls.map(([, args]) => args.artifactId)
).toEqual([filename.slice(0, -4), filename.slice(0, -4)]);
expect(logger.info).toHaveBeenCalledWith(
expect.anything(),
expect.stringMatching(
/^Uploaded preview screenshot screenshot-2026-09-24T08-45-59-123Z\.png \(3 B\) in \d+ ms after 1 failed attempt\.$/
)
);
expect(failedUploads.size).toBe(0);
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('uploads atomic captures immediately, including a capture arriving during another upload', async () => {
jest.useFakeTimers();
const collector = await startDeviceRunSessionScreenshotsAsync(ctx, options);
let releaseFirstUpload!: () => void;
const firstUploadGate = new Promise<void>(resolve => {
releaseFirstUpload = resolve;
});
let signalFirstUpload!: () => void;
const firstUploadStarted = new Promise<void>(resolve => {
signalFirstUpload = resolve;
});
let signalSecondUpload!: () => void;
const secondUploadStarted = new Promise<void>(resolve => {
signalSecondUpload = resolve;
});
const uploadNormally = jest.mocked(uploadDeviceRunSessionArtifactAsync).getMockImplementation();
if (!uploadNormally) {
throw new Error('Expected the default upload implementation.');
}
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (...args) => {
signalFirstUpload();
await firstUploadGate;
await uploadNormally(...args);
})
.mockImplementationOnce(async (...args) => {
signalSecondUpload();
await uploadNormally(...args);
});
const firstFile = path.join(
collector.directory,
'screenshot-2026-09-24T08-45-59-123Z-aaaaaaaaaaaa.png'
);
const secondFile = path.join(
collector.directory,
'screenshot-2026-09-24T08-45-59-124Z-bbbbbbbbbbbb.png'
);
try {
await writeFile(`${firstFile}.tmp`, 'first-capture');
await rename(`${firstFile}.tmp`, firstFile);
await firstUploadStarted;
await writeFile(`${secondFile}.tmp`, 'second-capture');
await rename(`${secondFile}.tmp`, secondFile);
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledTimes(1);
releaseFirstUpload();
await secondUploadStarted;
await collector.finishAsync(true);
expect(uploaded).toEqual([Buffer.from('first-capture'), Buffer.from('second-capture')]);
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledTimes(2);
} finally {
releaseFirstUpload();
jest.useRealTimers();
await collector.finishAsync(true);
await rm(collector.directory, { recursive: true, force: true });
}
});
it('uses periodic scans when watching is unavailable and does not overlap uploads', async () => {
jest.useFakeTimers();
const watchSpy = jest.spyOn(fs, 'watch').mockImplementation(() => {
throw new Error('Watching unavailable');
});
const collector = await startDeviceRunSessionScreenshotsAsync(ctx, options);
let releaseUpload!: () => void;
const uploadGate = new Promise<void>(resolve => {
releaseUpload = resolve;
});
let signalUploadStarted!: () => void;
const uploadStarted = new Promise<void>(resolve => {
signalUploadStarted = resolve;
});
try {
await writeFile(
path.join(collector.directory, 'screenshot-2026-09-24T08-45-59-123Z-aaaaaaaaaaaa.png'),
'live-capture'
);
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementationOnce(async () => {
signalUploadStarted();
await uploadGate;
});
jest.advanceTimersByTime(30_000);
await uploadStarted;
jest.advanceTimersByTime(60_000);
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledTimes(1);
releaseUpload();
await collector.finishAsync(true);
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledTimes(1);
} finally {
releaseUpload();
watchSpy.mockRestore();
jest.useRealTimers();
await collector.finishAsync(true);
await rm(collector.directory, { recursive: true, force: true });
}
});
it('warns for the first three attempts and every tenth after, without reporting to Sentry', async () => {
const filename = `screenshot-2026-09-24T08-45-59-123Z-${randomBytes(6).toString('hex')}.png`;
await writeFile(path.join(directory, filename), 'png');
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockRejectedValue(new Error('offline'));
for (let i = 0; i < 12; i++) {
await flush();
}
expect(jest.mocked(logger.warn).mock.calls.map(([fields]) => fields.attempt)).toEqual([
1, 2, 3, 10,
]);
expect(logger.warn).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ file: path.join(directory, filename), attempt: 1, size: 3 }),
'Could not upload preview screenshot (attempt 1). Keeping it for retry.'
);
expect(logger.warn).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ attempt: 2 }),
'Could not upload preview screenshot (attempt 2). Keeping it for retry.'
);
expect(failedUploads.get(filename)).toEqual({
attempts: 12,
lastError: expect.objectContaining({ message: 'offline' }),
});
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('removes an empty directory on finish and returns the same task when finish repeats', async () => {
const collector = await startDeviceRunSessionScreenshotsAsync(ctx, options);
const finish = collector.finishAsync(true);
expect(collector.finishAsync(true)).toBe(finish);
await finish;
await expect(readdir(collector.directory)).rejects.toMatchObject({ code: 'ENOENT' });
expect(logger.info).toHaveBeenCalledWith('Uploaded 0 preview screenshots during the session.');
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('keeps the directory and warns when the session host is still running', async () => {
const collector = await startDeviceRunSessionScreenshotsAsync(ctx, options);
try {
await collector.finishAsync(false);
expect(await readdir(collector.directory)).toEqual([]);
expect(logger.warn).toHaveBeenCalledWith(
{ directory: collector.directory },
'The session host is still running, so preview screenshots it saves from now on are not uploaded.'
);
expect(Sentry.capture).not.toHaveBeenCalled();
} finally {
await rm(collector.directory, { recursive: true, force: true });
}
});
it('warns with the files not uploaded and their last error, and reports them to Sentry once', async () => {
const collector = await startDeviceRunSessionScreenshotsAsync(ctx, options);
const filename = 'screenshot-2026-09-24T08-45-59-123Z-aaaaaaaaaaaa.png';
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockRejectedValue(new Error('offline'));
try {
await writeFile(path.join(collector.directory, filename), 'png');
await collector.finishAsync(true);
const notUploaded = [{ name: filename, attempts: expect.any(Number), lastError: 'offline' }];
expect(logger.warn).toHaveBeenCalledWith(
{ directory: collector.directory, files: notUploaded },
'1 preview screenshot was not uploaded.'
);
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
'Preview screenshots were not uploaded',
expect.objectContaining({
message: '1 preview screenshot was not uploaded.',
}),
{ extras: { files: notUploaded } }
);
} finally {
await rm(collector.directory, { recursive: true, force: true });
}
});
it('reports a screenshot as not uploaded when the shutdown deadline cuts off its upload', async () => {
jest.useFakeTimers();
const collector = await startDeviceRunSessionScreenshotsAsync(ctx, options);
const filename = 'screenshot-2026-09-24T08-45-59-123Z-aaaaaaaaaaaa.png';
const file = path.join(collector.directory, filename);
let signalUploadStarted!: () => void;
const uploadStarted = new Promise<void>(resolve => {
signalUploadStarted = resolve;
});
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_ctx, { signal }) => {
signalUploadStarted();
await new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () => reject(signal.reason));
});
});
try {
await writeFile(file, 'png');
const finish = collector.finishAsync(true);
await uploadStarted;
jest.advanceTimersByTime(30_000);
await finish;
expect(logger.warn).toHaveBeenCalledWith(
{ directory: collector.directory },
'Preview screenshot flush timed out after 30 s.'
);
expect(logger.warn).toHaveBeenCalledWith(
{ file, size: 3 },
'Shutdown deadline reached before this preview screenshot uploaded. Keeping the file.'
);
expect(logger.warn).toHaveBeenCalledWith(
{ directory: collector.directory, files: [{ name: filename }] },
'1 preview screenshot was not uploaded.'
);
expect(logger.info).toHaveBeenCalledWith('Uploaded 0 preview screenshots during the session.');
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(await readdir(collector.directory)).toEqual([filename]);
} finally {
jest.useRealTimers();
await rm(collector.directory, { recursive: true, force: true });
}
});
it('reports a failure record once and deletes it while uploading captures from the same scan', async () => {
const png = 'screenshot-2026-09-24T08-45-59-123Z-aaaaaaaaaaaa.png';
const failed = 'screenshot-2026-09-24T08-45-59-124Z-bbbbbbbbbbbb.png';
const record = { file: failed, error: 'ENOSPC: no space left', at: '2026-09-24T08:45:59.124Z' };
await writeFile(path.join(directory, png), 'png');
await writeFile(
path.join(directory, 'screenshot-2026-09-24T08-45-59-124Z-bbbbbbbbbbbb.failed.json'),
JSON.stringify(record)
);
expect(await flush()).toEqual({ uploaded: 1, saveFailures: 1 });
expect(await flush()).toEqual({ uploaded: 0, saveFailures: 0 });
expect(uploaded).toEqual([Buffer.from('png')]);
expect(await readdir(directory)).toEqual([]);
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
record,
'The session host could not save a preview screenshot.'
);
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
'The session host could not save a preview screenshot',
expect.objectContaining({ message: 'ENOSPC: no space left' }),
{ extras: { file: failed, at: record.at } }
);
});
it('warns about a malformed failure record, deletes it, and does not report it to Sentry', async () => {
const file = path.join(directory, 'screenshot-2026-09-24T08-45-59-124Z-bbbbbbbbbbbb.failed.json');
await writeFile(file, JSON.stringify({ file: 'screenshot.png' }));
await flush();
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.anything(), file },
'Could not read a preview screenshot failure record.'
);
expect(await readdir(directory)).toEqual([]);
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('handles a failure record as soon as it appears and counts it on finish', async () => {
jest.useFakeTimers();
const collector = await startDeviceRunSessionScreenshotsAsync(ctx, options);
let signalReported!: () => void;
const reported = new Promise<void>(resolve => {
signalReported = resolve;
});
jest.mocked(Sentry.capture).mockImplementationOnce(() => signalReported());
const record = path.join(
collector.directory,
'screenshot-2026-09-24T08-45-59-124Z-bbbbbbbbbbbb.failed.json'
);
try {
await writeFile(`${record}.tmp`, JSON.stringify({ file: 'x.png', error: 'EIO', at: 'now' }));
await rename(`${record}.tmp`, record);
await reported;
await collector.finishAsync(true);
expect(logger.info).toHaveBeenCalledWith(
'Uploaded 0 preview screenshots during the session; the session host could not save 1.'
);
} finally {
jest.useRealTimers();
await collector.finishAsync(true);
await rm(collector.directory, { recursive: true, force: true });
}
});
@@ -0,0 +1,265 @@
import type { bunyan } from '@expo/logger';
import { Client, fetchExchange } from '@urql/core';
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
import { tmpdir } from 'node:os';
import path from 'node:path';
import type { CustomBuildContext } from '../../../customBuildContext';
import {
loadScreenshotSessionAsync,
screenshotArtifactDetails,
} from '../deviceRunSessionScreenshotNames';
import { uploadDeviceRunSessionScreenshotsAsync } from '../deviceRunSessionScreenshots';
jest.unmock('node-fetch');
jest.mock('../../../sentry');
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(r => {
resolve = r;
});
return { promise, resolve };
}
let handle: (request: IncomingMessage, response: ServerResponse) => void;
let server: ReturnType<typeof createServer>;
let ctx: CustomBuildContext;
let url: string;
let directory: string;
let creates: number;
let authorization: string | undefined;
let stallCreation: boolean;
let creationStarted: ReturnType<typeof deferred<IncomingMessage>>;
let creationBodies: string[];
const installedCommit = 'a1b2c3d' + '0'.repeat(33);
let sessionLookupFails: boolean;
let sessionLookups: number;
let uploadQuery: string;
beforeEach(async () => {
creates = 0;
sessionLookups = 0;
sessionLookupFails = false;
uploadQuery = '';
authorization = undefined;
stallCreation = false;
creationBodies = [];
creationStarted = deferred();
directory = await mkdtemp(path.join(tmpdir(), 'recording-upload-http-'));
server = createServer((request, response) => {
if (request.url?.startsWith('/graphql')) {
authorization = request.headers.authorization;
request.setEncoding('utf8');
let body = '';
if (request.method === 'GET') {
const requestUrl = new URL(request.url, 'http://localhost');
body = JSON.stringify({ query: requestUrl.searchParams.get('query') });
}
request.on('data', chunk => (body += chunk));
request.on('end', () => {
const operation = JSON.parse(body);
if (operation.query.includes('query ScreenshotSession')) {
sessionLookups++;
response.setHeader('Content-Type', 'application/json');
if (sessionLookupFails) {
response.end(JSON.stringify({ errors: [{ message: 'Lookup unavailable' }] }));
} else {
response.end(
JSON.stringify({
data: {
deviceRunSessions: {
byId: {
platform: 'IOS',
app: { slug: 'session-project' },
build: {
id: 'installed-build',
gitCommitHash: installedCommit,
app: { slug: 'my-app' },
},
},
},
},
})
);
}
return;
}
creates++;
creationBodies.push(body);
creationStarted.resolve(request);
if (stallCreation) {
return;
}
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify({
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: { url: `${url}/artifact${uploadQuery}`, headers: {} },
},
},
},
})
);
});
} else {
handle(request, response);
}
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Missing test server address');
}
url = `http://127.0.0.1:${address.port}`;
ctx = {
graphqlClient: new Client({
url: `${url}/graphql`,
exchanges: [fetchExchange],
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
}),
} as CustomBuildContext;
});
afterEach(async () => {
server.closeAllConnections();
await new Promise<void>(resolve => server.close(() => resolve()));
await rm(directory, { recursive: true, force: true });
});
it('publishes a manual preview PNG through the artifact GraphQL and HTTP upload path', async () => {
const payload = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aF9sAAAAASUVORK5CYII=',
'base64'
);
const filename = 'screenshot-2026-09-24T08-45-59-123Z-a1b2c3d4e5f6.png';
await writeFile(path.join(directory, filename), payload);
const bodies: Buffer[] = [];
handle = (request, response) => {
const chunks: Buffer[] = [];
request.on('data', chunk => chunks.push(Buffer.from(chunk)));
request.on('end', () => {
bodies.push(Buffer.concat(chunks));
response.end();
});
};
const session = loadScreenshotSessionAsync(ctx, 'drs-id', {
warn: jest.fn(),
} as unknown as bunyan);
await uploadDeviceRunSessionScreenshotsAsync(ctx, {
session,
directory,
deviceRunSessionId: 'drs-id',
failedUploads: new Map(),
logger: { info: jest.fn(), warn: jest.fn() } as unknown as bunyan,
signal: new AbortController().signal,
});
expect(bodies).toEqual([payload]);
expect(creates).toBe(1);
expect(sessionLookups).toBe(1);
expect(authorization).toBe('Bearer test-auth');
expect(JSON.parse(creationBodies[0]).variables).toEqual(
expect.objectContaining({
deviceRunSessionId: 'drs-id',
input: expect.objectContaining({
name: 'Screenshot 2026-09-24 08:45:59 UTC',
filename: 'my-app-ios-a1b2c3d-2026-09-24T08-45-59-123Z.png',
kind: 'screenshot',
size: payload.length,
metadata: {
__eas_type: 'screenshot',
appSlug: 'my-app',
platform: 'ios',
buildId: 'installed-build',
gitCommitHash: installedCommit,
},
}),
})
);
await expect(stat(path.join(directory, filename))).rejects.toMatchObject({ code: 'ENOENT' });
});
it('rereads the PNG when the artifact PUT is retried', async () => {
const filename = 'screenshot-2026-09-24T08-45-59-123Z-a1b2c3d4e5f6.png';
await writeFile(path.join(directory, filename), 'retried-capture');
const bodies: string[] = [];
handle = (request, response) => {
let body = '';
request.setEncoding('utf8');
request.on('data', chunk => (body += chunk));
request.on('end', () => {
bodies.push(body);
response.statusCode = bodies.length === 1 ? 503 : 200;
response.end();
});
};
const uploaded = await uploadDeviceRunSessionScreenshotsAsync(ctx, {
directory,
deviceRunSessionId: 'drs-id',
failedUploads: new Map(),
logger: { info: jest.fn(), warn: jest.fn() } as unknown as bunyan,
signal: new AbortController().signal,
});
expect(uploaded).toEqual({ uploaded: 1, saveFailures: 0 });
expect(creates).toBe(1);
expect(bodies).toEqual(['retried-capture', 'retried-capture']);
await expect(stat(path.join(directory, filename))).rejects.toMatchObject({ code: 'ENOENT' });
});
it('aborts a stalled GraphQL upload request and keeps the PNG', async () => {
stallCreation = true;
const filename = 'screenshot-2026-09-24T08-45-59-123Z-a1b2c3d4e5f6.png';
await writeFile(path.join(directory, filename), 'png');
const controller = new AbortController();
const upload = uploadDeviceRunSessionScreenshotsAsync(ctx, {
directory,
deviceRunSessionId: 'drs-id',
failedUploads: new Map(),
logger: { info: jest.fn(), warn: jest.fn() } as unknown as bunyan,
signal: controller.signal,
});
await creationStarted.promise;
controller.abort();
await upload;
expect((await stat(path.join(directory, filename))).size).toBe(3);
});
it('keeps the signed upload URL out of the error when the upload connection drops', async () => {
uploadQuery = '?X-Goog-Signature=secret-signature';
handle = request => request.socket.destroy();
const filename = 'screenshot-2026-09-24T08-45-59-123Z-a1b2c3d4e5f6.png';
await writeFile(path.join(directory, filename), 'png');
const failedUploads = new Map<string, { attempts: number; lastError: Error }>();
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
await uploadDeviceRunSessionScreenshotsAsync(ctx, {
directory,
deviceRunSessionId: 'drs-id',
failedUploads,
logger,
signal: new AbortController().signal,
});
const error = failedUploads.get(filename)?.lastError;
expect(error?.message).toBe(
`Failed to upload device run session artifact ${filename.slice(0, -4)}: ECONNRESET.`
);
expect(error?.cause).toBeUndefined();
expect(JSON.stringify(jest.mocked(logger.warn).mock.calls)).not.toContain('secret-signature');
expect(JSON.stringify(jest.mocked(logger.warn).mock.calls)).not.toContain('/artifact');
});
it('falls back without using the workflow commit when the session lookup fails', async () => {
sessionLookupFails = true;
ctx.metadata = { appName: 'wrong-app', gitCommitHash: 'f'.repeat(40) };
const logger = { warn: jest.fn() } as unknown as bunyan;
const session = await loadScreenshotSessionAsync(ctx, 'drs-id', logger);
expect(session).toBeNull();
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ deviceRunSessionId: 'drs-id' }),
'Could not load screenshot session details; using capture timestamps.'
);
expect(screenshotArtifactDetails('2026-09-24T08-45-59-123Z', session)).toEqual({
name: 'Screenshot 2026-09-24 08:45:59 UTC',
filename: 'screenshot-2026-09-24T08-45-59-123Z.png',
metadata: { __eas_type: 'screenshot' },
});
});
@@ -0,0 +1,80 @@
import {
DeviceRunSessionTimeoutError,
withDeviceRunSessionTimeoutAsync,
} from '../deviceRunSessionTimeout';
afterEach(() => jest.useRealTimers());
it('aborts a stalled operation when its deadline expires', async () => {
jest.useFakeTimers();
let signal: AbortSignal | undefined;
const operation = withDeviceRunSessionTimeoutAsync(
{ name: 'test operation', timeoutMs: 100 },
async value => {
signal = value;
return await new Promise<void>(() => {});
}
);
const rejected = expect(operation).rejects.toThrow('test operation timed out after 100ms');
const typed = expect(operation).rejects.toBeInstanceOf(DeviceRunSessionTimeoutError);
await jest.advanceTimersByTimeAsync(100);
await rejected;
await typed;
expect(signal?.aborted).toBe(true);
expect(jest.getTimerCount()).toBe(0);
});
it('does not start an operation with an already aborted parent', async () => {
const operation = jest.fn();
await expect(
withDeviceRunSessionTimeoutAsync(
{
name: 'test',
timeoutMs: 100,
signal: AbortSignal.abort(new Error('parent ended')),
},
operation
)
).rejects.toThrow('parent ended');
expect(operation).not.toHaveBeenCalled();
});
it('clears the deadline when an operation succeeds', async () => {
jest.useFakeTimers();
await expect(
withDeviceRunSessionTimeoutAsync({ name: 'test', timeoutMs: 100 }, async () => 42)
).resolves.toBe(42);
expect(jest.getTimerCount()).toBe(0);
});
it('ignores a deadline reset that arrives after the operation settled', async () => {
jest.useFakeTimers();
let resetDeadline!: () => void;
await expect(
withDeviceRunSessionTimeoutAsync({ name: 'test', timeoutMs: 100 }, async (_signal, reset) => {
resetDeadline = reset;
return 42;
})
).resolves.toBe(42);
resetDeadline();
expect(jest.getTimerCount()).toBe(0);
});
it('keeps an operation alive while it reports progress', async () => {
jest.useFakeTimers();
const operation = withDeviceRunSessionTimeoutAsync(
{ name: 'test operation', timeoutMs: 100 },
async (signal, resetDeadline) => {
await new Promise<void>(resolve => setTimeout(resolve, 80));
resetDeadline();
await new Promise<void>(resolve => setTimeout(resolve, 80));
resetDeadline();
await new Promise<void>(resolve => setTimeout(resolve, 80));
return signal.aborted;
}
);
const settled = expect(operation).resolves.toBe(false);
await jest.advanceTimersByTimeAsync(240);
await settled;
expect(jest.getTimerCount()).toBe(0);
});
@@ -0,0 +1,329 @@
import type { bunyan } from '@expo/logger';
import type { BuildStepEnv } from '@expo/steps';
import { Client, fetchExchange } from '@urql/core';
import { createReadStream } from 'node:fs';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
import type { Socket } from 'node:net';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { Readable } from 'node:stream';
import { setTimeout as delay } from 'node:timers/promises';
import type { CustomBuildContext } from '../../../customBuildContext';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import { findUnlistedDeviceScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
jest.unmock('node-fetch');
jest.unmock('node:fs');
jest.unmock('node:fs/promises');
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(r => {
resolve = r;
});
return { promise, resolve };
}
const metadata = {
deviceRunSessionId: 'drs-id',
artifactId: 'recording-id',
name: 'Recording',
filename: 'recording.mp4',
kind: 'screen-recording',
size: 16,
};
let handle: (request: IncomingMessage, response: ServerResponse) => void;
let server: ReturnType<typeof createServer>;
let ctx: CustomBuildContext;
let url: string;
let directory: string;
let creates: number;
let authorization: string | undefined;
let stallCreation: boolean;
let creationStarted: ReturnType<typeof deferred<IncomingMessage>>;
beforeEach(async () => {
creates = 0;
authorization = undefined;
stallCreation = false;
creationStarted = deferred();
directory = await mkdtemp(path.join(tmpdir(), 'recording-upload-http-'));
server = createServer((request, response) => {
if (request.url === '/graphql') {
creates++;
authorization = request.headers.authorization;
creationStarted.resolve(request);
request.resume();
request.on('end', () => {
if (stallCreation) {
return;
}
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify({
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: { url: `${url}/artifact`, headers: {} },
},
},
},
})
);
});
} else {
handle(request, response);
}
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Missing test server address');
}
url = `http://127.0.0.1:${address.port}`;
ctx = {
graphqlClient: new Client({
url: `${url}/graphql`,
exchanges: [fetchExchange],
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
}),
} as CustomBuildContext;
});
afterEach(async () => {
server.closeAllConnections();
await new Promise<void>(resolve => server.close(() => resolve()));
await rm(directory, { recursive: true, force: true });
});
it('replays the full file after a transient PUT failure without recreating the upload session', async () => {
const bodies: string[] = [];
handle = (request, response) => {
const chunks: string[] = [];
request.setEncoding('utf8');
request.on('data', chunk => chunks.push(chunk));
request.on('end', () => {
bodies.push(chunks.join(''));
response.statusCode = bodies.length === 1 ? 503 : 200;
response.end();
});
};
const file = path.join(directory, 'recording.mp4');
const payload = '*'.repeat(128 * 1024);
await writeFile(file, payload);
const streams: Readable[] = [];
const open = () => {
const stream = createReadStream(file);
streams.push(stream);
return stream;
};
await uploadDeviceRunSessionArtifactAsync(ctx, {
...metadata,
size: payload.length,
stream: open(),
reopenStream: open,
});
expect(creates).toBe(1);
expect(authorization).toBe('Bearer test-auth');
expect(bodies).toEqual([payload, payload]);
expect(streams).toHaveLength(2);
expect(streams.every(stream => stream.destroyed)).toBe(true);
});
it('keeps a slow PUT alive while its body is still flowing', async () => {
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
let received = 0;
let onChunk = () => {};
handle = (request, response) => {
request.on('data', chunk => {
received += chunk.length;
onChunk();
});
request.on('end', () => {
response.statusCode = 200;
response.end();
});
};
const waitForBytes = (total: number) =>
new Promise<void>(resolve => {
onChunk = () => {
if (received >= total) {
resolve();
}
};
onChunk();
});
const stream = new Readable({ read() {} });
const upload = uploadDeviceRunSessionArtifactAsync(ctx, { ...metadata, size: 3, stream });
const settled = expect(upload).resolves.toBeUndefined();
stream.push('a');
await waitForBytes(1);
await jest.advanceTimersByTimeAsync(60_000);
stream.push('b');
await waitForBytes(2);
await jest.advanceTimersByTimeAsync(60_000);
stream.push('c');
stream.push(null);
await settled;
expect(received).toBe(3);
jest.useRealTimers();
});
it('retries a PUT that stalled past the idle deadline with a fresh stream', async () => {
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
let puts = 0;
const firstBodyRead = deferred<void>();
handle = (request, response) => {
puts++;
request.resume();
request.on('end', () => {
if (puts === 1) {
firstBodyRead.resolve();
return;
}
response.statusCode = 200;
response.end();
});
};
const streams: Readable[] = [];
const open = () => {
const stream = Readable.from('recording');
streams.push(stream);
return stream;
};
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
...metadata,
size: 9,
stream: open(),
reopenStream: open,
});
const settled = expect(upload).resolves.toBeUndefined();
await firstBodyRead.promise;
await jest.advanceTimersByTimeAsync(90_000);
await jest.advanceTimersByTimeAsync(500);
await settled;
expect(puts).toBe(2);
expect(streams).toHaveLength(2);
expect(streams.every(stream => stream.destroyed)).toBe(true);
jest.useRealTimers();
});
it('cancels an active PUT socket and destroys its source without retrying', async () => {
const started = deferred<void>();
const closed = deferred<void>();
let puts = 0;
handle = request => {
puts++;
request.socket.once('close', () => closed.resolve());
request.on('data', () => started.resolve());
};
const controller = new AbortController();
const stream = new Readable({ read() {} });
stream.push(Buffer.alloc(1024));
const reopenStream = jest.fn(() => Readable.from('unexpected retry'));
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
...metadata,
stream,
reopenStream,
signal: controller.signal,
});
const rejected = expect(upload).rejects.toThrow('test cancellation');
await started.promise;
controller.abort(new Error('test cancellation'));
await rejected;
await closed.promise;
expect(stream.destroyed).toBe(true);
expect(reopenStream).not.toHaveBeenCalled();
expect(puts).toBe(1);
});
it.each([200, 400, 503])(
'closes a stalled HTTP %s response before leaving or retrying the PUT',
async status => {
const sockets = new Set<Socket>();
let puts = 0;
let maxOpenSockets = 0;
handle = (request, response) => {
puts++;
sockets.add(request.socket);
maxOpenSockets = Math.max(maxOpenSockets, sockets.size);
request.socket.once('close', () => sockets.delete(request.socket));
request.resume();
request.on('end', () => {
response.writeHead(status, { 'Content-Length': '1000' });
response.flushHeaders();
response.write('partial response');
});
};
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
...metadata,
stream: Readable.from('recording'),
reopenStream: () => Readable.from('recording'),
});
if (status === 200) {
await expect(upload).resolves.toBeUndefined();
} else {
await expect(upload).rejects.toThrow(`HTTP ${status}`);
}
for (let i = 0; i < 100 && sockets.size > 0; i++) {
await delay(10);
}
expect(creates).toBe(1);
expect(puts).toBe(status === 503 ? 3 : 1);
expect(maxOpenSockets).toBe(1);
expect(sockets.size).toBe(0);
}
);
it('cancels urql session creation with auth intact and never starts a PUT', async () => {
stallCreation = true;
const puts = jest.fn();
handle = puts;
const controller = new AbortController();
const stream = Readable.from('recording');
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
...metadata,
stream,
signal: controller.signal,
});
const rejected = expect(upload).rejects.toThrow('test cancellation');
const request = await creationStarted.promise;
const closed = new Promise<void>(resolve => request.socket.once('close', resolve));
controller.abort(new Error('test cancellation'));
await rejected;
await closed;
expect(authorization).toBe('Bearer test-auth');
expect(creates).toBe(1);
expect(puts).not.toHaveBeenCalled();
expect(stream.destroyed).toBe(true);
});
it('says ffprobe is missing instead of calling an unlisted recording undecodable', async () => {
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const session = path.join(directory, 'session');
await mkdir(session);
await writeFile(path.join(session, 'recording.mp4.partial'), new Uint8Array(28));
await writeFile(
path.join(session, 'session.json'),
JSON.stringify({
udid: 'emulator-5554',
deviceName: 'Pixel',
runtimeDisplayName: 'Android 16',
status: 'recording',
recording: 'recording.mp4.partial',
})
);
const recordings = await findUnlistedDeviceScreenRecordingsAsync({
root: directory,
env: { ...process.env, PATH: path.join(directory, 'no-tools') } as BuildStepEnv,
logger,
});
expect(recordings).toEqual([]);
expect(logger.warn).toHaveBeenCalledWith(
'ffprobe is not available; cannot recover the unlisted recording session/recording.mp4.partial.'
);
});
@@ -0,0 +1,576 @@
import type { bunyan } from '@expo/logger';
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import * as ngrok from '@ngrok/ngrok';
import { access, mkdir, readdir, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import {
findUnlistedDeviceScreenRecordingsAsync,
uploadDeviceRunSessionScreenRecordingsAsync,
} from '../deviceRunSessionScreenRecordings';
import {
ensureMacosPreviewEncoderInstalledAsync,
startDeviceRunSessionPreview,
} from '../deviceRunSessionPreview';
import { startDeviceSessionHostAsync } from '../deviceSessionHost';
import { ensureFfmpegInstalledOnceAsync, spawnDetached } from '../remoteDeviceRunSession';
jest.mock('@ngrok/ngrok');
jest.mock('../deviceRunSessionArtifacts');
jest.mock('../../../sentry');
jest.mock('../serveSimMetricsRecorder', () => ({
readServeSimServersAsync: jest
.fn()
.mockResolvedValue([{ udid: 'emulator-5554', token: 'preview-token' }]),
}));
jest.mock('../../../utils/turtleFetch');
jest.mock('../remoteDeviceRunSession', () => ({
...jest.requireActual('../remoteDeviceRunSession'),
ensureFfmpegInstalledOnceAsync: jest.fn(),
fetchWebPreviewTurnArgsAsync: jest.fn().mockResolvedValue([]),
spawnDetached: jest.fn(),
}));
jest.mock('../deviceRunSessionPreview', () => ({
...jest.requireActual('../deviceRunSessionPreview'),
ensureMacosPreviewEncoderInstalledAsync: jest.fn(),
startDeviceRunSessionPreview: jest.fn(),
}));
jest.mock('../deviceRunSessionScreenRecordings', () => ({
...jest.requireActual('../deviceRunSessionScreenRecordings'),
findUnlistedDeviceScreenRecordingsAsync: jest.fn(),
uploadDeviceRunSessionScreenRecordingsAsync: jest.fn(),
}));
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(r => {
resolve = r;
});
return { promise, resolve };
}
const env = {
DEVICE_RUN_SESSION_ID: 'drs-id',
NGROK_AUTHTOKEN: 'token',
} as BuildStepEnv;
const ctx = {} as CustomBuildContext;
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const stopServer = jest.fn();
const closeTunnel = jest.fn();
const directories: string[] = [];
const baseDomain = 'preview.example.test';
async function startHostAsync() {
return await startDeviceSessionHostAsync(ctx, {
runtimePlatform: BuildRuntimePlatform.LINUX,
env,
logger,
timeoutMs: 10_000,
});
}
const stopSessionPreview = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
stopSessionPreview.mockResolvedValue(undefined);
jest.mocked(startDeviceRunSessionPreview).mockReturnValue({ stopAsync: stopSessionPreview });
jest.mocked(ensureFfmpegInstalledOnceAsync).mockResolvedValue(undefined);
jest.mocked(ensureMacosPreviewEncoderInstalledAsync).mockResolvedValue(undefined);
stopServer.mockResolvedValue(undefined);
closeTunnel.mockResolvedValue(undefined);
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockReset().mockResolvedValue(false);
jest.mocked(findUnlistedDeviceScreenRecordingsAsync).mockReset().mockResolvedValue([]);
jest.mocked(spawnDetached).mockImplementation(options => {
const flag = options.args.indexOf('--android-recording-directory');
if (flag >= 0) {
directories.push(options.args[flag + 1]);
}
return {
pid: undefined,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: stopServer,
};
});
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://public.example.test',
close: closeTunnel,
} as never);
jest.mocked(turtleFetch).mockImplementation(
async () =>
({
ok: true,
json: async () => ({ status: 'ready', device: 'emulator-5554' }),
}) as Awaited<ReturnType<typeof turtleFetch>>
);
});
afterEach(async () => {
jest.useRealTimers();
await Promise.all(
directories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))
);
});
it('records by default with no preview and finalizes before process stop, uploading exactly once', async () => {
const host = await startHostAsync();
expect(ngrok.forward).not.toHaveBeenCalled();
const directory = directories[0];
const recordings = [
{
udid: 'emulator-5554',
deviceName: 'Pixel',
runtimeDisplayName: 'Android 16',
directory: path.join(directory, 'session'),
},
];
await writeFile(path.join(directory, 'recordings.json'), JSON.stringify(recordings));
await mkdir(recordings[0].directory);
const started = deferred<void>();
const response = deferred<Awaited<ReturnType<typeof turtleFetch>>>();
jest.mocked(turtleFetch).mockImplementationOnce(async () => {
started.resolve();
return await response.promise;
});
const finishing = host.finishAsync();
expect(host.finishAsync()).toBe(finishing);
await started.promise;
expect(stopServer).not.toHaveBeenCalled();
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
const token =
jest.mocked(spawnDetached).mock.calls[0][0].env.EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN;
expect(token).toMatch(/^[a-f0-9]{64}$/);
expect(turtleFetch).toHaveBeenLastCalledWith(
expect.stringMatching(/\/_eas\/android-recording\/stop$/),
'POST',
{
headers: { Authorization: `Bearer ${token}` },
retries: 0,
shouldThrowOnNotOk: false,
signal: expect.any(AbortSignal),
}
);
response.resolve({ ok: true } as Awaited<ReturnType<typeof turtleFetch>>);
await finishing;
await host.finishAsync();
expect(stopServer).toHaveBeenCalledTimes(1);
expect(uploadDeviceRunSessionScreenRecordingsAsync).toHaveBeenCalledTimes(1);
expect(uploadDeviceRunSessionScreenRecordingsAsync).toHaveBeenCalledWith(ctx, {
logger,
deviceRunSessionId: 'drs-id',
recordings,
});
expect(stopServer.mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mock.invocationCallOrder[0]
);
await expect(host.openPreviewAsync({ baseDomain })).rejects.toThrow(
'after session host finalization'
);
});
it('coalesces preview opens and allows close/reopen without ending capture', async () => {
const host = await startHostAsync();
const opening = host.openPreviewAsync({ baseDomain });
expect(host.openPreviewAsync({ baseDomain })).toBe(opening);
const first = await opening;
const closing = first.closeAsync();
expect(first.closeAsync()).toBe(closing);
await closing;
const secondClose = jest.fn().mockResolvedValue(undefined);
jest.mocked(ngrok.forward).mockResolvedValueOnce({
url: () => 'https://replacement.example.test',
close: secondClose,
} as never);
const second = await host.openPreviewAsync({ baseDomain });
await first.closeAsync();
expect(await host.openPreviewAsync({ baseDomain })).toBe(second);
expect(secondClose).not.toHaveBeenCalled();
expect(spawnDetached).toHaveBeenCalledTimes(1);
expect(stopServer).not.toHaveBeenCalled();
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
expect(jest.mocked(turtleFetch).mock.calls.some(([, method]) => method === 'POST')).toBe(false);
await host.finishAsync();
expect(closeTunnel).toHaveBeenCalledTimes(1);
expect(secondClose).toHaveBeenCalledTimes(1);
expect(stopServer).toHaveBeenCalledTimes(1);
});
it('retries a failed tunnel without restarting the host', async () => {
const host = await startHostAsync();
jest.mocked(ngrok.forward).mockRejectedValueOnce(new Error('tunnel unavailable'));
await expect(host.openPreviewAsync({ baseDomain })).rejects.toThrow('tunnel unavailable');
expect(stopServer).not.toHaveBeenCalled();
await host.openPreviewAsync({ baseDomain });
expect(spawnDetached).toHaveBeenCalledTimes(1);
await host.finishAsync();
expect(closeTunnel).toHaveBeenCalledTimes(1);
});
it('drains an in-flight tunnel when finishing and rejects new opens', async () => {
const host = await startHostAsync();
const tunnel = deferred<Awaited<ReturnType<typeof ngrok.forward>>>();
jest.mocked(ngrok.forward).mockReturnValueOnce(tunnel.promise);
const opening = host.openPreviewAsync({ baseDomain });
const finishing = host.finishAsync();
await expect(host.openPreviewAsync({ baseDomain })).rejects.toThrow(
'after session host finalization'
);
tunnel.resolve({ url: () => 'https://late.example.test', close: closeTunnel } as never);
await expect(opening).rejects.toThrow('finalized while the preview was opening');
await finishing;
expect(closeTunnel).toHaveBeenCalledTimes(1);
expect(stopServer).toHaveBeenCalledTimes(1);
});
it('keeps cleanup and upload best-effort when tunnel close and finalization fail', async () => {
jest.mocked(spawnDetached).mockImplementationOnce(options => {
directories.push(options.args[options.args.indexOf('--android-recording-directory') + 1]);
return {
pid: undefined,
getOutput: () => '[serve-emu] emulator-5554 capture error: scrcpy exited with code 255',
getExitError: () => undefined,
stopAsync: stopServer,
};
});
const host = await startHostAsync();
await writeFile(path.join(directories[0], 'recordings.json'), '[]');
await host.openPreviewAsync({ baseDomain });
closeTunnel.mockRejectedValueOnce(new Error('tunnel close failure'));
jest.mocked(turtleFetch).mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => 'Error: scrcpy exited with code 255',
} as Awaited<ReturnType<typeof turtleFetch>>);
await host.finishAsync();
expect(stopServer).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{
err: expect.objectContaining({
message: expect.stringContaining('HTTP 500: Error: scrcpy exited with code 255'),
}),
},
'Could not finalize Android recording before shutdown.'
);
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not finalize Android recording before shutdown',
expect.any(Error),
{ level: 'warning' }
);
expect(uploadDeviceRunSessionScreenRecordingsAsync).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ hostOutput: '[serve-emu] emulator-5554 capture error: scrcpy exited with code 255' },
'Session host output around the recording failure.'
);
});
it('logs the reason and skips upload and output dump when the Hub never recorded', async () => {
jest.mocked(spawnDetached).mockImplementationOnce(options => {
directories.push(options.args[options.args.indexOf('--android-recording-directory') + 1]);
return {
pid: undefined,
getOutput: () => '[serve-emu] Android recording skipped',
getExitError: () => undefined,
stopAsync: stopServer,
};
});
const host = await startHostAsync();
jest.mocked(turtleFetch).mockResolvedValueOnce({
ok: false,
status: 409,
text: async () => 'Android recording requires exactly one booted emulator; found 0.',
} as Awaited<ReturnType<typeof turtleFetch>>);
await host.finishAsync();
expect(stopServer).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
'Android recording was not captured: Android recording requires exactly one booted emulator; found 0.'
);
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalledWith(
expect.anything(),
'Session host output around the recording failure.'
);
});
it('rolls back failed host startup without replacing the original error', async () => {
stopServer.mockRejectedValueOnce(new Error('stop failure'));
await expect(
startDeviceSessionHostAsync(ctx, {
runtimePlatform: BuildRuntimePlatform.LINUX,
env,
logger,
timeoutMs: 0,
})
).rejects.toThrow('Timed out waiting');
expect(stopServer).toHaveBeenCalledTimes(1);
const directory =
jest.mocked(spawnDetached).mock.calls[0][0].env.EXPO_DEVICE_HUB_SCREENSHOT_DIRECTORY;
if (!directory) {
throw new Error('Missing screenshot artifact directory');
}
// The host could not be stopped, so it may still write captures there.
await expect(access(directory)).resolves.toBeUndefined();
expect(ngrok.forward).not.toHaveBeenCalled();
expect(jest.mocked(turtleFetch).mock.calls.some(([, method]) => method === 'POST')).toBe(false);
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
const hostStillRunning =
'The session host is still running, so preview screenshots it saves from now on are not uploaded.';
expect(logger.warn).toHaveBeenCalledWith({ directory }, hostStillRunning);
const recordingWarnings = jest
.mocked(logger.warn)
.mock.calls.map(([, message]) => message)
.filter(message => message !== hostStillRunning);
expect(recordingWarnings).not.toContainEqual(expect.stringMatching(/finalize|upload/));
});
async function writeRecordingDescriptorAsync(directory: string) {
const child = path.join(directory, 'session');
await mkdir(child);
await writeFile(
path.join(directory, 'recordings.json'),
JSON.stringify([
{
udid: 'emulator-5554',
deviceName: 'Pixel',
runtimeDisplayName: 'Android',
directory: child,
},
])
);
}
it.each([true, false])(
'removes its recording root only when every upload succeeds (%s)',
async uploaded => {
const host = await startHostAsync();
const directory = directories[0];
await writeRecordingDescriptorAsync(directory);
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockResolvedValueOnce(uploaded);
await host.finishAsync();
if (uploaded) {
await expect(access(directory)).rejects.toMatchObject({ code: 'ENOENT' });
} else {
await expect(access(directory)).resolves.toBeUndefined();
}
}
);
it('finishes despite a stalled tunnel close', async () => {
const host = await startHostAsync();
await host.openPreviewAsync({ baseDomain });
const closing = deferred<void>();
closeTunnel.mockReturnValueOnce(closing.promise);
jest.useFakeTimers();
const finishing = host.finishAsync();
await jest.advanceTimersByTimeAsync(5_000);
await finishing;
expect(stopServer).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('deadline'));
closing.resolve();
});
it('aborts a stalled finalization request before stopping the host', async () => {
const host = await startHostAsync();
const response = deferred<Awaited<ReturnType<typeof turtleFetch>>>();
jest.mocked(turtleFetch).mockReturnValueOnce(response.promise);
jest.useFakeTimers();
const finishing = host.finishAsync();
await jest.advanceTimersByTimeAsync(60_000);
await finishing;
const options = jest.mocked(turtleFetch).mock.calls.at(-1)?.[2];
expect(options?.signal?.aborted).toBe(true);
expect(stopServer).toHaveBeenCalledTimes(1);
response.resolve({ ok: true } as Awaited<ReturnType<typeof turtleFetch>>);
});
it('uploads the partial recording a killed host left behind and then removes the root', async () => {
const host = await startHostAsync();
const directory = directories[0];
const child = path.join(directory, 'session');
await mkdir(child);
await writeFile(path.join(directory, 'recordings.json'), '[]');
const partial = [
{ udid: 'emulator-5554', deviceName: 'Pixel', runtimeDisplayName: 'Android', directory: child },
];
jest.mocked(findUnlistedDeviceScreenRecordingsAsync).mockResolvedValueOnce(partial);
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockResolvedValueOnce(true);
await host.finishAsync();
expect(findUnlistedDeviceScreenRecordingsAsync).toHaveBeenCalledWith({
root: directory,
env,
logger,
});
expect(uploadDeviceRunSessionScreenRecordingsAsync).toHaveBeenCalledWith(ctx, {
logger,
deviceRunSessionId: 'drs-id',
recordings: partial,
});
await expect(access(directory)).rejects.toMatchObject({ code: 'ENOENT' });
});
it('rejects descriptors outside its recording root', async () => {
const host = await startHostAsync();
const directory = directories[0];
await writeFile(
path.join(directory, 'recordings.json'),
JSON.stringify([
{
udid: 'emulator-5554',
deviceName: 'Pixel',
runtimeDisplayName: 'Android',
directory: path.dirname(directory),
},
])
);
await host.finishAsync();
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
await expect(access(directory)).resolves.toBeUndefined();
});
it('retains recordings without uploading if the host cannot be stopped', async () => {
const host = await startHostAsync();
const directory = directories[0];
await writeRecordingDescriptorAsync(directory);
stopServer.mockRejectedValueOnce(new Error('process stop failed'));
await host.finishAsync();
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
await expect(access(directory)).resolves.toBeUndefined();
});
it('closes a listener that arrives after finish has already timed out waiting for it', async () => {
const host = await startHostAsync();
const tunnel = deferred<Awaited<ReturnType<typeof ngrok.forward>>>();
jest.mocked(ngrok.forward).mockReturnValueOnce(tunnel.promise);
const opening = host.openPreviewAsync({ baseDomain });
const rejected = expect(opening).rejects.toThrow('finalized while the preview was opening');
jest.useFakeTimers();
const finishing = host.finishAsync();
await jest.advanceTimersByTimeAsync(5_000);
await finishing;
expect(stopServer).toHaveBeenCalledTimes(1);
tunnel.resolve({ url: () => 'https://late.example.test', close: closeTunnel } as never);
await rejected;
expect(closeTunnel).toHaveBeenCalledTimes(1);
});
it('leaves iOS recording to its existing build steps', async () => {
const host = await startDeviceSessionHostAsync(ctx, {
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger,
timeoutMs: 10_000,
});
const options = jest.mocked(spawnDetached).mock.calls[0][0];
expect(options.args).not.toContain('--android-recording-directory');
expect(options.env.EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN).toBeUndefined();
await host.finishAsync();
expect(jest.mocked(turtleFetch).mock.calls.some(([, method]) => method === 'POST')).toBe(false);
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
expect(stopServer).toHaveBeenCalledTimes(1);
});
it.each([BuildRuntimePlatform.LINUX, BuildRuntimePlatform.DARWIN])(
'uploads screenshots and flushes them exactly once when the %s host finishes',
async runtimePlatform => {
const uploads: Buffer[] = [];
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementation(async (_ctx, { stream }) => {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk));
}
uploads.push(Buffer.concat(chunks));
});
const host = await startDeviceSessionHostAsync(ctx, {
runtimePlatform,
env,
logger,
timeoutMs: 10_000,
});
await host.openPreviewAsync({ baseDomain });
const directory =
jest.mocked(spawnDetached).mock.calls[0][0].env.EXPO_DEVICE_HUB_SCREENSHOT_DIRECTORY;
if (!directory) {
throw new Error('Missing screenshot artifact directory');
}
await writeFile(
path.join(directory, 'screenshot-2026-09-24T08-45-59-123Z-a1b2c3d4e5f6.png'),
'manual-capture'
);
let directoryWhenStopping: string[] | undefined;
stopServer.mockImplementationOnce(async () => {
directoryWhenStopping = await readdir(directory);
});
const finishing = host.finishAsync();
expect(host.finishAsync()).toBe(finishing);
await finishing;
expect(uploads).toEqual([Buffer.from('manual-capture')]);
expect(stopServer).toHaveBeenCalledTimes(1);
expect(closeTunnel).toHaveBeenCalledTimes(1);
// The collector finishes, and removes its directory, only after the host has stopped.
expect(directoryWhenStopping).toBeDefined();
await expect(access(directory)).rejects.toMatchObject({ code: 'ENOENT' });
}
);
it.each([BuildRuntimePlatform.LINUX, BuildRuntimePlatform.DARWIN])(
'refreshes the session preview of the ready device until the %s host finishes',
async runtimePlatform => {
const host = await startDeviceSessionHostAsync(ctx, {
runtimePlatform,
env,
logger,
timeoutMs: 10_000,
});
await new Promise(resolve => setImmediate(resolve));
expect(startDeviceRunSessionPreview).toHaveBeenCalledTimes(1);
expect(startDeviceRunSessionPreview).toHaveBeenCalledWith(
expect.objectContaining({ deviceRunSessionId: 'drs-id', captureAsync: expect.any(Function) })
);
await host.finishAsync();
expect(stopSessionPreview).toHaveBeenCalledTimes(1);
expect(stopSessionPreview.mock.invocationCallOrder[0]).toBeLessThan(
stopServer.mock.invocationCallOrder[0]
);
}
);
it('does not delay macOS readiness on encoder setup or start the preview after finish', async () => {
const install = deferred<void>();
jest.mocked(ensureMacosPreviewEncoderInstalledAsync).mockReturnValueOnce(install.promise);
const host = await startDeviceSessionHostAsync(ctx, {
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger,
timeoutMs: 10_000,
});
await host.finishAsync();
install.resolve();
await new Promise(resolve => setImmediate(resolve));
expect(startDeviceRunSessionPreview).not.toHaveBeenCalled();
expect(stopServer).toHaveBeenCalledTimes(1);
});
it('keeps the session running when encoder setup for the preview fails', async () => {
jest
.mocked(ensureMacosPreviewEncoderInstalledAsync)
.mockRejectedValueOnce(new Error('brew failed'));
const host = await startDeviceSessionHostAsync(ctx, {
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger,
timeoutMs: 10_000,
});
await new Promise(resolve => setImmediate(resolve));
expect(startDeviceRunSessionPreview).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.objectContaining({ message: 'brew failed' }) },
'Could not start refreshing the session preview.'
);
await host.finishAsync();
});
@@ -454,6 +454,7 @@ describe(startChiselServerAsync, () => {
jest.mocked(spawnDetached).mockReturnValue({
pid: 12345,
getOutput: () => output,
getExitError: () => undefined,
stopAsync,
});
});
@@ -124,6 +124,23 @@ describe(GuardEventRelay, () => {
expect(relay.summary()).toEqual({ blocked: 3, logged: 0, distinct: 2, suppressed: 0 });
});
it('omits the pid when the source has none and appends a note', () => {
const logger = createLogger();
const relay = new GuardEventRelay(logger);
relay.handle({
process: 'Android emulator',
pid: null,
function: 'udp',
action: 'blocked',
peer: '142.250.152.94:443',
callers: [],
note: 'QUIC',
});
expect(logger.lines.map(l => l.msg)).toEqual([
'Local egress guard: refused udp from Android emulator to 142.250.152.94:443 (QUIC)',
]);
});
it('words observed events differently and stops logging past the limit', () => {
const logger = createLogger();
const relay = new GuardEventRelay(logger, /* limit */ 1);
@@ -3,6 +3,9 @@ import { bunyan } from '@expo/logger';
import { BuildRuntimePlatform, BuildStepEnv, BuildStepInputValueTypeName } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import * as ngrok from '@ngrok/ngrok';
import { EventEmitter } from 'node:events';
import fs from 'node:fs/promises';
import path from 'node:path';
import {
clearTimeout as clearTimeoutCallback,
setTimeout as setTimeoutCallback,
@@ -13,26 +16,36 @@ import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
import {
IosSimulatorRecordingUtils,
SERVE_SIM_STOP_GRACE_PERIOD_MS,
} from '../IosSimulatorRecordingUtils';
import * as remoteDeviceRunSession from '../remoteDeviceRunSession';
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
import { sleepAsync } from '../../../utils/retry';
import { uploadDeviceRunSessionScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
import {
createExpoDeviceHubArgs,
createServeSimArgs,
createServeSimLaunchInputProviders,
describeServeSimLaunch,
ensureFfmpegInstalledOnceAsync,
fetchWebPreviewTurnArgsAsync,
parseServeSimLaunchInputs,
simulatorPreviewPageUrl,
startDeviceWebPreviewWithTunnelAsync,
startExpoDeviceHubWithTunnelAsync,
spawnDetached,
startNgrokTunnelAsync,
turnIceServersToWebPreviewArgs,
waitForDeviceRunSessionStoppedAsync,
} from '../remoteDeviceRunSession';
import {
createExpoDeviceHubArgs,
createServeSimArgs,
simulatorPreviewPageUrl,
startDeviceSessionHostAsync,
waitForWebPreviewReadyAsync,
websiteOrigin,
websiteOriginServeSimArgs,
} from '../remoteDeviceRunSession';
} from '../deviceSessionHost';
import { parseNetworkCaptureFieldsInput, parseNetworkCaptureInputs } from '../networkCaptureFields';
jest.mock('@ngrok/ngrok');
jest.mock('node:timers');
@@ -41,6 +54,10 @@ jest.mock('../../../utils/turtleFetch');
jest.mock('../../../utils/retry', () => ({ sleepAsync: jest.fn() }));
jest.mock('../../../sentry');
jest.mock('@expo/turtle-spawn');
jest.mock('../deviceRunSessionScreenRecordings', () => ({
...jest.requireActual('../deviceRunSessionScreenRecordings'),
uploadDeviceRunSessionScreenRecordingsAsync: jest.fn(),
}));
// Spyable so a test can stand in for the serve-sim state directory, which a local serve-sim owns.
jest.mock('../serveSimMetricsRecorder', () => {
const actual = jest.requireActual('../serveSimMetricsRecorder');
@@ -56,6 +73,69 @@ function createLoggerMock(): bunyan {
} as unknown as bunyan;
}
describe(spawnDetached, () => {
function mockProcess(promise: Promise<unknown>): void {
jest
.mocked(spawn)
.mockReturnValue(
Object.assign(promise, { child: { pid: 1234, unref: jest.fn(), once: jest.fn() } }) as never
);
}
it('observes exit without waiting for inherited output pipes to close', () => {
const child = Object.assign(new EventEmitter(), { pid: 1234, unref: jest.fn() });
jest.mocked(spawn).mockReturnValue(Object.assign(new Promise(() => {}), { child }) as never);
const handle = spawnDetached({ command: 'server', args: [], env: {} });
child.emit('exit', 1, null);
expect(handle.getExitError()?.message).toContain('code 1');
expect(child.listenerCount('exit')).toBe(0);
});
it('does not report an exit while the process is running', () => {
mockProcess(new Promise(() => {}));
const handle = spawnDetached({ command: 'server', args: [], env: {} });
expect(handle.getExitError()).toBeUndefined();
});
it('reports a clean exit so startup does not keep waiting', async () => {
const completion = Promise.resolve();
mockProcess(completion);
const handle = spawnDetached({ command: 'server', args: [], env: {} });
await completion;
expect(handle.getExitError()?.message).toContain('code 0');
});
it.each([0, 1])('observes a real subprocess exiting with code %s', async exitCode => {
const actualSpawn =
jest.requireActual<typeof import('@expo/turtle-spawn')>('@expo/turtle-spawn').default;
let completion: ReturnType<typeof actualSpawn>;
jest.mocked(spawn).mockImplementationOnce((...args) => {
completion = actualSpawn(...args);
return completion;
});
const handle = spawnDetached({
command: process.execPath,
args: ['-e', `console.error('startup-marker'); process.exit(${exitCode});`],
env: {},
});
await completion!.catch(() => {});
expect(handle.getExitError()).toBeInstanceOf(Error);
expect(handle.getOutput()).toContain('startup-marker');
});
it.each(['spawn ENOENT', 'server exited with code 1', 'server terminated by SIGTERM'])(
'preserves the process failure: %s',
async message => {
const error = new Error(message);
const completion = Promise.reject(error);
mockProcess(completion);
const handle = spawnDetached({ command: 'server', args: [], env: {} });
await completion.catch(() => {});
expect(handle.getExitError()).toBe(error);
}
);
});
function createCtxMock(): CustomBuildContext {
return {
env: {
@@ -379,9 +459,94 @@ describe(createServeSimArgs, () => {
expect(args.some(argument => argument.startsWith('--launch'))).toBe(false);
expect(args).not.toContain('--open-url');
});
it('omits --network-capture by default', () => {
expect(createServeSimArgs({ port: 4321 })).not.toContain('--network-capture');
expect(createServeSimArgs({ port: 4321, networkCapture: false })).not.toContain(
'--network-capture'
);
});
it('appends --network-capture when enabled, which also covers an already booted simulator', () => {
const args = createServeSimArgs({ port: 4321, networkCapture: true });
expect(args).toContain('--network-capture');
expect(args).not.toContain('--enable');
});
it('repeats --network-capture-field once per requested field', () => {
expect(
createServeSimArgs({
port: 4321,
networkCapture: true,
networkCaptureFields: ['header', 'query'],
})
).toEqual(
expect.arrayContaining([
'--network-capture',
'--network-capture-field',
'header',
'--network-capture-field',
'query',
])
);
});
it('keeps capture metadata-only when no field is requested', () => {
expect(createServeSimArgs({ port: 4321, networkCapture: true })).not.toContain(
'--network-capture-field'
);
});
it('does not pass fields when capture itself is off', () => {
expect(
createServeSimArgs({ port: 4321, networkCapture: false, networkCaptureFields: ['header'] })
).not.toContain('--network-capture-field');
});
it('rejects network capture on a runtime that has no serve-sim', () => {
expect(() =>
parseNetworkCaptureInputs(
{ networkCapture: true },
{ runtimePlatform: BuildRuntimePlatform.LINUX }
)
).toThrow('this session runs on linux');
expect(() =>
parseNetworkCaptureInputs(
{ networkCaptureFields: ['header'] },
{ runtimePlatform: BuildRuntimePlatform.DARWIN }
)
).toThrow('needs "network_capture: true"');
expect(parseNetworkCaptureInputs({}, { runtimePlatform: BuildRuntimePlatform.LINUX })).toEqual({
networkCapture: false,
networkCaptureFields: [],
});
expect(
parseNetworkCaptureInputs(
{ networkCapture: true, networkCaptureFields: ['header'] },
{ runtimePlatform: BuildRuntimePlatform.DARWIN }
)
).toEqual({ networkCapture: true, networkCaptureFields: ['header'] });
});
it('rejects a step input that is not an array of strings', () => {
// A JSON step input is whatever the workflow author wrote, so the shape has to be checked.
expect(() => parseNetworkCaptureFieldsInput('header,query')).toThrow(UserError);
expect(() => parseNetworkCaptureFieldsInput('header,query')).toThrow(
/must be an array of strings/
);
expect(() => parseNetworkCaptureFieldsInput([1, 2])).toThrow(UserError);
expect(parseNetworkCaptureFieldsInput(undefined)).toEqual([]);
expect(parseNetworkCaptureFieldsInput(['header'])).toEqual(['header']);
});
});
describe(createExpoDeviceHubArgs, () => {
it('opts in to recording only when a directory is provided', () => {
expect(createExpoDeviceHubArgs({ port: 4321 })).not.toContain('--android-recording-directory');
expect(
createExpoDeviceHubArgs({ port: 4321, recordingDirectory: '/tmp/recordings' }).slice(-2)
).toEqual(['--android-recording-directory', '/tmp/recordings']);
});
it('uses the latest Expo package and applies the EAS Android streaming policy', () => {
expect(
createExpoDeviceHubArgs({
@@ -432,19 +597,26 @@ describe(websiteOriginServeSimArgs, () => {
]);
});
it('names staging, its deploy previews and each website dev port on staging', () => {
it('names staging, its deploy previews and local website subdomains on staging', () => {
const args = websiteOriginServeSimArgs({ EXPO_STAGING: '1' } as BuildStepEnv);
expect(args.slice(0, 4)).toEqual([
expect(args).toEqual([
'--cors-origin',
'https://staging.expo.dev',
'--frame-ancestor',
'https://staging.expo.dev',
'--cors-origin',
'https://*.expo.dev',
'--frame-ancestor',
'https://*.expo.dev',
'--cors-origin',
'https://expo.test',
'--frame-ancestor',
'https://expo.test',
'--cors-origin',
'https://*.expo.test',
'--frame-ancestor',
'https://*.expo.test',
]);
expect(args).toContain('https://*.expo.dev');
expect(args).toContain('https://expo.test:13001');
expect(args).toContain('https://expo.test:13215');
expect(args).not.toContain('https://expo.test:13216');
expect(args).not.toContain('https://expo.dev');
});
it('names only https origins', () => {
@@ -454,12 +626,18 @@ describe(websiteOriginServeSimArgs, () => {
}
});
it('names the website dev ports on local, without the deploy-preview wildcard', () => {
it('names local website subdomains without the deploy-preview wildcard on local', () => {
for (const env of [{ EXPO_LOCAL: '1' }, { EXPO_LOCAL: '1', EXPO_STAGING: '1' }]) {
const args = websiteOriginServeSimArgs(env as BuildStepEnv);
expect(args).toContain('https://expo.test:13001');
expect(args).not.toContain('https://*.expo.dev');
expect(args.filter(value => value === 'https://expo.test')).toHaveLength(2);
expect(websiteOriginServeSimArgs(env as BuildStepEnv)).toEqual([
'--cors-origin',
'https://expo.test',
'--frame-ancestor',
'https://expo.test',
'--cors-origin',
'https://*.expo.test',
'--frame-ancestor',
'https://*.expo.test',
]);
}
});
});
@@ -493,6 +671,7 @@ describe(waitForWebPreviewReadyAsync, () => {
expect.objectContaining({ retries: 0 })
);
expect(sleepAsync).toHaveBeenCalledTimes(1);
expect(sleepAsync).toHaveBeenCalledWith(250);
});
});
@@ -531,7 +710,57 @@ describe(startNgrokTunnelAsync, () => {
});
});
describe(startDeviceWebPreviewWithTunnelAsync, () => {
describe('spawnDetached process group shutdown', () => {
const env = {} as BuildStepEnv;
beforeEach(() => {
const spawned = Object.assign(Promise.resolve(undefined), {
child: { pid: 4321, unref: jest.fn(), once: jest.fn() },
});
jest.mocked(spawn).mockReturnValue(spawned as never);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('waits for a detached child after its package-manager wrapper exits', async () => {
let groupChecks = 0;
const kill = jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
if (pid === -4321 && signal === 0) {
groupChecks += 1;
if (groupChecks < 4) {
return true;
}
throw new Error('Process group exited');
}
if (pid === -4321 && signal === 'SIGTERM') {
return true;
}
throw new Error(`Unexpected process signal: ${pid} ${signal}`);
});
const detached = spawnDetached({ command: 'npx', args: [], env, stopGracePeriodMs: 90_000 });
await detached.stopAsync();
expect(jest.mocked(sleepAsync)).toHaveBeenCalledWith(100);
expect(kill).toHaveBeenCalledWith(-4321, 'SIGTERM');
expect(kill).not.toHaveBeenCalledWith(-4321, 'SIGKILL');
expect(kill).not.toHaveBeenCalledWith(4321, 0);
});
it('kills a detached child that outlives the shutdown deadline', async () => {
const kill = jest.spyOn(process, 'kill').mockReturnValue(true);
const detached = spawnDetached({ command: 'npx', args: [], env, stopGracePeriodMs: 0 });
await detached.stopAsync();
expect(kill).toHaveBeenCalledWith(-4321, 'SIGTERM');
expect(kill).toHaveBeenCalledWith(-4321, 'SIGKILL');
});
});
describe(startDeviceSessionHostAsync, () => {
const baseDomain = 'eas-simulator.ngrok.dev';
const turnArgs = [
'--turn-url',
@@ -548,6 +777,7 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
} as unknown as BuildStepEnv;
beforeEach(() => {
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockReset();
jest.mocked(spawn).mockReset();
jest.mocked(ngrok.forward).mockReset();
jest.mocked(turtleFetch).mockReset();
@@ -559,6 +789,7 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
child: {
pid: undefined,
unref: jest.fn(),
once: jest.fn(),
},
});
jest.mocked(spawn).mockReturnValue(spawnPromise as never);
@@ -585,6 +816,25 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
});
});
afterEach(() => {
jest.restoreAllMocks();
});
it('gives serve-sim its recording grace period on shutdown', async () => {
const spawnDetachedSpy = jest.spyOn(remoteDeviceRunSession, 'spawnDetached');
const host = await startDeviceSessionHostAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
});
expect(spawnDetachedSpy).toHaveBeenCalledWith(
expect.objectContaining({ stopGracePeriodMs: SERVE_SIM_STOP_GRACE_PERIOD_MS })
);
await host.finishAsync();
});
it('installs ffmpeg before starting expo-device-hub for Linux', async () => {
const packageVersion = '1.2.3';
const close = jest.fn().mockResolvedValue(undefined);
@@ -600,14 +850,14 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>)
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>);
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
const host = await startDeviceSessionHostAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.LINUX,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
packageVersion,
});
const preview = await host.openPreviewAsync({ baseDomain });
const spawnCalls = jest.mocked(spawn).mock.calls;
expect(spawnCalls[0]).toEqual(['ffmpeg', ['-version'], { env }]);
@@ -634,11 +884,17 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
const port = Number(args[args.indexOf('--port') + 1]);
expect(port).toBeGreaterThan(0);
expect(command).toBe('npx');
expect(args).toEqual(['--yes', ...createExpoDeviceHubArgs({ port, turnArgs, packageVersion })]);
expect(args).toContain('--android-recording-directory');
const recordingDirectory = args[args.indexOf('--android-recording-directory') + 1];
expect(args).toEqual([
'--yes',
...createExpoDeviceHubArgs({ port, turnArgs, packageVersion, recordingDirectory }),
]);
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(preview.apiUrl).toBe('https://android-preview.example.test');
await preview.stopAsync();
await host.finishAsync();
await fs.rm(recordingDirectory, { recursive: true, force: true });
expect(close).toHaveBeenCalledTimes(1);
});
@@ -648,13 +904,13 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
close: jest.fn().mockResolvedValue(undefined),
} as never);
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
const host = await startDeviceSessionHostAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
});
const preview = await host.openPreviewAsync({ baseDomain });
expect(preview.previewToken).toBe('tok-1');
expect(preview.apiUrl).toBe('https://preview.example.test');
@@ -677,13 +933,13 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
close: jest.fn().mockResolvedValue(undefined),
} as never);
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
const host = await startDeviceSessionHostAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env: { ...env, EXPO_STAGING: '1' },
logger: createLoggerMock(),
timeoutMs: 10_000,
});
const preview = await host.openPreviewAsync({ baseDomain });
expect(preview.previewPageUrl).toMatch(
/^https:\/\/staging\.expo\.dev\/simulator-preview\/[a-f0-9]{32}$/
@@ -702,9 +958,8 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
.mockResolvedValue([{ udid: 'device-id', url: 'http://127.0.0.1:1' }]);
await expect(
startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
startDeviceSessionHostAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
@@ -720,13 +975,13 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
close: jest.fn().mockResolvedValue(undefined),
} as never);
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
const host = await startDeviceSessionHostAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.LINUX,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
});
const preview = await host.openPreviewAsync({ baseDomain });
expect(preview.previewToken).toBeUndefined();
const [, args] = jest.mocked(spawn).mock.calls[0];
@@ -741,14 +996,14 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
close,
} as never);
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
const host = await startDeviceSessionHostAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
packageVersion,
});
const preview = await host.openPreviewAsync({ baseDomain });
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
@@ -767,37 +1022,12 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(preview.apiUrl).toBe('https://ios-preview.example.test');
await preview.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
});
it('does not install ffmpeg before starting expo-device-hub outside Linux', async () => {
const close = jest.fn().mockResolvedValue(undefined);
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://android-preview.example.test',
close,
} as never);
const preview = await startExpoDeviceHubWithTunnelAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
});
expect(jest.mocked(spawn).mock.calls[0][0]).toBe('npx');
expect(jest.mocked(spawn)).not.toHaveBeenCalledWith(
'ffmpeg',
expect.anything(),
expect.anything()
);
await preview.stopAsync();
await host.finishAsync();
expect(close).toHaveBeenCalledTimes(1);
});
it('launches serve-sim with bun x when EAS_OVERRIDE_PACKAGE_MANAGER is bun', async () => {
const usePackage = jest.spyOn(IosSimulatorRecordingUtils, 'useServeSimPackage');
const close = jest.fn().mockResolvedValue(undefined);
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://ios-preview.example.test',
@@ -805,14 +1035,14 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
} as never);
const bunEnv = { ...env, EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' };
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
const host = await startDeviceSessionHostAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env: bunEnv,
logger: createLoggerMock(),
timeoutMs: 10_000,
packageVersion: '4.5.6',
});
const preview = await host.openPreviewAsync({ baseDomain });
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
@@ -827,8 +1057,9 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
packageVersion: '4.5.6',
}),
]);
expect(usePackage).toHaveBeenCalledWith('@expo/[email protected]');
await preview.stopAsync();
await host.finishAsync();
expect(close).toHaveBeenCalledTimes(1);
});
@@ -839,13 +1070,13 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
close,
} as never);
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
const host = await startDeviceSessionHostAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env: { ...env, EAS_FALLBACK_PACKAGE_MANAGER: 'bun' },
logger: createLoggerMock(),
timeoutMs: 10_000,
});
const preview = await host.openPreviewAsync({ baseDomain });
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
@@ -860,7 +1091,7 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
}),
]);
await preview.stopAsync();
await host.finishAsync();
expect(close).toHaveBeenCalledTimes(1);
});
@@ -870,9 +1101,8 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
close: jest.fn().mockResolvedValue(undefined),
} as never);
await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
const host = await startDeviceSessionHostAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
@@ -880,6 +1110,7 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
openUrl: 'exp://127.0.0.1:8081',
});
await host.finishAsync();
const [, args] = jest.mocked(spawn).mock.calls[0];
expect(args.slice(-8)).toEqual([
@@ -896,9 +1127,8 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
it('refuses to launch an application on Linux, where expo-device-hub cannot', async () => {
await expect(
startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
startDeviceSessionHostAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.LINUX,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
@@ -0,0 +1,25 @@
import type { BuildStepEnv } from '@expo/steps';
import { isProcessRunning, spawnDetached } from '../remoteDeviceRunSession';
jest.mock('@ngrok/ngrok');
it('resolves stopAsync only after a process that ignores SIGTERM is gone', async () => {
const handle = spawnDetached({
command: 'sh',
args: ['-c', 'trap "" TERM; echo ready; sleep 30 & wait'],
env: process.env as BuildStepEnv,
stopGracePeriodMs: 300,
});
const pid = handle.pid;
if (pid === undefined) {
throw new Error('spawn failed');
}
while (!handle.getOutput().includes('ready')) {
await new Promise(resolve => setTimeout(resolve, 20));
}
const startedAt = Date.now();
await handle.stopAsync();
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(300);
expect(isProcessRunning(pid)).toBe(false);
});
@@ -0,0 +1,57 @@
import { type bunyan } from '@expo/logger';
import { Readable } from 'node:stream';
import { writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { type CustomBuildContext } from '../../../customBuildContext';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import { uploadServeSimLogsFileAsync } from '../serveSimLogsArtifacts';
jest.mock('../deviceRunSessionArtifacts');
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const ctx = {} as CustomBuildContext;
const filePath = path.join(os.tmpdir(), 'logs.ndjson');
const args = { deviceRunSessionId: 'session', udid: 'A', filePath, logger };
it('uploads NDJSON with explicit user-app scope and destroys the stream afterward', async () => {
await writeFile(filePath, '{"pid":42}\n');
await uploadServeSimLogsFileAsync(ctx, args);
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
kind: 'simulator-log',
filename: 'app-logs.ndjson',
size: 11,
metadata: expect.objectContaining({ scope: 'user-apps', udid: 'A' }),
})
);
expect(
(jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls[0][1].stream as Readable).destroyed
).toBe(true);
});
it('skips empty and missing files', async () => {
await uploadServeSimLogsFileAsync(ctx, args);
await writeFile(filePath, '');
await uploadServeSimLogsFileAsync(ctx, args);
expect(uploadDeviceRunSessionArtifactAsync).not.toHaveBeenCalled();
});
it('handles a file read error while upload allocation is pending without an unhandled error', async () => {
await writeFile(filePath, '{}\n');
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { stream, signal }) => {
const aborted = new Promise<void>((_resolve, reject) => {
signal!.addEventListener('abort', () => reject(signal!.reason), { once: true });
});
(stream as Readable).destroy(new Error('read failure'));
await aborted;
});
await expect(uploadServeSimLogsFileAsync(ctx, args)).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
expect.stringContaining('Could not upload')
);
});
@@ -0,0 +1,204 @@
import { type bunyan } from '@expo/logger';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { type Server, createServer } from 'node:http';
import { type AddressInfo } from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { type CustomBuildContext } from '../../../customBuildContext';
import { uploadServeSimLogsFileAsync } from '../serveSimLogsArtifacts';
import { streamServeSimLogsToFileAsync } from '../serveSimLogsRecorder';
// This test exercises actual sockets, UTF-8 decoding, files, and PUT uploads.
// Only WWW's allocation response is substituted; it does not verify cloud storage.
jest.mock('../../../sentry');
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
jest.unmock('node-fetch');
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
} as unknown as bunyan;
let directory: string;
let server: Server;
let baseUrl: string;
beforeEach(async () => {
directory = await mkdtemp(path.join(os.tmpdir(), 'simulator-logs-http-test-'));
server = createServer();
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
server.closeAllConnections();
await new Promise<void>((resolve, reject) =>
server.close(error => (error ? reject(error) : resolve()))
);
await rm(directory, { recursive: true, force: true });
});
it('streams selected-device logs into NDJSON and uploads the exact bytes as a session artifact', async () => {
const record = JSON.stringify({ eventMessage: 'Hello 서울 🌲', process: 'MyApp' });
const expected = Buffer.from(record + '\n');
const envelope = JSON.stringify({ seq: 7, at: 1000, raw: record });
const wire = Buffer.from(
`: heartbeat\n\ndata: ${envelope}\n\ndata: invalid json\n\ndata: {"partial":`
);
const splitAt = wire.indexOf(Buffer.from('서울')) + 1;
let streamRequest: { url?: string; authorization?: string } | undefined;
let uploaded: Buffer | undefined;
let uploadedLength: string | undefined;
server.on('request', (request, response) => {
if (request.method === 'GET') {
streamRequest = { url: request.url, authorization: request.headers.authorization };
response.writeHead(200, {
'Content-Type': 'text/event-stream',
'X-Serve-Sim-Log-Scope': 'user-apps',
});
response.write(wire.subarray(0, splitAt));
setImmediate(() => response.end(wire.subarray(splitAt)));
return;
}
const chunks: Buffer[] = [];
request.on('data', chunk => chunks.push(Buffer.from(chunk)));
request.on('end', () => {
uploaded = Buffer.concat(chunks);
uploadedLength = request.headers['content-length'];
response.writeHead(200).end();
});
});
const filePath = path.join(directory, 'simulator.ndjson');
const result = await streamServeSimLogsToFileAsync({
serveSimUrl: baseUrl,
serveSimToken: 'local-test-token',
serveSimDevice: 'device-B',
filePath,
signal: new AbortController().signal,
logger,
});
expect(streamRequest).toEqual({
url: '/logs?envelope=true&scope=user-apps&device=device-B',
authorization: 'Bearer local-test-token',
});
expect(result).toEqual({
receivedData: true,
limitReached: false,
bytesWritten: expected.length,
lastSequence: 7,
});
expect(await readFile(filePath)).toEqual(expected);
const mutation = jest.fn().mockReturnValue({
toPromise: async () => ({
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: {
url: `${baseUrl}/upload`,
headers: { 'Content-Length': String(expected.length) },
},
},
},
},
}),
});
await uploadServeSimLogsFileAsync(
{ graphqlClient: { mutation } } as unknown as CustomBuildContext,
{ deviceRunSessionId: 'session-test', udid: 'device-B', filePath, logger }
);
expect(mutation.mock.calls[0][1]).toEqual({
deviceRunSessionId: 'session-test',
input: {
name: 'App logs (device-B)',
filename: 'app-logs.ndjson',
kind: 'simulator-log',
metadata: {
__eas_type: 'simulator-log',
udid: 'device-B',
scope: 'user-apps',
source: 'serve-sim/logs',
},
size: expected.length,
},
});
expect(uploaded).toEqual(expected);
expect(uploadedLength).toBe(String(expected.length));
// Only the invalid JSON record is reported; the partial trailing record is not a record yet.
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ malformedRecords: 1 },
expect.stringContaining('not valid JSON')
);
});
it('resumes a buffered stream without appending already persisted sequence numbers', async () => {
const urls: string[] = [];
const first = JSON.stringify({ eventMessage: 'first' });
const second = JSON.stringify({ eventMessage: 'second' });
const frame = (seq: number, raw: string): string =>
`data: ${JSON.stringify({ seq, at: seq * 1000, raw })}\n\n`;
server.on('request', (request, response) => {
urls.push(request.url!);
response.writeHead(200, {
'Content-Type': 'text/event-stream',
'X-Serve-Sim-Log-Scope': 'user-apps',
});
// Deliberately resend seq=1 on reconnect to exercise the client's guard too.
response.end(frame(1, first) + (urls.length > 1 ? frame(2, second) : ''));
});
const filePath = path.join(directory, 'resumed.ndjson');
const options = {
serveSimUrl: baseUrl,
serveSimDevice: 'device-A',
filePath,
signal: new AbortController().signal,
logger,
};
const initial = await streamServeSimLogsToFileAsync(options);
const resumed = await streamServeSimLogsToFileAsync({
...options,
since: initial.lastSequence,
});
expect(initial.lastSequence).toBe(1);
expect(resumed.lastSequence).toBe(2);
expect(resumed.bytesWritten).toBe(Buffer.byteLength(second + '\n'));
expect(urls).toEqual([
'/logs?envelope=true&scope=user-apps&device=device-A',
'/logs?envelope=true&scope=user-apps&since=1&device=device-A',
]);
expect(await readFile(filePath, 'utf8')).toBe(first + '\n' + second + '\n');
});
it('aborts a real open HTTP stream without waiting for the server to finish', async () => {
const controller = new AbortController();
let sawRequest: () => void = () => {};
const requestStarted = new Promise<void>(resolve => (sawRequest = resolve));
server.on('request', (_request, response) => {
response.writeHead(200, {
'Content-Type': 'text/event-stream',
'X-Serve-Sim-Log-Scope': 'user-apps',
});
response.write(': heartbeat\n\n');
sawRequest();
});
const streaming = streamServeSimLogsToFileAsync({
serveSimUrl: baseUrl,
filePath: path.join(directory, 'aborted.ndjson'),
signal: controller.signal,
logger,
});
await requestStarted;
controller.abort();
await expect(streaming).resolves.toEqual({
receivedData: false,
limitReached: false,
bytesWritten: 0,
});
});
@@ -0,0 +1,280 @@
import { type bunyan } from '@expo/logger';
import fetch from 'node-fetch';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { Readable } from 'node:stream';
import { setTimeout as delay } from 'node:timers/promises';
import { Sentry } from '../../../sentry';
import { ServeSimLogsRecorder, streamServeSimLogsToFileAsync } from '../serveSimLogsRecorder';
jest.mock('node-fetch');
jest.mock('../../../sentry');
jest.unmock('node:fs');
jest.unmock('node:fs/promises');
const { Response: FetchResponse } = jest.requireActual('node-fetch') as typeof import('node-fetch');
// A compatible serve-sim explicitly acknowledges the requested log scope.
class Response extends FetchResponse {
constructor(...args: ConstructorParameters<typeof FetchResponse>) {
super(...args);
this.headers.set('x-serve-sim-log-scope', 'user-apps');
}
}
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
let directory: string;
beforeEach(async () => {
jest.mocked(fetch).mockReset();
directory = await mkdtemp(path.join(os.tmpdir(), 'logs-test-'));
});
afterEach(async () => {
const files = await ServeSimLogsRecorder.finishAsync({ logger });
for (const file of files) {
await rm(path.dirname(file.filePath), { recursive: true, force: true });
}
await rm(directory, { recursive: true, force: true });
});
function record(options: Partial<Parameters<typeof streamServeSimLogsToFileAsync>[0]> = {}) {
return streamServeSimLogsToFileAsync({
serveSimUrl: 'http://localhost:1234',
filePath: path.join(directory, 'logs.ndjson'),
signal: new AbortController().signal,
logger,
...options,
});
}
it('preserves split UTF-8 and CRLF records, skips heartbeat and reports invalid JSON, and selects a device', async () => {
const input = Buffer.from(
':\r\n\r\ndata: {"message":"안녕"}\r\n\r\ndata: bad\n\ndata: {"pid":42}\n\n'
);
jest
.mocked(fetch)
.mockResolvedValue(new Response(Readable.from([...input].map(byte => Buffer.from([byte])))));
const result = await record({ serveSimToken: 'secret', serveSimDevice: 'device two' });
expect(result.receivedData).toBe(true);
expect(await readFile(path.join(directory, 'logs.ndjson'), 'utf8')).toBe(
'{"message":"안녕"}\n{"pid":42}\n'
);
expect(Sentry.capture).toHaveBeenCalledWith('serve-sim sent malformed simulator log records', {
extras: { malformedRecords: 1, serveSimDevice: 'device two' },
});
expect(fetch).toHaveBeenCalledWith(
'http://localhost:1234/logs?envelope=true&scope=user-apps&device=device+two',
expect.objectContaining({ headers: { Authorization: 'Bearer secret' } })
);
});
it('retains completed records when a connection errors', async () => {
const stream = Readable.from(
(async function* () {
yield Buffer.from('data: {"pid":42}\n\n');
await delay(10);
throw new Error('disconnect');
})()
);
jest.mocked(fetch).mockResolvedValue(new Response(stream));
expect(await record()).toEqual({ receivedData: true, bytesWritten: 11, limitReached: false });
expect(await readFile(path.join(directory, 'logs.ndjson'), 'utf8')).toBe('{"pid":42}\n');
});
it.each([undefined, 'all'])('refuses an unconfirmed user-app scope (%s)', async scope => {
const body = Readable.from(['data: {"eventMessage":"system noise"}\n\n']);
const response = new FetchResponse(body);
if (scope) {
response.headers.set('x-serve-sim-log-scope', scope);
}
jest.mocked(fetch).mockResolvedValue(response);
expect(await record()).toEqual({ receivedData: false, bytesWritten: 0, limitReached: false });
expect(body.destroyed).toBe(true);
await expect(readFile(path.join(directory, 'logs.ndjson'))).rejects.toMatchObject({
code: 'ENOENT',
});
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('did not confirm user-app'));
});
it('aborts a quiet live stream and drains preceding writes before returning', async () => {
const controller = new AbortController();
const stream = new Readable({ read() {} });
stream.push('data: {"pid":42}\n\n');
jest.mocked(fetch).mockResolvedValue(new Response(stream));
const done = record({ signal: controller.signal });
await delay(20);
controller.abort();
expect((await done).receivedData).toBe(true);
expect(stream.destroyed).toBe(true);
expect(await readFile(path.join(directory, 'logs.ndjson'), 'utf8')).toBe('{"pid":42}\n');
});
it('enforces the byte limit without writing a truncated record', async () => {
jest
.mocked(fetch)
.mockResolvedValue(new Response(Readable.from(['data: {"pid":42}\n\ndata: {"pid":43}\n\n'])));
expect(await record({ maxBytes: 15 })).toEqual({
receivedData: true,
bytesWritten: 11,
limitReached: true,
});
expect(await readFile(path.join(directory, 'logs.ndjson'), 'utf8')).toBe('{"pid":42}\n');
});
it('drops an oversized partial SSE line and resumes at the next complete record', async () => {
jest
.mocked(fetch)
.mockResolvedValue(
new Response(
Readable.from(['data: ' + 'x'.repeat(1024 * 1024 + 1), 'tail\n\ndata: {"pid":42}\n\n'])
)
);
expect((await record()).bytesWritten).toBe(11);
});
it('warns and resolves on HTTP and file failures', async () => {
jest.mocked(fetch).mockResolvedValueOnce(new Response('missing', { status: 404 }));
expect((await record()).receivedData).toBe(false);
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from(['data: {}\n\n'])));
expect((await record({ filePath: '/missing-dir/log.ndjson' })).receivedData).toBe(false);
expect(logger.warn).toHaveBeenCalledTimes(2);
});
async function register(): Promise<string> {
const stateDir = path.join(directory, 'state');
await mkdir(stateDir);
await writeFile(
path.join(stateDir, 'server-A.json'),
JSON.stringify({ device: 'A', url: 'http://localhost:1234' })
);
return stateDir;
}
it('caps bytes across reconnects and finalizes only once even concurrently', async () => {
const stateDir = await register();
let seq = 0;
jest
.mocked(fetch)
.mockImplementation(
async () =>
new Response(
Readable.from([`data: ${JSON.stringify({ seq: ++seq, raw: '{"pid":42}' })}\n\n`])
)
);
await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5, maxBytes: 22 });
await delay(100);
expect(fetch).toHaveBeenCalledTimes(2);
const [first, second] = await Promise.all([
ServeSimLogsRecorder.finishAsync({ logger }),
ServeSimLogsRecorder.finishAsync({ logger }),
]);
expect(first).toHaveLength(1);
expect(second).toEqual([]);
expect(await readFile(first[0].filePath, 'utf8')).toBe('{"pid":42}\n{"pid":42}\n');
expect(await ServeSimLogsRecorder.finishAsync({ logger })).toEqual([]);
});
it('returns immediately before a server exists and discovers a later registration', async () => {
const stateDir = path.join(directory, 'state');
jest.mocked(fetch).mockImplementation(async () => new Response(Readable.from(['data: {}\n\n'])));
await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5 });
expect(fetch).not.toHaveBeenCalled();
await register();
await delay(30);
expect(await ServeSimLogsRecorder.finishAsync({ logger })).toHaveLength(1);
});
it('stops discovery and active streams at the session time limit', async () => {
const stateDir = await register();
const stream = new Readable({ read() {} });
stream.push('data: {}\n\n');
jest.mocked(fetch).mockResolvedValue(new Response(stream));
await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5, maxDurationMs: 30 });
await delay(80);
expect(stream.destroyed).toBe(true);
expect(fetch).toHaveBeenCalledTimes(1);
expect(await ServeSimLogsRecorder.finishAsync({ logger })).toHaveLength(1);
});
it('stops retrying after ten consecutive failures', async () => {
const stateDir = await register();
jest.mocked(fetch).mockImplementation(async () => new Response('bad', { status: 503 }));
await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5 });
await delay(150);
expect(fetch).toHaveBeenCalledTimes(10);
expect(await ServeSimLogsRecorder.finishAsync({ logger })).toEqual([]);
});
it('selects each registered device when two simulators share one server URL', async () => {
const stateDir = await register();
await writeFile(
path.join(stateDir, 'server-B.json'),
JSON.stringify({ device: 'B', url: 'http://localhost:1234' })
);
jest.mocked(fetch).mockImplementation(async () => new Response(Readable.from(['data: {}\n\n'])));
await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5, maxBytes: 3 });
await delay(50);
expect(fetch).toHaveBeenCalledWith(
'http://localhost:1234/logs?envelope=true&scope=user-apps&device=A',
expect.anything()
);
expect(fetch).toHaveBeenCalledWith(
'http://localhost:1234/logs?envelope=true&scope=user-apps&device=B',
expect.anything()
);
expect(fetch).toHaveBeenCalledTimes(2);
});
it('persists the envelope cursor only for written records and drops replayed sequences', async () => {
const frames = [1, 2, 3].map(
seq => `data: ${JSON.stringify({ seq, raw: JSON.stringify({ pid: seq }) })}\n\n`
);
jest.mocked(fetch).mockResolvedValue(new Response(Readable.from(frames)));
expect(await record({ since: 1, maxBytes: 10 })).toEqual({
receivedData: true,
bytesWritten: 10,
limitReached: true,
lastSequence: 2,
});
expect(await readFile(path.join(directory, 'logs.ndjson'), 'utf8')).toBe('{"pid":2}\n');
expect(fetch).toHaveBeenCalledWith(
'http://localhost:1234/logs?envelope=true&scope=user-apps&since=1',
expect.anything()
);
});
it('does not reconnect a legacy data stream without a replay cursor', async () => {
const stateDir = await register();
jest.mocked(fetch).mockImplementation(async () => new Response(Readable.from(['data: {}\n\n'])));
await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5 });
await delay(50);
expect(fetch).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('without a replay cursor'));
});
it('resets the replay cursor when a replacement server changes its token', async () => {
const stateDir = await register();
let calls = 0;
jest.mocked(fetch).mockImplementation(async (_url, options) => {
calls++;
if (calls === 1) {
await writeFile(
path.join(stateDir, 'server-A.json'),
JSON.stringify({
device: 'A',
url: 'http://localhost:1234',
token: 'new-token',
})
);
}
const seq = options?.headers ? 1 : 50;
return new Response(Readable.from([`data: ${JSON.stringify({ seq, raw: '{}' })}\n\n`]));
});
await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5, maxBytes: 6 });
await delay(50);
expect(fetch).toHaveBeenCalledTimes(2);
expect(String(jest.mocked(fetch).mock.calls[1][0])).not.toContain('since=');
expect(jest.mocked(fetch).mock.calls[1][1]?.headers).toEqual({
Authorization: 'Bearer new-token',
});
});
@@ -0,0 +1,91 @@
import { bunyan } from '@expo/logger';
import { createStartupTasks } from '../startupTasks';
function createLoggerMock(): bunyan {
return {
info: jest.fn(),
warn: jest.fn(),
child: jest.fn().mockReturnThis(),
} as unknown as bunyan;
}
describe(createStartupTasks, () => {
it('logs each task and sums them up in the order they finished', async () => {
const logger = createLoggerMock();
const tasks = createStartupTasks(logger);
let finishSlow!: () => void;
const slow = tasks.run(
'slow task',
() =>
new Promise<string>(resolve => {
finishSlow = () => resolve('slow');
})
);
const fast = tasks.run('fast task', async () => 'fast');
await expect(fast).resolves.toBe('fast');
finishSlow();
await expect(slow).resolves.toBe('slow');
expect(logger.info).toHaveBeenCalledWith('Starting slow task.');
expect(logger.info).toHaveBeenCalledWith(
expect.stringMatching(/^Finished fast task in \d+\.\d s\.$/)
);
expect(tasks.summary()).toMatch(
/^Startup took \d+\.\d s \(fast task \d+\.\d s, slow task \d+\.\d s\)\.$/
);
});
it('keeps the error for awaiters without an unhandled rejection', async () => {
const logger = createLoggerMock();
const tasks = createStartupTasks(logger);
const failing = tasks.run('failing task', async () => {
throw new Error('boom');
});
// Nothing awaits the task yet. Jest fails the test on an unhandled rejection.
await new Promise(resolve => setImmediate(resolve));
await expect(failing).rejects.toThrow('boom');
expect(logger.warn).toHaveBeenCalledWith(expect.stringMatching(/^failing task failed after/));
expect(tasks.summary()).toMatch(/\(\)\.$/);
});
it('aborts the signal with the first failure and gives each task a child logger', async () => {
const logger = createLoggerMock();
const tasks = createStartupTasks(logger);
let taskLogger: unknown;
await tasks.run('named task', async l => {
taskLogger = l;
});
expect(logger.child).toHaveBeenCalledWith({ startupTask: 'named task' });
expect(taskLogger).toBe(logger);
expect(tasks.signal.aborted).toBe(false);
const first = new Error('first failure');
await expect(tasks.run('first', async () => Promise.reject(first))).rejects.toBe(first);
await expect(
tasks.run('second', async () => Promise.reject(new Error('second failure')))
).rejects.toThrow('second failure');
expect(tasks.signal.aborted).toBe(true);
expect(tasks.signal.reason).toBe(first);
});
it('stops waiting on untilAborted when startup is aborted', async () => {
const tasks = createStartupTasks(createLoggerMock());
const never = new Promise<void>(() => {});
const waiting = tasks.untilAborted(never);
const failure = new Error('boot failed');
tasks.abort(failure);
await expect(waiting).rejects.toBe(failure);
await expect(tasks.untilAborted(Promise.resolve('late'))).rejects.toBe(failure);
await expect(
createStartupTasks(createLoggerMock()).untilAborted(Promise.resolve('ok'))
).resolves.toBe('ok');
});
});
@@ -20,6 +20,11 @@ const AgentDeviceArtifactSchema = z.object({
id: z.string(),
artifactType: z.string().nullish(),
filename: z.string(),
// Display rotation of a screenshot. An unknown value is dropped so it cannot block the upload.
displayRotation: z
.enum(['portrait', 'portrait-upside-down', 'landscape-left', 'landscape-right'])
.nullish()
.catch(undefined),
});
const AgentDeviceArtifactsListResponseSchema = z.object({
artifacts: z.array(AgentDeviceArtifactSchema),
@@ -154,6 +159,9 @@ export async function uploadAgentDeviceArtifactAsync(
name: `${artifact.filename} (${artifact.id})`,
filename: artifact.filename,
kind: artifact.artifactType ?? undefined,
...(artifact.displayRotation
? { metadata: { displayRotation: artifact.displayRotation } }
: {}),
size,
stream: createReadStream(temporaryArtifactPath),
});
@@ -0,0 +1,134 @@
import { SystemError } from '@expo/eas-build-job';
import { asyncResult } from '@expo/results';
import fetch, { Response } from 'node-fetch';
import { z } from 'zod';
const PostApi = {
'/token': {
request: z.object({
grant_type: z.literal('urn:ietf:params:oauth:grant-type:jwt-bearer'),
assertion: z.string().min(1),
}),
response: z.object({
access_token: z.string().min(1),
expires_in: z.number().finite().gt(60),
token_type: z.string().refine(value => value.toLowerCase() === 'bearer'),
}),
},
};
const OAuthErrorZ = z.enum([
'invalid_request',
'invalid_client',
'invalid_grant',
'unauthorized_client',
'unsupported_grant_type',
'invalid_scope',
'server_error',
'temporarily_unavailable',
]);
export class GooglePlayAuthRequestError extends Error {
constructor(
public readonly status: number,
public readonly errorCode?: z.output<typeof OAuthErrorZ>
) {
super(`Google Play OAuth request failed (HTTP ${status})${errorCode ? `: ${errorCode}` : ''}.`);
}
}
export class GooglePlayAuthClient {
private readonly baseUrl = 'https://oauth2.googleapis.com';
async postAsync<TPath extends keyof typeof PostApi>(
path: TPath,
body: z.input<(typeof PostApi)[TPath]['request']>,
signal?: AbortSignal
): Promise<z.output<(typeof PostApi)[TPath]['response']>> {
const schema = PostApi[path];
return await this.requestAsync({
method: 'POST',
path,
body,
requestSchema: schema.request,
responseSchema: schema.response,
signal,
});
}
private async requestAsync({
method,
path,
body,
requestSchema,
responseSchema,
signal,
}: {
method: 'POST';
path: string;
body: unknown;
requestSchema: z.ZodType<any>;
responseSchema: z.ZodType<any>;
signal?: AbortSignal;
}): Promise<any> {
const parsedBody = await asyncResult((async () => requestSchema.parse(body))());
if (!parsedBody.ok) {
throw new Error(
`Malformed request to Google Play OAuth: ${z.prettifyError(
parsedBody.enforceError() as z.ZodError
)}`
);
}
signal?.throwIfAborted();
let response: Response;
try {
response = await fetch(new URL(path, this.baseUrl).toString(), {
method,
redirect: 'manual',
signal,
body: new URLSearchParams(parsedBody.value),
});
} catch (cause) {
signal?.throwIfAborted();
throw new SystemError('Google Play OAuth request failed before a response was received.', {
cause,
});
}
let text: string;
try {
text = await response.text();
} catch (cause) {
signal?.throwIfAborted();
throw new SystemError(
`Could not read the Google Play OAuth response (HTTP ${response.status}).`,
{
cause,
}
);
}
const parsedJson = await asyncResult((async () => JSON.parse(text))());
if (!response.ok) {
const errorCode = OAuthErrorZ.safeParse(parsedJson.ok ? parsedJson.value?.error : undefined);
throw new GooglePlayAuthRequestError(
response.status,
errorCode.success ? errorCode.data : undefined
);
}
if (!parsedJson.ok) {
throw new SystemError(
`Malformed JSON response from Google Play OAuth (HTTP ${response.status}).`
);
}
const parsedResponse = await asyncResult(
(async () => responseSchema.parse(parsedJson.value))()
);
if (!parsedResponse.ok) {
throw new SystemError(
`Malformed response from Google Play OAuth (HTTP ${response.status}): ${z.prettifyError(
parsedResponse.enforceError() as z.ZodError
)}`
);
}
return parsedResponse.value;
}
}
@@ -0,0 +1,38 @@
import * as jose from 'jose';
import { KeyObject } from 'node:crypto';
import { GooglePlayAuthClient } from './GooglePlayAuthClient';
export type GoogleServiceAccount = {
client_email: string;
private_key: KeyObject;
private_key_id?: string;
};
export namespace GooglePlayAuthUtils {
async function createAssertionAsync(serviceAccount: GoogleServiceAccount): Promise<string> {
return await new jose.SignJWT({
scope: 'https://www.googleapis.com/auth/androidpublisher',
})
.setProtectedHeader({ alg: 'RS256', typ: 'JWT', kid: serviceAccount.private_key_id })
.setIssuer(serviceAccount.client_email)
.setAudience('https://oauth2.googleapis.com/token')
.setIssuedAt()
.setExpirationTime('1h')
.sign(serviceAccount.private_key);
}
export async function createTokenAsync(
serviceAccount: GoogleServiceAccount,
signal?: AbortSignal
): Promise<{ value: string; expiresAt: number }> {
const startedAt = Date.now();
const assertion = await createAssertionAsync(serviceAccount);
const response = await new GooglePlayAuthClient().postAsync(
'/token',
{ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion },
signal
);
return { value: response.access_token, expiresAt: startedAt + response.expires_in * 1000 };
}
}
@@ -0,0 +1,285 @@
import { SystemError } from '@expo/eas-build-job';
import fetch, { Response } from 'node-fetch';
import { z } from 'zod';
import { GooglePlayAuthUtils, GoogleServiceAccount } from './GooglePlayAuthUtils';
export class GooglePlayApiError extends Error {
constructor(
readonly status: number,
readonly apiMessage: string,
readonly reasons: string[]
) {
super(`Google Play request failed (HTTP ${status})${apiMessage ? `: ${apiMessage}` : '.'}`);
}
}
const ReleaseStatusZ = z.enum(['draft', 'inProgress', 'halted', 'completed']);
const ReleaseNoteZ = z.object({
language: z.string(),
text: z.string(),
});
const ReleaseZ = z.object({
name: z.string().optional(),
versionCodes: z.array(z.string()),
status: ReleaseStatusZ,
userFraction: z.number().gt(0).lt(1).optional(),
releaseNotes: z.array(ReleaseNoteZ).optional(),
});
const GetApi = {
'/androidpublisher/v3/applications/:packageName/edits/:editId': {
path: z.object({ packageName: z.string().min(1), editId: z.string().min(1) }),
request: z.object({}),
query: z.object({}),
response: z.object({ id: z.string().min(1), expiryTimeSeconds: z.string().optional() }),
},
'/androidpublisher/v3/applications/:packageName/edits/:editId/tracks/:track': {
path: z.object({
packageName: z.string().min(1),
editId: z.string().min(1),
track: z.string().min(1),
}),
request: z.object({}),
query: z.object({}),
response: z.object({
track: z.string().min(1),
releases: z.array(ReleaseZ).optional(),
}),
},
};
const PostApi = {
'/androidpublisher/v3/applications/:packageName/edits': {
path: z.object({ packageName: z.string().min(1) }),
request: z.object({}),
query: z.object({}),
response: z.object({ id: z.string().min(1), expiryTimeSeconds: z.string().optional() }),
},
'/androidpublisher/v3/applications/:packageName/edits/:editId:commit': {
path: z.object({ packageName: z.string().min(1), editId: z.string().min(1) }),
request: z.object({}),
query: z.object({ changesNotSentForReview: z.boolean().optional() }),
response: z.object({ id: z.string().min(1), expiryTimeSeconds: z.string().optional() }),
},
'/androidpublisher/v3/applications/:packageName/edits/:editId:validate': {
path: z.object({ packageName: z.string().min(1), editId: z.string().min(1) }),
request: z.object({}),
query: z.object({}),
response: z.object({ id: z.string().min(1), expiryTimeSeconds: z.string().optional() }),
},
};
const PutApi = {
'/androidpublisher/v3/applications/:packageName/edits/:editId/tracks/:track': {
path: z.object({
packageName: z.string().min(1),
editId: z.string().min(1),
track: z.string().min(1),
}),
request: z.object({
track: z.string().min(1),
releases: z.array(ReleaseZ).optional(),
}),
query: z.object({}),
response: z.object({
track: z.string().min(1),
releases: z.array(ReleaseZ).optional(),
}),
},
};
const DeleteApi = {
'/androidpublisher/v3/applications/:packageName/edits/:editId': {
path: z.object({ packageName: z.string().min(1), editId: z.string().min(1) }),
},
};
export class GooglePlayClient {
private readonly baseUrl = 'https://androidpublisher.googleapis.com';
private cachedToken?: Promise<{ value: string; expiresAt: number }>;
constructor(private readonly serviceAccount: GoogleServiceAccount) {}
private async getTokenAsync(signal?: AbortSignal): Promise<string> {
signal?.throwIfAborted();
while (this.cachedToken) {
const pending = this.cachedToken;
const cached = await pending;
signal?.throwIfAborted();
if (Date.now() < cached.expiresAt - 60_000) {
return cached.value;
}
if (this.cachedToken === pending) {
break;
}
}
const pending = GooglePlayAuthUtils.createTokenAsync(this.serviceAccount, signal);
this.cachedToken = pending;
try {
return (await pending).value;
} catch (error) {
if (this.cachedToken === pending) {
this.cachedToken = undefined;
}
throw error;
}
}
async getAsync<TPath extends keyof typeof GetApi>(
path: TPath,
params: z.input<(typeof GetApi)[TPath]['path']>,
signal?: AbortSignal
): Promise<z.output<(typeof GetApi)[TPath]['response']>> {
return await this.sendJsonRequestAsync('GET', path, GetApi[path], {}, params, {}, signal);
}
async postAsync<TPath extends keyof typeof PostApi>(
path: TPath,
body: z.input<(typeof PostApi)[TPath]['request']>,
params: z.input<(typeof PostApi)[TPath]['path']>,
options: { query?: z.input<(typeof PostApi)[TPath]['query']>; signal?: AbortSignal } = {}
): Promise<z.output<(typeof PostApi)[TPath]['response']>> {
return await this.sendJsonRequestAsync(
'POST',
path,
PostApi[path],
body,
params,
options.query ?? {},
options.signal
);
}
async putAsync<TPath extends keyof typeof PutApi>(
path: TPath,
body: z.input<(typeof PutApi)[TPath]['request']>,
params: z.input<(typeof PutApi)[TPath]['path']>,
signal?: AbortSignal
): Promise<z.output<(typeof PutApi)[TPath]['response']>> {
return await this.sendJsonRequestAsync('PUT', path, PutApi[path], body, params, {}, signal);
}
async deleteAsync<TPath extends keyof typeof DeleteApi>(
path: TPath,
params: z.input<(typeof DeleteApi)[TPath]['path']>,
signal?: AbortSignal
): Promise<void> {
let url: string = path;
for (const [key, value] of Object.entries(DeleteApi[path].path.parse(params))) {
url = url.replace(`:${key}`, encodeURIComponent(value));
}
await this.requestAsync('DELETE', url, undefined, signal);
}
private async sendJsonRequestAsync(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
schema: {
path: z.ZodType<Record<string, string>>;
request: z.ZodType;
query: z.ZodType;
response: z.ZodType;
},
body: unknown,
params: unknown,
query: unknown,
signal?: AbortSignal
): Promise<any> {
const parsedBody = schema.request.parse(body);
for (const [key, value] of Object.entries(schema.path.parse(params))) {
path = path.replace(`:${key}`, encodeURIComponent(value));
}
const search = new URLSearchParams();
for (const [key, value] of Object.entries(
schema.query.parse(query) as Record<string, unknown>
)) {
if (value !== undefined) {
search.set(key, String(value));
}
}
const response = await this.requestAsync(
method,
search.size ? `${path}?${search}` : path,
method === 'GET' ? undefined : JSON.stringify(parsedBody),
signal
);
let data: unknown;
try {
data = await response.json();
} catch {
signal?.throwIfAborted();
throw new SystemError(
`Could not read the Google Play JSON response (HTTP ${response.status}).`
);
}
const parsed = schema.response.safeParse(data);
if (!parsed.success) {
throw new SystemError(
`Malformed response from Google Play (HTTP ${response.status}): ${z.prettifyError(parsed.error)}`
);
}
return parsed.data;
}
private async requestAsync(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
body?: string,
signal?: AbortSignal
): Promise<Response> {
const url = new URL(path, this.baseUrl);
const token = await this.getTokenAsync(signal);
let response: Response;
signal?.throwIfAborted();
try {
response = await fetch(url.toString(), {
method,
body,
signal,
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
redirect: 'manual',
});
} catch {
signal?.throwIfAborted();
throw new SystemError('Google Play request failed before a response was received.');
}
if (!response.ok) {
let data: unknown;
try {
data = await response.json();
} catch {
signal?.throwIfAborted();
}
const parsed = z
.object({
error: z.object({
message: z.string().optional(),
errors: z.array(z.object({ reason: z.string().optional() })).optional(),
}),
})
.safeParse(data);
throw new GooglePlayApiError(
response.status,
parsed.success ? (parsed.data.error.message ?? '') : '',
parsed.success
? (parsed.data.error.errors?.flatMap(error => (error.reason ? [error.reason] : [])) ?? [])
: []
);
}
return response;
}
}
@@ -0,0 +1,111 @@
import { SystemError } from '@expo/eas-build-job';
import nock from 'nock';
import { inspect } from 'node:util';
import { GooglePlayAuthClient, GooglePlayAuthRequestError } from '../GooglePlayAuthClient';
jest.unmock('node-fetch');
const request = {
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer' as const,
assertion: 'caller-signed-assertion',
};
let client: GooglePlayAuthClient;
beforeEach(() => {
nock.disableNetConnect();
client = new GooglePlayAuthClient();
});
afterEach(() => {
const pending = nock.pendingMocks();
nock.cleanAll();
nock.enableNetConnect();
expect(pending).toEqual([]);
});
it('posts the caller request and returns a validated response on each call', async () => {
nock('https://oauth2.googleapis.com')
.post('/token', request)
.reply(200, { access_token: 'first-token', expires_in: 3600, token_type: 'Bearer' })
.post('/token', request)
.reply(200, { access_token: 'second-token', expires_in: 1800, token_type: 'Bearer' });
await expect(client.postAsync('/token', request)).resolves.toEqual({
access_token: 'first-token',
expires_in: 3600,
token_type: 'Bearer',
});
await expect(client.postAsync('/token', request)).resolves.toEqual({
access_token: 'second-token',
expires_in: 1800,
token_type: 'Bearer',
});
});
it.each([
{ access_token: '', expires_in: 3600, token_type: 'Bearer' },
{ access_token: 'example-token', expires_in: 60, token_type: 'Bearer' },
{ access_token: 'example-token', expires_in: '3600', token_type: 'Bearer' },
{ access_token: 'example-token', expires_in: 3600, token_type: 1 },
{ access_token: 'example-token', expires_in: 3600, token_type: 'Basic' },
])(
'includes validation details without token data for invalid OAuth responses (%j)',
async body => {
nock('https://oauth2.googleapis.com').post('/token').reply(200, body);
const result = client.postAsync('/token', request);
await expect(result).rejects.toThrow('Malformed response from Google Play OAuth (HTTP 200):');
try {
await result;
} catch (error) {
expect(inspect(error, { depth: null })).not.toContain('example-token');
expect((error as Error).message).toMatch(/access_token|expires_in|token_type/);
}
}
);
it.each([
[{ error: 'invalid_grant', error_description: 'example-token' }, 'invalid_grant'],
[{ error: 'example-token' }, undefined],
['<html>example-token</html>', undefined],
])(
'keeps HTTP status and known OAuth codes without retaining response data (%j)',
async (body, errorCode) => {
nock('https://oauth2.googleapis.com').post('/token').reply(400, body);
let error: unknown;
try {
await client.postAsync('/token', request);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(GooglePlayAuthRequestError);
expect(error).toMatchObject({ status: 400, errorCode });
expect((error as Error).message).toContain('HTTP 400');
if (errorCode) {
expect((error as Error).message).toContain(errorCode);
}
expect(inspect(error, { depth: null })).not.toContain('example-token');
}
);
it.each(['network failure', 'malformed JSON'])('returns a system error for %s', async failure => {
const endpoint = nock('https://oauth2.googleapis.com').post('/token');
if (failure === 'network failure') {
endpoint.replyWithError('connection failed');
} else {
endpoint.reply(200, 'example-token is not JSON');
}
const result = client.postAsync('/token', request);
await expect(result).rejects.toBeInstanceOf(SystemError);
if (failure === 'network failure') {
await expect(result).rejects.toMatchObject({
cause: { message: expect.stringContaining('connection failed') },
});
} else {
await expect(result).rejects.toThrow(
'Malformed JSON response from Google Play OAuth (HTTP 200).'
);
try {
await result;
} catch (error) {
expect(inspect(error, { depth: null })).not.toContain('example-token');
}
}
});
@@ -0,0 +1,158 @@
import nock from 'nock';
import { generateKeyPairSync } from 'node:crypto';
import { SystemError } from '@expo/eas-build-job';
import { ZodError } from 'zod';
import { GooglePlayClient } from '../GooglePlayClient';
const packageName = 'dev.expo.submitfixture';
const editPath = `/androidpublisher/v3/applications/${packageName}/edits/edit`;
const api = (): nock.Scope =>
nock('https://androidpublisher.googleapis.com', {
reqheaders: { authorization: 'Bearer test-access-token' },
});
jest.unmock('node-fetch');
const serviceAccount = {
client_email: '[email protected]',
private_key: generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey,
};
let client: GooglePlayClient;
beforeEach(() => {
nock.disableNetConnect();
client = new GooglePlayClient(serviceAccount);
nock('https://oauth2.googleapis.com')
.post('/token')
.optionally()
.reply(200, { access_token: 'test-access-token', expires_in: 3600, token_type: 'Bearer' });
});
afterEach(() => {
const pending = nock.pendingMocks();
nock.cleanAll();
nock.enableNetConnect();
jest.restoreAllMocks();
expect(pending).toEqual([]);
});
it('creates an edit and validates the response', async () => {
api().post(`/androidpublisher/v3/applications/${packageName}/edits`, {}).reply(200, {
id: 'edit',
expiryTimeSeconds: '1234567890',
});
await expect(
client.postAsync(
'/androidpublisher/v3/applications/:packageName/edits',
{},
{
packageName,
}
)
).resolves.toEqual({ id: 'edit', expiryTimeSeconds: '1234567890' });
});
it('encodes track path parameters and validates track responses', async () => {
api().get(`${editPath}/tracks/wear%3Aproduction`).reply(200, { track: 'wear:production' });
await expect(
client.getAsync('/androidpublisher/v3/applications/:packageName/edits/:editId/tracks/:track', {
packageName,
editId: 'edit',
track: 'wear:production',
})
).resolves.toEqual({ track: 'wear:production' });
});
it('rejects invalid rollout requests before sending them', async () => {
await expect(
client.putAsync(
'/androidpublisher/v3/applications/:packageName/edits/:editId/tracks/:track',
{
track: 'production',
releases: [{ versionCodes: ['123'], status: 'inProgress', userFraction: 1 }],
},
{ packageName, editId: 'edit', track: 'production' }
)
).rejects.toBeInstanceOf(ZodError);
});
it('does not retry an ambiguous edit commit', async () => {
api()
.post(`${editPath}:commit`, {})
.query({ changesNotSentForReview: false })
.replyWithError('Lost response');
await expect(
client.postAsync(
'/androidpublisher/v3/applications/:packageName/edits/:editId:commit',
{},
{ packageName, editId: 'edit' },
{ query: { changesNotSentForReview: false } }
)
).rejects.toBeInstanceOf(SystemError);
});
it('deletes an edit with an empty response', async () => {
api().delete(editPath).reply(204);
await expect(
client.deleteAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
packageName,
editId: 'edit',
})
).resolves.toBeUndefined();
});
it('rejects malformed success responses without exposing their contents', async () => {
api()
.get(editPath)
.reply(200, { id: 'edit', expiryTimeSeconds: { secret: 'PRIVATE' } });
await expect(
client.getAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
packageName,
editId: 'edit',
})
).rejects.toThrow('Malformed response from Google Play (HTTP 200)');
});
it('retains structured API errors', async () => {
api()
.get(editPath)
.reply(403, { error: { message: 'Denied', errors: [{ reason: 'forbidden' }] } });
await expect(
client.getAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
packageName,
editId: 'edit',
})
).rejects.toMatchObject({ status: 403, apiMessage: 'Denied', reasons: ['forbidden'] });
});
it.each(['body', 'query'])('accepts extra fields in an empty %s object', async field => {
api()
.post(`/androidpublisher/v3/applications/${packageName}/edits`, {})
.reply(200, { id: 'edit' });
await expect(
client.postAsync(
'/androidpublisher/v3/applications/:packageName/edits',
(field === 'body' ? { unexpected: true } : {}) as never,
{ packageName },
{ query: (field === 'query' ? { unexpected: true } : {}) as never }
)
).resolves.toEqual({ id: 'edit' });
});
it('shares a token across concurrent requests and refreshes before expiry', async () => {
nock.cleanAll();
const now = Date.now();
const clock = jest.spyOn(Date, 'now').mockReturnValue(now);
nock('https://oauth2.googleapis.com')
.post('/token')
.twice()
.reply(200, { access_token: 'test-access-token', expires_in: 3600, token_type: 'Bearer' });
api().get(editPath).times(4).reply(200, { id: 'edit' });
const get = () =>
client.getAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
packageName,
editId: 'edit',
});
await Promise.all([get(), get()]);
clock.mockReturnValue(now + 3550_000);
await Promise.all([get(), get()]);
});
@@ -0,0 +1,92 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import spawn from '@expo/spawn-async';
import StreamZip from 'node-stream-zip';
export type AndroidArtifactType = 'apk' | 'aab';
export async function readAndroidArtifactInfoAsync(
artifactPath: string,
signal?: AbortSignal
): Promise<{ artifactType: AndroidArtifactType; packageName: string }> {
signal?.throwIfAborted();
const artifactType = await detectAndroidArtifactTypeAsync(artifactPath);
switch (artifactType) {
case 'apk': {
const packageName = await readApkPackageNameAsync(artifactPath, signal);
return { artifactType, packageName };
}
case 'aab': {
const packageName = await readAabPackageNameAsync(artifactPath, signal);
return { artifactType, packageName };
}
}
}
async function detectAndroidArtifactTypeAsync(artifactPath: string): Promise<AndroidArtifactType> {
const zip = new StreamZip.async({ file: artifactPath });
try {
const bundleConfig = await zip.entry('BundleConfig.pb');
return bundleConfig ? 'aab' : 'apk';
} catch (error) {
throw new UserError(
'EAS_READ_ANDROID_APP_INFO_INVALID_ARTIFACT',
'Cannot read the Android artifact as a ZIP archive.',
{ cause: error }
);
} finally {
await zip.close().catch(() => {});
}
}
async function readApkPackageNameAsync(
artifactPath: string,
signal?: AbortSignal
): Promise<string> {
signal?.throwIfAborted();
try {
const { stdout } = await spawn('aapt2', ['dump', 'packagename', artifactPath], {
stdio: 'pipe',
signal,
});
return stdout.trim();
} catch (error) {
signal?.throwIfAborted();
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new SystemError('aapt2 is not installed or is not available on the worker PATH.', {
cause: error,
});
}
throw new UserError(
'EAS_READ_ANDROID_APP_INFO_INVALID_MANIFEST',
'Cannot read the APK manifest with aapt2. Check the app binary.',
{ cause: error }
);
}
}
async function readAabPackageNameAsync(
artifactPath: string,
signal?: AbortSignal
): Promise<string> {
signal?.throwIfAborted();
try {
const { stdout } = await spawn(
'bundletool',
['dump', 'manifest', '--bundle', artifactPath, '--xpath', '/manifest/@package'],
{ stdio: 'pipe', signal }
);
return stdout.trim();
} catch (error) {
signal?.throwIfAborted();
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new SystemError('bundletool is not installed or is not available on the worker PATH.', {
cause: error,
});
}
throw new UserError(
'EAS_READ_ANDROID_APP_INFO_INVALID_MANIFEST',
'Cannot read the AAB manifest with bundletool. Check the app binary.',
{ cause: error }
);
}
}
@@ -0,0 +1,860 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { type BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import readline from 'node:readline';
import {
type AndroidDeviceSerialId,
type AndroidEmulatorLaunchGate,
} from '../../utils/AndroidEmulatorUtils';
import { sleepAsync } from '../../utils/retry';
import {
LOCAL_EGRESS_HANDOFF_PATH,
LOCAL_EGRESS_PROXY_HOST,
readLocalEgressHandoffAsync,
} from './localEgress';
import {
type GuardEvent,
GuardEventRelay,
registerLocalEgressGuardRelay,
} from './localEgressGuard';
/**
* Android emulator side of local egress, on Linux device hosts.
*
* - The emulator's `-http-proxy` points at a relay on LOCAL_EGRESS_RELAY_PORT.
* The relay always listens, because qemu tests the proxy once at startup and
* ignores it for the emulator's whole life if that test fails. It forwards
* each connection to the chisel listener, which exists only while the EAS
* CLI egress client is connected, and refuses the request otherwise.
* - The guest's system proxy is the relay as well (10.0.2.2 is host loopback),
* so proxy-aware clients send hostnames instead of IP addresses.
* - Every emulator process runs in one systemd scope. An nftables table refuses
* traffic from that scope that is not loopback TCP or DNS to
* systemd-resolved: UDP, QUIC, ICMP and the emulator's
* own direct connections. Kernel log lines from the fence become session log
* lines through the guard relay.
*
* This keeps routing faithful; it is not a security boundary.
*/
export const LOCAL_EGRESS_RELAY_PORT = 8898;
export const ANDROID_EMULATOR_HOST_LOOPBACK_ALIAS = '10.0.2.2';
/** TEST-NET-1; the relay answers CONNECTs to it itself, so it proves the proxy path is up. */
export const LOCAL_EGRESS_READINESS_TARGET = { host: '192.0.2.1', port: 443 } as const;
export const LOCAL_EGRESS_SESSION_UNIT = 'eas-egress-session';
export const EGRESS_FENCE_TABLE = 'eas_fence';
const FENCE_LOG_PREFIX = 'eas-egress';
const FENCE_RELAY_KEY = 'android-emulator-fence';
const FENCED_PROCESS_LABEL = 'Android emulator';
const FENCED_PROCESS_PATTERN = /^(qemu-system|netsimd|crashpad_handle)/;
const RELAY_MAX_HEAD_BYTES = 16 * 1024;
const SCOPE_STARTUP_TIMEOUT_MS = 10_000;
const ADB_REVERSE_INTERVAL_MS = 3_000;
const GATE_SCRIPT = 'read -r _ || exit 1; exec "$@" < /dev/null';
const CONNECTION_ESTABLISHED = 'HTTP/1.1 200 Connection Established\r\n\r\n';
const CLIENT_NOT_CONNECTED_BODY = 'The EAS CLI local egress client is not connected.\r\n';
const CLIENT_NOT_CONNECTED =
'HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Type: text/plain\r\n' +
`Content-Length: ${Buffer.byteLength(CLIENT_NOT_CONNECTED_BODY)}\r\n\r\n${CLIENT_NOT_CONNECTED_BODY}`;
// ---------------------------------------------------------------------------
// Relay
// ---------------------------------------------------------------------------
export function parseConnectAuthority(head: string): { host: string; port: number } | null {
const match = /^CONNECT\s+(\[[^\]]+\]|[^\s:]+):(\d{1,5})\s+HTTP\/1\.[01]\r?\n/i.exec(head);
if (!match) {
return null;
}
return { host: match[1].replace(/^\[|\]$/g, '').toLowerCase(), port: Number(match[2]) };
}
function isReadinessTarget({ host, port }: { host: string; port: number }): boolean {
return host === LOCAL_EGRESS_READINESS_TARGET.host && port === LOCAL_EGRESS_READINESS_TARGET.port;
}
/**
* On the emulator's Wi-Fi path, a guest connection to the guest proxy
* (10.0.2.2:<relay port>) is itself wrapped in `-http-proxy`'s CONNECT.
*/
function isRelayTarget({ host, port }: { host: string; port: number }, relayPort: number): boolean {
return (
port === relayPort &&
(host === LOCAL_EGRESS_PROXY_HOST ||
host === ANDROID_EMULATOR_HOST_LOOPBACK_ALIAS ||
host === 'localhost')
);
}
export type LocalEgressRelay = {
port: number;
setLogger: (logger: bunyan) => void;
stopAsync: () => Promise<void>;
};
export async function startLocalEgressRelayAsync({
port = LOCAL_EGRESS_RELAY_PORT,
upstreamPort,
logger,
}: {
port?: number;
upstreamPort: number;
logger: bunyan;
}): Promise<LocalEgressRelay> {
let currentLogger = logger;
let refusedWhileDetached = 0;
const sockets = new Set<net.Socket>();
const track = (socket: net.Socket): void => {
sockets.add(socket);
socket.on('error', () => socket.destroy());
socket.once('close', () => sockets.delete(socket));
};
const endWith = (client: net.Socket, response: string): void => {
client.end(response, () => client.destroy());
};
const refuse = (client: net.Socket): void => {
if (refusedWhileDetached === 0) {
currentLogger.warn(
'Local egress: the Android emulator made a request while the EAS CLI egress client is not ' +
'connected, so it was refused. Requests from the emulator fail until the client connects.'
);
}
refusedWhileDetached++;
endWith(client, CLIENT_NOT_CONNECTED);
};
const attached = (): void => {
if (refusedWhileDetached > 0) {
currentLogger.info(
`Local egress: the EAS CLI egress client is connected. ${refusedWhileDetached} request(s) ` +
'from the Android emulator were refused while it was not.'
);
refusedWhileDetached = 0;
}
};
const forward = (client: net.Socket, buffered: Buffer): void => {
const upstream = net.connect({ host: LOCAL_EGRESS_PROXY_HOST, port: upstreamPort });
track(upstream);
let connected = false;
upstream.once('connect', () => {
connected = true;
attached();
if (buffered.length > 0) {
upstream.write(buffered);
}
client.pipe(upstream);
upstream.pipe(client);
client.resume();
});
upstream.once('error', () => {
if (!connected && !client.destroyed) {
refuse(client);
}
});
upstream.once('close', () => {
if (connected) {
client.destroy();
}
});
client.once('close', () => upstream.destroy());
};
const route = (client: net.Socket, initial: Buffer): void => {
let buffered = initial;
const decide = (): void => {
const headEnd = buffered.indexOf('\r\n\r\n');
if (headEnd === -1 && buffered.length < RELAY_MAX_HEAD_BYTES) {
return;
}
client.off('data', onData);
client.pause();
const target =
headEnd === -1
? null
: parseConnectAuthority(buffered.subarray(0, headEnd + 2).toString('latin1'));
if (target && isReadinessTarget(target)) {
endWith(client, CONNECTION_ESTABLISHED);
} else if (target && isRelayTarget(target, port)) {
client.write(CONNECTION_ESTABLISHED);
route(client, buffered.subarray(headEnd + 4));
} else {
forward(client, buffered);
}
};
const onData = (chunk: Buffer): void => {
buffered = Buffer.concat([buffered, chunk]);
decide();
};
client.on('data', onData);
client.resume();
if (buffered.length > 0) {
decide();
}
};
const server = net.createServer(client => {
track(client);
route(client, Buffer.alloc(0));
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(port, LOCAL_EGRESS_PROXY_HOST, () => {
server.removeListener('error', reject);
resolve();
});
});
server.unref();
return {
port,
setLogger: nextLogger => {
currentLogger = nextLogger;
},
stopAsync: async () => {
await new Promise<void>(resolve => {
server.close(() => resolve());
for (const socket of sockets) {
socket.destroy();
}
});
},
};
}
// ---------------------------------------------------------------------------
// Fence
// ---------------------------------------------------------------------------
export function buildEgressFenceRuleset({ cgroupLevel }: { cgroupLevel: number }): string {
return [
`table inet ${EGRESS_FENCE_TABLE} {`,
' set fenced { type cgroupsv2; }',
' counter fence_tcp {}',
' counter fence_udp {}',
' counter fence_other {}',
' chain out {',
' type filter hook output priority filter; policy accept;',
' ct state established,related accept',
` socket cgroupv2 level ${cgroupLevel} @fenced jump fence`,
' }',
' chain fence {',
// The emulator and netsimd talk to each other and to adb over loopback TCP;
// guest TCP reaches loopback only through the proxy.
' oif "lo" meta l4proto tcp accept',
' oif "lo" ip daddr 127.0.0.53 udp dport 53 accept',
` limit rate 50/second burst 200 packets log prefix "${FENCE_LOG_PREFIX} " level info`,
' meta l4proto tcp counter name fence_tcp reject with tcp reset',
' meta l4proto udp counter name fence_udp reject with icmpx admin-prohibited',
' counter name fence_other reject with icmpx admin-prohibited',
' }',
'}',
'',
].join('\n');
}
function compressIpv6(address: string): string {
try {
return new URL(`http://[${address}]`).hostname.slice(1, -1);
} catch {
return address;
}
}
/**
* One kernel log line from the fence's `log` rule, for example
* `eas-egress IN= OUT=ens3 SRC=10.0.0.2 DST=142.250.1.1 LEN=60 ... PROTO=TCP SPT=40000 DPT=443 ...`.
* nft sees the emulator's sockets, not the guest app behind them, so events
* carry no pid or callers.
*/
export function parseFenceLogLine(line: string): GuardEvent | null {
const match = new RegExp(`${FENCE_LOG_PREFIX} (IN=.*)$`).exec(line);
if (!match) {
return null;
}
const fields = new Map<string, string>();
for (const token of match[1].split(/\s+/)) {
const separator = token.indexOf('=');
if (separator > 0) {
fields.set(token.slice(0, separator), token.slice(separator + 1));
}
}
const protocol = fields.get('PROTO')?.toLowerCase();
const destination = fields.get('DST');
if (!protocol || !destination) {
return null;
}
const fn = protocol === 'icmpv6' ? 'icmp' : protocol;
const host = net.isIPv6(destination) ? compressIpv6(destination) : destination;
const destinationPort = fields.get('DPT');
const peer = destinationPort
? `${net.isIPv6(host) ? `[${host}]` : host}:${destinationPort}`
: host;
return {
process: FENCED_PROCESS_LABEL,
pid: null,
function: fn,
action: 'blocked',
peer,
callers: [],
note: describeFenceEvent(fn, destinationPort),
};
}
function describeFenceEvent(fn: string, destinationPort: string | undefined): string | undefined {
if (fn === 'tcp') {
return 'a connection the emulator itself opened, such as its update check; app TCP goes through the proxy';
}
if (fn === 'udp' && destinationPort === '443') {
return 'QUIC; clients usually retry over TCP through the proxy';
}
return undefined;
}
export type FenceCounters = { tcp: number; udp: number; other: number };
/** `nft -j list counters table inet eas_fence` output. */
export function parseFenceCounters(json: string): FenceCounters {
const counters: FenceCounters = { tcp: 0, udp: 0, other: 0 };
const parsed = JSON.parse(json) as {
nftables?: { counter?: { name?: string; packets?: number } }[];
};
for (const entry of parsed.nftables ?? []) {
const name = entry.counter?.name;
const packets = entry.counter?.packets ?? 0;
if (name === 'fence_tcp') {
counters.tcp = packets;
} else if (name === 'fence_udp') {
counters.udp = packets;
} else if (name === 'fence_other') {
counters.other = packets;
}
}
return counters;
}
export function getCgroupLevel(cgroupPath: string): number {
return cgroupPath.split('/').filter(Boolean).length;
}
async function startSessionScopeAsync({ env }: { env: BuildStepEnv }): Promise<string> {
const scope = spawn(
'sudo',
[
'systemd-run',
'--scope',
'--quiet',
`--unit=${LOCAL_EGRESS_SESSION_UNIT}`,
'--',
'sleep',
'infinity',
],
{ env, detached: true, stdio: 'ignore' }
);
scope.child.unref();
let exited = false;
void asyncResult(scope).then(() => {
exited = true;
});
const deadline = Date.now() + SCOPE_STARTUP_TIMEOUT_MS;
while (Date.now() < deadline) {
const result = await asyncResult(
spawn(
'systemctl',
['show', '-p', 'ControlGroup', '--value', `${LOCAL_EGRESS_SESSION_UNIT}.scope`],
{ env, stdio: 'pipe' }
)
);
const cgroupPath = result.ok ? result.value.stdout.trim().replace(/^\//, '') : '';
if (cgroupPath) {
return cgroupPath;
}
if (exited) {
break;
}
await sleepAsync(200);
}
throw new SystemError(
`Could not start the ${LOCAL_EGRESS_SESSION_UNIT} systemd scope for the Android emulator, so ` +
'local egress cannot keep its traffic on the tunnel. Retry the session; if it keeps failing, ' +
'please contact support.'
);
}
async function loadFenceAsync({
cgroupPath,
workDir,
env,
}: {
cgroupPath: string;
workDir: string;
env: BuildStepEnv;
}): Promise<void> {
const rulesetPath = path.join(workDir, 'fence.nft');
await fs.promises.writeFile(
rulesetPath,
buildEgressFenceRuleset({ cgroupLevel: getCgroupLevel(cgroupPath) })
);
await asyncResult(
spawn('sudo', ['nft', 'delete', 'table', 'inet', EGRESS_FENCE_TABLE], { env, stdio: 'pipe' })
);
try {
await spawn('sudo', ['nft', '-f', rulesetPath], { env, stdio: 'pipe' });
await spawn(
'sudo',
['nft', 'add', 'element', 'inet', EGRESS_FENCE_TABLE, 'fenced', `{ "${cgroupPath}" }`],
{ env, stdio: 'pipe' }
);
} catch (err) {
const failed = err as { stderr?: string };
throw new SystemError(
'Could not install the network fence for the Android emulator, so local egress cannot keep its ' +
'traffic on the tunnel. Retry the session; if it keeps failing, please contact support.' +
(failed.stderr ? ` nft output: ${failed.stderr.trim()}` : ''),
{ cause: err }
);
}
}
async function readFenceCountersAsync({ env }: { env: BuildStepEnv }): Promise<FenceCounters> {
const result = await spawn(
'sudo',
['nft', '-j', 'list', 'counters', 'table', 'inet', EGRESS_FENCE_TABLE],
{ env, stdio: 'pipe' }
);
return parseFenceCounters(result.stdout);
}
function startFenceLogReader({
env,
sinceEpochSeconds,
relay,
getLogger,
}: {
env: BuildStepEnv;
sinceEpochSeconds: number;
relay: GuardEventRelay;
getLogger: () => bunyan;
}): { stopAsync: () => Promise<void> } {
const reader = spawn(
'sudo',
['journalctl', '-k', '-f', '-o', 'cat', '--since', `@${sinceEpochSeconds}`],
{ env, stdio: ['ignore', 'pipe', 'pipe'], ignoreStdio: true }
);
let stopping = false;
const lines = reader.child.stdout
? readline.createInterface({ input: reader.child.stdout }).on('line', line => {
const event = parseFenceLogLine(line);
if (event) {
relay.handle(event);
}
})
: undefined;
const finished = asyncResult(reader).then(() => {
if (!stopping) {
getLogger().warn(
'Local egress: stopped reading the kernel log, so further connections the Android emulator ' +
'fence refuses are not listed here. They are still refused.'
);
}
});
reader.child.unref();
for (const stream of [reader.child.stdout, reader.child.stderr]) {
if (stream instanceof net.Socket) {
stream.unref();
}
}
return {
stopAsync: async () => {
stopping = true;
// Kernel log lines arrive slightly after the packet; give the last ones a moment.
await sleepAsync(500);
reader.child.kill('SIGTERM');
await finished;
lines?.close();
},
};
}
// ---------------------------------------------------------------------------
// Process coverage
// ---------------------------------------------------------------------------
export type EmulatorProcess = { pid: number; comm: string; cgroup: string };
export async function listEmulatorProcessesAsync(procRoot = '/proc'): Promise<EmulatorProcess[]> {
const entries = await fs.promises.readdir(procRoot);
const processes: EmulatorProcess[] = [];
for (const entry of entries) {
if (!/^\d+$/.test(entry)) {
continue;
}
try {
const comm = (await fs.promises.readFile(path.join(procRoot, entry, 'comm'), 'utf8')).trim();
if (!FENCED_PROCESS_PATTERN.test(comm)) {
continue;
}
const cgroupFile = await fs.promises.readFile(path.join(procRoot, entry, 'cgroup'), 'utf8');
const cgroup = /^0::(\S*)$/m.exec(cgroupFile)?.[1] ?? '';
processes.push({ pid: Number(entry), comm, cgroup });
} catch {
// The process exited while it was being read.
}
}
return processes;
}
export function findProcessesOutsideScope(
processes: readonly EmulatorProcess[],
cgroupPath: string
): EmulatorProcess[] {
const scope = `/${cgroupPath}`;
return processes.filter(({ cgroup }) => cgroup !== scope && !cgroup.startsWith(`${scope}/`));
}
async function assertEmulatorProcessesFencedAsync({
cgroupPath,
when,
}: {
cgroupPath: string;
when: string;
}): Promise<void> {
const outside = findProcessesOutsideScope(await listEmulatorProcessesAsync(), cgroupPath);
if (outside.length === 0) {
return;
}
throw new SystemError(
`Android emulator processes are running outside the local egress fence ${when}: ${outside
.map(({ comm, pid, cgroup }) => `${comm} (pid ${pid}, ${cgroup || 'unknown cgroup'})`)
.join(
', '
)}. Their traffic would not use the tunnel, so the session was stopped. Retry the ` +
'session; if it keeps failing, please contact support.'
);
}
// ---------------------------------------------------------------------------
// adb reverse for --egress-allow localhost:<port>
// ---------------------------------------------------------------------------
/** Loopback ports `pid` listens on, from `ss -Hltnp`. */
export function parseLoopbackListenerPorts(ssOutput: string, pid: number): number[] {
const ports = new Set<number>();
for (const line of ssOutput.split('\n')) {
if (!line.includes(`pid=${pid},`)) {
continue;
}
const match = /\s127\.0\.0\.1:(\d+)\s/.exec(line);
if (match) {
ports.add(Number(match[1]));
}
}
return [...ports].sort((a, b) => a - b);
}
export function parseAdbReversePorts(output: string): Set<number> {
const ports = new Set<number>();
for (const match of output.matchAll(/tcp:(\d+)\s+tcp:\d+/g)) {
ports.add(Number(match[1]));
}
return ports;
}
/**
* The EAS CLI forwards each `--egress-allow localhost:<port>` to the same
* port on this host's loopback, where the guest cannot see it. `adb reverse`
* makes guest `localhost:<port>` reach it. Reverse mappings are lost when an
* emulator or the adb server restarts, so this runs on a timer.
*/
async function reconcileAdbReverseAsync({
env,
logger,
chiselPid,
tunnelPorts,
reported,
serialIds,
}: {
env: BuildStepEnv;
logger: bunyan;
chiselPid: number;
/** chisel's own listeners: the proxy port and the control port ngrok forwards to. */
tunnelPorts: readonly number[];
reported: Set<string>;
serialIds?: AndroidDeviceSerialId[];
}): Promise<void> {
const listeners = await spawn('ss', ['-Hltnp'], { env, stdio: 'pipe' });
const ports = parseLoopbackListenerPorts(listeners.stdout, chiselPid).filter(
port => !tunnelPorts.includes(port)
);
if (ports.length === 0) {
return;
}
const serials =
serialIds ??
(await spawn('adb', ['devices'], { env, stdio: 'pipe' })).stdout
.split('\n')
.map(line => /^(emulator-\d+)\s+device\b/.exec(line)?.[1])
.filter((serial): serial is AndroidDeviceSerialId => !!serial);
for (const serial of serials) {
const list = await asyncResult(
spawn('adb', ['-s', serial, 'reverse', '--list'], { env, stdio: 'pipe' })
);
const existing = list.ok ? parseAdbReversePorts(list.value.stdout) : new Set<number>();
for (const port of ports) {
if (existing.has(port)) {
continue;
}
const result = await asyncResult(
spawn('adb', ['-s', serial, 'reverse', `tcp:${port}`, `tcp:${port}`], {
env,
stdio: 'pipe',
})
);
const key = `${serial}:${port}`;
if (result.ok && !reported.has(key)) {
reported.add(key);
logger.info(
`Local egress: localhost:${port} in ${serial} now reaches port ${port} on the EAS CLI machine.`
);
}
}
}
}
// ---------------------------------------------------------------------------
// Session
// ---------------------------------------------------------------------------
type AndroidLocalEgressSession = {
cgroupPath: string;
env: BuildStepEnv;
chiselPid: number;
tunnelPorts: number[];
relay: LocalEgressRelay;
reportedReverse: Set<string>;
reverseTimer?: NodeJS.Timeout;
logger: bunyan;
};
let activeSession: AndroidLocalEgressSession | undefined;
/**
* Start the relay, the session scope and the fence, and begin reporting fence
* events. Runs in `eas/start_local_egress`, before any emulator starts. The
* fence and scope stay in place until the job ends: removing them while an
* emulator still runs would let its traffic leave from this host.
*/
export async function startAndroidLocalEgressAsync({
env,
logger,
workDir,
chiselPid,
proxyPort,
controlPort,
}: {
env: BuildStepEnv;
logger: bunyan;
workDir: string;
chiselPid: number;
proxyPort: number;
controlPort: number;
}): Promise<{ stopAsync: () => Promise<void> }> {
if (activeSession) {
throw new SystemError('Android local egress is already started for this job.');
}
const relay = await startLocalEgressRelayAsync({ upstreamPort: proxyPort, logger });
try {
const cgroupPath = await startSessionScopeAsync({ env });
const sinceEpochSeconds = Math.floor(Date.now() / 1000) - 1;
await loadFenceAsync({ cgroupPath, workDir, env });
const guardRelay = new GuardEventRelay(logger);
let guardLogger = logger;
const reader = startFenceLogReader({
env,
sinceEpochSeconds,
relay: guardRelay,
getLogger: () => guardLogger,
});
registerLocalEgressGuardRelay(FENCE_RELAY_KEY, {
source: reader,
relay: guardRelay,
onRebind: nextLogger => {
guardLogger = nextLogger;
relay.setLogger(nextLogger);
},
afterSummaryAsync: async () => {
const { tcp, udp, other } = await readFenceCountersAsync({ env });
guardLogger.info(
`Local egress fence: refused ${tcp} TCP, ${udp} UDP and ${other} other packet(s) from the ` +
'Android emulator in total, including retries and any not listed above.'
);
},
});
const session: AndroidLocalEgressSession = {
cgroupPath,
env,
chiselPid,
tunnelPorts: [proxyPort, controlPort],
relay,
reportedReverse: new Set(),
logger,
};
session.reverseTimer = setInterval(() => {
void reconcileAdbReverseAsync({
env,
logger: guardLogger,
chiselPid,
tunnelPorts: session.tunnelPorts,
reported: session.reportedReverse,
}).catch(() => {});
}, ADB_REVERSE_INTERVAL_MS);
session.reverseTimer.unref();
activeSession = session;
logger.info(
`Local egress: Android emulator processes run in ${cgroupPath}, and the network fence ` +
'refuses traffic that cannot use the tunnel and reports it here.'
);
} catch (err) {
await relay.stopAsync();
throw err;
}
return {
stopAsync: async () => {
const session = activeSession;
activeSession = undefined;
if (session?.reverseTimer) {
clearInterval(session.reverseTimer);
}
await relay.stopAsync();
},
};
}
export type AndroidEmulatorLocalEgress = {
launchGate: AndroidEmulatorLaunchGate;
networkReadyTarget: { host: string; port: number };
configureBootedEmulatorAsync: (args: {
serialId: AndroidDeviceSerialId;
logger: bunyan;
}) => Promise<void>;
};
/**
* What `eas/start_android_emulator` changes for a local egress session, or
* null for every other session, which then starts the emulator exactly as
* before.
*/
export async function resolveAndroidEmulatorLocalEgressAsync({
handoffPath = LOCAL_EGRESS_HANDOFF_PATH,
}: { handoffPath?: string } = {}): Promise<AndroidEmulatorLocalEgress | null> {
const handoff = await readLocalEgressHandoffAsync(handoffPath);
if (!handoff) {
return null;
}
const session = activeSession;
if (handoff.platform !== 'android' || !session) {
throw new SystemError(
'This session uses local egress, but the Android emulator fence was not started, so the ' +
"emulator's traffic would not use the tunnel. Please contact support."
);
}
const relayAddress = `${LOCAL_EGRESS_PROXY_HOST}:${session.relay.port}`;
return {
launchGate: {
emulatorArgs: ['-http-proxy', relayAddress, '-no-metrics'],
wrapperCommand: 'bash',
wrapperArgs: ['-c', GATE_SCRIPT, 'eas-egress-gate'],
admitAsync: async pid => {
await assertEmulatorProcessesFencedAsync({
cgroupPath: session.cgroupPath,
when: 'before the emulator started',
});
await admitToScopeAsync({ pid, cgroupPath: session.cgroupPath, env: session.env });
},
},
networkReadyTarget: LOCAL_EGRESS_READINESS_TARGET,
configureBootedEmulatorAsync: async ({ serialId, logger }) => {
await assertEmulatorProcessesFencedAsync({
cgroupPath: session.cgroupPath,
when: 'after boot',
});
const guestProxy = `${ANDROID_EMULATOR_HOST_LOOPBACK_ALIAS}:${session.relay.port}`;
const proxyResult = await asyncResult(
spawn(
'adb',
['-s', serialId, 'shell', 'settings', 'put', 'global', 'http_proxy', guestProxy],
{
env: session.env,
stdio: 'pipe',
}
)
);
if (proxyResult.ok) {
logger.info(
`Local egress: the system proxy in ${serialId} is ${guestProxy}, so apps that honor it ` +
"resolve hostnames on the EAS CLI machine. Other TCP still exits from that machine through the emulator's proxy."
);
} else {
logger.warn(
{ err: proxyResult.reason },
`Local egress: could not set the system proxy in ${serialId}. Traffic still exits from the ` +
"EAS CLI machine through the emulator's proxy, but hostnames resolve on this worker."
);
}
await reconcileAdbReverseAsync({
env: session.env,
logger,
chiselPid: session.chiselPid,
tunnelPorts: session.tunnelPorts,
reported: session.reportedReverse,
serialIds: [serialId],
}).catch(err =>
logger.warn(
{ err },
`Local egress: could not forward allowed localhost ports to ${serialId}.`
)
);
},
};
}
async function admitToScopeAsync({
pid,
cgroupPath,
env,
}: {
pid: number;
cgroupPath: string;
env: BuildStepEnv;
}): Promise<void> {
const procsPath = `/sys/fs/cgroup/${cgroupPath}/cgroup.procs`;
try {
await spawn('sudo', ['sh', '-c', 'echo "$1" > "$2"', 'sh', String(pid), procsPath], {
env,
stdio: 'pipe',
});
const cgroupFile = await fs.promises.readFile(`/proc/${pid}/cgroup`, 'utf8');
if (!cgroupFile.includes(`0::/${cgroupPath}\n`)) {
throw new Error(`/proc/${pid}/cgroup is ${cgroupFile.trim()}`);
}
} catch (err) {
throw new SystemError(
'Could not move the Android emulator into the local egress fence, so its traffic would not use ' +
'the tunnel. Retry the session; if it keeps failing, please contact support.',
{ cause: err }
);
}
}
@@ -1,8 +1,15 @@
import { SystemError } from '@expo/eas-build-job';
import { graphql } from 'gql.tada';
import fetch, { Headers } from 'node-fetch';
import type { Readable } from 'node:stream';
import { setTimeout as delay } from 'node:timers/promises';
import { CustomBuildContext } from '../../customBuildContext';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import {
DeviceRunSessionTimeoutError,
withDeviceRunSessionTimeoutAsync,
} from './deviceRunSessionTimeout';
const CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
mutation CreateDeviceRunSessionArtifactUploadSession(
@@ -20,82 +27,178 @@ const CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
}
`);
type ArtifactMetadata = {
deviceRunSessionId: string;
artifactId: string;
name: string;
filename: string;
kind: string | undefined;
metadata?: Record<string, unknown>;
size: number;
};
type ArtifactSource = { stream: Readable; reopenStream?: () => Readable };
type ArtifactUploadSession = Awaited<
ReturnType<typeof createDeviceRunSessionArtifactUploadSessionAsync>
>;
export async function uploadDeviceRunSessionArtifactAsync(
ctx: CustomBuildContext,
{
deviceRunSessionId,
artifactId,
name,
filename,
kind,
metadata,
size,
stream,
}: {
deviceRunSessionId: string;
artifactId: string;
name: string;
filename: string;
kind: string | undefined;
metadata?: Record<string, unknown>;
size: number;
stream: NodeJS.ReadableStream;
}
options: ArtifactMetadata & ArtifactSource & { signal?: AbortSignal }
): Promise<void> {
const uploadSession = await createDeviceRunSessionArtifactUploadSessionAsync(ctx, {
deviceRunSessionId,
artifactId,
name,
filename,
kind,
metadata,
size,
});
const response = await fetch(uploadSession.url, {
method: 'PUT',
headers: new Headers(uploadSession.headers as Record<string, string>),
body: stream,
});
if (!response.ok) {
throw new SystemError(
`Failed to upload device run session artifact ${artifactId}: ${response.status} ${response.statusText}`,
{ cause: response }
try {
const uploadSession = await withDeviceRunSessionTimeoutAsync(
{ name: 'Artifact upload session creation', timeoutMs: 15_000, signal: options.signal },
async signal => await createDeviceRunSessionArtifactUploadSessionAsync(ctx, options, signal)
);
options.signal?.throwIfAborted();
await putArtifactWithRetriesAsync({
uploadSession,
source: options,
artifactId: options.artifactId,
signal: options.signal,
});
} finally {
// One-shot callers may have opened their stream before upload-session creation failed.
options.stream.destroy();
}
}
async function putArtifactWithRetriesAsync({
uploadSession,
source,
artifactId,
signal,
}: {
uploadSession: ArtifactUploadSession;
source: ArtifactSource;
artifactId: string;
signal?: AbortSignal;
}): Promise<void> {
const attempts = source.reopenStream ? 3 : 1;
for (let attempt = 0; attempt < attempts; attempt++) {
signal?.throwIfAborted();
const stream = attempt > 0 && source.reopenStream ? source.reopenStream() : source.stream;
try {
// Upload time scales with file size, so the deadline bounds stalls, not the whole request.
await withDeviceRunSessionTimeoutAsync(
{ name: 'Artifact PUT', timeoutMs: 90_000, signal },
async (putSignal, resetDeadline) =>
await putArtifactAsync({
uploadSession,
stream,
artifactId,
signal: putSignal,
onProgress: resetDeadline,
})
);
return;
} catch (error) {
signal?.throwIfAborted();
const retryable =
error instanceof ArtifactPutError
? [408, 429, 500, 502, 503, 504].includes(error.status)
: error instanceof DeviceRunSessionTimeoutError ||
error instanceof ArtifactPutNetworkError;
if (!retryable || attempt + 1 === attempts) {
throw error;
}
await delay(500 * (attempt + 1), undefined, { signal });
} finally {
stream.destroy();
}
}
}
async function putArtifactAsync({
uploadSession,
stream,
artifactId,
signal,
onProgress,
}: {
uploadSession: ArtifactUploadSession;
stream: Readable;
artifactId: string;
signal: AbortSignal;
onProgress: () => void;
}): Promise<void> {
const requestController = new AbortController();
try {
const responding = fetch(uploadSession.url, {
method: 'PUT',
headers: new Headers(uploadSession.headers as Record<string, string>),
body: stream,
signal: AbortSignal.any([signal, requestController.signal]),
});
// fetch has piped the body already, so this listener observes flow without starting it.
stream.on('data', onProgress);
let response;
try {
response = await responding;
} catch (error) {
throw error instanceof Error && error.name === 'FetchError'
? new ArtifactPutNetworkError(artifactId, error)
: error;
}
signal.throwIfAborted();
if (!response.ok) {
throw new ArtifactPutError(artifactId, response);
}
} finally {
// node-fetch destroys the request body on abort, and its response stream is a PassThrough
// whose destruction leaves the socket open, so the request itself is aborted here.
requestController.abort();
}
}
class ArtifactPutError extends SystemError {
readonly status: number;
constructor(artifactId: string, response: { status: number; statusText: string }) {
super(
`Failed to upload device run session artifact ${artifactId}: HTTP ${response.status} ${response.statusText}.`
);
this.status = response.status;
}
}
// node-fetch names the request URL in its message, and here that URL is signed. The message keeps
// only the error code, and the original is not kept as the cause because loggers and Sentry print causes.
class ArtifactPutNetworkError extends SystemError {
readonly code: string | undefined;
constructor(artifactId: string, error: Error & { code?: unknown }) {
const code = typeof error.code === 'string' ? error.code : undefined;
super(
`Failed to upload device run session artifact ${artifactId}: ${code ?? 'network error'}.`
);
this.code = code;
}
}
async function createDeviceRunSessionArtifactUploadSessionAsync(
ctx: CustomBuildContext,
{
deviceRunSessionId,
artifactId,
name,
filename,
kind,
metadata,
size,
}: {
deviceRunSessionId: string;
artifactId: string;
name: string;
filename: string;
kind: string | undefined;
metadata?: Record<string, unknown>;
size: number;
}
{ deviceRunSessionId, artifactId, name, filename, kind, metadata, size }: ArtifactMetadata,
signal: AbortSignal
) {
const result = await ctx.graphqlClient
.mutation(CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION, {
deviceRunSessionId,
input: {
name,
filename,
...(kind !== undefined ? { kind } : {}),
...(metadata !== undefined ? { metadata } : {}),
size,
.mutation(
CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION,
{
deviceRunSessionId,
input: {
name,
filename,
...(kind !== undefined ? { kind } : {}),
...(metadata !== undefined ? { metadata } : {}),
size,
},
},
})
graphqlAbortContext(signal)
)
.toPromise();
signal.throwIfAborted();
if (result.error) {
throw new SystemError(
`Failed to create upload session for device run session artifact ${artifactId}: ${result.error.message}`,
@@ -0,0 +1,264 @@
import { type bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { graphql } from 'gql.tada';
import fetch from 'node-fetch';
import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { type CustomBuildContext } from '../../customBuildContext';
const PREVIEW_INTERVAL_MS = 60_000;
const PREVIEW_MAX_DIMENSION = 320;
const PREVIEW_QUALITY = 70;
const MAX_PREVIEW_SIZE_BYTES = 5 * 1024 * 1024;
const CREATE_PREVIEW_UPLOAD_SESSION_MUTATION = graphql(`
mutation CreateDeviceRunSessionPreviewUploadSession($deviceRunSessionId: ID!) {
deviceRunSession {
createPreviewUploadSession(deviceRunSessionId: $deviceRunSessionId) {
uploadSession {
url
headers
}
}
}
}
`);
/**
* node-fetch includes the request URL in its error messages, and this URL is a signed upload URL
* that is valid for the whole session. Replace such errors with one that names only the failure.
*/
async function putPreviewAsync(
uploadSession: { url: string; headers: Record<string, string> },
{ image, signal }: { image: Buffer; signal: AbortSignal }
): Promise<Awaited<ReturnType<typeof fetch>>> {
try {
return await fetch(uploadSession.url, {
method: 'PUT',
headers: uploadSession.headers,
body: image,
signal,
timeout: 30_000,
});
} catch (err) {
const { name, type, code } = err as { name?: unknown; type?: unknown; code?: unknown };
const details = Object.entries({ name, type, code })
.filter(([, value]) => typeof value === 'string' && value.length > 0)
.map(([key, value]) => `${key}: ${value}`)
.join(', ');
throw new Error(`Session preview upload request failed (${details || 'unknown error'}).`);
}
}
export function startDeviceRunSessionPreview({
ctx,
deviceRunSessionId,
captureAsync,
logger,
}: {
ctx: CustomBuildContext;
deviceRunSessionId: string;
captureAsync: (signal: AbortSignal) => Promise<Buffer>;
logger: bunyan;
}): { stopAsync: () => Promise<void> } {
const controller = new AbortController();
let timer: NodeJS.Timeout | undefined;
let uploadSession: { url: string; headers: Record<string, string> } | undefined;
let pending: Promise<void>;
const uploadAsync = async (): Promise<void> => {
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(60_000)]);
try {
const image = await captureAsync(signal);
if (controller.signal.aborted) {
return;
}
if (image.length === 0 || image.length > MAX_PREVIEW_SIZE_BYTES) {
throw new Error('Session preview is empty or exceeds the 5 MiB upload limit.');
}
if (!uploadSession) {
const result = await ctx.graphqlClient
.mutation(
CREATE_PREVIEW_UPLOAD_SESSION_MUTATION,
{ deviceRunSessionId },
{
// Keep the client's auth headers and combine cancellation after URQL sets its signal.
fetch: (url, options) =>
globalThis.fetch(url, {
...options,
signal: AbortSignal.any([signal, ...(options?.signal ? [options.signal] : [])]),
}),
}
)
.toPromise();
if (result.error) {
throw result.error;
}
const session = result.data?.deviceRunSession.createPreviewUploadSession.uploadSession;
if (!session) {
throw new Error('No session preview upload URL was returned.');
}
uploadSession = {
url: session.url,
headers: session.headers as Record<string, string>,
};
}
if (controller.signal.aborted) {
return;
}
const response = await putPreviewAsync(uploadSession, { image, signal });
if (!response.ok) {
throw new Error(`Session preview upload failed: ${response.status} ${response.statusText}`);
}
} catch (err) {
if (!controller.signal.aborted) {
logger.warn({ err }, 'Could not refresh the session preview; retrying in 60 seconds.');
}
}
};
const tick = (): void => {
pending = uploadAsync().finally(() => {
if (!controller.signal.aborted) {
timer = setTimeout(tick, PREVIEW_INTERVAL_MS);
timer.unref();
}
});
};
tick();
return {
stopAsync: async () => {
controller.abort();
clearTimeout(timer);
await pending;
},
};
}
/**
* Homebrew's FFmpeg is built without libwebp, so macOS encodes previews with `cwebp` from
* Homebrew's webp package instead. Throws when it cannot be installed.
*/
export async function ensureMacosPreviewEncoderInstalledAsync({
env,
logger,
}: {
env: BuildStepEnv;
logger: bunyan;
}): Promise<void> {
if ((await asyncResult(spawn('cwebp', ['-version'], { env, stdio: 'pipe' }))).ok) {
return;
}
logger.info('Installing webp with Homebrew for the session preview.');
await spawn('brew', ['install', 'webp'], {
env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1' },
logger,
});
}
export async function captureDeviceRunSessionPreviewAsync({
runtimePlatform,
device,
env,
signal,
}: {
runtimePlatform: BuildRuntimePlatform;
device: string;
env: BuildStepEnv;
signal: AbortSignal;
}): Promise<Buffer> {
const directory = await mkdtemp(path.join(os.tmpdir(), 'session-preview-'));
try {
const options = { env, signal, timeout: 30_000, stdio: 'pipe' as const };
const preview = path.join(directory, 'preview.webp');
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await captureIosPreviewAsync({ device, directory, preview, options });
} else {
await captureAndroidPreviewAsync({ device, directory, preview, options });
}
return await readFile(preview);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
type CaptureOptions = {
env: BuildStepEnv;
signal: AbortSignal;
timeout: number;
stdio: 'pipe';
};
async function captureIosPreviewAsync({
device,
directory,
preview,
options,
}: {
device: string;
directory: string;
preview: string;
options: CaptureOptions;
}): Promise<void> {
const screenshot = path.join(directory, 'screen.png');
const resized = path.join(directory, 'resized.png');
await spawn('xcrun', ['simctl', 'io', device, 'screenshot', screenshot], options);
await spawn('sips', ['-Z', String(PREVIEW_MAX_DIMENSION), screenshot, '--out', resized], options);
await spawn('cwebp', ['-quiet', '-q', String(PREVIEW_QUALITY), resized, '-o', preview], options);
}
async function captureAndroidPreviewAsync({
device,
directory,
preview,
options,
}: {
device: string;
directory: string;
preview: string;
options: CaptureOptions;
}): Promise<void> {
const screenshot = path.join(directory, 'screen.png');
let serial = device;
// expo-device-hub's readiness endpoint currently reports a placeholder instead of a serial.
if (serial === 'no-device-id') {
const result = await spawn('adb', ['devices'], options);
const devices = result.stdout
.split('\n')
.map(line => line.trim().split(/\s+/))
.filter(([, status]) => status === 'device');
if (devices.length !== 1) {
throw new Error('Expected exactly one connected Android device for the session preview.');
}
serial = devices[0][0];
}
const capture = spawn('adb', ['-s', serial, 'exec-out', 'screencap', '-p'], options);
// Preserve binary PNG output instead of spawn-async's UTF-8 stdout conversion.
await Promise.all([
capture,
pipeline(capture.child.stdout!, createWriteStream(screenshot), { signal: options.signal }),
]);
await spawn(
'ffmpeg',
[
'-nostdin',
'-y',
'-i',
screenshot,
'-vf',
`scale=${PREVIEW_MAX_DIMENSION}:${PREVIEW_MAX_DIMENSION}:force_original_aspect_ratio=decrease`,
'-frames:v',
'1',
'-c:v',
'libwebp',
'-quality',
String(PREVIEW_QUALITY),
preview,
],
options
);
}
Loaded 100 of 204 files, more files were not shown because too many files have changed in this diff. Show more