Compare commits

...
65 Commits
Author SHA1 Message Date
Expo CI 34e8b45bad v24.13.0 2026-10-12 17:44:06 +00:00
Tomasz Mazur d1de958201 [build-tools] Close artifact upload streams before an upload finishes (#4609) 2026-10-12 17:58:38 +02:00
Tomasz Mazur 2b672fc07f [build-tools] Add a sandbox uploadArtifact command (#4563)
* [build-tools] Add a sandbox uploadArtifact command

* [build-tools] Add changelog entry for sandbox uploadArtifact

* [build-tools] Return BAD_REQUEST for refused sandbox artifact uploads

* [build-tools] Let sandbox uploadArtifact wait for the upload and report its status and download URL

* [build-tools] Wait up to 20 seconds for sandbox artifact uploads by default

* [build-tools] Count upload session creation toward the sandbox upload yield time

* [build-tools] Replace the shared withTimeoutAsync with abort signal timeouts for sandbox artifact uploads
2026-10-12 15:56:21 +02:00
Tomasz Mazur 8453efcfb6 [eas-build-job] Add uploadArtifact to the sandbox daemon protocol (#4562)
* [eas-build-job] Add uploadArtifact to the sandbox daemon protocol

* [eas-build-job] Add changelog entry for sandbox uploadArtifact

* [build-tools] Reject sandbox uploadArtifact calls until uploads are implemented

* [eas-build-job] Add yieldTimeMs, status and downloadUrl to sandbox uploadArtifact
2026-10-12 15:56:21 +02:00
Stanisław Chmiela 0456e8eea6 [build-tools] Download submission archives with fresh URLs (#4539)
* [build-tools] request fresh submission archive URLs

* [build-tools] link submission archive changelog entry

* [build-tools] simplify submission archive downloads

* [build-tools] validate submission download inputs together

* [build-tools] retry submission archive URL requests

* [build-tools] infer submission archive mutation types from schema
2026-10-12 12:20:34 +02:00
Szymon Świerk 0ec9a81552 [eas-cli] Don't hide inputting individual ASC API keys for submissions behind a flag (#4589)
* [eas-cli] Remove EAS_ENABLE_INDIVIDUAL_ASC_API_KEYS and enable individual ASC API keys for submissions

* [eas-cli] changelog entry about enabling individual ASC API keys for submissions

* [eas-cli] Point changelog entry to #4589
2026-10-12 11:27:55 +02:00
Szymon Świerk 3b4fedeee5 [eas-cli] Don't check for ASC API key validity on Apple servers for individual keys (#4588)
* [eas-cli] Don't check for ASC API key validity on Apple servers for individual keys

* [eas-cli] changelog entry about not checking for individual asc api keys validity on apple servers
2026-10-12 11:14:15 +02:00
Ash Wu 570617f467 [eas-cli] show avg and P75 run duration in workflow:insights (#4566)
* [eas-cli] regenerate GraphQL types

* [eas-cli] share the nullable insights metric helpers

Maestro insights kept NullableMetricSummary and its duration-with-trend
formatter private. Workflows insights needs the same pair for run durations,
so they move to insights/metrics.ts as toNullableMetricSummary and
formatNullableMetricWithTrend, which takes the value formatter as an
argument. Maestro output is unchanged.

* [eas-cli] show run durations in workflow:insights

A customer doing weekly CI health reporting asked for the average and P75
end-to-end workflow run duration, excluding canceled runs (ENG-27927). The
server now exposes avgDurationMs and p75DurationMs on the Workflows Insights
overview metrics and on each workflow node.

eas workflow:insights requests both, shows "Avg duration" and "P75 duration"
in the overview with a lower-is-better trend, adds the two columns to the
workflows table, and includes them in --json. Durations render as h/m/s;
a window with no non-canceled run shows n/a.
2026-10-12 16:51:41 +08:00
Ash Wu 8262aa34c6 [build-tools] Write readable lines to the eas/run_agent job log instead of raw JSON events (#4568) 2026-10-12 16:24:28 +08:00
1f72736126 [build-tools] Update Appium command list (#4607)
* [build-tools] Update Appium command list

* [build-tools] Add summaries for simulated hinge angle commands

---------

Co-authored-by: Expo Bot <[email protected]>
Co-authored-by: Stanisław Chmiela <[email protected]>
2026-10-12 08:41:42 +02:00
Ash Wu 3328b60f85 [build-tools] Support Codex in eas/run_agent (#4553)
Codex has no single switch for its built-in tools, so its shell, image,
apps, plugins and subagent features are turned off by name, it runs in a
read-only sandbox, and the MCP server's tools are pre-approved. Resuming
uses exec resume --last, which starts a new session when none was saved,
so the continuation prompt now repeats the task.
2026-10-12 14:39:11 +08:00
Ash Wu 4abb8601d5 [build-tools] Add the eas/run_agent step function (#4552)
www creates an agent run as a generic job with this single step. The step
validates its inputs, reads the prompt and the job token from the job
environment, takes the API and MCP URLs from the worker config, and hands
over to the agent runner.
2026-10-12 14:39:10 +08:00
Ash Wu 9a881d8366 [build-tools] Add the agent runner for Claude Code (#4551)
Installs the pinned Claude Code CLI into a private run directory, points
it at the Expo MCP server with no built-in tools and a minimal environment,
enforces the maximum duration, and resumes the CLI's session when its
credentials expire mid-run. Nothing calls it yet.
2026-10-12 14:39:10 +08:00
Ash Wu 657034a1a6 [build-tools] Add the agent run lease client (#4550)
Leases the provider credentials of an agent run from www with the job
token. Network failures and 5xx responses are retried; every 4xx is final,
as the route's contract says. Nothing calls it yet.
2026-10-12 14:39:10 +08:00
Douglas LowderandClaude Opus 5.5 bb98f5beef [eas-cli][local-build-plugin] Deprecate Node 20, use Node 22 in CI (#4579)
* [eas-cli][local-build-plugin] Deprecate Node 20, use Node 22 in CI

Print a deprecation warning from the eas-cli and local-build-plugin
bin scripts when running on Node.js < 22. Develop and test on Node.js
22: bump mise, drop 20.18.3 from the test matrix, and move to
@tsconfig/node22 and @types/node 22. Published engines are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:45:17 -07:00
Gabe DebesandClaude Opus 5.5 4aca8b1cc4 [build-tools] Look up ffmpeg on PATH instead of running it (#4582)
The first run of the preinstalled ffmpeg-full on
macos-tahoe-26.6-xcode-27.1 takes about 13 s while it loads its
libraries, and the ffmpeg setup waited for that `ffmpeg -version`.
Find ffmpeg on PATH the way device-session tools do, and run it once in
the background so its first real use does not pay the cold start.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:31:50 -07:00
Tomasz Mazur 800a0e0893 [build-tools] Add a sandbox readFile command for text and image files (#4555) 2026-10-09 17:23:40 +02:00
Tomasz Mazur 2911b04ea1 [build-tools] Return sandbox daemon error codes (#4571) 2026-10-09 17:02:27 +02:00
Tomasz Mazur 50b381abe0 [eas-build-job] Add sandbox daemon error codes (#4570) 2026-10-09 17:02:26 +02:00
Gabe Debes c8696f6012 [build-tools] Install ffmpeg before starting Appium sessions (#4577) 2026-10-08 16:12:29 -07:00
Brent Vatne 45d795e076 [eas-cli] Remove obsolete appVersion runtime policy warning (#4375) 2026-10-08 15:02:35 -07:00
Expo CI 8e98a915b9 bump STAGING and PRODUCTION to 24.12.1 in cli-versions.json 2026-10-08 21:54:04 +00:00
Expo CI 73c65d3edb update CHANGELOG.md for v24.12.1 2026-10-08 21:51:34 +00:00
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
208 changed files with 13012 additions and 3104 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
+2 -5
View File
@@ -15,9 +15,9 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node: ['20.18.3', '22', '24']
node: ['22', '24']
include:
- node: '20.18.3'
- node: '22'
coverage: true
name: Test with Node ${{ matrix.node }}
steps:
@@ -26,9 +26,6 @@ jobs:
uses: ./.github/actions/setup-mise
env:
MISE_NODE_VERSION: ${{ matrix.node }}
- name: Install ffmpeg
# build-tools recording-recovery tests cut and decode 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
env:
+79 -2
View File
@@ -8,6 +8,81 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [build-tools] Let the MCP server read text and image files from sandboxes. ([#4555](https://github.com/expo/eas-cli/pull/4555) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [eas-cli][local-build-plugin] Deprecate Node.js 20, and print a warning when running on it. A future release will require Node.js 22 or newer. Development and CI now use Node.js 22. ([#4579](https://github.com/expo/eas-cli/pull/4579) by [@douglowder](https://github.com/douglowder))
- [build-tools] Add `eas/run_agent`, a step that runs Claude Code for an agent run and connects it to the Expo MCP server. ([#4552](https://github.com/expo/eas-cli/pull/4552) by [@hSATAC](https://github.com/hSATAC))
- [build-tools] Support Codex in `eas/run_agent`. ([#4553](https://github.com/expo/eas-cli/pull/4553) by [@hSATAC](https://github.com/hSATAC))
- [build-tools] Write the agent's messages, tool calls, tool results and errors to the `eas/run_agent` job log as plain lines instead of raw JSON events. ([#4568](https://github.com/expo/eas-cli/pull/4568) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Show the average and P75 end-to-end run duration in `eas workflow:insights`. ([#4566](https://github.com/expo/eas-cli/pull/4566) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Ask for the App Store Connect API key type (team or individual) in the submission key prompt and skip the Issuer ID prompt for individual keys. ([#4589](https://github.com/expo/eas-cli/pull/4589) by [@sswrk](https://github.com/sswrk))
- [build-tools] Add `eas/download_submission_archive` to request a fresh archive URL when the step runs. ([#4539](https://github.com/expo/eas-cli/pull/4539) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-build-job] Add an `uploadArtifact` command to the sandbox daemon protocol. ([#4562](https://github.com/expo/eas-cli/pull/4562) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [build-tools] Let connected sandboxes upload files as sandbox artifacts through the Expo MCP server. ([#4563](https://github.com/expo/eas-cli/pull/4563) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### 🐛 Bug fixes
- [build-tools] Look up ffmpeg on PATH instead of running it. ([#4582](https://github.com/expo/eas-cli/pull/4582) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Stop reporting individual App Store Connect API keys as not valid on Apple's servers when adding them in `eas credentials`. ([#4588](https://github.com/expo/eas-cli/pull/4588) by [@sswrk](https://github.com/sswrk))
- [build-tools] Install ffmpeg for Appium simulator sessions. ([#4577](https://github.com/expo/eas-cli/pull/4577) by [@gwdp](https://github.com/gwdp))
- [build-tools] Close the file of an artifact upload before reporting that the upload is done. ([#4609](https://github.com/expo/eas-cli/pull/4609) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### 🧹 Chores
## [24.12.1](https://github.com/expo/eas-cli/releases/tag/v24.12.1) - 2026-10-08
### 🐛 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))
## [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))
@@ -32,14 +107,13 @@ This is the log of notable changes to EAS CLI and related packages.
- [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))
### 🧹 Chores
## [24.8.0](https://github.com/expo/eas-cli/releases/tag/v24.8.0) - 2026-09-24
### 🎉 New features
@@ -69,6 +143,7 @@ This is the log of notable changes to EAS CLI and related packages.
- [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. ([#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
@@ -181,6 +256,8 @@ This is the log of notable changes to EAS CLI and related packages.
- [build-tools] Report a system error when project source refresh fails for a remote build. ([#4359](https://github.com/expo/eas-cli/pull/4359) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Remove the obsolete `appVersion` runtime policy warning that referenced SDK 46 and expo-updates 0.14.4. ([#4375](https://github.com/expo/eas-cli/pull/4375) by [@brentvatne](https://github.com/brentvatne))
## [24.0.0](https://github.com/expo/eas-cli/releases/tag/v24.0.0) - 2026-09-09
### 🛠 Breaking changes
+1 -1
View File
@@ -164,7 +164,7 @@ Each package has independent TypeScript compilation:
TypeScript configs:
- `tsconfig.json` - Base configuration (extends @tsconfig/node20)
- `tsconfig.json` - Base configuration (extends @tsconfig/node22)
- `tsconfig.build.json` - Production builds
- `tsconfig.allowUnused.json` - Development with relaxed rules
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "24.8.0",
"PRODUCTION": "24.8.0"
"STAGING": "24.12.1",
"PRODUCTION": "24.12.1"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "24.9.0",
"version": "24.13.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+1 -1
View File
@@ -6,4 +6,4 @@ MISE_NODE_COREPACK = "true"
actionlint = "1.7.12"
gcloud = "570.0.0"
jq = "1.8.1"
node = "20.19.4"
node = "22.23.3"
+1 -1
View File
@@ -22,7 +22,7 @@
},
"devDependencies": {
"@types/jest": "29.5.11",
"@types/node": "20.19.43",
"@types/node": "22.20.5",
"eslint-plugin-async-protect": "3.1.0",
"jest": "29.7.0",
"jest-watch-typeahead": "2.2.2",
-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;
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "24.9.0",
"version": "24.13.0",
"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\"",
@@ -41,17 +41,17 @@
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "24.8.0",
"@expo/eas-build-job": "24.9.0",
"@expo/downloader": "24.13.0",
"@expo/eas-build-job": "24.13.0",
"@expo/env": "^0.4.0",
"@expo/logger": "24.8.0",
"@expo/logger": "24.13.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.9.0",
"@expo/template-file": "24.8.0",
"@expo/turtle-spawn": "24.8.0",
"@expo/steps": "24.13.0",
"@expo/template-file": "24.13.0",
"@expo/turtle-spawn": "24.13.0",
"@expo/xcpretty": "^4.3.1",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
@@ -88,7 +88,7 @@
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.4",
"@types/node": "20.19.43",
"@types/node": "22.20.5",
"@types/node-fetch": "^2.6.11",
"@types/node-forge": "^1.3.11",
"@types/plist": "^3.0.5",
@@ -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}';
}
@@ -61,7 +61,7 @@ jest.mock('../../utils/expoUpdates', () => ({
}));
jest.mock('../../utils/expoUpdatesEmbedded', () => ({
...jest.requireActual('../../utils/expoUpdatesEmbedded'),
uploadEmbeddedBundleAsync: jest.fn(),
uploadEmbeddedBundleAsync: jest.fn(async () => ({ status: 'uploaded' })),
}));
jest.mock('../../utils/hooks', () => ({
Hook: {
@@ -344,7 +344,29 @@ describe(androidBuilder, () => {
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
expect.objectContaining({ job: ctx.job, env: ctx.env, projectDir: '/workingdir/build' })
);
});
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' });
await androidBuilder(ctx);
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect.objectContaining({
build_phase: BuildPhase.UPLOAD_EMBEDDED_BUNDLE.toLowerCase(),
result: phaseResult,
})
);
});
it('skips by default on SDK 57 and earlier', async () => {
@@ -371,7 +393,9 @@ describe(androidBuilder, () => {
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(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 () => {
@@ -393,7 +417,9 @@ describe(androidBuilder, () => {
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
expect.objectContaining({ job: ctx.job })
);
});
});
});
+14 -2
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';
@@ -220,7 +221,7 @@ 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,
});
@@ -228,7 +229,18 @@ async function buildInnerAsync(
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();
}
});
}
+13 -2
View File
@@ -237,7 +237,7 @@ 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,
});
@@ -245,7 +245,18 @@ async function buildInnerAsync(
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();
}
});
}
@@ -63,12 +63,12 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
public readonly logger: bunyan;
public readonly graphqlClient: Client;
public readonly runtimeApi: BuilderRuntimeApi;
public readonly expoApiV2BaseUrl?: string;
public readonly mcpServerUrl?: string;
public job: TJob;
public metadata?: Metadata;
private _env: Env;
private readonly expoApiV2BaseUrl?: string;
private readonly pendingMetricUploads: Promise<void>[] = [];
constructor(buildCtx: BuildContext<TJob>) {
+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';
@@ -10,6 +10,7 @@ import { configureIosVersionFunction } from './functions/configureIosVersion';
import { createSubmissionEntityFunction } from './functions/createSubmissionEntity';
import { createDownloadArtifactFunction } from './functions/downloadArtifact';
import { createDownloadBuildFunction } from './functions/downloadBuild';
import { createDownloadSubmissionArchiveFunction } from './functions/downloadSubmissionArchive';
import { createEasDeployBuildFunction } from './functions/deploy';
import { createEasExportBuildFunction } from './functions/export';
import { eagerBundleBuildFunction } from './functions/eagerBundle';
@@ -27,6 +28,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';
@@ -43,9 +45,9 @@ import { runGradleFunction } from './functions/runGradle';
import { createMaestroTestsBuildFunction } from './functions/maestroTests';
import { createSaveBuildCacheFunction } from './functions/saveBuildCache';
import { createRolloutPosthogFlagFunction } from './functions/rolloutPosthogFlag';
import { createRunAgentBuildFunction } from './functions/runAgent';
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';
@@ -62,6 +64,7 @@ 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';
@@ -82,7 +85,9 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createInstallNodeModulesBuildFunction(),
createPrebuildBuildFunction(),
createReadIpaInfoBuildFunction(),
createReadAndroidAppInfoBuildFunction(),
createDownloadBuildFunction(ctx),
createDownloadSubmissionArchiveFunction(ctx),
createInstallBuildFunction(),
createLaunchApplicationFunction(),
createEasExportBuildFunction(),
@@ -104,7 +109,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
generateGymfileFromTemplateFunction(),
runFastlaneFunction(),
parseXcactivitylogFunction(),
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
createStartAgentDeviceSessionBuildFunction(ctx),
createStartArgentRemoteSessionBuildFunction(ctx),
createStartAppiumRemoteSessionBuildFunction(ctx),
@@ -120,6 +124,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createStartServeSimLogsBuildFunction(),
createCollectServeSimLogsBuildFunction(ctx),
createStartSandboxBuildFunction(ctx),
createRunAgentBuildFunction(ctx),
createCollectServeSimMetricsBuildFunction(ctx),
createInstallMaestroBuildFunction(),
createInstallMitmproxyBuildFunction(),
@@ -147,6 +152,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),
});
});
@@ -16,7 +16,7 @@ import {
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
jest.mock('@expo/turtle-spawn');
@@ -27,6 +27,14 @@ 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(),
@@ -48,6 +56,15 @@ 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)
@@ -89,7 +106,7 @@ afterEach(() => {
describe.each([
['Appium', createStartAppiumRemoteSessionBuildFunction],
['Agent Device', createStartAgentDeviceRemoteSessionBuildFunction],
['Agent Device', createStartAgentDeviceSessionBuildFunction],
] as const)('%s host ownership', (name, createFunction) => {
async function runAsync() {
const fn = createFunction({} as CustomBuildContext);
@@ -99,10 +116,10 @@ describe.each([
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown 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: {
DEVICE_RUN_SESSION_ID: 'session-id',
@@ -243,3 +260,76 @@ describe.each([
);
});
});
describe('Appium ffmpeg setup', () => {
async function runAppiumAsync(): Promise<void> {
const fn = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext);
await fn.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
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
);
}
function holdFfmpegInstall({ appiumError }: { appiumError?: Error } = {}) {
let finish!: (outcome: 'installed' | 'failed') => void;
const install = new Promise((resolve, reject) => {
finish = outcome =>
outcome === 'installed' ? resolve({}) : reject(new Error('apt-get failed'));
});
jest.mocked(spawn).mockImplementation(((command: string, args: string[]) => {
if (command === 'ffmpeg') {
return Promise.reject(new Error('ffmpeg missing'));
}
if (args.includes('install') && args.includes('ffmpeg')) {
return install;
}
return command === 'npm' && appiumError
? Promise.reject(appiumError)
: Promise.resolve({ stdout: '{}' });
}) as never);
return finish;
}
it.each(['installed', 'failed'] as const)(
'starts Appium after the ffmpeg install settles (%s)',
async outcome => {
const finishFfmpegInstall = holdFfmpegInstall();
const run = runAppiumAsync();
await new Promise(resolve => setImmediate(resolve));
expect(spawnDetached).not.toHaveBeenCalled();
finishFfmpegInstall(outcome);
await run;
expect(spawnDetached).toHaveBeenCalledTimes(1);
}
);
it('waits for the ffmpeg install before failing on an Appium install error', async () => {
const appiumError = new Error('npm failed');
const finishFfmpegInstall = holdFfmpegInstall({ appiumError });
let settled = false;
const run = runAppiumAsync().finally(() => {
settled = true;
});
await new Promise(resolve => setImmediate(resolve));
expect(settled).toBe(false);
finishFfmpegInstall('installed');
await expect(run).rejects.toBe(appiumError);
});
});
@@ -0,0 +1,192 @@
import { Client, CombinedError } from '@urql/core';
import fetch, { Response } from 'node-fetch';
import fs from 'node:fs';
import { Readable } from 'node:stream';
import { CustomBuildContext } from '../../../customBuildContext';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { createDownloadSubmissionArchiveFunction } from '../downloadSubmissionArchive';
const submissionId = 'aabbccdd-1234-4321-abcd-123456789012';
const url = 'https://storage.example/app.ipa?signature=secret';
it('reports invalid submission ID and extensions together', async () => {
const mutation = jest.fn();
const step = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { submission_id: 'invalid', extensions: [123] },
});
await expect(step.executeAsync()).rejects.toMatchObject({
issues: expect.arrayContaining([
expect.objectContaining({ path: ['submissionId'] }),
expect.objectContaining({ path: ['extensions', 0] }),
]),
});
expect(mutation).not.toHaveBeenCalled();
});
it('requests a fresh URL on each execution, downloads without auth and does not log the URL', async () => {
const mutation = jest.fn(() => ({
toPromise: async () => ({
data: {
submission: { generateSubmissionArchiveDownloadUrl: url },
},
}),
}));
const logger = createMockLogger();
const fn = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext);
for (let i = 0; i < 2; i++) {
jest.mocked(fetch).mockResolvedValue({
ok: true,
url,
body: Readable.from(Buffer.from('IPA bytes')),
headers: { get: () => null },
} as unknown as Response);
const step = fn.createBuildStepFromFunctionCall(createGlobalContextMock({ logger }), {
callInputs: { submission_id: submissionId },
});
await step.executeAsync();
expect(await fs.promises.readFile(step.getOutputValueByName('artifact_path')!, 'utf8')).toBe(
'IPA bytes'
);
}
expect(mutation).toHaveBeenCalledTimes(2);
expect(fetch).toHaveBeenCalledWith(url, { headers: undefined, signal: undefined });
expect(JSON.stringify(jest.mocked(logger.info).mock.calls)).not.toContain(url);
});
it('preserves the API error as the cause without downloading', async () => {
const error = new CombinedError({ networkError: new Error('Connection reset') });
const mutation = jest.fn(() => ({
toPromise: async () => ({
error,
}),
}));
const step = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { submission_id: submissionId },
});
await expect(step.executeAsync()).rejects.toMatchObject({
message: 'Could not request the submission archive. Try again later.',
cause: error,
});
expect(fetch).not.toHaveBeenCalled();
expect(mutation).toHaveBeenCalledTimes(4);
});
it('retries a temporary URL request failure and then downloads the archive', async () => {
const mutation = jest
.fn()
.mockReturnValueOnce({
toPromise: async () => ({
error: new CombinedError({ networkError: new Error('Connection reset') }),
}),
})
.mockReturnValue({
toPromise: async () => ({
data: { submission: { generateSubmissionArchiveDownloadUrl: url } },
}),
});
jest.mocked(fetch).mockResolvedValue({
ok: true,
url,
body: Readable.from(Buffer.from('IPA bytes')),
headers: { get: () => null },
} as unknown as Response);
const step = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { submission_id: submissionId },
});
await step.executeAsync();
expect(mutation).toHaveBeenCalledTimes(2);
expect(await fs.promises.readFile(step.getOutputValueByName('artifact_path')!, 'utf8')).toBe(
'IPA bytes'
);
});
it('does not retry an access error', async () => {
const error = new CombinedError({ graphQLErrors: [{ message: 'Not authorized' }] });
const mutation = jest.fn(() => ({ toPromise: async () => ({ error }) }));
const step = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { submission_id: submissionId },
});
await expect(step.executeAsync()).rejects.toMatchObject({ cause: error });
expect(mutation).toHaveBeenCalledTimes(1);
expect(fetch).not.toHaveBeenCalled();
});
it('does not send another URL request after the step times out during retry backoff', async () => {
const mutation = jest.fn(() => ({
toPromise: async () => ({
error: new CombinedError({ networkError: new Error('Connection reset') }),
}),
}));
const step = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { submission_id: submissionId },
timeoutMs: 100,
});
await expect(step.executeAsync()).rejects.toThrow('timed out');
await new Promise(resolve => setTimeout(resolve, 1100));
expect(mutation).toHaveBeenCalledTimes(1);
expect(fetch).not.toHaveBeenCalled();
});
it('uses the requested extension for an archive without a filename extension', async () => {
const mutation = jest.fn(() => ({
toPromise: async () => ({
data: {
submission: { generateSubmissionArchiveDownloadUrl: 'https://storage.example/archive' },
},
}),
}));
jest.mocked(fetch).mockResolvedValue({
ok: true,
url: 'https://storage.example/archive',
body: Readable.from(Buffer.from('Android archive bytes')),
headers: { get: () => null },
} as unknown as Response);
const step = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { submission_id: submissionId, extensions: ['aab'] },
});
await step.executeAsync();
const artifactPath = step.getOutputValueByName('artifact_path')!;
expect(artifactPath).toMatch(/\.aab$/);
expect(await fs.promises.readFile(artifactPath, 'utf8')).toBe('Android archive bytes');
});
it('propagates the original download error', async () => {
const error = new Error('Download failed');
const mutation = jest.fn(() => ({
toPromise: async () => ({
data: { submission: { generateSubmissionArchiveDownloadUrl: url } },
}),
}));
jest.mocked(fetch).mockRejectedValue(error);
const step = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { submission_id: submissionId },
});
await expect(step.executeAsync()).rejects.toBe(error);
});
@@ -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',
@@ -1,5 +1,6 @@
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';
@@ -11,6 +12,58 @@ jest.mock('@expo/turtle-spawn', () => ({
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();
@@ -24,28 +77,51 @@ describe('createInstallMitmproxyBuildFunction', () => {
});
}
it('does not install when mitmdump is already on PATH', async () => {
mockedSpawn.mockResolvedValueOnce({} as never);
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('mitmdump', ['--version'], expect.anything());
expect(mockedSpawn).toHaveBeenCalledTimes(1);
expect(mockedSpawn).toHaveBeenCalledWith(
'xattr',
['-dr', 'com.apple.quarantine', APP],
expect.anything()
);
expect(spawnedCommands()).toEqual(['which', 'xattr -dr', 'xattr -p', 'mitmdump']);
});
it('warns instead of installing outside EAS Build VMs', async () => {
mockedSpawn.mockRejectedValueOnce(new Error('not found'));
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(mockedSpawn).toHaveBeenCalledTimes(1);
expect(spawnedCommands()).toEqual(['mitmdump']);
});
it('installs the cask with Homebrew and leaves auto-update off', async () => {
mockedSpawn
.mockRejectedValueOnce(new Error('not found'))
.mockResolvedValueOnce({} as never)
.mockResolvedValueOnce({} as never);
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();
@@ -54,13 +130,19 @@ describe('createInstallMitmproxyBuildFunction', () => {
['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 () => {
mockedSpawn
.mockRejectedValueOnce(new Error('not found'))
.mockResolvedValueOnce({} as never)
.mockRejectedValueOnce(new Error('not found'));
mockCommands({ runs: [false, false] });
await expect(
createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync()
@@ -18,7 +18,7 @@ import {
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';
@@ -29,6 +29,12 @@ 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() }),
@@ -78,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'],
@@ -93,10 +99,10 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
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,
@@ -0,0 +1,187 @@
import { SystemError } from '@expo/eas-build-job';
import turtleSpawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import { spawn } from 'node:child_process';
import { EventEmitter, once } from 'node:events';
import path from 'node:path';
import { PassThrough } from 'node:stream';
import { createMockLogger } from '../../../__tests__/utils/logger';
import * as agentRun from '../../utils/agentRun';
import { leaseAgentRunProviderCredentialsAsync } from '../../utils/agentRunLease';
import { createRunAgentBuildFunction } from '../runAgent';
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('node:child_process', () => ({
...jest.requireActual('node:child_process'),
spawn: jest.fn(),
}));
jest.mock('../../utils/agentRunLease');
const installMock = jest.mocked(turtleSpawn);
const spawnMock = jest.mocked(spawn);
const leaseMock = jest.mocked(leaseAgentRunProviderCredentialsAsync);
const ctx = {
expoApiV2BaseUrl: 'https://staging-api.expo.test/v2/',
mcpServerUrl: 'wss://staging-mcp.expo.test',
} as any;
const inputs = {
agent_kind: { value: 'claude-code' },
agent_run_id: { value: '0199b0a0-1111-7222-8333-444455556666' },
max_duration_seconds: { value: 1800 },
prompt: { value: 'Fix the failing test.' },
};
const env = {
PATH: '/usr/local/bin:/usr/bin',
HOME: '/home/expo',
EXPO_TOKEN: 'expo-token',
__API_SERVER_URL: 'https://staging-api.expo.test',
EAS_BUILD_ID: 'build-id',
NODE_OPTIONS: '--max-old-space-size=3072',
USER_DEFINED_SECRET: 'secret',
};
async function runAsync(
overrides: {
ctx?: any;
inputs?: Record<string, { value: unknown }>;
env?: Record<string, string>;
} = {}
): Promise<void> {
const fn = createRunAgentBuildFunction(overrides.ctx ?? ctx);
await fn.fn!({ logger: createMockLogger() } as any, {
inputs: overrides.inputs ?? inputs,
outputs: {},
env: overrides.env ?? env,
});
}
function mockSuccessfulRun(): { mcpConfig?: any } {
const captured: { mcpConfig?: any } = {};
leaseMock.mockResolvedValue({ provider: 'anthropic', accessToken: 'anthropic-access-token' });
installMock.mockResolvedValue({} as any);
spawnMock.mockImplementation(((_command: string, _args: string[], spawnOptions: any) => {
const child = Object.assign(new EventEmitter(), {
stdin: { on: jest.fn(), end: jest.fn() },
stdout: new PassThrough(),
stderr: new PassThrough(),
});
captured.mcpConfig = JSON.parse(
vol.readFileSync(path.join(spawnOptions.env.HOME, 'mcp.json'), 'utf8') as string
);
process.nextTick(() => {
child.emit('exit', 0, null);
child.stdout.end();
child.stderr.end();
void Promise.all([once(child.stdout, 'end'), once(child.stderr, 'end')]).then(() =>
child.emit('close')
);
});
return child;
}) as any);
return captured;
}
describe(createRunAgentBuildFunction, () => {
it('is the eas/run_agent function', () => {
expect(createRunAgentBuildFunction(ctx).getFullId()).toBe('eas/run_agent');
});
it('passes none of the job environment to npm or the agent', async () => {
mockSuccessfulRun();
await runAsync();
expect(leaseMock).toHaveBeenCalledWith(
expect.objectContaining({
expoApiV2BaseUrl: 'https://staging-api.expo.test/v2/',
expoToken: 'expo-token',
agentRunId: '0199b0a0-1111-7222-8333-444455556666',
})
);
const npmEnv = installMock.mock.calls[0][2]!.env!;
const agentEnv = spawnMock.mock.calls[0][2].env!;
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'LANG', 'PATH', 'TMPDIR']);
expect(Object.keys(agentEnv).sort()).toEqual([
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
'CLAUDE_CODE_OAUTH_TOKEN',
'HOME',
'LANG',
'PATH',
'TMPDIR',
]);
expect(agentEnv.PATH).toBe('/usr/local/bin:/usr/bin');
expect(agentEnv.HOME).not.toBe('/home/expo');
});
it.each([
['wss://staging-mcp.expo.test', 'https://staging-mcp.expo.test/mcp'],
['ws://localhost:8787', 'http://localhost:8787/mcp'],
])('points the agent at the HTTP endpoint of %s', async (mcpServerUrl, mcpUrl) => {
const captured = mockSuccessfulRun();
await runAsync({ ctx: { ...ctx, mcpServerUrl } });
expect(captured.mcpConfig.mcpServers.expo.url).toBe(mcpUrl);
});
it('stops the run at max_duration_seconds and reports a timeout', async () => {
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
const runAgentSpy = jest.spyOn(agentRun, 'runAgentAsync').mockImplementation(
({ signal }) =>
new Promise((_resolve, reject) => {
signal!.addEventListener('abort', () => reject(signal!.reason));
})
);
try {
const run = runAsync({ inputs: { ...inputs, max_duration_seconds: { value: 60 } } });
const rejection = expect(run).rejects.toMatchObject({
errorCode: 'EAS_RUN_AGENT_TIMEOUT',
message: expect.stringContaining('60 seconds'),
});
await jest.advanceTimersByTimeAsync(59_000);
expect(runAgentSpy.mock.calls[0][0].signal!.aborted).toBe(false);
await jest.advanceTimersByTimeAsync(1_000);
await rejection;
} finally {
runAgentSpy.mockRestore();
jest.useRealTimers();
}
});
it.each([
['an unknown agent_kind', { inputs: { ...inputs, agent_kind: { value: 'gemini' } } }],
['a malformed agent_run_id', { inputs: { ...inputs, agent_run_id: { value: 'not-a-uuid' } } }],
['an empty agent_run_id', { inputs: { ...inputs, agent_run_id: { value: '' } } }],
['a zero duration', { inputs: { ...inputs, max_duration_seconds: { value: 0 } } }],
['a negative duration', { inputs: { ...inputs, max_duration_seconds: { value: -300 } } }],
['a fractional duration', { inputs: { ...inputs, max_duration_seconds: { value: 12.5 } } }],
['an empty prompt', { inputs: { ...inputs, prompt: { value: '' } } }],
['no EXPO_TOKEN', { env: { ...env, EXPO_TOKEN: '' } }],
['no PATH', { env: { ...env, PATH: '' } }],
['no Expo API URL', { ctx: { ...ctx, expoApiV2BaseUrl: undefined } }],
['no MCP server URL', { ctx: { ...ctx, mcpServerUrl: undefined } }],
])('throws a system error for %s', async (_name, overrides) => {
await expect(runAsync(overrides)).rejects.toBeInstanceOf(SystemError);
expect(leaseMock).not.toHaveBeenCalled();
expect(installMock).not.toHaveBeenCalled();
expect(spawnMock).not.toHaveBeenCalled();
});
it('reports every invalid input at once', async () => {
await expect(
runAsync({
inputs: {
...inputs,
agent_run_id: { value: 'not-a-uuid' },
max_duration_seconds: { value: 0 },
},
})
).rejects.toThrow(
'The "agent_run_id" input must be the UUID of an agent run, but received "not-a-uuid". ' +
'The "max_duration_seconds" input must be a positive whole number of seconds, but received "0".'
);
});
});
@@ -1,10 +1,9 @@
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';
@@ -21,10 +20,7 @@ import {
waitForFileAsync,
} from '../../utils/remoteDeviceRunSession';
import { type StartupTasks, createStartupTasks } from '../../utils/startupTasks';
import {
createStartAgentDeviceRemoteSessionBuildFunction,
runAgentDeviceRemoteSessionAsync,
} from '../startAgentDeviceRemoteSession';
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.
@@ -70,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();
@@ -141,7 +143,6 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
await runAsync(logger, BuildRuntimePlatform.LINUX);
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
@@ -162,36 +163,12 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
});
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
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' },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
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.'
);
});
it('hands network capture to serve-sim on an iOS session', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
network_capture: { value: true },
network_capture_fields: { value: ['header'] },
networkCapture: true,
networkCaptureFields: ['header'],
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
@@ -200,26 +177,6 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
);
});
it('fails before starting the daemon when network capture is asked for on Android', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
await expect(
runAsync(logger, BuildRuntimePlatform.LINUX, { network_capture: { value: true } })
).rejects.toThrow('records traffic through serve-sim on an iOS simulator');
expect(spawnDetached).not.toHaveBeenCalled();
});
it('fails before starting the daemon when a launch is asked for on Android', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
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 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()!(
@@ -323,8 +280,7 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
packageVersion: undefined,
maxIdleTimeMinutes: undefined,
maxDurationSeconds: undefined,
launch: {},
capture: { networkCapture: false, networkCaptureFields: [] },
capture: NO_CAPTURE,
tasks,
device: typeof device === 'function' ? device(tasks) : device,
});
@@ -415,13 +371,4 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
});
it('declares the launch inputs', () => {
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
const globalCtx = createGlobalContextMock();
expect(
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
});
});
@@ -85,8 +85,10 @@ 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',
},
})
@@ -132,6 +134,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
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',
},
@@ -3,6 +3,7 @@ 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,
@@ -10,6 +11,7 @@ import {
} from '../updateTestFlightMetadata';
jest.unmock('node-fetch');
jest.mock('../../../sentry', () => ({ Sentry: { capture: jest.fn() } }));
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
const key = { keyId: 'TESTKEY', privateKey };
@@ -56,8 +58,21 @@ function mockAssignedGroups(ids: string[] = []): void {
.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 });
});
@@ -378,6 +393,7 @@ it('attempts all localization changes and reports every failed update', async ()
it.each([1, 2])('explains automatic internal group rejection (%s errors)', async errorCount => {
mockBuild();
mockInternalBuildState();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
@@ -438,6 +454,7 @@ it('tries group assignment if the changelog update fails', async () => {
it('reports both write failures', async () => {
mockBuild();
mockInternalBuildState();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
@@ -459,6 +476,7 @@ it.each([false, true])(
'attempts all groups and reports assignment errors (multiple: %s)',
async multiple => {
mockBuild();
mockInternalBuildState();
mockAssignedGroups();
api()
.get('/v1/betaGroups')
@@ -484,7 +502,7 @@ it.each([false, true])(
.reply(204);
await expect(
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A', 'B', 'C'] })
).rejects.toThrow(multiple ? /FIRST_GROUP_FAILED.*Partial assignment/ : 'Partial assignment');
).rejects.toThrow(multiple ? 'FIRST_GROUP_FAILED' : 'Partial assignment');
expect(options.logger.error).toHaveBeenCalledWith(
expect.stringContaining('Group "B" (B): assignment failed.')
);
@@ -531,3 +549,73 @@ it('propagates Apple write failures', async () => {
.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'),
},
}
);
}
);
@@ -20,7 +20,8 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockReset()
.mockImplementation(async (_ctx, { stream }) => {
.mockImplementation(async (_ctx, { openStream }) => {
const stream = openStream();
await new Promise<void>((resolve, reject) => {
stream.once('end', resolve);
stream.once('error', reject);
@@ -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,114 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import { graphql } from 'gql.tada';
import { z } from 'zod';
import { downloadBuildAsync } from './downloadBuild';
import { CustomBuildContext } from '../../customBuildContext';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import { promiseRetryWithCondition } from '../../utils/promiseRetryWithCondition';
const CREATE_ARCHIVE_DOWNLOAD_URL = graphql(`
mutation GenerateSubmissionArchiveDownloadUrl($submissionId: ID!) {
submission {
generateSubmissionArchiveDownloadUrl(submissionId: $submissionId)
}
}
`);
export function createDownloadSubmissionArchiveFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'download_submission_archive',
name: 'Download submission archive',
__metricsId: 'eas/download_submission_archive',
inputProviders: [
BuildStepInput.createProvider({
id: 'submission_id',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'extensions',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
defaultValue: ['apk', 'aab', 'ipa'],
}),
],
outputProviders: [
BuildStepOutput.createProvider({
id: 'artifact_path',
required: true,
}),
],
fn: async (stepsCtx, { inputs, outputs, signal }) => {
const { submissionId, extensions } = z
.object({
submissionId: z.string().uuid(),
extensions: z.array(z.string()),
})
.parse({
submissionId: inputs.submission_id.value,
extensions: inputs.extensions.value,
});
stepsCtx.logger.info(`Downloading archive for submission ${submissionId}...`);
const applicationArchiveUrl = await promiseRetryWithCondition(
async () => {
signal?.throwIfAborted();
const result = await ctx.graphqlClient
.mutation(CREATE_ARCHIVE_DOWNLOAD_URL, { submissionId }, graphqlAbortContext(signal))
.toPromise();
signal?.throwIfAborted();
if (result.error) {
throw result.error.networkError || result.error.response?.status >= 500
? new SystemError('Could not request the submission archive. Try again later.', {
cause: result.error,
})
: new UserError(
'EAS_SUBMISSION_ARCHIVE_FETCH_FAILED',
'Could not request the submission archive. Check your project access and the submission archive.',
{ cause: result.error }
);
}
const url = result.data?.submission.generateSubmissionArchiveDownloadUrl;
if (!url) {
throw new SystemError(
'The server did not return a submission archive URL. Try again later.'
);
}
return url;
},
error => !signal?.aborted && error instanceof SystemError,
{ retries: 3, minTimeout: 1000, maxTimeout: 1000 },
({ attemptNumber, maxAttemptsCount }) => {
stepsCtx.logger.info(
`Retrying submission archive URL request (${attemptNumber}/${maxAttemptsCount})...`
);
}
)();
const { artifactPath } = await downloadBuildAsync({
logger: stepsCtx.logger,
graphqlClient: ctx.graphqlClient,
applicationArchiveUrl,
robotAccessToken: null,
extensions,
signal,
});
outputs.artifact_path.set(artifactPath);
},
});
}
@@ -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.`);
@@ -2,6 +2,7 @@ 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';
@@ -13,7 +14,10 @@ export function createInstallMitmproxyBuildFunction(): BuildFunction {
__metricsId: 'eas/install_mitmproxy',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
fn: async ({ logger }, { env }) => {
if (await isMitmproxyAvailableAsync(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;
}
@@ -28,9 +32,11 @@ export function createInstallMitmproxyBuildFunction(): BuildFunction {
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 is not on PATH.');
throw new Error('`brew install --cask mitmproxy` succeeded but mitmdump did not run.');
}
logger.info('Installed mitmproxy.');
} catch (err) {
@@ -48,7 +54,18 @@ export function createInstallMitmproxyBuildFunction(): BuildFunction {
}
async function isMitmproxyAvailableAsync(env: BuildStepEnv): Promise<boolean> {
return (await asyncResult(spawn('mitmdump', ['--version'], { env }))).ok;
// 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({
@@ -63,3 +80,25 @@ async function installMitmproxyWithHomebrewAsync({
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;
}
@@ -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);
},
});
}
@@ -0,0 +1,136 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
import { AGENT_KINDS, runAgentAsync } from '../utils/agentRun';
export function createRunAgentBuildFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'run_agent',
name: 'Run agent',
__metricsId: 'eas/run_agent',
supportedRuntimePlatforms: [BuildRuntimePlatform.LINUX],
inputProviders: [
BuildStepInput.createProvider({
id: 'agent_kind',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'agent_run_id',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'max_duration_seconds',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'prompt',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async (stepCtx, { inputs, env, signal }) => {
const inputErrors = {
agentKind: `The "agent_kind" input must be one of ${AGENT_KINDS.join(', ')}, but received "${String(inputs.agent_kind.value)}".`,
agentRunId: `The "agent_run_id" input must be the UUID of an agent run, but received "${String(inputs.agent_run_id.value)}".`,
maxDurationSeconds: `The "max_duration_seconds" input must be a positive whole number of seconds, but received "${String(inputs.max_duration_seconds.value)}".`,
prompt: 'The "prompt" input must be a non-empty string.',
};
const parsedInputs = z
.object({
agent_kind: z.enum(AGENT_KINDS, { error: inputErrors.agentKind }),
agent_run_id: z.uuid({ error: inputErrors.agentRunId }),
max_duration_seconds: z
.number({ error: inputErrors.maxDurationSeconds })
.int({ error: inputErrors.maxDurationSeconds })
.positive({ error: inputErrors.maxDurationSeconds }),
prompt: z.string({ error: inputErrors.prompt }).min(1, { error: inputErrors.prompt }),
})
.safeParse({
agent_kind: inputs.agent_kind.value,
agent_run_id: inputs.agent_run_id.value,
max_duration_seconds: inputs.max_duration_seconds.value,
prompt: inputs.prompt.value,
});
if (!parsedInputs.success) {
throw createValidationError(parsedInputs.error);
}
const {
agent_kind: agentKind,
agent_run_id: agentRunId,
max_duration_seconds: maxDurationSeconds,
prompt,
} = parsedInputs.data;
const parsedEnv = z
.object({
EXPO_TOKEN: z
.string({ error: 'EXPO_TOKEN is required to run an agent.' })
.min(1, { error: 'EXPO_TOKEN is required to run an agent.' }),
PATH: z
.string({ error: 'PATH is required to run an agent.' })
.min(1, { error: 'PATH is required to run an agent.' }),
EAS_BUILD_NPM_CACHE_URL: z.string().optional(),
})
.safeParse(env);
if (!parsedEnv.success) {
throw createValidationError(parsedEnv.error);
}
const {
EXPO_TOKEN: expoToken,
PATH: pathEnv,
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
} = parsedEnv.data;
if (!ctx.expoApiV2BaseUrl) {
throw new SystemError('Expo API URL is required to run an agent.');
}
if (!ctx.mcpServerUrl) {
throw new SystemError('MCP server URL is required to run an agent.');
}
// The worker config holds the MCP server's WebSocket URL; its HTTP endpoint is on the same host.
const mcpUrl = new URL('/mcp', ctx.mcpServerUrl);
mcpUrl.protocol = mcpUrl.protocol.replace(/^ws/, 'http');
const timeoutError = new UserError(
'EAS_RUN_AGENT_TIMEOUT',
`The agent did not finish within its limit of ${maxDurationSeconds} seconds, so it was stopped. Raise the agent's maximum duration or give it a smaller task, then start a new agent run.`
);
const timeoutController = new AbortController();
const timeout = setTimeout(
() => timeoutController.abort(timeoutError),
maxDurationSeconds * 1000
);
try {
await runAgentAsync({
agentKind,
agentRunId,
prompt,
expoToken,
expoApiV2BaseUrl: ctx.expoApiV2BaseUrl,
mcpUrl: mcpUrl.toString(),
pathEnv,
npmCacheUrl,
logger: stepCtx.logger,
signal: signal
? AbortSignal.any([signal, timeoutController.signal])
: timeoutController.signal,
});
} finally {
clearTimeout(timeout);
}
},
});
}
function createValidationError(error: z.ZodError): SystemError {
return new SystemError(error.issues.map(issue => issue.message).join(' '));
}
@@ -1,22 +1,13 @@
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 {
@@ -26,22 +17,15 @@ import {
resolvePackageInstall,
} from '../../utils/packageManager';
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
import {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
} from '../utils/networkCaptureFields';
import { type parseNetworkCaptureInputs } from '../utils/networkCaptureFields';
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
import { type StartupTasks, createStartupTasks } from '../utils/startupTasks';
import { type StartupTasks } from '../utils/startupTasks';
import {
type DetachedProcessHandle,
createServeSimLaunchInputProviders,
describeServeSimLaunch,
finishRemoteSessionAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startNgrokTunnelAsync,
waitForDeviceRunSessionStoppedAsync,
@@ -66,81 +50,6 @@ const AGENT_DEVICE_DAEMON_ENV = {
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(),
...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 ({ logger, global }, { inputs, env, signal }) => {
// Fail fast before any expensive setup if the injected env vars are missing.
const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(env);
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 }
);
const capture = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
{ runtimePlatform }
);
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
// Earlier steps booted the device and installed and launched the app.
await runAgentDeviceRemoteSessionAsync(ctx, {
env,
logger,
signal,
runtimePlatform,
sessionEnv,
packageVersion,
maxIdleTimeMinutes,
maxDurationSeconds,
launch,
capture,
tasks: createStartupTasks(logger),
device: { booted: Promise.resolve(), ready: Promise.resolve() },
});
}),
});
}
export type AgentDeviceRemoteSessionEnv = {
deviceRunSessionId: string;
ngrokTunnelDomain: string;
@@ -186,7 +95,6 @@ export async function runAgentDeviceRemoteSessionAsync(
packageVersion,
maxIdleTimeMinutes,
maxDurationSeconds,
launch,
capture,
tasks,
device,
@@ -199,7 +107,6 @@ export async function runAgentDeviceRemoteSessionAsync(
packageVersion: string | undefined;
maxIdleTimeMinutes: number | undefined;
maxDurationSeconds: number | undefined;
launch: ReturnType<typeof parseServeSimLaunchInputs>;
capture: ReturnType<typeof parseNetworkCaptureInputs>;
tasks: StartupTasks;
device: { booted: Promise<unknown>; ready: Promise<unknown> };
@@ -247,18 +154,11 @@ export async function runAgentDeviceRemoteSessionAsync(
// A boot cannot be cancelled, so stop waiting for it when startup is aborted.
await tasks.untilAborted(device.booted);
tasks.signal.throwIfAborted();
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
taskLogger.info(launchDescription);
}
sessionHost = await startDeviceSessionHostAsync(ctx, {
runtimePlatform,
env,
logger: taskLogger,
timeoutMs: STARTUP_TIMEOUT_MS,
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
networkCapture: capture.networkCapture,
networkCaptureFields: capture.networkCaptureFields,
});
@@ -17,10 +17,7 @@ import {
} from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorName, IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
import { withLocalEgressSession } from '../utils/localEgressSession';
import {
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
} from '../utils/remoteDeviceRunSession';
import { selectXcodeDeveloperDirectoryAsync } from '../utils/remoteDeviceRunSession';
import {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
@@ -47,9 +44,9 @@ 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 replaces eas/start_ios_simulator or eas/start_android_emulator, eas/download_build,
* eas/install_build, eas/launch_application and eas/start_agent_device_remote_session,
* so the parts that do not depend on each other can run at the same time:
* 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 ─────────┘ │
@@ -261,8 +258,6 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
// 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,
// The app is launched with simctl / adb above, not by serve-sim.
launch: parseServeSimLaunchInputs({}, { runtimePlatform }),
capture,
tasks,
device: { booted, ready },
@@ -1,5 +1,6 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
BuildFunction,
BuildRuntimePlatform,
@@ -37,6 +38,7 @@ import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
import {
createServeSimLaunchInputProviders,
describeServeSimLaunch,
ensureFfmpegInstalledOnceAsync,
finishRemoteSessionAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
@@ -108,12 +110,17 @@ export function createStartAppiumRemoteSessionBuildFunction(
`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
const device = await resolveAppiumDeviceAsync({ runtimePlatform, env, logger });
const { appiumHome, appiumBinPath, appiumEnv } = await installAppiumAsync({
versionSpec,
driverName: device.driverName,
env,
logger,
});
// Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the
// simulator stream with it, and UiAutomator2 merges long recordings with it. The
// macOS session image does not ship ffmpeg, so install it concurrently with Appium.
// Both installs settle before the step continues or fails, so no install outlives it.
const [appiumInstall] = await Promise.all([
asyncResult(
installAppiumAsync({ versionSpec, driverName: device.driverName, env, logger })
),
ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger }),
]);
const { appiumHome, appiumBinPath, appiumEnv } = appiumInstall.enforceValue();
const appiumProcess = spawnDetached({
command: appiumBinPath,
@@ -52,6 +52,8 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
signal,
workingDirectory: stepCtx.workingDirectory,
env: commandEnv,
graphqlClient: ctx.graphqlClient,
sandboxId,
});
try {
await daemon.ready;
@@ -102,10 +102,16 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
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,
});
@@ -5,6 +5,7 @@ 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';
@@ -191,6 +192,7 @@ export async function updateTestFlightMetadataAsync({
})
: [];
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 =>
@@ -219,6 +221,17 @@ export async function updateTestFlightMetadataAsync({
);
} 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',
@@ -229,6 +242,7 @@ export async function updateTestFlightMetadataAsync({
{ cause: error }
);
}
throw error;
}
logger.info(`✅ Group ${label}: assignment completed.`);
@@ -269,6 +283,48 @@ export async function updateTestFlightMetadataAsync({
}
}
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) {
@@ -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;
}
},
});
}
@@ -155,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, () => {
@@ -120,8 +153,8 @@ describe(uploadAgentDeviceArtifactAsync, () => {
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { stream }) => {
await readStreamAsync(stream);
.mockImplementationOnce(async (_ctx, { openStream }) => {
await readStreamAsync(openStream());
});
await uploadAgentDeviceArtifactAsync(ctx, {
@@ -146,7 +179,7 @@ describe(uploadAgentDeviceArtifactAsync, () => {
filename: 'report.json',
kind: 'agent-device-test-report',
size: data.length,
stream: expect.anything(),
openStream: expect.any(Function),
});
});
@@ -158,8 +191,8 @@ describe(uploadAgentDeviceArtifactAsync, () => {
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { stream }) => {
await readStreamAsync(stream);
.mockImplementationOnce(async (_ctx, { openStream }) => {
await readStreamAsync(openStream());
});
await uploadAgentDeviceArtifactAsync(ctx, {
@@ -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, { openStream }) => {
await readStreamAsync(openStream());
});
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, { openStream }) => {
await readStreamAsync(openStream());
});
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, () => {
@@ -255,8 +345,8 @@ describe(pollAgentDeviceArtifactsForUploadAsync, () => {
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockRejectedValueOnce(new Error('upload failed'))
.mockImplementationOnce(async (_ctx, { stream }) => {
await readStreamAsync(stream);
.mockImplementationOnce(async (_ctx, { openStream }) => {
await readStreamAsync(openStream());
});
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
@@ -0,0 +1,592 @@
import { SystemError } from '@expo/eas-build-job';
import turtleSpawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import { spawn } from 'node:child_process';
import { EventEmitter, once } from 'node:events';
import os from 'node:os';
import path from 'node:path';
import { PassThrough } from 'node:stream';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { Sentry } from '../../../sentry';
import { killProcessGroup } from '../../../utils/processes';
import { runAgentAsync } from '../agentRun';
import {
type AgentRunProviderCredentials,
leaseAgentRunProviderCredentialsAsync,
} from '../agentRunLease';
import { CLAUDE_CODE_CLI } from '../agents/claude';
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('node:child_process', () => ({
...jest.requireActual('node:child_process'),
spawn: jest.fn(),
}));
jest.mock('../agentRunLease');
jest.mock('../../../sentry');
jest.mock('../../../utils/processes', () => ({
...jest.requireActual('../../../utils/processes'),
killProcessGroup: jest.fn(),
}));
const installMock = jest.mocked(turtleSpawn);
const spawnMock = jest.mocked(spawn);
const leaseMock = jest.mocked(leaseAgentRunProviderCredentialsAsync);
const killProcessGroupMock = jest.mocked(killProcessGroup);
const agentRunId = '0199b0a0-1111-7222-8333-444455556666';
const anthropicLease: AgentRunProviderCredentials = {
provider: 'anthropic',
accessToken: 'anthropic-access-token',
};
const openaiLease: AgentRunProviderCredentials = {
provider: 'openai',
idToken: 'openai-id-token',
accessToken: 'openai-access-token',
accountId: 'openai-account-id',
};
interface AgentInvocation {
command: string;
args: string[];
env: Record<string, string>;
cwd: string;
stdin: jest.Mock;
child: EventEmitter & { stdout: PassThrough; stderr: PassThrough };
/** The run removes its files when it ends, so call this while the agent is running. */
readHomeFile(relativePath: string): { content: string; mode: number };
exit(status: number | null, signal?: NodeJS.Signals, options?: { closeOutput: boolean }): void;
closeOutput(): void;
}
function mockSpawn({
onAgent,
onInstall,
}: {
onAgent: (invocation: AgentInvocation) => void;
onInstall?: () => void;
}): AgentInvocation[] {
installMock.mockImplementation((() => {
try {
onInstall?.();
return Promise.resolve({});
} catch (error) {
return Promise.reject(error);
}
}) as any);
const invocations: AgentInvocation[] = [];
spawnMock.mockImplementation(((command: string, args: string[], spawnOptions: any) => {
const child = Object.assign(new EventEmitter(), {
pid: 4242,
stdin: { on: jest.fn(), end: jest.fn() },
stdout: new PassThrough(),
stderr: new PassThrough(),
});
// Like a real child: `close` comes after everything it printed has been read.
const closeOutput = (): void => {
child.stdout.end();
child.stderr.end();
void Promise.all([once(child.stdout, 'end'), once(child.stderr, 'end')]).then(() =>
child.emit('close')
);
};
const invocation: AgentInvocation = {
command,
args,
env: spawnOptions.env,
cwd: spawnOptions.cwd,
stdin: child.stdin.end,
child,
readHomeFile(relativePath) {
const filePath = path.join(spawnOptions.env.HOME, relativePath);
return {
content: vol.readFileSync(filePath, 'utf8') as string,
mode: vol.statSync(filePath).mode & 0o777,
};
},
exit(status, signal, options = { closeOutput: true }) {
process.nextTick(() => {
child.emit('exit', status, signal ?? null);
if (options.closeOutput) {
closeOutput();
}
});
},
closeOutput,
};
invocations.push(invocation);
onAgent(invocation);
return child;
}) as any);
return invocations;
}
function runDirectories(): string[] {
return (vol.readdirSync(os.tmpdir()) as string[]).filter(name =>
name.startsWith('eas-agent-run-')
);
}
function createOptions(
overrides: Partial<Parameters<typeof runAgentAsync>[0]> = {}
): Parameters<typeof runAgentAsync>[0] {
return {
agentKind: 'claude-code',
agentRunId,
prompt: 'Fix the failing test.',
expoToken: 'expo-token',
expoApiV2BaseUrl: 'https://staging-api.expo.test/v2/',
mcpUrl: 'https://staging-mcp.expo.test/mcp',
pathEnv: '/usr/local/bin:/usr/bin',
npmCacheUrl: 'https://npm-cache.expo.test',
logger: createMockLogger(),
signal: new AbortController().signal,
...overrides,
};
}
describe(runAgentAsync, () => {
afterEach(() => {
jest.useRealTimers();
expect(runDirectories()).toEqual([]);
});
it('runs Claude Code with MCP as its only tool source and an allowlisted environment', async () => {
leaseMock.mockResolvedValue(anthropicLease);
const invocations = mockSpawn({
onAgent: agent => {
const mcpConfig = agent.readHomeFile('mcp.json');
expect(JSON.parse(mcpConfig.content)).toEqual({
mcpServers: {
expo: {
type: 'http',
url: 'https://staging-mcp.expo.test/mcp',
headers: {
Authorization: 'Bearer expo-token',
'X-Expo-Agent-Run-Id': agentRunId,
},
},
},
});
expect(mcpConfig.mode).toBe(0o600);
agent.exit(0);
},
});
await runAgentAsync(createOptions());
expect(installMock.mock.calls[0].slice(0, 2)).toEqual([
'npm',
[
'install',
'--prefix',
expect.stringMatching(/eas-agent-run-.*\/cli$/),
'--no-save',
'--package-lock=false',
'--no-audit',
'--no-fund',
'--registry',
'https://npm-cache.expo.test',
'@anthropic-ai/[email protected]',
],
]);
expect(invocations).toHaveLength(1);
const [agent] = invocations;
const runDirectory = path.dirname(agent.env.HOME);
expect(path.dirname(runDirectory)).toBe(os.tmpdir());
expect(agent.command).toBe(path.join(runDirectory, 'cli', 'node_modules', '.bin', 'claude'));
expect(agent.args).toEqual([
'-p',
'--output-format',
'stream-json',
'--verbose',
'--tools',
'',
'--allowedTools',
'mcp__expo',
'--mcp-config',
path.join(agent.env.HOME, 'mcp.json'),
'--strict-mcp-config',
]);
expect(agent.env).toEqual({
PATH: '/usr/local/bin:/usr/bin',
HOME: path.join(runDirectory, 'home'),
TMPDIR: path.join(runDirectory, 'tmp'),
LANG: 'C.UTF-8',
CLAUDE_CODE_OAUTH_TOKEN: 'anthropic-access-token',
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
});
expect(agent.cwd).toBe(path.join(runDirectory, 'cwd'));
expect(agent.stdin).toHaveBeenCalledWith('Fix the failing test.');
});
it('writes the agent output as log lines with the leased credentials masked', async () => {
leaseMock.mockResolvedValue(anthropicLease);
const logger = createMockLogger();
mockSpawn({
onAgent: agent => {
// One JSON event split across two chunks, in the middle of the token.
agent.child.stdout.write(
'{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"token: anthropic-acc'
);
agent.child.stdout.write('ess-token"}]}}\n');
agent.child.stdout.write(
'{"type":"assistant","message":{"content":[{"type":"text","text":"Done.\\nThe token was anthropic-access-token.\\n"}]}}\n'
);
agent.child.stdout.write(
'{"type":"result","subtype":"error_during_execution","is_error":true,"errors":["boom"]}\n'
);
agent.child.stderr.write('warning: anthropic-access-token\n');
agent.exit(0);
},
});
await runAgentAsync(createOptions({ logger }));
const child = jest.mocked(logger.child);
expect(child.mock.calls).toEqual([[{ source: 'stdout' }], [{ source: 'stderr' }]]);
const [stdoutLogger, stderrLogger] = child.mock.results.map(result => result.value);
expect(stdoutLogger.info.mock.calls).toEqual([
['Tool result: token: [redacted]'],
['Agent: Done.\nThe token was [redacted].'],
]);
expect(stdoutLogger.error.mock.calls).toEqual([['Agent failed: boom']]);
expect(stderrLogger.info.mock.calls).toEqual([['warning: [redacted]']]);
});
it('reports a line the formatter cannot handle instead of ending the run', async () => {
leaseMock.mockResolvedValue(anthropicLease);
const logger = createMockLogger();
const formatOutputLine = jest
.spyOn(CLAUDE_CODE_CLI, 'formatOutputLine')
.mockImplementationOnce(() => {
throw new RangeError('Maximum call stack size exceeded');
});
mockSpawn({
onAgent: agent => {
agent.child.stdout.write(
'{"type":"assistant","message":{"content":[{"type":"tool_use","name":"t","input":{}}]}}\n'
);
agent.child.stdout.write(
'{"type":"assistant","message":{"content":[{"type":"text","text":"Still here."}]}}\n'
);
agent.exit(0);
},
});
try {
await runAgentAsync(createOptions({ logger }));
} finally {
formatOutputLine.mockRestore();
}
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not format a line of the agent output',
expect.any(RangeError)
);
const [stdoutLogger] = jest.mocked(logger.child).mock.results.map(result => result.value);
expect(stdoutLogger.info.mock.calls).toEqual([['Agent: Still here.']]);
});
it('runs Codex with its shell tool off and the credentials in a private home', async () => {
leaseMock.mockResolvedValue(openaiLease);
const invocations = mockSpawn({
onAgent: agent => {
const config = agent.readHomeFile('.codex/config.toml');
expect(config.content).toContain(
'[features]\nshell_tool = false\nview_image = false\nimage_generation = false\napps = false\nplugins = false\n'
);
expect(config.content).toContain('sandbox_mode = "read-only"');
expect(config.content).toContain('[agents]\nenabled = false\n');
expect(config.content).toContain(
[
'[mcp_servers.expo]',
'url = "http://localhost:8787/mcp"',
'default_tools_approval_mode = "approve"',
'',
'[mcp_servers.expo.http_headers]',
'"Authorization" = "Bearer expo-token"',
`"X-Expo-Agent-Run-Id" = "${agentRunId}"`,
].join('\n')
);
expect(config.mode).toBe(0o600);
const auth = agent.readHomeFile('.codex/auth.json');
expect(JSON.parse(auth.content)).toEqual({
auth_mode: 'chatgptAuthTokens',
tokens: {
id_token: 'openai-id-token',
access_token: 'openai-access-token',
refresh_token: '',
account_id: 'openai-account-id',
},
last_refresh: expect.any(String),
});
expect(auth.mode).toBe(0o600);
agent.exit(0);
},
});
await runAgentAsync(
createOptions({
agentKind: 'codex',
mcpUrl: 'http://localhost:8787/mcp',
npmCacheUrl: undefined,
})
);
const npmArgs = installMock.mock.calls[0][1]!;
expect(npmArgs).not.toContain('--registry');
expect(npmArgs.at(-1)).toBe('@openai/[email protected]');
const [agent] = invocations;
const runDirectory = path.dirname(agent.env.HOME);
expect(agent.command).toBe(path.join(runDirectory, 'cli', 'node_modules', '.bin', 'codex'));
expect(agent.args).toEqual(['exec', '--strict-config', '--json', '--skip-git-repo-check']);
expect(agent.cwd).toBe(path.join(runDirectory, 'cwd'));
expect(agent.env).toEqual({
PATH: '/usr/local/bin:/usr/bin',
HOME: path.join(runDirectory, 'home'),
TMPDIR: path.join(runDirectory, 'tmp'),
LANG: 'C.UTF-8',
});
expect(agent.stdin).toHaveBeenCalledWith('Fix the failing test.');
});
it('escalates to SIGKILL when the agent ignores the stop', async () => {
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
leaseMock.mockResolvedValue(anthropicLease);
const controller = new AbortController();
let agentStarted!: () => void;
const started = new Promise<void>(resolve => {
agentStarted = resolve;
});
const invocations = mockSpawn({ onAgent: () => agentStarted() });
const run = runAgentAsync(createOptions({ signal: controller.signal }));
const rejection = expect(run).rejects.toMatchObject({ name: 'AbortError' });
await started;
const [agent] = invocations;
controller.abort();
expect(killProcessGroupMock.mock.calls).toEqual([[agent.child, 'SIGTERM']]);
await jest.advanceTimersByTimeAsync(10_000);
expect(killProcessGroupMock.mock.calls).toEqual([
[agent.child, 'SIGTERM'],
[agent.child, 'SIGKILL'],
]);
agent.exit(null, 'SIGKILL');
await rejection;
expect(leaseMock).toHaveBeenCalledTimes(1);
});
it('ends what the agent left running and waits a bounded time for its output', async () => {
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
leaseMock.mockResolvedValue(anthropicLease);
let agentStarted!: () => void;
const started = new Promise<void>(resolve => {
agentStarted = resolve;
});
const invocations = mockSpawn({ onAgent: () => agentStarted() });
const run = runAgentAsync(createOptions());
await started;
invocations[0].exit(0, undefined, { closeOutput: false });
await jest.advanceTimersByTimeAsync(5_000);
await expect(run).resolves.toBeUndefined();
expect(killProcessGroupMock.mock.calls).toEqual([[invocations[0].child, 'SIGKILL']]);
});
it('stops the agent when the step is aborted and fails with the abort reason', async () => {
leaseMock.mockResolvedValue(anthropicLease);
const controller = new AbortController();
const reason = new Error('the step was canceled');
const invocations = mockSpawn({
onAgent: agent => {
queueMicrotask(() => {
controller.abort(reason);
agent.exit(null, 'SIGTERM');
});
},
});
await expect(runAgentAsync(createOptions({ signal: controller.signal }))).rejects.toBe(reason);
expect(killProcessGroupMock.mock.calls).toEqual([
[invocations[0].child, 'SIGTERM'],
[invocations[0].child, 'SIGKILL'],
]);
expect(leaseMock).toHaveBeenCalledTimes(1);
});
it('resumes Claude Code with new credentials when the lease changed during the run', async () => {
const renewedLease: AgentRunProviderCredentials = {
...anthropicLease,
accessToken: 'renewed-access-token',
};
leaseMock.mockResolvedValueOnce(anthropicLease).mockResolvedValueOnce(renewedLease);
const logger = createMockLogger();
const invocations = mockSpawn({
onAgent: agent => {
if (invocations.length === 2) {
agent.child.stderr.write('tokens: anthropic-access-token renewed-access-token\n');
}
agent.exit(invocations.length === 1 ? 1 : 0);
},
});
await runAgentAsync(createOptions({ logger }));
expect(installMock).toHaveBeenCalledTimes(1);
expect(invocations).toHaveLength(2);
const [first, resumed] = invocations;
expect(resumed.args).toEqual(['-p', '--continue', ...first.args.slice(1)]);
expect(resumed.env.CLAUDE_CODE_OAUTH_TOKEN).toBe('renewed-access-token');
expect(resumed.stdin).toHaveBeenCalledWith(
expect.stringMatching(/^Your previous run of this task stopped.*\n\nFix the failing test\.$/s)
);
const child = jest.mocked(logger.child);
expect(child.mock.calls).toEqual([
[{ source: 'stdout' }],
[{ source: 'stderr' }],
[{ source: 'stdout' }],
[{ source: 'stderr' }],
]);
const [, , , resumedStderrLogger] = child.mock.results.map(result => result.value);
expect(resumedStderrLogger.info.mock.calls).toEqual([['tokens: [redacted] [redacted]']]);
});
it('resumes Codex with new credentials when the lease changed during the run', async () => {
const renewedLease: AgentRunProviderCredentials = {
...openaiLease,
accessToken: 'renewed-access-token',
idToken: 'renewed-id-token',
};
leaseMock.mockResolvedValueOnce(openaiLease).mockResolvedValueOnce(renewedLease);
const invocations = mockSpawn({
onAgent: agent => {
if (invocations.length === 1) {
agent.exit(1);
return;
}
expect(JSON.parse(agent.readHomeFile('.codex/auth.json').content).tokens).toMatchObject({
id_token: 'renewed-id-token',
access_token: 'renewed-access-token',
});
agent.exit(0);
},
});
await runAgentAsync(createOptions({ agentKind: 'codex' }));
expect(invocations).toHaveLength(2);
expect(invocations[1].args).toEqual([
'exec',
'--strict-config',
'--json',
'--skip-git-repo-check',
'resume',
'--last',
'-',
]);
expect(invocations[1].stdin).toHaveBeenCalledWith(
expect.stringContaining('\n\nFix the failing test.')
);
expect(invocations[1].cwd).toBe(invocations[0].cwd);
});
it('does not resume an agent when the step was aborted while its credentials were renewed', async () => {
const controller = new AbortController();
leaseMock.mockResolvedValueOnce(anthropicLease).mockImplementationOnce(async () => {
controller.abort();
return { ...anthropicLease, accessToken: 'renewed-access-token' };
});
const invocations = mockSpawn({ onAgent: agent => agent.exit(1) });
await expect(runAgentAsync(createOptions({ signal: controller.signal }))).rejects.toMatchObject(
{ name: 'AbortError' }
);
expect(invocations).toHaveLength(1);
});
it.each([
[2, undefined, 'exited with code 2'],
[null, 'SIGKILL' as const, 'was terminated by SIGKILL'],
])(
'fails when the agent fails and the lease is unchanged (status %p, signal %p)',
async (status, signal, description) => {
leaseMock.mockResolvedValue(anthropicLease);
const invocations = mockSpawn({ onAgent: agent => agent.exit(status, signal) });
await expect(runAgentAsync(createOptions())).rejects.toMatchObject({
errorCode: 'EAS_RUN_AGENT_FAILED',
message: expect.stringContaining(description),
});
expect(invocations).toHaveLength(1);
expect(leaseMock).toHaveBeenCalledTimes(2);
}
);
it('fails with the lease error when the lease cannot be renewed after the agent failed', async () => {
leaseMock
.mockResolvedValueOnce(anthropicLease)
.mockRejectedValueOnce(new Error('lease refused'));
const invocations = mockSpawn({ onAgent: agent => agent.exit(1) });
await expect(runAgentAsync(createOptions())).rejects.toThrow('lease refused');
expect(invocations).toHaveLength(1);
});
it('reports an agent CLI that cannot start', async () => {
leaseMock.mockResolvedValue(anthropicLease);
mockSpawn({
onAgent: agent => {
process.nextTick(() => agent.child.emit('error', new Error('spawn claude ENOENT')));
},
});
await expect(runAgentAsync(createOptions())).rejects.toThrow(
'Could not start the agent CLI on the worker: spawn claude ENOENT.'
);
expect(leaseMock).toHaveBeenCalledTimes(1);
});
it('cleans up when the installation fails', async () => {
leaseMock.mockResolvedValue(anthropicLease);
const invocations = mockSpawn({
onInstall: () => {
throw new Error('npm ERR! network');
},
onAgent: agent => agent.exit(0),
});
await expect(runAgentAsync(createOptions())).rejects.toBeInstanceOf(SystemError);
expect(invocations).toHaveLength(0);
});
it('does not install or start anything when the lease is refused', async () => {
leaseMock.mockRejectedValue(new Error('lease refused'));
mockSpawn({ onAgent: agent => agent.exit(0) });
await expect(runAgentAsync(createOptions())).rejects.toThrow('lease refused');
expect(installMock).not.toHaveBeenCalled();
expect(spawnMock).not.toHaveBeenCalled();
});
it('refuses credentials of the wrong provider', async () => {
leaseMock.mockResolvedValue(openaiLease);
const invocations = mockSpawn({ onAgent: agent => agent.exit(0) });
await expect(runAgentAsync(createOptions({ agentKind: 'claude-code' }))).rejects.toBeInstanceOf(
SystemError
);
expect(invocations).toHaveLength(0);
});
});
@@ -0,0 +1,148 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import fetch, { type Response } from 'node-fetch';
import { leaseAgentRunProviderCredentialsAsync } from '../agentRunLease';
jest.mock('node-fetch');
const fetchMock = jest.mocked(fetch);
afterEach(() => {
jest.useRealTimers();
});
const agentRunId = '0199b0a0-1111-7222-8333-444455556666';
const options = {
expoApiV2BaseUrl: 'https://staging-api.expo.test/v2/',
expoToken: 'expo-token',
agentRunId,
};
function createResponse(status: number, body: unknown): Response {
return {
ok: status >= 200 && status < 300,
status,
json: async () => {
if (body === undefined) {
throw new Error('not JSON');
}
return body;
},
} as unknown as Response;
}
describe(leaseAgentRunProviderCredentialsAsync, () => {
it('leases Anthropic credentials with the job token', async () => {
const lease = { provider: 'anthropic', accessToken: 'access-token' };
fetchMock.mockResolvedValue(
createResponse(200, { data: { ...lease, expiresAt: '2026-10-06T12:00:00.000Z' } })
);
await expect(leaseAgentRunProviderCredentialsAsync(options)).resolves.toEqual(lease);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe(`https://staging-api.expo.test/v2/agent-runs/${agentRunId}/auth-lease`);
expect(init).toMatchObject({
method: 'POST',
headers: { Authorization: 'Bearer expo-token' },
});
});
it('leases OpenAI credentials', async () => {
const lease = {
provider: 'openai',
idToken: 'id-token',
accessToken: 'access-token',
accountId: 'account-id',
};
fetchMock.mockResolvedValue(createResponse(200, { data: lease }));
await expect(leaseAgentRunProviderCredentialsAsync(options)).resolves.toEqual(lease);
});
it('reports a 4xx with the message from the server and does not retry', async () => {
fetchMock.mockResolvedValue(
createResponse(400, {
errors: [
{
code: 'AGENT_RUN_PROVIDER_CONNECTION_DELETED',
message: 'The provider connection for this agent run was deleted.',
},
],
})
);
const lease = leaseAgentRunProviderCredentialsAsync(options);
await expect(lease).rejects.toBeInstanceOf(UserError);
await expect(lease).rejects.toMatchObject({
errorCode: 'EAS_RUN_AGENT_CREDENTIALS_UNAVAILABLE',
message: 'The provider connection for this agent run was deleted.',
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('reports a 4xx without a readable body', async () => {
fetchMock.mockResolvedValue(createResponse(401, undefined));
await expect(leaseAgentRunProviderCredentialsAsync(options)).rejects.toThrow(
'Expo refused to issue provider credentials for this agent run (HTTP 401).'
);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('retries server errors and network failures', async () => {
jest.useFakeTimers();
const lease = { provider: 'anthropic', accessToken: 'access-token' };
fetchMock
.mockResolvedValueOnce(createResponse(503, undefined))
.mockRejectedValueOnce(new Error('socket hang up'))
.mockResolvedValueOnce(createResponse(200, { data: lease }));
const assertion = expect(leaseAgentRunProviderCredentialsAsync(options)).resolves.toEqual(
lease
);
await jest.runAllTimersAsync();
await assertion;
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('retries when the body of a successful response cannot be read', async () => {
jest.useFakeTimers();
const lease = { provider: 'anthropic', accessToken: 'access-token' };
fetchMock
.mockResolvedValueOnce(createResponse(200, undefined))
.mockResolvedValueOnce(createResponse(200, { data: lease }));
const assertion = expect(leaseAgentRunProviderCredentialsAsync(options)).resolves.toEqual(
lease
);
await jest.runAllTimersAsync();
await assertion;
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('gives up after three failed attempts', async () => {
jest.useFakeTimers();
fetchMock.mockResolvedValue(createResponse(500, undefined));
const assertion = expect(leaseAgentRunProviderCredentialsAsync(options)).rejects.toBeInstanceOf(
SystemError
);
await jest.runAllTimersAsync();
await assertion;
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('rejects a successful response that is not a lease', async () => {
fetchMock.mockResolvedValue(
createResponse(200, { data: { provider: 'anthropic', refreshToken: 'nope' } })
);
await expect(leaseAgentRunProviderCredentialsAsync(options)).rejects.toBeInstanceOf(
SystemError
);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
@@ -124,8 +124,8 @@ describe(uploadArgentArtifactAsync, () => {
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { stream }) => {
await readStreamAsync(stream);
.mockImplementationOnce(async (_ctx, { openStream }) => {
await readStreamAsync(openStream());
});
await uploadArgentArtifactAsync(ctx, {
@@ -151,7 +151,7 @@ describe(uploadArgentArtifactAsync, () => {
filename: 'report.json',
kind: undefined,
size: data.length,
stream: expect.anything(),
openStream: expect.any(Function),
});
});
@@ -168,8 +168,8 @@ describe(uploadArgentArtifactAsync, () => {
.mockResolvedValueOnce(new Response(Readable.from([Buffer.from('artifact-data')])));
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { stream }) => {
await readStreamAsync(stream);
.mockImplementationOnce(async (_ctx, { openStream }) => {
await readStreamAsync(openStream());
});
await uploadArgentArtifactAsync(ctx, {
@@ -345,8 +345,8 @@ describe(pollArgentArtifactsForUploadAsync, () => {
});
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementation(async (_ctx, { stream }) => {
await readStreamAsync(stream);
.mockImplementation(async (_ctx, { openStream }) => {
await readStreamAsync(openStream());
});
const pollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
@@ -371,7 +371,7 @@ describe(pollArgentArtifactsForUploadAsync, () => {
name: 'b.json (artifact-b)',
filename: 'b.json',
size: 'artifact-b-data'.length,
stream: expect.anything(),
openStream: expect.any(Function),
});
});
@@ -49,7 +49,7 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
kind: 'agent-device-test-report',
metadata: { firstFrameRecordAt: 'test-time' },
size: reportedSize,
stream,
openStream: () => stream,
});
expect(mutation).toHaveBeenCalledWith(
@@ -103,7 +103,7 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
filename: 'logs.ndjson',
kind: 'simulator-log',
size: 0,
stream: Readable.from([]),
openStream: () => Readable.from([]),
signal: controller.signal,
})
).rejects.toThrow();
@@ -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.',
});
});
@@ -27,18 +27,17 @@ beforeEach(async () => {
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));
});
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementation(async (_ctx, { openStream }) => {
const chunks: Buffer[] = [];
for await (const chunk of openStream()) {
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 = () =>
@@ -1,9 +1,8 @@
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 { createReadStream } from 'node:fs';
import { copyFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
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';
@@ -13,10 +12,7 @@ import { setTimeout as delay } from 'node:timers/promises';
import type { CustomBuildContext } from '../../../customBuildContext';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import {
findUnlistedDeviceScreenRecordingsAsync,
uploadDeviceRunSessionScreenRecordingsAsync,
} from '../deviceRunSessionScreenRecordings';
import { findUnlistedDeviceScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
jest.unmock('node-fetch');
jest.unmock('node:fs');
@@ -47,13 +43,11 @@ let creates: number;
let authorization: string | undefined;
let stallCreation: boolean;
let creationStarted: ReturnType<typeof deferred<IncomingMessage>>;
let creationBodies: string[];
beforeEach(async () => {
creates = 0;
authorization = undefined;
stallCreation = false;
creationBodies = [];
creationStarted = deferred();
directory = await mkdtemp(path.join(tmpdir(), 'recording-upload-http-'));
server = createServer((request, response) => {
@@ -61,10 +55,7 @@ beforeEach(async () => {
creates++;
authorization = request.headers.authorization;
creationStarted.resolve(request);
request.setEncoding('utf8');
let body = '';
request.on('data', chunk => (body += chunk));
request.on('end', () => creationBodies.push(body));
request.resume();
request.on('end', () => {
if (stallCreation) {
return;
@@ -131,8 +122,8 @@ it('replays the full file after a transient PUT failure without recreating the u
await uploadDeviceRunSessionArtifactAsync(ctx, {
...metadata,
size: payload.length,
stream: open(),
reopenStream: open,
openStream: open,
attempts: 3,
});
expect(creates).toBe(1);
expect(authorization).toBe('Bearer test-auth');
@@ -165,7 +156,11 @@ it('keeps a slow PUT alive while its body is still flowing', async () => {
onChunk();
});
const stream = new Readable({ read() {} });
const upload = uploadDeviceRunSessionArtifactAsync(ctx, { ...metadata, size: 3, stream });
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
...metadata,
size: 3,
openStream: () => stream,
});
const settled = expect(upload).resolves.toBeUndefined();
stream.push('a');
await waitForBytes(1);
@@ -205,8 +200,8 @@ it('retries a PUT that stalled past the idle deadline with a fresh stream', asyn
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
...metadata,
size: 9,
stream: open(),
reopenStream: open,
openStream: open,
attempts: 3,
});
const settled = expect(upload).resolves.toBeUndefined();
await firstBodyRead.promise;
@@ -231,11 +226,11 @@ it('cancels an active PUT socket and destroys its source without retrying', asyn
const controller = new AbortController();
const stream = new Readable({ read() {} });
stream.push(Buffer.alloc(1024));
const reopenStream = jest.fn(() => Readable.from('unexpected retry'));
const openStream = jest.fn(() => stream);
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
...metadata,
stream,
reopenStream,
openStream,
attempts: 3,
signal: controller.signal,
});
const rejected = expect(upload).rejects.toThrow('test cancellation');
@@ -244,7 +239,7 @@ it('cancels an active PUT socket and destroys its source without retrying', asyn
await rejected;
await closed.promise;
expect(stream.destroyed).toBe(true);
expect(reopenStream).not.toHaveBeenCalled();
expect(openStream).toHaveBeenCalledTimes(1);
expect(puts).toBe(1);
});
@@ -268,8 +263,8 @@ it.each([200, 400, 503])(
};
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
...metadata,
stream: Readable.from('recording'),
reopenStream: () => Readable.from('recording'),
openStream: () => Readable.from('recording'),
attempts: 3,
});
if (status === 200) {
await expect(upload).resolves.toBeUndefined();
@@ -291,10 +286,10 @@ it('cancels urql session creation with auth intact and never starts a PUT', asyn
const puts = jest.fn();
handle = puts;
const controller = new AbortController();
const stream = Readable.from('recording');
const openStream = jest.fn(() => Readable.from('recording'));
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
...metadata,
stream,
openStream,
signal: controller.signal,
});
const rejected = expect(upload).rejects.toThrow('test cancellation');
@@ -306,7 +301,7 @@ it('cancels urql session creation with auth intact and never starts a PUT', asyn
expect(authorization).toBe('Bearer test-auth');
expect(creates).toBe(1);
expect(puts).not.toHaveBeenCalled();
expect(stream.destroyed).toBe(true);
expect(openStream).not.toHaveBeenCalled();
});
it('says ffprobe is missing instead of calling an unlisted recording undecodable', async () => {
@@ -336,128 +331,3 @@ it('says ffprobe is missing instead of calling an unlisted recording undecodable
'ffprobe is not available; cannot recover the unlisted recording session/recording.mp4.partial.'
);
});
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.',
});
});
@@ -12,8 +12,12 @@ import {
findUnlistedDeviceScreenRecordingsAsync,
uploadDeviceRunSessionScreenRecordingsAsync,
} from '../deviceRunSessionScreenRecordings';
import {
ensureMacosPreviewEncoderInstalledAsync,
startDeviceRunSessionPreview,
} from '../deviceRunSessionPreview';
import { startDeviceSessionHostAsync } from '../deviceSessionHost';
import { spawnDetached } from '../remoteDeviceRunSession';
import { ensureFfmpegInstalledOnceAsync, spawnDetached } from '../remoteDeviceRunSession';
jest.mock('@ngrok/ngrok');
jest.mock('../deviceRunSessionArtifacts');
@@ -30,6 +34,11 @@ jest.mock('../remoteDeviceRunSession', () => ({
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(),
@@ -64,8 +73,14 @@ async function startHostAsync() {
});
}
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);
@@ -464,9 +479,9 @@ it.each([BuildRuntimePlatform.LINUX, BuildRuntimePlatform.DARWIN])(
const uploads: Buffer[] = [];
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementation(async (_ctx, { stream }) => {
.mockImplementation(async (_ctx, { openStream }) => {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
for await (const chunk of openStream()) {
chunks.push(Buffer.from(chunk));
}
uploads.push(Buffer.concat(chunks));
@@ -502,3 +517,60 @@ it.each([BuildRuntimePlatform.LINUX, BuildRuntimePlatform.DARWIN])(
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();
});
@@ -0,0 +1,3 @@
Robin, sparrow, leiothrix and chat
Zażółć gęślą jaźń
€ 🐦
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -5,6 +5,7 @@ import spawn from '@expo/turtle-spawn';
import * as ngrok from '@ngrok/ngrok';
import { EventEmitter } from 'node:events';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
clearTimeout as clearTimeoutCallback,
@@ -16,6 +17,11 @@ 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';
@@ -592,19 +598,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', () => {
@@ -614,12 +627,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',
]);
}
});
});
@@ -692,6 +711,56 @@ describe(startNgrokTunnelAsync, () => {
});
});
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 = [
@@ -748,6 +817,25 @@ describe(startDeviceSessionHostAsync, () => {
});
});
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);
@@ -755,11 +843,9 @@ describe(startDeviceSessionHostAsync, () => {
url: () => 'https://android-preview.example.test',
close,
} as never);
// `env` has no PATH, so the host installs ffmpeg.
jest
.mocked(spawn)
.mockReturnValueOnce(
Promise.reject(new Error('ffmpeg is missing')) as ReturnType<typeof spawn>
)
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>)
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>);
@@ -773,24 +859,24 @@ describe(startDeviceSessionHostAsync, () => {
const preview = await host.openPreviewAsync({ baseDomain });
const spawnCalls = jest.mocked(spawn).mock.calls;
expect(spawnCalls[0]).toEqual(['ffmpeg', ['-version'], { env }]);
expect(spawnCalls[1]).toEqual([
expect(spawnCalls[0]).toEqual([
'sudo',
['apt-get', 'update'],
expect.objectContaining({
env: expect.objectContaining({ DEBIAN_FRONTEND: 'noninteractive' }),
}),
]);
expect(spawnCalls[2]).toEqual([
expect(spawnCalls[1]).toEqual([
'sudo',
['apt-get', 'install', '-y', 'ffmpeg'],
expect.objectContaining({
env: expect.objectContaining({ DEBIAN_FRONTEND: 'noninteractive' }),
}),
]);
expect(spawnCalls[2]).toEqual(['ffmpeg', ['-version'], { env }]);
const expoDeviceHubCallIndex = spawnCalls.findIndex(([command]) => command === 'npx');
expect(expoDeviceHubCallIndex).toBeGreaterThan(2);
expect(jest.mocked(spawn).mock.invocationCallOrder[2]).toBeLessThan(
expect(jest.mocked(spawn).mock.invocationCallOrder[1]).toBeLessThan(
jest.mocked(spawn).mock.invocationCallOrder[expoDeviceHubCallIndex]
);
const [command, args] = spawnCalls[expoDeviceHubCallIndex];
@@ -940,6 +1026,7 @@ describe(startDeviceSessionHostAsync, () => {
});
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',
@@ -969,6 +1056,7 @@ describe(startDeviceSessionHostAsync, () => {
packageVersion: '4.5.6',
}),
]);
expect(usePackage).toHaveBeenCalledWith('@expo/[email protected]');
await host.finishAsync();
expect(close).toHaveBeenCalledTimes(1);
@@ -1419,6 +1507,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
describe(ensureFfmpegInstalledOnceAsync, () => {
const spawnMock = jest.mocked(spawn);
let binDirectory: string;
function spawnResolved(): ReturnType<typeof spawn> {
return Promise.resolve({}) as unknown as ReturnType<typeof spawn>;
@@ -1428,68 +1517,140 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
return Promise.reject(new Error('boom')) as unknown as ReturnType<typeof spawn>;
}
beforeEach(() => {
function spawnPending(): ReturnType<typeof spawn> {
return new Promise(() => {}) as unknown as ReturnType<typeof spawn>;
}
async function installFakeFfmpegAsync({ mode }: { mode: number }): Promise<string> {
const ffmpegPath = path.join(binDirectory, 'ffmpeg');
await fs.writeFile(ffmpegPath, '#!/bin/sh\n');
await fs.chmod(ffmpegPath, mode);
return ffmpegPath;
}
function createPathEnv(): BuildStepEnv {
return { ...createEnvMock(), PATH: binDirectory };
}
const warmUpCall = ['ffmpeg', ['-version'], expect.anything()];
beforeEach(async () => {
spawnMock.mockReset();
jest.mocked(Sentry).capture.mockReset();
binDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'ffmpeg-path-'));
});
it('does not install when ffmpeg is on PATH', async () => {
spawnMock.mockReturnValueOnce(spawnResolved());
afterEach(async () => {
await fs.rm(binDirectory, { recursive: true, force: true });
});
it('finds ffmpeg on PATH without waiting for it to run, then warms it up', async () => {
const ffmpegPath = await installFakeFfmpegAsync({ mode: 0o755 });
spawnMock.mockReturnValueOnce(spawnPending());
const logger = createLoggerMock();
await ensureFfmpegInstalledOnceAsync({
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: createEnvMock(),
logger: createLoggerMock(),
env: createPathEnv(),
logger,
});
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock).toHaveBeenCalledWith('ffmpeg', ['-version'], expect.anything());
expect(spawnMock).toHaveBeenCalledWith(...warmUpCall);
expect(logger.info).toHaveBeenCalledWith(`ffmpeg is already installed at ${ffmpegPath}.`);
});
it('installs ffmpeg with Homebrew on darwin when it is missing', async () => {
spawnMock.mockReturnValueOnce(spawnRejected()).mockReturnValueOnce(spawnResolved());
it('installs ffmpeg when the file on PATH is not executable', async () => {
await installFakeFfmpegAsync({ mode: 0o644 });
spawnMock.mockReturnValue(spawnResolved());
await ensureFfmpegInstalledOnceAsync({
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: createEnvMock(),
env: createPathEnv(),
logger: createLoggerMock(),
});
expect(spawnMock).toHaveBeenLastCalledWith(
expect(spawnMock).toHaveBeenCalledWith('brew', ['install', 'ffmpeg'], expect.anything());
});
it('installs ffmpeg when the ffmpeg entry on PATH is a directory', async () => {
await fs.mkdir(path.join(binDirectory, 'ffmpeg'));
spawnMock.mockReturnValue(spawnResolved());
await ensureFfmpegInstalledOnceAsync({
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: createPathEnv(),
logger: createLoggerMock(),
});
expect(spawnMock).toHaveBeenCalledWith('brew', ['install', 'ffmpeg'], expect.anything());
});
it('treats an empty PATH entry as the current directory', async () => {
await installFakeFfmpegAsync({ mode: 0o755 });
jest.spyOn(process, 'cwd').mockReturnValue(binDirectory);
spawnMock.mockReturnValue(spawnResolved());
try {
await ensureFfmpegInstalledOnceAsync({
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: { ...createEnvMock(), PATH: `${path.delimiter}/nonexistent` },
logger: createLoggerMock(),
});
} finally {
jest.mocked(process.cwd).mockRestore();
}
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock).toHaveBeenCalledWith(...warmUpCall);
});
it('installs ffmpeg with Homebrew on darwin when it is missing, then warms it up', async () => {
spawnMock.mockReturnValue(spawnResolved());
await ensureFfmpegInstalledOnceAsync({
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: createPathEnv(),
logger: createLoggerMock(),
});
expect(spawnMock).toHaveBeenNthCalledWith(
1,
'brew',
['install', 'ffmpeg'],
expect.objectContaining({
env: expect.objectContaining({ HOMEBREW_NO_AUTO_UPDATE: '1' }),
})
);
expect(spawnMock).toHaveBeenLastCalledWith(...warmUpCall);
});
it('installs ffmpeg with apt on linux when it is missing', async () => {
spawnMock
.mockReturnValueOnce(spawnRejected()) // ffmpeg -version
.mockReturnValueOnce(spawnResolved()) // apt-get update
.mockReturnValueOnce(spawnResolved()); // apt-get install
spawnMock.mockReturnValue(spawnResolved());
await ensureFfmpegInstalledOnceAsync({
runtimePlatform: BuildRuntimePlatform.LINUX,
env: createEnvMock(),
env: createPathEnv(),
logger: createLoggerMock(),
});
expect(spawnMock).toHaveBeenCalledWith(
expect(spawnMock).toHaveBeenNthCalledWith(
1,
'sudo',
['apt-get', 'update'],
expect.objectContaining({
env: expect.objectContaining({ DEBIAN_FRONTEND: 'noninteractive' }),
})
);
expect(spawnMock).toHaveBeenLastCalledWith(
expect(spawnMock).toHaveBeenNthCalledWith(
2,
'sudo',
['apt-get', 'install', '-y', 'ffmpeg'],
expect.objectContaining({
env: expect.objectContaining({ DEBIAN_FRONTEND: 'noninteractive' }),
})
);
expect(spawnMock).toHaveBeenLastCalledWith(...warmUpCall);
});
it('shares an in-flight ffmpeg setup between callers', async () => {
@@ -1498,21 +1659,21 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
finishInstall = resolve;
});
spawnMock
.mockReturnValueOnce(spawnRejected()) // ffmpeg -version
.mockReturnValueOnce(spawnResolved()) // apt-get update
.mockReturnValueOnce(pendingInstall as unknown as ReturnType<typeof spawn>); // apt-get install
.mockReturnValueOnce(pendingInstall as unknown as ReturnType<typeof spawn>) // apt-get install
.mockReturnValueOnce(spawnResolved()); // ffmpeg -version warm-up
const options = {
runtimePlatform: BuildRuntimePlatform.LINUX,
env: createEnvMock(),
env: createPathEnv(),
logger: createLoggerMock(),
};
const firstSetup = ensureFfmpegInstalledOnceAsync(options);
await new Promise<void>(resolve => setImmediate(resolve));
expect(spawnMock).toHaveBeenCalledTimes(3);
expect(spawnMock).toHaveBeenCalledTimes(2);
const secondSetup = ensureFfmpegInstalledOnceAsync(options);
expect(spawnMock).toHaveBeenCalledTimes(3);
expect(spawnMock).toHaveBeenCalledTimes(2);
finishInstall?.();
await Promise.all([firstSetup, secondSetup]);
@@ -1521,18 +1682,17 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
it('still installs on linux when the apt index refresh fails', async () => {
spawnMock
.mockReturnValueOnce(spawnRejected()) // ffmpeg -version
.mockReturnValueOnce(spawnRejected()) // apt-get update
.mockReturnValueOnce(spawnResolved()); // apt-get install
.mockReturnValue(spawnResolved());
const logger = createLoggerMock();
await ensureFfmpegInstalledOnceAsync({
runtimePlatform: BuildRuntimePlatform.LINUX,
env: createEnvMock(),
env: createPathEnv(),
logger,
});
expect(spawnMock).toHaveBeenLastCalledWith(
expect(spawnMock).toHaveBeenCalledWith(
'sudo',
['apt-get', 'install', '-y', 'ffmpeg'],
expect.anything()
@@ -1541,17 +1701,18 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
});
it('warns and resolves when the install fails, so the session still starts', async () => {
spawnMock.mockReturnValueOnce(spawnRejected()).mockReturnValueOnce(spawnRejected());
spawnMock.mockReturnValueOnce(spawnRejected());
const logger = createLoggerMock();
await expect(
ensureFfmpegInstalledOnceAsync({
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: createEnvMock(),
env: createPathEnv(),
logger,
})
).resolves.toBeUndefined();
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalled();
expect(jest.mocked(Sentry).capture).toHaveBeenCalled();
});
@@ -1559,7 +1720,7 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
// The caller runs this with `void` and the worker installs no unhandledRejection
// handler, so a rejection here would crash the process. `spawn` is not async and
// can throw synchronously, which `asyncResult` cannot catch.
it('resolves when the availability check throws synchronously', async () => {
it('resolves when the install throws synchronously', async () => {
spawnMock.mockImplementationOnce(() => {
throw new Error('sync spawn failure');
});
@@ -1568,7 +1729,7 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
await expect(
ensureFfmpegInstalledOnceAsync({
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: createEnvMock(),
env: createPathEnv(),
logger,
})
).resolves.toBeUndefined();
@@ -1576,4 +1737,29 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
expect(logger.warn).toHaveBeenCalled();
expect(jest.mocked(Sentry).capture).toHaveBeenCalled();
});
it.each([
['rejects', () => spawnRejected()],
[
'throws synchronously',
() => {
throw new Error('sync spawn failure');
},
],
])('ignores a warm-up that %s', async (_, warmUp) => {
await installFakeFfmpegAsync({ mode: 0o755 });
spawnMock.mockImplementationOnce(warmUp as () => ReturnType<typeof spawn>);
const logger = createLoggerMock();
await expect(
ensureFfmpegInstalledOnceAsync({
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: createPathEnv(),
logger,
})
).resolves.toBeUndefined();
await new Promise<void>(resolve => setImmediate(resolve));
expect(logger.warn).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,451 @@
jest.unmock('fs');
jest.unmock('fs/promises');
jest.unmock('node:fs');
jest.unmock('node:fs/promises');
import { SandboxDaemonErrorCode, SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { type Client, CombinedError } from '@urql/core';
import fetch, { type RequestInit } from 'node-fetch';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { performance } from 'node:perf_hooks';
import { type Readable } from 'node:stream';
import { setTimeout as delay } from 'node:timers/promises';
import { inspect } from 'node:util';
import { SandboxArtifactUploadManager, startSandboxArtifactUploadAsync } from '../sandboxArtifacts';
const { AbortError, FetchError, Response } = jest.requireActual(
'node-fetch'
) as typeof import('node-fetch');
const ARTIFACT_ID = '0199c0de-7b3a-7c1e-8f00-1234567890ab';
const SIGNED_URL = 'https://uploads.expo.test/artifact?X-Amz-Signature=secret-signature';
const DOWNLOAD_URL = 'https://downloads.expo.test/artifact?X-Amz-Signature=download-signature';
const createdResult = {
data: {
sandbox: {
createArtifactUploadSession: {
artifact: { id: ARTIFACT_ID },
uploadSession: { url: SIGNED_URL, headers: { 'If-None-Match': '*' } },
},
},
},
};
const finalizedResult = {
data: { sandbox: { finalizeArtifact: { id: ARTIFACT_ID, downloadUrl: DOWNLOAD_URL } } },
};
describe('sandbox artifact uploads', () => {
let directory: string;
let filePath: string;
let mutation: jest.Mock;
let graphqlClient: Client;
let logger: { info: jest.Mock; error: jest.Mock };
let uploadArtifactAsync: (options: {
filePath: string;
name: string;
signal: AbortSignal;
}) => ReturnType<typeof startSandboxArtifactUploadAsync>;
beforeEach(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'sandbox-artifacts-'));
filePath = path.join(directory, 'crash.log');
await fs.writeFile(filePath, 'crash');
jest.mocked(fetch).mockReset();
mutation = jest.fn();
logger = { info: jest.fn(), error: jest.fn() };
graphqlClient = { mutation } as unknown as Client;
uploadArtifactAsync = async options =>
await startSandboxArtifactUploadAsync({
graphqlClient,
sandboxId: 'sandbox-id',
logger: logger as unknown as bunyan,
...options,
});
});
afterEach(async () => {
await fs.rm(directory, { recursive: true, force: true });
});
it('returns the artifact id before the PUT completes', async () => {
mockMutationResults(createdResult, finalizedResult);
let respond!: (response: InstanceType<typeof Response>) => void;
jest.mocked(fetch).mockReturnValueOnce(
new Promise(resolve => {
respond = resolve;
}) as ReturnType<typeof fetch>
);
const upload = await uploadArtifactAsync({
filePath,
name: 'Crash log',
signal: new AbortController().signal,
});
expect(upload.id).toBe(ARTIFACT_ID);
expect(mutation).toHaveBeenCalledTimes(1);
expect(mutation).toHaveBeenCalledWith(
expect.anything(),
{ sandboxId: 'sandbox-id', input: { name: 'Crash log', filename: 'crash.log', size: 5 } },
expect.anything()
);
respond(new Response('', { status: 200 }));
await expect(upload.completed).resolves.toEqual({
status: 'uploaded',
downloadUrl: DOWNLOAD_URL,
});
expect(mutation).toHaveBeenLastCalledWith(
expect.anything(),
{ artifactId: ARTIFACT_ID },
expect.anything()
);
expect(logger.error).not.toHaveBeenCalled();
});
it('sends exactly the stat size on every attempt when the file grows', async () => {
mutation
.mockReturnValueOnce({
toPromise: async () => {
await fs.appendFile(filePath, ' and more');
return createdResult;
},
})
.mockReturnValueOnce({ toPromise: async () => finalizedResult });
const bodies: string[] = [];
const respondAfterReadingBody =
(status: number) =>
async (_url: unknown, init?: RequestInit): Promise<InstanceType<typeof Response>> => {
const chunks: Buffer[] = [];
for await (const chunk of init!.body as Readable) {
chunks.push(chunk);
}
bodies.push(Buffer.concat(chunks).toString());
return new Response('', { status });
};
jest
.mocked(fetch)
.mockImplementationOnce(respondAfterReadingBody(503) as unknown as typeof fetch)
.mockImplementationOnce(respondAfterReadingBody(200) as unknown as typeof fetch);
const upload = await uploadArtifactAsync({
filePath,
name: 'Crash log',
signal: new AbortController().signal,
});
await upload.completed;
expect(bodies).toEqual(['crash', 'crash']);
expect(logger.error).not.toHaveBeenCalled();
});
it('uploads an empty file with an empty body', async () => {
await fs.writeFile(filePath, '');
mockMutationResults(createdResult, finalizedResult);
let body: string | undefined;
jest.mocked(fetch).mockImplementationOnce((async (_url: unknown, init?: RequestInit) => {
const chunks: Buffer[] = [];
for await (const chunk of init!.body as Readable) {
chunks.push(chunk);
}
body = Buffer.concat(chunks).toString();
return new Response('', { status: 200 });
}) as unknown as typeof fetch);
const upload = await uploadArtifactAsync({
filePath,
name: 'Empty log',
signal: new AbortController().signal,
});
await upload.completed;
expect(mutation).toHaveBeenCalledWith(
expect.anything(),
{ sandboxId: 'sandbox-id', input: { name: 'Empty log', filename: 'crash.log', size: 0 } },
expect.anything()
);
expect(body).toBe('');
expect(logger.error).not.toHaveBeenCalled();
});
it('finalizes when a retry fails the upload precondition', async () => {
mockMutationResults(createdResult, finalizedResult);
jest
.mocked(fetch)
.mockRejectedValueOnce(
new FetchError(`request to ${SIGNED_URL} failed, reason: socket hang up`, 'system', {
code: 'ECONNRESET',
} as any)
)
.mockResolvedValueOnce(new Response('', { status: 412, statusText: 'Precondition Failed' }));
const upload = await uploadArtifactAsync({
filePath,
name: 'Crash log',
signal: new AbortController().signal,
});
await upload.completed;
expect(fetch).toHaveBeenCalledTimes(2);
expect(mutation).toHaveBeenLastCalledWith(
expect.anything(),
{ artifactId: ARTIFACT_ID },
expect.anything()
);
expect(logger.error).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenLastCalledWith(
`Uploaded sandbox artifact "Crash log" (${ARTIFACT_ID}, 5 bytes).`
);
});
it('retries finalize after a server error and logs a failure that persists', async () => {
mockMutationResults(
createdResult,
{
error: new CombinedError({
networkError: new Error('Bad Gateway'),
response: { status: 502 },
}),
},
{
error: new CombinedError({
graphQLErrors: ['Sandbox artifact "Crash log" has no uploaded file.'],
response: { status: 200 },
}),
}
);
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
const upload = await uploadArtifactAsync({
filePath,
name: 'Crash log',
signal: new AbortController().signal,
});
await expect(upload.completed).resolves.toEqual({ status: 'failed' });
expect(mutation).toHaveBeenCalledTimes(3);
expect(logger.error).toHaveBeenCalledWith(
expect.anything(),
`Uploaded sandbox artifact "Crash log" (${ARTIFACT_ID}), but could not finalize it.`
);
});
it.each(['VALIDATION_ERROR', 'UNAUTHORIZED'])(
'rejects a %s upload session error as a bad request with the GraphQL message',
async errorCode => {
const message =
'Sandbox already has an artifact named "Crash log". Use a different name for this artifact.';
mockMutationResults({
error: new CombinedError({ graphQLErrors: [{ message, extensions: { errorCode } }] }),
});
const upload = uploadArtifactAsync({
filePath,
name: 'Crash log',
signal: new AbortController().signal,
});
await expect(upload).rejects.toMatchObject({
code: SandboxDaemonErrorCode.BAD_REQUEST,
message,
});
expect(fetch).not.toHaveBeenCalled();
}
);
it('rejects an unexpected upload session error as a system error', async () => {
mockMutationResults({
error: new CombinedError({
graphQLErrors: [
{ message: 'Request failed.', extensions: { errorCode: 'UNEXPECTED_ERROR' } },
],
}),
});
const upload = uploadArtifactAsync({
filePath,
name: 'Crash log',
signal: new AbortController().signal,
});
await expect(upload).rejects.toBeInstanceOf(SystemError);
await expect(upload).rejects.toMatchObject({ message: 'Request failed.' });
});
it('rejects a missing file as a bad request without creating an upload session', async () => {
const missingPath = path.join(directory, 'missing.log');
const upload = uploadArtifactAsync({
filePath: missingPath,
name: 'Crash log',
signal: new AbortController().signal,
});
await expect(upload).rejects.toMatchObject({
code: SandboxDaemonErrorCode.BAD_REQUEST,
message: `File does not exist: ${missingPath}`,
});
expect(mutation).not.toHaveBeenCalled();
});
it('keeps the signed URL out of logs', async () => {
mockMutationResults(createdResult);
jest.mocked(fetch).mockRejectedValue(
new FetchError(`request to ${SIGNED_URL} failed, reason: socket hang up`, 'system', {
code: 'ECONNRESET',
} as any)
);
const upload = await uploadArtifactAsync({
filePath,
name: 'Crash log',
signal: new AbortController().signal,
});
await upload.completed;
expect(logger.error).toHaveBeenCalledTimes(1);
const logged = inspect([logger.info.mock.calls, logger.error.mock.calls], { depth: 10 });
expect(fetch).toHaveBeenCalledTimes(3);
expect(logged).toContain('Artifact PUT failed: ECONNRESET.');
expect(logged).not.toContain('secret-signature');
});
it('waits for an aborted upload before the manager reports that it stopped', async () => {
mockMutationResults(createdResult);
mockPutUntilAborted();
const controller = new AbortController();
const manager = createManager(controller.signal);
await expect(
manager.startAsync({ filePath, name: 'Crash log', yieldTimeMs: 0 })
).resolves.toEqual({
id: ARTIFACT_ID,
status: 'uploading',
});
controller.abort();
await manager.stoppedPromise;
expect(logger.error).toHaveBeenCalledWith(
expect.anything(),
`Failed to upload sandbox artifact "Crash log" (${ARTIFACT_ID}).`
);
await expect(
manager.startAsync({ filePath, name: 'Late log', yieldTimeMs: 0 })
).rejects.toMatchObject({
name: 'AbortError',
});
expect(mutation).toHaveBeenCalledTimes(1);
});
it.each([
{
case: 'a finalized upload as uploaded with its download URL',
finalizeResult: finalizedResult,
outcome: { status: 'uploaded', downloadUrl: DOWNLOAD_URL },
},
{
case: 'a failed finalization as failed',
finalizeResult: {
error: new CombinedError({
graphQLErrors: ['Sandbox artifact "Crash log" has no uploaded file.'],
response: { status: 200 },
}),
},
outcome: { status: 'failed' },
},
{
case: 'a finalized artifact without a download URL as failed',
finalizeResult: {
data: { sandbox: { finalizeArtifact: { id: ARTIFACT_ID, downloadUrl: null } } },
},
outcome: { status: 'failed' },
},
])(
'waits for an upload that ends within the yield time and reports $case',
async ({ finalizeResult, outcome }) => {
mockMutationResults(createdResult, finalizeResult);
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
const manager = createManager(new AbortController().signal);
await expect(
manager.startAsync({ filePath, name: 'Crash log', yieldTimeMs: 30_000 })
).resolves.toEqual({ id: ARTIFACT_ID, ...outcome });
}
);
it('reports an upload that outlasts the yield time as uploading and keeps uploading', async () => {
let finalizeCalled!: () => void;
const finalizeCalledPromise = new Promise<void>(resolve => {
finalizeCalled = resolve;
});
mockMutationResults(createdResult);
mutation.mockReturnValueOnce({
toPromise: async () => {
finalizeCalled();
return finalizedResult;
},
});
let respond!: (response: InstanceType<typeof Response>) => void;
jest.mocked(fetch).mockReturnValueOnce(
new Promise(resolve => {
respond = resolve;
}) as ReturnType<typeof fetch>
);
const manager = createManager(new AbortController().signal);
await expect(
manager.startAsync({ filePath, name: 'Crash log', yieldTimeMs: 10 })
).resolves.toEqual({ id: ARTIFACT_ID, status: 'uploading' });
respond(new Response('', { status: 200 }));
await finalizeCalledPromise;
});
it('counts upload session creation toward the yield time', async () => {
mutation.mockReturnValueOnce({
toPromise: async () => {
await delay(400);
return createdResult;
},
});
mockPutUntilAborted();
const controller = new AbortController();
const manager = createManager(controller.signal);
const startedAt = performance.now();
await expect(
manager.startAsync({ filePath, name: 'Crash log', yieldTimeMs: 400 })
).resolves.toEqual({ id: ARTIFACT_ID, status: 'uploading' });
expect(performance.now() - startedAt).toBeLessThan(600);
controller.abort();
await manager.stoppedPromise;
});
function createManager(signal: AbortSignal): SandboxArtifactUploadManager {
return new SandboxArtifactUploadManager({
graphqlClient,
sandboxId: 'sandbox-id',
logger: logger as unknown as bunyan,
signal,
});
}
function mockPutUntilAborted(): void {
jest.mocked(fetch).mockImplementationOnce((async (_url: unknown, init?: RequestInit) => {
await new Promise<never>((_resolve, reject) => {
init!.signal!.addEventListener('abort', () => {
reject(new AbortError('The user aborted a request.'));
});
});
}) as unknown as typeof fetch);
}
function mockMutationResults(...results: unknown[]): void {
for (const result of results) {
mutation.mockReturnValueOnce({ toPromise: async () => result });
}
}
});
@@ -1,9 +1,11 @@
import { SandboxDaemonResponseZ, SystemError } from '@expo/eas-build-job';
import Log from '@expo/logger';
import { type Client } from '@urql/core';
import http from 'node:http';
import net, { AddressInfo } from 'node:net';
import WebSocket, { WebSocketServer } from 'ws';
import * as sandboxCommandImplementations from '../sandboxCommandImplementations';
import { startSandboxDaemonAsync } from '../sandboxDaemon';
jest.unmock('@expo/logger');
@@ -14,6 +16,7 @@ jest.unmock('node:fs/promises');
describe(startSandboxDaemonAsync.name, () => {
const logger = Log.child({ buildStepId: 'sandbox-test' });
const graphqlClient = {} as Client;
it.each(['ftp://localhost', 'not a URL'])('reports invalid server URL %s', async serverUrl => {
const errorLog = jest.spyOn(logger, 'error').mockImplementation(() => {});
const daemon = await startSandboxDaemonAsync({
@@ -23,6 +26,8 @@ describe(startSandboxDaemonAsync.name, () => {
logger,
workingDirectory: process.cwd(),
env: process.env,
graphqlClient,
sandboxId: 'sandbox-id',
});
try {
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
@@ -48,6 +53,8 @@ describe(startSandboxDaemonAsync.name, () => {
logger,
workingDirectory: process.cwd(),
env: process.env,
graphqlClient,
sandboxId: 'sandbox-id',
});
try {
const socket = await connection;
@@ -82,6 +89,8 @@ describe(startSandboxDaemonAsync.name, () => {
logger,
workingDirectory: process.cwd(),
env: process.env,
graphqlClient,
sandboxId: 'sandbox-id',
});
try {
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
@@ -112,6 +121,8 @@ describe(startSandboxDaemonAsync.name, () => {
signal: controller.signal,
workingDirectory: process.cwd(),
env: process.env,
graphqlClient,
sandboxId: 'sandbox-id',
});
const connectionError = daemon.ready.catch(error => error);
@@ -140,6 +151,8 @@ describe(startSandboxDaemonAsync.name, () => {
logger,
workingDirectory: process.cwd(),
env: process.env,
graphqlClient,
sandboxId: 'sandbox-id',
});
await expect(daemon.ready).rejects.toThrow('Sandbox MCP server connection failed');
@@ -173,6 +186,8 @@ describe(startSandboxDaemonAsync.name, () => {
logger,
workingDirectory: process.cwd(),
env: process.env,
graphqlClient,
sandboxId: 'sandbox-id',
});
const socket = await connection;
const daemon = await daemonPromise;
@@ -204,6 +219,8 @@ describe(startSandboxDaemonAsync.name, () => {
logger,
workingDirectory: process.cwd(),
env: process.env,
graphqlClient,
sandboxId: 'sandbox-id',
});
const socket = await connection;
await daemon.ready;
@@ -247,6 +264,8 @@ describe(startSandboxDaemonAsync.name, () => {
logger,
workingDirectory: process.cwd(),
env: process.env,
graphqlClient,
sandboxId: 'sandbox-id',
});
const socket = await connection;
await daemon.ready;
@@ -289,7 +308,7 @@ describe(startSandboxDaemonAsync.name, () => {
)
).resolves.toMatchObject({
id: '3',
error: { code: -32603, message: 'Command session 999 does not exist.' },
error: { code: 1, message: 'Command session 999 does not exist.' },
});
await daemon.stopAsync();
@@ -297,6 +316,68 @@ describe(startSandboxDaemonAsync.name, () => {
await new Promise<void>(resolve => httpServer.close(() => resolve()));
});
it('returns an internal error for a command failure without a daemon error code', async () => {
const createCommandImplementations =
sandboxCommandImplementations.createSandboxCommandImplementations;
const createSpy = jest
.spyOn(sandboxCommandImplementations, 'createSandboxCommandImplementations')
.mockImplementation(options => {
const created = createCommandImplementations(options);
return {
...created,
commandImplementations: {
...created.commandImplementations,
execCommand: async () => {
throw new Error('Unexpected failure');
},
},
};
});
const httpServer = http.createServer();
const mcpServer = new WebSocketServer({ noServer: true });
httpServer.on('upgrade', (request, socket, head) => {
mcpServer.handleUpgrade(request, socket, head, client =>
mcpServer.emit('connection', client)
);
});
await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve));
const address = httpServer.address() as AddressInfo;
const connection = new Promise<WebSocket>(resolve => mcpServer.once('connection', resolve));
const daemon = await startSandboxDaemonAsync({
credential: 'secret-token',
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 10,
logger,
workingDirectory: process.cwd(),
env: process.env,
graphqlClient,
sandboxId: 'sandbox-id',
});
const socket = await connection;
await daemon.ready;
const commandResponse = new Promise<string>(resolve =>
socket.once('message', data => resolve(`${data}`))
);
socket.send(
JSON.stringify({
jsonrpc: '2.0',
id: 'command-1',
method: 'execCommand',
params: { cmd: 'pwd' },
})
);
expect(JSON.parse(await commandResponse)).toEqual({
jsonrpc: '2.0',
id: 'command-1',
error: { code: -32603, message: 'Unexpected failure' },
});
await daemon.stopAsync();
mcpServer.close();
await new Promise<void>(resolve => httpServer.close(() => resolve()));
createSpy.mockRestore();
});
it('cancels the reconnect delay when stopped', async () => {
const httpServer = http.createServer();
const mcpServer = new WebSocketServer({ noServer: true });
@@ -315,6 +396,8 @@ describe(startSandboxDaemonAsync.name, () => {
logger,
workingDirectory: process.cwd(),
env: process.env,
graphqlClient,
sandboxId: 'sandbox-id',
});
const socket = await connection;
const daemon = await daemonPromise;
@@ -3,8 +3,12 @@ jest.unmock('fs/promises');
jest.unmock('node:fs');
jest.unmock('node:fs/promises');
import { type SandboxDaemonCommandResult, type SandboxDaemonMethod } from '@expo/eas-build-job';
import fs from 'node:fs/promises';
import { type SandboxDaemonCommandResult, SandboxDaemonErrorCode } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { type Client } from '@urql/core';
import fetch from 'node-fetch';
import { readFileSync, statSync } from 'node:fs';
import fs, { type FileHandle } from 'node:fs/promises';
import { spawnSync } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
@@ -16,18 +20,34 @@ import {
createSandboxCommandImplementations,
} from '../sandboxCommandImplementations';
const READ_FILE_LIMITS = { maxTextBytes: 40_000, maxImageBytes: 3_000_000 };
const FIXTURES_DIRECTORY = path.join(__dirname, 'fixtures');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
const ARTIFACT_ID = '0199c0de-7b3a-7c1e-8f00-1234567890ab';
describe('sandbox daemon commands', () => {
let commandImplementations: SandboxDaemonCommandImplementations;
let stopAsync: () => Promise<void>;
let workingDirectory: string;
let mutation: jest.Mock;
let graphqlClient: Client;
let logger: bunyan;
beforeEach(async () => {
workingDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'sandbox-command-executor-'));
mutation = jest.fn();
graphqlClient = { mutation } as unknown as Client;
logger = { info: jest.fn(), error: jest.fn() } as unknown as bunyan;
const controller = new AbortController();
const commands = createSandboxCommandImplementations({
workingDirectory,
env: process.env,
signal: controller.signal,
graphqlClient,
sandboxId: 'sandbox-id',
logger,
});
commandImplementations = commands.commandImplementations;
stopAsync = async () => {
@@ -55,6 +75,9 @@ describe('sandbox daemon commands', () => {
workingDirectory,
env: process.env,
signal: controller.signal,
graphqlClient,
sandboxId: 'sandbox-id',
logger,
});
const command = commands.commandImplementations.execCommand({ cmd: 'printf should-not-run' });
controller.abort();
@@ -194,9 +217,10 @@ describe('sandbox daemon commands', () => {
it('reports a missing working directory', async () => {
await expect(
commandImplementations.execCommand({ cmd: 'true', workdir: 'missing' })
).rejects.toThrow(
`Working directory does not exist: ${path.join(workingDirectory, 'missing')}`
);
).rejects.toMatchObject({
code: SandboxDaemonErrorCode.BAD_REQUEST,
message: `Working directory does not exist: ${path.join(workingDirectory, 'missing')}`,
});
});
it('reports a working directory that is not a directory', async () => {
@@ -205,7 +229,10 @@ describe('sandbox daemon commands', () => {
await expect(
commandImplementations.execCommand({ cmd: 'true', workdir: 'file' })
).rejects.toThrow(`Working directory is not a directory: ${file}`);
).rejects.toMatchObject({
code: SandboxDaemonErrorCode.BAD_REQUEST,
message: `Working directory is not a directory: ${file}`,
});
});
it('reports an inaccessible working directory', async () => {
@@ -217,7 +244,10 @@ describe('sandbox daemon commands', () => {
await expect(
commandImplementations.execCommand({ cmd: 'true', workdir: 'inaccessible' })
).rejects.toThrow(`Working directory is not accessible: ${directory}`);
).rejects.toMatchObject({
code: SandboxDaemonErrorCode.BAD_REQUEST,
message: `Working directory is not accessible: ${directory}`,
});
await fs.chmod(directory, 0o700);
});
@@ -237,10 +267,226 @@ describe('sandbox daemon commands', () => {
expect(isProcessRunning(childPid)).toBe(false);
});
describe('readFile', () => {
let readLengths: number[];
beforeEach(() => {
readLengths = [];
const open = fs.open;
jest.spyOn(fs, 'open').mockImplementation(async (...args) => {
const file = await open(...args);
const read = file.read.bind(file);
file.read = ((buffer: Buffer, offset: number, length: number, position: number) => {
readLengths.push(length);
return read(buffer, offset, length, position);
}) as FileHandle['read'];
return file;
});
});
afterEach(() => {
jest.restoreAllMocks();
});
async function readAsync(
contents: Buffer | string,
limits: Partial<typeof READ_FILE_LIMITS> = {}
) {
await fs.writeFile(path.join(workingDirectory, 'file'), contents);
return await commandImplementations.readFile({
path: 'file',
...READ_FILE_LIMITS,
...limits,
});
}
async function readFixtureAsync(name: string, limits: Partial<typeof READ_FILE_LIMITS> = {}) {
return await commandImplementations.readFile({
path: path.join(FIXTURES_DIRECTORY, name),
...READ_FILE_LIMITS,
...limits,
});
}
it.each([
['leiothrix.png', 'image/png'],
['sparrow.jpg', 'image/jpeg'],
['robin.gif', 'image/gif'],
['chat.webp', 'image/webp'],
])('returns %s as an %s image with its data', async (name, mimeType) => {
const contents = readFileSync(path.join(FIXTURES_DIRECTORY, name));
expect(await readFixtureAsync(name)).toEqual({
kind: 'image',
mimeType,
data: contents.toString('base64'),
});
expect(readLengths).toEqual([12, contents.length]);
});
it('detects an image by its content instead of its extension', async () => {
await fs.copyFile(
path.join(FIXTURES_DIRECTORY, 'leiothrix.png'),
path.join(workingDirectory, 'screenshot.txt')
);
expect(
await commandImplementations.readFile({ path: 'screenshot.txt', ...READ_FILE_LIMITS })
).toMatchObject({ kind: 'image', mimeType: 'image/png' });
});
it('reports an image over the byte limit after reading only its header', async () => {
const { size } = statSync(path.join(FIXTURES_DIRECTORY, 'leiothrix.png'));
expect(await readFixtureAsync('leiothrix.png', { maxImageBytes: size - 1 })).toEqual({
kind: 'image',
mimeType: 'image/png',
size,
error: 'tooLarge',
});
expect(readLengths).toEqual([12]);
});
it('returns a text file within the limit in full', async () => {
expect(await readFixtureAsync('birds.txt')).toEqual({
kind: 'text',
text: 'Robin, sparrow, leiothrix and chat\nZażółć gęślą jaźń\n€ 🐦\n',
truncated: false,
size: 71,
});
});
it('reads no more text than the limit without splitting a character', async () => {
expect(await readFixtureAsync('birds.txt', { maxTextBytes: 38 })).toEqual({
kind: 'text',
text: 'Robin, sparrow, leiothrix and chat\nZa',
truncated: true,
size: 71,
});
expect(readLengths).toEqual([12, 38]);
});
it.each([
['invalid UTF-8', Buffer.from([0x61, 0x80, 0x62])],
['an incomplete final character', Buffer.from([0x61, 0xe2, 0x82])],
])('reports a file with %s as binary', async (_, contents) => {
expect(await readAsync(contents)).toEqual({ kind: 'binary', size: 3 });
});
it('reports a missing file', async () => {
await expect(
commandImplementations.readFile({ path: 'missing', ...READ_FILE_LIMITS })
).rejects.toMatchObject({
code: SandboxDaemonErrorCode.BAD_REQUEST,
message: `File does not exist: ${path.join(workingDirectory, 'missing')}`,
});
});
it('reports an inaccessible file', async () => {
const file = path.join(workingDirectory, 'inaccessible');
await fs.writeFile(file, 'content', { mode: 0o000 });
await expect(
commandImplementations.readFile({ path: 'inaccessible', ...READ_FILE_LIMITS })
).rejects.toMatchObject({
code: SandboxDaemonErrorCode.BAD_REQUEST,
message: `File is not accessible: ${file}`,
});
});
it('rejects a directory', async () => {
const directory = path.join(workingDirectory, 'directory');
await fs.mkdir(directory);
await expect(
commandImplementations.readFile({ path: 'directory', ...READ_FILE_LIMITS })
).rejects.toMatchObject({
code: SandboxDaemonErrorCode.BAD_REQUEST,
message: `${directory} is not a regular file.`,
});
});
it('rejects a FIFO without waiting for a writer', async () => {
const fifo = path.join(workingDirectory, 'fifo');
expect(spawnSync('mkfifo', [fifo]).status).toBe(0);
await expect(
commandImplementations.readFile({ path: 'fifo', ...READ_FILE_LIMITS })
).rejects.toMatchObject({
code: SandboxDaemonErrorCode.BAD_REQUEST,
message: `${fifo} is not a regular file.`,
});
});
});
it('rejects a directory with a hint to upload an archive', async () => {
await fs.mkdir(path.join(workingDirectory, 'dist'));
await expect(
commandImplementations.uploadArtifact({ path: 'dist', name: 'Build output' })
).rejects.toMatchObject({
code: SandboxDaemonErrorCode.BAD_REQUEST,
message: expect.stringContaining(
`${path.join(workingDirectory, 'dist')} is not a regular file. To upload a directory, create an archive first`
),
});
expect(mutation).not.toHaveBeenCalled();
});
it('uploads an artifact from a path relative to the working directory and waits for it by default', async () => {
await fs.mkdir(path.join(workingDirectory, 'logs'));
await fs.writeFile(path.join(workingDirectory, 'logs', 'crash.log'), 'crash');
mutation
.mockReturnValueOnce({
toPromise: async () => ({
data: {
sandbox: {
createArtifactUploadSession: {
artifact: { id: ARTIFACT_ID },
uploadSession: { url: 'https://uploads.expo.test/artifact', headers: {} },
},
},
},
}),
})
.mockReturnValueOnce({
toPromise: async () => ({
data: {
sandbox: {
finalizeArtifact: {
id: ARTIFACT_ID,
downloadUrl: 'https://downloads.expo.test/artifact',
},
},
},
}),
});
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
const result = await commandImplementations.uploadArtifact({
path: 'logs/crash.log',
name: 'Crash log',
});
expect(result).toEqual({
id: ARTIFACT_ID,
status: 'uploaded',
downloadUrl: 'https://downloads.expo.test/artifact',
});
expect(mutation).toHaveBeenCalledWith(
expect.anything(),
{ sandboxId: 'sandbox-id', input: { name: 'Crash log', filename: 'crash.log', size: 5 } },
expect.anything()
);
});
async function readUntilAsync(
initial: SandboxDaemonCommandResult<SandboxDaemonMethod>,
isReady: (output: string, result: SandboxDaemonCommandResult<SandboxDaemonMethod>) => boolean
): Promise<SandboxDaemonCommandResult<SandboxDaemonMethod>> {
initial: SandboxDaemonCommandResult<'execCommand' | 'writeStdin'>,
isReady: (
output: string,
result: SandboxDaemonCommandResult<'execCommand' | 'writeStdin'>
) => boolean
): Promise<SandboxDaemonCommandResult<'execCommand' | 'writeStdin'>> {
let result = initial;
let output = result.output;
const deadline = Date.now() + 10_000;
@@ -1,5 +1,4 @@
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';
@@ -14,7 +13,7 @@ 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 () => {
it('uploads NDJSON with explicit user-app scope', async () => {
await writeFile(filePath, '{"pid":42}\n');
await uploadServeSimLogsFileAsync(ctx, args);
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
@@ -26,9 +25,6 @@ it('uploads NDJSON with explicit user-app scope and destroys the stream afterwar
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 () => {
@@ -38,15 +34,15 @@ it('skips empty and missing files', async () => {
expect(uploadDeviceRunSessionArtifactAsync).not.toHaveBeenCalled();
});
it('handles a file read error while upload allocation is pending without an unhandled error', async () => {
it('aborts the upload on a file read error without an unhandled error', async () => {
await writeFile(filePath, '{}\n');
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { stream, signal }) => {
.mockImplementationOnce(async (_ctx, { openStream, signal }) => {
const aborted = new Promise<void>((_resolve, reject) => {
signal!.addEventListener('abort', () => reject(signal!.reason), { once: true });
});
(stream as Readable).destroy(new Error('read failure'));
openStream().destroy(new Error('read failure'));
await aborted;
});
await expect(uploadServeSimLogsFileAsync(ctx, args)).resolves.toBeUndefined();
@@ -50,8 +50,8 @@ describe(uploadServeSimMetricsFileAsync, () => {
let uploaded = '';
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementation(async (_ctx, { stream }) => {
uploaded = await readStreamAsync(stream);
.mockImplementation(async (_ctx, { openStream }) => {
uploaded = await readStreamAsync(openStream());
});
await uploadServeSimMetricsFileAsync(ctx, {
@@ -87,8 +87,8 @@ describe(uploadServeSimMetricsFileAsync, () => {
await writeFile(filePath, NDJSON);
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementation(async (_ctx, { stream }) => {
await readStreamAsync(stream);
.mockImplementation(async (_ctx, { openStream }) => {
await readStreamAsync(openStream());
});
await uploadServeSimMetricsFileAsync(ctx, {
@@ -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,8 +159,11 @@ 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),
openStream: () => createReadStream(temporaryArtifactPath),
});
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
@@ -0,0 +1,253 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import turtleSpawn from '@expo/turtle-spawn';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import readline from 'node:readline';
import { leaseAgentRunProviderCredentialsAsync } from './agentRunLease';
import { type AgentCli, type AgentInvocation, type AgentLogMessage } from './agents/agent';
import { CLAUDE_CODE_CLI, prepareClaudeCodeAsync } from './agents/claude';
import { CODEX_CLI, prepareCodexAsync } from './agents/codex';
import { Sentry } from '../../sentry';
import { killProcessGroup } from '../../utils/processes';
export type AgentKind = 'claude-code' | 'codex';
const AGENT_CLIS: Record<AgentKind, AgentCli> = {
'claude-code': CLAUDE_CODE_CLI,
codex: CODEX_CLI,
};
export const AGENT_KINDS = Object.keys(AGENT_CLIS) as AgentKind[];
const KILL_GRACE_PERIOD_MS = 10_000;
const OUTPUT_CLOSE_GRACE_PERIOD_MS = 5_000;
export async function runAgentAsync({
agentKind,
agentRunId,
prompt,
expoToken,
expoApiV2BaseUrl,
mcpUrl,
pathEnv,
npmCacheUrl,
logger,
signal,
}: {
agentKind: AgentKind;
agentRunId: string;
prompt: string;
expoToken: string;
expoApiV2BaseUrl: string;
/** The Expo MCP server's streamable HTTP endpoint. */
mcpUrl: string;
pathEnv: string;
npmCacheUrl?: string;
logger: bunyan;
signal: AbortSignal;
}): Promise<void> {
const leaseOptions = { expoApiV2BaseUrl, expoToken, agentRunId, signal };
let credentials = await leaseAgentRunProviderCredentialsAsync(leaseOptions);
const secrets = new Set<string>();
const runDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-agent-run-'));
try {
const cliDirectory = path.join(runDirectory, 'cli');
const homeDirectory = path.join(runDirectory, 'home');
const tmpDirectory = path.join(runDirectory, 'tmp');
const workingDirectory = path.join(runDirectory, 'cwd');
await Promise.all(
[cliDirectory, homeDirectory, tmpDirectory, workingDirectory].map(directory =>
fs.mkdir(directory)
)
);
// Of the job's environment, only `PATH` reaches npm and the agent.
const minimalEnv = {
PATH: pathEnv,
HOME: homeDirectory,
TMPDIR: tmpDirectory,
LANG: 'C.UTF-8',
};
const { packageSpec, bin, formatOutputLine } = AGENT_CLIS[agentKind];
logger.info(`Installing ${packageSpec}.`);
try {
await turtleSpawn(
'npm',
[
'install',
...['--prefix', cliDirectory],
...['--no-save', '--package-lock=false', '--no-audit', '--no-fund'],
...(npmCacheUrl ? ['--registry', npmCacheUrl] : []),
packageSpec,
],
{ env: minimalEnv, signal }
);
} catch (error) {
signal.throwIfAborted();
throw new SystemError(
`Could not install ${packageSpec} on the worker, so the agent cannot start. This is usually a temporary network or registry problem; start the agent run again.`,
{ cause: error }
);
}
const mcpServer = {
url: mcpUrl,
headers: { Authorization: `Bearer ${expoToken}`, 'X-Expo-Agent-Run-Id': agentRunId },
};
logger.info('Starting the agent.');
for (let isResuming = false; ; isResuming = true) {
signal.throwIfAborted();
let invocation: AgentInvocation;
switch (agentKind) {
case 'claude-code':
invocation = await prepareClaudeCodeAsync({
homeDirectory,
minimalEnv,
mcpServer,
prompt,
credentials,
isResuming,
});
break;
case 'codex':
invocation = await prepareCodexAsync({
homeDirectory,
minimalEnv,
mcpServer,
prompt,
credentials,
isResuming,
});
break;
}
for (const secret of invocation.secrets) {
secrets.add(secret);
}
const { exitCode, exitSignal } = await runAgentProcessAsync({
command: path.join(cliDirectory, 'node_modules', '.bin', bin),
...invocation,
cwd: workingDirectory,
formatOutputLine,
secrets: [...secrets],
logger,
signal,
});
if (exitCode === 0) {
return;
}
const exitDescription = exitSignal
? `was terminated by ${exitSignal}`
: `exited with code ${exitCode}`;
// www hands out the stored token until it has expired, so a different token means the
// one the agent used has expired.
const nextCredentials = await leaseAgentRunProviderCredentialsAsync(leaseOptions);
if (nextCredentials.accessToken === credentials.accessToken) {
throw new UserError(
'EAS_RUN_AGENT_FAILED',
`The agent ${exitDescription} before it finished the task. Its output above shows what went wrong; fix the cause and start a new agent run.`
);
}
logger.info(
`The agent ${exitDescription} after its provider credentials expired. Resuming it with new credentials.`
);
credentials = nextCredentials;
}
} finally {
await fs.rm(runDirectory, { recursive: true, force: true });
}
}
async function runAgentProcessAsync({
command,
args,
env,
cwd,
stdin,
formatOutputLine,
secrets,
logger,
signal,
}: {
command: string;
args: string[];
env: Record<string, string>;
cwd: string;
stdin: string;
formatOutputLine: AgentCli['formatOutputLine'];
secrets: string[];
logger: bunyan;
signal: AbortSignal;
}): Promise<{ exitCode: number | null; exitSignal: NodeJS.Signals | null }> {
signal.throwIfAborted();
// Detached, so the agent leads a process group that can be signaled as a whole.
const child = spawn(command, args, { cwd, env, detached: true });
const outputClosed = new Promise<void>(resolve => child.once('close', () => resolve()));
const redact = (text: string): string =>
secrets.reduce((masked, secret) => masked.replaceAll(secret, '[redacted]'), text);
const stdoutLogger = logger.child({ source: 'stdout' });
readline.createInterface({ input: child.stdout }).on('line', line => {
let messages: AgentLogMessage[];
try {
messages = formatOutputLine(line);
} catch (error) {
// An exception in a readline listener is uncaught and would end the worker process.
Sentry.capture('Could not format a line of the agent output', error as Error);
return;
}
for (const { level, message } of messages) {
stdoutLogger[level](redact(message).trimEnd());
}
});
const stderrLogger = logger.child({ source: 'stderr' });
readline.createInterface({ input: child.stderr }).on('line', line => {
stderrLogger.info(redact(line));
});
// The agent may exit before it reads the prompt; its exit status reports that.
child.stdin.on('error', () => {});
child.stdin.end(stdin);
let killTimer: NodeJS.Timeout | undefined;
const stop = (): void => {
if (killTimer) {
return;
}
killProcessGroup(child, 'SIGTERM');
killTimer = setTimeout(() => killProcessGroup(child, 'SIGKILL'), KILL_GRACE_PERIOD_MS);
};
signal.addEventListener('abort', stop);
let exitCode: number | null;
let exitSignal: NodeJS.Signals | null;
try {
[exitCode, exitSignal] = await once(child, 'exit');
} catch (error: any) {
// `once` rejects when the child emits `error`, which is how a failed spawn reports.
throw new SystemError(
`Could not start the agent CLI on the worker: ${error.message}. Start the agent run again, and contact Expo support if it keeps failing.`,
{ cause: error }
);
} finally {
clearTimeout(killTimer);
signal.removeEventListener('abort', stop);
}
// Whatever the agent left running ends with it. Something outside its process group could
// still hold the output open, so the wait for the last lines is bounded.
killProcessGroup(child, 'SIGKILL');
let outputGraceTimer: NodeJS.Timeout | undefined;
await Promise.race([
outputClosed,
new Promise<void>(resolve => {
outputGraceTimer = setTimeout(resolve, OUTPUT_CLOSE_GRACE_PERIOD_MS);
}),
]);
clearTimeout(outputGraceTimer);
signal.throwIfAborted();
return { exitCode, exitSignal };
}
@@ -0,0 +1,103 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import fetch from 'node-fetch';
import { z } from 'zod';
import { promiseRetryWithCondition } from '../../utils/promiseRetryWithCondition';
const AgentRunProviderCredentialsZ = z.discriminatedUnion('provider', [
z.object({
provider: z.literal('anthropic'),
accessToken: z.string().min(1),
}),
z.object({
provider: z.literal('openai'),
idToken: z.string().min(1),
accessToken: z.string().min(1),
accountId: z.string().min(1),
}),
]);
const LeaseResponseZ = z.object({ data: AgentRunProviderCredentialsZ });
const ErrorResponseZ = z.object({
errors: z.array(z.object({ message: z.string().min(1) })).min(1),
});
export type AgentRunProviderCredentials = z.infer<typeof AgentRunProviderCredentialsZ>;
const MAX_ATTEMPTS = 3;
const RETRY_DELAY_MS = 2_000;
const REQUEST_TIMEOUT_MS = 15_000;
class RetryableLeaseError extends Error {}
export async function leaseAgentRunProviderCredentialsAsync(options: {
expoApiV2BaseUrl: string;
expoToken: string;
agentRunId: string;
signal?: AbortSignal;
}): Promise<AgentRunProviderCredentials> {
try {
return await promiseRetryWithCondition(
requestLeaseAsync,
error => error instanceof RetryableLeaseError,
{ retries: MAX_ATTEMPTS - 1, factor: 1, minTimeout: RETRY_DELAY_MS }
)(options);
} catch (error) {
if (error instanceof RetryableLeaseError) {
throw new SystemError(
`Could not lease provider credentials for the agent run because the Expo API failed ${MAX_ATTEMPTS} times in a row. This is usually temporary; start the agent run again, and contact Expo support if it keeps failing.`,
{ cause: error.cause ?? error }
);
}
throw error;
}
}
// Which failures count as retryable follows www's contract for this route.
async function requestLeaseAsync({
expoApiV2BaseUrl,
expoToken,
agentRunId,
signal,
}: {
expoApiV2BaseUrl: string;
expoToken: string;
agentRunId: string;
signal?: AbortSignal;
}): Promise<AgentRunProviderCredentials> {
const url = new URL(`agent-runs/${agentRunId}/auth-lease`, expoApiV2BaseUrl).toString();
let response;
let body: unknown;
try {
response = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${expoToken}` },
timeout: REQUEST_TIMEOUT_MS,
signal,
});
// Reading the body can fail the way the request can, so it is retried with it.
body = response.ok ? await response.json() : undefined;
} catch (error) {
signal?.throwIfAborted();
throw new RetryableLeaseError('The request to the Expo API failed.', { cause: error });
}
if (response.ok) {
const parsed = LeaseResponseZ.safeParse(body);
if (!parsed.success) {
throw new SystemError(
'The Expo API returned provider credentials in an unexpected format, so the agent cannot run. This is a problem on our side; contact Expo support if it keeps happening.',
{ cause: parsed.error }
);
}
return parsed.data.data;
}
if (response.status < 500) {
const errorBody = ErrorResponseZ.safeParse(await response.json().catch(() => null));
throw new UserError(
'EAS_RUN_AGENT_CREDENTIALS_UNAVAILABLE',
errorBody.success
? errorBody.data.errors[0].message
: `Expo refused to issue provider credentials for this agent run (HTTP ${response.status}). The run may have ended, or its provider connection may have been removed. Start a new agent run.`
);
}
throw new RetryableLeaseError(`The Expo API responded with HTTP ${response.status}.`);
}
@@ -0,0 +1,137 @@
import { CLAUDE_CODE_CLI } from '../claude';
import { readFixtureLines } from './readFixture';
const format = CLAUDE_CODE_CLI.formatOutputLine;
function formatFixture(name: string): ReturnType<typeof format> {
return readFixtureLines(name).flatMap(line => format(line));
}
describe('CLAUDE_CODE_CLI.formatOutputLine', () => {
it('renders a run with a tool call and skips thinking and token counts', () => {
expect(formatFixture('claude-code-tool-call.jsonl')).toEqual([
{ level: 'info', message: 'MCP server expo is connected.' },
{ level: 'info', message: 'Tool call: mcp__expo__sandbox_exec {"command":"echo hi"}' },
{ level: 'info', message: 'Tool result: stub ran: echo hi' },
{
level: 'info',
message:
'Agent: The command returned exactly: `stub ran: echo hi`\n\nThe tools I can call are:\n\n1. `mcp__expo__sandbox_exec` — Execute a shell command in a running sandbox\n2. `mcp__expo__sandbox_list` — List the sandboxes of the project',
},
]);
});
it('renders a run that failed before it could do anything', () => {
expect(formatFixture('claude-code-not-logged-in.jsonl')).toEqual([
{ level: 'info', message: 'MCP server expo is connected.' },
{ level: 'info', message: 'Agent: Not logged in · Please run /login' },
{ level: 'error', message: 'Agent failed: Not logged in · Please run /login' },
]);
});
it('reports every MCP server, warning about one the agent could not connect to', () => {
const event = {
type: 'system',
subtype: 'init',
mcp_servers: [
{ name: 'expo', status: 'failed' },
{ name: 'other', status: 'connected' },
],
};
expect(format(JSON.stringify(event))).toEqual([
{ level: 'warn', message: 'MCP server expo is not connected (failed).' },
{ level: 'info', message: 'MCP server other is connected.' },
]);
});
it('renders a failed tool result as a warning and joins its text blocks', () => {
const event = {
type: 'user',
message: {
content: [
{
type: 'tool_result',
tool_use_id: 'toolu_1',
is_error: true,
content: [
{ type: 'text', text: 'first' },
{ type: 'image', source: {} },
{ type: 'text', text: 'second' },
],
},
],
},
};
expect(format(JSON.stringify(event))).toEqual([
{ level: 'warn', message: 'Tool error: first\nsecond' },
]);
});
it('describes a failure without a result text by its errors, then by its subtype', () => {
expect(
format(
JSON.stringify({
type: 'result',
subtype: 'error_max_turns',
is_error: true,
errors: ['Reached max turns (1)'],
})
)
).toEqual([{ level: 'error', message: 'Agent failed: Reached max turns (1)' }]);
expect(
format(
JSON.stringify({
type: 'result',
subtype: 'error_max_turns',
is_error: true,
result: '',
errors: ['Reached max turns (1)'],
})
)
).toEqual([{ level: 'error', message: 'Agent failed: Reached max turns (1)' }]);
expect(
format(
JSON.stringify({ type: 'result', subtype: 'error_max_turns', is_error: true, errors: [] })
)
).toEqual([{ level: 'error', message: 'Agent failed: error_max_turns' }]);
expect(
format(JSON.stringify({ type: 'result', subtype: 'error_during_execution', is_error: true }))
).toEqual([{ level: 'error', message: 'Agent failed: error_during_execution' }]);
});
it('skips what it does not know without failing', () => {
expect(format(JSON.stringify({ type: 'rate_limit_event', rate_limit_info: {} }))).toEqual([]);
expect(format(JSON.stringify({ type: 'stream_event', event: {} }))).toEqual([]);
expect(format(JSON.stringify({ type: 'assistant', message: { content: 'text' } }))).toEqual([]);
expect(format(JSON.stringify({ type: 'user', message: { content: 'the prompt' } }))).toEqual(
[]
);
expect(format(JSON.stringify({ type: 'result', subtype: 'success', is_error: 'yes' }))).toEqual(
[]
);
expect(format('null')).toEqual([]);
expect(format('[]')).toEqual([]);
});
it('keeps the known blocks of a message with an unknown block', () => {
const event = {
type: 'assistant',
message: {
content: [
{ type: 'server_tool_use', id: 'srvtoolu_1', name: 'web_search', input: {} },
{ type: 'text', text: 'Searching.' },
{ type: 'tool_use', id: 'toolu_1', name: 'mcp__expo__sandbox_list', input: 'bad' },
],
},
};
expect(format(JSON.stringify(event))).toEqual([
{ level: 'info', message: 'Agent: Searching.' },
]);
});
it('passes a line that is not JSON through as it is', () => {
expect(format('Error: ENOENT: no such file or directory')).toEqual([
{ level: 'info', message: 'Error: ENOENT: no such file or directory' },
]);
});
});
@@ -0,0 +1,138 @@
import { CODEX_CLI } from '../codex';
import { readFixtureLines } from './readFixture';
const format = CODEX_CLI.formatOutputLine;
function formatFixture(name: string): ReturnType<typeof format> {
return readFixtureLines(name).flatMap(line => format(line));
}
describe('CODEX_CLI.formatOutputLine', () => {
it('renders a run with two tool calls and skips the thread and turn events', () => {
expect(formatFixture('codex-tool-calls.jsonl')).toEqual([
{
level: 'info',
message:
'Agent: I will run these three operations in order and report the exact result of each.\n',
},
{ level: 'info', message: 'Tool call: expo.sandbox_exec {"command":"echo hi"}' },
{ level: 'info', message: 'Tool result: stub ran: echo hi' },
{ level: 'info', message: 'Tool call: expo.sandbox_list {}' },
{ level: 'info', message: 'Tool result: no sandboxes' },
{
level: 'info',
message:
'Agent: The exact results, in order:\n\n1. `expo sandbox_exec` with command `echo hi`:\n ```json\n {"content":[{"type":"text","text":"stub ran: echo hi"}],"isError":false}\n ```\n\n2. `expo sandbox_list`:\n ```json\n {"content":[{"type":"text","text":"no sandboxes"}],"isError":false}\n ```\n\n3. `apply_patch` to create `probe.txt` with the single line `hello` failed:\n ```text\n patch rejected: writing is blocked by read-only sandbox; rejected by user approval settings\n ```\n The file was not created.',
},
]);
});
it('renders a refused tool call as a warning', () => {
expect(formatFixture('codex-tool-call-refused.jsonl')).toEqual([
{
level: 'info',
message:
"Agent: I will run these three operations in order and report each tool's actual result.\n",
},
{ level: 'info', message: 'Tool call: expo.sandbox_exec {"command":"echo hi"}' },
{
level: 'warn',
message: 'Tool error: MCP tool call requires approval, but approval policy is never',
},
{
level: 'info',
message: expect.stringMatching(
/^Agent: 1\. Called `sandbox_exec` on the `expo` MCP server/
),
},
]);
});
it('renders a failed turn with its transient errors as warnings and the final one as an error', () => {
const reconnecting = (attempt: number): { level: 'warn'; message: string } => ({
level: 'warn',
message: `Error: Reconnecting... ${attempt}/5 (workspace routing discovery unauthorized (401))`,
});
expect(formatFixture('codex-failed-turn.jsonl')).toEqual([
reconnecting(2),
reconnecting(3),
reconnecting(4),
reconnecting(5),
{
level: 'warn',
message:
'Error: Falling back from WebSockets to HTTPS transport. workspace routing discovery unauthorized (401)',
},
reconnecting(1),
reconnecting(2),
reconnecting(3),
reconnecting(4),
reconnecting(5),
{ level: 'warn', message: 'Error: workspace routing discovery unauthorized (401)' },
{ level: 'error', message: 'Agent failed: workspace routing discovery unauthorized (401)' },
]);
});
it('renders an agent message once, when it completes', () => {
const item = {
id: 'item_1',
type: 'agent_message',
text: 'Hello',
};
expect(format(JSON.stringify({ type: 'item.started', item }))).toEqual([]);
expect(format(JSON.stringify({ type: 'item.completed', item }))).toEqual([
{ level: 'info', message: 'Agent: Hello' },
]);
});
it('renders a tool call without arguments', () => {
const item = {
id: 'item_1',
type: 'mcp_tool_call',
server: 'expo',
tool: 'sandbox_list',
arguments: null,
result: null,
error: null,
status: 'in_progress',
};
expect(format(JSON.stringify({ type: 'item.started', item }))).toEqual([
{ level: 'info', message: 'Tool call: expo.sandbox_list {}' },
]);
});
it('renders a tool result the server marked as an error as a warning', () => {
const item = {
id: 'item_1',
type: 'mcp_tool_call',
server: 'expo',
tool: 'sandbox_exec',
arguments: { command: 'false' },
result: { content: [{ type: 'text', text: 'exit code 1' }], structured_content: null },
error: null,
status: 'failed',
};
expect(format(JSON.stringify({ type: 'item.completed', item }))).toEqual([
{ level: 'warn', message: 'Tool error: exit code 1' },
]);
});
it('skips what it does not know without failing', () => {
expect(format(JSON.stringify({ type: 'turn.completed', usage: {} }))).toEqual([]);
expect(
format(
JSON.stringify({
type: 'item.completed',
item: { id: 'item_1', type: 'reasoning', text: 'Thinking.' },
})
)
).toEqual([]);
expect(format(JSON.stringify({ type: 'item.completed', item: 'bad' }))).toEqual([]);
expect(format(JSON.stringify({ type: 'error', message: 42 }))).toEqual([]);
expect(format('true')).toEqual([]);
});
it('passes a line that is not JSON through as it is', () => {
expect(format('thread panicked')).toEqual([{ level: 'info', message: 'thread panicked' }]);
});
});
@@ -0,0 +1,3 @@
{"type": "system", "subtype": "init", "cwd": "/tmp/eas-agent-run-example/cwd", "session_id": "11111111-1111-4111-8111-111111111111", "tools": ["mcp__expo__sandbox_exec", "mcp__expo__sandbox_list"], "mcp_servers": [{"name": "expo", "status": "connected", "source": "dynamic"}], "model": "claude-haiku-4-5-20251001", "permissionMode": "default", "apiKeySource": "none", "claude_code_version": "2.1.291", "uuid": "22222222-2222-4222-8222-000000000000"}
{"type": "assistant", "message": {"diagnostics": null, "id": "msg_001", "container": null, "model": "<synthetic>", "role": "assistant", "stop_details": null, "stop_reason": "stop_sequence", "stop_sequence": "", "type": "message", "usage": {"output_tokens_details": null, "input_tokens": 0, "output_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": null, "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": null, "iterations": null, "speed": null, "fallback_credit": null}, "content": [{"type": "text", "text": "Not logged in · Please run /login"}], "context_management": null}, "parent_tool_use_id": null, "session_id": "11111111-1111-4111-8111-111111111111", "uuid": "22222222-2222-4222-8222-000000000000", "timestamp": "2026-10-06T12:24:54.066Z", "error": "authentication_failed", "is_api_error_message": true}
{"duration_api_ms": 0, "stop_reason": "stop_sequence", "session_id": "11111111-1111-4111-8111-111111111111", "total_cost_usd": 0, "usage": {"output_tokens_details": {"thinking_tokens": 0}, "input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 0, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": "", "iterations": [], "speed": "standard", "fallback_credit": null}, "modelUsage": {}, "permission_denials": [], "terminal_reason": "api_error", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": true, "num_turns": 1, "subtype": "success", "api_error_status": null, "result": "Not logged in · Please run /login", "type": "result", "duration_ms": 20, "uuid": "22222222-2222-4222-8222-000000000000", "queued_turn_count": 0, "result_index": 0}
@@ -0,0 +1,11 @@
{"type": "system", "subtype": "init", "cwd": "/tmp/eas-agent-run-example/cwd", "session_id": "11111111-1111-4111-8111-111111111111", "tools": ["mcp__expo__sandbox_exec", "mcp__expo__sandbox_list"], "mcp_servers": [{"name": "expo", "status": "connected", "source": "dynamic"}], "model": "claude-haiku-4-5-20251001", "permissionMode": "default", "apiKeySource": "none", "claude_code_version": "2.1.291", "uuid": "22222222-2222-4222-8222-000000000000"}
{"type": "system", "subtype": "thinking_tokens", "estimated_tokens": 50, "estimated_tokens_delta": 50, "session_id": "11111111-1111-4111-8111-111111111111", "uuid": "22222222-2222-4222-8222-000000000000"}
{"type": "system", "subtype": "thinking_tokens", "estimated_tokens": 135, "estimated_tokens_delta": 85, "session_id": "11111111-1111-4111-8111-111111111111", "uuid": "22222222-2222-4222-8222-000000000000"}
{"type": "assistant", "message": {"model": "claude-haiku-4-5-20251001", "id": "msg_001", "type": "message", "role": "assistant", "content": [{"type": "thinking", "thinking": "", "signature": "signature-removed"}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 9, "cache_creation_input_tokens": 8450, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 8450}, "output_tokens": 1, "service_tier": "standard", "inference_geo": "not_available"}, "input_transformations": [], "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "11111111-1111-4111-8111-111111111111", "uuid": "22222222-2222-4222-8222-000000000000", "timestamp": "2026-10-06T12:24:56.369Z", "request_id": "req_001", "thinking_duration_ms": 1012}
{"type": "assistant", "message": {"model": "claude-haiku-4-5-20251001", "id": "msg_001", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_001", "name": "mcp__expo__sandbox_exec", "input": {"command": "echo hi"}, "caller": {"type": "direct"}}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 9, "cache_creation_input_tokens": 8450, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 8450}, "output_tokens": 1, "service_tier": "standard", "inference_geo": "not_available"}, "input_transformations": [], "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "11111111-1111-4111-8111-111111111111", "uuid": "22222222-2222-4222-8222-000000000000", "timestamp": "2026-10-06T12:24:56.590Z", "request_id": "req_001", "tool_use_meta": [{"id": "toolu_01FxZWi8cmA45ua2osBST33c", "display_name": "Sandbox Exec", "server_display_name": "expo-stub"}]}
{"type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_001", "type": "tool_result", "content": [{"type": "text", "text": "stub ran: echo hi"}]}]}, "parent_tool_use_id": null, "session_id": "11111111-1111-4111-8111-111111111111", "uuid": "22222222-2222-4222-8222-000000000000", "timestamp": "2026-10-06T12:24:56.604Z", "tool_use_result": [{"type": "text", "text": "stub ran: echo hi"}]}
{"type": "system", "subtype": "thinking_tokens", "estimated_tokens": 50, "estimated_tokens_delta": 50, "session_id": "11111111-1111-4111-8111-111111111111", "uuid": "22222222-2222-4222-8222-000000000000"}
{"type": "system", "subtype": "thinking_tokens", "estimated_tokens": 156, "estimated_tokens_delta": 106, "session_id": "11111111-1111-4111-8111-111111111111", "uuid": "22222222-2222-4222-8222-000000000000"}
{"type": "assistant", "message": {"model": "claude-haiku-4-5-20251001", "id": "msg_002", "type": "message", "role": "assistant", "content": [{"type": "thinking", "thinking": "", "signature": "signature-removed"}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 8, "cache_creation_input_tokens": 206, "cache_read_input_tokens": 8450, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 206}, "output_tokens": 2, "service_tier": "standard", "inference_geo": "not_available"}, "input_transformations": [], "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "11111111-1111-4111-8111-111111111111", "uuid": "22222222-2222-4222-8222-000000000000", "timestamp": "2026-10-06T12:24:58.292Z", "request_id": "req_002", "thinking_duration_ms": 828}
{"type": "assistant", "message": {"model": "claude-haiku-4-5-20251001", "id": "msg_002", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "The command returned exactly: `stub ran: echo hi`\n\nThe tools I can call are:\n\n1. `mcp__expo__sandbox_exec` — Execute a shell command in a running sandbox\n2. `mcp__expo__sandbox_list` — List the sandboxes of the project"}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 8, "cache_creation_input_tokens": 206, "cache_read_input_tokens": 8450, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 206}, "output_tokens": 2, "service_tier": "standard", "inference_geo": "not_available"}, "input_transformations": [], "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "11111111-1111-4111-8111-111111111111", "uuid": "22222222-2222-4222-8222-000000000000", "timestamp": "2026-10-06T12:24:58.835Z", "request_id": "req_002"}
{"duration_api_ms": 4085, "stop_reason": "end_turn", "session_id": "11111111-1111-4111-8111-111111111111", "total_cost_usd": 0, "usage": {"input_tokens": 17, "cache_creation_input_tokens": 8656, "cache_read_input_tokens": 8450, "output_tokens": 310, "output_tokens_details": {"thinking_tokens": 180}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 8656, "ephemeral_5m_input_tokens": 0}, "inference_geo": "not_available", "iterations": [], "speed": "standard", "fallback_credit": null}, "modelUsage": {"claude-haiku-4-5-20251001": {"inputTokens": 17, "outputTokens": 310, "cacheReadInputTokens": 8450, "cacheCreationInputTokens": 8656, "webSearchRequests": 0, "costUSD": 0, "contextWindow": 200000, "maxOutputTokens": 32000, "thinkingTokens": 180, "canonicalModel": "claude-haiku-4-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "terminal_reason": "completed", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": false, "num_turns": 2, "subtype": "success", "api_error_status": null, "result": "The command returned exactly: `stub ran: echo hi`\n\nThe tools I can call are:\n\n1. `mcp__expo__sandbox_exec` — Execute a shell command in a running sandbox\n2. `mcp__expo__sandbox_list` — List the sandboxes of the project", "ttft_ms": 1696, "type": "result", "duration_ms": 4257, "uuid": "22222222-2222-4222-8222-000000000000", "ttft_stream_ms": 684, "time_to_request_ms": 85, "first_content_frame_ms": 684, "queued_turn_count": 0, "result_index": 0}
@@ -0,0 +1,14 @@
{"type": "thread.started", "thread_id": "01a10000-0000-7000-8000-000000000001"}
{"type": "turn.started"}
{"type": "error", "message": "Reconnecting... 2/5 (workspace routing discovery unauthorized (401))"}
{"type": "error", "message": "Reconnecting... 3/5 (workspace routing discovery unauthorized (401))"}
{"type": "error", "message": "Reconnecting... 4/5 (workspace routing discovery unauthorized (401))"}
{"type": "error", "message": "Reconnecting... 5/5 (workspace routing discovery unauthorized (401))"}
{"type": "item.completed", "item": {"id": "item_0", "type": "error", "message": "Falling back from WebSockets to HTTPS transport. workspace routing discovery unauthorized (401)"}}
{"type": "error", "message": "Reconnecting... 1/5 (workspace routing discovery unauthorized (401))"}
{"type": "error", "message": "Reconnecting... 2/5 (workspace routing discovery unauthorized (401))"}
{"type": "error", "message": "Reconnecting... 3/5 (workspace routing discovery unauthorized (401))"}
{"type": "error", "message": "Reconnecting... 4/5 (workspace routing discovery unauthorized (401))"}
{"type": "error", "message": "Reconnecting... 5/5 (workspace routing discovery unauthorized (401))"}
{"type": "error", "message": "workspace routing discovery unauthorized (401)"}
{"type": "turn.failed", "error": {"message": "workspace routing discovery unauthorized (401)"}}
@@ -0,0 +1,7 @@
{"type": "thread.started", "thread_id": "01a10000-0000-7000-8000-000000000001"}
{"type": "turn.started"}
{"type": "item.completed", "item": {"id": "item_0", "type": "agent_message", "text": "I will run these three operations in order and report each tool's actual result.\n"}}
{"type": "item.started", "item": {"id": "item_1", "type": "mcp_tool_call", "server": "expo", "tool": "sandbox_exec", "arguments": {"command": "echo hi"}, "result": null, "error": null, "status": "in_progress"}}
{"type": "item.completed", "item": {"id": "item_1", "type": "mcp_tool_call", "server": "expo", "tool": "sandbox_exec", "arguments": {"command": "echo hi"}, "result": null, "error": {"message": "MCP tool call requires approval, but approval policy is never"}, "status": "failed"}}
{"type": "item.completed", "item": {"id": "item_2", "type": "agent_message", "text": "1. Called `sandbox_exec` on the `expo` MCP server with command `echo hi`. Actual result:\n ```text\n MCP tool call requires approval, but approval policy is never\n ```\n The command did not run.\n\n2. Used `apply_patch` to create `probe.txt` with the single line `hello`. Actual result:\n ```text\n Script error:\n patch rejected: writing is blocked by read-only sandbox; rejected by user approval settings\n ```\n The file was not created.\n\n3. All callable tool names (being callable does not mean the current permissions allow running them):\n\n Direct tools:\n - `functions.exec`\n - `functions.wait`\n - `functions.request_user_input`\n - `functions.request_user_input_async`\n - `clock.sleep`\n\n Nested tools inside `functions.exec`:\n - `apply_patch`\n - `clock__curr_time`\n - `create_goal`\n - `get_goal`\n - `list_mcp_resource_templates`\n - `list_mcp_resources`\n - `mcp__expo__sandbox_exec`\n - `mcp__expo__sandbox_list`\n - `read_mcp_resource`\n - `update_goal`"}}
{"type": "turn.completed", "usage": {"input_tokens": 48856, "cached_input_tokens": 28416, "cache_write_input_tokens": 0, "output_tokens": 518, "reasoning_output_tokens": 0}}
@@ -0,0 +1,9 @@
{"type": "thread.started", "thread_id": "01a10000-0000-7000-8000-000000000001"}
{"type": "turn.started"}
{"type": "item.completed", "item": {"id": "item_0", "type": "agent_message", "text": "I will run these three operations in order and report the exact result of each.\n"}}
{"type": "item.started", "item": {"id": "item_1", "type": "mcp_tool_call", "server": "expo", "tool": "sandbox_exec", "arguments": {"command": "echo hi"}, "result": null, "error": null, "status": "in_progress"}}
{"type": "item.completed", "item": {"id": "item_1", "type": "mcp_tool_call", "server": "expo", "tool": "sandbox_exec", "arguments": {"command": "echo hi"}, "result": {"content": [{"type": "text", "text": "stub ran: echo hi"}], "structured_content": null}, "error": null, "status": "completed"}}
{"type": "item.started", "item": {"id": "item_2", "type": "mcp_tool_call", "server": "expo", "tool": "sandbox_list", "arguments": {}, "result": null, "error": null, "status": "in_progress"}}
{"type": "item.completed", "item": {"id": "item_2", "type": "mcp_tool_call", "server": "expo", "tool": "sandbox_list", "arguments": {}, "result": {"content": [{"type": "text", "text": "no sandboxes"}], "structured_content": null}, "error": null, "status": "completed"}}
{"type": "item.completed", "item": {"id": "item_3", "type": "agent_message", "text": "The exact results, in order:\n\n1. `expo sandbox_exec` with command `echo hi`:\n ```json\n {\"content\":[{\"type\":\"text\",\"text\":\"stub ran: echo hi\"}],\"isError\":false}\n ```\n\n2. `expo sandbox_list`:\n ```json\n {\"content\":[{\"type\":\"text\",\"text\":\"no sandboxes\"}],\"isError\":false}\n ```\n\n3. `apply_patch` to create `probe.txt` with the single line `hello` failed:\n ```text\n patch rejected: writing is blocked by read-only sandbox; rejected by user approval settings\n ```\n The file was not created."}}
{"type": "turn.completed", "usage": {"input_tokens": 49143, "cached_input_tokens": 28672, "cache_write_input_tokens": 0, "output_tokens": 354, "reasoning_output_tokens": 0}}
@@ -0,0 +1,11 @@
import path from 'node:path';
const realFs = jest.requireActual<typeof import('node:fs')>('node:fs');
/** The lines of a capture of an agent's stdout; ids and local details replaced, text in English. */
export function readFixtureLines(name: string): string[] {
return realFs
.readFileSync(path.join(__dirname, 'fixtures', name), 'utf8')
.split('\n')
.filter(line => line !== '');
}
@@ -0,0 +1,70 @@
import { SystemError } from '@expo/eas-build-job';
import { z } from 'zod';
import { type AgentRunProviderCredentials } from '../agentRunLease';
export const MCP_SERVER_NAME = 'expo';
export interface McpServer {
url: string;
headers: Record<string, string>;
}
export interface AgentInvocation {
args: string[];
env: Record<string, string>;
stdin: string;
secrets: string[];
}
export interface AgentLogMessage {
level: 'info' | 'warn' | 'error';
message: string;
}
export interface AgentCli {
packageSpec: string;
bin: string;
formatOutputLine(line: string): AgentLogMessage[];
}
export function parseJsonLine(line: string): unknown {
try {
return JSON.parse(line);
} catch {
return undefined;
}
}
export function tolerantArray<Element extends z.ZodType>(element: Element) {
// `.catch(undefined)` turns an element the schema rejects into `undefined`.
return z
.array(element.optional().catch(undefined))
.transform(elements =>
elements.filter((parsed): parsed is z.output<Element> => parsed !== undefined)
);
}
/** The text blocks of a content list, as both the Anthropic API and MCP shape them. */
export const ContentBlocksSchema = tolerantArray(
z.object({ type: z.literal('text'), text: z.string() })
);
export function contentBlocksText(blocks: z.output<typeof ContentBlocksSchema>): string {
return blocks.map(block => block.text).join('\n');
}
// Repeats the task because Codex's `resume --last` starts a new session when none was saved,
// and that session would otherwise have no task.
export function createContinuationPrompt(prompt: string): string {
return `Your previous run of this task stopped before it was finished. Continue it from where it stopped; do not start over. The task was:\n\n${prompt}`;
}
export function createProviderMismatchError(
cliName: string,
credentials: AgentRunProviderCredentials
): SystemError {
return new SystemError(
`The agent run uses ${cliName}, but Expo issued ${credentials.provider} credentials for it. This is a problem on our side; contact Expo support.`
);
}
@@ -0,0 +1,160 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { z } from 'zod';
import {
type AgentCli,
type AgentInvocation,
type AgentLogMessage,
ContentBlocksSchema,
MCP_SERVER_NAME,
type McpServer,
contentBlocksText,
createContinuationPrompt,
createProviderMismatchError,
parseJsonLine,
tolerantArray,
} from './agent';
import { type AgentRunProviderCredentials } from '../agentRunLease';
// The flags and the output format below are only known to hold for this version.
export const CLAUDE_CODE_CLI: AgentCli = {
packageSpec: '@anthropic-ai/[email protected]',
bin: 'claude',
formatOutputLine: formatClaudeCodeOutputLine,
};
export async function prepareClaudeCodeAsync({
homeDirectory,
minimalEnv,
mcpServer,
prompt,
credentials,
isResuming,
}: {
homeDirectory: string;
minimalEnv: Record<string, string>;
mcpServer: McpServer;
prompt: string;
credentials: AgentRunProviderCredentials;
isResuming: boolean;
}): Promise<AgentInvocation> {
if (credentials.provider !== 'anthropic') {
throw createProviderMismatchError('Claude Code', credentials);
}
const mcpConfigPath = path.join(homeDirectory, 'mcp.json');
await fs.writeFile(
mcpConfigPath,
JSON.stringify({ mcpServers: { [MCP_SERVER_NAME]: { type: 'http', ...mcpServer } } }),
{ mode: 0o600 }
);
return {
args: [
'-p',
...(isResuming ? ['--continue'] : []),
// Print mode requires `--verbose` for `stream-json`.
...['--output-format', 'stream-json', '--verbose'],
// No built-in tools. Print mode denies any tool without an allow rule, so the MCP server
// gets one.
...['--tools', '', '--allowedTools', `mcp__${MCP_SERVER_NAME}`],
...['--mcp-config', mcpConfigPath, '--strict-mcp-config'],
],
env: {
...minimalEnv,
CLAUDE_CODE_OAUTH_TOKEN: credentials.accessToken,
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
},
stdin: isResuming ? createContinuationPrompt(prompt) : prompt,
secrets: [credentials.accessToken],
};
}
const AssistantBlockSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('text'), text: z.string() }),
z.object({
type: z.literal('tool_use'),
name: z.string(),
input: z.record(z.string(), z.unknown()),
}),
]);
const ToolResultBlockSchema = z.object({
type: z.literal('tool_result'),
content: z.union([z.string(), ContentBlocksSchema]).default(''),
is_error: z.boolean().optional(),
});
const ClaudeCodeEventSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('system'),
subtype: z.literal('init'),
mcp_servers: tolerantArray(z.object({ name: z.string(), status: z.string() })),
}),
z.object({
type: z.literal('assistant'),
message: z.object({ content: tolerantArray(AssistantBlockSchema) }),
}),
z.object({
type: z.literal('user'),
message: z.object({ content: tolerantArray(ToolResultBlockSchema) }),
}),
z.object({
type: z.literal('result'),
subtype: z.string(),
is_error: z.boolean(),
result: z.string().optional(),
errors: z.array(z.string()).optional(),
}),
]);
function formatClaudeCodeOutputLine(line: string): AgentLogMessage[] {
const json = parseJsonLine(line);
if (json === undefined) {
return [{ level: 'info', message: line }];
}
const event = ClaudeCodeEventSchema.safeParse(json);
if (!event.success) {
return [];
}
switch (event.data.type) {
case 'system':
return event.data.mcp_servers.map(server =>
server.status === 'connected'
? { level: 'info', message: `MCP server ${server.name} is connected.` }
: {
level: 'warn',
message: `MCP server ${server.name} is not connected (${server.status}).`,
}
);
case 'assistant':
return event.data.message.content.map(block => {
switch (block.type) {
case 'text':
return { level: 'info', message: `Agent: ${block.text}` };
case 'tool_use':
return {
level: 'info',
message: `Tool call: ${block.name} ${JSON.stringify(block.input)}`,
};
}
});
case 'user':
return event.data.message.content.map(block => {
const text =
typeof block.content === 'string' ? block.content : contentBlocksText(block.content);
return block.is_error
? { level: 'warn', message: `Tool error: ${text}` }
: { level: 'info', message: `Tool result: ${text}` };
});
case 'result': {
// `result` repeats the agent's last text block, so on success there is nothing to add.
const { is_error, result, errors, subtype } = event.data;
return is_error
? [
{
level: 'error',
message: `Agent failed: ${result || errors?.join('\n') || subtype}`,
},
]
: [];
}
}
}
@@ -0,0 +1,185 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { z } from 'zod';
import {
type AgentCli,
type AgentInvocation,
type AgentLogMessage,
ContentBlocksSchema,
MCP_SERVER_NAME,
type McpServer,
contentBlocksText,
createContinuationPrompt,
createProviderMismatchError,
parseJsonLine,
} from './agent';
import { type AgentRunProviderCredentials } from '../agentRunLease';
// The flags, settings and output format below are only known to hold for this version.
export const CODEX_CLI: AgentCli = {
packageSpec: '@openai/[email protected]',
bin: 'codex',
formatOutputLine: formatCodexOutputLine,
};
export async function prepareCodexAsync({
homeDirectory,
minimalEnv,
mcpServer,
prompt,
credentials,
isResuming,
}: {
homeDirectory: string;
minimalEnv: Record<string, string>;
mcpServer: McpServer;
prompt: string;
credentials: AgentRunProviderCredentials;
isResuming: boolean;
}): Promise<AgentInvocation> {
if (credentials.provider !== 'openai') {
throw createProviderMismatchError('Codex', credentials);
}
const codexHomeDirectory = path.join(homeDirectory, '.codex');
await fs.mkdir(codexHomeDirectory, { recursive: true });
await fs.writeFile(
path.join(codexHomeDirectory, 'config.toml'),
[
'approval_policy = "never"',
// Codex always offers `apply_patch`.
'sandbox_mode = "read-only"',
'web_search = "disabled"',
'check_for_update_on_startup = false',
'',
'[features]',
'shell_tool = false',
'view_image = false',
'image_generation = false',
// Apps and plugins installed on the credential owner's ChatGPT account.
'apps = false',
'plugins = false',
'',
// A subagent's session could otherwise be the one `resume --last` picks.
'[agents]',
'enabled = false',
'',
'[analytics]',
'enabled = false',
'',
`[mcp_servers.${MCP_SERVER_NAME}]`,
// JSON strings are valid TOML basic strings.
`url = ${JSON.stringify(mcpServer.url)}`,
// `approval_policy` would otherwise refuse the server's tools that are not read-only.
'default_tools_approval_mode = "approve"',
'',
`[mcp_servers.${MCP_SERVER_NAME}.http_headers]`,
...Object.entries(mcpServer.headers).map(
([name, value]) => `${JSON.stringify(name)} = ${JSON.stringify(value)}`
),
'',
].join('\n'),
{ mode: 0o600 }
);
await fs.writeFile(
path.join(codexHomeDirectory, 'auth.json'),
JSON.stringify({
auth_mode: 'chatgptAuthTokens',
tokens: {
id_token: credentials.idToken,
access_token: credentials.accessToken,
// www keeps the refresh token.
refresh_token: '',
account_id: credentials.accountId,
},
// Codex sends no bearer token unless this is set.
last_refresh: new Date().toISOString(),
}),
{ mode: 0o600 }
);
return {
args: [
'exec',
// Without it Codex skips settings it does not know, leaving on whatever they turn off.
'--strict-config',
...['--json', '--skip-git-repo-check'],
...(isResuming ? ['resume', '--last', '-'] : []),
],
env: minimalEnv,
stdin: isResuming ? createContinuationPrompt(prompt) : prompt,
secrets: [credentials.accessToken, credentials.idToken],
};
}
const CodexItemSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('agent_message'), text: z.string() }),
z.object({
type: z.literal('mcp_tool_call'),
server: z.string(),
tool: z.string(),
arguments: z.record(z.string(), z.unknown()).nullable(),
result: z.object({ content: ContentBlocksSchema }).nullable(),
error: z.object({ message: z.string() }).nullable(),
status: z.string(),
}),
z.object({ type: z.literal('error'), message: z.string() }),
]);
const CodexEventSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('item.started'), item: CodexItemSchema.optional().catch(undefined) }),
z.object({
type: z.literal('item.completed'),
item: CodexItemSchema.optional().catch(undefined),
}),
z.object({ type: z.literal('error'), message: z.string() }),
z.object({ type: z.literal('turn.failed'), error: z.object({ message: z.string() }) }),
]);
function formatCodexOutputLine(line: string): AgentLogMessage[] {
const json = parseJsonLine(line);
if (json === undefined) {
return [{ level: 'info', message: line }];
}
const event = CodexEventSchema.safeParse(json);
if (!event.success) {
return [];
}
switch (event.data.type) {
case 'item.started': {
const item = event.data.item;
if (item?.type !== 'mcp_tool_call') {
return [];
}
return [
{
level: 'info',
message: `Tool call: ${item.server}.${item.tool} ${JSON.stringify(item.arguments ?? {})}`,
},
];
}
case 'item.completed':
return formatCompletedItem(event.data.item);
case 'error':
return [{ level: 'warn', message: `Error: ${event.data.message}` }];
case 'turn.failed':
return [{ level: 'error', message: `Agent failed: ${event.data.error.message}` }];
}
}
function formatCompletedItem(
item: z.output<typeof CodexItemSchema> | undefined
): AgentLogMessage[] {
switch (item?.type) {
case 'agent_message':
return [{ level: 'info', message: `Agent: ${item.text}` }];
case 'mcp_tool_call': {
const resultText = contentBlocksText(item.result?.content ?? []);
return item.status === 'failed'
? [{ level: 'warn', message: `Tool error: ${item.error?.message ?? resultText}` }]
: [{ level: 'info', message: `Tool result: ${resultText}` }];
}
case 'error':
return [{ level: 'warn', message: `Error: ${item.message}` }];
case undefined:
return [];
}
}
@@ -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');
}
}
});
Loaded 100 of 208 files, more files were not shown because too many files have changed in this diff. Show more