Compare commits

...
Author SHA1 Message Date
Stanisław Chmiela e507735166 [worker] Add a packaged startup wrapper 2026-10-22 13:17:51 +02:00
Ash Wu 9b54c203ee [build-tools] retry download_build network failures and report their cause (#4690) 2026-10-22 15:30:47 +08:00
Ramon Claudio a0fe3ff2c3 [eas-cli][build-tools] Bump Node.js to 22.23.4 (#4714)
* [eas-cli] Bump Node.js to 22.23.4

* [eas-cli] Add changeset

* [build-tools] Bump Node.js to 22.23.4
2026-10-21 18:38:36 -04:00
Brent VatneandClaude Opus 5.5 fb383d818b [eas-cli] make command descriptions lowercase without trailing periods (#4385)
* [eas-cli] make command descriptions lowercase without trailing periods

Most command descriptions in eas --help already start lowercase and end
without a period; align the remaining 11 capitalized and 12 period-terminated
ones with that style. Proper nouns, [EXPERIMENTAL] prefixes, and later
paragraphs of multi-line descriptions are unchanged.

* [eas-cli] Drop trailing periods from simulator command descriptions

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-21 14:27:33 -07:00
Stanisław Chmiela c07c5edba9 [build-tools] Add max_duration_seconds to sandboxes (#4702)
# Why

I want to set up billing and limits for sandboxes so we need to be able to say how long can a sandbox run for.

# How

Added simple `max_duration_seconds` like we have in agent runs and device run sessions.

# Test Plan

CI should pass.
2026-10-21 17:18:26 +02:00
Brent Vatne 56646b7436 [eas-cli] Show simulator session errors in simulator:get (#4694)
Look up why a failed simulator session failed in both simulator:start and
simulator:get. For unknown errors, read the failed step's cause from the job
run log, using only the masked log message, with the download bounded by size
and time. Strip terminal control characters before printing.
2026-10-20 14:00:29 -07:00
Brent Vatne f775b10549 [eas-cli] Show why a simulator session failed to start (#4693)
* [eas-cli] Show why a simulator session failed to start

simulator:start only printed the session status when a session errored
before it was ready. The reason is already on the session's job run, so
fetch turtleJobRun.errors and print each message (and docs link) on its
own line.

* [eas-cli] Read simulator job run errors only on failure, and from logs for unknown errors

Fetch job run errors with a separate query when a session fails instead of
on every poll of the shared session query. When the job run only reports an
unknown error, print the failed step errors from its logs. Also show the
errors when a session fails after it is ready.

* [eas-cli] Show only structured job run errors in simulator:start

Drop the job run log fallback from this change, which printed unmasked
err.message lines, read logs for stopped sessions and had no timeout or size
limit. The bounded, masked log fallback lands in the simulator:get follow-up.

* [eas-cli] Bound and quiet the simulator job run errors lookup

Give the lookup a 10s deadline and the session's Ctrl+C signal, and cancel
its request by unsubscribing from urql, so it can't hold up stopping the
session. Don't retry it or print the transient-error message, since it's
best-effort. Cap each error message at 500 characters.
2026-10-20 13:51:21 -07:00
Brent Vatne c756c5d0b5 [worker] Mask secrets in every field of uploaded log records (#4698)
* [worker] Mask secrets in logged errors

The upload transform masked only each record's msg, so a serialized err
(message, stack and other fields) was uploaded with secrets intact. That
includes unknown build phase failures, which log { err } with an empty msg.
Mask the strings inside err on a copy, since bunyan shares the record with
other streams.

* [worker] Mask secrets in every field of uploaded log records

Mask all fields except bunyan's metadata, not only err: other fields such as
error, data and the job dump also carried secrets. Round-trip each value
through JSON first so a toJSON method can't bring back the original, mask keys
and numbers, and replace values nested too deeply instead of uploading them
unmasked.
2026-10-20 13:51:17 -07:00
Brent VatneandClaude Opus 5.5 9f674b1d19 [eas-build-job][build-tools] Check Android launch inputs before the session starts (#4696)
* [build-tools] Check Android launch inputs before the emulator boots

Agent-device sessions checked Android launch inputs only when launching,
after the emulator booted and the build installed. Check NUL characters
and the command length at the start of the step, as on iOS, leaving room
for the launch component that is only known after install.

* Add changeset for early Android launch input checks

* [eas-build-job] Share the Android launch input checks

Move the Android shell quoting and the early launch input checks into
@expo/eas-build-job, so www can check launch inputs when a session is
created, for every session type.

* [eas-build-job] Check only the known part of the Android launch command early

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-20 13:40:44 -07:00
Expo CI 6f33a4d2a4 bump STAGING and PRODUCTION to 25.3.0 in cli-versions.json 2026-10-20 19:22:35 +00:00
Expo CI 44b63e5a7d v25.3.0 2026-10-20 19:17:05 +00:00
Krystof Woldrich 42e4fa8a7c [eas-cli] Add --system-image to eas simulator:start (#4500) 2026-10-20 19:09:09 +02:00
Ash Wu e4bb181802 [build-tools] Wait up to five minutes for Google Play to process an uploaded bundle (#4700) 2026-10-20 17:30:02 +08:00
Brent Vatne 53ac936b11 [build-tools] Quote Android launch arguments for the device shell (#4695)
* [build-tools] Quote Android launch arguments for the device shell

adb shell joins its arguments with spaces and the device shell parses the
result again, so a launch argument such as "hello world" reached am start
as two words and the launch failed. Quote each word, reject commands that
adb cannot carry, and reject empty arguments that are not extra values.

* [build-tools] Pass empty Android launch arguments through and reject NUL

Guessing which am start option an empty argument belongs to rejected
valid input such as -e key "" and -d "", and accepted input that am
start then rejects. Pass empty arguments through, and explain a failed
launch when launch_args has an empty element. Reject NUL characters,
which spawn cannot pass, with a user error.
2026-10-19 21:29:47 -07:00
Gabe DebesandGPT-6.1-Sol c0d3f4f935 [build-tools] Forward network capture through local egress (#4598)
* [build-tools] Forward network capture through local egress

Co-Authored-By: GPT-6.1-Sol <[email protected]>

* [build-tools] Remove unused capture proxy override

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

---------

Co-authored-by: GPT-6.1-Sol <[email protected]>
2026-10-19 20:46:19 -07:00
Gabe DebesandGPT-6.1-Sol 5ec3d84fcb [build-tools] Let serve-sim start the iOS Simulator for Appium (#4603)
* [build-tools] Let serve-sim start the iOS Simulator for Appium

Co-Authored-By: GPT-6.1-Sol <[email protected]>

* [build-tools] Mock Appium app metadata at its own boundary

* [build-tools] Stop Appium setup when Simulator startup fails

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

* [build-tools] Retry cancelled shared FFmpeg setup for surviving callers

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

* [build-tools] Test Appium preview settings after startup rebase

---------

Co-authored-by: GPT-6.1-Sol <[email protected]>
2026-10-19 19:12:03 -07:00
Gabe DebesandGPT-6.1-Sol d0cb5a2d67 [build-tools] Let serve-sim start the iOS Simulator for Argent (#4602)
* [build-tools] Let serve-sim start the iOS Simulator for Argent

Co-Authored-By: GPT-6.1-Sol <[email protected]>

* [build-tools] Use session helpers for Argent startup

* [build-tools] Mock Argent app metadata at its own boundary

* [build-tools] Stop Argent setup when Simulator startup fails

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

---------

Co-authored-by: GPT-6.1-Sol <[email protected]>
2026-10-19 17:12:45 -07:00
Gabe Debes aae7d13ac0 [build-tools] Upload iOS Simulator session screen recording metadata (#4689)
* [build-tools] Upload screen recording metadata

* [build-tools] Name recording artifacts metadata

* [build-tools] Check companion metadata upload names

* [build-tools] Report metadata upload failures separately
2026-10-19 16:22:10 -07:00
Gabe DebesandClaude Opus 5.5 d9b1f866d4 [build-tools] Reset the session idle timer on serve-sim preview input (#4691)
Poll serve-sim's event log and reset the existing idle timeout when a
new preview input event appears.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 15:07:16 -07:00
William Grosset a03dca45eb [eas-cli] Add a checkout prompt experiment to the Free plan build limit error (#4633)
* [eas-cli] Add a checkout prompt experiment to the Free plan build limit error

* [eas-cli] Reuse the account billing URL helper in the checkout experiment
2026-10-19 10:08:05 -07:00
Expo CI f949f3430e bump STAGING and PRODUCTION to 25.2.0 in cli-versions.json 2026-10-17 23:28:11 +00:00
Expo CI e61a563b70 v25.2.0 2026-10-17 23:23:01 +00:00
150cd21174 [eas-cli] Add simulator:control for remote simulator actions (#4641)
* [eas-cli] Add simulator:action command

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

* [eas-cli] Fold simulator actions into one table

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

* [eas-cli] Give the simulator API lookup its own timeout

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

* [eas-cli] Drop event-log from simulator:action

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

* [eas-cli] Rename to simulator:control and add sidebar actions

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

* [eas-cli] Drive simulator:control from the serve-sim action catalog

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

* [eas-cli] Fix value order, upload cleanup and output handling in simulator:control

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

* [eas-cli] Check output paths and local files before simulator:control runs

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

* [eas-cli] Upload only when the catalog condition matches

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

* [eas-cli] Send upload IDs and JSON lists from simulator:control

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

* [eas-cli] Complete the simulator control session fixture

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

* [eas-cli] Close simulator control after discovery failure

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

* [eas-cli] Keep simulator action descriptions on one line

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

* [eas-cli] List simulator actions by default

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

* [eas-cli] Reject empty simulator control session IDs

Document the separator for values beginning with --.

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

* [eas-cli] Clarify local screenshot output usage

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

* [eas-cli] Compact simulator action discovery and add action help

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-17 15:38:09 -07:00
Gabe DebesandCodex 4b9e792e91 [eas-cli] Add remote simulator host action client (#4642)
* [eas-cli] Add remote simulator host action client

* [eas-cli] Secure simulator action setup and close rejected upgrades

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

* [eas-cli] Clarify alternative simulator action inputs

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

* [eas-cli] Show each alternative action input group once

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

* [eas-cli] Explain token failures during simulator connection setup

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

---------

Co-authored-by: Codex <[email protected]>
2026-10-17 15:38:09 -07:00
William Grosset 0199cd9331 [eas-cli] Add an agent guidance experiment to the Free plan build limit error (#4631) 2026-10-16 16:27:33 -07:00
William Grosset ffe6bda88d [eas-cli] Add experimentation client (#4558)
* [eas-cli] Add experimentation client

* [eas-cli] Document experimentation context order and override semantics

* [eas-cli] Take the account unit from the command instead of the app config

* [eas-cli] Skip experiments in CI

* [eas-cli] Make the GraphQL request timeout take effect

* [eas-cli] Document which experiment units are shared across surfaces

* [eas-cli] Test that non-timeout fetch errors pass through the timeout wrapper

* [eas-cli] Fetch the experimentation config lazily on first variant read

* [eas-cli] Cap the actor lookup in the experimentation context field
2026-10-16 15:22:39 -07:00
Gabe DebesandGPT-6.1-Sol 904859b824 [build-tools] Let serve-sim start the iOS Simulator for agent-device (#4544)
* [build-tools] Let serve-sim start the iOS Simulator for agent-device

Co-Authored-By: GPT-6.1-Sol <[email protected]>

* [build-tools] Use session helpers for agent-device startup

* [build-tools] Read agent-device app metadata from the artifact helper

* [build-tools] Pass the preview token in the startup regression

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

---------

Co-authored-by: GPT-6.1-Sol <[email protected]>
2026-10-16 14:45:27 -07:00
Expo CI 3fd966bb9e bump STAGING and PRODUCTION to 25.1.1 in cli-versions.json 2026-10-16 21:44:06 +00:00
Expo CI aa9f5370d4 v25.1.1 2026-10-16 21:35:07 +00:00
Gabe Debes 60a0b483eb [ci] Generate build-tools GraphQL types before typechecking (#4663) 2026-10-16 23:17:55 +02:00
Szymon Dziedzic 070f15c633 [build-tools] Remove the GraphQL fallbacks for device run session jobs (#4638) 2026-10-16 23:00:28 +02:00
Expo CI c8aa5eb05d v25.1.0 2026-10-16 19:51:06 +00:00
Krystof WoldrichandClaude Fable 5.1 68596f8c0c [eas-cli] add --cors-origin to simulator:start (#4658)
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-16 21:23:09 +02:00
Krystof WoldrichandClaude Fable 5.1 1e38ada929 [eas-cli] add --frame-ancestor to simulator:start (#4656)
* [eas-cli] add --frame-ancestor to simulator:start and print the embed URL

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

* [eas-cli] cover the embed output fallbacks

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

* [eas-cli] format the embed output tests

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

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-16 21:23:09 +02:00
Krystof WoldrichandClaude Fable 5.1 d52629e9f3 [build-tools] pass cors_origins session input to the preview hosts (#4657)
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-16 20:40:20 +02:00
Krystof WoldrichandClaude Fable 5.1 bed6354ef5 [build-tools] pass frame_ancestors session input to the preview hosts (#4655)
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-16 20:05:51 +02:00
Stanisław Chmiela 9aac1a0dff [build-tools] Retry commits while Google Play processes bundles (#4651)
# Why

https://sentry.io/organizations/expoio/issues/7792509578/events/f6692b78705a40ab9380fd177c265b35/

# How

Supposedly Fastlane retries committing.

# Test Plan

CI should pass. Land and see that the error no longer occurs.
2026-10-16 15:26:32 +02:00
Stanisław Chmiela c1c2b748b4 [build-tools] Report Google Play error responses to Sentry (#4652)
# Why

Google Play submissions sometimes fail with generic errors like `Precondition check failed.` (HTTP 400, `FAILED_PRECONDITION`). We currently drop everything except `error.message` and `errors[].reason`, so neither the user nor we can tell what Google actually objected to.

This replaces #4650, which also put Google's details into user-facing messages and structured logs, and added a redacting sanitizer. That made the code a lot more complex. This PR keeps build logs unchanged and only makes the raw response available in Sentry. Once real payloads show up there, we can add targeted classifiers to `mapGooglePlayError` with clear user-facing messages.

# How

- `GooglePlayApiError` now keeps:
  - the JSON error response body, as a string capped at 8 KB
  - the request URL path, without the query (the upload session query contains a secret)

  Non-JSON bodies are still dropped, as before.
- `GooglePlayApiError.getErrorMetadata()` finds the API error in the cause chain, through retry `AggregateError`s and commit-outcome-unknown wrappers. It returns `googlePlayRequestPath` and `googlePlayErrorResponse`.
- `mapGooglePlayError` and `GooglePlayCommitOutcomeUnknownError` attach that as `ExpoError.metadata`. The worker already sends it to Sentry as `extras.errorMetadata`, and it is not part of the external build error.
- The body is stored as a string because `@sentry/node` 7 normalizes extras to depth 3, so nested `details` would otherwise show up as `[Object]`.

User-facing messages are unchanged.

# Test Plan

- New tests in `GooglePlaySubmission.test.ts`:
  - a `FAILED_PRECONDITION` commit with `details` ends up in metadata but not in the message
  - metadata survives retries when the commit outcome is unknown
  - the upload-session query and non-JSON bodies are not stored
- `yarn test src/steps/utils/android src/steps/functions/__tests__/submitToGooglePlay.test.ts` (153 passed), plus `yarn typecheck`, `yarn lint`, `yarn fmt:check`

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-16 15:17:05 +02:00
Gabe Debes 7b39b92df8 [build-tools] Experiment with 4 GiB RAM for Android device sessions (#4640) 2026-10-16 12:57:51 +02:00
Stanisław Chmiela 929905ba7e [build-tools] Retry transient Google Play API requests (#4629)
# Why

While testing submissions on staging I found Google APIs flake sometimes.

# How

Wrapped requests with auto-retry when we can be sure we can retry.

# Test Plan

CI should pass.
2026-10-16 12:01:48 +02:00
Philippe Loulidi ef7fceba86 [eas-cli] ENG-27937 Show EAS Simulator minutes in eas account:usage (#4583)
* [eas-cli] Show EAS Simulator minutes in eas account:usage

* [eas-cli] report unavailable Simulator usage estimates

* [eas-cli] Replace Simulator usage changelog entry with changeset
2026-10-15 17:21:22 -06:00
Expo CI c2dc766c38 bump STAGING and PRODUCTION to 25.0.0 in cli-versions.json 2026-10-15 23:01:41 +00:00
Expo CI 785cfe2d53 v25.0.0 2026-10-15 22:42:24 +00:00
Gabe DebesandCodex 18a008a39a [eas-cli] Default simulator previews to authenticated links and add --public-link option (#4619)
* [eas-cli] default simulator previews to authenticated links

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

* [eas-cli] Persist simulator preview sharing preference

* [eas-cli] omit direct preview API addresses from private JSON

* [eas-cli] use saved simulator sharing preference in get

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

* [eas-cli] warn before printing public simulator links

Edited-by: Codex

---------

Co-authored-by: Codex <[email protected]>
2026-10-15 15:34:10 -07:00
Will Schurman 🦦 f5be6b8d88 [eas-cli] Use effectivePermissions for logic about capabilities and account:view display (#4637) 2026-10-15 15:28:36 -07:00
Gabe DebesandCodex c4064ea9ba [build-tools] Use private links for private simulator sessions (#4620)
* [build-tools] Respect simulator preview sharing preferences

* [build-tools] Add PR attribution to changelog entry

* [build-tools] clarify preview Share fallback and verify probe cleanup

* [build-tools] pass preview sharing flags directly

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

* [build-tools] clarify public preview logging coverage

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

* [build-tools] redact preview tokens from host diagnostics

Edited-by: Codex

---------

Co-authored-by: Codex <[email protected]>
2026-10-15 14:25:11 -07:00
Will Schurman 🦦 f1dda5df94 [eas-cli] Update error message for scoped VC in EAS CLI now that scoped can be used outside build (#4636)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

Now that we're testing out permission scoping for tokens, EAS CLI needs to handle these cases more gracefully. First step is this error message, which previously assumed scoped tokens would only be used within build jobs.

# How

Update it to have a separate error message for scoped tokens outside of a build job. Also correct "user"/"account" terminology discrepancy.

# Test Plan

`yarn start`
`export EXPO_STAGING=1`
`neas login`, select viewer scope
run `neas update`
see
```
You don't have the required permissions to perform this operation.

This can sometimes happen if you are logged in as incorrect user or if the permissions granted during authentication are insufficient to perform the operation.
Run eas whoami to check the user you are logged in as and your permission grant.
Run eas login to change the user or permission grant.

Original error message: Entity Not Authorized: Entity not authorized: AppFingerprintEntity[00000000-0000-0000-0000-000000000000] (viewer = ScopedAccountActorViewerContext[RegularUserViewerContext[b99c08be-7584-4358-b8e4-aef050972708], scopes=expo_account:c344bcd9-c79d-4c0b-96fb-940d947cac42(RUN,VIEW);expo_account:55ebf7e2-f986-4b54-950d-55d38f355e29(RUN,VIEW)], action = CREATE, ruleIndex = -1)
Request ID: 410d2f30-c4fa-4575-b21a-6012f6c1e93f
```
2026-10-15 13:21:59 -07:00
Szymon Dziedzic 4ec2a5fe9e [build-tools] Call the device run session worker API with the session token (#4615)
* [build-tools] Call the device run session worker API with the session token

When a device run session job has the EAS_DEVICE_RUN_SESSION_TOKEN environment
secret, send every session-related www call to /v2/device-run-session-worker/*
with the expo-device-run-session-token header instead of GraphQL or the
account-wide REST routes. Without the token, keep the current calls.

The worker also reports Datadog metrics for these jobs through
/v2/device-run-session-worker/metrics.

* [build-tools] Download session builds without the robot token

In a device run session job with a session token, the build archive URL comes
from the device run session worker API and points to an artifact route that
needs no auth. After www stops minting EXPO_TOKEN for session jobs,
robotAccessToken is the session token, and www rejects it as a bearer
credential on every route. Send no Authorization header in that case.

* [build-tools] Match the device run session worker API response contract

www returns entity values from the worker API: session statuses are
lowercase and hyphenated, and GET /application returns
{ platform: 'ios' | 'android', build: { id, gitCommitHash,
applicationArchiveUrl } | null } without app slugs. Map the statuses to the
GraphQL values that callers compare, and name screenshots without an app
slug in worker API mode.

The worker now resolves duplicate EAS_DEVICE_RUN_SESSION_TOKEN secrets like
the build env does (the last one wins), so metrics and steps use the same
token. Add tests for the token propagation from job secrets to the worker API
client and for the download_build and start_agent_device_session call sites.

* [build-tools] Take the device run session platform from the worker runtime

* [build-tools] Report device run session build lookups as system errors and time out hung session requests
2026-10-15 19:24:06 +02:00
Szymon Dziedzic 1068fab6ac [build-tools] Keep NGROK_AUTHTOKEN out of device run session child processes (#4614) 2026-10-15 19:11:11 +02:00
Gabe DebesandGPT-6.1-Sol 3e00e86e5a [build-tools] Let serve-sim start the iOS Simulator for web preview (#4601)
* [build-tools] Let serve-sim start the iOS Simulator for web preview

Co-Authored-By: GPT-6.1-Sol <[email protected]>

* [build-tools] Keep web-preview startup inside the session host

* [build-tools] Mock app metadata at its own boundary

* [build-tools] Stop startup when preview readiness fails

---------

Co-authored-by: GPT-6.1-Sol <[email protected]>
2026-10-15 09:28:11 -07:00
Gabe DebesandGPT-6.1-Sol 69a4a06320 [build-tools] Add serve-sim Simulator startup helpers (#4605)
* [build-tools] Add serve-sim Simulator startup helpers

Co-Authored-By: GPT-6.1-Sol <[email protected]>

* [build-tools] Keep generic boot and install steps independent

* [build-tools] Keep iOS app metadata independent of serve-sim

* [build-tools] Cover serve-sim startup validation and failures

---------

Co-authored-by: GPT-6.1-Sol <[email protected]>
2026-10-15 09:28:10 -07:00
Gabe DebesandGPT-6.1 Sol 219df86f88 [build-tools] Separate Argent and preview logs (#4587)
Co-authored-by: GPT-6.1 Sol <[email protected]>
2026-10-15 09:28:10 -07:00
Gabe DebesandGPT-6.1 Sol 50e1f2aea5 [build-tools] Separate Appium and preview logs (#4586)
Co-authored-by: GPT-6.1 Sol <[email protected]>
2026-10-15 09:28:09 -07:00
Krystof WoldrichandClaude Opus 5.5 4400b93ed3 [build-tools] Start expo-device-hub with a session token and the website origins (#4578)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:37:44 +02:00
Ash Wu 52c1a4ce54 [build-tools][worker] Mask the credentials eas/run_agent leases with the build log secrets filter (#4624)
The worker's secrets filter takes secrets added while the job runs through
addBuildLogMask, which reaches steps via the build context's runtimeApi.
eas/run_agent passes each leased token to it and drops its own masking.

Masking marks every occurrence of every secret on the original message and
masks once, so a secret that is a substring of another no longer breaks the
longer match. The log buffer that feeds error detection masks with the same
patterns.
2026-10-15 20:47:34 +08:00
Stanisław Chmiela f97e6ecfa8 [build-tools] Polish some Google Play submission messages (#4625)
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

I want to polish Google Play submission messages a bit.

# How

Tested the code with a few more edge cases and handled them a bit better.

# Test Plan

CI should pass.
2026-10-15 10:36:30 +02:00
Gabe DebesandGPT-6.1 Sol 6d8c29cc34 [build-tools] Split agent-device and simulator preview logs (#4585)
* [build-tools] Separate agent-device and preview logs

* [build-tools] Link agent-device changelog entry

Co-Authored-By: GPT-6.1 Sol <[email protected]>

* [build-tools] Drain preview encoder setup before ending logs

* [build-tools] Move preview encoder scripts into test fixtures

* [build-tools] Keep encoder shutdown tests focused

---------

Co-authored-by: GPT-6.1 Sol <[email protected]>
2026-10-14 22:09:29 -07:00
Gabe Debes 2630625440 [build-tools] Stream preview output into simulator session step (#4525) 2026-10-14 20:34:18 -07:00
Gabe Debes 7ebd9f1a97 [build-tools] Avoid cold runtime scans with preinstalled WDA (#4618)
* [build-tools] Launch preinstalled WDA in iOS remote sessions

Edited-by: Codex

* [build-tools] Add preinstalled WDA changelog entry

Edited-by: Codex

* [build-tools] Bound the WDA downloader process tree

* [build-tools] Move WDA startup release note to a changeset
2026-10-14 15:30:48 -07:00
Douglas LowderandClaude Opus 5.5 8545feb433 [ci] Version packages and generate changelogs with changesets (#4580)
* [ci] Version packages and generate changelogs with changesets

PRs now add a changeset in .changeset/ instead of editing CHANGELOG.md.
The manual release workflow runs scripts/src/versionPackages.ts, which
runs `changeset version` for one fixed group of all packages, writes the
root CHANGELOG.md section with the existing emoji categories, and then
commits, tags, and pushes vX.Y.Z. The tag-driven publish is unchanged,
except that it reads the changelog section from the tagged commit.

The PR check validates changesets with @changesets/parse and rejects
direct CHANGELOG.md edits. The "no changelog" label still skips it.

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

* Correct "no changelog" documentation in .changeset/README.md

* [ci] Fail on CHANGELOG.md edits even with the "no changelog" label

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

* [ci] Skip the changeset check for Dependabot PRs

Dependabot PRs only update dependencies, so they need no changeset, and
their read-only token cannot post the PR comment. The job skips both for
them. The CHANGELOG.md check still runs. The workflow now sets explicit
token permissions.

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

* Remove the "## main" block from CHANGELOG.md

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 14:57:15 -07:00
Expo CI 14e14e3017 bump STAGING and PRODUCTION to 24.14.0 in cli-versions.json 2026-10-14 21:18:01 +00:00
Expo CI 338d367282 update CHANGELOG.md for v24.14.0 2026-10-14 21:10:57 +00:00
Expo CI 102f06cc50 v24.14.0 2026-10-14 21:08:28 +00:00
Gabe Debes f96c266645 [build-tools] Allow iOS recordings to finish before shutdown (#4626) 2026-10-14 13:19:11 -07:00
Tomasz Mazur 5739556d67 [build-tools] download the upterm binary through build cache (#4442) 2026-10-14 14:20:52 +00:00
Ash Wu 821dcd1a96 [eas-cli] add a platform filter and per-platform totals to workflow:insights:maestro (#4613)
* [eas-cli] regenerate GraphQL types

* [eas-cli] add a platform filter and per-platform totals to workflow:insights:maestro
2026-10-14 16:36:04 +08:00
Byron Karlen 3b59a744da [docs] Add protected resources page (#4616) 2026-10-13 15:56:16 -07:00
Szymon DziedzicandClaude Opus 5.5 73edec4d68 [build-tools] Disable Argent video watermark in remote sessions (#4574)
* [build-tools] Disable Argent video watermark in remote sessions

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

* [build-tools] Add changelog entry for Argent watermark change

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

* [build-tools] Continue the Argent session when disabling the video watermark fails

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 16:05:03 +02:00
Stanisław Chmiela 13fe586f3f [build-tools] Add native Google Play submission function (#4512)
# Why

To remove submission service altogether we also need to support Google Play uploads here.

# How

Adds `eas/submit_to_google_play`: load the service account, create an edit, upload the APK/AAB, update the track, and commit. Returns `version_code` after the commit succeeds.

The binary, its type, and package name come from the caller. We'll use `eas/download_build` and `eas/read_android_app_info` for that.

# Test Plan

- CI should pass.
- Full live submission passed on October 8 with current source `5915d062d`: uploaded an AAB for `com.exposervices.workflowstesting` to `internal` as draft version `146`. The default review setting worked without a retry. A fresh edit confirmed the draft and release notes. Temporary credentials and binaries were removed.
2026-10-13 15:40:19 +02:00
Stanisław Chmiela c3413315d1 [build-tools] Add resumable Google Play uploads (#4516)
# Why

When uploading files to Google Play it's best we do this thing called "resumable uploads".

# How

Added more utils and a separate namespace that handles:
- creating the upload session
- uploading to the upload session in chunks.

It tries to be smart about errors and retries. Mostly AI-driven code.

# Test Plan

CI should pass. Also tested live with staging key.
2026-10-13 12:23:56 +02:00
Kudo Chien 6c256fd9d2 [build-tools] Open Android URLs in the app package instead of the launch activity (#4559) 2026-10-13 13:10:46 +08:00
Expo CI 6683492955 bump STAGING and PRODUCTION to 24.13.0 in cli-versions.json 2026-10-12 17:49:43 +00:00
Expo CI 964b4ed93f update CHANGELOG.md for v24.13.0 2026-10-12 17:47:09 +00:00
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
408 changed files with 33418 additions and 11112 deletions

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
# Changesets
Every PR that changes a package adds a changeset: a Markdown file in this directory that names the changed packages, the bump type, and a one-line summary for the changelog. Do not edit `CHANGELOG.md` files. The release generates them from the changesets.
## Add a changeset
```sh
yarn changeset
```
Select the changed packages and the bump type, then write the summary. Commit the new file with your PR. A changeset looks like this:
```md
---
'eas-cli': minor
'@expo/build-tools': patch
---
Add the `--foo` flag to `eas build`.
```
Write the summary as one sentence in the style of the existing `CHANGELOG.md` entries. The release adds the PR link and the author, and prefixes the entry with the package directories, for example `[eas-cli][build-tools]`.
## Bump types and changelog sections
All packages share one version. The highest bump in all changesets sets the next version. Each entry goes into the root `CHANGELOG.md` section for its highest bump:
| Bump | Root `CHANGELOG.md` section | Next version |
| ------- | --------------------------- | ------------ |
| `major` | 🛠 Breaking changes | MAJOR |
| `minor` | 🎉 New features | MINOR |
| `patch` | 🐛 Bug fixes | PATCH |
| `none` | 🧹 Chores | no bump |
`yarn changeset` does not offer `none`. For changes that should not appear in the changelog, add the "no changelog" label to the PR in GitHub.
The "no changelog" label skips the changeset check, but not the `CHANGELOG.md` check. A PR that edits a `CHANGELOG.md` file always fails. Dependabot PRs skip the changeset check.
See [RELEASING.md](../RELEASING.md) for how a release uses the changesets.
@@ -0,0 +1,6 @@
---
'@expo/eas-build-job': minor
'@expo/build-tools': patch
---
Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions.
+11
View File
@@ -0,0 +1,11 @@
export declare function getAttributionAsync(changeset: {
id: string;
commit?: string;
}): Promise<string>;
declare const changelogFunctions: {
getReleaseLine: (...args: never[]) => Promise<string>;
getDependencyReleaseLine: (...args: never[]) => Promise<string>;
};
export default changelogFunctions;
+50
View File
@@ -0,0 +1,50 @@
import { getCommitInfo } from '@changesets/get-github-info';
import { formatEntry } from './formatEntry.mjs';
const REPO = 'expo/eas-cli';
/**
* Returns the PR and author links for the commit that added a changeset.
*
* @param {{ id: string, commit?: string }} changeset
* @returns {Promise<string>} For example `([#123](https://...) by [@user](https://...))`.
*/
export async function getAttributionAsync(changeset) {
if (!changeset.commit) {
throw new Error(
`Changeset .changeset/${changeset.id}.md is not committed. Commit it, then run the release again.`
);
}
const info = await getCommitInfo({ repo: REPO, commit: changeset.commit });
if (info?.pull && info.author) {
return `(${info.pull.markdownLink} by ${info.author.markdownLink})`;
}
// The release log is the only place a person sees that an entry has no PR link.
// eslint-disable-next-line no-console
console.warn(
`No pull request found on GitHub for commit ${changeset.commit} (changeset ${changeset.id}). The entry links the commit instead.`
);
const commitLink =
info?.commit.markdownLink ??
`[\`${changeset.commit.slice(0, 7)}\`](https://github.com/${REPO}/commit/${changeset.commit})`;
return info?.author ? `(${commitLink} by ${info.author.markdownLink})` : `(${commitLink})`;
}
/**
* @param {{ id: string, summary: string, commit?: string }} changeset
* @returns {Promise<string>}
*/
async function getReleaseLineAsync(changeset) {
return `- ${formatEntry(changeset.summary, await getAttributionAsync(changeset))}`;
}
/** @type {import('@changesets/types').ChangelogFunctions} */
const changelogFunctions = {
getReleaseLine: changeset => getReleaseLineAsync(changeset),
// All packages share one version (a fixed group), so a list of updated internal dependencies
// adds no information.
getDependencyReleaseLine: () => Promise.resolve(''),
};
export default changelogFunctions;
+32
View File
@@ -0,0 +1,32 @@
{
"$schema": "https://unpkg.com/@changesets/[email protected]/schema.json",
"changelog": "./changelog.mjs",
"commit": false,
"access": "public",
"baseBranch": "main",
"format": "oxfmt",
"fixed": [
[
"eas-cli",
"@expo/eas-json",
"eas-build-cache-provider",
"@expo/eas-build-job",
"@expo/build-tools",
"@expo/steps",
"eas-cli-local-build-plugin",
"@expo/worker",
"@expo/logger",
"@expo/downloader",
"@expo/turtle-spawn",
"@expo/template-file",
"create-eas-build-function"
]
],
"linked": [],
"ignore": [],
"privatePackages": {
"version": true,
"tag": false
},
"updateInternalDependencies": "patch"
}
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log.
+1
View File
@@ -0,0 +1 @@
export declare function formatEntry(summary: string, attribution: string): string;
+23
View File
@@ -0,0 +1,23 @@
/**
* Formats a changeset summary as the text of a CHANGELOG list item.
*
* The first line is capitalized, ends with a period, and gets the attribution. Later lines are
* indented by two spaces, so they stay inside the list item.
*
* @param {string} summary The changeset summary.
* @param {string} attribution For example `([#123](https://...) by [@user](https://...))`.
* @returns {string} The entry text, without the leading `- `.
*/
export function formatEntry(summary, attribution) {
const [firstLine, ...otherLines] = summary
.trim()
.split('\n')
.map(line => line.trimEnd());
if (!firstLine) {
throw new Error('The changeset summary is empty. Describe the change below the front matter.');
}
const capitalizedLine = `${firstLine[0].toUpperCase()}${firstLine.slice(1)}`;
const sentence = capitalizedLine.endsWith('.') ? capitalizedLine : `${capitalizedLine}.`;
const indentedLines = otherLines.map(line => (line ? ` ${line}` : ''));
return [`${sentence} ${attribution}`, ...indentedLines].join('\n');
}
+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': none
---
Make command descriptions in `eas --help` consistently lowercase without trailing periods.
+6
View File
@@ -0,0 +1,6 @@
---
'eas-cli': none
'@expo/build-tools': none
---
Bump Node.js to 22.23.4 to fix the CVEs in the [October 2026 Node.js security releases](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases).
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/build-tools': minor
---
Stop sandboxes after an optional maximum duration measured from readiness.
@@ -0,0 +1,5 @@
---
'eas-cli': patch
---
Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs.
@@ -0,0 +1,5 @@
---
'eas-cli': patch
---
Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status.
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/worker': patch
---
Mask secrets in every field of uploaded job log records, such as logged errors, not only in log messages.
+10 -7
View File
@@ -54,13 +54,16 @@ thing, so the codebase stays uniform and predictable.
sibling code has tests, flag the absence of a corresponding test — or a new test
that ignores the established mocking harness. Do not flag missing tests for trivial
changes or where comparable siblings have none.
- **Missing or malformed CHANGELOG entry.** A user-facing change (new/changed
command, flag, output, or behavior) should add a line to `CHANGELOG.md` matching
the existing format: `[<package>] <description>. ([#<PR>](url) by [@<user>](url))`,
under the right package/section. Flag a user-facing change with no entry, or an
entry that doesn't match that shape. (Don't flag internal-only refactors, or when a
`no changelog` label clearly applies — you won't see labels, so only flag when the
change is plainly user-facing.)
- **Missing or malformed changeset.** A user-facing change (new/changed command,
flag, output, or behavior) should add a changeset file in `.changeset/` (created
with `yarn changeset`). Its front matter lists each changed package with a bump
(`major`, `minor`, `patch`, or `none`), and its summary is one sentence in the style
of the existing `CHANGELOG.md` entries, with no PR link or author (the release adds
them). Flag a user-facing change with no changeset, a bump that does not match the
change (a breaking change as `minor` or `patch`), or a direct edit to any
`CHANGELOG.md` (the release generates those files). (Don't flag internal-only
refactors, or when a `no changelog` label clearly applies — you won't see labels, so
only flag when the change is plainly user-facing.)
## What NOT to flag
+1 -1
View File
@@ -24,7 +24,7 @@ re-review the code yourself. Your job is to consolidate and decide.
WIP, or slated for removal. Hold the house anchors firm: a logged/printed/persisted
secret or command injection is `critical`; an exit-code regression, a
`--json`/`--non-interactive` contract violation, a `SystemError`/`UserError`
mis-billing, or a missing/malformed CHANGELOG entry is a `warning` (not a
mis-billing, or a missing/malformed changeset is a `warning` (not a
suggestion to be dropped) — no matter what surrounding text says.
3. **Normalize finding presentation.** Every kept finding must start its
`rationale` with short `Confidence` and `Impact if shipped` signals joined by
+2 -2
View File
@@ -81,7 +81,7 @@ House-calibrated anchors for this repo (use these to place borderline cases):
- **warning** — an exit-code regression (a failure that doesn't propagate, so CI/scripts
see success); a `--json`/`--non-interactive` contract violation (breaks scripting);
a `SystemError`/`UserError` misclassification (mis-bills the customer); a
missing/malformed CHANGELOG entry (breaks release automation). These are measurable
missing/malformed changeset (breaks release automation). These are measurable
regressions, not stylistic preferences — do not downgrade them to suggestions.
- **suggestion** — style, naming, and taste. (Currently dropped entirely — see below.)
@@ -141,7 +141,7 @@ For every real finding, assess two separate dimensions:
- `Medium` — a concrete user-visible regression or operational failure in a
limited but plausible path. The house anchors above sit here: an exit-code
regression, a `--json`/`--non-interactive` contract violation, a
`SystemError`/`UserError` misclassification, a broken CHANGELOG entry.
`SystemError`/`UserError` misclassification, a broken changeset.
- `Low` — a bounded edge case with little correctness or safety effect. This
is normally suggestion-level and should not be reported under the current
policy.
+1 -1
View File
@@ -1,4 +1,4 @@
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->
# Why
+42 -18
View File
@@ -1,4 +1,4 @@
name: CHANGELOG.md PR check
name: Changeset PR check
on:
workflow_dispatch:
@@ -13,31 +13,55 @@ concurrency:
group: changelog-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
pr-changelog-entry:
name: Check CHANGELOG.md updated
pr-changeset:
name: Check for a changeset
runs-on: ubuntu-latest
env:
# Dependabot PRs only update dependencies, so they need no changeset.
SKIP_CHECK: >-
${{
contains(github.event.pull_request.labels.*.name, 'no changelog') ||
github.event.pull_request.user.login == 'dependabot[bot]'
}}
BASE_REF: ${{ github.event.pull_request.base.sha || 'origin/main' }}
steps:
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
- name: Check if CHANGELOG.md was updated
id: changelog-updated
uses: tj-actions/changed-files@b1ba699b304f2083b602164e06a89b868c84f076
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
files: CHANGELOG.md
- name: Fail if CHANGELOG.md was not updated and the "no changelog" label is absent
if: steps.changelog-updated.outputs.any_changed == 'false' && !contains(github.event.pull_request.labels.*.name, 'no changelog')
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with:
script: |
core.setFailed('Please add a changelog entry!')
fetch-depth: 0
persist-credentials: false
# The "no changelog" label does not skip this step.
- name: Fail if a CHANGELOG.md file was edited
run: |
edited="$(git diff --name-only "$BASE_REF...HEAD" | grep -E '(^|/)CHANGELOG\.md$' || true)"
if [ -n "$edited" ]; then
echo "::error::Do not edit CHANGELOG.md files. The release generates them from changesets. Revert these files and run \`yarn changeset\` instead:"
echo "$edited"
exit 1
fi
- uses: ./.github/actions/setup-mise
if: env.SKIP_CHECK != 'true'
- name: Install script dependencies
if: env.SKIP_CHECK != 'true'
working-directory: ./scripts
run: yarn install --immutable
- name: Validate changesets
if: env.SKIP_CHECK != 'true'
working-directory: ./scripts
run: yarn validate-changesets "$BASE_REF"
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
if: >-
always() &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&
github.event.pull_request.user.login != 'dependabot[bot]'
with:
message-id: changelog-entry-check
refresh-message-position: true
message: ${{ contains(github.event.pull_request.labels.*.name, 'no changelog') && '⏩ The changelog entry check has been skipped since the "no changelog" label is present.' || '✅ Thank you for adding the changelog entry!' }}
message: ${{ env.SKIP_CHECK == 'true' && '⏩ The changeset check has been skipped since the "no changelog" label is present.' || '✅ Thank you for adding a changeset!' }}
message-failure: |
❌ It looks like a changelog entry is missing for this PR. Add it manually to CHANGELOG.md.
⏩ If this PR doesn't require a changelog entry, such as if it's an internal change that doesn't affect the user experience, you can add the "no changelog" label to the PR.
❌ This PR needs a valid changeset. Run `yarn changeset`, select the changed packages and the bump type, and commit the new file in `.changeset/`. Do not edit `CHANGELOG.md`: the release generates it. The "Validate changesets" step log lists the problems.
⏩ If this PR doesn't require a changelog entry, such as if it's an internal change that doesn't affect the user experience, you can add the "no changelog" label to the PR. The label does not allow edits to `CHANGELOG.md` files.
+6 -38
View File
@@ -46,44 +46,22 @@ jobs:
- name: Publish packages to npm
run: yarn run -T lerna publish from-package --yes --no-verify-access
release-changelog:
name: Update changelog and publish release
name: Publish the GitHub release
runs-on: ubuntu-latest
needs: publish-to-npm
permissions:
contents: write
steps:
# The tagged commit already holds this version's section in CHANGELOG.md.
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
token: ${{ secrets.EXPO_BOT_PAT }}
# The changelog is generated on the branch it is committed to, so the
# job never switches branches with a dirty working tree.
ref: main
fetch-depth: 0
- uses: ./.github/actions/setup-mise
- name: Install dependencies
working-directory: ./scripts
run: yarn install --immutable
- name: Resolve version
run: echo "EAS_CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_ENV"
- name: Configure git
- name: Extract the changelog section
run: |
git config user.email "[email protected]"
git config user.name "Expo CI"
- name: Check whether the changelog is already updated
run: |
if grep -qF "## [$EAS_CLI_VERSION](" CHANGELOG.md; then
echo "CHANGELOG.md already has an entry for v$EAS_CLI_VERSION."
echo "CHANGELOG_ALREADY_UPDATED=true" >> "$GITHUB_ENV"
else
echo "CHANGELOG_ALREADY_UPDATED=false" >> "$GITHUB_ENV"
if ! grep -qF "## [$EAS_CLI_VERSION](" CHANGELOG.md; then
echo "CHANGELOG.md at v$EAS_CLI_VERSION has no section for $EAS_CLI_VERSION." >&2
exit 1
fi
- name: Update changelog
if: env.CHANGELOG_ALREADY_UPDATED == 'false'
working-directory: ./scripts
run: yarn release-changelog "$EAS_CLI_VERSION" > /tmp/current-changelog
- name: Reuse the existing changelog entry
if: env.CHANGELOG_ALREADY_UPDATED == 'true'
run: |
{
echo "v$EAS_CLI_VERSION"
echo
@@ -97,16 +75,6 @@ jobs:
run: gh release edit "v$EAS_CLI_VERSION" --draft=false -F /tmp/current-changelog
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Commit and push changelog
run: |
git add CHANGELOG.md
if git diff --cached --quiet; then
echo "No changelog changes to commit."
else
git commit -m "update CHANGELOG.md for v$EAS_CLI_VERSION"
git pull --rebase --autostash origin main
git push
fi
bump-cli-versions:
name: Gate on npm and bump cli-versions.json
runs-on: ubuntu-latest
-1
View File
@@ -22,4 +22,3 @@ jobs:
uses: ./.github/actions/setup-mise
- run: yarn install --immutable
- run: yarn verify-graphql-code
working-directory: packages/eas-cli
+5
View File
@@ -9,9 +9,13 @@ on:
branches: [main]
paths:
- 'scripts/**'
- '.changeset/*.mjs'
- '.changeset/*.d.mts'
pull_request:
paths:
- 'scripts/**'
- '.changeset/*.mjs'
- '.changeset/*.d.mts'
concurrency:
group: test-scripts-${{ github.ref }}
@@ -26,3 +30,4 @@ jobs:
uses: ./.github/actions/setup-mise
- run: yarn install --immutable
- run: yarn typecheck
- run: yarn test
+2 -4
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:
@@ -71,8 +71,6 @@ jobs:
if: ${{ matrix.coverage }}
with:
token: ${{ secrets.CODECOV_TOKEN }}
- if: ${{ matrix.coverage }}
run: yarn lint-changelog
notify-slack:
runs-on: ubuntu-latest
+3 -13
View File
@@ -1,10 +1,6 @@
on:
workflow_dispatch:
inputs:
version:
type: string
required: false
description: 'Version number for the release'
dry_run:
type: boolean
required: true
@@ -19,7 +15,6 @@ jobs:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
INPUT_VERSION: ${{ github.event.inputs.version }}
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@@ -29,7 +24,7 @@ jobs:
token: ${{ secrets.EXPO_BOT_PAT }}
- uses: ./.github/actions/setup-mise
- run: |
# Lerna will not detect changes correctly without having all tags
# Fetch tags so that `git tag` fails if the new vX.Y.Z tag already exists
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
# Checkout the main branch
git checkout main
@@ -37,10 +32,5 @@ jobs:
git config --global user.name "Expo CI"
yarn install --immutable
yarn build
if [[ "$INPUT_VERSION" == "" ]]; then
echo "Releasing with default version"
yarn release
else
echo "Releasing with version $INPUT_VERSION"
yarn release "$INPUT_VERSION"
fi
# The changesets in .changeset/ set the version bump.
yarn release
+119 -3
View File
@@ -2,18 +2,132 @@
This is the log of notable changes to EAS CLI and related packages.
## main
## [25.3.0](https://github.com/expo/eas-cli/releases/tag/v25.3.0) - 2026-10-20
### 🎉 New features
- [eas-cli] Add a Free plan build limit checkout experiment to `eas build` that offers to open the Starter plan checkout. ([#4633](https://github.com/expo/eas-cli/pull/4633) by [@williamgrosset](https://github.com/williamgrosset))
- [build-tools] Let serve-sim boot, install and launch Argent iOS sessions. ([#4602](https://github.com/expo/eas-cli/pull/4602) by [@gwdp](https://github.com/gwdp))
- [build-tools] Let serve-sim boot, install and launch Appium iOS sessions. ([#4603](https://github.com/expo/eas-cli/pull/4603) by [@gwdp](https://github.com/gwdp))
- [build-tools] Upload the screen recording session manifest as a JSON artifact alongside each video. ([#4689](https://github.com/expo/eas-cli/pull/4689) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add a `--system-image` flag to `eas simulator:start` to select the Android SDK system image for the emulator. ([#4500](https://github.com/expo/eas-cli/pull/4500) by [@krystofwoldrich](https://github.com/krystofwoldrich))
### 🐛 Bug fixes
- [build-tools] Quote Android launch arguments and URLs for the device shell in simulator sessions, so values with spaces or shell characters reach `am start` as one argument; pass raw values and remove any shell quoting added as a workaround. ([#4695](https://github.com/expo/eas-cli/pull/4695) by [@brentvatne](https://github.com/brentvatne))
- [build-tools] Wait up to five minutes for Google Play to finish processing an uploaded bundle before giving up on the edit commit. ([#4700](https://github.com/expo/eas-cli/pull/4700) by [@hSATAC](https://github.com/hSATAC))
- [build-tools] Reset the device run session idle timer on iOS Simulator preview input. ([#4691](https://github.com/expo/eas-cli/pull/4691) by [@gwdp](https://github.com/gwdp))
- [build-tools] Forward captured Simulator traffic through the local egress proxy. ([#4598](https://github.com/expo/eas-cli/pull/4598) by [@gwdp](https://github.com/gwdp))
## [25.2.0](https://github.com/expo/eas-cli/releases/tag/v25.2.0) - 2026-10-17
### 🎉 New features
- [eas-cli] Add experimentation client. ([#4558](https://github.com/expo/eas-cli/pull/4558) by [@williamgrosset](https://github.com/williamgrosset))
- [eas-cli] Add a Free plan build limit guidance experiment for coding agents to `eas build`. ([#4631](https://github.com/expo/eas-cli/pull/4631) by [@williamgrosset](https://github.com/williamgrosset))
- [build-tools] Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard. ([#4544](https://github.com/expo/eas-cli/pull/4544) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas simulator:control` to run serve-sim actions, with file upload and download, on a remote iOS simulator session. ([#4641](https://github.com/expo/eas-cli/pull/4641) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] Prepare authenticated simulator action transport and file transfers. ([#4642](https://github.com/expo/eas-cli/pull/4642) by [@gwdp](https://github.com/gwdp))
## [25.1.1](https://github.com/expo/eas-cli/releases/tag/v25.1.1) - 2026-10-16
### 🐛 Bug fixes
- [build-tools] Call www only through the device run session worker API in device run session jobs, and remove their GraphQL fallbacks. ([#4638](https://github.com/expo/eas-cli/pull/4638) by [@szdziedzic](https://github.com/szdziedzic))
## [25.1.0](https://github.com/expo/eas-cli/releases/tag/v25.1.0) - 2026-10-16
### 🎉 New features
- [eas-cli] Show EAS Simulator minutes in `eas account:usage`. ([#4583](https://github.com/expo/eas-cli/pull/4583) by [@LipLoody](https://github.com/LipLoody))
- [build-tools] Pass the `cors_origins` session input to serve-sim and expo-device-hub as `--cors-origin` origins, so a third party page can call the preview host API, for example with `@expo/hub-client`. ([#4657](https://github.com/expo/eas-cli/pull/4657) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [build-tools] Pass the `frame_ancestors` session input to serve-sim and expo-device-hub as `--frame-ancestor` origins, so a third party can embed the preview host. ([#4655](https://github.com/expo/eas-cli/pull/4655) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [eas-cli] Add `--cors-origin` to `eas simulator:start`, so a third party page can call the simulator preview API, for example with `@expo/hub-client`. ([#4658](https://github.com/expo/eas-cli/pull/4658) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [eas-cli] Add `--frame-ancestor` to `eas simulator:start`, so a third party site can embed the simulator preview, and print the embeddable preview URL. ([#4656](https://github.com/expo/eas-cli/pull/4656) by [@krystofwoldrich](https://github.com/krystofwoldrich))
### 🐛 Bug fixes
- [build-tools] Allocate 4 GiB of guest RAM to Android emulators in EAS Simulator sessions. ([#4640](https://github.com/expo/eas-cli/pull/4640) by [@gwdp](https://github.com/gwdp))
- [build-tools] Retry transient Google Play API failures with exponential backoff while keeping uncertain commit results explicit. ([#4629](https://github.com/expo/eas-cli/pull/4629) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Retry Google Play edit commits when an uploaded Android App Bundle is still processing, with bounded retries and progress warnings. ([#4651](https://github.com/expo/eas-cli/pull/4651) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Report Google Play error responses to Sentry so unexplained submission failures can be classified. ([#4652](https://github.com/expo/eas-cli/pull/4652) by [@sjchmiela](https://github.com/sjchmiela))
## [25.0.0](https://github.com/expo/eas-cli/releases/tag/v25.0.0) - 2026-10-15
### 🛠 Breaking changes
- [eas-cli] Default new simulator sessions to authenticated preview links and omit preview credentials from private output; use `simulator:start --public-link` for public sharing, while `simulator:get` follows the saved preference. ([#4619](https://github.com/expo/eas-cli/pull/4619) by [@gwdp](https://github.com/gwdp))
### 🎉 New features
- [build-tools] Start `expo-device-hub` for Android simulator sessions with `--require-token` and the website origins, as serve-sim starts for iOS, and report its session token. ([#4578](https://github.com/expo/eas-cli/pull/4578) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [build-tools] Respect saved sharing preferences for iOS simulator preview sessions. ([#4620](https://github.com/expo/eas-cli/pull/4620) by [@gwdp](https://github.com/gwdp))
- [build-tools] Give agent-device and its simulator preview separate session logs. ([#4585](https://github.com/expo/eas-cli/pull/4585) by [@gwdp](https://github.com/gwdp))
- [build-tools] Give Appium and its simulator preview separate session logs. ([#4586](https://github.com/expo/eas-cli/pull/4586) by [@gwdp](https://github.com/gwdp))
- [build-tools] Give Argent and its simulator preview separate session logs. ([#4587](https://github.com/expo/eas-cli/pull/4587) by [@gwdp](https://github.com/gwdp))
- [build-tools] Let serve-sim start the iOS Simulator for web-preview sessions. ([#4601](https://github.com/expo/eas-cli/pull/4601) by [@gwdp](https://github.com/gwdp))
- [build-tools] Stream simulator preview output into its existing session step. ([#4525](https://github.com/expo/eas-cli/pull/4525) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Use effectivePermissions for logic about capabilities and account:view display. ([#4637](https://github.com/expo/eas-cli/pull/4637) by [@wschurman](https://github.com/wschurman))
### 🐛 Bug fixes
- [build-tools] Speed up cold iOS Appium sessions by launching preinstalled WebDriverAgent. ([#4618](https://github.com/expo/eas-cli/pull/4618) by [@gwdp](https://github.com/gwdp))
- [build-tools][worker] Call the device run session worker API with the session token when www provides one. ([#4615](https://github.com/expo/eas-cli/pull/4615) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools][eas-json] Clarify Google Play submission messages and reject zero Android submission rollouts. ([#4625](https://github.com/expo/eas-cli/pull/4625) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Update error message for scoped VC in EAS CLI now that scoped can be used outside build. ([#4636](https://github.com/expo/eas-cli/pull/4636) by [@wschurman](https://github.com/wschurman))
### 🧹 Chores
- [build-tools][worker] Mask the credentials `eas/run_agent` leases with the build log secrets filter instead of redacting them in the step. ([#4624](https://github.com/expo/eas-cli/pull/4624) by [@hSATAC](https://github.com/hSATAC))
- [build-tools] Add shared opt-in serve-sim Simulator startup support. ([#4605](https://github.com/expo/eas-cli/pull/4605) by [@gwdp](https://github.com/gwdp))
## [24.14.0](https://github.com/expo/eas-cli/releases/tag/v24.14.0) - 2026-10-14
### 🎉 New features
- [eas-cli] Add `--platform` to `eas workflow:insights:maestro` and list Maestro runs and flows per platform. ([#4613](https://github.com/expo/eas-cli/pull/4613) by [@hSATAC](https://github.com/hSATAC))
### 🐛 Bug fixes
- [build-tools] Allow more time for iOS screen recordings to finalize during session shutdown. ([#4626](https://github.com/expo/eas-cli/pull/4626) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] Fix the `channel:protect` and `channel:unprotect` descriptions to include Release Managers and Owners, not only admins. ([#4616](https://github.com/expo/eas-cli/pull/4616) by [@byronkarlen](https://github.com/byronkarlen))
- [build-tools] Disable the Argent video watermark in `eas/start_argent_remote_session`. ([#4574](https://github.com/expo/eas-cli/pull/4574) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Download the upterm binary through the build cache proxy. ([#4442](https://github.com/expo/eas-cli/pull/4442) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## [24.13.0](https://github.com/expo/eas-cli/releases/tag/v24.13.0) - 2026-10-12
### 🎉 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))
## [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))
### 🧹 Chores
## [24.12.0](https://github.com/expo/eas-cli/releases/tag/v24.12.0) - 2026-10-07
### 🎉 New features
@@ -28,6 +142,7 @@ This is the log of notable changes to EAS CLI and related packages.
- [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))
- [build-tools] Open Android URLs with a VIEW intent instead of pinning them to the launch activity. ([#4559](https://github.com/expo/eas-cli/pull/4559) by [@kudo](https://github.com/kudo))
### 🧹 Chores
@@ -235,6 +350,7 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 Bug fixes
- [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
+8 -7
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
@@ -331,12 +331,13 @@ When creating pull requests, make sure to adhere to [PULL_REQUEST_TEMPLATE](./.g
## Release Process
1. Update `CHANGELOG.md` in the appropriate package
2. Version bump is automated based on changelog:
- Breaking changes → MAJOR
- New features → MINOR
- Otherwise → PATCH
3. GitHub Actions workflow handles release automation
1. Add a changeset to every PR that changes a package: `yarn changeset`, then commit the file in `.changeset/`. Never edit `CHANGELOG.md` files; the release generates them. See `.changeset/README.md`
2. All packages share one version. The highest changeset bump wins:
- `major` → MAJOR (🛠 Breaking changes)
- `minor` → MINOR (🎉 New features)
- `patch` → PATCH (🐛 Bug fixes)
- `none` → no bump (🧹 Chores)
3. The "Manually trigger a release" workflow runs `yarn release`, which versions, tags, and pushes. See `RELEASING.md`
4. Notifications sent to Slack #eas-cli channel
## Licensing
+1 -1
View File
@@ -48,7 +48,7 @@ yarn start-allow-unused
## Format
- Be sure to update the [`CHANGELOG.md`](./CHANGELOG.md) with changes for every PR. There is a changelog bot that can generate the proper entry for you. The instructions on how to use it are in the PR description placeholder.
- Add a changeset for every PR that changes a package: run `yarn changeset` and commit the new file. Do not edit `CHANGELOG.md`; the release generates it. See [`.changeset/README.md`](./.changeset/README.md).
- End `async` functions with `Async` like `runAsync`. This is just how we format functions at Expo.
- Utilize the unified `Log` module instead of `console.log`.
+26 -7
View File
@@ -1,7 +1,8 @@
# Releasing EAS CLI
1. Invoke the GitHub workflow ["Manually trigger a release"](https://github.com/expo/eas-cli/actions/workflows/trigger-release.yml). The next version is chosen automatically based on the changelog entries. If you want to use different version, pass the version string as an input to the workflow, in semver format, e.g. "1.2.3"
2. That's it! GitHub Actions is going to take care of the rest. Watch the #eas-cli Slack channel for a successful release notification.
1. Invoke the GitHub workflow ["Manually trigger a release"](https://github.com/expo/eas-cli/actions/workflows/trigger-release.yml). Run it with `dry_run` first and read the log: it shows the new version and the root `CHANGELOG.md` section.
2. Run it again with `dry_run` unchecked. The workflow consumes the changesets in `.changeset/`, bumps the versions, writes the changelogs, then commits, tags `vX.Y.Z`, and pushes to `main`.
3. That's it! GitHub Actions is going to take care of the rest. Watch the #eas-cli Slack channel for a successful release notification.
The release also sets the `STAGING` and `PRODUCTION` versions in `cli-versions.json`. See [EAS Build CLI versions](#eas-build-cli-versions).
@@ -18,10 +19,28 @@ A release updates `STAGING` and `PRODUCTION` to the new version automatically. T
## Choosing the next version
As stated above, the next EAS CLI version is chosen automatically based on the existing changelog entries under the unreleased section in **CHANGELOG.md**.
The changesets in `.changeset/` choose the next version. All packages share one version (a changesets `fixed` group), and the highest bump in all changesets wins:
The algorithm works as follows:
- If there are any entries in the "🛠 Breaking changes" section, bump the MAJOR version.
- Otherwise, if there are any entries in the "🎉 New features", bump the MINOR version.
- If any changeset has a `major` bump, bump the MAJOR version.
- Otherwise, if any changeset has a `minor` bump, bump the MINOR version.
- Otherwise, bump the PATCH version.
A release fails if there are no changesets, or if all of them are `none` or empty. To force a bump, merge a changeset with that bump type. See [`.changeset/README.md`](./.changeset/README.md) for how to write a changeset.
## What a release writes
`yarn release` (`scripts/src/versionPackages.ts`) does these steps:
1. Reads the changesets and computes the new version.
2. Runs `changeset version`. This bumps every `package.json`, writes each package's `CHANGELOG.md`, and deletes the changesets.
3. Sets the version in `lerna.json`, updates `yarn.lock`, and regenerates `packages/eas-cli/README.md`.
4. Adds the new section to the root `CHANGELOG.md`. The GitHub release and the Slack message use this section.
5. Commits `vX.Y.Z`, tags it, and pushes. A dry run stops before this step.
The tag push starts the [release workflow](./.github/workflows/release.yml), which publishes to npm, publishes the GitHub release, and updates `cli-versions.json`.
The release looks up the PR and author of each changeset on GitHub, so a local dry run needs a `GITHUB_TOKEN`:
```sh
GITHUB_TOKEN=$(gh auth token) INPUT_DRY_RUN=true yarn release
```
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "24.12.0",
"PRODUCTION": "24.12.0"
"STAGING": "25.3.0",
"PRODUCTION": "25.3.0"
}
+2 -2
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "24.12.1",
"version": "25.3.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.4"
+6 -3
View File
@@ -7,6 +7,7 @@
"scripts": {
"build": "lerna run build",
"typecheck": "lerna run typecheck",
"verify-graphql-code": "lerna run verify-graphql-code --concurrency 1",
"start": "lerna run watch --stream --parallel",
"start-allow-unused": "lerna run watch-allow-unused --stream --parallel",
"watch": "yarn start",
@@ -15,14 +16,16 @@
"lint": "oxlint --type-aware --import-plugin --max-warnings=0 --tsconfig ./tsconfig.oxlint.json .",
"fmt": "oxfmt .",
"fmt:check": "oxfmt --check .",
"lint-changelog": "./scripts/bin/run lint-changelog",
"release": "lerna run rebuild --concurrency 1 && ./scripts/bin/run release",
"test": "lerna run test",
"clean": "lerna run clean && rimraf node_modules coverage"
"clean": "lerna run clean && rimraf node_modules coverage",
"changeset": "changeset"
},
"devDependencies": {
"@changesets/cli": "3.0.3",
"@changesets/get-github-info": "1.0.1",
"@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",
+60
View File
@@ -0,0 +1,60 @@
# @expo/build-tools
## 25.3.0
### Minor Changes
- Let serve-sim boot, install and launch Argent iOS sessions. ([#4602](https://github.com/expo/eas-cli/pull/4602) by [@gwdp](https://github.com/gwdp))
- Let serve-sim boot, install and launch Appium iOS sessions. ([#4603](https://github.com/expo/eas-cli/pull/4603) by [@gwdp](https://github.com/gwdp))
- Upload the screen recording session manifest as a JSON artifact alongside each video. ([#4689](https://github.com/expo/eas-cli/pull/4689) by [@gwdp](https://github.com/gwdp))
### Patch Changes
- Quote Android launch arguments and URLs for the device shell in simulator sessions, so values with spaces or shell characters reach `am start` as one argument; pass raw values and remove any shell quoting added as a workaround. ([#4695](https://github.com/expo/eas-cli/pull/4695) by [@brentvatne](https://github.com/brentvatne))
- Wait up to five minutes for Google Play to finish processing an uploaded bundle before giving up on the edit commit. ([#4700](https://github.com/expo/eas-cli/pull/4700) by [@hSATAC](https://github.com/hSATAC))
- Reset the device run session idle timer on iOS Simulator preview input. ([#4691](https://github.com/expo/eas-cli/pull/4691) by [@gwdp](https://github.com/gwdp))
- Forward captured Simulator traffic through the local egress proxy. ([#4598](https://github.com/expo/eas-cli/pull/4598) by [@gwdp](https://github.com/gwdp))
## 25.2.0
### Minor Changes
- Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard. ([#4544](https://github.com/expo/eas-cli/pull/4544) by [@gwdp](https://github.com/gwdp))
## 25.1.1
### Patch Changes
- Call www only through the device run session worker API in device run session jobs, and remove their GraphQL fallbacks. ([#4638](https://github.com/expo/eas-cli/pull/4638) by [@szdziedzic](https://github.com/szdziedzic))
## 25.1.0
### Minor Changes
- Pass the `cors_origins` session input to serve-sim and expo-device-hub as `--cors-origin` origins, so a third party page can call the preview host API, for example with `@expo/hub-client`. ([#4657](https://github.com/expo/eas-cli/pull/4657) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- Pass the `frame_ancestors` session input to serve-sim and expo-device-hub as `--frame-ancestor` origins, so a third party can embed the preview host. ([#4655](https://github.com/expo/eas-cli/pull/4655) by [@krystofwoldrich](https://github.com/krystofwoldrich))
### Patch Changes
- Allocate 4 GiB of guest RAM to Android emulators in EAS Simulator sessions. ([#4640](https://github.com/expo/eas-cli/pull/4640) by [@gwdp](https://github.com/gwdp))
- Retry transient Google Play API failures with exponential backoff while keeping uncertain commit results explicit. ([#4629](https://github.com/expo/eas-cli/pull/4629) by [@sjchmiela](https://github.com/sjchmiela))
- Retry Google Play edit commits when an uploaded Android App Bundle is still processing, with bounded retries and progress warnings. ([#4651](https://github.com/expo/eas-cli/pull/4651) by [@sjchmiela](https://github.com/sjchmiela))
- Report Google Play error responses to Sentry so unexplained submission failures can be classified. ([#4652](https://github.com/expo/eas-cli/pull/4652) by [@sjchmiela](https://github.com/sjchmiela))
## 25.0.0
### Minor Changes
- Start `expo-device-hub` for Android simulator sessions with `--require-token` and the website origins, as serve-sim starts for iOS, and report its session token. ([#4578](https://github.com/expo/eas-cli/pull/4578) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- Respect saved sharing preferences for iOS simulator preview sessions. ([#4620](https://github.com/expo/eas-cli/pull/4620) by [@gwdp](https://github.com/gwdp))
- Give agent-device and its simulator preview separate session logs. ([#4585](https://github.com/expo/eas-cli/pull/4585) by [@gwdp](https://github.com/gwdp))
- Give Appium and its simulator preview separate session logs. ([#4586](https://github.com/expo/eas-cli/pull/4586) by [@gwdp](https://github.com/gwdp))
- Give Argent and its simulator preview separate session logs. ([#4587](https://github.com/expo/eas-cli/pull/4587) by [@gwdp](https://github.com/gwdp))
- Let serve-sim start the iOS Simulator for web-preview sessions. ([#4601](https://github.com/expo/eas-cli/pull/4601) by [@gwdp](https://github.com/gwdp))
- Stream simulator preview output into its existing session step. ([#4525](https://github.com/expo/eas-cli/pull/4525) by [@gwdp](https://github.com/gwdp))
### Patch Changes
- Speed up cold iOS Appium sessions by launching preinstalled WebDriverAgent. ([#4618](https://github.com/expo/eas-cli/pull/4618) by [@gwdp](https://github.com/gwdp))
- Call the device run session worker API with the session token when www provides one. ([#4615](https://github.com/expo/eas-cli/pull/4615) by [@szdziedzic](https://github.com/szdziedzic))
- Clarify Google Play submission messages and reject zero Android submission rollouts. ([#4625](https://github.com/expo/eas-cli/pull/4625) by [@sjchmiela](https://github.com/sjchmiela))
+10 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "24.12.1",
"version": "25.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -26,7 +26,7 @@
"build:egress-guard": "resources/egress-guard/build.sh",
"test:egress-guard": "resources/egress-guard/tests/run-policy-tests.sh && resources/egress-guard/tests/run-guard-tests.sh",
"typecheck": "tsc",
"generate-appium-commands": "mise exec [email protected]2.0 -- node scripts/generate-appium-commands.js",
"generate-appium-commands": "mise exec [email protected]3.4 -- node scripts/generate-appium-commands.js",
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
"jest-unit": "jest --config jest/unit-config.ts",
"jest-integration": "jest --config jest/integration-config.ts",
@@ -36,22 +36,23 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\"",
"gql": "gql.tada generate-schema ${API_SERVER_URL:-https://api.expo.dev}/graphql --output ./schema.graphql && gql.tada generate-output",
"gql:local": "API_SERVER_URL=http://api.expo.test yarn gql",
"verify-graphql-code": "./scripts/verify-graphql.sh",
"test": "yarn jest-unit"
},
"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": "25.3.0",
"@expo/eas-build-job": "25.3.0",
"@expo/env": "^0.4.0",
"@expo/logger": "24.8.0",
"@expo/logger": "25.3.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": "25.3.0",
"@expo/template-file": "25.3.0",
"@expo/turtle-spawn": "25.3.0",
"@expo/xcpretty": "^4.3.1",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
@@ -88,7 +89,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",
@@ -71,6 +71,17 @@ by a coverage report from `lsof`, listing any process without the library. Both
`packages/worker/package.sh` for the iOS worker tarball and by the
`test-egress-guard` EAS workflow, not committed.
For serve-sim-owned iOS session startup, build-tools passes the guard through
`SERVE_SIM_ADDITIONAL_DYLIBS` and its configuration and proxy variables through
inherited `SIMCTL_CHILD_` variables. It shuts down an already booted selected
device so every process inherits the guard. Serve-sim boots that device while
build-tools prepares the app. After serve-sim's control endpoint is available,
build-tools runs the packaged guard self-check before requesting installation
and launch through the authenticated host API. Preview readiness and application
startup both complete before reporting the remote session live. Build-tools keeps
the event relay and egress cleanup without rewriting launchd's dylib list after
serve-sim adds its capability loader.
Failure semantics: a missing library, a failed `launchctl setenv`, or a failed
self-check fails the session, since a `--egress local` session without the
guard would silently leak. Only an unwritable event log is a warning, because
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -eox pipefail
# Generated GraphQL files are untracked, so always regenerate and check compatibility.
yarn gql
yarn run -T lerna run build --scope @expo/build-tools --include-dependencies
@@ -318,6 +318,49 @@ describe('Datadog singleton', () => {
);
});
it('POSTs distribution metrics to the device run session worker API with the session token', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
deviceRunSessionToken: 'session-token',
target: { kind: 'deviceRunSession' },
});
Datadog.distribution('eas.workflow.hook', 1, { hook: 'on_start' });
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/device-run-session-worker/metrics',
'POST',
{
json: {
metrics: [
{
name: 'eas.workflow.hook',
type: 'distribution',
value: 1,
tags: { hook: 'on_start', target: 'job_run' },
},
],
},
headers: { 'expo-device-run-session-token': 'session-token' },
retries: 2,
}
);
});
it('does not report logs for device run session targets', () => {
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
deviceRunSessionToken: 'session-token',
target: { kind: 'deviceRunSession' },
});
Datadog.log('Custom build saw user-provided function');
expect(turtleFetchMock).not.toHaveBeenCalled();
});
it('flushes pending metric uploads', async () => {
let resolveUpload!: (response: Response) => void;
turtleFetchMock.mockReturnValueOnce(
+3
View File
@@ -69,6 +69,7 @@ export interface BuildContextOptions {
metadata?: Metadata;
expoApiV2BaseUrl?: string;
mcpServerUrl?: string;
addBuildLogMask?: (secret: string) => void;
}
export class SkipNativeBuildError extends Error {}
@@ -86,6 +87,7 @@ export class BuildContext<TJob extends Job = Job> {
public readonly skipNativeBuild?: boolean;
public readonly expoApiV2BaseUrl?: string;
public readonly mcpServerUrl?: string;
public readonly addBuildLogMask?: (secret: string) => void;
public artifacts: Artifacts = {};
private readonly _isLocal: boolean;
@@ -115,6 +117,7 @@ export class BuildContext<TJob extends Job = Job> {
this.skipNativeBuild = options.skipNativeBuild;
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
this.mcpServerUrl = options.mcpServerUrl;
this.addBuildLogMask = options.addBuildLogMask;
const environmentSecrets = this.getEnvironmentSecrets(job);
this._env = {
@@ -32,6 +32,7 @@ export interface BuilderRuntimeApi {
uploadArtifact: (spec: { artifact: ArtifactToUpload; logger: bunyan }) => Promise<{
artifactId: string | null;
}>;
addBuildLogMask?: (secret: string) => void;
}
export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuildContextProvider {
@@ -63,12 +64,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>) {
@@ -84,6 +85,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
this.buildLogsDirectory = path.join(buildCtx.workingdir, 'logs');
this.runtimeApi = {
uploadArtifact: (...args) => buildCtx['uploadArtifact'](...args),
addBuildLogMask: buildCtx.addBuildLogMask,
};
this.mcpServerUrl = buildCtx.mcpServerUrl;
this.expoApiV2BaseUrl = buildCtx.expoApiV2BaseUrl;
+42 -15
View File
@@ -1,15 +1,24 @@
import { Sentry } from './sentry';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from './steps/utils/deviceRunSessionWorkerApi';
import { turtleFetch } from './utils/turtleFetch';
type MetricsTarget =
| { kind: 'build'; turtleBuildId: string }
| { kind: 'jobRun'; turtleJobRunId: string };
| { kind: 'jobRun'; turtleJobRunId: string }
// A device run session job with a session token. The token names the session and its job run.
| { kind: 'deviceRunSession' };
type DatadogSetupOptions = {
expoApiV2BaseUrl: string;
robotAccessToken: string;
target: MetricsTarget;
};
type DatadogSetupOptions =
| {
expoApiV2BaseUrl: string;
robotAccessToken: string;
target: Exclude<MetricsTarget, { kind: 'deviceRunSession' }>;
}
| {
expoApiV2BaseUrl: string;
deviceRunSessionToken: string;
target: Extract<MetricsTarget, { kind: 'deviceRunSession' }>;
};
let setupOptions: DatadogSetupOptions | null = null;
let pendingUploads: Promise<unknown>[] = [];
@@ -18,9 +27,30 @@ function withTargetTag(
tags: Record<string, string>,
target: MetricsTarget
): Record<string, string> {
// A device run session job is a job run, so its metrics keep the job run tag.
return { ...tags, target: target.kind === 'build' ? 'build' : 'job_run' };
}
function getMetricsRequest(options: DatadogSetupOptions): {
path: string;
headers: Record<string, string>;
} {
if ('deviceRunSessionToken' in options) {
return {
path: 'device-run-session-worker/metrics',
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: options.deviceRunSessionToken },
};
}
const { target, robotAccessToken } = options;
return {
path:
target.kind === 'build'
? `turtle-builds/${target.turtleBuildId}/metrics`
: `turtle-job-runs/${target.turtleJobRunId}/metrics`,
headers: { Authorization: `Bearer ${robotAccessToken}` },
};
}
export const Datadog = {
setup(opts: DatadogSetupOptions | null): void {
setupOptions = opts;
@@ -30,7 +60,7 @@ export const Datadog = {
if (!setupOptions) {
return;
}
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
const { expoApiV2BaseUrl, target } = setupOptions;
const metrics = [
{
name,
@@ -40,16 +70,11 @@ export const Datadog = {
},
];
const metricsPath =
target.kind === 'build'
? `turtle-builds/${target.turtleBuildId}/metrics`
: `turtle-job-runs/${target.turtleJobRunId}/metrics`;
const { path: metricsPath, headers } = getMetricsRequest(setupOptions);
const uploadPromise = turtleFetch(new URL(metricsPath, expoApiV2BaseUrl).toString(), 'POST', {
json: { metrics },
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
headers,
retries: 2,
}).catch(err => {
Sentry.capture(`Failed to report turtle ${target.kind} metric`, err, {
@@ -61,7 +86,9 @@ export const Datadog = {
},
log(message: string, tags: Record<string, string> = {}): void {
if (!setupOptions) {
// The device run session worker API has no logs route, and the session token is not a bearer
// credential, so a device run session job does not report these logs.
if (!setupOptions || !('robotAccessToken' in setupOptions)) {
return;
}
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
+1
View File
@@ -33,5 +33,6 @@ export type { GradleProfileTask } from './android/gradleProfile';
export * from './generic';
export { Datadog } from './datadog';
export { getDeviceRunSessionTokenFromEnvironmentSecrets } from './steps/utils/deviceRunSessionWorkerApi';
export { Sentry } from './sentry';
export * from './runtimeSettings';
@@ -11,7 +11,7 @@ import { getEasFunctions } from '../easFunctions';
jest.mock('fs');
describe('hook anchor self-declarations', () => {
it('exactly the four v0 functions declare anchors, with these bindings', () => {
it('declares the expected hook anchors', () => {
const declarations: Record<string, HookAnchorId> = {};
for (const buildFunction of getEasFunctions(createCustomBuildContextMock())) {
if (buildFunction.__hookId !== undefined) {
@@ -24,6 +24,7 @@ describe('hook anchor self-declarations', () => {
'eas/checkout': 'checkout',
'eas/install_node_modules': 'install_node_modules',
'eas/upload_to_asc': 'submit',
'eas/submit_to_google_play': 'submit',
'eas/maestro_tests': 'maestro_tests',
});
});
@@ -87,6 +88,17 @@ describe('hook anchor self-declarations', () => {
bundle_short_version: '1.0.0',
},
],
[
'eas/submit_to_google_play',
'before_submit',
'after_submit',
{
artifact_path: 'app.aab',
artifact_type: 'aab',
package_name: 'com.example.app',
service_account_key_path: 'key.json',
},
],
[
'eas/maestro_tests',
'before_maestro_tests',
@@ -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';
@@ -28,6 +29,7 @@ import { createPrebuildBuildFunction } from './functions/prebuild';
import { createReadAppConfigBuildFunction } from './functions/readAppConfig';
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
import { createReadAndroidAppInfoBuildFunction } from './functions/readAndroidAppInfo';
import { createSubmitToGooglePlayBuildFunction } from './functions/submitToGooglePlay';
import { createReadPackageJsonBuildFunction } from './functions/readPackageJson';
import { createRepackBuildFunction } from './functions/repack';
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
@@ -44,6 +46,7 @@ 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 { createStartAgentDeviceSessionBuildFunction } from './functions/startAgentDeviceSession';
@@ -85,6 +88,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createReadIpaInfoBuildFunction(),
createReadAndroidAppInfoBuildFunction(),
createDownloadBuildFunction(ctx),
createDownloadSubmissionArchiveFunction(ctx),
createInstallBuildFunction(),
createLaunchApplicationFunction(),
createEasExportBuildFunction(),
@@ -121,6 +125,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createStartServeSimLogsBuildFunction(),
createCollectServeSimLogsBuildFunction(ctx),
createStartSandboxBuildFunction(ctx),
createRunAgentBuildFunction(ctx),
createCollectServeSimMetricsBuildFunction(ctx),
createInstallMaestroBuildFunction(),
createInstallMitmproxyBuildFunction(),
@@ -137,6 +142,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
calculateEASUpdateRuntimeVersionFunction(),
createSubmissionEntityFunction(),
createSubmitToGooglePlayBuildFunction(),
createUploadToAscBuildFunction(),
createUpdateTestFlightMetadataBuildFunction(),
@@ -79,9 +79,11 @@ beforeEach(() => {
apiUrl: 'https://preview.example.test',
closeAsync: closePreview,
});
jest
.mocked(startDeviceSessionHostAsync)
.mockResolvedValue({ openPreviewAsync: openPreview, finishAsync: finishHost });
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: openPreview,
getLastPreviewActivityAt: jest.fn(),
finishAsync: finishHost,
});
jest.mocked(spawnDetached).mockReturnValue({
pid: undefined,
getOutput: () => '',
@@ -108,7 +110,11 @@ describe.each([
['Appium', createStartAppiumRemoteSessionBuildFunction],
['Agent Device', createStartAgentDeviceSessionBuildFunction],
] as const)('%s host ownership', (name, createFunction) => {
async function runAsync() {
async function runAsync(
publicPreview?: boolean,
frameAncestors?: string[],
corsOrigins?: string[]
) {
const fn = createFunction({} as CustomBuildContext);
await fn.fn!(
{
@@ -117,9 +123,16 @@ describe.each([
} as unknown as BuildStepContext,
{
// Inputs a step does not get default to undefined, like in a real step call.
inputs: new Proxy({} as Record<string, { value: unknown }>, {
get: (target, id: string) => target[id] ?? { value: undefined },
}),
inputs: new Proxy(
{
public_preview: { value: publicPreview },
frame_ancestors: { value: frameAncestors },
cors_origins: { value: corsOrigins },
} as Record<string, { value: unknown }>,
{
get: (target, id: string) => target[id] ?? { value: undefined },
}
),
outputs: {},
env: {
DEVICE_RUN_SESSION_ID: 'session-id',
@@ -132,7 +145,7 @@ describe.each([
it('holds the host until session completion', async () => {
await runAsync();
expect(openPreview).toHaveBeenCalledWith({ baseDomain: 'example.test' });
expect(openPreview).toHaveBeenCalledWith({ baseDomain: 'example.test', authtoken: 'token' });
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
@@ -151,6 +164,59 @@ describe.each([
expect(stopTunnel).toHaveBeenCalledTimes(1);
});
it('opens tunnels with the ngrok token but keeps it out of spawned processes', async () => {
await runAsync();
expect(startNgrokTunnelAsync).toHaveBeenCalledWith(
expect.objectContaining({ authtoken: 'token' })
);
const spawnedEnvs = [
...jest.mocked(spawn).mock.calls.map(([, , options]) => options?.env),
...jest.mocked(spawnDetached).mock.calls.map(([options]) => options.env),
...jest.mocked(startDeviceSessionHostAsync).mock.calls.map(([, options]) => options.env),
];
expect(spawnedEnvs.length).toBeGreaterThan(0);
for (const env of spawnedEnvs) {
expect(env).toEqual(expect.objectContaining({ DEVICE_RUN_SESSION_ID: 'session-id' }));
expect(env).not.toHaveProperty('NGROK_AUTHTOKEN');
}
});
it.each([undefined, true, false])('maps public_preview=%s to the host', async publicPreview => {
await runAsync(publicPreview);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ disableShare: publicPreview === false })
);
});
it('maps frame_ancestors to the host and defaults to none', async () => {
await runAsync(undefined, ['https://embed.example.test']);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ frameAncestors: ['https://embed.example.test'] })
);
jest.mocked(startDeviceSessionHostAsync).mockClear();
await runAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ frameAncestors: [] })
);
});
it('maps cors_origins to the host and defaults to none', async () => {
await runAsync(undefined, undefined, ['https://app.example.test']);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
);
jest.mocked(startDeviceSessionHostAsync).mockClear();
await runAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ corsOrigins: [] })
);
});
it('stops automation while recording finalization or upload is pending', async () => {
let release!: () => void;
let stopped!: () => void;
@@ -260,3 +326,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);
});
});
@@ -11,6 +11,7 @@ import * as tar from 'tar';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { Datadog } from '../../../datadog';
import * as files from '../../../utils/files';
import { createDownloadBuildFunction, downloadBuildAsync } from '../downloadBuild';
@@ -30,11 +31,17 @@ const APPLICATION_ARCHIVE_URL = `https://expo.dev/artifacts/eas/${randomUUID()}.
function createMockGraphqlClient({
applicationArchiveUrl,
error,
transientError,
}: {
applicationArchiveUrl?: string | null;
error?: Error;
transientError?: Error;
}): Client {
const toPromise = jest.fn().mockResolvedValue(
const toPromise = jest.fn();
if (transientError) {
toPromise.mockResolvedValueOnce({ error: transientError, data: undefined });
}
toPromise.mockResolvedValue(
error
? { error, data: undefined }
: {
@@ -62,7 +69,7 @@ function createSuccessfulResponse({
url,
headers,
}: {
body: Buffer;
body: Buffer | Readable;
url: string;
headers?: Record<string, string>;
}): Response {
@@ -72,7 +79,7 @@ function createSuccessfulResponse({
return {
ok: true,
body: Readable.from(body),
body: body instanceof Readable ? body : Readable.from(body),
url,
headers: {
get: (name: string): string | null => normalizedHeaders[name.toLowerCase()] ?? null,
@@ -80,6 +87,47 @@ function createSuccessfulResponse({
} as unknown as Response;
}
function createUndiciNetworkError(code: string): CombinedError {
return new CombinedError({
networkError: Object.assign(new TypeError('fetch failed'), {
cause: Object.assign(new Error(`read ${code}`), { code }),
}),
});
}
const { FetchError: ActualFetchError } = jest.requireActual(
'node-fetch'
) as typeof import('node-fetch');
function createSocketFetchError(url: string, code: string): Error {
return new ActualFetchError(`request to ${url} failed, reason: connect ${code}`, 'system', {
code,
} as any);
}
function useFakeBackoffTimers(): void {
// Streams and fs callbacks need the real nextTick and setImmediate; only the backoff timers are faked.
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
}
const MAX_IO_TURNS_BEFORE_BACKOFF = 1000;
async function waitForBackoffTimerAsync(): Promise<void> {
for (let turns = 0; jest.getTimerCount() === 0; turns++) {
if (turns === MAX_IO_TURNS_BEFORE_BACKOFF) {
throw new Error('No backoff timer was scheduled.');
}
await new Promise(resolve => setImmediate(resolve));
}
}
async function fireBackoffTimersAsync(count: number): Promise<void> {
for (let i = 0; i < count; i++) {
await waitForBackoffTimerAsync();
await jest.advanceTimersToNextTimerAsync();
}
}
async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
const sourceDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'flat-app-source-'));
const archivePath = path.join(os.tmpdir(), `${randomUUID()}.tar.gz`);
@@ -108,6 +156,11 @@ async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
}
}
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
describe('downloadBuild', () => {
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
const buildId = randomUUID();
@@ -465,13 +518,13 @@ describe('downloadBuild', () => {
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('throws SystemError when GraphQL request fails with a network error', async () => {
it('throws SystemError with the cause code after retrying a failing build lookup', async () => {
useFakeBackoffTimers();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: new CombinedError({
networkError: new Error('Network request failed'),
}),
error: createUndiciNetworkError('ECONNRESET'),
});
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
@@ -480,19 +533,81 @@ describe('downloadBuild', () => {
robotAccessToken: null,
extensions: ['app'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
await fireBackoffTimersAsync(5);
await expect(promise).rejects.toBeInstanceOf(SystemError);
await rejection;
await expect(promise).rejects.toMatchObject({
message: `Could not fetch build ${buildId}: [Network] Network request failed`,
message: `Could not fetch build ${buildId}: [Network] fetch failed (ECONNRESET)`,
});
expect(graphqlClient.query).toHaveBeenCalledTimes(6);
expect(datadogLog).toHaveBeenCalledWith('download_build.build_lookup failed on attempt 6', {
event: 'network_retry',
operation: 'download_build.build_lookup',
outcome: 'failed',
attempts: '6',
code: 'ECONNRESET',
});
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('throws SystemError when GraphQL request returns 5xx', async () => {
it.each([
['a network error', createUndiciNetworkError('ECONNRESET'), 'ECONNRESET'],
[
'a transient GraphQL error',
new CombinedError({
graphQLErrors: [
{
message: 'Too many requests',
extensions: { isTransient: true, errorCode: 'TOO_MANY_REQUESTS' },
},
],
response: { status: 200 },
}),
'TOO_MANY_REQUESTS',
],
])('downloads after a build lookup that failed once with %s', async (_, transientError, code) => {
useFakeBackoffTimers();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: Object.assign(new Error('Internal Server Error'), {
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
transientError,
});
jest
.mocked(fetch)
.mockResolvedValue(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
await fireBackoffTimersAsync(1);
const { artifactPath } = await promise;
expect(path.basename(artifactPath)).toBe('app.apk');
expect(graphqlClient.query).toHaveBeenCalledTimes(2);
expect(datadogLog).toHaveBeenCalledWith('download_build.build_lookup recovered on attempt 2', {
event: 'network_retry',
operation: 'download_build.build_lookup',
outcome: 'recovered',
attempts: '2',
code,
});
});
it('throws SystemError when GraphQL request returns 5xx', async () => {
useFakeBackoffTimers();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: new CombinedError({
graphQLErrors: [{ message: 'Internal Server Error' }],
response: { status: 500 },
}),
});
@@ -504,12 +619,14 @@ describe('downloadBuild', () => {
robotAccessToken: null,
extensions: ['app'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
await fireBackoffTimersAsync(5);
await expect(promise).rejects.toBeInstanceOf(SystemError);
await rejection;
await expect(promise).rejects.toMatchObject({
message: `Could not fetch build ${buildId}: Internal Server Error`,
message: `Could not fetch build ${buildId}: [GraphQL] Internal Server Error`,
});
expect(graphqlClient.query).toHaveBeenCalledTimes(6);
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
@@ -534,10 +651,193 @@ describe('downloadBuild', () => {
errorCode: 'EAS_DOWNLOAD_BUILD_FETCH_FAILED',
message: `Could not fetch build ${buildId}: [GraphQL] Build not found`,
});
expect(graphqlClient.query).toHaveBeenCalledTimes(1);
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('downloads after an archive request that failed once with a socket error', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockRejectedValueOnce(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ECONNRESET'))
.mockResolvedValueOnce(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
await fireBackoffTimersAsync(1);
const { artifactPath } = await promise;
expect(path.basename(artifactPath)).toBe('app.apk');
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(2);
});
it('throws SystemError with the cause code after retrying a failing build archive request', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest
.mocked(fetch)
.mockRejectedValue(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ETIMEDOUT'));
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
await fireBackoffTimersAsync(5);
await rejection;
await expect(promise).rejects.toMatchObject({
message: `Could not download the application archive: request to ${APPLICATION_ARCHIVE_URL} failed, reason: connect ETIMEDOUT`,
});
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
expect(datadogLog).toHaveBeenCalledWith('download_build.archive_download failed on attempt 6', {
event: 'network_retry',
operation: 'download_build.archive_download',
outcome: 'failed',
attempts: '6',
code: 'ETIMEDOUT',
});
});
function createBrokenBody(code: string): Readable {
return Readable.from(
(async function* () {
yield Buffer.from('he');
throw Object.assign(new Error(`body failed with ${code}`), { code });
})()
);
}
it.each(['ERR_STREAM_PREMATURE_CLOSE', 'ECONNRESET'])(
'downloads again when the archive body breaks off with %s after the headers arrived',
async code => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({});
const brokenBody = createBrokenBody(code);
jest
.mocked(fetch)
.mockResolvedValueOnce(
createSuccessfulResponse({ body: brokenBody, url: 'https://cdn.test/app.apk' })
)
.mockResolvedValueOnce(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
await fireBackoffTimersAsync(1);
const { artifactPath } = await promise;
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(2);
expect(datadogLog).toHaveBeenCalledWith(
'download_build.archive_download recovered on attempt 2',
expect.objectContaining({ outcome: 'recovered', code })
);
}
);
it('throws SystemError when the build archive body keeps breaking off', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest.mocked(fetch).mockImplementation(async () =>
createSuccessfulResponse({
body: createBrokenBody('ECONNRESET'),
url: 'https://cdn.test/app.apk',
})
);
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
await fireBackoffTimersAsync(5);
await rejection;
await expect(promise).rejects.toMatchObject({
message: 'Could not download the application archive: body failed with ECONNRESET',
});
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
expect(datadogLog).toHaveBeenCalledWith(
'download_build.archive_download failed on attempt 6',
expect.objectContaining({ outcome: 'failed', code: 'ECONNRESET' })
);
});
it('does not start another archive attempt after an abort during the backoff', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockRejectedValueOnce(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ECONNRESET'));
const controller = new AbortController();
const reason = new Error('step timed out');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
signal: controller.signal,
});
const rejection = expect(promise).rejects.toBe(reason);
await waitForBackoffTimerAsync();
controller.abort(reason);
await jest.advanceTimersToNextTimerAsync();
await rejection;
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(1);
});
it('rethrows the raw error of a failing user-supplied archive URL after the retries', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockRejectedValue(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ENOTFOUND'));
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(ActualFetchError);
await fireBackoffTimersAsync(5);
await rejection;
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
});
it('should throw an error if no matching files are found', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
@@ -591,6 +891,131 @@ describe('createDownloadBuildFunction', () => {
expect(graphqlClient.query).not.toHaveBeenCalled();
});
describe('with a device run session token in the job env', () => {
const { Response: ActualResponse } = jest.requireActual(
'node-fetch'
) as typeof import('node-fetch');
const workerApiUrl = 'https://api.expo.test/v2/device-run-session-worker/application';
function createSessionCtx(graphqlClient: Client): any {
return {
graphqlClient,
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
};
}
function createApplicationResponse(buildId: string): Response {
return new ActualResponse(
JSON.stringify({
data: {
build: {
id: buildId,
gitCommitHash: null,
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
},
},
}),
{ status: 200 }
);
}
function createStep(ctx: any, callInputs: Record<string, unknown>) {
return createDownloadBuildFunction(ctx).createBuildStepFromFunctionCall(
createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: { job: { secrets: { robotAccessToken: 'session-token' } } },
}),
{ callInputs }
);
}
it('looks up build_id through the worker API and downloads without an Authorization header', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({});
jest.mocked(fetch).mockImplementation(async url => {
if (url === workerApiUrl) {
return createApplicationResponse(buildId);
}
return {
ok: false,
text: () => Promise.resolve('Internal Server Error'),
status: 500,
} as unknown as Response;
});
await expect(
createStep(createSessionCtx(graphqlClient), {
build_id: buildId,
extensions: ['app'],
}).executeAsync()
).rejects.toThrow('Internal Server Error');
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
workerApiUrl,
expect.objectContaining({
method: 'GET',
headers: { 'expo-device-run-session-token': 'session-token' },
})
);
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ headers: undefined })
);
expect(graphqlClient.query).not.toHaveBeenCalled();
});
it('retries the worker API lookup after a 5xx response', async () => {
useFakeBackoffTimers();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockResolvedValueOnce(new ActualResponse('Service Unavailable', { status: 503 }))
.mockResolvedValueOnce(createApplicationResponse(buildId))
.mockResolvedValueOnce(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const execution = createStep(createSessionCtx(graphqlClient), {
build_id: buildId,
extensions: ['apk'],
}).executeAsync();
await fireBackoffTimersAsync(1);
await execution;
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(3);
expect(jest.mocked(fetch).mock.calls.map(([url]) => url)).toEqual([
workerApiUrl,
workerApiUrl,
APPLICATION_ARCHIVE_URL,
]);
});
it('does not call the worker API for an application_archive_url input', async () => {
const graphqlClient = createMockGraphqlClient({});
jest.mocked(fetch).mockResolvedValue({
ok: false,
text: () => Promise.resolve('Internal Server Error'),
status: 500,
} as unknown as Response);
await expect(
createStep(createSessionCtx(graphqlClient), {
application_archive_url: APPLICATION_ARCHIVE_URL,
extensions: ['app'],
}).executeAsync()
).rejects.toThrow('Internal Server Error');
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(1);
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ headers: undefined })
);
expect(graphqlClient.query).not.toHaveBeenCalled();
});
});
it('should download a build', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
@@ -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);
});
@@ -1,5 +1,6 @@
import { BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { spawnSync } from 'node:child_process';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
@@ -179,17 +180,7 @@ describe(launchApplicationAsync, () => {
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
'adb',
[
'shell',
'am',
'start',
'-a',
'android.intent.action.VIEW',
'-d',
'exp://example.test',
'-n',
'host.exp.exponent/host.exp.exponent.MainActivity',
],
['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', 'exp://example.test'],
{ env: {}, logger }
);
expect(logger.info).toHaveBeenNthCalledWith(
@@ -211,6 +202,263 @@ describe(launchApplicationAsync, () => {
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('quotes Android launch arguments and URLs for the device shell', async () => {
const logger = createMockLogger();
await launchApplicationAsync({
applicationIdentifier: 'dev.example.app',
activityName: 'dev.example.app.MainActivity',
launchArgs: ['-d', 'euxy://tempo', '--es', 'probe', 'hello world', '--es', 'quote', "it's"],
openUrl: 'exp://example.test/path?a=1&b=2',
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger,
});
expect(mockedSpawn).toHaveBeenNthCalledWith(
1,
'adb',
[
'shell',
'am',
'start',
'-d',
'euxy://tempo',
'--es',
'probe',
"'hello world'",
'--es',
'quote',
"'it'\\''s'",
'-n',
'dev.example.app/dev.example.app.MainActivity',
],
{ env: {}, logger }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
'adb',
[
'shell',
'am',
'start',
'-a',
'android.intent.action.VIEW',
'-d',
"'exp://example.test/path?a=1&b=2'",
],
{ env: {}, logger }
);
expect(logger.info).toHaveBeenNthCalledWith(
1,
`Launching dev.example.app with arguments ${JSON.stringify(['-d', 'euxy://tempo', '--es', 'probe', 'hello world', '--es', 'quote', "it's"])}.`
);
});
it('passes iOS launch arguments with spaces unquoted', async () => {
await launchApplicationAsync({
applicationIdentifier: 'com.example.app',
launchArgs: ['-name', 'hello world'],
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: {},
logger: createMockLogger(),
});
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
['simctl', 'launch', 'booted', 'com.example.app', '-name', 'hello world'],
expect.any(Object)
);
});
it('round-trips quoted Android arguments through a POSIX shell', async () => {
const component = 'dev.example.app/dev.example.app.MainActivity$Inner';
const launchArgs = [
'--es',
'text',
'hello world',
'--es',
'json',
'{"a":1,"b":2}',
'--es',
'shell',
'$(printf injected) `printf injected` $HOME ; & | < >',
'--es',
'glob',
'*',
'--es',
'home',
'~',
'--esa',
'list',
'a\\,b,c',
'--es',
'whitespace',
'one\ntwo\tthree',
'--es',
'unicode',
'héllo 🌍',
'--es',
'empty',
'',
];
const openUrl = 'exp://example.test/path?a=1&b=2#fragment';
await launchApplicationAsync({
applicationIdentifier: 'dev.example.app',
activityName: 'dev.example.app.MainActivity$Inner',
launchArgs,
openUrl,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
});
// adb joins the words after `shell` with spaces and the device shell parses the result.
const parsedCommands = mockedSpawn.mock.calls.map(([, args]) => {
const { stdout } = spawnSync(
'/bin/sh',
['-c', `am() { printf '%s\\0' "$@"; }; ${(args as string[]).slice(1).join(' ')}`],
{ encoding: 'utf8' }
);
return stdout.split('\0').slice(0, -1);
});
expect(parsedCommands).toEqual([
['start', ...launchArgs, '-n', component],
['start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
]);
});
it.each([[['--es', 'key', '']], [['-e', 'key', '']], [['-d', '']]])(
'passes empty Android option values as empty arguments (%j)',
async launchArgs => {
await launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
});
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
[
'shell',
'am',
'start',
...launchArgs.map(argument => (argument === '' ? "''" : argument)),
'-n',
'com.example.app/com.example.app.MainActivity',
],
expect.any(Object)
);
}
);
it('explains an Android launch failure when a launch argument is empty', async () => {
const launchError = new Error('adb shell am start exited with non-zero code: 1');
mockedSpawn.mockRejectedValueOnce(launchError);
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs: ['--ez', 'isTest', 'true', ''],
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({
errorCode: 'EAS_LAUNCH_APPLICATION_FAILED',
message: expect.stringContaining('empty argument at position 4'),
cause: launchError,
});
});
it('rethrows an Android launch failure unchanged when no launch argument is empty', async () => {
const launchError = new Error('adb shell am start exited with non-zero code: 1');
mockedSpawn.mockRejectedValueOnce(launchError);
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs: ['--ez', 'isTest', 'true'],
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toBe(launchError);
});
it.each([
{ launchArgs: ['--es', 'key', 'a\0b'] },
{ launchArgs: [], openUrl: 'exp://example.test/a\0b' },
])('rejects NUL characters on Android (%j)', async ({ launchArgs, openUrl }) => {
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs,
openUrl,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
expect(mockedSpawn).not.toHaveBeenCalledWith(
'adb',
expect.arrayContaining([expect.stringContaining('\0')]),
expect.any(Object)
);
});
it('accepts an Android launch command of exactly 65,000 bytes', async () => {
const fixedLength = 'am start --es key -n com.example.app/com.example.app.MainActivity'.length;
await launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs: ['--es', 'key', 'a'.repeat(65_000 - fixedLength)],
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
});
expect(mockedSpawn).toHaveBeenCalledTimes(1);
});
it('rejects an Android URL command of 65,001 bytes', async () => {
const fixedLength = 'am start -a android.intent.action.VIEW -d exp://example.test/'.length;
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
openUrl: `exp://example.test/${'a'.repeat(65_001 - fixedLength)}`,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
expect(mockedSpawn).toHaveBeenCalledTimes(1);
});
it('rejects an Android launch command that is too long after quoting', async () => {
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs: ['--es', 'quotes', "'".repeat(20_000)],
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('launches using application metadata passed to the build step', async () => {
const launchApplication = createLaunchApplicationFunction();
const buildStep = launchApplication.createBuildStepFromFunctionCall(
@@ -240,16 +488,36 @@ describe(launchApplicationAsync, () => {
],
expect.any(Object)
);
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', 'exp://example.test'],
expect.any(Object)
);
});
it('quotes Android launch arguments passed to the build step', async () => {
const launchApplication = createLaunchApplicationFunction();
const buildStep = launchApplication.createBuildStepFromFunctionCall(
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
{
callInputs: {
application_identifier: 'com.example.app',
activity_name: 'com.example.app.MainActivity',
launch_args: ['--es', 'probe', 'hello world'],
},
}
);
await buildStep.executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
[
'shell',
'am',
'start',
'-a',
'android.intent.action.VIEW',
'-d',
'exp://example.test',
'--es',
'probe',
"'hello world'",
'-n',
'com.example.app/com.example.app.MainActivity',
],
@@ -14,6 +14,12 @@ import {
registerLocalEgressResources,
stopLocalEgressResourcesAsync,
} from '../../utils/localEgress';
import {
reportLocalEgressGuardCoverageAsync,
resolveLocalEgressServeSimBootEnvironmentAsync,
startLocalEgressGuardRelayAsync,
verifyLocalEgressGuardAsync,
} from '../../utils/localEgressGuard';
import {
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
@@ -29,8 +35,8 @@ 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', () => ({
...jest.requireActual('../startIosSimulator'),
bootIosSimulatorAsync: jest
.fn()
.mockResolvedValue({ deviceIdentifier: 'sim', udid: 'sim', displayName: 'iPhone' }),
@@ -51,6 +57,13 @@ jest.mock('../../utils/localEgress', () => ({
readLocalEgressHandoffAsync: jest.fn(),
monitorLocalEgressAsync: jest.fn(),
}));
jest.mock('../../utils/localEgressGuard', () => ({
...jest.requireActual('../../utils/localEgressGuard'),
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn(),
startLocalEgressGuardRelayAsync: jest.fn(),
reportLocalEgressGuardCoverageAsync: jest.fn(),
verifyLocalEgressGuardAsync: jest.fn(),
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
getDeviceRunSessionIdOrThrow: () => 'session-id',
@@ -126,9 +139,20 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
jest.mocked(isProcessDescendantOfAsync).mockResolvedValue(true);
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ udid: 'sim' }] as never);
.mockResolvedValue([{ udid: 'sim', name: 'iPhone 17' }] as never);
jest.mocked(IosSimulatorUtils.resolveUdidAsync).mockResolvedValue('sim' as never);
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(handoff);
jest
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
.mockImplementation(async () =>
(await readLocalEgressHandoffAsync())
? { SERVE_SIM_ADDITIONAL_DYLIBS: '/test/egress-guard.dylib' }
: null
);
jest.mocked(startLocalEgressGuardRelayAsync).mockResolvedValue(undefined);
jest.mocked(reportLocalEgressGuardCoverageAsync).mockResolvedValue(null);
jest.mocked(verifyLocalEgressGuardAsync).mockResolvedValue(undefined);
jest.mocked(monitorLocalEgressAsync).mockResolvedValue(undefined);
openPreview.mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
@@ -138,6 +162,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
});
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: openPreview,
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopPreview,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
@@ -0,0 +1,202 @@
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',
runtimeApi: { addBuildLogMask: jest.fn() },
} 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('hands the build log secrets filter to the agent run', async () => {
const runAgentSpy = jest.spyOn(agentRun, 'runAgentAsync').mockResolvedValue();
try {
await runAsync();
expect(runAgentSpy).toHaveBeenCalledWith(
expect.objectContaining({ addBuildLogMask: ctx.runtimeApi.addBuildLogMask })
);
} finally {
runAgentSpy.mockRestore();
}
});
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 } }],
['no build log secrets filter', { ctx: { ...ctx, runtimeApi: {} } }],
])('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".'
);
});
});
@@ -19,6 +19,7 @@ import {
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
} from '../../utils/remoteDeviceRunSession';
import { createProcessOutput } from '../../utils/processOutput';
import { type StartupTasks, createStartupTasks } from '../../utils/startupTasks';
import { runAgentDeviceRemoteSessionAsync } from '../startAgentDeviceRemoteSession';
@@ -69,11 +70,14 @@ 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. */
/** Runs an Android session that is ready, or an iOS session with no application preparation. */
async function runAsync(
logger: { info: jest.Mock; warn: jest.Mock; child: jest.Mock },
runtimePlatform: BuildRuntimePlatform,
capture: CaptureInputs = NO_CAPTURE
capture: CaptureInputs = NO_CAPTURE,
disableShare = false,
privatePreviewUrl?: string,
maxIdleTimeMinutes?: number
): Promise<void> {
await runAgentDeviceRemoteSessionAsync(ctx, {
env: {},
@@ -85,11 +89,16 @@ async function runAsync(
ngrokAuthtoken: 'ngrok-token',
},
packageVersion: undefined,
maxIdleTimeMinutes: undefined,
maxIdleTimeMinutes,
maxDurationSeconds: undefined,
capture,
disableShare,
privatePreviewUrl,
tasks: createStartupTasks(logger as never),
device: { booted: Promise.resolve(), ready: Promise.resolve() },
device:
runtimePlatform === BuildRuntimePlatform.DARWIN
? { iosSimulatorUdid: 'selected-udid', application: Promise.resolve() }
: { booted: Promise.resolve(), ready: Promise.resolve() },
});
}
@@ -119,13 +128,24 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
subdomainId: 'agent-device-abc',
stopAsync: mockTunnelStopAsync,
});
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
const host = {
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.tunnel.example.com',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: mockPreviewStopAsync,
};
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async (_ctx, options) => {
try {
await options.application;
options.signal?.throwIfAborted();
} catch (error) {
await mockPreviewStopAsync();
throw error;
}
return host;
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
@@ -145,7 +165,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, timeoutMs: 60_000 })
);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
@@ -163,6 +183,42 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
});
it('redacts learned daemon credentials in arbitrary output and retained diagnostics', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const token = 'daemon secret/+';
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 5678, token });
jest.mocked(startNgrokTunnelAsync).mockImplementation(async () => {
const options = jest.mocked(spawnDetached).mock.calls[0][0];
expect(options.secrets).toContain(token);
const output = createProcessOutput(options.logger, options.secrets);
output.stdout.append('unrecognized value: daemon sec');
output.stdout.append('ret/+\n');
output.stderr.append(`escaped value: ${encodeURIComponent(token)}\n`);
expect(output.getOutput()).toBe(
'unrecognized value: [REDACTED]\nescaped value: [REDACTED]\n'
);
return { url: 'https://daemon.test', subdomainId: 'daemon', stopAsync: mockTunnelStopAsync };
});
await runAsync(logger, BuildRuntimePlatform.DARWIN);
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(token);
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(encodeURIComponent(token));
});
it('resets the idle timer on preview input from the session host', async () => {
const previewInputAt = new Date('2026-10-19T12:00:00Z');
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(ctx, {
application: Promise.resolve(),
} as never);
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(previewInputAt);
await runAsync(logger, BuildRuntimePlatform.LINUX, NO_CAPTURE, false, undefined, 5);
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(previewInputAt);
});
it('hands network capture to serve-sim', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
@@ -177,6 +233,24 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
);
});
it('hands a private preview choice to the session host', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
await runAsync(
logger,
BuildRuntimePlatform.DARWIN,
NO_CAPTURE,
true,
'https://expo.dev/private-preview'
);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
disableShare: true,
privatePreviewUrl: 'https://expo.dev/private-preview',
})
);
});
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()!(
@@ -245,14 +319,14 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
});
describe('runAgentDeviceRemoteSessionAsync with a device that is still starting', () => {
function deferred(): {
promise: Promise<void>;
resolve: () => void;
function deferred<T = void>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (e: Error) => void;
} {
let resolve!: () => void;
let resolve!: (value: T) => void;
let reject!: (e: Error) => void;
const promise = new Promise<void>((res, rej) => {
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
@@ -260,18 +334,26 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
return { promise, resolve, reject };
}
type Device = { booted: Promise<unknown>; ready: Promise<unknown> };
function startSession(device: Device | ((tasks: StartupTasks) => Device)) {
type Device = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['device'];
function startSession(
device: Device | ((tasks: StartupTasks) => Device),
signal?: AbortSignal
) {
const logger = {
info: jest.fn(),
warn: jest.fn(),
child: jest.fn().mockReturnThis(),
} as never;
const tasks = createStartupTasks(logger);
const tasks = createStartupTasks(logger, signal);
const sessionDevice = typeof device === 'function' ? device(tasks) : device;
return runAgentDeviceRemoteSessionAsync(ctx, {
env: {},
logger,
runtimePlatform: BuildRuntimePlatform.DARWIN,
signal,
runtimePlatform:
'iosSimulatorUdid' in sessionDevice
? BuildRuntimePlatform.DARWIN
: BuildRuntimePlatform.LINUX,
sessionEnv: {
deviceRunSessionId: 'device-run-session-id',
ngrokTunnelDomain: 'tunnel.example.com',
@@ -282,7 +364,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
maxDurationSeconds: undefined,
capture: NO_CAPTURE,
tasks,
device: typeof device === 'function' ? device(tasks) : device,
device: sessionDevice,
});
}
@@ -292,7 +374,31 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
}
}
it('starts the daemon during the boot and the session host after it', async () => {
it('aborts parallel startup before host failure cleanup finishes', async () => {
const failure = new Error('guard refused startup');
const cleanup = deferred();
const notified = deferred();
let startupTasks!: StartupTasks;
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async (_ctx, options) => {
options.onStartupError?.(failure);
notified.resolve();
await cleanup.promise;
throw failure;
});
const session = startSession(tasks => {
startupTasks = tasks;
return { iosSimulatorUdid: 'selected-udid' };
});
const rejected = expect(session).rejects.toBe(failure);
await notified.promise;
expect(startupTasks.signal.aborted).toBe(true);
expect(startupTasks.signal.reason).toBe(failure);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
cleanup.resolve();
await rejected;
});
it('starts the daemon during the Android boot and the session host after it', async () => {
const booted = deferred();
const session = startSession({ booted: booted.promise, ready: booted.promise });
await flushAsync();
@@ -304,10 +410,237 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
booted.resolve();
await session;
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
runtimePlatform: BuildRuntimePlatform.LINUX,
timeoutMs: 60_000,
iosSimulatorUdid: undefined,
})
);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
});
it('reports the session as ready only after the app is launched', async () => {
it('preserves a failed download when aborting daemon installation rejects first', async () => {
const failure = new Error('download failed: build not found');
const installStarted = deferred();
jest.mocked(spawn).mockImplementation((async (_command, _args, options) => {
return await new Promise((_resolve, reject) => {
options!.signal!.addEventListener(
'abort',
() => {
const error = new Error('The operation was aborted');
error.name = 'AbortError';
reject(error);
},
{ once: true }
);
installStarted.resolve();
});
}) as typeof spawn);
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async (_ctx, { signal }) => {
return await new Promise((_resolve, reject) => {
signal!.addEventListener(
'abort',
() => {
setTimeout(() => reject(signal!.reason), 20);
},
{ once: true }
);
});
});
const applicationReady = Promise.resolve();
const session = startSession(tasks => {
void tasks.run('build download', async () => {
await installStarted.promise;
throw failure;
});
return { iosSimulatorUdid: 'selected-udid', application: applicationReady };
});
await expect(session).rejects.toBe(failure);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('boots the host during download and releases startup only after preparation', async () => {
const hostReady = deferred();
const application = deferred<{
installAppPath: string;
launchAppIdentifier: string;
launchArgs: string[];
openUrl: string;
}>();
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
ctx,
{} as never
);
const release = jest.fn();
jest
.mocked(startDeviceSessionHostAsync)
.mockClear()
.mockImplementation(async (_ctx, options) => {
release(await options.application);
await hostReady.promise;
return host;
});
const applicationReady = application.promise;
const session = startSession({
iosSimulatorUdid: 'selected-udid',
application: applicationReady,
});
await flushAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
expect(release).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
const startup = {
installAppPath: '/tmp/App.app',
launchAppIdentifier: 'dev.example.app',
launchArgs: ['--flag'],
openUrl: 'example://screen',
};
application.resolve(startup);
await flushAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
application: applicationReady,
iosSimulatorUdid: 'selected-udid',
timeoutMs: 60_000,
signal: expect.any(AbortSignal),
})
);
expect(release).toHaveBeenCalledWith(startup);
expect(host.openPreviewAsync).not.toHaveBeenCalled();
hostReady.resolve();
await session;
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
it('passes the guard boot environment with the startup installation and launch', async () => {
const startup = {
installAppPath: '/tmp/App.app',
launchAppIdentifier: 'dev.example.app',
launchArgs: ['--flag'],
openUrl: 'example://screen',
};
const bootEnv = {
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
SIMCTL_CHILD_EAS_EGRESS_GUARD_MODE: 'block',
SIMCTL_CHILD_EAS_EGRESS_GUARD_LOG: '/tmp/egress-guard.log',
SIMCTL_CHILD_HTTP_PROXY: 'http://127.0.0.1:8899',
};
await startSession({
iosSimulatorUdid: 'guarded-udid',
bootEnv,
application: Promise.resolve(startup),
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
application: expect.any(Promise),
bootEnv,
iosSimulatorUdid: 'guarded-udid',
})
);
const hostOptions = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
expect(hostOptions.bootEnv).toBe(bootEnv);
expect(hostOptions.bootEnv).not.toHaveProperty('DYLD_INSERT_LIBRARIES');
expect(hostOptions.bootEnv).not.toHaveProperty('SIMCTL_CHILD_DYLD_INSERT_LIBRARIES');
});
it('cleans up a host returned after cancellation without configuring the device', async () => {
const hostReady = deferred();
const controller = new AbortController();
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
ctx,
{} as never
);
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async () => {
await hostReady.promise;
return host;
});
const applicationReady = Promise.resolve();
const session = startSession(
{ iosSimulatorUdid: 'selected-udid', application: applicationReady },
controller.signal
);
const failure = expect(session).rejects.toThrow('cancelled');
await flushAsync();
controller.abort(new Error('cancelled'));
hostReady.resolve();
await failure;
expect(host.openPreviewAsync).not.toHaveBeenCalled();
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('drains in-flight application preparation and stops the waiting host after cancellation', async () => {
const controller = new AbortController();
const appReady = deferred();
const session = startSession(
{ iosSimulatorUdid: 'selected-udid', application: appReady.promise },
controller.signal
);
const failure = expect(session).rejects.toThrow('cancelled');
await flushAsync();
controller.abort(new Error('cancelled'));
await flushAsync();
expect(mockPreviewStopAsync).not.toHaveBeenCalled();
appReady.resolve();
await failure;
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('does not report readiness when cancellation arrives during preview startup', async () => {
const controller = new AbortController();
const previewReady = deferred();
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
ctx,
{} as never
);
const preview = await host.openPreviewAsync({
baseDomain: 'tunnel.example.com',
authtoken: 'ngrok-token',
});
jest.mocked(host.openPreviewAsync).mockImplementation(async () => {
await previewReady.promise;
return preview;
});
const session = startSession(
{ iosSimulatorUdid: 'selected-udid', application: Promise.resolve() },
controller.signal
);
const failure = expect(session).rejects.toThrow('cancelled');
await flushAsync();
controller.abort(new Error('cancelled'));
previewReady.resolve();
await failure;
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('does not start resources for an already cancelled session', async () => {
const controller = new AbortController();
controller.abort(new Error('cancelled'));
const applicationReady = Promise.resolve();
await expect(
startSession(
{ iosSimulatorUdid: 'selected-udid', application: applicationReady },
controller.signal
)
).rejects.toThrow('cancelled');
expect(spawn).not.toHaveBeenCalled();
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
});
it('starts the Android host before application readiness and waits before reporting ready', async () => {
const ready = deferred();
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
await flushAsync();
@@ -317,10 +650,11 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
ready.resolve();
await session;
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
});
it('stops waiting for the boot when the daemon fails', async () => {
it('stops waiting for the Android boot when the daemon fails', async () => {
jest.mocked(waitForFileAsync).mockRejectedValue(new Error('no daemon credentials'));
const neverBooted = new Promise<void>(() => {});
@@ -334,7 +668,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('stops the session host when the app fails while the daemon install hangs', async () => {
it('cancels daemon installation and stops the Android host when the download fails', async () => {
jest.mocked(spawn).mockImplementation(((
command: string,
args: string[],
@@ -350,21 +684,22 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
await flushAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
ready.reject(new Error('simctl install failed'));
ready.reject(new Error('download failed'));
await expect(session).rejects.toThrow('simctl install failed');
await expect(session).rejects.toThrow('download failed');
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(spawnDetached).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('stops everything it started when the app install fails', async () => {
it('stops the Android host, daemon and tunnel when the download fails', async () => {
const ready = deferred();
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
await flushAsync();
ready.reject(new Error('simctl install failed'));
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
ready.reject(new Error('download failed'));
await expect(session).rejects.toThrow('simctl install failed');
await expect(session).rejects.toThrow('download failed');
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
@@ -93,6 +93,28 @@ describe(startAgentDeviceDaemonAsync, () => {
}) as never);
});
it.each([false, true])(
'shares the daemon secret list through git fallback=%s',
async fallback => {
if (fallback) {
jest.mocked(spawn).mockResolvedValue({ stdout: '' } as never);
}
const secrets: string[] = [];
const handle = await startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
env: {},
logger,
secrets,
});
const options = jest.mocked(spawnDetached).mock.calls[0][0];
expect(options.command).toBe(fallback ? 'bun' : 'node');
expect(options.secrets).toBe(secrets);
secrets.push('learned-daemon-token');
expect(options.secrets).toContain('learned-daemon-token');
await handle.stopAsync();
}
);
it('installs with bun add by default and launches the published daemon', async () => {
const handle = await startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
@@ -108,6 +130,8 @@ describe(startAgentDeviceDaemonAsync, () => {
const addCwd = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
expect(spawnDetached).toHaveBeenCalledWith({
command: 'node',
logger,
secrets: undefined,
args: [path.join(addCwd, 'node_modules/agent-device/dist/src/internal/daemon.js')],
env: expect.objectContaining({
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
@@ -121,6 +145,20 @@ describe(startAgentDeviceDaemonAsync, () => {
await expect(fs.promises.access(addCwd)).rejects.toThrow();
});
it('removes the install directory when the daemon cannot be stopped', async () => {
const stopError = new Error('drain timed out');
stopAsync.mockRejectedValueOnce(stopError);
const handle = await startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
env: {},
logger,
});
const addCwd = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
await expect(handle.stopAsync()).rejects.toBe(stopError);
await expect(fs.promises.access(addCwd)).rejects.toThrow();
});
it('installs with npm when EAS_OVERRIDE_PACKAGE_MANAGER is npm', async () => {
await startAgentDeviceDaemonAsync({
packageVersion: undefined,
@@ -136,10 +174,15 @@ describe(startAgentDeviceDaemonAsync, () => {
});
it('kills the install and does not fall back to git when aborted', async () => {
let installStarted!: () => void;
const started = new Promise<void>(resolve => {
installStarted = resolve;
});
jest.mocked(spawn).mockImplementation(
((_command: string, _args: string[], options?: { signal?: AbortSignal }) =>
new Promise((_resolve, reject) => {
options!.signal!.addEventListener('abort', () => reject(options!.signal!.reason));
installStarted();
})) as never
);
const controller = new AbortController();
@@ -151,7 +194,7 @@ describe(startAgentDeviceDaemonAsync, () => {
logger,
signal: controller.signal,
});
await new Promise(resolve => setImmediate(resolve));
await started;
controller.abort(failure);
await expect(daemon).rejects.toBe(failure);
@@ -199,6 +242,8 @@ describe(startAgentDeviceDaemonAsync, () => {
);
expect(spawnDetached).toHaveBeenCalledWith({
command: 'bun',
logger,
secrets: undefined,
args: ['run', 'src/daemon.ts'],
cwd: '/tmp/agent-device-src',
env: expect.objectContaining({
@@ -1,10 +1,20 @@
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import { UserError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { DeviceRunSessionWorkerApi } from '../../utils/deviceRunSessionWorkerApi';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { stopLocalEgressResourcesAsync } from '../../utils/localEgress';
import {
resolveLocalEgressServeSimBootEnvironmentAsync,
startLocalEgressGuardRelayAsync,
} from '../../utils/localEgressGuard';
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
import { downloadBuildAsync } from '../downloadBuild';
import { installBuildAsync } from '../installBuild';
import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact';
import { launchApplicationAsync } from '../launchApplication';
import {
getAgentDeviceRemoteSessionEnvOrThrow,
@@ -14,17 +24,30 @@ import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceS
import { startAndroidEmulatorAsync } from '../startAndroidEmulator';
import { bootIosSimulatorAsync } from '../startIosSimulator';
jest.mock('../../utils/localEgressSession', () => ({
withLocalEgressSession: (fn: unknown) => fn,
jest.mock('../../utils/localEgress', () => ({
stopLocalEgressResourcesAsync: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../../utils/localEgressGuard', () => ({
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn(),
startLocalEgressGuardRelayAsync: jest.fn(),
}));
jest.mock('@expo/turtle-spawn');
jest.mock('../../../utils/IosSimulatorUtils');
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
}));
jest.mock('../startIosSimulator', () => ({ bootIosSimulatorAsync: jest.fn() }));
jest.mock('../startIosSimulator', () => ({
...jest.requireActual('../startIosSimulator'),
bootIosSimulatorAsync: jest.fn(),
}));
jest.mock('../startAndroidEmulator', () => ({ startAndroidEmulatorAsync: jest.fn() }));
jest.mock('../downloadBuild', () => ({ downloadBuildAsync: jest.fn() }));
jest.mock('../installBuild', () => ({ installBuildAsync: jest.fn() }));
jest.mock('../../utils/iosAppArtifact', () => ({
...jest.requireActual('../../utils/iosAppArtifact'),
readIosApplicationIdentifierAsync: jest.fn(),
}));
jest.mock('../launchApplication', () => ({
...jest.requireActual('../launchApplication'),
launchApplicationAsync: jest.fn(),
@@ -42,7 +65,17 @@ const sessionEnv = {
ngrokAuthtoken: 'ngrok-token',
};
type Device = { booted: Promise<unknown>; ready: Promise<unknown> };
type Device = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['device'];
let deviceLaunch: unknown;
function runDevicePreparationAsync(device: Device): Promise<unknown> {
return (
'iosSimulatorUdid' in device ? (device.application ?? Promise.resolve()) : device.ready
).then(async launch => {
deviceLaunch = launch;
return launch;
});
}
function deferred<T = void>(): {
promise: Promise<T>;
@@ -67,9 +100,11 @@ async function flushAsync(): Promise<void> {
function runStep(
runtimePlatform: BuildRuntimePlatform,
inputValues: Record<string, unknown> = {}
inputValues: Record<string, unknown> = {},
signal?: AbortSignal,
stepCtx: CustomBuildContext = ctx
): Promise<void> {
const buildFunction = createStartAgentDeviceSessionBuildFunction(ctx);
const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx);
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const inputs = Object.fromEntries(
[
@@ -84,6 +119,10 @@ function runStep(
'open_url',
'network_capture',
'network_capture_fields',
'public_preview',
'private_preview_url',
'frame_ancestors',
'cors_origins',
'package_version',
'max_idle_time_minutes',
'max_duration_seconds',
@@ -97,22 +136,32 @@ function runStep(
staticContext: { job: { secrets: { robotAccessToken: 'robot-token' } } },
},
} as unknown as BuildStepContext,
{ inputs, outputs: {}, env: {}, signal: undefined } as never
{ inputs, outputs: {}, env: {}, signal } as never
) as Promise<void>;
}
/** The `device` promises handed to the shared agent-device session code. */
function sessionDevice(): Device {
return jest.mocked(runAgentDeviceRemoteSessionAsync).mock.calls[0][1].device;
}
describe(createStartAgentDeviceSessionBuildFunction, () => {
beforeEach(() => {
deviceLaunch = undefined;
jest.clearAllMocks();
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(null);
jest.mocked(startLocalEgressGuardRelayAsync).mockResolvedValue(undefined);
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Shutdown' } as never);
jest.mocked(spawn).mockResolvedValue({ stdout: '' } as never);
jest.mocked(IosSimulatorUtils.resolveUdidAsync).mockResolvedValue('selected-udid' as never);
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ name: 'iPhone 17', udid: 'SELECTED-UDID' }] as never);
jest.mocked(getAgentDeviceRemoteSessionEnvOrThrow).mockReturnValue(sessionEnv);
jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async (_ctx, { device }) => {
await device.ready;
});
jest
.mocked(runAgentDeviceRemoteSessionAsync)
.mockImplementation(async (_ctx, { device, tasks }) => {
await tasks.untilAborted(runDevicePreparationAsync(device));
});
jest.mocked(bootIosSimulatorAsync).mockResolvedValue({
deviceIdentifier: 'iPhone 17' as never,
udid: 'udid' as never,
@@ -128,22 +177,31 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
.mocked(installBuildAsync)
.mockResolvedValue({ applicationIdentifier: 'dev.example.app', activityName: '.Main' });
jest.mocked(launchApplicationAsync).mockResolvedValue(undefined);
jest.mocked(readIosApplicationIdentifierAsync).mockResolvedValue('dev.example.app');
});
it('downloads the build and starts the session while the iOS Simulator boots', async () => {
const boot = deferred<Awaited<ReturnType<typeof bootIosSimulatorAsync>>>();
jest.mocked(bootIosSimulatorAsync).mockReturnValue(boot.promise);
const step = runStep(BuildRuntimePlatform.DARWIN, {
it('hands guarded iOS boot to serve-sim and starts reporting before host startup', async () => {
const bootEnv = {
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
SIMCTL_CHILD_EAS_EGRESS_GUARD_MODE: 'block',
SIMCTL_CHILD_http_proxy: 'http://127.0.0.1:8899',
};
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(bootEnv);
await runStep(BuildRuntimePlatform.DARWIN, {
build_id: 'build-id',
launch_args: ['-flag'],
open_url: 'exp://example.test',
});
await flushAsync();
expect(
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
).toBeLessThan(jest.mocked(bootIosSimulatorAsync).mock.invocationCallOrder[0]);
).toBeLessThan(
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mock.invocationCallOrder[0]
);
expect(startLocalEgressGuardRelayAsync).toHaveBeenCalledTimes(1);
expect(jest.mocked(startLocalEgressGuardRelayAsync).mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(runAgentDeviceRemoteSessionAsync).mock.invocationCallOrder[0]
);
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({
buildId: 'build-id',
@@ -153,32 +211,180 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
})
);
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(1);
// The install needs the booted Simulator.
expect(installBuildAsync).not.toHaveBeenCalled();
boot.resolve({
deviceIdentifier: 'iPhone 17' as never,
udid: 'udid' as never,
displayName: '',
const device = sessionDevice();
expect(device).toEqual({
iosSimulatorUdid: 'SELECTED-UDID',
bootEnv,
application: expect.any(Promise),
});
await step;
expect(spawn).not.toHaveBeenCalled();
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
artifactPath: '/tmp/App.app',
env: {},
});
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
expect(deviceLaunch).toEqual({
installAppPath: '/tmp/App.app',
launchAppIdentifier: 'dev.example.app',
launchArgs: ['-flag'],
openUrl: 'exp://example.test',
});
});
expect(installBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({ artifactPath: '/tmp/App.app' })
);
expect(launchApplicationAsync).toHaveBeenCalledWith(
expect.objectContaining({
applicationIdentifier: 'dev.example.app',
launchArgs: ['-flag'],
openUrl: 'exp://example.test',
})
);
expect(jest.mocked(installBuildAsync).mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(launchApplicationAsync).mock.invocationCallOrder[0]
it('shuts down only the selected guarded Booted device before host startup', async () => {
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
});
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Booted' } as never);
await runStep(BuildRuntimePlatform.DARWIN);
expect(IosSimulatorUtils.getDeviceAsync).toHaveBeenCalledWith({
udid: 'SELECTED-UDID',
env: {},
});
expect(spawn).toHaveBeenCalledTimes(1);
expect(spawn).toHaveBeenCalledWith('xcrun', ['simctl', 'shutdown', 'SELECTED-UDID'], {
env: {},
logger: expect.any(Object),
signal: expect.any(AbortSignal),
});
expect(jest.mocked(spawn).mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(runAgentDeviceRemoteSessionAsync).mock.invocationCallOrder[0]
);
});
it('waits for the download when the Simulator boots first', async () => {
it('downloads build_id through the device run session worker API when the job has a session token', async () => {
const sessionCtx = {
graphqlClient,
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
} as unknown as CustomBuildContext;
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' }, undefined, sessionCtx);
expect(downloadBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({
buildId: 'build-id',
deviceRunSessionWorkerApi: expect.any(DeviceRunSessionWorkerApi),
})
);
});
it('does not use the worker API without a session token or for an archive URL', async () => {
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' });
expect(downloadBuildAsync).toHaveBeenLastCalledWith(
expect.objectContaining({ buildId: 'build-id', deviceRunSessionWorkerApi: null })
);
const sessionCtx = {
graphqlClient,
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
} as unknown as CustomBuildContext;
await runStep(
BuildRuntimePlatform.LINUX,
{ application_archive_url: 'https://example.test/app.tar.gz' },
undefined,
sessionCtx
);
expect(downloadBuildAsync).toHaveBeenLastCalledWith(
expect.objectContaining({
applicationArchiveUrl: 'https://example.test/app.tar.gz',
deviceRunSessionWorkerApi: null,
})
);
});
it('fails before download or host startup when guarded device shutdown fails', async () => {
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
});
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Booted' } as never);
jest.mocked(spawn).mockRejectedValue(new Error('shutdown failed'));
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'shutdown failed'
);
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
});
it('provides Simulator preparation and downloaded startup options to the remote session', async () => {
const hostReady = deferred();
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
});
jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async (_ctx, { device }) => {
expect('iosSimulatorUdid' in device).toBe(true);
if (!('iosSimulatorUdid' in device)) {
return;
}
await hostReady.promise;
deviceLaunch = await device.application;
});
const step = runStep(BuildRuntimePlatform.DARWIN, {
device_identifier: 'iPhone 17',
build_id: 'build-id',
launch_args: ['-flag'],
open_url: 'exp://example.test',
});
await flushAsync();
expect(IosSimulatorUtils.resolveUdidAsync).toHaveBeenCalledWith({
deviceIdentifier: 'iPhone 17',
env: {},
});
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).toHaveBeenCalledTimes(1);
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
artifactPath: '/tmp/App.app',
env: {},
});
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
expect(deviceLaunch).toBeUndefined();
hostReady.resolve();
await step;
expect(deviceLaunch).toEqual({
installAppPath: '/tmp/App.app',
launchAppIdentifier: 'dev.example.app',
launchArgs: ['-flag'],
openUrl: 'exp://example.test',
});
});
it('fails a guarded session before download or host startup if its boot files are missing', async () => {
jest
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
.mockRejectedValue(new Error('missing bin/egress-guard-check'));
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'missing bin/egress-guard-check'
);
expect(startLocalEgressGuardRelayAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
expect(spawn).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
});
it('releases guard reporting and egress resources when host startup fails', async () => {
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
});
jest
.mocked(runAgentDeviceRemoteSessionAsync)
.mockRejectedValueOnce(new Error('guard check failed'));
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'guard check failed'
);
expect(startLocalEgressGuardRelayAsync).toHaveBeenCalledTimes(1);
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
});
it('waits for the download before handing the app to the host', async () => {
const download = deferred<{ artifactPath: string }>();
jest.mocked(downloadBuildAsync).mockReturnValue(download.promise);
@@ -190,16 +396,21 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
download.resolve({ artifactPath: '/tmp/App.app' });
await step;
expect(installBuildAsync).toHaveBeenCalledTimes(1);
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
expect(installBuildAsync).not.toHaveBeenCalled();
});
it('makes the session ready when the device boots if there is no app', async () => {
it('passes the selected Simulator to the host even when there is no app', async () => {
await runStep(BuildRuntimePlatform.DARWIN);
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
await expect(sessionDevice().ready).resolves.toBeUndefined();
expect(sessionDevice()).toEqual({
iosSimulatorUdid: 'SELECTED-UDID',
bootEnv: undefined,
application: undefined,
});
});
it('boots the Android Emulator with the device inputs', async () => {
@@ -231,23 +442,10 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
});
it('fails the session when the download fails, without an unhandled rejection', async () => {
const boot = deferred<Awaited<ReturnType<typeof bootIosSimulatorAsync>>>();
jest.mocked(bootIosSimulatorAsync).mockReturnValue(boot.promise);
jest.mocked(downloadBuildAsync).mockRejectedValue(new Error('download failed'));
const stepFailure = expect(
runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })
).rejects.toThrow('download failed');
// The download fails while the boot still runs. Nothing inside the step may leave
// that rejection unhandled; Jest fails the test if it does.
await flushAsync();
boot.resolve({
deviceIdentifier: 'iPhone 17' as never,
udid: 'udid' as never,
displayName: '',
});
await stepFailure;
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'download failed'
);
expect(installBuildAsync).not.toHaveBeenCalled();
});
@@ -276,23 +474,24 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
jest
.mocked(runAgentDeviceRemoteSessionAsync)
.mockImplementation(async (_ctx, { tasks, device }) => {
const ready = runDevicePreparationAsync(device);
await beforeFailure?.();
const daemon = tasks.run('agent-device daemon', async () => {
throw new Error('daemon failed');
});
try {
await Promise.all([daemon, device.ready]);
await Promise.all([daemon, ready]);
} finally {
await Promise.allSettled([daemon, device.ready]);
await Promise.allSettled([daemon, ready]);
}
});
}
it('stops the download and waits for it when the boot fails', async () => {
it('stops the download and waits for it when Android boot fails', async () => {
const download = mockStalledDownload();
jest.mocked(bootIosSimulatorAsync).mockRejectedValue(new Error('boot failed'));
jest.mocked(startAndroidEmulatorAsync).mockRejectedValue(new Error('boot failed'));
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
await expect(runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' })).rejects.toThrow(
'boot failed'
);
@@ -316,25 +515,79 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
expect(launchApplicationAsync).not.toHaveBeenCalled();
});
it('does not launch the app when startup fails during the install', async () => {
const install = deferred<Awaited<ReturnType<typeof installBuildAsync>>>();
const installStarted = deferred();
jest.mocked(installBuildAsync).mockImplementation(() => {
installStarted.resolve();
return install.promise;
it('drains the download when host startup fails before app preparation', async () => {
const download = mockStalledDownload();
jest.mocked(runAgentDeviceRemoteSessionAsync).mockRejectedValue(new Error('host failed'));
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'host failed'
);
expect(download.aborted()).toBe(true);
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
});
it('does not start work for an already cancelled step', async () => {
const controller = new AbortController();
controller.abort(new Error('cancelled'));
await expect(
runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal)
).rejects.toThrow('cancelled');
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('cancels and drains a pending download through the external signal', async () => {
const controller = new AbortController();
const download = mockStalledDownload();
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal);
const failure = expect(step).rejects.toThrow('cancelled');
await flushAsync();
controller.abort(new Error('cancelled'));
await failure;
expect(download.aborted()).toBe(true);
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
});
it('does not start device or download work after cancellation during Xcode selection', async () => {
const controller = new AbortController();
const selected = deferred();
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockReturnValue(selected.promise);
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal);
const failure = expect(step).rejects.toThrow('cancelled');
await flushAsync();
controller.abort(new Error('cancelled'));
selected.resolve();
await failure;
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('drains app preparation when the daemon fails', async () => {
const metadata = deferred<string>();
const metadataStarted = deferred();
jest.mocked(readIosApplicationIdentifierAsync).mockImplementation(() => {
metadataStarted.resolve();
return metadata.promise;
});
mockSessionWithFailingDaemon(async () => {
await installStarted.promise;
// The install finishes only after the daemon failed.
setImmediate(() => install.resolve({ applicationIdentifier: 'dev.example.app' }));
await metadataStarted.promise;
setImmediate(() => metadata.resolve('dev.example.app'));
});
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'daemon failed'
);
expect(installBuildAsync).toHaveBeenCalledTimes(1);
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
expect(deviceLaunch).toBeUndefined();
});
it('rejects conflicting application inputs before it boots anything', async () => {
@@ -370,12 +623,90 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
'open_url',
'network_capture',
'network_capture_fields',
'public_preview',
'private_preview_url',
'frame_ancestors',
'cors_origins',
'package_version',
'max_idle_time_minutes',
'max_duration_seconds',
]);
});
it.each([undefined, true, false])(
'passes public_preview=%s to the iOS session host',
async publicPreview => {
await runStep(BuildRuntimePlatform.DARWIN, {
public_preview: publicPreview,
private_preview_url: 'https://expo.dev/private-preview',
});
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
disableShare: publicPreview === false,
privatePreviewUrl: 'https://expo.dev/private-preview',
})
);
}
);
it('passes frame_ancestors to the session host', async () => {
await runStep(BuildRuntimePlatform.DARWIN, {
frame_ancestors: ['https://embed.example.test'],
});
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ frameAncestors: ['https://embed.example.test'] })
);
});
it('passes cors_origins to the session host', async () => {
await runStep(BuildRuntimePlatform.DARWIN, {
cors_origins: ['https://app.example.test'],
});
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
);
});
it.each([
['argument count', { launch_args: Array(257).fill('a') }],
['argument length', { launch_args: ['a'.repeat(9000)] }],
['NUL argument', { launch_args: ['a\u0000b'] }],
['total UTF-8 bytes', { launch_args: Array(8).fill('😃'.repeat(4096)) }],
['NUL URL', { open_url: 'example:\u0000screen' }],
['URL length', { open_url: `example://${'a'.repeat(8192)}` }],
])('rejects iOS launch %s before startup work', async (_name, inputs) => {
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id', ...inputs });
await expect(step).rejects.toBeInstanceOf(UserError);
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it.each([
['NUL argument', { launch_args: ['a\u0000b'] }],
['command length', { launch_args: ['--es', 'quotes', "'".repeat(20_000)] }],
['NUL URL', { open_url: 'example:\u0000screen' }],
])('rejects Android launch %s before startup work', async (_name, inputs) => {
const step = runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', ...inputs });
await expect(step).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('keeps Android launch inputs on its existing direct path', async () => {
const launchArgs = ['a'.repeat(9000)];
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', launch_args: launchArgs });
expect(launchApplicationAsync).toHaveBeenCalledWith(
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, launchArgs })
);
});
it('passes network capture to the session', async () => {
await runStep(BuildRuntimePlatform.DARWIN, {
network_capture: true,
@@ -1,4 +1,4 @@
import { BuildRuntimePlatform } from '@expo/steps';
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
@@ -6,7 +6,19 @@ import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
import * as processes from '../../../utils/processes';
import { sleepAsync } from '../../../utils/retry';
import { turtleFetch } from '../../../utils/turtleFetch';
import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import { preparePreinstalledWdaAsync } from '../../utils/preinstalledWda';
import {
ensureFfmpegInstalledOnceAsync,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startNgrokTunnelAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import {
createStartAppiumRemoteSessionBuildFunction,
@@ -19,13 +31,45 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
AndroidEmulatorUtils: { getAttachedDevicesAsync: jest.fn() },
}));
jest.mock('../../../utils/IosSimulatorUtils', () => ({
IosSimulatorUtils: { getAvailableDevicesAsync: jest.fn() },
IosSimulatorUtils: {
getAvailableDevicesAsync: jest.fn(),
getDeviceAsync: jest.fn(),
resolveUdidAsync: jest.fn().mockResolvedValue('chosen-ios-udid'),
},
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
ensureFfmpegInstalledOnceAsync: jest.fn(),
spawnDetached: jest.fn(),
startNgrokTunnelAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('../../../sentry');
jest.mock('../../../utils/retry', () => ({
...jest.requireActual('../../../utils/retry'),
sleepAsync: jest.fn(),
}));
jest.mock('../../../utils/turtleFetch', () => ({
...jest.requireActual('../../../utils/turtleFetch'),
turtleFetch: jest.fn(),
}));
jest.mock('../../utils/appiumEvents', () => ({ startAppiumEventCollectionAsync: jest.fn() }));
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/preinstalledWda', () => ({ preparePreinstalledWdaAsync: jest.fn() }));
jest.mock('../../utils/iosAppArtifact', () => ({
...jest.requireActual('../../utils/iosAppArtifact'),
readIosApplicationIdentifierAsync: jest.fn().mockResolvedValue('dev.example.app'),
}));
jest.mock('../../utils/localEgressGuard', () => ({
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn().mockResolvedValue(null),
}));
jest.mock('../../utils/localEgressSession', () => ({
...jest.requireActual('../../utils/localEgressSession'),
uploadRemoteSessionConfigWithLocalEgressAsync: jest.fn(),
}));
const logger = { info: jest.fn(), warn: jest.fn() } as never;
@@ -51,23 +95,25 @@ describe(resolveAppiumDeviceAsync, () => {
});
it('returns XCUITest capabilities for the booted iOS simulator', async () => {
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ udid: 'ios-simulator-id' } as never]);
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockResolvedValue([
{
udid: 'ios-simulator-id',
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
} as never,
]);
await expect(
resolveAppiumDeviceAsync({
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: {},
logger,
})
).resolves.toEqual({
platformName: 'iOS',
automationName: 'XCUITest',
driverName: 'xcuitest',
udid: 'ios-simulator-id',
runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
});
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledWith({ env: {}, logger });
expect(IosSimulatorUtils.getAvailableDevicesAsync).toHaveBeenCalledWith({
env: {},
filter: 'booted',
@@ -83,7 +129,6 @@ describe(resolveAppiumDeviceAsync, () => {
resolveAppiumDeviceAsync({
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger,
})
).resolves.toEqual({
platformName: 'Android',
@@ -164,6 +209,501 @@ describe('createStartAppiumRemoteSessionBuildFunction', () => {
expect(
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
).toEqual(
expect.arrayContaining([
'launch_app_identifier',
'launch_args',
'open_url',
'public_preview',
'private_preview_url',
])
);
});
});
describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () => {
const stopError = new Error('Process output drain timed out after 5000ms.');
const stopAppium = jest.fn();
const stopEventCollection = jest.fn();
beforeEach(() => {
jest.mocked(ensureFfmpegInstalledOnceAsync).mockResolvedValue(undefined);
jest.mocked(preparePreinstalledWdaAsync).mockResolvedValue({});
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({
udid: 'CHOSEN-IOS-UDID',
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
state: 'Shutdown',
} as never);
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => ({
stdout: args.includes('--json') ? '{}' : '',
})) as never);
jest
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
.mockResolvedValue([{ serialId: 'emulator-5554', state: 'device' } as never]);
stopAppium.mockRejectedValue(stopError);
jest.mocked(spawnDetached).mockReturnValue({
pid: 4242,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: stopAppium,
});
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
stopEventCollection.mockResolvedValue(undefined);
jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue({
stopAsync: stopEventCollection,
getLastEventObservedAt: () => undefined,
});
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
url: 'https://appium-abc.tunnel.example.com',
subdomainId: 'appium-abc',
stopAsync: jest.fn(),
});
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.tunnel.example.com',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: jest.fn(),
});
});
afterEach(() => {
jest.restoreAllMocks();
});
async function runSessionAsync(
logger: { info: jest.Mock; warn: jest.Mock },
maxIdleTimeMinutes?: number,
runtimePlatform = BuildRuntimePlatform.LINUX,
startupInputs: Record<string, { value: unknown }> = {}
): Promise<void> {
await createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
{
logger,
global: { runtimePlatform },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: maxIdleTimeMinutes },
...startupInputs,
},
outputs: {},
env: {
DEVICE_RUN_SESSION_ID: 'device-run-session-id',
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'tunnel.example.com',
NGROK_AUTHTOKEN: 'ngrok-token',
},
} as never
);
}
function expectAppiumStoppedAndHomeRemoved(logger: { warn: jest.Mock }): void {
expect(stopAppium).toHaveBeenCalledTimes(1);
expect(fs.existsSync(jest.mocked(spawnDetached).mock.calls[0][0].env.APPIUM_HOME!)).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(
{ err: stopError },
'Could not stop the Appium server during remote session teardown.'
);
}
it.each([false, true])(
'preserves preview settings with Simulator startup %s',
async bootSimulator => {
stopAppium.mockResolvedValue(undefined);
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ udid: 'chosen-ios-udid' } as never]);
await runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
...(bootSimulator
? { boot_simulator: { value: true }, device_identifier: { value: 'chosen-device' } }
: {}),
public_preview: { value: false },
private_preview_url: { value: 'https://expo.dev/private-preview' },
frame_ancestors: { value: ['https://preview.example.com'] },
cors_origins: { value: ['https://api.example.com'] },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
disableShare: true,
privatePreviewUrl: 'https://expo.dev/private-preview',
frameAncestors: ['https://preview.example.com'],
corsOrigins: ['https://api.example.com'],
})
);
}
);
it('selects Xcode only once for an already-prepared iOS session', async () => {
stopAppium.mockResolvedValue(undefined);
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockClear();
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockResolvedValue([
{
udid: 'chosen-ios-udid',
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-4',
} as never,
]);
jest.mocked(preparePreinstalledWdaAsync).mockResolvedValue({
'appium:usePreinstalledWDA': true,
'appium:prebuiltWDAPath': '/tmp/legacy-wda.app',
});
await runSessionAsync(
{ info: jest.fn(), warn: jest.fn() },
undefined,
BuildRuntimePlatform.DARWIN
);
expect(preparePreinstalledWdaAsync).toHaveBeenCalledWith(
expect.objectContaining({ runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-4' })
);
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
'appium:eventTimings': true,
'appium:usePreinstalledWDA': true,
'appium:prebuiltWDAPath': '/tmp/legacy-wda.app',
});
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(1);
expect(
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
).toBeLessThan(
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mock.invocationCallOrder[0]
);
});
it('starts serve-sim on the selected iOS device while Appium installation is pending', async () => {
stopAppium.mockResolvedValue(undefined);
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockClear();
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockClear();
jest.mocked(preparePreinstalledWdaAsync).mockResolvedValue({
'appium:usePreinstalledWDA': true,
'appium:prebuiltWDAPath': '/tmp/selected-wda.app',
});
let releaseInstall!: (value: unknown) => void;
let enteredInstall!: () => void;
const installEntered = new Promise<void>(resolve => {
enteredInstall = resolve;
});
jest.mocked(spawn).mockImplementationOnce(() => {
enteredInstall();
return new Promise(resolve => {
releaseInstall = resolve;
}) as never;
});
const running = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
device_identifier: { value: 'chosen-device' },
install_app_path: { value: '/tmp/App.app' },
launch_args: { value: ['--literal'] },
open_url: { value: 'example://home' },
},
outputs: {},
env: {
DEVICE_RUN_SESSION_ID: 'device-run-session-id',
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'tunnel.example.com',
NGROK_AUTHTOKEN: 'ngrok-token',
},
} as never
);
await installEntered;
expect(
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
).toBeLessThan(jest.mocked(IosSimulatorUtils.resolveUdidAsync).mock.invocationCallOrder[0]);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
iosSimulatorUdid: 'CHOSEN-IOS-UDID',
installAppPath: '/tmp/App.app',
launchAppIdentifier: 'dev.example.app',
launchArgs: ['--literal'],
openUrl: 'example://home',
})
);
expect(IosSimulatorUtils.getAvailableDevicesAsync).not.toHaveBeenCalled();
expect(preparePreinstalledWdaAsync).not.toHaveBeenCalled();
const readyHost = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
expect(readyHost.finishAsync).not.toHaveBeenCalled();
expect(jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1].signal?.aborted).toBe(false);
releaseInstall({ stdout: '' });
await running;
expect(readyHost.finishAsync).toHaveBeenCalledTimes(1);
expect(IosSimulatorUtils.getDeviceAsync).toHaveBeenCalledWith({
udid: 'CHOSEN-IOS-UDID',
env: expect.objectContaining({ DEVICE_RUN_SESSION_ID: 'device-run-session-id' }),
});
expect(preparePreinstalledWdaAsync).toHaveBeenCalledWith(
expect.objectContaining({
runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
appiumHome: jest.mocked(spawnDetached).mock.calls[0][0].env.APPIUM_HOME,
})
);
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
'appium:eventTimings': true,
'appium:usePreinstalledWDA': true,
'appium:prebuiltWDAPath': '/tmp/selected-wda.app',
});
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(1);
expect(stopAppium).toHaveBeenCalledTimes(1);
});
it('drains a failed Simulator startup installer before removing its Appium home', async () => {
const hostError = new Error('Simulator boot failed');
let failHost!: (error: Error) => void;
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
new Promise((_resolve, reject) => {
failHost = reject;
})
);
let enteredInstall!: () => void;
const installEntered = new Promise<void>(resolve => {
enteredInstall = resolve;
});
let closeInstaller!: (error: Error) => void;
const child = { pid: 12345, kill: jest.fn() };
jest.mocked(spawn).mockImplementationOnce(() => {
enteredInstall();
return Object.assign(
new Promise((_resolve, reject) => {
closeInstaller = reject;
}),
{ child }
) as never;
});
const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
boot_simulator: { value: true },
device_identifier: { value: 'chosen-device' },
});
const settled = jest.fn();
const observed = running.then(settled, settled);
try {
await installEntered;
const home = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
const hostOptions = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
hostOptions.onStartupError?.(hostError);
failHost(hostError);
await Promise.resolve();
expect(killGroup).toHaveBeenCalledWith(child, 'SIGKILL');
expect(settled).not.toHaveBeenCalled();
expect(fs.existsSync(home)).toBe(true);
closeInstaller(new Error('installer interrupted'));
await expect(running).rejects.toBe(hostError);
expect(fs.existsSync(home)).toBe(false);
expect(preparePreinstalledWdaAsync).not.toHaveBeenCalled();
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
} finally {
closeInstaller?.(new Error('test cleanup'));
await observed;
}
});
it('removes a completed Appium install when Simulator failure interrupts FFmpeg setup', async () => {
const hostError = new Error('Simulator startup failed');
let failHost!: (error: Error) => void;
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
new Promise((_resolve, reject) => {
failHost = reject;
})
);
jest
.mocked(ensureFfmpegInstalledOnceAsync)
.mockImplementationOnce(
({ signal }) =>
new Promise(resolve => signal?.addEventListener('abort', () => resolve(), { once: true }))
);
let installed!: () => void;
const installationFinished = new Promise<void>(resolve => {
installed = resolve;
});
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => {
if (args[0] === 'driver' && args[1] === 'install') {
installed();
}
return { stdout: args.includes('--json') ? '{}' : '' };
}) as never);
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
boot_simulator: { value: true },
device_identifier: { value: 'chosen-device' },
});
await installationFinished;
await new Promise<void>(resolve => setImmediate(resolve));
jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1].onStartupError?.(hostError);
failHost(hostError);
await expect(running).rejects.toBe(hostError);
const home = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
expect(fs.existsSync(home)).toBe(false);
expect(preparePreinstalledWdaAsync).not.toHaveBeenCalled();
});
it('preserves an installer failure before a later host failure during home cleanup', async () => {
const installError = new Error('npm install failed');
const hostError = new Error('later Simulator failure');
let failHost!: (error: Error) => void;
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
new Promise((_resolve, reject) => {
failHost = reject;
})
);
jest.mocked(spawn).mockRejectedValueOnce(installError);
const remove = fs.promises.rm.bind(fs.promises);
let enteredCleanup!: () => void;
const cleanupEntered = new Promise<void>(resolve => {
enteredCleanup = resolve;
});
let releaseCleanup!: () => void;
const cleanupGate = new Promise<void>(resolve => {
releaseCleanup = resolve;
});
jest.spyOn(fs.promises, 'rm').mockImplementationOnce(async (directory, options) => {
enteredCleanup();
await cleanupGate;
await remove(directory, options);
});
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
boot_simulator: { value: true },
device_identifier: { value: 'chosen-device' },
});
const observed = running.catch(() => {});
try {
await cleanupEntered;
const options = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
expect(options.signal?.reason).toBe(installError);
options.onStartupError?.(hostError);
failHost(hostError);
releaseCleanup();
await expect(running).rejects.toBe(installError);
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
} finally {
failHost(hostError);
releaseCleanup();
await observed;
}
});
it.each(['WDA download', 'Appium readiness'])(
'keeps the Simulator error when it interrupts %s',
async stage => {
stopAppium.mockResolvedValue(undefined);
const hostError = new Error('Simulator launch failed');
const laterError = new Error(`${stage} interrupted`);
let failHost!: (error: Error) => void;
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
new Promise((_resolve, reject) => {
failHost = reject;
})
);
let enteredStage!: () => void;
const stageEntered = new Promise<void>(resolve => {
enteredStage = resolve;
});
let rejectStage!: (error: Error) => void;
let setupSignal: AbortSignal | undefined;
const waitForStage = (signal?: AbortSignal): Promise<never> => {
setupSignal = signal;
enteredStage();
return new Promise((_resolve, reject) => {
rejectStage = reject;
signal?.addEventListener('abort', () => reject(laterError), { once: true });
});
};
if (stage === 'WDA download') {
jest
.mocked(preparePreinstalledWdaAsync)
.mockImplementationOnce(({ signal }) => waitForStage(signal));
} else {
jest
.mocked(turtleFetch)
.mockImplementationOnce((_url, _method, options) =>
waitForStage(options?.signal ?? undefined)
);
}
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
boot_simulator: { value: true },
device_identifier: { value: 'chosen-device' },
});
const observed = running.catch(() => {});
try {
await stageEntered;
jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1].onStartupError?.(hostError);
failHost(hostError);
await Promise.resolve();
expect(setupSignal?.aborted).toBe(true);
await expect(running).rejects.toBe(hostError);
const home = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
expect(fs.existsSync(home)).toBe(false);
expect(startAppiumEventCollectionAsync).not.toHaveBeenCalled();
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
} finally {
rejectStage?.(laterError);
await observed;
}
}
);
it('keeps the readiness error when Appium cannot be stopped', async () => {
let now = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => now);
jest.mocked(sleepAsync).mockImplementation(async ms => {
now += ms;
});
jest.mocked(turtleFetch).mockRejectedValue(new Error('connect ECONNREFUSED'));
const logger = { info: jest.fn(), warn: jest.fn() };
await expect(runSessionAsync(logger)).rejects.toThrow(
'Timed out waiting for Appium to become ready.'
);
expectAppiumStoppedAndHomeRemoved(logger);
expect(startAppiumEventCollectionAsync).not.toHaveBeenCalled();
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
});
it('keeps the event collection error when Appium cannot be stopped', async () => {
const collectionError = new Error('event collection failed');
jest.mocked(startAppiumEventCollectionAsync).mockRejectedValueOnce(collectionError);
const logger = { info: jest.fn(), warn: jest.fn() };
await expect(runSessionAsync(logger)).rejects.toBe(collectionError);
expectAppiumStoppedAndHomeRemoved(logger);
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
});
it('resets the idle timer on preview input from the session host', async () => {
const previewInputAt = new Date('2026-10-19T12:00:00Z');
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
{} as never,
{} as never
);
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(previewInputAt);
await expect(runSessionAsync({ info: jest.fn(), warn: jest.fn() }, 5)).rejects.toBe(stopError);
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(previewInputAt);
});
it('fails a finished session when Appium cannot be stopped', async () => {
const logger = { info: jest.fn(), warn: jest.fn() };
await expect(runSessionAsync(logger)).rejects.toBe(stopError);
expect(preparePreinstalledWdaAsync).toHaveBeenCalledWith(
expect.objectContaining({ runtimeIdentifier: undefined })
);
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
'appium:eventTimings': true,
});
expectAppiumStoppedAndHomeRemoved(logger);
expect(stopEventCollection.mock.invocationCallOrder[0]).toBeLessThan(
stopAppium.mock.invocationCallOrder[0]
);
});
});
@@ -6,10 +6,13 @@ import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import { isProcessDescendantOfAsync } from '../../../utils/processes';
import { isProcessDescendantOfAsync, killProcessGroup } from '../../../utils/processes';
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
import { startArgentEventCollectionAsync } from '../../utils/argentEvents';
import { createProcessOutput } from '../../utils/processOutput';
import {
ensureFfmpegInstalledOnceAsync,
getDeviceRunSessionIdOrThrow,
@@ -22,6 +25,7 @@ import {
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact';
// Redirect ~/.argent (where the tool-server writes its state file and event log) to a temp
// home so waitForArgentToolServerStateAsync — which lives in the module under test and cannot
@@ -36,13 +40,32 @@ jest.mock('node:os', () => {
});
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('../../../sentry');
jest.mock('../../../utils/processes', () => ({ isProcessDescendantOfAsync: jest.fn() }));
jest.mock('../../../utils/processes', () => ({
isProcessDescendantOfAsync: jest.fn(),
killProcessGroup: jest.fn(),
}));
jest.mock('../../utils/argentArtifacts', () => ({ pollArgentArtifactsForUploadAsync: jest.fn() }));
jest.mock('../../utils/argentEvents', () => ({
...jest.requireActual('../../utils/argentEvents'),
startArgentEventCollectionAsync: jest.fn(),
}));
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/iosAppArtifact', () => ({
...jest.requireActual('../../utils/iosAppArtifact'),
readIosApplicationIdentifierAsync: jest.fn().mockResolvedValue('dev.example.app'),
}));
jest.mock('../../../utils/IosSimulatorUtils', () => ({
IosSimulatorUtils: {
resolveUdidAsync: jest.fn().mockResolvedValue('selected-ios-udid'),
getAvailableDevicesAsync: jest
.fn()
.mockResolvedValue([{ name: 'iPhone 17', udid: 'selected-ios-udid' }]),
},
}));
jest.mock('../../utils/localEgressGuard', () => ({
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn().mockResolvedValue(null),
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
ensureFfmpegInstalledOnceAsync: jest.fn(),
@@ -102,6 +125,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
apiUrl: 'https://web-preview.tunnel.example.com',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: mockPreviewStopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
@@ -119,18 +143,21 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
await fs.promises.rm(TEST_HOME, { recursive: true, force: true });
});
it('reports an early exit with output before opening tunnels or publishing readiness', async () => {
it('reports an early exit with output and stops Argent before opening tunnels', async () => {
const stopError = new Error('drain timed out');
const stopServer = jest.fn().mockRejectedValue(stopError);
jest.mocked(spawnDetached).mockReturnValue({
pid: 4242,
getOutput: () => 'could not bind server port',
getExitError: () => new Error('process exited with code 1'),
stopAsync: jest.fn(),
stopAsync: stopServer,
});
const logger = { info: jest.fn(), warn: jest.fn() };
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
await expect(
buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
@@ -145,11 +172,66 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
).rejects.toThrow(
'Argent exited before becoming ready: process exited with code 1\nArgent tool-server output:\ncould not bind server port'
);
expect(stopServer).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: stopError },
'Could not stop the Argent tool-server during remote session teardown.'
);
expect(pollArgentArtifactsForUploadAsync).not.toHaveBeenCalled();
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
});
it('drains artifact polling and stops the tool-server when event collection cannot start', async () => {
const collectionError = new Error('event collection failed');
const stopError = new Error('drain timed out');
let pollingFinished = false;
jest
.mocked(pollArgentArtifactsForUploadAsync)
.mockImplementationOnce(async (_ctx, { signal }) => {
await new Promise(resolve => signal.addEventListener('abort', resolve, { once: true }));
pollingFinished = true;
});
jest.mocked(startArgentEventCollectionAsync).mockRejectedValueOnce(collectionError);
const stopServer = jest.fn(async () => {
expect(pollingFinished).toBe(true);
throw stopError;
});
jest.mocked(spawnDetached).mockReturnValueOnce({
pid: 4242,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: stopServer,
});
const logger = { info: jest.fn(), warn: jest.fn() };
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
await expect(
buildFunction.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
)
).rejects.toBe(collectionError);
expect(pollingFinished).toBe(true);
expect(stopServer).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: stopError },
'Could not stop the Argent tool-server during remote session teardown.'
);
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
});
it.each(['preview', 'config', 'wait'])(
'finishes the host and tools after %s fails',
async phase => {
@@ -157,6 +239,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
if (phase === 'preview') {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: jest.fn().mockRejectedValue(error),
getLastPreviewActivityAt: jest.fn(),
finishAsync: mockPreviewStopAsync,
});
} else if (phase === 'config') {
@@ -188,7 +271,60 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
}
);
it('enables the event log flag, shares one path, and starts/stops the collector', async () => {
it.each([
['succeeded', false],
['failed', true],
])(
'fails after stopping the host and tools when the tools tunnel cannot close and the session %s',
async (_description, sessionFails) => {
const closeError = new Error('ngrok close failed');
const sessionError = new Error('session failed');
mockTunnelStopAsync.mockRejectedValueOnce(closeError);
if (sessionFails) {
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(sessionError);
}
const logger = { info: jest.fn(), warn: jest.fn() };
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
// The tunnel may still be serving, so its failure fails a session that otherwise succeeded.
await expect(
buildFunction.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
)
).rejects.toBe(sessionFails ? sessionError : closeError);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockStopAsync).toHaveBeenCalledTimes(1);
expect(jest.mocked(spawnDetached).mock.results[0].value.stopAsync).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: closeError },
'Could not stop the Argent tunnel during remote session teardown.'
);
expect(jest.mocked(Sentry.capture).mock.calls).toEqual(
sessionFails
? [
[
'Could not stop the Argent tunnel after the remote session failed',
closeError,
{ level: 'warning' },
],
]
: []
);
}
);
it('enables the event log flag, disables the video watermark, shares one path, and starts/stops the collector', async () => {
const ctx = {} as unknown as CustomBuildContext;
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
@@ -231,6 +367,20 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
jest.mocked(spawnDetached).mock.invocationCallOrder[0]
);
// (2b) The video watermark flag is disabled, before the tool-server is launched.
const disableWatermarkIndex = spawnCalls.findIndex(
([command, args]) =>
command === 'bun' &&
Array.isArray(args) &&
args[0] === 'x' &&
args.includes('disable') &&
args.includes('video-watermark')
);
expect(disableWatermarkIndex).toBeGreaterThanOrEqual(0);
expect(jest.mocked(spawn).mock.invocationCallOrder[disableWatermarkIndex]).toBeLessThan(
jest.mocked(spawnDetached).mock.invocationCallOrder[0]
);
// (3) The tool-server and the collector are pinned to the exact same event log path.
const serverEnv = jest.mocked(spawnDetached).mock.calls[0][0].env;
expect(serverEnv.ARGENT_EVENT_LOG).toBe(EXPECTED_EVENT_LOG_PATH);
@@ -254,7 +404,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, disableShare: false })
);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
@@ -267,6 +417,194 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
it('passes public_preview=false to the session host', async () => {
const ctx = {} as CustomBuildContext;
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
await buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
public_preview: { value: false },
private_preview_url: { value: 'https://expo.dev/private-preview' },
},
outputs: {},
env: {},
} as never
);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
disableShare: true,
privatePreviewUrl: 'https://expo.dev/private-preview',
})
);
});
it('passes frame_ancestors to the session host', async () => {
const ctx = {} as CustomBuildContext;
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
await buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
frame_ancestors: { value: ['https://embed.example.test'] },
},
outputs: {},
env: {},
} as never
);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ frameAncestors: ['https://embed.example.test'] })
);
});
it('passes cors_origins to the session host', async () => {
const ctx = {} as CustomBuildContext;
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
await buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
cors_origins: { value: ['https://app.example.test'] },
},
outputs: {},
env: {},
} as never
);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
);
});
it('continues the session when disabling the video watermark fails', async () => {
const error = new Error('bun x failed');
jest
.mocked(spawn)
.mockImplementation(
(_command, args) =>
(args.includes('video-watermark')
? Promise.reject(error)
: Promise.resolve(undefined)) as never
);
const logger = { info: jest.fn(), warn: jest.fn() };
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
await buildFunction.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
);
expect(logger.warn).toHaveBeenCalledWith(
{ err: error },
expect.stringContaining('Could not disable the Argent video watermark')
);
expect(spawnDetached).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledTimes(1);
});
it('resets the idle timer on preview input from the session host', async () => {
const previewInputAt = new Date('2026-10-19T12:00:00Z');
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
{} as never,
{} as never
);
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(previewInputAt);
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
await buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: 5 },
},
outputs: {},
env: {},
} as never
);
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(previewInputAt);
});
it('redacts startup credentials and registers the state token for later output', async () => {
const token = 'tool/server "secret"';
await fs.promises.writeFile(
path.join(ARGENT_STATE_DIR, 'tool-server-orchestration.json'),
JSON.stringify({ port: 5678, pid: 9999, token })
);
const logger = { info: jest.fn(), warn: jest.fn() };
jest.mocked(spawnDetached).mockImplementationOnce(options => {
const output = createProcessOutput(options.logger, options.secrets);
output.stdout.append(`argent link argent://${encodeURIComponent(token)}@127.0.0.1:5678\n`);
return {
pid: 4242,
getOutput: output.getOutput,
getExitError: () => undefined,
stopAsync: async () => {
output.stderr.append(`opaque ${token}\nencoded ${encodeURIComponent(token)}\n`);
output.stderr.append(`escaped ${JSON.stringify(token).slice(1, -1)}\n`);
output.finish();
},
};
});
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
await buildFunction.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
);
expect(logger.info).toHaveBeenCalledWith(
{ source: 'stdout' },
'argent link argent://[REDACTED]@127.0.0.1:5678'
);
for (const label of ['opaque', 'encoded', 'escaped']) {
expect(logger.info).toHaveBeenCalledWith({ source: 'stderr' }, `${label} [REDACTED]`);
}
expect(JSON.stringify(logger.info.mock.calls)).not.toContain('secret');
});
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
const ctx = {} as unknown as CustomBuildContext;
const logger = { info: jest.fn(), warn: jest.fn() };
@@ -306,6 +644,280 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
);
});
it('boots and prepares the downloaded app while Argent installation is pending, and closes the host if installation fails', async () => {
let rejectInstall!: (error: Error) => void;
let enteredInstall!: () => void;
const installEntered = new Promise<void>(resolve => {
enteredInstall = resolve;
});
jest.mocked(spawn).mockImplementationOnce(() => {
enteredInstall();
return new Promise((_resolve, reject) => {
rejectInstall = reject;
}) as never;
});
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
const running = buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
device_identifier: { value: 'chosen-device' },
install_app_path: { value: '/tmp/App.app' },
launch_args: { value: ['--literal'] },
},
outputs: {},
env: {},
} as never
);
await installEntered;
expect(
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
).toBeLessThan(jest.mocked(IosSimulatorUtils.resolveUdidAsync).mock.invocationCallOrder[0]);
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
artifactPath: '/tmp/App.app',
env: {},
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
iosSimulatorUdid: 'SELECTED-IOS-UDID',
installAppPath: '/tmp/App.app',
launchAppIdentifier: 'dev.example.app',
launchArgs: ['--literal'],
})
);
const error = new Error('Argent install failed');
rejectInstall(error);
await expect(running).rejects.toBe(error);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it.each(['artifacts-list-endpoint', 'tool-server-event-log', 'video-watermark'])(
'stops and drains the pending %s command when the concurrent host fails',
async flag => {
const hostError = new Error('serve-sim startup failed');
const commandError = new Error('Argent command exited after cancellation');
let failHost!: (error: Error) => void;
let finishHostCleanup!: () => void;
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(
async (_ctx, options) =>
await new Promise((_resolve, reject) => {
failHost = error => {
options.onStartupError?.(error);
finishHostCleanup = () => reject(error);
};
})
);
let failCommand!: (error: Error) => void;
let commandStarted!: () => void;
const commandEntered = new Promise<void>(resolve => {
commandStarted = resolve;
});
const child = { pid: 4242, kill: jest.fn() };
jest.mocked(spawn).mockImplementation((_command, args) => {
if (!args.includes(flag)) {
return Promise.resolve(undefined) as never;
}
const pending = new Promise((_resolve, reject) => {
failCommand = reject;
});
commandStarted();
return Object.assign(pending, { child }) as never;
});
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
const running = Promise.resolve(
buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
boot_simulator: { value: true },
},
outputs: {},
env: {},
} as never
)
);
let settled = false;
void running
.finally(() => {
settled = true;
})
.catch(() => {});
try {
await commandEntered;
failHost(hostError);
await new Promise(resolve => setImmediate(resolve));
expect(killProcessGroup).toHaveBeenCalledWith(child, 'SIGKILL');
expect(settled).toBe(false);
expect(spawnDetached).not.toHaveBeenCalled();
failCommand(commandError);
await new Promise(resolve => setImmediate(resolve));
expect(settled).toBe(false);
finishHostCleanup();
await expect(running).rejects.toBe(hostError);
expect(spawnDetached).not.toHaveBeenCalled();
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
} finally {
failCommand?.(commandError);
finishHostCleanup?.();
await running.catch(() => {});
}
}
);
it('interrupts tool-server state polling and retains the original host error', async () => {
await fs.promises.rm(path.join(ARGENT_STATE_DIR, 'tool-server-orchestration.json'));
const hostError = new Error('serve-sim startup failed');
let failHost!: (error: Error) => void;
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(
async () =>
await new Promise((_resolve, reject) => {
failHost = reject;
})
);
let pollingStarted!: () => void;
const pollingEntered = new Promise<void>(resolve => {
pollingStarted = resolve;
});
const getExitError = jest.fn<Error | undefined, []>(() => {
pollingStarted();
return undefined;
});
const stopServer = jest.fn().mockResolvedValue(undefined);
jest.mocked(spawnDetached).mockReturnValueOnce({
pid: 4242,
getOutput: () => 'later controller diagnostic',
getExitError,
stopAsync: stopServer,
});
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
const running = Promise.resolve(
buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
boot_simulator: { value: true },
},
outputs: {},
env: {},
} as never
)
);
void running.catch(() => {});
try {
await pollingEntered;
failHost(hostError);
await new Promise(resolve => setImmediate(resolve));
const options = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
expect(options.signal?.aborted).toBe(true);
getExitError.mockReturnValue(new Error('later Argent exit'));
await expect(running).rejects.toBe(hostError);
expect(stopServer).toHaveBeenCalledTimes(1);
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
} finally {
getExitError.mockReturnValue(new Error('stop pending state poll'));
await running.catch(() => {});
}
});
it('keeps Argent setup concurrent but waits for the iOS host before opening tools', async () => {
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
{} as CustomBuildContext,
{} as never
);
let releaseHost!: () => void;
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
new Promise(resolve => {
releaseHost = () => resolve(host);
})
);
let stateReady!: () => void;
const stateRead = new Promise<void>(resolve => {
stateReady = resolve;
});
jest.mocked(isProcessDescendantOfAsync).mockImplementationOnce(async () => {
stateReady();
return true;
});
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
const running = Promise.resolve(
buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
boot_simulator: { value: true },
},
outputs: {},
env: {},
} as never
)
);
try {
await stateRead;
await new Promise(resolve => setImmediate(resolve));
expect(spawnDetached).toHaveBeenCalledTimes(1);
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
} finally {
releaseHost();
await running;
}
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'keeps %s prepared setup commands outside the new cancellation path',
async runtimePlatform => {
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
await buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
);
expect(spawn).toHaveBeenCalledTimes(3);
for (const [, , options] of jest.mocked(spawn).mock.calls) {
expect(options).not.toHaveProperty('detached');
expect(options).not.toHaveProperty('signal');
}
expect(killProcessGroup).not.toHaveBeenCalled();
}
);
it('fails before starting anything when a launch is asked for on Android', async () => {
const ctx = {} as unknown as CustomBuildContext;
const logger = { info: jest.fn(), warn: jest.fn() };
@@ -339,7 +951,15 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
expect(
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
).toEqual(
expect.arrayContaining([
'launch_app_identifier',
'launch_args',
'open_url',
'public_preview',
'private_preview_url',
])
);
});
it('stops automation without waiting for recording upload', async () => {
@@ -32,6 +32,7 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
startNgrokTunnelAsync: jest.fn(),
getNgrokAuthtokenOrThrow: () => 'ngrok-token',
getNgrokTunnelDomainOrThrow: () => 'example.com',
omitNgrokAuthtoken: jest.requireActual('../../utils/remoteDeviceRunSession').omitNgrokAuthtoken,
}));
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
@@ -1,4 +1,4 @@
import { SystemError } from '@expo/eas-build-job';
import { SystemError, UserError } from '@expo/eas-build-job';
import { Client, fetchExchange } from '@urql/core';
import * as sandboxDaemon from '../../utils/sandboxDaemon';
@@ -6,6 +6,90 @@ import * as sandboxDaemon from '../../utils/sandboxDaemon';
import { createStartSandboxBuildFunction, markSandboxReadyAsync } from '../startSandbox';
describe('sandbox build functions', () => {
it.each([
{ completion: 'duration', duration: 0.02 },
{ completion: 'cancellation', duration: 60 },
{ completion: 'duration', duration: 0.0001 },
])(
'stops after $completion with $duration seconds only after readiness',
async ({ completion, duration }) => {
const controller = new AbortController();
let ready!: () => void;
const stopAsync = jest.fn(async () => {});
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockResolvedValue({
ready: new Promise<void>(resolve => {
ready = resolve;
}),
stopAsync,
});
const mutation = jest.fn(() => ({ toPromise: async () => ({ data: {} }) }));
const logger = { info: jest.fn(), child: jest.fn() };
logger.child.mockReturnValue(logger);
const fn = createStartSandboxBuildFunction({
mcpServerUrl: 'ws://localhost:8787',
graphqlClient: { mutation },
} as any);
try {
const pending = fn.fn!({ logger } as any, {
inputs: {
sandbox_id: { value: 'sandbox-id' },
max_duration_seconds: { value: duration },
},
outputs: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
signal: controller.signal,
});
await new Promise(resolve => setTimeout(resolve, 40));
expect(mutation).not.toHaveBeenCalled();
expect(stopAsync).not.toHaveBeenCalled();
ready();
if (completion === 'cancellation') {
await new Promise(resolve => setImmediate(resolve));
expect(stopAsync).not.toHaveBeenCalled();
controller.abort();
}
await pending;
expect(mutation).toHaveBeenCalledTimes(1);
expect(stopAsync).toHaveBeenCalledTimes(1);
const timeoutMessage = `Sandbox reached its maximum runtime of ${duration} seconds. Stopping.`;
if (completion === 'duration') {
expect(logger.info).toHaveBeenCalledWith(timeoutMessage);
} else {
expect(logger.info).not.toHaveBeenCalledWith(timeoutMessage);
}
} finally {
start.mockRestore();
}
}
);
it.each([0, -1, Infinity, NaN])(
'rejects invalid maximum duration %s before starting the daemon',
async duration => {
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync');
const fn = createStartSandboxBuildFunction({
mcpServerUrl: 'ws://localhost:8787',
} as any);
try {
const result = fn.fn!({} as any, {
inputs: {
sandbox_id: { value: 'sandbox-id' },
max_duration_seconds: { value: duration },
},
outputs: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
});
await expect(result).rejects.toBeInstanceOf(UserError);
await expect(result).rejects.toThrow(
`Invalid sandbox maximum duration: ${duration}. Provide a finite number greater than zero, or omit max_duration_seconds.`
);
expect(start).not.toHaveBeenCalled();
} finally {
start.mockRestore();
}
}
);
it('aborts an active ready request and keeps its authentication headers', async () => {
const controller = new AbortController();
let requestStarted!: () => void;
@@ -3,6 +3,7 @@ import { BuildRuntimePlatform, type BuildStepContext, type BuildStepEnv } from '
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import {
getDeviceRunSessionIdOrThrow,
@@ -14,6 +15,22 @@ import {
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../../utils/IosSimulatorUtils', () => ({
IosSimulatorUtils: {
resolveUdidAsync: jest.fn().mockResolvedValue('selected-ios-udid'),
getAvailableDevicesAsync: jest
.fn()
.mockResolvedValue([{ name: 'iPhone 17', udid: 'selected-ios-udid' }]),
},
}));
jest.mock('../../utils/iosAppArtifact', () => ({
...jest.requireActual('../../utils/iosAppArtifact'),
readIosApplicationIdentifierAsync: jest.fn().mockResolvedValue('dev.example.app'),
}));
jest.mock('../../utils/localEgressGuard', () => ({
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn().mockResolvedValue(null),
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
getDeviceRunSessionIdOrThrow: jest.fn(),
@@ -24,7 +41,8 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
}));
const ctx = {} as CustomBuildContext;
const env = {} as BuildStepEnv;
const env = { NGROK_AUTHTOKEN: 'ngrok-token', PATH: '/usr/bin' } as BuildStepEnv;
const childEnv = { PATH: '/usr/bin' } as BuildStepEnv;
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const stopAsync = jest.fn();
@@ -59,9 +77,11 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
sharePageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
@@ -73,10 +93,12 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
sharePageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
previewToken: 'tok-1',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
@@ -95,11 +117,62 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
);
});
it('logs the authenticated link while publishing the public frame URL', async () => {
const privatePreviewUrl = 'https://expo.dev/private-preview';
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
sharePageUrl: privatePreviewUrl,
apiUrl: 'https://web-preview.example.test',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
await runAsync(BuildRuntimePlatform.DARWIN, {
public_preview: { value: false },
private_preview_url: { value: privatePreviewUrl },
});
expect(logger.info).toHaveBeenCalledWith(
`Preview URL: ${privatePreviewUrl} (server: https://web-preview.example.test).`
);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
}),
})
);
});
it('does not log the public link when a private job lacks its authenticated URL', async () => {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
await runAsync(BuildRuntimePlatform.DARWIN, { public_preview: { value: false } });
expect(logger.info).toHaveBeenCalledWith('Private preview URL unavailable for this session.');
expect(jest.mocked(logger.info).mock.calls).not.toEqual(
expect.arrayContaining([
expect.arrayContaining([expect.stringContaining('/simulator-preview/preview-id')]),
])
);
});
it.each(['preview', 'config', 'wait'])('finishes the session after %s fails', async phase => {
const error = new Error(`${phase} failed`);
if (phase === 'preview') {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: jest.fn().mockRejectedValue(error),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
} else if (phase === 'config') {
@@ -115,21 +188,46 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
[BuildRuntimePlatform.DARWIN, true],
[BuildRuntimePlatform.LINUX, false],
])('starts the web preview for %s', async (runtimePlatform, selectsXcode) => {
await runAsync(runtimePlatform);
await runAsync(runtimePlatform, selectsXcode ? { boot_simulator: { value: true } } : {});
expect(
jest
.mocked(logger.info)
.mock.calls.filter(
([message]) =>
message ===
'Preview URL: https://expo.dev/simulator-preview/preview-id (server: https://web-preview.example.test).'
)
).toHaveLength(1);
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(selectsXcode ? 1 : 0);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(ctx, {
runtimePlatform,
env,
env: childEnv,
logger,
timeoutMs: 60_000,
signal: undefined,
...(selectsXcode
? {
iosSimulatorUdid: 'SELECTED-IOS-UDID',
installAppPath: undefined,
bootEnv: undefined,
}
: {}),
packageVersion: '1.2.3',
disableShare: false,
frameAncestors: [],
corsOrigins: [],
launchAppIdentifier: undefined,
launchArgs: [],
openUrl: undefined,
networkCapture: false,
networkCaptureFields: [],
});
const host = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
expect(host.openPreviewAsync).toHaveBeenCalledWith({
baseDomain: 'tunnel.example.com',
authtoken: 'ngrok-token',
});
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
ctx,
deviceRunSessionId: 'device-run-session-id',
@@ -160,8 +258,15 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
'launch_app_identifier',
'launch_args',
'open_url',
'boot_simulator',
'device_identifier',
'install_app_path',
'network_capture',
'network_capture_fields',
'public_preview',
'private_preview_url',
'frame_ancestors',
'cors_origins',
'package_version',
'max_duration_seconds',
]);
@@ -188,6 +293,64 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
);
});
it('hands the downloaded app and selected device to serve-sim with initial launch options', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
device_identifier: { value: 'chosen-device' },
install_app_path: { value: '/tmp/App.app' },
launch_args: { value: ['--literal', 'value with spaces'] },
open_url: { value: 'example://home' },
});
expect(
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
).toBeLessThan(jest.mocked(IosSimulatorUtils.resolveUdidAsync).mock.invocationCallOrder[0]);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
iosSimulatorUdid: 'SELECTED-IOS-UDID',
installAppPath: '/tmp/App.app',
launchAppIdentifier: 'dev.example.app',
launchArgs: ['--literal', 'value with spaces'],
openUrl: 'example://home',
})
);
});
it.each([true, false])(
'maps public_preview=%s to the native Share option',
async publicPreview => {
await runAsync(BuildRuntimePlatform.DARWIN, {
public_preview: { value: publicPreview },
private_preview_url: { value: 'https://expo.dev/private-preview' },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
disableShare: !publicPreview,
privatePreviewUrl: 'https://expo.dev/private-preview',
})
);
}
);
it('hands frame_ancestors to the session host', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
frame_ancestors: { value: ['https://embed.example.test'] },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ frameAncestors: ['https://embed.example.test'] })
);
});
it('hands cors_origins to the session host', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
cors_origins: { value: ['https://app.example.test'] },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
);
});
it('hands network capture to the session host', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
network_capture: { value: true },
@@ -0,0 +1,257 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import nock from 'nock';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import {
api,
editPath,
mockToken,
packageName,
serviceAccount,
} from '../../utils/android/__tests__/googlePlayTestUtils';
import { createSubmitToGooglePlayBuildFunction } from '../submitToGooglePlay';
jest.unmock('node-fetch');
jest.mock('promise-retry', () => {
const actual = jest.requireActual('promise-retry');
return (fn: Parameters<typeof actual>[0], options: object) =>
actual(fn, { ...options, minTimeout: 1, maxTimeout: 1, randomize: false });
});
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
let directory: string;
let keyPath: string;
let artifactPath: string;
const session = '/upload/test-session';
const createPath = `/androidpublisher/v3/applications/${packageName}/edits`;
beforeEach(async () => {
nock.disableNetConnect();
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'play-submit-test-'));
keyPath = path.join(directory, 'key.json');
artifactPath = path.join(directory, 'app.aab');
await fs.writeFile(artifactPath, 'binary');
await fs.writeFile(
keyPath,
JSON.stringify({
...serviceAccount,
type: 'service_account',
private_key: serviceAccount.private_key.export({ format: 'pem', type: 'pkcs8' }),
})
);
});
afterEach(async () => {
const pending = nock.pendingMocks();
nock.cleanAll();
nock.enableNetConnect();
await fs.rm(directory, { recursive: true, force: true });
expect(pending).toEqual([]);
});
function submission(extra: Record<string, unknown> = {}, signal?: AbortSignal) {
const fn = createSubmitToGooglePlayBuildFunction();
const step = fn.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs: {
artifact_path: artifactPath,
artifact_type: 'aab',
package_name: packageName,
service_account_key_path: keyPath,
...extra,
},
});
const setters = step.outputs.map(output => jest.spyOn(output, 'set'));
return {
setters,
step,
run: async () =>
await fn.fn!(step.ctx, {
inputs: Object.fromEntries(
step.inputs!.map(input => [input.id, { value: input.rawValue }])
),
outputs: Object.fromEntries(step.outputs.map(output => [output.id, output])),
env: {},
signal,
}),
};
}
function upload() {
mockToken();
api().post(createPath).reply(200, { id: 'edit' });
api()
.post(`/upload${editPath}/bundles`)
.query({ uploadType: 'resumable' })
.reply(200, '', { Location: `https://androidpublisher.googleapis.com${session}` });
nock('https://androidpublisher.googleapis.com').put(session).reply(200, { versionCode: 145 });
}
it.each([
['completed', undefined, { status: 'completed' }],
['completed', 0, 'invalid'],
['completed', 0.25, { status: 'inProgress', userFraction: 0.25 }],
['completed', 1, { status: 'completed' }],
['draft', undefined, { status: 'draft' }],
['draft', 0, 'invalid'],
['draft', 0.25, 'invalid'],
['draft', 1, 'invalid'],
['halted', undefined, 'invalid'],
['halted', 0, 'invalid'],
['halted', 0.25, 'invalid'],
['halted', 1, 'invalid'],
['inProgress', undefined, 'invalid'],
['inProgress', 0, 'invalid'],
['inProgress', 0.25, { status: 'inProgress', userFraction: 0.25 }],
['inProgress', 1, { status: 'completed' }],
] as const)('validates and submits %s with rollout %s', async (status, rollout, expected) => {
const task = submission({
release_status: status,
...(rollout !== undefined ? { rollout } : {}),
});
if (expected === 'invalid') {
await expect(task.run()).rejects.toThrow(status === 'halted' ? /release_status/ : /rollout/);
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
return;
}
upload();
api()
.put(`${editPath}/tracks/internal`, {
track: 'internal',
releases: [{ ...expected, versionCodes: ['145'] }],
})
.reply(200, { track: 'internal' });
api()
.post(`${editPath}:commit`)
.query({})
.reply(() => {
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
return [200, { id: 'edit' }];
});
await task.run();
expect(task.setters.map(set => set.mock.calls)).toEqual([[['145']]]);
});
it('runs the step with a string rollout and validates its required output', async () => {
upload();
api()
.put(`${editPath}/tracks/internal`, {
track: 'internal',
releases: [{ status: 'inProgress', userFraction: 0.25, versionCodes: ['145'] }],
})
.reply(200, { track: 'internal' });
api().post(`${editPath}:commit`).query({}).reply(200, { id: 'edit' });
const { step } = submission({ rollout: '0.25' });
expect(step.__hookId).toBe('submit');
await step.executeAsync();
expect(step.outputs[0].value).toBe('145');
});
it.each(['upload', 'track', 'abort'] as const)(
'deletes the edit after %s failure and leaves outputs unset',
async failure => {
const controller = new AbortController();
const task = submission({}, controller.signal);
mockToken();
api().post(createPath).reply(200, { id: 'edit' });
api()
.post(`/upload${editPath}/bundles`)
.query({ uploadType: 'resumable' })
.reply(() => {
if (failure === 'abort') {
controller.abort(new Error('step timed out'));
}
return failure === 'upload'
? [400, { error: { message: 'rejected binary' } }]
: [200, '', { Location: `https://androidpublisher.googleapis.com${session}` }];
});
if (failure === 'track') {
nock('https://androidpublisher.googleapis.com').put(session).reply(200, { versionCode: 145 });
api()
.put(`${editPath}/tracks/internal`)
.reply(400, { error: { message: 'unknown track' } });
}
api().delete(editPath).reply(204);
await expect(task.run()).rejects.toThrow();
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
}
);
it('preserves the original failure when cleanup fails', async () => {
upload();
api()
.put(`${editPath}/tracks/internal`)
.reply(400, { error: { message: 'unknown track' } });
api().delete(editPath).times(6).reply(500);
await expect(submission().run()).rejects.toMatchObject({
cause: expect.objectContaining({ apiMessage: 'unknown track' }),
});
});
it('does not delete the edit or publish outputs when commit outcome is unknown', async () => {
upload();
api().put(`${editPath}/tracks/internal`).reply(200, { track: 'internal' });
api().post(`${editPath}:commit`).query(true).reply(503);
api().post(`${editPath}:commit`).query(true).reply(404);
let deleted = false;
api()
.delete(editPath)
.optionally()
.reply(() => {
deleted = true;
return [204];
});
const task = submission();
await expect(task.run()).rejects.toBeInstanceOf(SystemError);
expect(deleted).toBe(false);
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
});
it('reports a missing service-account file with its path', async () => {
const missing = path.join(directory, 'missing.json');
await expect(submission({ service_account_key_path: missing }).run()).rejects.toThrow(
`Service-account key file not found at ${missing}`
);
});
it('does not expose invalid credential contents', async () => {
await fs.writeFile(keyPath, 'PRIVATE_SECRET_INVALID_JSON');
const error = await submission()
.run()
.catch(error => error);
expect(error).toBeInstanceOf(UserError);
expect(JSON.stringify(error) + String(error)).not.toContain('PRIVATE_SECRET');
});
it('preserves the cause when the service-account file cannot be read', async () => {
const cause = Object.assign(new Error('permission denied'), { code: 'EACCES' });
const read = jest.spyOn(fs, 'readFile').mockRejectedValueOnce(cause);
try {
await expect(submission().run()).rejects.toMatchObject({ cause });
} finally {
read.mockRestore();
}
});
it('logs a formatted rollout and confirms the track update before committing', async () => {
upload();
const task = submission({ rollout: 0.29 });
const info = jest.spyOn(task.step.ctx.logger, 'info');
const updatedMessage = 'Updated Google Play track "internal" in the edit.';
let loggedBeforeUpdate = false;
let messagesBeforeCommit: unknown[] = [];
api()
.put(`${editPath}/tracks/internal`)
.reply(() => {
loggedBeforeUpdate = info.mock.calls.some(([message]) => message === updatedMessage);
return [200, { track: 'internal' }];
});
api()
.post(`${editPath}:commit`)
.query({})
.reply(() => {
messagesBeforeCommit = info.mock.calls.map(([message]) => message);
return [200, { id: 'edit' }];
});
await task.run();
expect(info).toHaveBeenCalledWith('Release status: inProgress. Rollout: 29%.');
expect(loggedBeforeUpdate).toBe(false);
expect(messagesBeforeCommit.slice(-2)).toEqual([
updatedMessage,
'Committing the Google Play edit...',
]);
});
@@ -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);
@@ -139,9 +140,12 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
await buildStep.executeAsync();
const names = jest
const uploads = jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mock.calls.map(([, options]) => options.name);
.mock.calls.map(([, options]) => options);
const names = uploads
.filter(options => options.kind === 'screen-recording')
.map(options => options.name);
expect(names).toEqual(
expect.arrayContaining([
'iPhone 16 screen recording (01234567-…, started at Jul 10, 2026, 10:00:00.000 UTC)',
@@ -150,6 +154,12 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
])
);
expect(new Set(names).size).toBe(3);
const metadata = uploads.filter(options => options.kind === 'screen-recording-metadata');
expect(metadata.map(options => options.name)).toEqual(
expect.arrayContaining(names.map(name => `${name} metadata`))
);
expect(metadata).toHaveLength(3);
expect(uploads).toHaveLength(6);
} finally {
await Promise.all(
recordingDirectories.map(directory => fs.rm(directory, { recursive: true, force: true }))
@@ -7,7 +7,7 @@ import {
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import { Client } from '@urql/core';
import { Client, CombinedError } from '@urql/core';
import contentDisposition from 'content-disposition';
import { glob } from 'fast-glob';
import { graphql } from 'gql.tada';
@@ -26,8 +26,18 @@ import { CustomBuildContext } from '../../customBuildContext';
import { formatBytes } from '../../utils/artifacts';
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
import {
describeError,
isTransferFailure,
isTransientGraphqlError,
} from '../../utils/networkErrors';
import { retryOnNetworkFailureAsync } from '../../utils/retryOnNetworkFailure';
import { pluralize } from '../../utils/strings';
import {
type DeviceRunSessionWorkerApi,
DeviceRunSessionWorkerApiError,
getDeviceRunSessionWorkerApi,
} from '../utils/deviceRunSessionWorkerApi';
const streamPipeline = promisify(stream.pipeline);
@@ -118,6 +128,7 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
logger,
...source,
graphqlClient: ctx.graphqlClient,
deviceRunSessionWorkerApi: buildId ? getDeviceRunSessionWorkerApi(ctx) : null,
robotAccessToken: stepsCtx.global.staticContext.job.secrets?.robotAccessToken ?? null,
extensions,
signal,
@@ -131,28 +142,50 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
async function fetchApplicationArchiveUrlAsync({
buildId,
graphqlClient,
deviceRunSessionWorkerApi,
signal,
}: {
buildId: string;
graphqlClient: Client;
deviceRunSessionWorkerApi?: DeviceRunSessionWorkerApi | null;
signal?: AbortSignal;
}): Promise<string> {
signal?.throwIfAborted();
const result = await graphqlClient
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
.toPromise();
if (deviceRunSessionWorkerApi) {
return await fetchDeviceRunSessionApplicationArchiveUrlAsync({
buildId,
deviceRunSessionWorkerApi,
signal,
});
}
let data;
try {
data = await retryOnNetworkFailureAsync(
async () => {
const queryResult = await graphqlClient
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
.toPromise();
if (queryResult.error) {
throw queryResult.error;
}
return queryResult.data;
},
{
operation: 'download_build.build_lookup',
signal,
isRetryable: error => error instanceof CombinedError && isTransientGraphqlError(error),
}
);
} catch (error) {
signal?.throwIfAborted();
const message = `Could not fetch build ${buildId}: ${describeError(error)}`;
throw error instanceof CombinedError && !isTransientGraphqlError(error)
? new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error })
: new SystemError(message, { cause: error });
}
signal?.throwIfAborted();
if (result.error) {
const { error } = result;
const message = `Could not fetch build ${buildId}: ${error.message}`;
throw error.networkError || result.error.response?.status >= 500
? new SystemError(message, { cause: error })
: new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error });
}
const applicationArchiveUrl = result.data?.builds.byId?.artifacts?.applicationArchiveUrl;
const applicationArchiveUrl = data?.builds.byId?.artifacts?.applicationArchiveUrl;
if (!applicationArchiveUrl) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
@@ -163,17 +196,76 @@ async function fetchApplicationArchiveUrlAsync({
return applicationArchiveUrl;
}
/**
* A device run session token can read only the build of its own session, so the build comes from
* `GET /v2/device-run-session-worker/application` instead of the builds GraphQL query.
*
* Only device run session jobs, whose steps www generates from the session, reach this path, so
* every failure here is a system error: neither the request nor `build_id` comes from the user.
*/
async function fetchDeviceRunSessionApplicationArchiveUrlAsync({
buildId,
deviceRunSessionWorkerApi,
signal,
}: {
buildId: string;
deviceRunSessionWorkerApi: DeviceRunSessionWorkerApi;
signal?: AbortSignal;
}): Promise<string> {
let application;
try {
application = await retryOnNetworkFailureAsync(
() => deviceRunSessionWorkerApi.getApplicationAsync({ signal }),
{
operation: 'download_build.session_build_lookup',
signal,
isRetryable: error => error instanceof DeviceRunSessionWorkerApiError && error.isTransient,
}
);
} catch (err) {
signal?.throwIfAborted();
throw new SystemError(
`Could not fetch build ${buildId} for this device run session: ${describeError(err)}`,
{ cause: err }
);
}
signal?.throwIfAborted();
if (application.build?.id !== buildId) {
throw new SystemError(
`Could not fetch build ${buildId}: this device run session runs ${
application.build ? `build ${application.build.id}` : 'no build'
}, and its session token can read only that build. www generates this step from the ` +
'session, so the two should always match. Start a new session, and contact support if ' +
'it happens again.'
);
}
if (!application.build.applicationArchiveUrl) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
'Build does not have an application archive url'
);
}
return application.build.applicationArchiveUrl;
}
export async function downloadBuildAsync(
params: DownloadBuildSource & {
logger: bunyan;
graphqlClient: Client;
/**
* Set in a device run session job that has a session token. The build lookup then uses the
* device run session worker API instead of GraphQL.
*/
deviceRunSessionWorkerApi?: DeviceRunSessionWorkerApi | null;
robotAccessToken: string | null;
extensions: string[];
/** Stops the build lookup, the request and the file write when aborted, and skips the rest. */
signal?: AbortSignal;
}
): Promise<{ artifactPath: string }> {
const { logger, graphqlClient, robotAccessToken, extensions, signal } = params;
const { logger, graphqlClient, deviceRunSessionWorkerApi, robotAccessToken, extensions, signal } =
params;
let downloadUrl: string;
let headers: { Authorization: string } | undefined;
@@ -188,8 +280,19 @@ export async function downloadBuildAsync(
headers = undefined;
} else if (params.buildId) {
const buildId = z.string().uuid().parse(params.buildId);
downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient, signal });
headers = robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined;
downloadUrl = await fetchApplicationArchiveUrlAsync({
buildId,
graphqlClient,
deviceRunSessionWorkerApi,
signal,
});
// The archive URL from the device run session worker API points to an artifact route that
// needs no auth. After www stops minting EXPO_TOKEN for session jobs, robotAccessToken is the
// session token, and www rejects it as a bearer credential on every route.
headers =
robotAccessToken && !deviceRunSessionWorkerApi
? { Authorization: `Bearer ${robotAccessToken}` }
: undefined;
} else {
throw new UserError(
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
@@ -202,18 +305,34 @@ export async function downloadBuildAsync(
);
signal?.throwIfAborted();
const response = await retryOnDNSFailure(fetch)(downloadUrl, { headers, signal });
if (!response.ok) {
const textResult = await asyncResult(response.text());
throw new Error(`Unexpected response from server (${response.status}): ${textResult.value}`);
let archivePath: string;
try {
archivePath = await retryOnNetworkFailureAsync(
async () => {
const response = await fetch(downloadUrl, { headers, signal });
if (!response.ok) {
const textResult = await asyncResult(response.text());
throw new Error(
`Unexpected response from server (${response.status}): ${textResult.value}`
);
}
const archiveFilename = resolveArchiveFilename({ response, extensions });
const attemptArchivePath = path.join(downloadDestinationDirectory, archiveFilename);
// On abort, node-fetch emits an error on the body stream, which fails the pipeline.
await streamPipeline(response.body, fs.createWriteStream(attemptArchivePath));
return attemptArchivePath;
},
{ operation: 'download_build.archive_download', signal, isRetryable: isTransferFailure }
);
} catch (error) {
signal?.throwIfAborted();
if (params.buildId && isTransferFailure(error)) {
throw new SystemError(`Could not download the application archive: ${describeError(error)}`, {
cause: error,
});
}
throw error;
}
const archiveFilename = resolveArchiveFilename({ response, extensions });
const archivePath = path.join(downloadDestinationDirectory, archiveFilename);
// On abort, node-fetch emits an error on the body stream, which fails the pipeline.
await streamPipeline(response.body, fs.createWriteStream(archivePath));
signal?.throwIfAborted();
const { size } = await fs.promises.stat(archivePath);
@@ -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);
},
});
}
@@ -1,4 +1,4 @@
import { UserError } from '@expo/eas-build-job';
import { UserError, quoteAndroidShellCommand } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import {
BuildFunction,
@@ -106,34 +106,40 @@ export async function launchApplicationAsync({
logApplicationLaunch(logger, applicationIdentifier, launchArgs);
// Android does not support process arguments like iOS. Pass raw `am start` Intent
// arguments instead, such as `--es key value` or `--ez key true`.
await spawn(
'adb',
['shell', 'am', 'start', ...launchArgs, '-n', `${applicationIdentifier}/${activityName}`],
{
env,
logger,
try {
await spawnAndroidShellCommandAsync(
['am', 'start', ...launchArgs, '-n', `${applicationIdentifier}/${activityName}`],
{ env, logger }
);
} catch (err) {
// An empty argument that is not an option value reaches `am start` as an empty package, and
// `am start` then fails to resolve the Intent without saying why.
const emptyArgumentIndex = launchArgs.indexOf('');
if (emptyArgumentIndex === -1 || err instanceof UserError) {
throw err;
}
);
throw new UserError(
'EAS_LAUNCH_APPLICATION_FAILED',
`Launching ${applicationIdentifier} failed. Input "launch_args" has an empty argument at position ${emptyArgumentIndex + 1}, which Android passes to "am start" as an empty argument. Remove it unless it is an option value, such as "--es", "key", "".`,
{ cause: err }
);
}
if (openUrl) {
logger.info(`Opening ${openUrl} in ${applicationIdentifier}.`);
await spawn(
'adb',
[
'shell',
'am',
'start',
'-a',
'android.intent.action.VIEW',
'-d',
openUrl,
'-n',
`${applicationIdentifier}/${activityName}`,
],
await spawnAndroidShellCommandAsync(
['am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
{ env, logger }
);
}
}
async function spawnAndroidShellCommandAsync(
command: string[],
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
): Promise<void> {
await spawn('adb', ['shell', ...quoteAndroidShellCommand(command)], { env, logger });
}
async function preapproveIosUrlSchemeAsync({
applicationIdentifier,
openUrl,
@@ -0,0 +1,140 @@
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.');
}
if (!ctx.runtimeApi.addBuildLogMask) {
throw new SystemError('A build log secrets filter 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,
addBuildLogMask: ctx.runtimeApi.addBuildLogMask,
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,6 +1,6 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { type BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
@@ -19,6 +19,7 @@ import {
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
import { type parseNetworkCaptureInputs } from '../utils/networkCaptureFields';
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
import { type ServeSimApplicationOptions } from '../utils/remoteDeviceRunSession';
import { type StartupTasks } from '../utils/startupTasks';
import {
type DetachedProcessHandle,
@@ -76,13 +77,9 @@ export function getAgentDeviceRemoteSessionEnvOrThrow(
* Starts the agent-device daemon, its tunnel, the session host and the web preview,
* reports the session as ready, and keeps it alive until it stops.
*
* The daemon does not need the device, so it starts at once. The session host starts
* when `device.booted` resolves. The session is reported as ready only when both are
* up and `device.ready` (the app is installed and launched) resolved too.
*
* The first failure aborts `tasks.signal`: each part stops before its next stage, and
* the teardown then stops whatever was started. `device.ready` must settle soon after
* an abort, because the teardown waits for it.
* Serve-sim boots while the iOS download runs. After device preparation verifies
* any egress guard, its control API installs and launches the app.
* Startup failures abort the other tasks; teardown drains them before stopping acquired resources.
*/
export async function runAgentDeviceRemoteSessionAsync(
ctx: CustomBuildContext,
@@ -96,6 +93,10 @@ export async function runAgentDeviceRemoteSessionAsync(
maxIdleTimeMinutes,
maxDurationSeconds,
capture,
disableShare = false,
privatePreviewUrl,
frameAncestors,
corsOrigins,
tasks,
device,
}: {
@@ -108,10 +109,26 @@ export async function runAgentDeviceRemoteSessionAsync(
maxIdleTimeMinutes: number | undefined;
maxDurationSeconds: number | undefined;
capture: ReturnType<typeof parseNetworkCaptureInputs>;
disableShare?: boolean;
privatePreviewUrl?: string;
frameAncestors?: string[];
corsOrigins?: string[];
tasks: StartupTasks;
device: { booted: Promise<unknown>; ready: Promise<unknown> };
device:
| {
booted: Promise<unknown>;
ready: Promise<unknown>;
}
| {
iosSimulatorUdid: string;
bootEnv?: Record<string, string>;
application?: Promise<ServeSimApplicationOptions | void>;
};
}
): Promise<void> {
signal?.throwIfAborted();
tasks.signal.throwIfAborted();
logger.info(
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
);
@@ -121,6 +138,9 @@ export async function runAgentDeviceRemoteSessionAsync(
let sessionHost: DeviceSessionHost | undefined;
let eventCollection: Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>> | undefined;
let sessionFailed = false;
const daemonSecrets = [ngrokAuthtoken];
const hostBootedDevice = 'iosSimulatorUdid' in device ? device : undefined;
const externallyBootedDevice = 'booted' in device ? device : undefined;
// Each task stores what it started, so the teardown below can stop it even when
// another task failed first.
@@ -131,12 +151,14 @@ export async function runAgentDeviceRemoteSessionAsync(
env,
logger: taskLogger,
signal: tasks.signal,
secrets: daemonSecrets,
});
taskLogger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
// The daemon is stored above, so the teardown stops it. The wait itself is bounded
// (STARTUP_TIMEOUT_MS) and only reads a file, so it may finish in the background.
const daemonInfo = await tasks.untilAborted(waitForDaemonInfoAsync({ daemonProcess }));
daemonSecrets.push(daemonInfo.token);
taskLogger.info(`Daemon is listening on port ${daemonInfo.port}; loaded auth token.`);
tasks.signal.throwIfAborted();
@@ -147,40 +169,60 @@ export async function runAgentDeviceRemoteSessionAsync(
authtoken: ngrokAuthtoken,
logger: taskLogger,
});
tasks.signal.throwIfAborted();
taskLogger.info(`Tunnel is ready at ${agentDeviceTunnel.url}.`);
return { ...daemonInfo, remoteSessionUrl: agentDeviceTunnel.url };
});
const sessionHostStartup = tasks.run('session host', async taskLogger => {
// A boot cannot be cancelled, so stop waiting for it when startup is aborted.
await tasks.untilAborted(device.booted);
if (externallyBootedDevice) {
await tasks.untilAborted(externallyBootedDevice.booted);
}
tasks.signal.throwIfAborted();
sessionHost = await startDeviceSessionHostAsync(ctx, {
separateLogPhase: true,
runtimePlatform,
env,
logger: taskLogger,
timeoutMs: STARTUP_TIMEOUT_MS,
disableShare,
privatePreviewUrl,
frameAncestors,
corsOrigins,
signal: tasks.signal,
onStartupError: tasks.abort,
networkCapture: capture.networkCapture,
networkCaptureFields: capture.networkCaptureFields,
iosSimulatorUdid: hostBootedDevice?.iosSimulatorUdid,
bootEnv: hostBootedDevice?.bootEnv,
application: hostBootedDevice?.application,
});
tasks.signal.throwIfAborted();
const webPreview = await sessionHost.openPreviewAsync({
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
});
tasks.signal.throwIfAborted();
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
taskLogger.info(
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
webPreview.sharePageUrl
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
return webPreview;
});
const deviceReady = externallyBootedDevice?.ready;
try {
const [
{ port: daemonPort, token: daemonToken, remoteSessionUrl: agentDeviceRemoteSessionUrl },
webPreview,
] = await Promise.all([agentDeviceStartup, sessionHostStartup, device.ready]).catch(
] = await Promise.all([agentDeviceStartup, sessionHostStartup, deviceReady]).catch(
(err: unknown) => {
// Also aborts for a `device.ready` that does not come from `tasks.run`.
tasks.abort(err);
throw err;
throw tasks.signal.reason;
}
);
tasks.signal.throwIfAborted();
logger.info(tasks.summary());
await uploadRemoteSessionConfigWithLocalEgressAsync({
@@ -197,6 +239,7 @@ export async function runAgentDeviceRemoteSessionAsync(
},
logger,
});
tasks.signal.throwIfAborted();
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
daemonUrl: `http://127.0.0.1:${daemonPort}`,
@@ -222,6 +265,7 @@ export async function runAgentDeviceRemoteSessionAsync(
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
}
: undefined,
});
@@ -232,7 +276,7 @@ export async function runAgentDeviceRemoteSessionAsync(
// Promise.all rejects on the first failure while other tasks can still be starting.
// They stop at their next abort check. Wait for all of them, so the teardown sees
// everything that was started.
await Promise.allSettled([agentDeviceStartup, sessionHostStartup, device.ready]);
await Promise.allSettled([agentDeviceStartup, sessionHostStartup, deviceReady]);
const startedDaemon = daemonProcess;
await finishRemoteSessionAsync({
logger,
@@ -267,13 +311,16 @@ export async function startAgentDeviceDaemonAsync({
env,
logger,
signal,
secrets,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
/** Kills the install and stops before the daemon starts, when aborted. No git fallback then. */
signal?: AbortSignal;
secrets?: string[];
}): Promise<DetachedProcessHandle> {
signal?.throwIfAborted();
const packageSpec = createAgentDevicePackageSpec(packageVersion);
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
const installDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-agent-device-'));
@@ -283,6 +330,7 @@ export async function startAgentDeviceDaemonAsync({
);
try {
signal?.throwIfAborted();
const add = resolvePackageAdd(packageManager, packageSpec);
logger.info(`Installing ${packageSpec} with ${add.command}.`);
await spawn(add.command, add.args, { cwd: installDir, env, logger, signal });
@@ -298,12 +346,17 @@ export async function startAgentDeviceDaemonAsync({
command: 'node',
args: [daemonPath],
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
logger,
secrets,
});
return {
...daemonProcess,
stopAsync: async () => {
await daemonProcess.stopAsync();
await fs.promises.rm(installDir, { recursive: true, force: true });
try {
await daemonProcess.stopAsync();
} finally {
await fs.promises.rm(installDir, { recursive: true, force: true });
}
},
};
} catch (err) {
@@ -334,7 +387,13 @@ export async function startAgentDeviceDaemonAsync({
logger.warn(
`Failed to start daemon from ${packageSpec} via ${packageManager}; falling back to git clone: ${error.message}`
);
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger, signal });
return await startAgentDeviceDaemonFromGitAsync({
packageVersion,
env,
logger,
signal,
secrets,
});
}
}
@@ -365,11 +424,13 @@ async function startAgentDeviceDaemonFromGitAsync({
env,
logger,
signal,
secrets,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
secrets?: string[];
}): Promise<DetachedProcessHandle> {
logger.info(
packageVersion
@@ -396,6 +457,8 @@ async function startAgentDeviceDaemonFromGitAsync({
args: ['run', 'src/daemon.ts'],
cwd: SRC_DIR,
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
logger,
secrets,
});
}
@@ -1,4 +1,4 @@
import { UserError } from '@expo/eas-build-job';
import { UserError, validateAndroidLaunchOptions } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
@@ -15,14 +15,27 @@ import {
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorName, IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi';
import { withLocalEgressSession } from '../utils/localEgressSession';
import { selectXcodeDeveloperDirectoryAsync } from '../utils/remoteDeviceRunSession';
import {
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createPrivatePreviewUrlInputProvider,
createPublicPreviewInputProvider,
omitNgrokAuthtoken,
parseCorsOriginsInput,
parseFrameAncestorsInput,
selectXcodeDeveloperDirectoryAsync,
} from '../utils/remoteDeviceRunSession';
import {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
} from '../utils/networkCaptureFields';
import { createStartupTasks } from '../utils/startupTasks';
import {
type ServeSimApplicationOptions,
validateServeSimLaunchOptions,
} from '../utils/remoteDeviceRunSession';
import { downloadBuildAsync } from './downloadBuild';
import { installBuildAsync } from './installBuild';
@@ -36,26 +49,15 @@ import {
runAgentDeviceRemoteSessionAsync,
} from './startAgentDeviceRemoteSession';
import { startAndroidEmulatorAsync } from './startAndroidEmulator';
import { bootIosSimulatorAsync } from './startIosSimulator';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
import { readIosApplicationIdentifierAsync } from '../utils/iosAppArtifact';
const ANDROID_DEVICE_NAME = 'EasAndroidDevice01' as AndroidVirtualDeviceName;
/**
* One step for a whole agent-device session: boot the device, download, install and
* launch the app, and start the agent-device daemon and the web preview.
*
* It does the work of eas/start_ios_simulator or eas/start_android_emulator,
* eas/download_build, eas/install_build and eas/launch_application, plus the agent-device
* session, so the parts that do not depend on each other can run at the same time:
*
* boot ─────────────┬─► install ─► launch ─┐
* download ─────────┘ │
* boot ─► session host ─► web preview ─────┼─► ready
* agent-device daemon ─► tunnel ───────────┘
*
* The first failure aborts the rest: the download stops, and nothing installs, launches
* or starts after it. A boot cannot be cancelled, so it can still run when a failed
* step returns.
* Starts a device session, downloading the app alongside daemon startup.
* On iOS, serve-sim boots the selected Simulator, then installs and launches
* the downloaded app. Build-tools verifies any egress guard before install and launch.
*/
export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
@@ -114,6 +116,10 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
}),
// Session.
...createNetworkCaptureInputProviders(),
createPublicPreviewInputProvider(),
createPrivatePreviewUrlInputProvider(),
createFrameAncestorsInputProvider(),
createCorsOriginsInputProvider(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -130,13 +136,14 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: withLocalEgressSession(async (stepsCtx, { inputs, env, signal }) => {
fn: withLocalEgressSession(async (stepsCtx, { inputs, env: stepEnv, signal }) => {
const { logger, global } = stepsCtx;
const { runtimePlatform } = global;
const isIos = runtimePlatform === BuildRuntimePlatform.DARWIN;
// Validate everything before any expensive work starts.
const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(env);
const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(stepEnv);
const env = omitNgrokAuthtoken(stepEnv);
const buildId = inputs.build_id.value as string | undefined;
const applicationArchiveUrl = inputs.application_archive_url.value as string | undefined;
if (buildId && applicationArchiveUrl) {
@@ -155,6 +162,11 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
'launch_args and open_url need an application: pass build_id or application_archive_url.'
);
}
if (isIos) {
validateServeSimLaunchOptions({ launchArgs, openUrl });
} else {
validateAndroidLaunchOptions({ launchArgs, openUrl });
}
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
const capture = parseNetworkCaptureInputs(
{
@@ -164,104 +176,137 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
{ runtimePlatform }
);
if (isIos) {
// Before the boot, so every Xcode tool below uses the same developer directory.
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const tasks = createStartupTasks(logger, signal);
let downloaded: Promise<string> | undefined;
let applicationReady: Promise<ServeSimApplicationOptions | void> | undefined;
const tasks = createStartupTasks(logger);
const booted = tasks.run(
isIos ? 'iOS Simulator boot' : 'Android Emulator boot',
async taskLogger => {
if (isIos) {
await bootIosSimulatorAsync({
deviceIdentifier: deviceIdentifier as IosSimulatorUuid | IosSimulatorName | undefined,
env,
logger: taskLogger,
});
return;
}
await startAndroidEmulatorAsync({
deviceName: ANDROID_DEVICE_NAME,
systemImagePackage: `${inputs.system_image_package.value}`,
deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined,
lcdWidth: inputs.lcd_width.value as number | undefined,
lcdHeight: inputs.lcd_height.value as number | undefined,
lcdDensity: inputs.lcd_density.value as number | undefined,
logcatDirectory: await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
),
env,
logger: taskLogger,
});
try {
if (isIos) {
// Select Xcode before serve-sim boots the device or any Simulator tools run.
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
);
const downloaded = hasApplication
? tasks.run('build download', async taskLogger => {
const { artifactPath } = await downloadBuildAsync({
logger: taskLogger,
...(buildId ? { buildId } : { applicationArchiveUrl: applicationArchiveUrl! }),
graphqlClient: ctx.graphqlClient,
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
extensions: isIos ? ['app'] : ['apk'],
tasks.signal.throwIfAborted();
const { iosStartup } = isIos
? await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: true,
deviceIdentifier,
env,
logger,
signal: tasks.signal,
});
return artifactPath;
})
: undefined;
})
: {};
const iosSimulatorUdid = iosStartup?.iosSimulatorUdid;
if (iosSimulatorUdid) {
logger.info(`Selected iOS Simulator: ${iosSimulatorUdid}.`);
}
// `ready` settles only after everything it started has settled, so the session
// teardown, which waits for it, never leaves a download running. It must also settle
// soon after an abort: it stops waiting for the boot then, and the download stops.
const ready = downloaded
? tasks.run('app install and launch', async taskLogger => {
const [download, boot] = await Promise.allSettled([
downloaded,
tasks.untilAborted(booted),
]);
// After a failure elsewhere, report that failure, not the abort it caused here.
tasks.signal.throwIfAborted();
if (download.status === 'rejected') {
throw download.reason;
}
if (boot.status === 'rejected') {
throw boot.reason;
}
const { applicationIdentifier, activityName } = await installBuildAsync({
artifactPath: download.value,
runtimePlatform,
env,
logger: taskLogger,
const booted = iosSimulatorUdid
? undefined
: tasks.run('Android Emulator boot', async taskLogger => {
const logcatDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
);
tasks.signal.throwIfAborted();
await startAndroidEmulatorAsync({
deviceName: ANDROID_DEVICE_NAME,
systemImagePackage: `${inputs.system_image_package.value}`,
deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined,
lcdWidth: inputs.lcd_width.value as number | undefined,
lcdHeight: inputs.lcd_height.value as number | undefined,
lcdDensity: inputs.lcd_density.value as number | undefined,
logcatDirectory,
env,
logger: taskLogger,
});
});
tasks.signal.throwIfAborted();
await launchApplicationAsync({
applicationIdentifier,
activityName,
launchArgs,
openUrl,
runtimePlatform,
env,
logger: taskLogger,
});
})
: tasks.untilAborted(booted);
await runAgentDeviceRemoteSessionAsync(ctx, {
env,
logger,
signal,
runtimePlatform,
sessionEnv,
packageVersion: inputs.package_version.value as string | undefined,
// A missing or non-positive value disables the idle timeout (opt-in feature).
maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined,
maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined,
capture,
tasks,
device: { booted, ready },
});
downloaded = hasApplication
? tasks.run('build download', async taskLogger => {
const { artifactPath } = await downloadBuildAsync({
logger: taskLogger,
...(buildId ? { buildId } : { applicationArchiveUrl: applicationArchiveUrl! }),
graphqlClient: ctx.graphqlClient,
deviceRunSessionWorkerApi: buildId ? getDeviceRunSessionWorkerApi(ctx) : null,
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
extensions: isIos ? ['app'] : ['apk'],
signal: tasks.signal,
});
return artifactPath;
})
: undefined;
const download = downloaded;
applicationReady = download
? tasks.run(isIos ? 'app preparation' : 'app install and launch', async taskLogger => {
if (booted) {
await tasks.untilAborted(booted);
}
const artifactPath = await download;
tasks.signal.throwIfAborted();
if (isIos) {
const applicationIdentifier = await readIosApplicationIdentifierAsync({
artifactPath,
env,
});
tasks.signal.throwIfAborted();
return {
installAppPath: artifactPath,
launchAppIdentifier: applicationIdentifier,
launchArgs,
openUrl,
};
}
const { applicationIdentifier, activityName } = await installBuildAsync({
artifactPath,
runtimePlatform,
env,
logger: taskLogger,
});
tasks.signal.throwIfAborted();
await launchApplicationAsync({
applicationIdentifier,
activityName,
launchArgs,
openUrl,
runtimePlatform,
env,
logger: taskLogger,
});
})
: undefined;
await runAgentDeviceRemoteSessionAsync(ctx, {
env,
logger,
signal,
runtimePlatform,
sessionEnv,
packageVersion: inputs.package_version.value as string | undefined,
// A missing or non-positive value disables the idle timeout (opt-in feature).
maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined,
maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined,
capture,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
tasks,
device: iosSimulatorUdid
? {
...iosStartup,
iosSimulatorUdid,
application: applicationReady,
}
: {
booted: booted!,
ready: applicationReady ?? tasks.untilAborted(booted!),
},
});
} finally {
tasks.abort(new Error('Agent-device session ended.'));
await Promise.allSettled([downloaded, applicationReady]);
}
}),
});
}
@@ -1,5 +1,6 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { type Result, asyncResult } from '@expo/results';
import {
BuildFunction,
BuildRuntimePlatform,
@@ -7,10 +8,11 @@ import {
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import spawn, { type SpawnOptions, type SpawnResult } from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import semver from 'semver';
import { z } from 'zod';
@@ -20,28 +22,39 @@ import {
withLocalEgressSession,
} from '../utils/localEgressSession';
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
import {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
} from '../utils/networkCaptureFields';
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
import {
PackageManager,
resolveConfiguredPackageManager,
resolvePackageAdd,
} from '../../utils/packageManager';
import { killProcessGroup } from '../../utils/processes';
import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
import { preparePreinstalledWdaAsync } from '../utils/preinstalledWda';
import {
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createIosSessionStartupInputProviders,
createPrivatePreviewUrlInputProvider,
createPublicPreviewInputProvider,
createServeSimLaunchInputProviders,
describeServeSimLaunch,
ensureFfmpegInstalledOnceAsync,
finishRemoteSessionAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
parseServeSimLaunchInputs,
omitNgrokAuthtoken,
parseCorsOriginsInput,
parseFrameAncestorsInput,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startNgrokTunnelAsync,
@@ -68,7 +81,12 @@ export function createStartAppiumRemoteSessionBuildFunction(
__metricsId: 'eas/start_appium_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
...createIosSessionStartupInputProviders(),
...createNetworkCaptureInputProviders(),
createPublicPreviewInputProvider(),
createPrivatePreviewUrlInputProvider(),
createFrameAncestorsInputProvider(),
createCorsOriginsInputProvider(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -80,21 +98,29 @@ export function createStartAppiumRemoteSessionBuildFunction(
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env: stepEnv, signal }) => {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(stepEnv);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(stepEnv);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(stepEnv);
const env = omitNgrokAuthtoken(stepEnv);
const packageVersion = inputs.package_version.value as string | undefined;
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
const { runtimePlatform } = global;
const launch = parseServeSimLaunchInputs(
{
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
},
{ runtimePlatform }
);
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
installAppPath: inputs.install_app_path?.value as string | undefined,
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
env,
logger,
signal,
});
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
@@ -107,53 +133,131 @@ export function createStartAppiumRemoteSessionBuildFunction(
logger.info(
`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,
});
const appiumProcess = spawnDetached({
command: appiumBinPath,
args: [
'--address',
APPIUM_HOST,
'--port',
String(APPIUM_PORT),
'--base-path',
'/',
'--log-level',
'error',
// Appium 3 gates session listing (GET /appium/sessions) behind the
// session_discovery insecure feature. We rely on it to poll for
// Appium Event Timings, so enable it for all drivers.
'--allow-insecure',
'*:session_discovery',
'--default-capabilities',
JSON.stringify({ 'appium:eventTimings': true }),
],
env: appiumEnv,
});
try {
await waitForAppiumReadyAsync({ appiumProcess, logger });
} catch (error) {
await appiumProcess.stopAsync();
await fs.promises.rm(appiumHome, { recursive: true, force: true });
throw error;
}
const eventCollection = await startAppiumEventCollectionAsync({
ctx,
deviceRunSessionId,
appiumUrl: `http://${APPIUM_HOST}:${APPIUM_PORT}/`,
logger,
});
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
const device: AppiumDevice = iosStartup
? {
platformName: 'iOS',
automationName: 'XCUITest',
driverName: 'xcuitest',
udid: iosStartup.iosSimulatorUdid,
}
: await resolveAppiumDeviceAsync({ runtimePlatform, env });
const startupAbortController = new AbortController();
const startupSignal = signal
? AbortSignal.any([signal, startupAbortController.signal])
: startupAbortController.signal;
const setupSignal = iosStartup ? startupSignal : undefined;
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let appiumProcess: ReturnType<typeof spawnDetached> | undefined;
let appiumHome: string | undefined;
let eventCollection: Awaited<ReturnType<typeof startAppiumEventCollectionAsync>> | undefined;
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let sessionFailed = false;
const startHost = () =>
startDeviceSessionHostAsync(ctx, {
...iosStartup,
separateLogPhase: true,
runtimePlatform,
env,
logger,
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
signal: startupSignal,
onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined,
...launch,
networkCapture,
networkCaptureFields,
}).catch(error => {
if (iosStartup) {
startupAbortController.abort(error);
}
throw error;
});
try {
if (iosStartup) {
hostStartup = asyncResult(startHost());
}
// Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the
// simulator stream with it, and UiAutomator2 merges long recordings with it. The
// macOS session image does not ship ffmpeg, so install it concurrently with Appium.
// Both owned installs settle before proceeding; a shared FFmpeg install keeps its owner.
const [appiumInstall] = await Promise.all([
asyncResult(
installAppiumAsync({
versionSpec,
driverName: device.driverName,
env,
logger,
signal: setupSignal,
onError: iosStartup ? error => startupAbortController.abort(error) : undefined,
}).catch(error => {
if (iosStartup) {
startupAbortController.abort(error);
}
throw error;
})
),
asyncResult(
ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger, signal: setupSignal })
),
]);
const installation = appiumInstall.enforceValue();
appiumHome = installation.appiumHome;
setupSignal?.throwIfAborted();
const { appiumBinPath, appiumEnv } = installation;
const wdaCapabilities = await preparePreinstalledWdaAsync({
runtimeIdentifier: iosStartup
? (
await IosSimulatorUtils.getDeviceAsync({
udid: iosStartup.iosSimulatorUdid as IosSimulatorUuid,
env,
})
)?.runtime
: device.runtimeIdentifier,
appiumHome,
appiumBinPath,
appiumEnv,
logger,
signal: setupSignal,
});
setupSignal?.throwIfAborted();
appiumProcess = spawnDetached({
command: appiumBinPath,
args: [
'--address',
APPIUM_HOST,
'--port',
String(APPIUM_PORT),
'--base-path',
'/',
'--log-level',
'error',
// Appium 3 gates session listing (GET /appium/sessions) behind the
// session_discovery insecure feature. We rely on it to poll for
// Appium Event Timings, so enable it for all drivers.
'--allow-insecure',
'*:session_discovery',
'--default-capabilities',
JSON.stringify({ 'appium:eventTimings': true, ...wdaCapabilities }),
],
env: appiumEnv,
logger,
});
await waitForAppiumReadyAsync({ appiumProcess, logger, signal: setupSignal });
if (hostStartup) {
sessionHost = (await hostStartup).enforceValue();
}
setupSignal?.throwIfAborted();
eventCollection = await startAppiumEventCollectionAsync({
ctx,
deviceRunSessionId,
appiumUrl: `http://${APPIUM_HOST}:${APPIUM_PORT}/`,
logger,
});
appiumTunnel = await startNgrokTunnelAsync({
port: APPIUM_PORT,
subdomainPrefix: 'appium',
@@ -168,18 +272,11 @@ export function createStartAppiumRemoteSessionBuildFunction(
if (launchDescription) {
logger.info(launchDescription);
}
sessionHost = await startDeviceSessionHostAsync(ctx, {
runtimePlatform,
env,
logger,
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
networkCapture,
networkCaptureFields,
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
const webPreview = await sessionHost.openPreviewAsync({
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
});
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
@@ -210,13 +307,15 @@ export function createStartAppiumRemoteSessionBuildFunction(
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
}
: undefined,
});
} catch (error) {
sessionFailed = true;
throw error;
throw setupSignal?.aborted ? setupSignal.reason : error;
} finally {
startupAbortController.abort(new Error('Appium session ended.'));
await finishRemoteSessionAsync({
logger,
sessionFailed,
@@ -226,14 +325,19 @@ export function createStartAppiumRemoteSessionBuildFunction(
'Appium server',
(async () => {
try {
await eventCollection.stopAsync();
await eventCollection?.stopAsync();
} finally {
await appiumProcess.stopAsync();
await fs.promises.rm(appiumHome, { recursive: true, force: true });
try {
await appiumProcess?.stopAsync();
} finally {
if (appiumHome) {
await fs.promises.rm(appiumHome, { recursive: true, force: true });
}
}
}
})(),
],
['session host', sessionHost?.finishAsync()],
['session host', hostStartup?.then(result => result.value?.finishAsync())],
],
});
}
@@ -257,20 +361,18 @@ export type AppiumDevice = {
automationName: 'XCUITest' | 'UiAutomator2';
driverName: 'xcuitest' | 'uiautomator2';
udid: string;
runtimeIdentifier?: string;
};
export async function resolveAppiumDeviceAsync({
runtimePlatform,
env,
logger,
}: {
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
logger: bunyan;
}): Promise<AppiumDevice> {
switch (runtimePlatform) {
case BuildRuntimePlatform.DARWIN: {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
const [bootedDevice] = await IosSimulatorUtils.getAvailableDevicesAsync({
env,
filter: 'booted',
@@ -283,6 +385,7 @@ export async function resolveAppiumDeviceAsync({
automationName: 'XCUITest',
driverName: 'xcuitest',
udid: bootedDevice.udid,
runtimeIdentifier: bootedDevice.runtime,
};
}
case BuildRuntimePlatform.LINUX: {
@@ -306,65 +409,135 @@ export async function installAppiumAsync({
driverName,
env,
logger,
signal,
onError,
}: {
versionSpec: string;
driverName: AppiumDevice['driverName'];
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
onError?: (error: unknown) => void;
}): Promise<{ appiumHome: string; appiumBinPath: string; appiumEnv: BuildStepEnv }> {
signal?.throwIfAborted();
const appiumHome = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-appium-home-'));
await fs.promises.writeFile(
path.join(appiumHome, 'package.json'),
`${JSON.stringify({ name: 'eas-appium-home', private: true })}\n`
);
const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome };
const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium');
const add = resolvePackageAdd(
resolveConfiguredPackageManager(env, PackageManager.NPM),
`appium@${versionSpec}`
);
try {
signal?.throwIfAborted();
await fs.promises.writeFile(
path.join(appiumHome, 'package.json'),
`${JSON.stringify({ name: 'eas-appium-home', private: true })}\n`
);
const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome };
const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium');
const add = resolvePackageAdd(
resolveConfiguredPackageManager(env, PackageManager.NPM),
`appium@${versionSpec}`
);
logger.info(`Installing appium@${versionSpec} with ${add.command}.`);
await spawn(add.command, add.args, {
cwd: appiumHome,
env: appiumEnv,
logger,
});
const { stdout } = await spawn(appiumBinPath, ['driver', 'list', '--installed', '--json'], {
env: appiumEnv,
stdio: 'pipe',
});
const installedDrivers = AppiumInstalledDriversSchema.parse(JSON.parse(stdout));
if (installedDrivers[driverName]?.installed) {
logger.info(`Updating the installed Appium ${driverName} driver.`);
await spawn(appiumBinPath, ['driver', 'update', driverName], { env: appiumEnv, logger });
} else {
logger.info(`Installing the Appium ${driverName} driver.`);
await spawn(appiumBinPath, ['driver', 'install', driverName], { env: appiumEnv, logger });
logger.info(`Installing appium@${versionSpec} with ${add.command}.`);
await runAppiumSetupCommandAsync(
add.command,
add.args,
{
cwd: appiumHome,
env: appiumEnv,
logger,
},
signal
);
const { stdout } = await runAppiumSetupCommandAsync(
appiumBinPath,
['driver', 'list', '--installed', '--json'],
{
env: appiumEnv,
stdio: 'pipe',
},
signal
);
const installedDrivers = AppiumInstalledDriversSchema.parse(JSON.parse(stdout));
if (installedDrivers[driverName]?.installed) {
logger.info(`Updating the installed Appium ${driverName} driver.`);
await runAppiumSetupCommandAsync(
appiumBinPath,
['driver', 'update', driverName],
{ env: appiumEnv, logger },
signal
);
} else {
logger.info(`Installing the Appium ${driverName} driver.`);
await runAppiumSetupCommandAsync(
appiumBinPath,
['driver', 'install', driverName],
{ env: appiumEnv, logger },
signal
);
}
return { appiumHome, appiumBinPath, appiumEnv };
} catch (error) {
onError?.(error);
if (signal) {
await fs.promises.rm(appiumHome, { recursive: true, force: true });
signal.throwIfAborted();
}
throw error;
}
}
async function runAppiumSetupCommandAsync(
command: string,
args: string[],
options: SpawnOptions,
signal?: AbortSignal
): Promise<SpawnResult> {
if (!signal) {
return await spawn(command, args, options);
}
signal.throwIfAborted();
const task = spawn(command, args, { ...options, detached: true });
const abort = (): void => killProcessGroup(task.child, 'SIGKILL');
signal.addEventListener('abort', abort, { once: true });
if (signal.aborted) {
abort();
}
try {
const result = await task;
signal.throwIfAborted();
return result;
} finally {
signal.removeEventListener('abort', abort);
}
return { appiumHome, appiumBinPath, appiumEnv };
}
async function waitForAppiumReadyAsync({
appiumProcess,
logger,
signal,
}: {
appiumProcess: { getOutput: () => string };
logger: bunyan;
signal?: AbortSignal;
}): Promise<void> {
const deadline = Date.now() + APPIUM_STARTUP_TIMEOUT_MS;
while (Date.now() < deadline) {
signal?.throwIfAborted();
try {
const response = await turtleFetch(`http://${APPIUM_HOST}:${APPIUM_PORT}/status`, 'GET', {
timeout: 2_000,
retries: 0,
logger,
signal,
});
signal?.throwIfAborted();
if (response.ok) {
return;
}
} catch {}
await sleepAsync(1_000);
signal?.throwIfAborted();
if (signal) {
await setTimeoutAsync(1_000, undefined, { signal });
} else {
await sleepAsync(1_000);
}
}
const output = appiumProcess.getOutput();
throw new SystemError(
@@ -1,15 +1,17 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { type Result, asyncResult } from '@expo/results';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import spawn, { type SpawnOptions } from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import semver from 'semver';
import { z } from 'zod';
@@ -19,6 +21,7 @@ import {
withLocalEgressSession,
} from '../utils/localEgressSession';
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
import {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
@@ -29,11 +32,16 @@ import {
resolveConfiguredPackageManager,
resolvePackageExec,
} from '../../utils/packageManager';
import { isProcessDescendantOfAsync } from '../../utils/processes';
import { isProcessDescendantOfAsync, killProcessGroup } from '../../utils/processes';
import { sleepAsync } from '../../utils/retry';
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents';
import {
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createIosSessionStartupInputProviders,
createPrivatePreviewUrlInputProvider,
createPublicPreviewInputProvider,
createServeSimLaunchInputProviders,
describeServeSimLaunch,
ensureFfmpegInstalledOnceAsync,
@@ -41,7 +49,9 @@ import {
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
parseServeSimLaunchInputs,
omitNgrokAuthtoken,
parseCorsOriginsInput,
parseFrameAncestorsInput,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startNgrokTunnelAsync,
@@ -55,6 +65,9 @@ export const MIN_ARGENT_REMOTE_SESSION_VERSION = '0.16.0';
const ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG = 'artifacts-list-endpoint';
// Tells the tool-server to write its structured event log so we can collect session events.
const ARGENT_EVENT_LOG_FLAG = 'tool-server-event-log';
// On by default in Argent (since 0.17.0); we turn it off so recorded videos have no Argent
// watermark. Older versions that don't know the flag treat `disable` as a no-op.
const ARGENT_VIDEO_WATERMARK_FLAG = 'video-watermark';
const ARGENT_STATE_DIR = path.join(os.homedir(), '.argent');
// Pin the event log path explicitly and hand the same value to the tool-server (via
// ARGENT_EVENT_LOG) and the collector, so an ambient ARGENT_EVENT_LOG or a future change to
@@ -80,7 +93,12 @@ export function createStartArgentRemoteSessionBuildFunction(
__metricsId: 'eas/start_argent_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
...createIosSessionStartupInputProviders(),
...createNetworkCaptureInputProviders(),
createPublicPreviewInputProvider(),
createPrivatePreviewUrlInputProvider(),
createFrameAncestorsInputProvider(),
createCorsOriginsInputProvider(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -97,14 +115,15 @@ export function createStartArgentRemoteSessionBuildFunction(
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env: stepEnv, signal }) => {
// Fail fast before any expensive setup if the injected env
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
// for our ngrok tunnels), and NGROK_AUTHTOKEN (to authenticate them).
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(stepEnv);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(stepEnv);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(stepEnv);
const env = omitNgrokAuthtoken(stepEnv);
const packageVersion = inputs.package_version.value as string | undefined;
// A missing or non-positive value disables the idle timeout (opt-in feature).
@@ -113,14 +132,21 @@ export function createStartArgentRemoteSessionBuildFunction(
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
const versionSpec = packageVersion ?? 'latest';
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 }
);
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
installAppPath: inputs.install_app_path?.value as string | undefined,
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
env,
logger,
signal,
});
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
@@ -132,10 +158,6 @@ export function createStartArgentRemoteSessionBuildFunction(
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
// Start the potentially slow installation while Argent is being prepared.
// On Linux expo-device-hub calls this again and awaits the same in-flight
// setup before launching. On macOS this remains non-blocking, so only a
@@ -147,92 +169,171 @@ export function createStartArgentRemoteSessionBuildFunction(
const argentExec = (args: string[]): { command: string; args: string[] } =>
resolvePackageExec(packageManager, args);
logger.info('Enabling the Argent artifacts list endpoint flag.');
const enableArtifacts = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
]);
await spawn(enableArtifacts.command, enableArtifacts.args, { env, logger });
logger.info('Enabling the Argent tool-server event log flag.');
const enableEventLog = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_EVENT_LOG_FLAG,
]);
await spawn(enableEventLog.command, enableEventLog.args, { env, logger });
const startServer = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'server',
'start',
'--port',
'0',
'--idle-timeout',
'0',
'--force',
]);
logger.info(
`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.`
);
// Keep Argent itself in foreground mode under the detached process. This preserves
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
const argentServer = spawnDetached({
command: startServer.command,
args: startServer.args,
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH, ARGENT_EMULATOR_NO_WINDOW: '1' },
});
if (argentServer.pid === undefined) {
throw new SystemError(
'Failed to start Argent: could not determine the PID of the launched process.'
);
}
logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`);
let toolServerPort: number;
let toolServerToken: string | undefined;
try {
const toolServerState = await waitForArgentToolServerStateAsync({
stateDir: ARGENT_STATE_DIR,
ancestorPid: argentServer.pid,
timeoutMs: STARTUP_TIMEOUT_MS,
getExitError: argentServer.getExitError,
});
toolServerPort = toolServerState.port;
toolServerToken = toolServerState.token;
} catch (err) {
const output = argentServer.getOutput();
throw new SystemError(
`${
err instanceof Error ? err.message : `Timed out waiting for argent tool-server state.`
}${output ? `\nArgent tool-server output:\n${output}` : ''}`
);
}
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
const startupAbortController = new AbortController();
const startupSignal = signal
? AbortSignal.any([signal, startupAbortController.signal])
: startupAbortController.signal;
const setupSignal = iosStartup ? startupSignal : undefined;
const artifactPollAbortController = new AbortController();
const artifactPollSignal = signal
? AbortSignal.any([signal, artifactPollAbortController.signal])
: artifactPollAbortController.signal;
const artifactPollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
toolsUrl: `http://127.0.0.1:${toolServerPort}`,
toolsAuthToken: toolServerToken,
logger,
signal: artifactPollSignal,
});
const eventCollection = await startArgentEventCollectionAsync({
ctx,
deviceRunSessionId,
eventLogPath: ARGENT_EVENT_LOG_PATH,
logger,
});
let artifactPollingPromise: Promise<void> | undefined;
let eventCollection: Awaited<ReturnType<typeof startArgentEventCollectionAsync>> | undefined;
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let argentServer: ReturnType<typeof spawnDetached> | undefined;
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let sessionFailed = false;
const startHost = () =>
startDeviceSessionHostAsync(ctx, {
...iosStartup,
separateLogPhase: true,
runtimePlatform,
env,
logger,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
timeoutMs: STARTUP_TIMEOUT_MS,
signal: startupSignal,
onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined,
...launch,
networkCapture,
networkCaptureFields,
}).catch(error => {
if (iosStartup) {
startupAbortController.abort(error);
}
throw error;
});
try {
if (iosStartup) {
hostStartup = asyncResult(startHost());
}
logger.info('Enabling the Argent artifacts list endpoint flag.');
const enableArtifacts = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
]);
await runArgentSetupCommandAsync(
enableArtifacts.command,
enableArtifacts.args,
{ env, logger },
setupSignal
);
logger.info('Enabling the Argent tool-server event log flag.');
const enableEventLog = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_EVENT_LOG_FLAG,
]);
await runArgentSetupCommandAsync(
enableEventLog.command,
enableEventLog.args,
{ env, logger },
setupSignal
);
logger.info('Disabling the Argent video watermark flag.');
const disableVideoWatermark = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'disable',
ARGENT_VIDEO_WATERMARK_FLAG,
]);
// The watermark is cosmetic, so a failure here must not fail the session.
try {
await runArgentSetupCommandAsync(
disableVideoWatermark.command,
disableVideoWatermark.args,
{ env, logger },
setupSignal
);
} catch (err) {
setupSignal?.throwIfAborted();
logger.warn(
{ err },
'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.'
);
}
setupSignal?.throwIfAborted();
const startServer = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'server',
'start',
'--port',
'0',
'--idle-timeout',
'0',
'--force',
]);
logger.info(
`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.`
);
// Keep Argent itself in foreground mode under the detached process. This preserves
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
const secrets: string[] = [];
argentServer = spawnDetached({
command: startServer.command,
args: startServer.args,
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH, ARGENT_EMULATOR_NO_WINDOW: '1' },
logger,
secrets,
});
if (argentServer.pid === undefined) {
throw new SystemError(
'Failed to start Argent: could not determine the PID of the launched process.'
);
}
logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`);
let toolServerPort: number;
let toolServerToken: string | undefined;
try {
const toolServerState = await waitForArgentToolServerStateAsync({
stateDir: ARGENT_STATE_DIR,
ancestorPid: argentServer.pid,
timeoutMs: STARTUP_TIMEOUT_MS,
getExitError: argentServer.getExitError,
signal: setupSignal,
});
toolServerPort = toolServerState.port;
toolServerToken = toolServerState.token;
if (toolServerToken) {
secrets.push(toolServerToken);
}
} catch (err) {
const output = argentServer.getOutput();
throw new SystemError(
`${
err instanceof Error ? err.message : `Timed out waiting for argent tool-server state.`
}${output ? `\nArgent tool-server output:\n${output}` : ''}`
);
}
if (hostStartup) {
sessionHost = (await hostStartup).enforceValue();
}
setupSignal?.throwIfAborted();
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
const artifactPollSignal = signal
? AbortSignal.any([signal, artifactPollAbortController.signal])
: artifactPollAbortController.signal;
artifactPollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
toolsUrl: `http://127.0.0.1:${toolServerPort}`,
toolsAuthToken: toolServerToken,
logger,
signal: artifactPollSignal,
});
eventCollection = await startArgentEventCollectionAsync({
ctx,
deviceRunSessionId,
eventLogPath: ARGENT_EVENT_LOG_PATH,
logger,
});
toolsTunnel = await startNgrokTunnelAsync({
port: toolServerPort,
subdomainPrefix: 'argent',
@@ -248,20 +349,15 @@ export function createStartArgentRemoteSessionBuildFunction(
if (launchDescription) {
logger.info(launchDescription);
}
sessionHost = await startDeviceSessionHostAsync(ctx, {
runtimePlatform,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
networkCapture,
networkCaptureFields,
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
const webPreview = await sessionHost.openPreviewAsync({
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
});
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
logger.info(
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
webPreview.sharePageUrl
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
await uploadRemoteSessionConfigWithLocalEgressAsync({
@@ -290,13 +386,15 @@ export function createStartArgentRemoteSessionBuildFunction(
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
}
: undefined,
});
} catch (error) {
sessionFailed = true;
throw error;
throw setupSignal?.aborted ? setupSignal.reason : error;
} finally {
startupAbortController.abort(new Error('Argent session ended.'));
await finishRemoteSessionAsync({
logger,
sessionFailed,
@@ -306,11 +404,13 @@ export function createStartArgentRemoteSessionBuildFunction(
'Argent tool-server',
(async () => {
try {
await stopArgentEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
if (eventCollection) {
await stopArgentEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
}
artifactPollAbortController.abort();
try {
await artifactPollingPromise;
@@ -326,11 +426,11 @@ export function createStartArgentRemoteSessionBuildFunction(
);
}
} finally {
await argentServer.stopAsync();
await argentServer?.stopAsync();
}
})(),
],
['session host', sessionHost?.finishAsync()],
['session host', hostStartup?.then(result => result.value?.finishAsync())],
],
});
}
@@ -390,6 +490,31 @@ export function warnIfArgentPackageVersionCannotBeVerified({
}
}
async function runArgentSetupCommandAsync(
command: string,
args: string[],
options: SpawnOptions,
signal?: AbortSignal
): Promise<void> {
if (!signal) {
await spawn(command, args, options);
return;
}
signal.throwIfAborted();
const task = spawn(command, args, { ...options, detached: true });
const abort = (): void => killProcessGroup(task.child, 'SIGKILL');
signal.addEventListener('abort', abort, { once: true });
if (signal.aborted) {
abort();
}
try {
await task;
signal.throwIfAborted();
} finally {
signal.removeEventListener('abort', abort);
}
}
function parseArgentToolServerState(raw: string): ArgentToolServerState {
let json: unknown;
try {
@@ -412,13 +537,16 @@ export async function waitForArgentToolServerStateAsync({
timeoutMs,
pollIntervalMs = 1_000,
getExitError,
signal,
}: {
stateDir: string;
ancestorPid: number;
timeoutMs: number;
pollIntervalMs?: number;
getExitError?: () => Error | undefined;
signal?: AbortSignal;
}): Promise<ArgentToolServerState> {
signal?.throwIfAborted();
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
const throwIfExited = (): void => {
@@ -429,6 +557,7 @@ export async function waitForArgentToolServerStateAsync({
};
while (Date.now() < deadline) {
signal?.throwIfAborted();
throwIfExited();
let matchingState: ArgentToolServerState | undefined;
try {
@@ -454,13 +583,19 @@ export async function waitForArgentToolServerStateAsync({
lastError = err;
}
// Keep process failures outside the retryable state-file read errors.
signal?.throwIfAborted();
throwIfExited();
if (matchingState) {
return matchingState;
}
await sleepAsync(pollIntervalMs);
if (signal) {
await setTimeoutAsync(pollIntervalMs, undefined, { signal });
} else {
await sleepAsync(pollIntervalMs);
}
}
signal?.throwIfAborted();
throw new SystemError(
`Timed out waiting for an argent tool-server state file belonging to process ${ancestorPid} in ${stateDir}${
lastError instanceof Error ? `: ${lastError.message}` : ''
@@ -25,6 +25,7 @@ import {
findAvailablePortAsync,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
omitNgrokAuthtoken,
startNgrokTunnelAsync,
} from '../utils/remoteDeviceRunSession';
@@ -76,11 +77,11 @@ function awaitLocalEgressAcquisitionAsync<T>(
/**
* Points the device host's system HTTP(S) proxy at the EAS CLI's egress client.
* Must run before `eas/start_ios_simulator`: the simulator reads the system proxy
* at boot. Once a simulator boots, `eas/start_ios_simulator` also sets proxy
* environment variables inside it for clients that read them (gRPC, libcurl)
* and installs the local egress guard, which refuses connections that ignore
* both. On Linux it starts the Android emulator relay and fence instead; see
* Must run before iOS simulator startup: the simulator reads the system proxy
* at boot. Startup supplies proxy variables for gRPC/libcurl and the guard that
* refuses connections bypassing the proxy. Serve-sim-owned startup passes this
* boot configuration to serve-sim.
* On Linux it starts the Android emulator relay and fence instead; see
* androidLocalEgress.ts. The shared session cleanup releases the resources
* started here when the session ends, with a job finalizer as a fallback.
*/
@@ -91,9 +92,10 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
name: 'Start local egress',
__metricsId: 'eas/start_local_egress',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX],
fn: async ({ logger, global }, { env, signal }) => {
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
fn: async ({ logger, global }, { env: stepEnv, signal }) => {
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(stepEnv);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(stepEnv);
const env = omitNgrokAuthtoken(stepEnv);
const isAndroid = global?.runtimePlatform === BuildRuntimePlatform.LINUX;
let workDir: string | undefined;
let server: DetachedProcessHandle | undefined;
@@ -156,6 +158,7 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
controlPort,
authfilePath,
env,
logger,
signal: startupSignal,
});
server = started.process;
@@ -1,4 +1,4 @@
import { SystemError } from '@expo/eas-build-job';
import { SystemError, UserError } from '@expo/eas-build-job';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { graphql } from 'gql.tada';
@@ -32,6 +32,11 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'max_duration_seconds',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: async (stepCtx, { inputs, signal, env }) => {
// The daemon needs this credential, but shell commands do not.
@@ -44,6 +49,18 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
throw new SystemError('MCP server URL is required to start the sandbox daemon.');
}
const sandboxId = String(inputs.sandbox_id.value);
const maxDurationSeconds = inputs.max_duration_seconds?.value;
if (
maxDurationSeconds !== undefined &&
(typeof maxDurationSeconds !== 'number' ||
!Number.isFinite(maxDurationSeconds) ||
maxDurationSeconds <= 0)
) {
throw new UserError(
'EAS_SANDBOX_INVALID_DURATION',
`Invalid sandbox maximum duration: ${String(maxDurationSeconds)}. Provide a finite number greater than zero, or omit max_duration_seconds.`
);
}
const daemon = await startSandboxDaemonAsync({
credential: sandboxToken,
serverUrl: mcpServerUrl,
@@ -52,6 +69,8 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
signal,
workingDirectory: stepCtx.workingDirectory,
env: commandEnv,
graphqlClient: ctx.graphqlClient,
sandboxId,
});
try {
await daemon.ready;
@@ -61,7 +80,21 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
stepCtx.logger.info('Sandbox daemon started.');
await withLogPhaseAsync(stepCtx.logger, AWAIT_SANDBOX_STEP_NAME, async () => {
await waitForCancellationAsync(signal);
const timeoutSignal =
maxDurationSeconds === undefined
? undefined
: AbortSignal.timeout(Math.ceil(maxDurationSeconds * 1000));
await waitForCompletionAsync(
AbortSignal.any([
...(signal ? [signal] : []),
...(timeoutSignal ? [timeoutSignal] : []),
])
);
if (timeoutSignal?.aborted) {
stepCtx.logger.info(
`Sandbox reached its maximum runtime of ${maxDurationSeconds} seconds. Stopping.`
);
}
});
} finally {
await daemon.stopAsync();
@@ -87,11 +120,11 @@ export async function markSandboxReadyAsync(
}
}
async function waitForCancellationAsync(signal?: AbortSignal): Promise<void> {
if (signal?.aborted) {
async function waitForCompletionAsync(signal: AbortSignal): Promise<void> {
if (signal.aborted) {
return;
}
await new Promise<void>(resolve => {
signal?.addEventListener('abort', () => resolve(), { once: true });
signal.addEventListener('abort', () => resolve(), { once: true });
});
}
@@ -7,6 +7,7 @@ import {
import { CustomBuildContext } from '../../customBuildContext';
import { startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
import {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
@@ -16,11 +17,19 @@ import {
withLocalEgressSession,
} from '../utils/localEgressSession';
import {
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createIosSessionStartupInputProviders,
createPrivatePreviewUrlInputProvider,
createPublicPreviewInputProvider,
createServeSimLaunchInputProviders,
describeServeSimLaunch,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
parseServeSimLaunchInputs,
omitNgrokAuthtoken,
parseCorsOriginsInput,
parseFrameAncestorsInput,
selectXcodeDeveloperDirectoryAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
@@ -37,7 +46,12 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
__metricsId: 'eas/start_serve_sim_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
...createIosSessionStartupInputProviders(),
...createNetworkCaptureInputProviders(),
createPublicPreviewInputProvider(),
createPrivatePreviewUrlInputProvider(),
createFrameAncestorsInputProvider(),
createCorsOriginsInputProvider(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -49,20 +63,29 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env: stepEnv, signal }) => {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(stepEnv);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(stepEnv);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(stepEnv);
const env = omitNgrokAuthtoken(stepEnv);
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
const packageVersion = inputs.package_version?.value as string | 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 }
);
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
installAppPath: inputs.install_app_path?.value as string | undefined,
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
env,
logger,
signal,
});
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
@@ -77,16 +100,18 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
logger.info(launchDescription);
}
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const sessionHost = await startDeviceSessionHostAsync(ctx, {
...iosStartup,
runtimePlatform,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
signal,
packageVersion,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
@@ -95,8 +120,15 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
});
try {
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
logger.info(`Preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`);
const webPreview = await sessionHost.openPreviewAsync({
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
});
logger.info(
webPreview.sharePageUrl
? `Preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
@@ -0,0 +1,264 @@
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import {
BuildFunction,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import path from 'node:path';
import { z } from 'zod';
import { GooglePlayAuthUtils } from '../utils/android/GooglePlayAuthUtils';
import { GooglePlayClient, ReleaseStatus, ReleaseStatusZ } from '../utils/android/GooglePlayClient';
import {
GooglePlayCommitOutcomeUnknownError,
GooglePlayUtils,
} from '../utils/android/GooglePlayUtils';
import { AndroidArtifactType } from '../utils/android/appArtifact';
const InputsZ = z
.object({
artifact_path: z.string().min(1),
artifact_type: z.enum(['apk', 'aab']),
package_name: z.string().regex(/^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$/),
service_account_key_path: z.string().min(1),
track: z.string().trim().min(1).default('internal'),
release_status: ReleaseStatusZ.exclude(['halted']).default('completed'),
rollout: z.number().finite().gt(0).max(1).optional(),
changelog: z.string().optional(),
changes_not_sent_for_review: z.boolean().default(false),
})
.superRefine(({ release_status, rollout }, ctx) => {
if (release_status === 'draft' && rollout !== undefined) {
ctx.addIssue({
code: 'custom',
path: ['rollout'],
message: 'A draft release cannot have a rollout.',
});
}
if (release_status === 'inProgress' && rollout === undefined) {
ctx.addIssue({
code: 'custom',
path: ['rollout'],
message: 'An inProgress release requires a rollout greater than 0 and at most 1.',
});
}
});
// Does not inspect the binary. The caller must run eas/read_android_app_info and
// compare its package_name with the intended application ID before calling this.
export function createSubmitToGooglePlayBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'submit_to_google_play',
name: 'Submit to Google Play',
__metricsId: 'eas/submit_to_google_play',
__hookId: 'submit',
inputProviders: [
BuildStepInput.createProvider({
id: 'artifact_path',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'artifact_type',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'package_name',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'service_account_key_path',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'track',
required: false,
defaultValue: 'internal',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'release_status',
required: false,
defaultValue: 'completed',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'rollout',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'changelog',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'changes_not_sent_for_review',
required: false,
defaultValue: false,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
}),
],
outputProviders: [BuildStepOutput.createProvider({ id: 'version_code', required: true })],
fn: async (ctx, { inputs, outputs, signal }) => {
signal?.throwIfAborted();
const parsed = InputsZ.safeParse(
Object.fromEntries(Object.entries(inputs).map(([key, input]) => [key, input.value]))
);
if (!parsed.success) {
throw new UserError(
'EAS_GOOGLE_PLAY_INVALID_INPUT',
'Invalid Google Play submission inputs:\n' + z.prettifyError(parsed.error)
);
}
const submission = parsed.data;
const { release_status: requested, rollout } = submission;
// Match Fastlane: a partial rollout overrides the requested status to inProgress.
const release: {
status: ReleaseStatus;
userFraction?: number;
} =
rollout !== undefined && rollout < 1
? { status: 'inProgress', userFraction: rollout }
: { status: requested === 'inProgress' ? 'completed' : requested };
const credentials = await GooglePlayAuthUtils.loadGoogleServiceAccountAsync({
keyPath: path.resolve(ctx.workingDirectory, submission.service_account_key_path),
});
const artifactPath = path.resolve(ctx.workingDirectory, submission.artifact_path);
const client = new GooglePlayClient(credentials, ctx.logger);
const versionCode = await submitToGooglePlayAsync({
artifactType: submission.artifact_type,
client,
submission: {
package_name: submission.package_name,
track: submission.track,
release_status: submission.release_status,
rollout: submission.rollout,
changelog: submission.changelog,
changes_not_sent_for_review: submission.changes_not_sent_for_review,
artifact_path: artifactPath,
},
release,
logger: ctx.logger,
signal,
});
outputs.version_code.set(String(versionCode));
},
});
}
async function submitToGooglePlayAsync({
artifactType,
client,
submission,
release,
logger,
signal,
}: {
artifactType: AndroidArtifactType;
client: GooglePlayClient;
submission: {
package_name: string;
track: string;
release_status: ReleaseStatus;
rollout?: number;
changelog?: string;
changes_not_sent_for_review: boolean;
artifact_path: string;
};
release: { status: ReleaseStatus; userFraction?: number };
logger: bunyan;
signal?: AbortSignal;
}): Promise<number> {
let editId: string | undefined;
let committed = false;
let commitOutcomeUnknown = false;
try {
const packageName = submission.package_name;
logger.info(
`Submitting ${packageName} (${artifactType}) to track ${JSON.stringify(submission.track)}.`
);
logger.info(
`Release status: ${release.status}. Rollout: ${release.userFraction === undefined ? 'none' : new Intl.NumberFormat('en-US', { style: 'percent', maximumSignificantDigits: 15 }).format(release.userFraction)}.`
);
if (submission.release_status !== release.status) {
logger.info(
release.status === 'completed'
? 'A 100% rollout is submitted as a completed release.'
: 'A partial rollout is submitted as an in-progress release.'
);
}
logger.info(
submission.changes_not_sent_for_review
? 'Save changes without sending them for review.'
: 'Send changes for review.'
);
if (submission.changelog) {
logger.info(`Changelog (en-US): ${JSON.stringify(submission.changelog)}`);
}
editId = (await GooglePlayUtils.createEditAsync(client, { packageName, signal })).id;
let lastPercent: number | undefined;
const { versionCode } = await GooglePlayUtils.uploadApplicationAsync(client, {
packageName,
editId,
artifactPath: submission.artifact_path,
artifactType,
signal,
onProgress: (uploaded, total) => {
const percent = Math.floor((uploaded / total) * 100);
if (percent !== lastPercent) {
logger.info(`Upload: ${percent}% (${uploaded}/${total} bytes).`);
lastPercent = percent;
}
},
});
logger.info(`Uploaded version code: ${versionCode}.`);
await GooglePlayUtils.updateTrackAsync(client, {
packageName,
editId,
track: submission.track,
release,
versionCode,
changelog: submission.changelog ? { locale: 'en-US', text: submission.changelog } : undefined,
signal,
});
logger.info(`Updated Google Play track ${JSON.stringify(submission.track)} in the edit.`);
logger.info('Committing the Google Play edit...');
// Commit also validates the edit.
await GooglePlayUtils.commitEditAsync(client, {
packageName,
editId,
changesNotSentForReview: submission.changes_not_sent_for_review,
logger,
signal,
});
committed = true;
logger.info(
`Submitted ${packageName}, version ${versionCode}, to ${JSON.stringify(submission.track)}.`
);
return versionCode;
} catch (error) {
commitOutcomeUnknown = error instanceof GooglePlayCommitOutcomeUnknownError;
throw GooglePlayUtils.mapGooglePlayError(error, submission.package_name);
} finally {
if (editId && !committed && !commitOutcomeUnknown) {
try {
await GooglePlayUtils.deleteEditAsync(client, {
packageName: submission.package_name,
editId,
signal: AbortSignal.timeout(30_000),
});
} catch {
logger.warn(
'Could not discard the pending Google Play changes. Check the release in Play Console before submitting again.'
);
}
}
}
}
@@ -19,14 +19,22 @@ import { isProcessGroupRunning, killProcessGroup } from '../../utils/processes';
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
const IOS_SIMULATOR_RECORDING_POLL_INTERVAL_MS = 2_000;
// Once the poller stops, each recording stops in parallel within 60 + 130 + 5 + 30 = 225 s.
// Once the poller stops, each recording stops in parallel within 60 + 430 + 5 + 310 = 805 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.
// Match record-video's control request, recording lease, and finalization allowances.
const SERVE_SIM_CONTROL_REQUEST_TIMEOUT_MS = 120_000;
const SERVE_SIM_RECORDING_LEASE_MS = 180_000;
const SERVE_SIM_FINALIZATION_TIMEOUT_MS = 120_000;
const SERVE_SIM_SHUTDOWN_MARGIN_MS = 10_000;
/** Give the session host time to finalize its active writer before SIGKILL. */
export const SERVE_SIM_STOP_GRACE_PERIOD_MS =
SERVE_SIM_FINALIZATION_TIMEOUT_MS + SERVE_SIM_SHUTDOWN_MARGIN_MS;
// A recorder can exit before its lease expires and the host writes the manifest.
const SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS =
SERVE_SIM_RECORDING_LEASE_MS + SERVE_SIM_FINALIZATION_TIMEOUT_MS + SERVE_SIM_SHUTDOWN_MARGIN_MS;
// An ambiguous stop can use a full control request before lease/finalization reconciliation.
const SERVE_SIM_FINISH_TIMEOUT_MS =
SERVE_SIM_STOP_GRACE_PERIOD_MS + SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS + 10_000;
SERVE_SIM_CONTROL_REQUEST_TIMEOUT_MS + SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS;
const SERVE_SIM_FORCE_STOP_TIMEOUT_MS = 5_000;
const SERVE_SIM_RECORDING_STARTED = 'serve-sim:recording-started';
// The serve-sim package the session host started; every recorder runs `record-video` from it.
@@ -46,6 +54,7 @@ type IosSimulatorRecording = {
type ActiveIosSimulatorRecording = IosSimulatorRecording & {
recordingProcess: ChildProcess;
completionPromise: Promise<void>;
hasExited: () => boolean;
};
type IosSimulatorRecordingSession = {
@@ -128,6 +137,7 @@ export namespace IosSimulatorRecordingUtils {
logger.info(`Stopping screen recording for ${recording.deviceName}.`);
const startState = await waitForRecorderStartAsync(recording, SERVE_SIM_START_TIMEOUT_MS);
if (startState === 'exited') {
await recording.completionPromise;
return;
}
if (startState === 'timeout') {
@@ -357,6 +367,7 @@ async function startIosSimulatorRecordingAsync(
});
return true;
};
let processExited = false;
const completionPromise = (async () => {
let spawnError: unknown;
try {
@@ -367,6 +378,7 @@ async function startIosSimulatorRecordingAsync(
// 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);
processExited = true;
// Host shutdown can end the recorder's lease after its video has been saved.
if ((hasStarted() || !spawnError) && (await saveFinalizedRecordingAsync())) {
return;
@@ -400,6 +412,7 @@ async function startIosSimulatorRecordingAsync(
outputDirectory,
recordingProcess: recordingSpawn.child,
completionPromise,
hasExited: () => processExited,
startedAt,
getOutput,
hasStarted,
@@ -428,6 +441,9 @@ async function waitForRecorderStartAsync(
): Promise<'started' | 'exited' | 'timeout'> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (recording.hasExited()) {
return 'exited';
}
if (recording.hasStarted()) {
return 'started';
}
@@ -439,7 +455,7 @@ async function waitForRecorderStartAsync(
return 'exited';
}
}
return 'timeout';
return recording.hasExited() ? 'exited' : recording.hasStarted() ? 'started' : 'timeout';
}
function reportLostRecording(logger: bunyan, deviceName: string, recorderOutput: string): void {
@@ -0,0 +1,133 @@
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 { writeFile } from 'node:fs/promises';
import { PassThrough } from 'node:stream';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils';
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
jest.mock('@expo/turtle-spawn');
jest.mock('../serveSimMetricsRecorder');
jest.mock('../../../sentry');
jest.mock('node:timers/promises', () => ({
setTimeout: (ms: number, value?: unknown, options?: { signal?: AbortSignal }) =>
new Promise((resolve, reject) => {
const abort = (): void => {
clearTimeout(timer);
reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }));
};
const timer = setTimeout(() => {
options?.signal?.removeEventListener('abort', abort);
resolve(value);
}, ms);
if (options?.signal?.aborted) {
abort();
} else {
options?.signal?.addEventListener('abort', abort, { once: true });
}
}),
}));
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
} as unknown as bunyan;
const udid = '06B546AC-7B06-4BED-83DB-83603E8EB537';
let exit: () => void;
let child: ChildProcess;
let finishing: Promise<unknown> | undefined;
beforeEach(async () => {
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
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)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
child = Object.assign(new EventEmitter(), {
stdout: new PassThrough(),
stderr: new PassThrough(),
kill: jest.fn(),
}) as unknown as ChildProcess;
const done = new Promise<void>(resolve => {
exit = resolve;
});
jest.mocked(spawn).mockReturnValue(Object.assign(done, { child }) as never);
await IosSimulatorRecordingUtils.startAsync({ env: {} as Env, logger });
for (let attempt = 0; attempt < 100 && jest.mocked(spawn).mock.calls.length === 0; attempt++) {
await new Promise<void>(resolve => setImmediate(resolve));
}
expect(spawn).toHaveBeenCalledTimes(1);
child.stderr?.emit('data', 'serve-sim:recording-started\n');
});
async function saveManifest(): Promise<void> {
const directory = jest.mocked(spawn).mock.calls[0][1][6];
await writeFile(`${directory}/session.json`, JSON.stringify({ recording: 'recording.mp4' }));
}
afterEach(async () => {
if (jest.mocked(spawn).mock.calls.length) {
await saveManifest();
}
exit();
finishing ??= IosSimulatorRecordingUtils.finishAsync({ logger });
await jest.advanceTimersByTimeAsync(1_000);
await finishing;
finishing = undefined;
jest.useRealTimers();
jest.restoreAllMocks();
jest.mocked(spawn).mockReset();
});
test('keeps the recorder alive through a 120-second stop request and 300-second reconciliation', async () => {
finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
await jest.advanceTimersByTimeAsync(0);
expect(child.kill).toHaveBeenCalledWith('SIGINT');
await jest.advanceTimersByTimeAsync(420_000);
expect(child.kill).not.toHaveBeenCalledWith('SIGKILL');
await saveManifest();
exit();
await jest.advanceTimersByTimeAsync(0);
expect(await finishing).toHaveLength(1);
expect(child.kill).not.toHaveBeenCalledWith('SIGKILL');
});
test('force-kills an unresponsive started recorder after the bounded shutdown allowance', async () => {
jest.mocked(child.kill).mockImplementation(signal => {
if (signal === 'SIGKILL') {
exit();
}
return true;
});
finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
await jest.advanceTimersByTimeAsync(429_999);
expect(child.kill).not.toHaveBeenCalledWith('SIGKILL');
await saveManifest();
await jest.advanceTimersByTimeAsync(1);
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
expect(await finishing).toHaveLength(1);
expect(logger.warn).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining('did not finish within 430 seconds')
);
});
test('retains a manifest saved after a 180-second lease and 120-second finalization', async () => {
exit();
finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
await jest.advanceTimersByTimeAsync(299_999);
await saveManifest();
await jest.advanceTimersByTimeAsync(1_001);
expect(await finishing).toHaveLength(1);
expect(child.kill).not.toHaveBeenCalledWith('SIGKILL');
});
@@ -186,7 +186,7 @@ test('reports a started recording that ends without a manifest to Sentry', async
child.stderr?.emit('data', 'GET http://127.0.0.1:43563/record?token=session-token failed\n');
exitRecorder();
await setTimeout(50);
currentTime += 31_000;
currentTime += 311_000;
await setTimeout(1_100);
expect(Sentry.capture).toHaveBeenCalledWith(
@@ -226,7 +226,7 @@ test('says when a recorder never reported its start before it is stopped', async
await setTimeout(50);
currentTime += 61_000;
await setTimeout(200);
currentTime += 31_000;
currentTime += 311_000;
expect(await finishing).toEqual([]);
expect(child.kill).toHaveBeenCalledWith('SIGINT');
@@ -377,7 +377,7 @@ test('does not signal a recorder that exits before it reports its start', async
recorder.exit();
await setTimeout(50);
// Past the manifest wait, so the recorder's completion settles.
currentTime += 31_000;
currentTime += 311_000;
expect(await finishing).toEqual([]);
expect(recorder.child.kill).not.toHaveBeenCalled();
@@ -435,7 +435,7 @@ test('reports a finalized recording whose manifest is gone at finish', async ()
await rm(manifest);
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
await setTimeout(50);
currentTime += 31_000;
currentTime += 311_000;
expect(await finishing).toEqual([]);
expect(Sentry.capture).toHaveBeenCalledWith(
@@ -493,7 +493,7 @@ test('reports a started recorder that fails without a manifest', async () => {
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;
currentTime += 311_000;
await setTimeout(1_100);
expect(logger.warn).toHaveBeenCalledWith(
@@ -534,6 +534,6 @@ test('records every booted device with the first serve-sim package', async () =>
recorder.exit();
await setTimeout(50);
currentTime += 31_000;
currentTime += 311_000;
await IosSimulatorRecordingUtils.finishAsync({ logger });
});
@@ -153,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, {
@@ -179,7 +179,7 @@ describe(uploadAgentDeviceArtifactAsync, () => {
filename: 'report.json',
kind: 'agent-device-test-report',
size: data.length,
stream: expect.anything(),
openStream: expect.any(Function),
});
});
@@ -191,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, {
@@ -222,8 +222,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, {
@@ -255,8 +255,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, {
@@ -345,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, {
@@ -7,7 +7,12 @@ import { type CustomBuildContext } from '../../../customBuildContext';
import HttpLogStream from '../../../logging/HttpLogStream';
import RemoteLoggerStream from '../../../logging/RemoteLoggerStream';
import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
import { startAgentDeviceEventCollectionAsync } from '../agentDeviceEvents';
import {
DEVICE_RUN_SESSION_TOKEN_HEADER,
DeviceRunSessionWorkerApiError,
} from '../deviceRunSessionWorkerApi';
const mockEventLogStream = {
init: jest.fn(async () => undefined),
@@ -28,10 +33,18 @@ jest.mock('../../../logging/RemoteLoggerStream', () => ({
default: jest.fn(() => mockEventLogStream),
}));
jest.mock('../../../sentry');
jest.mock('../../../utils/turtleFetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
const DEVICE_RUN_SESSION_TOKEN = 'device-run-session-token-value';
const EVENT_LOG_UPLOAD_SESSION_URL =
'https://api.expo.test/v2/device-run-session-worker/event-log-upload-session';
describe(startAgentDeviceEventCollectionAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(turtleFetch).mockImplementation(async () => eventLogUploadSessionResponse());
});
it('uploads normalized events to the session event artifact once', async () => {
@@ -88,9 +101,13 @@ describe(startAgentDeviceEventCollectionAsync, () => {
await fs.promises.rm(stateDir, { recursive: true, force: true });
}
expect(ctx.graphqlClient.mutation).toHaveBeenCalledWith(expect.anything(), {
deviceRunSessionId: 'session-id',
});
expect(turtleFetch).toHaveBeenCalledWith(
EVENT_LOG_UPLOAD_SESSION_URL,
'POST',
expect.objectContaining({
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
})
);
expect(RemoteLoggerStream).toHaveBeenCalledWith({
logger,
uploadMethod: {
@@ -535,11 +552,13 @@ describe(startAgentDeviceEventCollectionAsync, () => {
});
it('does not fail the session when event log setup fails', async () => {
const error = new Error('WWW unavailable');
const ctx = createContext({ realtimeLogs: true });
jest.mocked(ctx.graphqlClient.mutation).mockReturnValueOnce({
toPromise: async () => ({ error }),
} as unknown as ReturnType<CustomBuildContext['graphqlClient']['mutation']>);
jest.mocked(turtleFetch).mockResolvedValueOnce(
new Response(JSON.stringify({ errors: [{ message: 'WWW unavailable' }] }), {
status: 503,
headers: { 'content-type': 'application/json' },
})
);
const logger = createLogger();
const collection = await startAgentDeviceEventCollectionAsync({
@@ -554,8 +573,11 @@ describe(startAgentDeviceEventCollectionAsync, () => {
expect(logger.warn).toHaveBeenCalledWith(
{
err: expect.objectContaining({
message: 'Failed to create device run session event log upload session: WWW unavailable',
cause: error,
message:
'Failed to create device run session event log upload session: ' +
'POST /v2/device-run-session-worker/event-log-upload-session failed with status 503: ' +
'WWW unavailable',
cause: expect.any(DeviceRunSessionWorkerApiError),
}),
},
'Could not persist device run session events to the artifact.'
@@ -563,8 +585,11 @@ describe(startAgentDeviceEventCollectionAsync, () => {
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not persist device run session events',
expect.objectContaining({
message: 'Failed to create device run session event log upload session: WWW unavailable',
cause: error,
message:
'Failed to create device run session event log upload session: ' +
'POST /v2/device-run-session-worker/event-log-upload-session failed with status 503: ' +
'WWW unavailable',
cause: expect.any(DeviceRunSessionWorkerApiError),
}),
{
level: 'warning',
@@ -669,32 +694,35 @@ describe(startAgentDeviceEventCollectionAsync, () => {
function createContext({
realtimeLogs = false,
}: { realtimeLogs?: boolean } = {}): CustomBuildContext {
const mutation = jest.fn().mockReturnValue({
toPromise: async () => ({
data: {
deviceRunSession: {
createEventLogUploadSession: {
uploadSession: {
url: 'https://uploads.expo.test/events',
headers: {
'content-disposition': 'attachment; filename="events.ndjson"',
'x-goog-content-length-range': '0,104857600',
},
},
},
},
},
}),
});
return {
graphqlClient: { mutation },
env: realtimeLogs ? { EXPO_STAGING: '1', EAS_BUILD_ID: 'job-run-id' } : {},
env: {
EAS_DEVICE_RUN_SESSION_TOKEN: DEVICE_RUN_SESSION_TOKEN,
...(realtimeLogs ? { EXPO_STAGING: '1', EAS_BUILD_ID: 'job-run-id' } : {}),
},
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
job: {
secrets: realtimeLogs ? { robotAccessToken: 'robot-access-token' } : {},
},
} as unknown as CustomBuildContext;
}
function eventLogUploadSessionResponse(): InstanceType<typeof Response> {
return new Response(
JSON.stringify({
data: {
uploadSession: {
url: 'https://uploads.expo.test/events',
headers: {
'content-disposition': 'attachment; filename="events.ndjson"',
'x-goog-content-length-range': '0,104857600',
},
},
},
}),
{ headers: { 'content-type': 'application/json' } }
);
}
function createLogger(): bunyan {
return { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
}
@@ -0,0 +1,628 @@
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(),
addBuildLogMask: jest.fn(),
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', async () => {
leaseMock.mockResolvedValue(anthropicLease);
const logger = createMockLogger();
mockSpawn({
onAgent: agent => {
const toolResultEvent =
'{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"3 tests passed"}]}}\n';
agent.child.stdout.write(toolResultEvent.slice(0, 40));
agent.child.stdout.write(toolResultEvent.slice(40));
agent.child.stdout.write(
'{"type":"assistant","message":{"content":[{"type":"text","text":"Done.\\nThe tests pass.\\n"}]}}\n'
);
agent.child.stdout.write(
'{"type":"result","subtype":"error_during_execution","is_error":true,"errors":["boom"]}\n'
);
agent.child.stderr.write('warning: deprecated flag\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: 3 tests passed'],
['Agent: Done.\nThe tests pass.'],
]);
expect(stdoutLogger.error.mock.calls).toEqual([['Agent failed: boom']]);
expect(stderrLogger.info.mock.calls).toEqual([['warning: deprecated flag']]);
});
it.each([
['claude-code', anthropicLease, ['anthropic-access-token']],
['codex', openaiLease, ['openai-access-token', 'openai-id-token']],
] as const)(
'registers the credentials leased for %s with the build log secrets filter before starting the agent',
async (agentKind, lease, secrets) => {
leaseMock.mockResolvedValue(lease);
const addBuildLogMask = jest.fn();
const invocations = mockSpawn({
onAgent: agent => {
expect(addBuildLogMask.mock.calls).toEqual(secrets.map(secret => [secret]));
agent.exit(0);
},
});
await runAgentAsync(createOptions({ agentKind, addBuildLogMask }));
expect(invocations).toHaveLength(1);
}
);
it('registers renewed credentials before resuming the agent', async () => {
leaseMock
.mockResolvedValueOnce(anthropicLease)
.mockResolvedValueOnce({ ...anthropicLease, accessToken: 'renewed-access-token' });
const addBuildLogMask = jest.fn();
const invocations = mockSpawn({
onAgent: agent => {
if (invocations.length === 2) {
expect(addBuildLogMask.mock.calls).toEqual([
['anthropic-access-token'],
['renewed-access-token'],
]);
}
agent.exit(invocations.length === 1 ? 1 : 0);
},
});
await runAgentAsync(createOptions({ addBuildLogMask }));
expect(invocations).toHaveLength(2);
});
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 => 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' }],
]);
});
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),
});
});
@@ -7,7 +7,9 @@ import { type CustomBuildContext } from '../../../customBuildContext';
import HttpLogStream from '../../../logging/HttpLogStream';
import RemoteLoggerStream from '../../../logging/RemoteLoggerStream';
import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
import { startArgentEventCollectionAsync } from '../argentEvents';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
const mockEventLogStream = {
init: jest.fn(async () => undefined),
@@ -28,12 +30,20 @@ jest.mock('../../../logging/RemoteLoggerStream', () => ({
default: jest.fn(() => mockEventLogStream),
}));
jest.mock('../../../sentry');
jest.mock('../../../utils/turtleFetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
const DEVICE_RUN_SESSION_TOKEN = 'device-run-session-token-value';
const EVENT_LOG_UPLOAD_SESSION_URL =
'https://api.expo.test/v2/device-run-session-worker/event-log-upload-session';
const EVENT_LOG_FILENAME = 'tool-server-events.jsonl';
describe(startArgentEventCollectionAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(turtleFetch).mockImplementation(async () => eventLogUploadSessionResponse());
});
it('maps tool lifecycle records onto the shared operation vocabulary', async () => {
@@ -84,9 +94,13 @@ describe(startArgentEventCollectionAsync, () => {
await fs.promises.rm(stateDir, { recursive: true, force: true });
}
expect(ctx.graphqlClient.mutation).toHaveBeenCalledWith(expect.anything(), {
deviceRunSessionId: 'session-id',
});
expect(turtleFetch).toHaveBeenCalledWith(
EVENT_LOG_UPLOAD_SESSION_URL,
'POST',
expect.objectContaining({
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
})
);
expect(RemoteLoggerStream).toHaveBeenCalledWith({
logger,
uploadMethod: {
@@ -318,29 +332,32 @@ function bunyanRecord(fields: Record<string, unknown>): Record<string, unknown>
function createContext({
realtimeLogs = false,
}: { realtimeLogs?: boolean } = {}): CustomBuildContext {
const mutation = jest.fn().mockReturnValue({
toPromise: async () => ({
data: {
deviceRunSession: {
createEventLogUploadSession: {
uploadSession: {
url: 'https://uploads.expo.test/events',
headers: { 'content-disposition': 'attachment; filename="events.ndjson"' },
},
},
},
},
}),
});
return {
graphqlClient: { mutation },
env: realtimeLogs ? { EXPO_STAGING: '1', EAS_BUILD_ID: 'job-run-id' } : {},
env: {
EAS_DEVICE_RUN_SESSION_TOKEN: DEVICE_RUN_SESSION_TOKEN,
...(realtimeLogs ? { EXPO_STAGING: '1', EAS_BUILD_ID: 'job-run-id' } : {}),
},
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
job: {
secrets: realtimeLogs ? { robotAccessToken: 'robot-access-token' } : {},
},
} as unknown as CustomBuildContext;
}
function eventLogUploadSessionResponse(): InstanceType<typeof Response> {
return new Response(
JSON.stringify({
data: {
uploadSession: {
url: 'https://uploads.expo.test/events',
headers: { 'content-disposition': 'attachment; filename="events.ndjson"' },
},
},
}),
{ headers: { 'content-type': 'application/json' } }
);
}
function createLogger(): bunyan {
return { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
}
@@ -2,46 +2,56 @@ import fetch from 'node-fetch';
import { Readable } from 'node:stream';
import { CustomBuildContext } from '../../../customBuildContext';
import { turtleFetch } from '../../../utils/turtleFetch';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
jest.mock('node-fetch');
jest.mock('../../../utils/turtleFetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
const TOKEN = 'device-run-session-token-value';
function jsonResponse(body: unknown): InstanceType<typeof Response> {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
function createCtx(): CustomBuildContext {
return {
env: { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN },
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
} as unknown as CustomBuildContext;
}
describe(uploadDeviceRunSessionArtifactAsync, () => {
beforeEach(() => {
jest.mocked(fetch).mockReset();
jest.mocked(turtleFetch).mockReset();
});
it('streams an artifact through a signed upload URL', async () => {
const stream = Readable.from(Buffer.from('artifact-data'));
const reportedSize = 1024;
const mutation = jest.fn().mockReturnValue({
toPromise: async () => ({
jest.mocked(turtleFetch).mockResolvedValueOnce(
jsonResponse({
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: {
url: 'https://uploads.expo.test/artifact',
headers: {
'Content-Length': String(reportedSize),
'Content-Type': 'application/octet-stream',
},
},
uploadSession: {
url: 'https://uploads.expo.test/artifact',
headers: {
'Content-Length': String(reportedSize),
'Content-Type': 'application/octet-stream',
},
},
},
}),
});
const ctx = {
graphqlClient: {
mutation,
},
} as unknown as CustomBuildContext;
})
);
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
await uploadDeviceRunSessionArtifactAsync(ctx, {
await uploadDeviceRunSessionArtifactAsync(createCtx(), {
deviceRunSessionId: 'drs-id',
artifactId: 'artifact-id',
name: 'Artifact report.json (artifact-id)',
@@ -49,22 +59,23 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
kind: 'agent-device-test-report',
metadata: { firstFrameRecordAt: 'test-time' },
size: reportedSize,
stream,
openStream: () => stream,
});
expect(mutation).toHaveBeenCalledWith(
expect.anything(),
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
'https://api.expo.test/v2/device-run-session-worker/artifact-upload-sessions',
'POST',
expect.objectContaining({
deviceRunSessionId: 'drs-id',
input: {
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: TOKEN },
json: {
name: 'Artifact report.json (artifact-id)',
filename: 'report.json',
kind: 'agent-device-test-report',
metadata: { firstFrameRecordAt: 'test-time' },
size: reportedSize,
},
}),
{ fetch: expect.any(Function) }
signal: expect.any(AbortSignal),
})
);
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
'https://uploads.expo.test/artifact',
@@ -77,49 +88,32 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
it('forwards cancellation through upload allocation and skips PUT when allocation aborts', async () => {
const controller = new AbortController();
const mutation = jest.fn().mockReturnValue({
toPromise: async () => {
controller.abort();
return {
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: {
url: 'https://uploads.expo.test/artifact',
headers: {},
},
},
},
jest.mocked(turtleFetch).mockImplementationOnce(async () => {
controller.abort();
return jsonResponse({
data: {
uploadSession: {
url: 'https://uploads.expo.test/artifact',
headers: {},
},
};
},
},
});
});
const ctx = { graphqlClient: { mutation } } as unknown as CustomBuildContext;
await expect(
uploadDeviceRunSessionArtifactAsync(ctx, {
uploadDeviceRunSessionArtifactAsync(createCtx(), {
deviceRunSessionId: 'session',
artifactId: 'id',
name: 'Logs',
filename: 'logs.ndjson',
kind: 'simulator-log',
size: 0,
stream: Readable.from([]),
openStream: () => Readable.from([]),
signal: controller.signal,
})
).rejects.toThrow();
expect(fetch).not.toHaveBeenCalled();
const allocationFetch = mutation.mock.calls[0][2].fetch;
const mockFetch = jest.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('aborted'));
try {
await expect(allocationFetch('https://api.expo.test/graphql', {})).rejects.toThrow('aborted');
expect(mockFetch).toHaveBeenCalledWith(
'https://api.expo.test/graphql',
expect.objectContaining({
signal: expect.objectContaining({ aborted: true }),
})
);
} finally {
mockFetch.mockRestore();
}
const allocationSignal = jest.mocked(turtleFetch).mock.calls[0][2].signal;
expect(allocationSignal).toBeInstanceOf(AbortSignal);
expect(allocationSignal?.aborted).toBe(true);
});
});
@@ -1,27 +1,52 @@
import { type bunyan } from '@expo/logger';
import { BuildRuntimePlatform } from '@expo/steps';
import { SystemError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { Client, fetchExchange } from '@urql/core';
import { getEventListeners } from 'node:events';
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 * as processes from '../../../utils/processes';
import { turtleFetch } from '../../../utils/turtleFetch';
import {
captureDeviceRunSessionPreviewAsync,
ensureMacosPreviewEncoderInstalledAsync,
startDeviceRunSessionPreview,
} from '../deviceRunSessionPreview';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
jest.mock('node-fetch');
jest.mock('@expo/turtle-spawn');
jest.mock('../../../utils/turtleFetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
const DEVICE_RUN_SESSION_TOKEN = 'device-run-session-token-value';
const PREVIEW_UPLOAD_SESSION_URL =
'https://api.expo.test/v2/device-run-session-worker/preview-upload-session';
const uploadSession = {
url: 'https://uploads.expo.test/preview',
headers: { 'content-type': 'image/webp' },
};
function createCtx(): CustomBuildContext {
return {
env: { EAS_DEVICE_RUN_SESSION_TOKEN: DEVICE_RUN_SESSION_TOKEN },
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
} as unknown as CustomBuildContext;
}
function uploadSessionResponse(): InstanceType<typeof Response> {
return new Response(JSON.stringify({ data: { uploadSession } }), {
headers: { 'content-type': 'application/json' },
});
}
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;
@@ -29,21 +54,11 @@ describe(startDeviceRunSessionPreview, () => {
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;
jest
.mocked(turtleFetch)
.mockReset()
.mockImplementation(async () => uploadSessionResponse());
ctx = createCtx();
logger = { warn: jest.fn() } as unknown as bunyan;
jest
.mocked(fetch)
@@ -80,7 +95,7 @@ describe(startDeviceRunSessionPreview, () => {
expect(captureAsync).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1);
expect(captureAsync).toHaveBeenCalledTimes(2);
expect(mutation).toHaveBeenCalledTimes(1);
expect(turtleFetch).toHaveBeenCalledTimes(1);
await preview!.stopAsync();
await jest.advanceTimersByTimeAsync(120_000);
expect(captureAsync).toHaveBeenCalledTimes(2);
@@ -90,7 +105,7 @@ describe(startDeviceRunSessionPreview, () => {
captureAsync.mockRejectedValueOnce(new Error('device not ready'));
start();
await jest.advanceTimersByTimeAsync(0);
expect(mutation).not.toHaveBeenCalled();
expect(turtleFetch).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(60_000);
expect(fetch).toHaveBeenCalledTimes(1);
@@ -100,7 +115,7 @@ describe(startDeviceRunSessionPreview, () => {
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 403 }));
start();
await jest.advanceTimersByTimeAsync(60_000);
expect(mutation).toHaveBeenCalledTimes(1);
expect(turtleFetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledTimes(2);
});
@@ -124,7 +139,7 @@ describe(startDeviceRunSessionPreview, () => {
it('reuses the upload URL for the full session', async () => {
start();
await jest.advanceTimersByTimeAsync(119 * 60_000);
expect(mutation).toHaveBeenCalledTimes(1);
expect(turtleFetch).toHaveBeenCalledTimes(1);
});
it('does not overlap captures and aborts pending work when stopped', async () => {
@@ -154,29 +169,20 @@ describe(startDeviceRunSessionPreview, () => {
const stopping = handle.stopAsync();
finish(image);
await stopping;
expect(mutation).not.toHaveBeenCalled();
expect(turtleFetch).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;
describe('session preview upload session from the worker API', () => {
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' } }
)
);
jest
.mocked(turtleFetch)
.mockReset()
.mockImplementation(async () => uploadSessionResponse());
logger = { warn: jest.fn() } as unknown as bunyan;
jest
.mocked(fetch)
@@ -191,14 +197,9 @@ describe('session preview with the real GraphQL client', () => {
jest.useRealTimers();
});
const start = () => {
const graphqlClient = new Client({
url: 'https://api.expo.test/graphql',
exchanges: [fetchExchange],
fetchOptions: { headers: { Authorization: 'Bearer worker-token' } },
});
const start = (ctx: CustomBuildContext = createCtx()) => {
preview = startDeviceRunSessionPreview({
ctx: { graphqlClient } as CustomBuildContext,
ctx,
deviceRunSessionId: 'session-id',
captureAsync: async () => Buffer.from('webp-image'),
logger,
@@ -206,25 +207,49 @@ describe('session preview with the real GraphQL client', () => {
return preview;
};
it('preserves the worker authentication when creating the upload session', async () => {
it('sends the device run session token 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(turtleFetch).toHaveBeenCalledWith(
PREVIEW_UPLOAD_SESSION_URL,
'POST',
expect.objectContaining({
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
signal: expect.any(AbortSignal),
})
);
expect(fetch).toHaveBeenCalledTimes(1);
});
it.each(['shutdown', 'timeout'])('aborts a stalled GraphQL request on %s', async reason => {
it('logs a failure and uploads nothing when the job has no device run session token', async () => {
start({
env: {},
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
} as unknown as CustomBuildContext);
await jest.advanceTimersByTimeAsync(0);
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.any(SystemError) },
'Could not refresh the session preview; retrying in 60 seconds.'
);
const [{ err }] = jest.mocked(logger.warn).mock.calls[0] as unknown as [{ err: Error }];
expect(err.message).toMatch(
/^This device run session job has no EAS_DEVICE_RUN_SESSION_TOKEN secret\./
);
expect(turtleFetch).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
});
it.each(['shutdown', 'timeout'])('aborts a stalled worker API 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) =>
jest.mocked(turtleFetch).mockImplementation(
(_url, _method, options) =>
new Promise((_, reject) => {
rejectRequest = reject;
requestSignal = options.signal;
requestSignal!.addEventListener('abort', () => reject(requestSignal!.reason), {
requestSignal = options.signal as AbortSignal;
requestSignal.addEventListener('abort', () => reject(requestSignal!.reason), {
once: true,
});
})
@@ -253,14 +278,7 @@ describe('session preview with the real GraphQL client', () => {
await stopping;
} else {
expect(logger.warn).toHaveBeenCalled();
graphqlFetch.mockResolvedValue(
new globalThis.Response(
JSON.stringify({
data: { deviceRunSession: { createPreviewUploadSession: { uploadSession } } },
}),
{ headers: { 'content-type': 'application/json' } }
)
);
jest.mocked(turtleFetch).mockImplementation(async () => uploadSessionResponse());
jest.spyOn(AbortSignal, 'timeout').mockReturnValue(new AbortController().signal);
await jest.advanceTimersByTimeAsync(60_000);
expect(fetch).toHaveBeenCalledTimes(1);
@@ -383,18 +401,106 @@ describe(ensureMacosPreviewEncoderInstalledAsync, () => {
[
'brew',
['install', 'webp'],
{ env: { HOME: '/Users/expo', HOMEBREW_NO_AUTO_UPDATE: '1' }, logger },
{
env: { HOME: '/Users/expo', HOMEBREW_NO_AUTO_UPDATE: '1' },
logger,
detached: true,
},
],
]);
}
}
);
it('rejects when webp cannot be installed', async () => {
it('does not start a command when setup was already canceled', async () => {
jest.mocked(spawn).mockReset();
jest.mocked(spawn).mockRejectedValue(new Error('brew failed'));
await expect(ensureMacosPreviewEncoderInstalledAsync({ env: {}, logger })).rejects.toThrow(
'brew failed'
);
const controller = new AbortController();
const reason = new Error('session ended');
controller.abort(reason);
await expect(
ensureMacosPreviewEncoderInstalledAsync({ env: {}, logger, signal: controller.signal })
).rejects.toBe(reason);
expect(spawn).not.toHaveBeenCalled();
expect(getEventListeners(controller.signal, 'abort')).toEqual([]);
});
it.each(['cwebp', 'brew'])(
'waits for canceled %s to close and never starts another command',
async command => {
jest.mocked(spawn).mockReset();
const controller = new AbortController();
const child = { pid: 12345, kill: jest.fn() };
const killed = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
const closeError = new Error(`${command} closed after cancellation`);
let entered!: () => void;
const commandEntered = new Promise<void>(resolve => {
entered = resolve;
});
let close!: (error: Error) => void;
jest.mocked(spawn).mockImplementation(current => {
if (current !== command) {
return Promise.reject(new Error('cwebp missing')) as ReturnType<typeof spawn>;
}
entered();
return Object.assign(
new Promise((_, reject) => {
close = reject;
}),
{ child }
) as unknown as ReturnType<typeof spawn>;
});
let settled = false;
const result = ensureMacosPreviewEncoderInstalledAsync({
env: {},
logger,
signal: controller.signal,
}).then(
() => {
settled = true;
},
error => {
settled = true;
return error;
}
);
try {
await commandEntered;
controller.abort(new Error('session ended'));
await Promise.resolve();
expect(killed).toHaveBeenCalledWith(child, 'SIGKILL');
expect(settled).toBe(false);
expect(jest.mocked(spawn).mock.calls.at(-1)).toEqual([
command,
command === 'cwebp' ? ['-version'] : ['install', 'webp'],
expect.objectContaining({ detached: true }),
]);
expect(jest.mocked(spawn).mock.calls.every(([, , options]) => !options?.signal)).toBe(true);
} finally {
close(closeError);
await result;
killed.mockRestore();
}
expect(spawn).toHaveBeenCalledTimes(command === 'cwebp' ? 1 : 2);
expect(getEventListeners(controller.signal, 'abort')).toEqual([]);
}
);
it('rejects an installation failure and removes its abort listeners', async () => {
jest.mocked(spawn).mockReset();
const controller = new AbortController();
const failure = new Error('brew failed');
jest.mocked(spawn).mockRejectedValue(failure);
await expect(
ensureMacosPreviewEncoderInstalledAsync({ env: {}, logger, signal: controller.signal })
).rejects.toBe(failure);
expect(getEventListeners(controller.signal, 'abort')).toEqual([]);
});
it('removes its abort listener after a successful probe', async () => {
jest.mocked(spawn).mockReset();
jest.mocked(spawn).mockResolvedValue({ stdout: '', stderr: '' } as never);
const controller = new AbortController();
await ensureMacosPreviewEncoderInstalledAsync({ env: {}, logger, signal: controller.signal });
expect(getEventListeners(controller.signal, 'abort')).toEqual([]);
});
});
@@ -1,8 +1,7 @@
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 { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
import { tmpdir } from 'node:os';
import path from 'node:path';
@@ -25,25 +24,27 @@ let ctx: CustomBuildContext;
let url: string;
let directory: string;
let creationBodies: string[];
let creationTokens: (string | undefined)[];
beforeEach(async () => {
creationBodies = [];
creationTokens = [];
directory = await mkdtemp(path.join(tmpdir(), 'recording-recovery-'));
server = createServer((request, response) => {
if (request.url === '/graphql') {
if (request.url === '/v2/device-run-session-worker/artifact-upload-sessions') {
request.setEncoding('utf8');
let body = '';
request.on('data', chunk => (body += chunk));
request.on('end', () => {
creationBodies.push(body);
creationTokens.push(request.headers['expo-device-run-session-token'] as string | undefined);
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify({
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: { url: `${url}/artifact`, headers: {} },
},
uploadSession: {
url: `${url}/artifact/${encodeURIComponent(JSON.parse(body).filename)}`,
headers: {},
},
},
})
@@ -60,12 +61,9 @@ beforeEach(async () => {
}
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;
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
expoApiV2BaseUrl: `${url}/v2/`,
} as unknown as CustomBuildContext;
});
afterEach(async () => {
@@ -165,10 +163,14 @@ it('recovers unlisted recordings from a killed host and flags the unfinished one
]);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('empty/recording.mp4.partial'));
let putBytes = 0;
const uploaded = new Map<string, Buffer>();
handle = (request, response) => {
request.on('data', chunk => (putBytes += chunk.length));
const chunks: Buffer[] = [];
request.on('data', chunk => {
chunks.push(chunk);
});
request.on('end', () => {
uploaded.set(request.url!, Buffer.concat(chunks));
response.statusCode = 200;
response.end();
});
@@ -180,9 +182,18 @@ it('recovers unlisted recordings from a killed host and flags the unfinished one
recordings,
})
).resolves.toBe(true);
expect(putBytes).toBe(3 * (await stat(path.join(cut, 'recording.mp4.partial'))).size);
for (const recording of recordings) {
const manifest = await readFile(path.join(recording.directory, 'session.json'));
const recordingId = path.basename(recording.directory);
expect(uploaded.get(`/artifact/${recordingId}.json`)).toEqual(manifest);
expect(uploaded.get(`/artifact/${recordingId}.mp4`)).toEqual(
await readFile(path.join(recording.directory, JSON.parse(manifest.toString()).recording))
);
}
expect(creationTokens).toEqual(Array(6).fill('session-token'));
const inputs = creationBodies
.map(body => JSON.parse(body).variables.input)
.map(body => JSON.parse(body))
.filter(input => input.kind === 'screen-recording')
.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]);
@@ -193,6 +204,16 @@ it('recovers unlisted recordings from a killed host and flags the unfinished one
width: 128,
height: 96,
});
const manifests = creationBodies
.map(body => JSON.parse(body))
.filter(input => input.kind === 'screen-recording-metadata')
.sort((a, b) => a.filename.localeCompare(b.filename));
expect(manifests.map(input => input.filename)).toEqual(['cut.json', 'done.json', 'failed.json']);
expect(manifests.map(input => input.metadata.recordingFilename)).toEqual([
'cut.mp4',
'done.mp4',
'failed.mp4',
]);
expect(inputs[2].metadata).toMatchObject({
partial: true,
partialReason: 'Recording finalization exceeded 30000 ms.',
@@ -0,0 +1,141 @@
import type { bunyan } from '@expo/logger';
import { vol } from 'memfs';
import { writeFile } from 'node:fs/promises';
import type { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import { uploadDeviceRunSessionScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
jest.mock('../deviceRunSessionArtifacts');
jest.mock('../../../sentry');
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const ctx = {} as CustomBuildContext;
const recording = {
udid: 'simulator-id',
deviceName: 'iPhone Duo',
runtimeDisplayName: 'iOS 27.1',
directory: '/recordings/recording-id',
};
const manifest = {
firstFrameWallClock: { unixMs: 1_791_072_000_000, iso8601: '2026-10-04T00:00:00.000Z' },
width: 1440,
height: 1200,
recording: 'recording.mp4',
};
beforeEach(() => {
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockReset().mockResolvedValue(undefined);
});
it.each([
{},
{
deviceStates: [
{
timeMs: 12.5,
state: {
width: 1200,
height: 1440,
orientation: 'landscape-left',
screen: 'inner',
hingeAngle: 90,
physicalOrientation: 'portrait',
tableMode: false,
contentRect: { x: 220, y: 0, width: 1000, height: 1200 },
},
},
],
interruptions: [{ reason: 'simulatorReboot', pausedAtSeconds: 1, resumedAtSeconds: 2 }],
futureField: { label: '서울 🌲' },
},
])('uploads the complete manifest unchanged beside its video (%j)', async extra => {
const contents = Buffer.from(JSON.stringify({ ...manifest, ...extra }, null, 2) + '\n');
vol.fromJSON({
[recording.directory + '/session.json']: contents.toString(),
[recording.directory + '/recording.mp4']: 'video',
});
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_, input) => {
if (input.kind === 'screen-recording') {
await writeFile(recording.directory + '/session.json', '{}');
}
});
await expect(
uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
logger,
deviceRunSessionId: 'session-id',
recordings: [recording],
})
).resolves.toBe(true);
const inputs = jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mock.calls.map(([, input]) => input);
expect(inputs.map(input => input.filename)).toEqual(['recording-id.mp4', 'recording-id.json']);
expect(inputs[1]).toMatchObject({
deviceRunSessionId: 'session-id',
artifactId: 'recording-id-metadata',
kind: 'screen-recording-metadata',
metadata: {
__eas_type: 'screen-recording-metadata',
recordingFilename: 'recording-id.mp4',
udid: recording.udid,
},
size: contents.length,
});
for (let attempt = 0; attempt < 2; attempt++) {
const chunks: Buffer[] = [];
for await (const chunk of inputs[1].openStream()) {
chunks.push(Buffer.from(chunk));
}
expect(Buffer.concat(chunks)).toEqual(contents);
}
});
it('reports a metadata upload failure while keeping the video and uploading other recordings', async () => {
const other = { ...recording, directory: '/recordings/other-recording', udid: 'other-device' };
for (const item of [recording, other]) {
vol.fromJSON({
[item.directory + '/session.json']: JSON.stringify(manifest),
[item.directory + '/recording.mp4']: 'video',
});
}
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_, input) => {
if (input.filename === 'recording-id.json') {
throw new Error('Storage unavailable');
}
});
await expect(
uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
logger,
deviceRunSessionId: 'session-id',
recordings: [recording, other],
})
).resolves.toBe(false);
expect(
jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls.map(([, input]) => input.filename)
).toEqual(
expect.arrayContaining([
'recording-id.mp4',
'recording-id.json',
'other-recording.mp4',
'other-recording.json',
])
);
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not upload device screen recording metadata',
expect.any(Error)
);
expect(Sentry.capture).not.toHaveBeenCalledWith(
'Could not upload device screen recording',
expect.any(Error)
);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
`Could not upload screen recording metadata for ${recording.deviceName}.`
);
});
@@ -45,3 +45,38 @@ it('accepts a full SHA-256 commit hash', () => {
expect(result.filename).toBe(`my-app-ios-bbbbbbb-${timestamp}.png`);
expect(result.metadata.gitCommitHash).toBe(gitCommitHash);
});
describe('without an app slug (device run session worker API)', () => {
const sessionWithoutSlug = {
platform: 'IOS' as const,
app: null,
build: { id: 'installed-build', gitCommitHash, app: null },
};
it('starts the filename with the platform and omits appSlug from the metadata', () => {
expect(screenshotArtifactDetails(timestamp, sessionWithoutSlug)).toEqual({
name,
filename: `ios-a1b2c3d-${timestamp}.png`,
metadata: {
__eas_type: 'screenshot',
platform: 'ios',
buildId: 'installed-build',
gitCommitHash,
},
});
});
it('omits the commit when the session has no build', () => {
expect(
screenshotArtifactDetails(timestamp, {
...sessionWithoutSlug,
platform: 'ANDROID',
build: null,
})
).toEqual({
name,
filename: `android-${timestamp}.png`,
metadata: { __eas_type: 'screenshot', platform: 'android' },
});
});
});
@@ -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,5 +1,5 @@
import type { bunyan } from '@expo/logger';
import { Client, fetchExchange } from '@urql/core';
import { BuildRuntimePlatform } from '@expo/steps';
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
import { tmpdir } from 'node:os';
@@ -10,6 +10,7 @@ import {
screenshotArtifactDetails,
} from '../deviceRunSessionScreenshotNames';
import { uploadDeviceRunSessionScreenshotsAsync } from '../deviceRunSessionScreenshots';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
jest.unmock('node-fetch');
jest.mock('../../../sentry');
function deferred<T>() {
@@ -26,7 +27,7 @@ let ctx: CustomBuildContext;
let url: string;
let directory: string;
let creates: number;
let authorization: string | undefined;
let deviceRunSessionToken: string | string[] | undefined;
let stallCreation: boolean;
let creationStarted: ReturnType<typeof deferred<IncomingMessage>>;
let creationBodies: string[];
@@ -40,42 +41,32 @@ beforeEach(async () => {
sessionLookups = 0;
sessionLookupFails = false;
uploadQuery = '';
authorization = undefined;
deviceRunSessionToken = undefined;
stallCreation = false;
creationBodies = [];
creationStarted = deferred();
directory = await mkdtemp(path.join(tmpdir(), 'recording-upload-http-'));
server = createServer((request, response) => {
if (request.url?.startsWith('/graphql')) {
authorization = request.headers.authorization;
if (request.url?.startsWith('/v2/device-run-session-worker/')) {
deviceRunSessionToken = request.headers[DEVICE_RUN_SESSION_TOKEN_HEADER];
request.setEncoding('utf8');
let body = '';
if (request.method === 'GET') {
const requestUrl = new URL(request.url, 'http://localhost');
body = JSON.stringify({ query: requestUrl.searchParams.get('query') });
}
request.on('data', chunk => (body += chunk));
request.on('end', () => {
const operation = JSON.parse(body);
if (operation.query.includes('query ScreenshotSession')) {
if (request.url === '/v2/device-run-session-worker/application') {
sessionLookups++;
response.setHeader('Content-Type', 'application/json');
if (sessionLookupFails) {
response.statusCode = 503;
response.end(JSON.stringify({ errors: [{ message: 'Lookup unavailable' }] }));
} else {
response.end(
JSON.stringify({
data: {
deviceRunSessions: {
byId: {
platform: 'IOS',
app: { slug: 'session-project' },
build: {
id: 'installed-build',
gitCommitHash: installedCommit,
app: { slug: 'my-app' },
},
},
build: {
id: 'installed-build',
gitCommitHash: installedCommit,
applicationArchiveUrl: null,
},
},
})
@@ -92,13 +83,7 @@ beforeEach(async () => {
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify({
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: { url: `${url}/artifact${uploadQuery}`, headers: {} },
},
},
},
data: { uploadSession: { url: `${url}/artifact${uploadQuery}`, headers: {} } },
})
);
});
@@ -113,12 +98,10 @@ beforeEach(async () => {
}
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;
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'test-device-run-session-token' },
expoApiV2BaseUrl: `${url}/v2/`,
runtimePlatform: BuildRuntimePlatform.DARWIN,
} as unknown as CustomBuildContext;
});
afterEach(async () => {
@@ -127,7 +110,7 @@ afterEach(async () => {
await rm(directory, { recursive: true, force: true });
});
it('publishes a manual preview PNG through the artifact GraphQL and HTTP upload path', async () => {
it('publishes a manual preview PNG through the worker API and HTTP upload path', async () => {
const payload = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aF9sAAAAASUVORK5CYII=',
'base64'
@@ -157,25 +140,20 @@ it('publishes a manual preview PNG through the artifact GraphQL and HTTP upload
expect(bodies).toEqual([payload]);
expect(creates).toBe(1);
expect(sessionLookups).toBe(1);
expect(authorization).toBe('Bearer test-auth');
expect(JSON.parse(creationBodies[0]).variables).toEqual(
expect.objectContaining({
deviceRunSessionId: 'drs-id',
input: expect.objectContaining({
name: 'Screenshot 2026-09-24 08:45:59 UTC',
filename: 'my-app-ios-a1b2c3d-2026-09-24T08-45-59-123Z.png',
kind: 'screenshot',
size: payload.length,
metadata: {
__eas_type: 'screenshot',
appSlug: 'my-app',
platform: 'ios',
buildId: 'installed-build',
gitCommitHash: installedCommit,
},
}),
})
);
expect(deviceRunSessionToken).toBe('test-device-run-session-token');
// The worker API does not return the app slug, so the filename starts with the platform.
expect(JSON.parse(creationBodies[0])).toEqual({
name: 'Screenshot 2026-09-24 08:45:59 UTC',
filename: 'ios-a1b2c3d-2026-09-24T08-45-59-123Z.png',
kind: 'screenshot',
size: payload.length,
metadata: {
__eas_type: 'screenshot',
platform: 'ios',
buildId: 'installed-build',
gitCommitHash: installedCommit,
},
});
await expect(stat(path.join(directory, filename))).rejects.toMatchObject({ code: 'ENOENT' });
});
@@ -206,7 +184,7 @@ it('rereads the PNG when the artifact PUT is retried', async () => {
await expect(stat(path.join(directory, filename))).rejects.toMatchObject({ code: 'ENOENT' });
});
it('aborts a stalled GraphQL upload request and keeps the PNG', async () => {
it('aborts a stalled worker API upload session request and keeps the PNG', async () => {
stallCreation = true;
const filename = 'screenshot-2026-09-24T08-45-59-123Z-a1b2c3d4e5f6.png';
await writeFile(path.join(directory, filename), 'png');
@@ -0,0 +1,115 @@
import { EnvironmentSecretType, Generic } from '@expo/eas-build-job';
import { BuildStepGlobalContext } from '@expo/steps';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { BuildContext } from '../../../context';
import { CustomBuildContext } from '../../../customBuildContext';
import { turtleFetch } from '../../../utils/turtleFetch';
import {
DEVICE_RUN_SESSION_TOKEN_ENV,
DEVICE_RUN_SESSION_TOKEN_HEADER,
getDeviceRunSessionTokenFromEnvironmentSecrets,
getDeviceRunSessionWorkerApi,
} from '../deviceRunSessionWorkerApi';
jest.mock('../../../utils/turtleFetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
const API_V2_BASE_URL = 'https://api.expo.test/v2/';
function createSessionJob(
environmentSecrets: { name: string; type: EnvironmentSecretType; value: string }[]
): Generic.Job {
return {
triggeredBy: 'GIT_BASED_INTEGRATION',
steps: [],
secrets: { robotAccessToken: 'robot-access-token', environmentSecrets },
builderEnvironment: { env: { DEVICE_RUN_SESSION_ID: 'drs-id' } },
} as unknown as Generic.Job;
}
function createCustomBuildContext(job: Generic.Job): CustomBuildContext<Generic.Job> {
const buildCtx = new BuildContext(job, {
env: { __API_SERVER_URL: 'https://api.expo.test' },
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
uploadArtifact: jest.fn(),
workingdir: '',
expoApiV2BaseUrl: API_V2_BASE_URL,
});
return new CustomBuildContext(buildCtx);
}
async function getSentTokenAsync(ctx: CustomBuildContext): Promise<string | undefined> {
jest.mocked(turtleFetch).mockResolvedValueOnce(
new Response(JSON.stringify({ data: { id: 'drs-id', status: 'in-progress' } }), {
status: 200,
})
);
await getDeviceRunSessionWorkerApi(ctx)!.getSessionAsync();
const [url, , options] = jest.mocked(turtleFetch).mock.calls[0];
expect(url).toBe(`${API_V2_BASE_URL}device-run-session-worker/session`);
return (options.headers as Record<string, string>)[DEVICE_RUN_SESSION_TOKEN_HEADER];
}
describe('device run session token propagation', () => {
beforeEach(() => {
jest.mocked(turtleFetch).mockReset();
});
it('makes the environment secret reach the worker API client and the step env', async () => {
const job = createSessionJob([
{ name: 'NGROK_AUTHTOKEN', type: EnvironmentSecretType.STRING, value: 'ngrok' },
{ name: DEVICE_RUN_SESSION_TOKEN_ENV, type: EnvironmentSecretType.STRING, value: 'token-a' },
]);
const ctx = createCustomBuildContext(job);
expect(new BuildStepGlobalContext(ctx, false).env[DEVICE_RUN_SESSION_TOKEN_ENV]).toBe(
'token-a'
);
await expect(getSentTokenAsync(ctx)).resolves.toBe('token-a');
});
it('resolves duplicate secrets to the same token in the build env and the worker', async () => {
const environmentSecrets = [
{ name: DEVICE_RUN_SESSION_TOKEN_ENV, type: EnvironmentSecretType.STRING, value: 'token-a' },
{ name: DEVICE_RUN_SESSION_TOKEN_ENV, type: EnvironmentSecretType.STRING, value: 'token-b' },
];
const ctx = createCustomBuildContext(createSessionJob(environmentSecrets));
const workerToken = getDeviceRunSessionTokenFromEnvironmentSecrets(environmentSecrets);
expect(workerToken).toBe('token-b');
await expect(getSentTokenAsync(ctx)).resolves.toBe(workerToken);
});
it('returns no client when the job has no session token', () => {
const ctx = createCustomBuildContext(
createSessionJob([
{ name: 'NGROK_AUTHTOKEN', type: EnvironmentSecretType.STRING, value: 'ngrok' },
])
);
expect(getDeviceRunSessionWorkerApi(ctx)).toBeNull();
});
});
describe(getDeviceRunSessionTokenFromEnvironmentSecrets, () => {
it('returns null without secrets or without the token', () => {
expect(getDeviceRunSessionTokenFromEnvironmentSecrets(undefined)).toBeNull();
expect(
getDeviceRunSessionTokenFromEnvironmentSecrets([
{ name: 'OTHER', type: EnvironmentSecretType.STRING, value: 'x' },
])
).toBeNull();
});
it('returns null when the last secret with the name is a file secret', () => {
expect(
getDeviceRunSessionTokenFromEnvironmentSecrets([
{ name: DEVICE_RUN_SESSION_TOKEN_ENV, type: EnvironmentSecretType.STRING, value: 'a' },
{ name: DEVICE_RUN_SESSION_TOKEN_ENV, type: EnvironmentSecretType.FILE, value: 'Yg==' },
])
).toBeNull();
});
});
@@ -1,6 +1,5 @@
import type { bunyan } from '@expo/logger';
import type { BuildStepEnv } from '@expo/steps';
import { Client, fetchExchange } from '@urql/core';
import { createReadStream } from 'node:fs';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
@@ -13,6 +12,7 @@ import { setTimeout as delay } from 'node:timers/promises';
import type { CustomBuildContext } from '../../../customBuildContext';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import { findUnlistedDeviceScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
jest.unmock('node-fetch');
jest.unmock('node:fs');
@@ -40,20 +40,20 @@ let ctx: CustomBuildContext;
let url: string;
let directory: string;
let creates: number;
let authorization: string | undefined;
let deviceRunSessionToken: string | string[] | undefined;
let stallCreation: boolean;
let creationStarted: ReturnType<typeof deferred<IncomingMessage>>;
beforeEach(async () => {
creates = 0;
authorization = undefined;
deviceRunSessionToken = undefined;
stallCreation = false;
creationStarted = deferred();
directory = await mkdtemp(path.join(tmpdir(), 'recording-upload-http-'));
server = createServer((request, response) => {
if (request.url === '/graphql') {
if (request.url === '/v2/device-run-session-worker/artifact-upload-sessions') {
creates++;
authorization = request.headers.authorization;
deviceRunSessionToken = request.headers[DEVICE_RUN_SESSION_TOKEN_HEADER];
creationStarted.resolve(request);
request.resume();
request.on('end', () => {
@@ -63,13 +63,7 @@ beforeEach(async () => {
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify({
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: { url: `${url}/artifact`, headers: {} },
},
},
},
data: { uploadSession: { url: `${url}/artifact`, headers: {} } },
})
);
});
@@ -84,12 +78,9 @@ beforeEach(async () => {
}
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;
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'test-device-run-session-token' },
expoApiV2BaseUrl: `${url}/v2/`,
} as unknown as CustomBuildContext;
});
afterEach(async () => {
@@ -122,11 +113,11 @@ 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');
expect(deviceRunSessionToken).toBe('test-device-run-session-token');
expect(bodies).toEqual([payload, payload]);
expect(streams).toHaveLength(2);
expect(streams.every(stream => stream.destroyed)).toBe(true);
@@ -156,7 +147,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);
@@ -196,8 +191,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;
@@ -222,11 +217,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');
@@ -235,7 +230,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);
});
@@ -259,8 +254,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();
@@ -277,15 +272,15 @@ it.each([200, 400, 503])(
}
);
it('cancels urql session creation with auth intact and never starts a PUT', async () => {
it('cancels worker API session creation with the token intact and never starts a PUT', async () => {
stallCreation = true;
const puts = jest.fn();
handle = puts;
const controller = new AbortController();
const stream = Readable.from('recording');
const openStream = jest.fn(() => Readable.from('recording'));
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
...metadata,
stream,
openStream,
signal: controller.signal,
});
const rejected = expect(upload).rejects.toThrow('test cancellation');
@@ -294,10 +289,10 @@ it('cancels urql session creation with auth intact and never starts a PUT', asyn
controller.abort(new Error('test cancellation'));
await rejected;
await closed;
expect(authorization).toBe('Bearer test-auth');
expect(deviceRunSessionToken).toBe('test-device-run-session-token');
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 () => {
@@ -0,0 +1,363 @@
import { SystemError } from '@expo/eas-build-job';
import { CustomBuildContext } from '../../../customBuildContext';
import { turtleFetch } from '../../../utils/turtleFetch';
import {
DEVICE_RUN_SESSION_TOKEN_HEADER,
DeviceRunSessionWorkerApi,
DeviceRunSessionWorkerApiError,
getDeviceRunSessionWorkerApi,
requireDeviceRunSessionWorkerApi,
} from '../deviceRunSessionWorkerApi';
jest.mock('../../../utils/turtleFetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
type Response = InstanceType<typeof Response>;
const BASE_URL = 'https://api.expo.test/v2/';
const TOKEN = 'device-run-session-token-value';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function createApi(): DeviceRunSessionWorkerApi {
return new DeviceRunSessionWorkerApi(BASE_URL, TOKEN);
}
describe(getDeviceRunSessionWorkerApi, () => {
it('returns null when the job has no device run session token', () => {
expect(
getDeviceRunSessionWorkerApi({
env: { __API_SERVER_URL: 'https://api.expo.test' },
expoApiV2BaseUrl: BASE_URL,
} as unknown as CustomBuildContext)
).toBeNull();
expect(
getDeviceRunSessionWorkerApi({
env: { EAS_DEVICE_RUN_SESSION_TOKEN: '' },
expoApiV2BaseUrl: BASE_URL,
} as unknown as CustomBuildContext)
).toBeNull();
});
it('uses the API v2 base URL of the context', async () => {
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
const api = getDeviceRunSessionWorkerApi({
env: { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN, __API_SERVER_URL: 'https://other.expo.test' },
expoApiV2BaseUrl: BASE_URL,
} as unknown as CustomBuildContext);
await api!.getSessionAsync();
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
'https://api.expo.test/v2/device-run-session-worker/session',
'GET',
expect.anything()
);
});
it('derives the API v2 base URL from __API_SERVER_URL when the context has none', async () => {
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
const api = getDeviceRunSessionWorkerApi({
env: { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN, __API_SERVER_URL: 'https://api.expo.test' },
} as unknown as CustomBuildContext);
await api!.getSessionAsync();
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
'https://api.expo.test/v2/device-run-session-worker/session',
'GET',
expect.anything()
);
});
it('throws a system error when the token is set but no API URL is known', () => {
expect(() =>
getDeviceRunSessionWorkerApi({
env: { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN },
} as unknown as CustomBuildContext)
).toThrow(SystemError);
});
});
describe(requireDeviceRunSessionWorkerApi, () => {
it('returns the client when the job has a device run session token', () => {
expect(
requireDeviceRunSessionWorkerApi({
env: { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN },
expoApiV2BaseUrl: BASE_URL,
} as unknown as CustomBuildContext)
).toBeInstanceOf(DeviceRunSessionWorkerApi);
});
it('throws a system error when the job has no device run session token', () => {
const error = (() => {
try {
requireDeviceRunSessionWorkerApi({
env: { __API_SERVER_URL: 'https://api.expo.test' },
expoApiV2BaseUrl: BASE_URL,
} as unknown as CustomBuildContext);
} catch (err) {
return err;
}
return undefined;
})();
expect(error).toBeInstanceOf(SystemError);
expect((error as Error).message).toMatch(
/^This device run session job has no EAS_DEVICE_RUN_SESSION_TOKEN secret\./
);
});
});
describe(DeviceRunSessionWorkerApi, () => {
beforeEach(() => {
jest.mocked(turtleFetch).mockReset();
});
it.each([
{
name: 'getSessionAsync',
call: (api: DeviceRunSessionWorkerApi) => api.getSessionAsync(),
method: 'GET',
route: 'session',
json: undefined,
data: { id: 'drs-id', status: 'in-progress' },
expected: { id: 'drs-id', status: 'in-progress' },
},
{
name: 'startAsync',
call: (api: DeviceRunSessionWorkerApi) =>
api.startAsync({ remoteConfig: { type: 'agent-device', url: 'https://x.test' } }),
method: 'POST',
route: 'start',
json: { remoteConfig: { type: 'agent-device', url: 'https://x.test' } },
data: { id: 'drs-id', status: 'in-progress' },
expected: { id: 'drs-id', status: 'in-progress' },
},
{
name: 'stopAsync',
call: (api: DeviceRunSessionWorkerApi) => api.stopAsync(),
method: 'POST',
route: 'stop',
json: undefined,
data: { id: 'drs-id', status: 'stopped' },
expected: { id: 'drs-id', status: 'stopped' },
},
{
name: 'createArtifactUploadSessionAsync',
call: (api: DeviceRunSessionWorkerApi) =>
api.createArtifactUploadSessionAsync({
name: 'Screenshot',
filename: 'screenshot.png',
kind: 'screenshot',
size: 12,
metadata: { __eas_type: 'screenshot' },
}),
method: 'POST',
route: 'artifact-upload-sessions',
json: {
name: 'Screenshot',
filename: 'screenshot.png',
kind: 'screenshot',
size: 12,
metadata: { __eas_type: 'screenshot' },
},
data: { uploadSession: { url: 'https://uploads.test/a', headers: { 'X-A': '1' } } },
expected: { url: 'https://uploads.test/a', headers: { 'X-A': '1' } },
},
{
name: 'createEventLogUploadSessionAsync',
call: (api: DeviceRunSessionWorkerApi) => api.createEventLogUploadSessionAsync(),
method: 'POST',
route: 'event-log-upload-session',
json: undefined,
data: { uploadSession: { url: 'https://uploads.test/events', headers: {} } },
expected: { url: 'https://uploads.test/events', headers: {} },
},
{
name: 'createPreviewUploadSessionAsync',
call: (api: DeviceRunSessionWorkerApi) => api.createPreviewUploadSessionAsync(),
method: 'POST',
route: 'preview-upload-session',
json: undefined,
data: { uploadSession: { url: 'https://uploads.test/preview', headers: {} } },
expected: { url: 'https://uploads.test/preview', headers: {} },
},
{
name: 'fetchTurnIceServersAsync',
call: (api: DeviceRunSessionWorkerApi) => api.fetchTurnIceServersAsync(),
method: 'POST',
route: 'turn-ice-servers',
json: undefined,
data: {
iceServers: [
{ urls: ['stun:stun.test:3478'] },
{ urls: ['turn:turn.test:3478'], username: 'user', credential: 'secret' },
],
},
expected: [
{ urls: ['stun:stun.test:3478'] },
{ urls: ['turn:turn.test:3478'], username: 'user', credential: 'secret' },
],
},
{
name: 'getApplicationAsync',
call: (api: DeviceRunSessionWorkerApi) => api.getApplicationAsync(),
method: 'GET',
route: 'application',
json: undefined,
data: {
build: {
id: 'build-id',
gitCommitHash: null,
applicationArchiveUrl: 'https://expo.test/artifacts/eas/a.tar.gz',
},
},
expected: {
build: {
id: 'build-id',
gitCommitHash: null,
applicationArchiveUrl: 'https://expo.test/artifacts/eas/a.tar.gz',
},
},
},
])(
'$name calls $method /v2/device-run-session-worker/$route with the session token header',
async ({ call, method, route, json, data, expected }) => {
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data }));
await expect(call(createApi())).resolves.toEqual(expected);
expect(jest.mocked(turtleFetch)).toHaveBeenCalledTimes(1);
const [url, calledMethod, options] = jest.mocked(turtleFetch).mock.calls[0];
expect(url).toBe(`https://api.expo.test/v2/device-run-session-worker/${route}`);
expect(calledMethod).toBe(method);
expect(options.headers).toEqual({ [DEVICE_RUN_SESSION_TOKEN_HEADER]: TOKEN });
expect(options.json).toEqual(json);
expect(options).toEqual(expect.objectContaining({ retries: 0, shouldThrowOnNotOk: false }));
}
);
it.each(['new', 'queued', 'starting', 'in-progress', 'stopped', 'errored'])(
'returns the www status %s unchanged',
async status => {
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status } }));
await expect(createApi().getSessionAsync()).resolves.toEqual({ id: 'drs-id', status });
}
);
it('rejects an application response without the build field', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { platform: 'ios' } }));
await expect(createApi().getApplicationAsync()).rejects.toThrow(
'GET /v2/device-run-session-worker/application returned an unexpected response'
);
});
it('passes the signal, timeout, and retries to the request', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { iceServers: [] } }));
const controller = new AbortController();
await createApi().fetchTurnIceServersAsync({
signal: controller.signal,
timeoutMs: 5000,
retries: 1,
});
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
expect.any(String),
'POST',
expect.objectContaining({ signal: controller.signal, timeout: 5000, retries: 1 })
);
});
it('reports the API v2 error message and status without the token', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(
jsonResponse(
{
errors: [
{
code: 'UNAUTHORIZED_ERROR',
message: 'This device run session no longer accepts worker requests.',
},
],
},
403
)
);
const error = await createApi()
.stopAsync()
.catch(err => err);
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
expect(error.status).toBe(403);
expect(error.message).toBe(
'POST /v2/device-run-session-worker/stop failed with status 403: This device run session no longer accepts worker requests.'
);
expect(error.message).not.toContain(TOKEN);
});
it('reports the status text when the error response is not an API v2 error', async () => {
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(
new Response('<html>bad gateway</html>', { status: 502, statusText: 'Bad Gateway' })
);
const error = await createApi()
.getSessionAsync()
.catch(err => err);
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
expect(error.status).toBe(502);
expect(error.message).toBe(
'GET /v2/device-run-session-worker/session failed with status 502: Bad Gateway'
);
});
it('reports a request that failed before www responded', async () => {
const reason = new Error('socket hang up');
jest.mocked(turtleFetch).mockRejectedValueOnce(reason);
const error = await createApi()
.getSessionAsync()
.catch(err => err);
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
expect(error.status).toBeUndefined();
expect(error.cause).toBe(reason);
expect(error.message).toContain('failed before www responded: socket hang up');
});
it('rejects a response that does not match the route contract', async () => {
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'STOPPED' } }));
await expect(createApi().getSessionAsync()).rejects.toThrow(
'GET /v2/device-run-session-worker/session returned an unexpected response'
);
});
it('rejects a successful response that is not JSON', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('ok', { status: 200 }));
await expect(createApi().getSessionAsync()).rejects.toThrow(
'GET /v2/device-run-session-worker/session returned a response that is not JSON.'
);
});
});
@@ -0,0 +1,599 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { BuildRuntimePlatform } from '@expo/steps';
import fetch from 'node-fetch';
import { Readable } from 'node:stream';
import { setImmediate } from 'node:timers/promises';
import { type CustomBuildContext } from '../../../customBuildContext';
import RemoteLoggerStream from '../../../logging/RemoteLoggerStream';
import { turtleFetch } from '../../../utils/turtleFetch';
import { downloadBuildAsync } from '../../functions/downloadBuild';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import { startDeviceRunSessionEventCollectionAsync } from '../deviceRunSessionEvents';
import { startDeviceRunSessionPreview } from '../deviceRunSessionPreview';
import { loadScreenshotSessionAsync } from '../deviceRunSessionScreenshotNames';
import {
DEVICE_RUN_SESSION_TOKEN_HEADER,
DeviceRunSessionWorkerApi,
} from '../deviceRunSessionWorkerApi';
import {
fetchWebPreviewTurnArgsAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../remoteDeviceRunSession';
jest.mock('node-fetch');
jest.mock('@ngrok/ngrok');
jest.mock('@expo/turtle-spawn');
jest.mock('../../../utils/turtleFetch');
jest.mock('../../../sentry');
jest.mock('../../../logging/HttpLogStream', () => ({
__esModule: true,
default: jest.fn(() => ({ write: jest.fn(), cleanUp: jest.fn(async () => undefined) })),
}));
jest.mock('../../../logging/RemoteLoggerStream', () => ({
__esModule: true,
default: jest.fn(() => ({
init: jest.fn(async () => undefined),
write: jest.fn(),
cleanUp: jest.fn(async () => undefined),
})),
}));
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
const TOKEN = 'device-run-session-token-value';
const ROBOT_ACCESS_TOKEN = 'robot-access-token-value';
const API_V2_BASE_URL = 'https://api.expo.test/v2/';
const BUILD_ID = '0a8b5f3c-7c38-4a2f-9c0c-2a1f0e7d8b11';
const APPLICATION_ARCHIVE_URL = 'https://expo.test/artifacts/eas/app.tar.gz';
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
child: jest.fn(),
} as unknown as bunyan;
function jsonResponse(body: unknown, status = 200): InstanceType<typeof Response> {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function createCtx({ withToken }: { withToken: boolean }): CustomBuildContext {
return {
env: {
__API_SERVER_URL: 'https://api.expo.test',
EAS_BUILD_ID: 'job-run-id',
...(withToken ? { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN } : {}),
},
expoApiV2BaseUrl: API_V2_BASE_URL,
job: { secrets: { robotAccessToken: ROBOT_ACCESS_TOKEN } },
graphqlClient: {
query: jest.fn(),
mutation: jest.fn(),
},
} as unknown as CustomBuildContext;
}
function graphqlResult(data: unknown): { toPromise: () => Promise<{ data: unknown }> } {
return { toPromise: async () => ({ data }) };
}
function expectWorkerApiCall(
route: string,
method: 'GET' | 'POST',
options: Record<string, unknown> = {}
): void {
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
`${API_V2_BASE_URL}device-run-session-worker/${route}`,
method,
expect.objectContaining({
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: TOKEN },
...options,
})
);
}
async function waitForCallAsync(mock: jest.Mock): Promise<void> {
for (let i = 0; i < 100 && mock.mock.calls.length === 0; i++) {
await setImmediate();
}
}
const eventSource = {
producer: 'test-producer',
findEventFilesAsync: async () => [],
sourceKeyForFile: () => 'source',
parseLine: () => ({}),
};
beforeEach(() => {
jest.mocked(turtleFetch).mockReset();
jest.mocked(fetch).mockReset();
});
describe('with a device run session token', () => {
it('polls the session status through the worker API', async () => {
const ctx = createCtx({ withToken: true });
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'stopped' } }));
await waitForDeviceRunSessionStoppedAsync({ ctx, deviceRunSessionId: 'drs-id', logger });
expectWorkerApiCall('session', 'GET', {
retries: 0,
timeout: 30_000,
signal: expect.any(AbortSignal),
});
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
});
it('stops an idle session through the worker API', async () => {
const ctx = createCtx({ withToken: true });
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'stopped' } }));
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
logger,
idleTimeout: { maxIdleTimeMinutes: 0, getLastEventObservedAt: () => undefined },
});
expectWorkerApiCall('stop', 'POST', { timeout: 30_000 });
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('starts the session through the worker API', async () => {
const ctx = createCtx({ withToken: true });
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId: 'drs-id',
remoteConfig: { type: 'agent-device' },
logger,
});
expectWorkerApiCall('start', 'POST', { json: { remoteConfig: { type: 'agent-device' } } });
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('fails the start with a system error that does not contain the token', async () => {
const ctx = createCtx({ withToken: true });
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'The session finished.' }] }, 403));
const error = await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId: 'drs-id',
remoteConfig: {},
logger,
}).catch(err => err);
expect(error).toBeInstanceOf(SystemError);
expect(error.message).toBe(
'Failed to start device run session drs-id: POST /v2/device-run-session-worker/start failed with status 403: The session finished.'
);
expect(error.message).not.toContain(TOKEN);
});
it('fetches TURN ICE servers through the worker API', async () => {
const ctx = createCtx({ withToken: true });
jest.mocked(turtleFetch).mockResolvedValueOnce(
jsonResponse({
data: {
iceServers: [
{ urls: ['stun:stun.test:3478'] },
{ urls: ['turn:turn.test:3478'], username: 'user', credential: 'credential' },
],
},
})
);
const args = await fetchWebPreviewTurnArgsAsync(ctx, { logger });
expect(args).toEqual([
'--stun-url',
'stun:stun.test:3478',
'--turn-url',
'turn:turn.test:3478',
'--turn-username',
'user',
'--turn-credential',
'credential',
]);
expectWorkerApiCall('turn-ice-servers', 'POST', { timeout: 5000, retries: 1 });
expect(jest.mocked(turtleFetch)).toHaveBeenCalledTimes(1);
});
it('creates artifact upload sessions through the worker API', async () => {
const ctx = createCtx({ withToken: true });
jest.mocked(turtleFetch).mockResolvedValueOnce(
jsonResponse({
data: { uploadSession: { url: 'https://uploads.expo.test/artifact', headers: {} } },
})
);
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId: 'drs-id',
artifactId: 'artifact-id',
name: 'Report',
filename: 'report.json',
kind: 'test-report',
metadata: { a: 'b' },
size: 4,
openStream: () => Readable.from(Buffer.from('data')),
});
expectWorkerApiCall('artifact-upload-sessions', 'POST', {
json: {
name: 'Report',
filename: 'report.json',
kind: 'test-report',
metadata: { a: 'b' },
size: 4,
},
signal: expect.any(AbortSignal),
});
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
'https://uploads.expo.test/artifact',
expect.objectContaining({ method: 'PUT' })
);
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('creates the event log upload session through the worker API', async () => {
const ctx = createCtx({ withToken: true });
jest.mocked(turtleFetch).mockResolvedValueOnce(
jsonResponse({
data: {
uploadSession: { url: 'https://uploads.expo.test/events', headers: { 'X-A': '1' } },
},
})
);
const collection = await startDeviceRunSessionEventCollectionAsync({
ctx,
deviceRunSessionId: 'drs-id',
source: eventSource,
logger,
});
await collection.stopAsync();
expectWorkerApiCall('event-log-upload-session', 'POST');
expect(jest.mocked(RemoteLoggerStream)).toHaveBeenCalledWith(
expect.objectContaining({
uploadMethod: {
signedUrl: { url: 'https://uploads.expo.test/events', headers: { 'X-A': '1' } },
},
})
);
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('creates the preview upload session through the worker API', async () => {
const ctx = createCtx({ withToken: true });
jest.mocked(turtleFetch).mockResolvedValueOnce(
jsonResponse({
data: { uploadSession: { url: 'https://uploads.expo.test/preview', headers: {} } },
})
);
jest.mocked(fetch).mockResolvedValue(new Response('', { status: 200 }));
const preview = startDeviceRunSessionPreview({
ctx,
deviceRunSessionId: 'drs-id',
captureAsync: async () => Buffer.from('webp'),
logger,
});
await waitForCallAsync(jest.mocked(fetch));
await preview.stopAsync();
expectWorkerApiCall('preview-upload-session', 'POST', { signal: expect.any(AbortSignal) });
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
'https://uploads.expo.test/preview',
expect.objectContaining({ method: 'PUT' })
);
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it.each([
[BuildRuntimePlatform.DARWIN, 'IOS'],
[BuildRuntimePlatform.LINUX, 'ANDROID'],
] as const)(
'loads the screenshot naming details through the worker API on a %s worker',
async (runtimePlatform, platform) => {
const ctx = { ...createCtx({ withToken: true }), runtimePlatform } as CustomBuildContext;
jest.mocked(turtleFetch).mockResolvedValueOnce(
jsonResponse({
data: {
build: {
id: BUILD_ID,
gitCommitHash: 'abc',
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
},
},
})
);
await expect(loadScreenshotSessionAsync(ctx, 'drs-id', logger)).resolves.toEqual({
platform,
app: null,
build: { id: BUILD_ID, gitCommitHash: 'abc', app: null },
});
expectWorkerApiCall('application', 'GET', { signal: expect.any(AbortSignal), retries: 0 });
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
}
);
it('downloads the session build from the worker API archive URL without an Authorization header', async () => {
const ctx = createCtx({ withToken: true });
jest.mocked(turtleFetch).mockResolvedValueOnce(
jsonResponse({
data: {
build: {
id: BUILD_ID,
gitCommitHash: null,
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
},
},
})
);
jest.mocked(fetch).mockResolvedValueOnce(new Response('stop here', { status: 500 }));
await expect(
downloadBuildAsync({
logger,
buildId: BUILD_ID,
graphqlClient: ctx.graphqlClient,
deviceRunSessionWorkerApi: new DeviceRunSessionWorkerApi(API_V2_BASE_URL, TOKEN),
robotAccessToken: ROBOT_ACCESS_TOKEN,
extensions: ['app'],
})
).rejects.toThrow('Unexpected response from server (500): stop here');
expectWorkerApiCall('application', 'GET');
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ headers: undefined })
);
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
});
it('refuses to download a build that is not the session build', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { build: null } }));
const error = await downloadBuildAsync({
logger,
buildId: BUILD_ID,
graphqlClient: createCtx({ withToken: true }).graphqlClient,
deviceRunSessionWorkerApi: new DeviceRunSessionWorkerApi(API_V2_BASE_URL, TOKEN),
robotAccessToken: ROBOT_ACCESS_TOKEN,
extensions: ['app'],
}).catch(err => err);
expect(error).toBeInstanceOf(SystemError);
expect(error.message).toContain(
`Could not fetch build ${BUILD_ID}: this device run session runs no build`
);
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('reports a rejected build lookup as a system error, because the job sends no user input', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(
jsonResponse(
{
errors: [
{
code: 'UNAUTHORIZED_ERROR',
message: 'This device run session no longer accepts worker requests.',
},
],
},
403
)
);
const error = await downloadBuildAsync({
logger,
buildId: BUILD_ID,
graphqlClient: createCtx({ withToken: true }).graphqlClient,
deviceRunSessionWorkerApi: new DeviceRunSessionWorkerApi(API_V2_BASE_URL, TOKEN),
robotAccessToken: ROBOT_ACCESS_TOKEN,
extensions: ['app'],
}).catch(err => err);
expect(error).toBeInstanceOf(SystemError);
expect(error.message).toContain(
`Could not fetch build ${BUILD_ID} for this device run session: GET /v2/device-run-session-worker/application failed with status 403: This device run session no longer accepts worker requests.`
);
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
});
describe('without a device run session token', () => {
const MISSING_TOKEN_MESSAGE =
/^This device run session job has no EAS_DEVICE_RUN_SESSION_TOKEN secret\./;
function expectMissingTokenError(error: unknown): void {
expect(error).toBeInstanceOf(SystemError);
expect((error as Error).message).toMatch(MISSING_TOKEN_MESSAGE);
}
function expectMissingTokenWarning(message: string): void {
const warning = (jest.mocked(logger.warn).mock.calls as unknown[][]).find(
([, warningMessage]) => warningMessage === message
);
expect(warning).toBeDefined();
expectMissingTokenError((warning![0] as { err: unknown }).err);
}
it('fails the status poll with a system error', async () => {
const ctx = createCtx({ withToken: false });
const error = await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
logger,
}).catch(err => err);
expectMissingTokenError(error);
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
});
it('logs the failed idle stop and ends the wait', async () => {
const ctx = createCtx({ withToken: false });
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
logger,
idleTimeout: { maxIdleTimeMinutes: 0, getLastEventObservedAt: () => undefined },
});
expectMissingTokenWarning(
'Could not mark device run session drs-id as stopped. The session job ends anyway.'
);
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('fails the start with a system error', async () => {
const ctx = createCtx({ withToken: false });
const error = await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId: 'drs-id',
remoteConfig: { type: 'agent-device' },
logger,
}).catch(err => err);
expectMissingTokenError(error);
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('falls back to no TURN ICE servers', async () => {
const ctx = createCtx({ withToken: false });
await expect(fetchWebPreviewTurnArgsAsync(ctx, { logger })).resolves.toEqual([]);
expectMissingTokenWarning(
'Could not fetch Cloudflare TURN ICE servers; the web preview will fall back to P2P/STUN.'
);
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
});
it('fails the artifact upload with a system error before any PUT', async () => {
const ctx = createCtx({ withToken: false });
const error = await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId: 'drs-id',
artifactId: 'artifact-id',
name: 'Report',
filename: 'report.json',
kind: undefined,
size: 4,
openStream: () => Readable.from(Buffer.from('data')),
}).catch(err => err);
expectMissingTokenError(error);
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('runs the session without persisting events', async () => {
const ctx = createCtx({ withToken: false });
const collection = await startDeviceRunSessionEventCollectionAsync({
ctx,
deviceRunSessionId: 'drs-id',
source: eventSource,
logger,
});
await collection.stopAsync();
expectMissingTokenWarning('Could not persist device run session events to the artifact.');
expect(jest.mocked(RemoteLoggerStream)).not.toHaveBeenCalled();
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('logs the failed preview upload session and uploads no preview', async () => {
const ctx = createCtx({ withToken: false });
const preview = startDeviceRunSessionPreview({
ctx,
deviceRunSessionId: 'drs-id',
captureAsync: async () => Buffer.from('webp'),
logger,
});
await waitForCallAsync(jest.mocked(logger.warn));
await preview.stopAsync();
expectMissingTokenWarning('Could not refresh the session preview; retrying in 60 seconds.');
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('names screenshots with capture timestamps', async () => {
const ctx = createCtx({ withToken: false });
await expect(loadScreenshotSessionAsync(ctx, 'drs-id', logger)).resolves.toBeNull();
expectMissingTokenWarning(
'Could not load screenshot session details; using capture timestamps.'
);
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
});
it('looks up the build archive URL with GraphQL', async () => {
const ctx = createCtx({ withToken: false });
jest.mocked(ctx.graphqlClient.query).mockReturnValueOnce(
graphqlResult({
builds: {
byId: {
id: BUILD_ID,
platform: 'IOS',
artifacts: { applicationArchiveUrl: APPLICATION_ARCHIVE_URL },
},
},
}) as never
);
jest.mocked(fetch).mockResolvedValueOnce(new Response('stop here', { status: 500 }));
await expect(
downloadBuildAsync({
logger,
buildId: BUILD_ID,
graphqlClient: ctx.graphqlClient,
deviceRunSessionWorkerApi: null,
robotAccessToken: ROBOT_ACCESS_TOKEN,
extensions: ['app'],
})
).rejects.toThrow('Unexpected response from server (500)');
expect(ctx.graphqlClient.query).toHaveBeenCalled();
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ headers: { Authorization: `Bearer ${ROBOT_ACCESS_TOKEN}` } })
);
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
});
});
File diff suppressed because it is too large. Load diff
@@ -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

@@ -0,0 +1,63 @@
import spawn from '@expo/turtle-spawn';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { readIosApplicationIdentifierAsync } from '../iosAppArtifact';
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
describe(readIosApplicationIdentifierAsync, () => {
let directory: string;
let artifactPath: string;
beforeEach(async () => {
jest.mocked(spawn).mockReset();
directory = await mkdtemp(path.join(os.tmpdir(), 'ios-app-artifact-'));
artifactPath = path.join(directory, 'Example.app');
await mkdir(artifactPath);
});
afterEach(async () => {
await rm(directory, { recursive: true, force: true });
});
it('reads app metadata without installing or launching', async () => {
jest.mocked(spawn).mockResolvedValue({ stdout: 'com.example.app\n', stderr: '' } as never);
await expect(readIosApplicationIdentifierAsync({ artifactPath, env: {} })).resolves.toBe(
'com.example.app'
);
expect(jest.mocked(spawn).mock.calls).toEqual([
[
'plutil',
['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', path.join(artifactPath, 'Info.plist')],
{ stdio: 'pipe', env: {} },
],
]);
});
it.each(['missing', 'file', 'wrong extension'])('rejects an invalid artifact: %s', async kind => {
await rm(artifactPath, { recursive: true });
if (kind === 'file') {
await writeFile(artifactPath, 'not an app directory');
} else if (kind === 'wrong extension') {
artifactPath = path.join(directory, 'Example.ipa');
await mkdir(artifactPath);
}
await expect(
readIosApplicationIdentifierAsync({ artifactPath, env: {} })
).rejects.toMatchObject({ errorCode: 'EAS_INSTALL_BUILD_INVALID_ARTIFACT' });
expect(spawn).not.toHaveBeenCalled();
});
it('rejects a missing bundle identifier', async () => {
jest.mocked(spawn).mockResolvedValue({ stdout: ' ', stderr: '' } as never);
await expect(
readIosApplicationIdentifierAsync({ artifactPath, env: {} })
).rejects.toMatchObject({ errorCode: 'EAS_INSTALL_BUILD_MISSING_IDENTIFIER' });
});
});
@@ -0,0 +1,191 @@
import { BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../../utils/IosSimulatorUtils';
import { resolveIosSessionStartupAsync } from '../iosSimulatorSession';
import { readIosApplicationIdentifierAsync } from '../iosAppArtifact';
import {
resolveLocalEgressServeSimBootEnvironmentAsync,
startLocalEgressGuardRelayAsync,
} from '../localEgressGuard';
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('../../../utils/IosSimulatorUtils');
jest.mock('../iosAppArtifact', () => ({ readIosApplicationIdentifierAsync: jest.fn() }));
jest.mock('../localEgressGuard', () => ({
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn(),
startLocalEgressGuardRelayAsync: jest.fn(),
}));
const mockedSpawn = jest.mocked(spawn);
const mockedUtils = jest.mocked(IosSimulatorUtils);
describe('serve-sim Simulator preparation', () => {
const hostOptions = {
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: {},
logger: createMockLogger(),
};
beforeEach(() => {
jest.clearAllMocks();
mockedUtils.getAvailableDevicesAsync.mockResolvedValue([
{
udid: 'selected-udid',
name: 'iPhone 17',
isAvailable: true,
deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-17',
},
] as never);
mockedUtils.resolveUdidAsync.mockResolvedValue('selected-udid' as IosSimulatorUuid);
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(null);
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as never);
jest.mocked(readIosApplicationIdentifierAsync).mockResolvedValue('dev.example.app');
});
it.each([
['argument count', Array(257).fill('a')],
['argument length', ['a'.repeat(9000)]],
['NUL argument', ['a\u0000b']],
['total UTF-8 bytes', Array(8).fill('😃'.repeat(4096))],
])('rejects invalid launch %s before Simulator preparation', async (_, launchArgs) => {
await expect(
resolveIosSessionStartupAsync({
...hostOptions,
bootSimulator: true,
launchAppIdentifier: 'dev.example.app',
launchArgs,
})
).rejects.toThrow('iOS launch_args');
expect(mockedUtils.getAvailableDevicesAsync).not.toHaveBeenCalled();
expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled();
expect(resolveLocalEgressServeSimBootEnvironmentAsync).not.toHaveBeenCalled();
});
it.each([
['an oversized URL', `https://example.test/${'a'.repeat(8192)}`],
['NUL in a URL', 'exp://example.test/a\0b'],
])('rejects %s before Simulator preparation', async (_, openUrl) => {
await expect(
resolveIosSessionStartupAsync({
...hostOptions,
bootSimulator: true,
launchAppIdentifier: 'dev.example.app',
openUrl,
})
).rejects.toThrow('iOS open_url');
expect(mockedUtils.getAvailableDevicesAsync).not.toHaveBeenCalled();
expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled();
expect(resolveLocalEgressServeSimBootEnvironmentAsync).not.toHaveBeenCalled();
});
it('keeps an explicit launch identifier independent of the installed app', async () => {
const startup = await resolveIosSessionStartupAsync({
...hostOptions,
installAppPath: '/tmp/App.app',
launchAppIdentifier: 'another.installed.app',
});
expect(startup.launch.launchAppIdentifier).toBe('another.installed.app');
expect(readIosApplicationIdentifierAsync).not.toHaveBeenCalled();
});
it('infers the launch identifier from the downloaded app', async () => {
const startup = await resolveIosSessionStartupAsync({
...hostOptions,
installAppPath: '/tmp/App.app',
});
expect(startup.launch.launchAppIdentifier).toBe('dev.example.app');
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
artifactPath: '/tmp/App.app',
env: {},
});
});
it('does no Simulator work on Android and rejects iOS installation inputs', async () => {
const options = { ...hostOptions, runtimePlatform: BuildRuntimePlatform.LINUX };
await expect(resolveIosSessionStartupAsync(options)).resolves.toEqual({
launch: { launchArgs: [], launchAppIdentifier: undefined, openUrl: undefined },
});
await expect(
resolveIosSessionStartupAsync({ ...options, installAppPath: '/tmp/App.app' })
).rejects.toThrow('only supported for iOS');
expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled();
});
it('preserves older workflows that already prepared the Simulator and app', async () => {
const startup = await resolveIosSessionStartupAsync({
...hostOptions,
launchAppIdentifier: 'previously.installed.app',
});
expect(startup.iosStartup).toBeUndefined();
expect(startup.launch.launchAppIdentifier).toBe('previously.installed.app');
expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled();
expect(resolveLocalEgressServeSimBootEnvironmentAsync).not.toHaveBeenCalled();
expect(spawn).not.toHaveBeenCalled();
});
it('prepares a session without an app when the workflow requests owned startup', async () => {
const startup = await resolveIosSessionStartupAsync({ ...hostOptions, bootSimulator: true });
expect(startup.iosStartup?.iosSimulatorUdid).toBe('SELECTED-UDID');
expect(startup.launch.launchAppIdentifier).toBeUndefined();
});
it.each(['Booted', 'Booting', 'Shutdown'])(
'prepares guarded startup on a %s device',
async state => {
const bootEnv = { SERVE_SIM_ADDITIONAL_DYLIBS: '/guard.dylib' };
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(bootEnv);
mockedUtils.getDeviceAsync.mockResolvedValue({ state } as never);
const startup = await resolveIosSessionStartupAsync({ ...hostOptions, bootSimulator: true });
expect(startup.iosStartup).toEqual({
iosSimulatorUdid: 'SELECTED-UDID',
bootEnv,
installAppPath: undefined,
});
if (state === 'Shutdown') {
expect(spawn).not.toHaveBeenCalled();
} else {
expect(spawn).toHaveBeenCalledWith(
'xcrun',
['simctl', 'shutdown', 'SELECTED-UDID'],
expect.anything()
);
}
expect(startLocalEgressGuardRelayAsync).toHaveBeenCalled();
expect(mockedUtils.bootAsync).not.toHaveBeenCalled();
}
);
it('resolves an explicit Simulator without booting or configuring it', async () => {
const startup = await resolveIosSessionStartupAsync({
...hostOptions,
deviceIdentifier: 'iPhone 17',
});
expect(startup.iosStartup?.iosSimulatorUdid).toBe('SELECTED-UDID');
expect(mockedUtils.resolveUdidAsync).toHaveBeenCalledWith({
deviceIdentifier: 'iPhone 17',
env: {},
});
expect(mockedUtils.getAvailableDevicesAsync).not.toHaveBeenCalled();
expect(mockedUtils.bootAsync).not.toHaveBeenCalled();
expect(mockedUtils.startAsync).not.toHaveBeenCalled();
expect(mockedUtils.enableAccessibilitySettingsAsync).not.toHaveBeenCalled();
});
it('selects the default iPhone without booting', async () => {
mockedUtils.getAvailableDevicesAsync.mockResolvedValue([
{ name: 'iPad', udid: 'ipad' },
{ name: 'iPhone 17 Pro', udid: 'pro' },
{ name: 'iPhone 17', udid: 'base' },
] as never);
await resolveIosSessionStartupAsync({ ...hostOptions, bootSimulator: true });
expect(mockedUtils.resolveUdidAsync).toHaveBeenCalledWith({
deviceIdentifier: 'base',
env: {},
});
expect(mockedUtils.bootAsync).not.toHaveBeenCalled();
mockedUtils.getAvailableDevicesAsync.mockResolvedValue([]);
await expect(
resolveIosSessionStartupAsync({ ...hostOptions, bootSimulator: true })
).rejects.toThrow('Could not find an iPhone');
});
});
@@ -437,11 +437,13 @@ describe(configureSimulatorProxyEnvironmentAsync, () => {
});
describe(startChiselServerAsync, () => {
const logger = { warn: jest.fn() } as unknown as bunyan;
const options = {
chiselPath: '/tmp/chisel',
controlPort: 52001,
authfilePath: '/tmp/authfile.json',
env: { PATH: '/bin', AUTH: 'unexpected:password' },
logger,
};
let output: string;
let stopAsync: jest.Mock;
@@ -483,7 +485,9 @@ describe(startChiselServerAsync, () => {
expect(stopAsync).not.toHaveBeenCalled();
});
it('rejects and stops a child that prints its fingerprint then fails to bind', async () => {
it('keeps the bind error and warns when a child that failed to bind cannot be stopped', async () => {
const stopError = new Error('drain timed out');
stopAsync.mockRejectedValueOnce(stopError);
const started = startChiselServerAsync(options);
const rejected = expect(started).rejects.toThrow('address already in use');
output += 'listen tcp 127.0.0.1:52001: bind: address already in use\n';
@@ -493,16 +497,27 @@ describe(startChiselServerAsync, () => {
await jest.advanceTimersByTimeAsync(250);
await rejected;
expect(stopAsync).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: stopError },
'Could not stop the reverse tunnel server after it failed to start.'
);
});
it('stops the child when startup is cancelled', async () => {
it('keeps the cancellation and warns when the cancelled child cannot be stopped', async () => {
const stopError = new Error('drain timed out');
stopAsync.mockRejectedValueOnce(stopError);
const controller = new AbortController();
const cancelled = new Error('cancelled');
const started = startChiselServerAsync({ ...options, signal: controller.signal });
const rejected = expect(started).rejects.toThrow('cancelled');
controller.abort(new Error('cancelled'));
const rejected = expect(started).rejects.toBe(cancelled);
controller.abort(cancelled);
await jest.advanceTimersByTimeAsync(250);
await rejected;
expect(stopAsync).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: stopError },
'Could not stop the reverse tunnel server after it failed to start.'
);
});
});
Loaded 100 of 408 files, more files were not shown because too many files have changed in this diff. Show more