Compare commits

...
Author SHA1 Message Date
Brent Vatne 72b8a78145 [worker] Apply log masking to uploaded Xcode build logs
Pass the job's log masks to the Xcode build log upload and mask the file
line by line while streaming it, including values written in the escaped
form Xcode uses for build settings.
2026-10-20 16:31:19 -07: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
246 changed files with 21895 additions and 9519 deletions

No files matched your search

@@ -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.
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Speed up cold iOS Appium sessions by launching preinstalled WebDriverAgent.
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': minor
---
Give agent-device and its simulator preview separate session logs.
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': minor
---
Stream simulator preview output into its existing session step.
@@ -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.
@@ -0,0 +1,5 @@
---
'@expo/worker': patch
---
Apply the job log's secret masking to uploaded Xcode build logs.
-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
+82
View File
@@ -2,6 +2,88 @@
This is the log of notable changes to EAS CLI and related packages.
## [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
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "24.14.0",
"PRODUCTION": "24.14.0"
"STAGING": "25.3.0",
"PRODUCTION": "25.3.0"
}
+2 -2
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "24.14.0",
"version": "25.3.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
}
+1
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",
+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))
+8 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "24.14.0",
"version": "25.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -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.13.0",
"@expo/eas-build-job": "24.13.0",
"@expo/downloader": "25.3.0",
"@expo/eas-build-job": "25.3.0",
"@expo/env": "^0.4.0",
"@expo/logger": "24.13.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.13.0",
"@expo/template-file": "24.13.0",
"@expo/turtle-spawn": "24.13.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",
@@ -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 {
@@ -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';
@@ -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;
@@ -591,6 +591,100 @@ 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 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 new ActualResponse(
JSON.stringify({
data: {
build: {
id: buildId,
gitCommitHash: null,
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
},
},
}),
{ status: 200 }
);
}
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('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({
@@ -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';
@@ -201,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(
@@ -237,6 +495,36 @@ describe(launchApplicationAsync, () => {
);
});
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',
'--es',
'probe',
"'hello world'",
'-n',
'com.example.app/com.example.app.MainActivity',
],
expect.any(Object)
);
});
it.each([{}, ['valid', 42]])('rejects invalid launch arguments (%j)', async launchArgs => {
const launchApplication = createLaunchApplicationFunction();
const buildStep = launchApplication.createBuildStepFromFunctionCall(
@@ -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);
@@ -25,6 +25,7 @@ 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' },
@@ -115,6 +116,19 @@ describe(createRunAgentBuildFunction, () => {
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'],
@@ -162,6 +176,7 @@ describe(createRunAgentBuildFunction, () => {
['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);
@@ -70,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: {},
@@ -86,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() },
});
}
@@ -120,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);
@@ -146,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({
@@ -185,6 +204,21 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
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() };
@@ -199,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()!(
@@ -267,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;
});
@@ -282,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',
@@ -304,7 +364,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
maxDurationSeconds: undefined,
capture: NO_CAPTURE,
tasks,
device: typeof device === 'function' ? device(tasks) : device,
device: sessionDevice,
});
}
@@ -314,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();
@@ -326,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();
@@ -339,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>(() => {});
@@ -356,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[],
@@ -372,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);
@@ -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;
@@ -62,7 +106,6 @@ describe(resolveAppiumDeviceAsync, () => {
resolveAppiumDeviceAsync({
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: {},
logger,
})
).resolves.toEqual({
platformName: 'iOS',
@@ -71,7 +114,6 @@ describe(resolveAppiumDeviceAsync, () => {
udid: 'ios-simulator-id',
runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
});
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledWith({ env: {}, logger });
expect(IosSimulatorUtils.getAvailableDevicesAsync).toHaveBeenCalledWith({
env: {},
filter: 'booted',
@@ -87,7 +129,6 @@ describe(resolveAppiumDeviceAsync, () => {
resolveAppiumDeviceAsync({
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger,
})
).resolves.toEqual({
platformName: 'Android',
@@ -168,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,6 +271,59 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
}
);
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);
@@ -268,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({
@@ -281,6 +417,82 @@ 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
@@ -318,6 +530,81 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
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() };
@@ -357,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() };
@@ -390,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;
@@ -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 },
@@ -15,6 +15,11 @@ import {
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');
@@ -174,7 +179,7 @@ it('preserves the original failure when cleanup fails', async () => {
api()
.put(`${editPath}/tracks/internal`)
.reply(400, { error: { message: 'unknown track' } });
api().delete(editPath).reply(500);
api().delete(editPath).times(6).reply(500);
await expect(submission().run()).rejects.toMatchObject({
cause: expect.objectContaining({ apiMessage: 'unknown track' }),
});
@@ -183,6 +188,7 @@ it('does not delete the edit or publish outputs when commit outcome is unknown',
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)
@@ -220,3 +226,32 @@ it('preserves the cause when the service-account file cannot be read', async ()
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...',
]);
});
@@ -140,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)',
@@ -151,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 }))
@@ -28,6 +28,10 @@ import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
import { pluralize } from '../../utils/strings';
import {
type DeviceRunSessionWorkerApi,
getDeviceRunSessionWorkerApi,
} from '../utils/deviceRunSessionWorkerApi';
const streamPipeline = promisify(stream.pipeline);
@@ -118,6 +122,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,13 +136,22 @@ 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();
if (deviceRunSessionWorkerApi) {
return await fetchDeviceRunSessionApplicationArchiveUrlAsync({
buildId,
deviceRunSessionWorkerApi,
signal,
});
}
const result = await graphqlClient
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
.toPromise();
@@ -163,17 +177,71 @@ 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 deviceRunSessionWorkerApi.getApplicationAsync({ signal });
} catch (err) {
signal?.throwIfAborted();
throw new SystemError(
`Could not fetch build ${buildId} for this device run session: ${
err instanceof Error ? err.message : String(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 +256,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',
@@ -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,24 +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],
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,
@@ -96,6 +96,9 @@ export function createRunAgentBuildFunction(ctx: CustomBuildContext): BuildFunct
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');
@@ -120,6 +123,7 @@ export function createRunAgentBuildFunction(ctx: CustomBuildContext): BuildFunct
pathEnv,
npmCacheUrl,
logger: stepCtx.logger,
addBuildLogMask: ctx.runtimeApi.addBuildLogMask,
signal: signal
? AbortSignal.any([signal, timeoutController.signal])
: timeoutController.signal,
@@ -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}).`
);
@@ -122,6 +139,8 @@ export async function runAgentDeviceRemoteSessionAsync(
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.
@@ -150,12 +169,14 @@ 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,
@@ -163,28 +184,45 @@ export async function runAgentDeviceRemoteSessionAsync(
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({
@@ -201,6 +239,7 @@ export async function runAgentDeviceRemoteSessionAsync(
},
logger,
});
tasks.signal.throwIfAborted();
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
daemonUrl: `http://127.0.0.1:${daemonPort}`,
@@ -226,6 +265,7 @@ export async function runAgentDeviceRemoteSessionAsync(
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
}
: undefined,
});
@@ -236,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,
@@ -280,6 +320,7 @@ export async function startAgentDeviceDaemonAsync({
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-'));
@@ -289,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 });
@@ -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,6 +1,6 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { type Result, asyncResult } from '@expo/results';
import {
BuildFunction,
BuildRuntimePlatform,
@@ -8,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';
@@ -21,22 +22,29 @@ 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,
@@ -44,7 +52,9 @@ import {
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
parseServeSimLaunchInputs,
omitNgrokAuthtoken,
parseCorsOriginsInput,
parseFrameAncestorsInput,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startNgrokTunnelAsync,
@@ -71,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,
@@ -83,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,
@@ -110,65 +133,131 @@ export function createStartAppiumRemoteSessionBuildFunction(
logger.info(
`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
const device = await resolveAppiumDeviceAsync({ runtimePlatform, env, logger });
// Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the
// simulator stream with it, and UiAutomator2 merges long recordings with it. The
// macOS session image does not ship ffmpeg, so install it concurrently with Appium.
// Both installs settle before the step continues or fails, so no install outlives it.
const [appiumInstall] = await Promise.all([
asyncResult(
installAppiumAsync({ versionSpec, driverName: device.driverName, env, logger })
),
ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger }),
]);
const { appiumHome, appiumBinPath, appiumEnv } = appiumInstall.enforceValue();
const wdaCapabilities = await preparePreinstalledWdaAsync({
runtimeIdentifier: device.runtimeIdentifier,
appiumHome,
appiumBinPath,
appiumEnv,
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, ...wdaCapabilities }),
],
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',
@@ -183,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,
@@ -225,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,
@@ -241,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())],
],
});
}
@@ -278,15 +367,12 @@ export type AppiumDevice = {
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',
@@ -323,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,
@@ -83,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,
@@ -100,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).
@@ -116,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,
@@ -135,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
@@ -150,108 +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 });
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 spawn(disableVideoWatermark.command, disableVideoWatermark.args, { env, logger });
} catch (err) {
logger.warn(
{ err },
'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.'
);
}
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',
@@ -267,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({
@@ -309,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,
@@ -325,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;
@@ -345,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())],
],
});
}
@@ -409,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 {
@@ -431,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 => {
@@ -448,6 +557,7 @@ export async function waitForArgentToolServerStateAsync({
};
while (Date.now() < deadline) {
signal?.throwIfAborted();
throwIfExited();
let matchingState: ArgentToolServerState | undefined;
try {
@@ -473,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;
@@ -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,
@@ -131,7 +131,7 @@ export function createSubmitToGooglePlayBuildFunction(): BuildFunction {
keyPath: path.resolve(ctx.workingDirectory, submission.service_account_key_path),
});
const artifactPath = path.resolve(ctx.workingDirectory, submission.artifact_path);
const client = new GooglePlayClient(credentials);
const client = new GooglePlayClient(credentials, ctx.logger);
const versionCode = await submitToGooglePlayAsync({
artifactType: submission.artifact_type,
client,
@@ -185,7 +185,19 @@ async function submitToGooglePlayAsync({
`Submitting ${packageName} (${artifactType}) to track ${JSON.stringify(submission.track)}.`
);
logger.info(
`Requested status: ${submission.release_status}; rollout: ${submission.rollout ?? 'none'}. Effective status: ${release.status}; rollout: ${release.userFraction ?? 'none'}. changes_not_sent_for_review: ${submission.changes_not_sent_for_review}.`
`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)}`);
@@ -216,7 +228,8 @@ async function submitToGooglePlayAsync({
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(`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,
@@ -232,7 +245,7 @@ async function submitToGooglePlayAsync({
return versionCode;
} catch (error) {
commitOutcomeUnknown = error instanceof GooglePlayCommitOutcomeUnknownError;
throw GooglePlayUtils.mapGooglePlayError(error);
throw GooglePlayUtils.mapGooglePlayError(error, submission.package_name);
} finally {
if (editId && !committed && !commitOutcomeUnknown) {
try {
@@ -243,7 +256,7 @@ async function submitToGooglePlayAsync({
});
} catch {
logger.warn(
'Could not delete the Google Play edit after failure. The original submission error is preserved.'
'Could not discard the pending Google Play changes. Check the release in Play Console before submitting again.'
);
}
}
@@ -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;
}
@@ -140,6 +140,7 @@ function createOptions(
pathEnv: '/usr/local/bin:/usr/bin',
npmCacheUrl: 'https://npm-cache.expo.test',
logger: createMockLogger(),
addBuildLogMask: jest.fn(),
signal: new AbortController().signal,
...overrides,
};
@@ -220,23 +221,22 @@ describe(runAgentAsync, () => {
expect(agent.stdin).toHaveBeenCalledWith('Fix the failing test.');
});
it('writes the agent output as log lines with the leased credentials masked', async () => {
it('writes the agent output as log lines', async () => {
leaseMock.mockResolvedValue(anthropicLease);
const logger = createMockLogger();
mockSpawn({
onAgent: agent => {
// One JSON event split across two chunks, in the middle of the token.
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":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"token: anthropic-acc'
);
agent.child.stdout.write('ess-token"}]}}\n');
agent.child.stdout.write(
'{"type":"assistant","message":{"content":[{"type":"text","text":"Done.\\nThe token was anthropic-access-token.\\n"}]}}\n'
'{"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: anthropic-access-token\n');
agent.child.stderr.write('warning: deprecated flag\n');
agent.exit(0);
},
});
@@ -247,11 +247,54 @@ describe(runAgentAsync, () => {
expect(child.mock.calls).toEqual([[{ source: 'stdout' }], [{ source: 'stderr' }]]);
const [stdoutLogger, stderrLogger] = child.mock.results.map(result => result.value);
expect(stdoutLogger.info.mock.calls).toEqual([
['Tool result: token: [redacted]'],
['Agent: Done.\nThe token was [redacted].'],
['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: [redacted]']]);
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 () => {
@@ -427,12 +470,7 @@ describe(runAgentAsync, () => {
leaseMock.mockResolvedValueOnce(anthropicLease).mockResolvedValueOnce(renewedLease);
const logger = createMockLogger();
const invocations = mockSpawn({
onAgent: agent => {
if (invocations.length === 2) {
agent.child.stderr.write('tokens: anthropic-access-token renewed-access-token\n');
}
agent.exit(invocations.length === 1 ? 1 : 0);
},
onAgent: agent => agent.exit(invocations.length === 1 ? 1 : 0),
});
await runAgentAsync(createOptions({ logger }));
@@ -452,8 +490,6 @@ describe(runAgentAsync, () => {
[{ source: 'stdout' }],
[{ source: 'stderr' }],
]);
const [, , , resumedStderrLogger] = child.mock.results.map(result => result.value);
expect(resumedStderrLogger.info.mock.calls).toEqual([['tokens: [redacted] [redacted]']]);
});
it('resumes Codex with new credentials when the lease changed during the run', async () => {
@@ -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)',
@@ -52,19 +62,20 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
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,26 +88,19 @@ 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',
@@ -108,18 +112,8 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
})
).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,7 +1,7 @@
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';
@@ -10,20 +10,43 @@ 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;
@@ -31,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)
@@ -82,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);
@@ -92,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);
@@ -102,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);
});
@@ -126,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 () => {
@@ -156,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)
@@ -193,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,
@@ -208,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,
});
})
@@ -255,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);
@@ -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' },
});
});
});
@@ -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 () => {
@@ -126,7 +117,7 @@ it('replays the full file after a transient PUT failure without recreating the u
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);
@@ -281,7 +272,7 @@ 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;
@@ -298,7 +289,7 @@ 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(openStream).not.toHaveBeenCalled();
@@ -0,0 +1,361 @@
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 () => {
jest.mocked(turtleFetch).mockRejectedValueOnce(new Error('socket hang up'));
const error = await createApi()
.getSessionAsync()
.catch(err => err);
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
expect(error.status).toBeUndefined();
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,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');
});
});
@@ -21,6 +21,8 @@ import {
rebindLocalEgressGuardRelays,
resolveEgressGuardLibraryAsync,
resolveLocalEgressBootEnvironmentAsync,
resolveLocalEgressServeSimBootEnvironmentAsync,
startLocalEgressGuardRelayAsync,
stopLocalEgressGuardRelaysAsync,
verifyLocalEgressGuardAsync,
} from '../localEgressGuard';
@@ -401,6 +403,38 @@ describe(installLocalEgressGuardAsync, () => {
expect(sessionLogger.lines.at(-1)?.msg).toContain('refused 1 connection attempt(s)');
});
it('still installs the guard when its event log cannot be created', async () => {
const handoffPath = path.join(dir, 'handoff.json');
await writeLocalEgressHandoffAsync(handoff, handoffPath);
const libraryPath = path.join(dir, 'egress-guard.dylib');
const blockedDirectory = path.join(dir, 'not-a-directory');
await fs.promises.writeFile(blockedDirectory, 'file');
await expect(
installLocalEgressGuardAsync({
udid: 'u' as any,
env: process.env,
logger,
handoffPath,
libraryPath,
logPath: path.join(blockedDirectory, 'guard.log'),
})
).resolves.toBe(true);
expect(mockedSetEnv).toHaveBeenCalledWith({
udid: 'u',
env: process.env,
variables: buildGuardLaunchdEnvironment({
libraryPath,
logPath: path.join(blockedDirectory, 'guard.log'),
mode: 'block',
}),
});
expect(
logger.lines.some(
line => line.level === 'warn' && line.msg.includes('They are still refused.')
)
).toBe(true);
});
it('fails the session when launchctl fails', async () => {
const handoffPath = path.join(dir, 'handoff.json');
await writeLocalEgressHandoffAsync(handoff, handoffPath);
@@ -469,6 +503,101 @@ describe(resolveLocalEgressBootEnvironmentAsync, () => {
});
});
describe(resolveLocalEgressServeSimBootEnvironmentAsync, () => {
let dir: string;
let handoffPath: string;
beforeEach(async () => {
dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'guard-serve-sim-'));
handoffPath = path.join(dir, 'handoff.json');
});
afterEach(async () => {
await fs.promises.rm(dir, { recursive: true, force: true });
});
it('does not require the guard or check for an ordinary session', async () => {
await expect(
resolveLocalEgressServeSimBootEnvironmentAsync({
handoffPath,
libraryPath: null,
checkPath: null,
})
).resolves.toBeNull();
});
it('requires both packaged files before handing startup to serve-sim', async () => {
await writeLocalEgressHandoffAsync(handoff, handoffPath);
await expect(
resolveLocalEgressServeSimBootEnvironmentAsync({ handoffPath, libraryPath: null })
).rejects.toThrow('missing bin/egress-guard.dylib');
await expect(
resolveLocalEgressServeSimBootEnvironmentAsync({
handoffPath,
libraryPath: '/w/bin/egress-guard.dylib',
checkPath: null,
})
).rejects.toThrow('missing bin/egress-guard-check');
});
it('passes the additional dylib and prefixed child variables without host DYLD injection', async () => {
await writeLocalEgressHandoffAsync(handoff, handoffPath);
const bootEnv = await resolveLocalEgressServeSimBootEnvironmentAsync({
handoffPath,
libraryPath: '/w/bin/egress-guard.dylib',
checkPath: '/w/bin/egress-guard-check',
logPath: '/tmp/guard.log',
});
expect(bootEnv).toEqual({
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
SIMCTL_CHILD_EAS_EGRESS_GUARD_LOG: '/tmp/guard.log',
SIMCTL_CHILD_EAS_EGRESS_GUARD_MODE: 'block',
SIMCTL_CHILD_http_proxy: 'http://127.0.0.1:8899',
SIMCTL_CHILD_https_proxy: 'http://127.0.0.1:8899',
SIMCTL_CHILD_HTTP_PROXY: 'http://127.0.0.1:8899',
SIMCTL_CHILD_HTTPS_PROXY: 'http://127.0.0.1:8899',
SIMCTL_CHILD_grpc_proxy: 'http://127.0.0.1:8899',
SIMCTL_CHILD_no_proxy: 'localhost,127.0.0.1,::1',
SIMCTL_CHILD_NO_PROXY: 'localhost,127.0.0.1,::1',
});
});
});
describe(startLocalEgressGuardRelayAsync, () => {
it('relays boot events once after repeated starts without changing Simulator state', async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'guard-relay-'));
const logPath = path.join(dir, 'guard.log');
const logger = createLogger();
mockedSetEnv.mockClear();
await fs.promises.writeFile(
logPath,
'eas-egress-guard\tSpringBoard\t4001\tconnect\tblocked\t1.1.1.1:443\tNetwork\n'
);
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
try {
await startLocalEgressGuardRelayAsync({ logger, logPath, tailIntervalMs: 5 });
await startLocalEgressGuardRelayAsync({ logger, logPath, tailIntervalMs: 5 });
await fs.promises.appendFile(
logPath,
'eas-egress-guard\tMyApp\t4002\tconnect\tblocked\t1.1.1.1:443\tNetwork\n'
);
await jest.advanceTimersByTimeAsync(20);
await stopLocalEgressGuardRelaysAsync(logger);
expect(mockedSetEnv).not.toHaveBeenCalled();
expect(
logger.lines.filter(line => /refused connect from SpringBoard/.test(line.msg))
).toHaveLength(1);
expect(logger.lines.filter(line => /refused connect from MyApp/.test(line.msg))).toHaveLength(
1
);
expect(logger.lines.at(-1)?.msg).toContain('refused 2 connection attempt(s)');
} finally {
await stopLocalEgressGuardRelaysAsync(logger);
jest.clearAllTimers();
jest.useRealTimers();
await fs.promises.rm(dir, { recursive: true, force: true });
}
});
});
describe(parseGuardCoverage, () => {
const ps = [
' 1 0 /sbin/launchd',
@@ -3,6 +3,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import * as processes from '../../../utils/processes';
import { preparePreinstalledWdaAsync } from '../preinstalledWda';
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
@@ -78,3 +79,37 @@ it('does not enable preinstalled startup when the download produces no runner',
jest.mocked(spawn).mockResolvedValue({ stdout: '' } as never);
await expect(preparePreinstalledWdaAsync(options)).resolves.toEqual({});
});
it('drains an aborted WDA downloader before removing its partial artifact', async () => {
await fs.promises.mkdir(outdir, { recursive: true });
const controller = new AbortController();
const error = new Error('Simulator startup failed');
const child = { pid: 12345, kill: jest.fn() };
let closeDownloader!: (error: Error) => void;
jest.mocked(spawn).mockReturnValueOnce(
Object.assign(
new Promise((_resolve, reject) => {
closeDownloader = reject;
}),
{ child }
) as never
);
const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
const running = preparePreinstalledWdaAsync({ ...options, signal: controller.signal });
const settled = jest.fn();
const observed = running.then(settled, settled);
try {
controller.abort(error);
expect(killGroup).toHaveBeenCalledWith(child, 'SIGKILL');
expect(settled).not.toHaveBeenCalled();
expect(fs.existsSync(outdir)).toBe(true);
closeDownloader(new Error('download interrupted'));
await expect(running).rejects.toBe(error);
expect(fs.existsSync(outdir)).toBe(false);
expect(logger.warn).not.toHaveBeenCalled();
} finally {
closeDownloader(error);
await observed;
killGroup.mockRestore();
}
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,184 @@
import { once } from 'node:events';
import {
access,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
readlink,
rm,
stat,
symlink,
writeFile,
} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { WebSocketServer } from 'ws';
import { runServeSimActionAsync, stageServeSimAppAsync } from '../serveSimActions';
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
let server: WebSocketServer;
let port: number;
const token = 'test-session-token';
const request = () => ({
port,
token,
action: 'app.install',
params: { udid: 'device', path: '/tmp/App.app' },
timeoutMs: 2_000,
});
beforeEach(async () => {
server = new WebSocketServer({ host: '127.0.0.1', port: 0 });
await once(server, 'listening');
port = (server.address() as { port: number }).port;
});
afterEach(async () => {
for (const socket of server.clients) {
socket.terminate();
}
await new Promise<void>(resolve => server.close(() => resolve()));
});
it('authenticates the upgrade, waits for ready, and sends the action once', async () => {
const actions: unknown[] = [];
const upgrades: unknown[] = [];
server.on('connection', (socket, upgrade) => {
upgrades.push({
url: upgrade.url,
protocol: upgrade.headers['sec-websocket-protocol'],
});
socket.on('message', raw => {
actions.push(JSON.parse(raw.toString()));
socket.send(JSON.stringify({ id: 1, stdout: '', stderr: '', exitCode: 0 }));
});
socket.send(JSON.stringify({ ready: true }));
socket.send(JSON.stringify({ ready: true }));
});
await runServeSimActionAsync(request());
expect(upgrades).toEqual([{ url: '/exec-ws', protocol: `serve-sim.token.${token}` }]);
expect(actions).toEqual([{ id: 1, action: 'app.install', params: request().params }]);
});
it.each([
[{ id: 1, exitCode: 1, stderr: 'install refused' }, 'install refused'],
[{ id: 1, error: 'unknown action app.install' }, 'unknown action'],
[{ id: 1, stdout: '' }, 'invalid response'],
[{ id: 1, exitCode: 1, stderr: '' }, 'invalid response'],
])('rejects unsuccessful action replies without retrying', async (reply, message) => {
let attempts = 0;
server.on('connection', socket => {
socket.send(JSON.stringify({ ready: true }));
socket.on('message', () => {
attempts++;
socket.send(JSON.stringify(reply));
});
});
await expect(runServeSimActionAsync(request())).rejects.toThrow(message);
expect(attempts).toBe(1);
});
it('rejects a connection that closes before its action finishes', async () => {
server.on('connection', socket => socket.close());
await expect(runServeSimActionAsync(request())).rejects.toThrow('closed before completion');
});
it('rejects an upgrade refused by the server without executing an action', async () => {
const connected = jest.fn();
server.options.verifyClient = () => false;
server.on('connection', connected);
await expect(runServeSimActionAsync(request())).rejects.toThrow('connection failed');
expect(connected).not.toHaveBeenCalled();
});
it('rejects malformed replies without resending the action', async () => {
const actions = jest.fn();
server.on('connection', socket => {
socket.send(JSON.stringify({ ready: true }));
socket.on('message', () => {
actions();
socket.send('not JSON');
});
});
await expect(runServeSimActionAsync(request())).rejects.toThrow('returned invalid JSON');
expect(actions).toHaveBeenCalledTimes(1);
});
it('times out and closes a socket that never becomes ready', async () => {
const disconnected = new Promise(resolve =>
server.on('connection', socket => socket.on('close', resolve))
);
await expect(runServeSimActionAsync({ ...request(), timeoutMs: 30 })).rejects.toThrow(
'timed out'
);
await disconnected;
});
it('aborts an in-flight action without resending it', async () => {
const controller = new AbortController();
let attempts = 0;
server.on('connection', socket => {
socket.send(JSON.stringify({ ready: true }));
socket.on('message', () => {
attempts++;
controller.abort(new Error('session stopped'));
});
});
await expect(runServeSimActionAsync({ ...request(), signal: controller.signal })).rejects.toThrow(
'session stopped'
);
expect(attempts).toBe(1);
});
it('does not connect when already aborted', async () => {
const connected = jest.fn();
server.on('connection', connected);
await expect(
runServeSimActionAsync({
...request(),
signal: AbortSignal.abort(new Error('already stopped')),
})
).rejects.toThrow('already stopped');
expect(connected).not.toHaveBeenCalled();
});
it("stages a local app without consuming the caller's original", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'serve-sim-stage-test-'));
try {
const source = path.join(root, 'downloaded', 'App.app');
await mkdir(source, { recursive: true });
await writeFile(path.join(source, 'marker'), 'app');
await symlink('marker', path.join(source, 'current'));
const alias = path.join(root, 'Alias.app');
await symlink(source, alias);
const staged = await stageServeSimAppAsync(alias, root);
expect(staged.path).toMatch(/serve-sim-uploads\/eas-app-[^/]+\/Alias.app$/);
expect((await lstat(staged.path)).isDirectory()).toBe(true);
await access(path.join(staged.path, 'marker'));
expect(await readlink(path.join(staged.path, 'current'))).toBe('marker');
expect((await stat(path.join(root, 'serve-sim-uploads'))).mode & 0o777).toBe(0o700);
await rm(staged.directory, { recursive: true, force: true });
expect(await readFile(path.join(source, 'marker'), 'utf8')).toBe('app');
} finally {
await rm(root, { recursive: true, force: true });
}
});
it('removes its staging directory when the source app cannot be read', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'serve-sim-stage-test-'));
try {
await expect(stageServeSimAppAsync(path.join(root, 'Missing.app'), root)).rejects.toThrow(
'ENOENT'
);
expect(await readdir(path.join(root, 'serve-sim-uploads'))).toEqual([]);
} finally {
await rm(root, { recursive: true, force: true });
}
});
@@ -0,0 +1,196 @@
import { type bunyan } from '@expo/logger';
import { once } from 'node:events';
import http from 'node:http';
import { fetchLatestServeSimEventIdAsync, startServeSimActivityPolling } from '../serveSimActivity';
const token = 'test-session-token';
let server: http.Server;
let port: number;
let events: { id: number }[];
let status: number;
let requests: { url?: string; authorization?: string }[];
let rawBody: string | undefined;
function createLoggerMock(): bunyan {
return { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
}
beforeEach(async () => {
events = [];
status = 200;
requests = [];
rawBody = undefined;
server = http.createServer((req, res) => {
requests.push({ url: req.url, authorization: req.headers.authorization });
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(rawBody ?? JSON.stringify({ events: events.slice(-1) }));
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
port = (server.address() as { port: number }).port;
});
afterEach(async () => {
await new Promise<void>(resolve => server.close(() => resolve()));
});
describe(fetchLatestServeSimEventIdAsync, () => {
it('reads the newest event ID with the session token', async () => {
events = [{ id: 3 }, { id: 7 }];
await expect(fetchLatestServeSimEventIdAsync({ port, token })).resolves.toBe(7);
expect(requests).toEqual([{ url: '/api/event-log?limit=1', authorization: `Bearer ${token}` }]);
});
it('returns undefined when serve-sim has logged no event', async () => {
await expect(fetchLatestServeSimEventIdAsync({ port, token })).resolves.toBeUndefined();
});
it.each([
['unparseable JSON', 'not json'],
['a body without an events array', '{}'],
['an entry without a numeric ID', '{"events":[{"id":"7"}]}'],
])('rejects %s', async (_, body) => {
rawBody = body;
await expect(fetchLatestServeSimEventIdAsync({ port, token })).rejects.toThrow(
'serve-sim event log'
);
});
it('rejects a refused request', async () => {
status = 401;
await expect(fetchLatestServeSimEventIdAsync({ port, token })).rejects.toThrow('401');
});
});
describe(startServeSimActivityPolling, () => {
async function waitForRequestsAsync(count: number): Promise<void> {
while (requests.length < count) {
await new Promise(resolve => setTimeout(resolve, 5));
}
}
it('reports activity only when a new event appears', async () => {
const polling = startServeSimActivityPolling({
port,
token,
logger: createLoggerMock(),
pollIntervalMs: 10,
});
try {
await waitForRequestsAsync(2);
expect(polling.getLastActivityObservedAt()).toBeUndefined();
events = [{ id: 1 }];
const before = Date.now();
await waitForRequestsAsync(requests.length + 2);
const firstSeen = polling.getLastActivityObservedAt();
expect(firstSeen?.getTime()).toBeGreaterThanOrEqual(before);
await waitForRequestsAsync(requests.length + 2);
expect(polling.getLastActivityObservedAt()).toBe(firstSeen);
events = [{ id: 1 }, { id: 2 }];
await waitForRequestsAsync(requests.length + 2);
expect(polling.getLastActivityObservedAt()).not.toBe(firstSeen);
} finally {
await polling.stopAsync();
}
});
it('reports current activity while the event log fails, and warns once', async () => {
status = 500;
const logger = createLoggerMock();
const polling = startServeSimActivityPolling({ port, token, logger, pollIntervalMs: 10 });
try {
await waitForRequestsAsync(3);
expect(logger.warn).toHaveBeenCalledTimes(1);
const before = Date.now();
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
status = 200;
events = [{ id: 4 }];
await waitForRequestsAsync(requests.length + 2);
const observedAt = polling.getLastActivityObservedAt();
await waitForRequestsAsync(requests.length + 2);
expect(polling.getLastActivityObservedAt()).toBe(observedAt);
} finally {
await polling.stopAsync();
}
});
it('keeps the last observation while serve-sim refuses connections', async () => {
await new Promise<void>(resolve => server.close(() => resolve()));
server = http.createServer();
const polling = startServeSimActivityPolling({
port,
token,
logger: createLoggerMock(),
pollIntervalMs: 10,
});
try {
await new Promise(resolve => setTimeout(resolve, 50));
expect(polling.getLastActivityObservedAt()).toBeUndefined();
} finally {
await polling.stopAsync();
}
});
it('reports current activity when serve-sim drops the connection mid-response', async () => {
server.removeAllListeners('request');
server.on('request', (req, res) => {
requests.push({ url: req.url });
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': '100' });
res.write('{"events":');
setImmediate(() => res.socket?.destroy());
});
const polling = startServeSimActivityPolling({
port,
token,
logger: createLoggerMock(),
pollIntervalMs: 10,
});
try {
await waitForRequestsAsync(3);
const before = Date.now();
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
} finally {
await polling.stopAsync();
}
});
it('reports current activity while serve-sim answers without an event log', async () => {
status = 404;
const polling = startServeSimActivityPolling({
port,
token,
logger: createLoggerMock(),
pollIntervalMs: 10,
});
try {
await waitForRequestsAsync(2);
const before = Date.now();
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
status = 200;
await waitForRequestsAsync(requests.length + 2);
expect(polling.getLastActivityObservedAt()).toBeUndefined();
} finally {
await polling.stopAsync();
}
});
it('stops polling after stopAsync', async () => {
const polling = startServeSimActivityPolling({
port,
token,
logger: createLoggerMock(),
pollIntervalMs: 10,
});
await waitForRequestsAsync(1);
await polling.stopAsync();
const count = requests.length;
await new Promise(resolve => setTimeout(resolve, 50));
expect(requests.length).toBe(count);
});
});
@@ -6,11 +6,13 @@ import os from 'node:os';
import path from 'node:path';
import { type CustomBuildContext } from '../../../customBuildContext';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
import { uploadServeSimLogsFileAsync } from '../serveSimLogsArtifacts';
import { streamServeSimLogsToFileAsync } from '../serveSimLogsRecorder';
// This test exercises actual sockets, UTF-8 decoding, files, and PUT uploads.
// Only WWW's allocation response is substituted; it does not verify cloud storage.
// WWW's device run session worker API is served by the local server; it does not verify cloud
// storage.
jest.mock('../../../sentry');
jest.unmock('fs');
jest.unmock('node:fs');
@@ -55,7 +57,29 @@ it('streams selected-device logs into NDJSON and uploads the exact bytes as a se
let streamRequest: { url?: string; authorization?: string } | undefined;
let uploaded: Buffer | undefined;
let uploadedLength: string | undefined;
let uploadSessionRequest: { token?: string | string[]; body: unknown } | undefined;
server.on('request', (request, response) => {
if (request.url === '/v2/device-run-session-worker/artifact-upload-sessions') {
const chunks: Buffer[] = [];
request.on('data', chunk => chunks.push(Buffer.from(chunk)));
request.on('end', () => {
uploadSessionRequest = {
token: request.headers[DEVICE_RUN_SESSION_TOKEN_HEADER],
body: JSON.parse(Buffer.concat(chunks).toString('utf8')),
};
response.writeHead(200, { 'Content-Type': 'application/json' }).end(
JSON.stringify({
data: {
uploadSession: {
url: `${baseUrl}/upload`,
headers: { 'Content-Length': String(expected.length) },
},
},
})
);
});
return;
}
if (request.method === 'GET') {
streamRequest = { url: request.url, authorization: request.headers.authorization };
response.writeHead(200, {
@@ -95,27 +119,16 @@ it('streams selected-device logs into NDJSON and uploads the exact bytes as a se
});
expect(await readFile(filePath)).toEqual(expected);
const mutation = jest.fn().mockReturnValue({
toPromise: async () => ({
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: {
url: `${baseUrl}/upload`,
headers: { 'Content-Length': String(expected.length) },
},
},
},
},
}),
});
await uploadServeSimLogsFileAsync(
{ graphqlClient: { mutation } } as unknown as CustomBuildContext,
{
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'device-run-session-token' },
expoApiV2BaseUrl: `${baseUrl}/v2/`,
} as unknown as CustomBuildContext,
{ deviceRunSessionId: 'session-test', udid: 'device-B', filePath, logger }
);
expect(mutation.mock.calls[0][1]).toEqual({
deviceRunSessionId: 'session-test',
input: {
expect(uploadSessionRequest).toEqual({
token: 'device-run-session-token',
body: {
name: 'App logs (device-B)',
filename: 'app-logs.ndjson',
kind: 'simulator-log',
@@ -88,4 +88,23 @@ describe(createStartupTasks, () => {
createStartupTasks(createLoggerMock()).untilAborted(Promise.resolve('ok'))
).resolves.toBe('ok');
});
it.each([false, true])(
'inherits parent cancellation, already aborted=%s',
async alreadyAborted => {
const parent = new AbortController();
const reason = new Error('cancelled');
if (alreadyAborted) {
parent.abort(reason);
}
const tasks = createStartupTasks(createLoggerMock(), parent.signal);
const waiting = tasks.untilAborted(new Promise<void>(() => {}));
if (!alreadyAborted) {
parent.abort(reason);
}
await expect(waiting).rejects.toBe(reason);
expect(tasks.signal.reason).toBe(reason);
tasks.abort(new Error('later failure'));
expect(tasks.signal.reason).toBe(reason);
}
);
});
@@ -35,6 +35,7 @@ export async function runAgentAsync({
pathEnv,
npmCacheUrl,
logger,
addBuildLogMask,
signal,
}: {
agentKind: AgentKind;
@@ -47,11 +48,11 @@ export async function runAgentAsync({
pathEnv: string;
npmCacheUrl?: string;
logger: bunyan;
addBuildLogMask: (secret: string) => void;
signal: AbortSignal;
}): Promise<void> {
const leaseOptions = { expoApiV2BaseUrl, expoToken, agentRunId, signal };
let credentials = await leaseAgentRunProviderCredentialsAsync(leaseOptions);
const secrets = new Set<string>();
const runDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-agent-run-'));
try {
@@ -126,14 +127,13 @@ export async function runAgentAsync({
}
for (const secret of invocation.secrets) {
secrets.add(secret);
addBuildLogMask(secret);
}
const { exitCode, exitSignal } = await runAgentProcessAsync({
command: path.join(cliDirectory, 'node_modules', '.bin', bin),
...invocation,
cwd: workingDirectory,
formatOutputLine,
secrets: [...secrets],
logger,
signal,
});
@@ -170,7 +170,6 @@ async function runAgentProcessAsync({
cwd,
stdin,
formatOutputLine,
secrets,
logger,
signal,
}: {
@@ -180,7 +179,6 @@ async function runAgentProcessAsync({
cwd: string;
stdin: string;
formatOutputLine: AgentCli['formatOutputLine'];
secrets: string[];
logger: bunyan;
signal: AbortSignal;
}): Promise<{ exitCode: number | null; exitSignal: NodeJS.Signals | null }> {
@@ -188,8 +186,6 @@ async function runAgentProcessAsync({
// Detached, so the agent leads a process group that can be signaled as a whole.
const child = spawn(command, args, { cwd, env, detached: true });
const outputClosed = new Promise<void>(resolve => child.once('close', () => resolve()));
const redact = (text: string): string =>
secrets.reduce((masked, secret) => masked.replaceAll(secret, '[redacted]'), text);
const stdoutLogger = logger.child({ source: 'stdout' });
readline.createInterface({ input: child.stdout }).on('line', line => {
let messages: AgentLogMessage[];
@@ -201,12 +197,12 @@ async function runAgentProcessAsync({
return;
}
for (const { level, message } of messages) {
stdoutLogger[level](redact(message).trimEnd());
stdoutLogger[level](message.trimEnd());
}
});
const stderrLogger = logger.child({ source: 'stderr' });
readline.createInterface({ input: child.stderr }).on('line', line => {
stderrLogger.info(redact(line));
stderrLogger.info(line);
});
// The agent may exit before it reads the prompt; its exit status reports that.
child.stdin.on('error', () => {});
@@ -0,0 +1,70 @@
import { ErrorMetadata } from '@expo/eas-build-job';
import { Response } from 'node-fetch';
import { z } from 'zod';
const MAX_RESPONSE_BODY_LENGTH = 8_000;
export class GooglePlayApiError extends Error {
constructor(
readonly status: number,
readonly apiMessage: string,
readonly reasons: string[],
// Kept for Sentry only, so build logs stay short.
readonly diagnostics: { requestPath?: string; responseBody?: string } = {}
) {
super(`Google Play request failed (HTTP ${status})${apiMessage ? `: ${apiMessage}` : '.'}`);
}
static isRetryableStatus(status: number): boolean {
return status === 408 || status === 429 || (status >= 500 && status < 600);
}
// Error metadata for the first Google Play API error in the cause chain. Retries and
// uncertain commits wrap the API error, e.g. in an AggregateError.
static getErrorMetadata(error: unknown): ErrorMetadata | undefined {
let cause = error;
for (let depth = 0; cause instanceof Error && depth < 5; depth++) {
if (cause instanceof GooglePlayApiError) {
return {
googlePlayRequestPath: cause.diagnostics.requestPath,
// A string, so Sentry does not cut off nested error details.
googlePlayErrorResponse: cause.diagnostics.responseBody,
};
}
cause = cause.cause;
}
return undefined;
}
static async fromResponseAsync(
response: Response,
signal?: AbortSignal
): Promise<GooglePlayApiError> {
let data: unknown;
try {
data = await response.json();
} catch {
signal?.throwIfAborted();
// Raw text or HTML can contain the request URL, including an upload secret.
}
const parsed = z
.object({
error: z.object({
message: z.string().optional(),
errors: z.array(z.object({ reason: z.string().optional() })).optional(),
}),
})
.safeParse(data);
return new GooglePlayApiError(
response.status,
parsed.data?.error.message ?? '',
parsed.data?.error.errors?.flatMap(error => (error.reason ? [error.reason] : [])) ?? [],
{
// Path only: the upload session query contains a secret.
requestPath: response.url ? new URL(response.url).pathname : undefined,
responseBody:
data === undefined ? undefined : JSON.stringify(data).slice(0, MAX_RESPONSE_BODY_LENGTH),
}
);
}
}
@@ -5,6 +5,7 @@ import { z } from 'zod';
import { promiseRetryWithCondition } from '../../../utils/promiseRetryWithCondition';
import { GooglePlayApiError } from './GooglePlayApiError';
const PostApi = {
'/token': {
request: z.object({
@@ -61,7 +62,7 @@ export class GooglePlayAuthClient {
error =>
!signal?.aborted &&
error instanceof GooglePlayAuthRequestError &&
(error.status === 429 || error.status >= 500),
GooglePlayApiError.isRetryableStatus(error.status),
{ retries: 3, factor: 2, minTimeout: 1000, maxTimeout: 4000 }
)();
}
@@ -1,45 +1,13 @@
import { SystemError } from '@expo/eas-build-job';
import fetch, { RequestInit, Response } from 'node-fetch';
import { bunyan } from '@expo/logger';
import { promiseRetryWithCondition } from '../../../utils/promiseRetryWithCondition';
import { z } from 'zod';
import { isConnectFailure, isConnectionInterruptedError } from '../../../utils/networkErrors';
import { GooglePlayApiError } from './GooglePlayApiError';
import { GooglePlayAuthUtils, GoogleServiceAccount } from './GooglePlayAuthUtils';
export class GooglePlayApiError extends Error {
constructor(
readonly status: number,
readonly apiMessage: string,
readonly reasons: string[]
) {
super(`Google Play request failed (HTTP ${status})${apiMessage ? `: ${apiMessage}` : '.'}`);
}
static async fromResponseAsync(
response: Response,
signal?: AbortSignal
): Promise<GooglePlayApiError> {
let data: unknown;
try {
data = await response.json();
} catch {
signal?.throwIfAborted();
// Raw text or HTML can contain the request URL, including an upload secret.
}
const parsed = z
.object({
error: z.object({
message: z.string().optional(),
errors: z.array(z.object({ reason: z.string().optional() })).optional(),
}),
})
.safeParse(data);
return new GooglePlayApiError(
response.status,
parsed.data?.error.message ?? '',
parsed.data?.error.errors?.flatMap(error => (error.reason ? [error.reason] : [])) ?? []
);
}
}
export const ReleaseStatusZ = z.enum(['draft', 'inProgress', 'halted', 'completed']);
export type ReleaseStatus = z.output<typeof ReleaseStatusZ>;
@@ -88,7 +56,9 @@ const PostApi = {
'/androidpublisher/v3/applications/:packageName/edits/:editId:commit': {
path: z.object({ packageName: z.string().min(1), editId: z.string().min(1) }),
request: z.object({}),
query: z.object({ changesNotSentForReview: z.boolean().optional() }),
query: z.object({
changesNotSentForReview: z.boolean().optional(),
}),
response: z.object({ id: z.string().min(1), expiryTimeSeconds: z.string().optional() }),
},
'/androidpublisher/v3/applications/:packageName/edits/:editId:validate': {
@@ -128,7 +98,10 @@ export class GooglePlayClient {
private readonly baseUrl = 'https://androidpublisher.googleapis.com';
private cachedToken?: Promise<{ value: string; expiresAt: number }>;
constructor(private readonly serviceAccount: GoogleServiceAccount) {}
constructor(
private readonly serviceAccount: GoogleServiceAccount,
private readonly logger?: bunyan
) {}
private async getTokenAsync(signal?: AbortSignal): Promise<string> {
signal?.throwIfAborted();
@@ -204,7 +177,21 @@ export class GooglePlayClient {
for (const [key, value] of Object.entries(DeleteApi[path].path.parse(params))) {
url = url.replace(`:${key}`, encodeURIComponent(value));
}
await this.requestAsync('DELETE', url, undefined, signal);
try {
await this.requestWithRetriesAsync(
() => this.requestAsync('DELETE', url, undefined, signal),
signal
);
} catch (error) {
if (
error instanceof AggregateError &&
error.cause instanceof GooglePlayApiError &&
error.cause.status === 404
) {
return;
}
throw error;
}
}
private async sendJsonRequestAsync(
@@ -236,12 +223,16 @@ export class GooglePlayClient {
}
}
const response = await this.requestAsync(
method,
search.size ? `${path}?${search}` : path,
method === 'GET' ? undefined : JSON.stringify(parsedBody),
signal,
{ 'Content-Type': 'application/json' }
const response = await this.requestWithRetriesAsync(
() =>
this.requestAsync(
method,
search.size ? `${path}?${search}` : path,
method === 'GET' ? undefined : JSON.stringify(parsedBody),
signal,
{ 'Content-Type': 'application/json' }
),
signal
);
let data: unknown;
@@ -264,6 +255,47 @@ export class GooglePlayClient {
return parsed.data;
}
private async requestWithRetriesAsync<T>(
request: () => Promise<T>,
signal?: AbortSignal
): Promise<T> {
const errors: unknown[] = [];
try {
return await promiseRetryWithCondition(
async () => {
try {
signal?.throwIfAborted();
return await request();
} catch (error) {
errors.push(error);
throw error;
}
},
error =>
!signal?.aborted &&
(error instanceof GooglePlayApiError
? GooglePlayApiError.isRetryableStatus(error.status)
: isConnectFailure(error) || isConnectionInterruptedError(error)),
{ retries: 5, factor: 2, minTimeout: 1000, maxTimeout: 32_000, randomize: true },
({ attemptNumber, maxAttemptsCount, error }) => {
const reason =
error instanceof GooglePlayApiError ? `HTTP ${error.status}` : 'network error';
this.logger?.warn(
`Retrying Google Play request (${reason}, attempt ${attemptNumber}/${maxAttemptsCount})...`
);
}
)();
} catch (error) {
// Keep earlier failures: a later rejection does not prove that every attempt failed.
if (errors.length > 1) {
throw new AggregateError(errors, 'Google Play request failed after retries.', {
cause: error,
});
}
throw error;
}
}
async requestAsync(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
@@ -1,10 +1,12 @@
import { SystemError } from '@expo/eas-build-job';
import fetch, { FetchError, Response } from 'node-fetch';
import fetch, { Response } from 'node-fetch';
import promiseRetry from 'promise-retry';
import { FileHandle } from 'node:fs/promises';
import { z } from 'zod';
import { GooglePlayApiError, GooglePlayClient } from './GooglePlayClient';
import { isConnectFailure, isConnectionInterruptedError } from '../../../utils/networkErrors';
import { GooglePlayApiError } from './GooglePlayApiError';
import { GooglePlayClient } from './GooglePlayClient';
import { AndroidArtifactType } from './appArtifact';
const UploadResponseZ = z.object({
@@ -53,8 +55,10 @@ export namespace GooglePlayResumableUpload {
signal?.throwIfAborted();
if (
timeout.aborted ||
isNetworkRetryable(error) ||
(error instanceof GooglePlayApiError && (error.status === 429 || error.status >= 500))
isConnectFailure(error) ||
isConnectionInterruptedError(error) ||
(error instanceof GooglePlayApiError &&
GooglePlayApiError.isRetryableStatus(error.status))
) {
retry(
timeout.aborted
@@ -187,7 +191,7 @@ export namespace GooglePlayResumableUpload {
const failure = new SystemError(
'Google Play upload failed before a response was received.'
);
if (timeout.aborted || isNetworkRetryable(error)) {
if (timeout.aborted || isConnectFailure(error) || isConnectionInterruptedError(error)) {
checkServerOffset = true;
retry(failure);
}
@@ -200,7 +204,7 @@ export namespace GooglePlayResumableUpload {
checkServerOffset = true;
return retry(new SystemError('Google Play upload response timed out.'));
}
if (response.status === 429 || response.status >= 500) {
if (GooglePlayApiError.isRetryableStatus(response.status)) {
checkServerOffset = true;
retry(error);
}
@@ -276,15 +280,3 @@ function getContentTypeFromArtifactType(artifactType: AndroidArtifactType): stri
? 'application/vnd.android.package-archive'
: 'application/octet-stream';
}
function isNetworkRetryable(error: unknown): boolean {
const visited = new Set<Error>();
while (error instanceof Error && !visited.has(error)) {
if (error instanceof FetchError && error.type === 'system') {
return true;
}
visited.add(error);
error = error.cause;
}
return false;
}
@@ -3,8 +3,11 @@ import { bunyan } from '@expo/logger';
import fs from 'node:fs/promises';
import promiseRetry from 'promise-retry';
import { promiseRetryWithCondition } from '../../../utils/promiseRetryWithCondition';
import { GooglePlayApiError } from './GooglePlayApiError';
import { GooglePlayAuthRequestError } from './GooglePlayAuthClient';
import { GooglePlayApiError, GooglePlayClient, ReleaseStatus } from './GooglePlayClient';
import { GooglePlayClient, ReleaseStatus } from './GooglePlayClient';
import { GooglePlayResumableUpload } from './GooglePlayResumableUpload';
import { AndroidArtifactType } from './appArtifact';
@@ -141,7 +144,7 @@ export namespace GooglePlayUtils {
signal?: AbortSignal;
}
): Promise<void> {
const commitOnceAsync = async ({
const sendCommitAsync = async ({
changesNotSentForReview,
}: {
changesNotSentForReview?: boolean;
@@ -163,13 +166,48 @@ export namespace GooglePlayUtils {
}
if (
!(error instanceof GooglePlayApiError) ||
error.status >= 500 ||
error.status === 408 ||
error.status === 429
GooglePlayApiError.isRetryableStatus(error.status)
) {
throw new GooglePlayCommitOutcomeUnknownError(
'Google Play commit outcome is unknown. Check the release in Play Console before you submit again. The commit was not retried.',
{ cause: error }
`Could not confirm whether Google Play saved the release for ${packageName}. Check its release status at https://play.google.com/console/ before submitting again.`,
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
);
}
throw error;
}
logger.info(
changesNotSentForReview
? 'Google Play saved the changes without sending them for review. Send them for review at https://play.google.com/console/.'
: 'Google Play saved the release using its normal review process. Check its review and publishing status at https://play.google.com/console/.'
);
};
// Google can accept the bundle upload before the edit is ready to commit.
// Retry only this explicit rejection, not other HTTP 400 preconditions.
const isUploadStillProcessing = (error: unknown): boolean =>
error instanceof GooglePlayApiError &&
error.status === 400 &&
error.apiMessage === 'Some of the Android App Bundle uploads are not completed yet.';
const commitAsync = async (options: { changesNotSentForReview?: boolean }): Promise<void> => {
try {
await promiseRetryWithCondition(
sendCommitAsync,
error => {
signal?.throwIfAborted();
return isUploadStillProcessing(error);
},
{ retries: 13, factor: 2, minTimeout: 2000, maxTimeout: 32_000 },
({ attemptNumber, maxAttemptsCount }) => {
logger.warn(
`Google Play is still processing the uploaded bundle. Retrying the same edit commit (attempt ${attemptNumber}/${maxAttemptsCount}).`
);
}
)(options);
} catch (error) {
if (isUploadStillProcessing(error)) {
throw new SystemError(
`Google Play did not finish processing the uploaded bundle for ${packageName} within five minutes. Wait a few minutes, then check the release at https://play.google.com/console/ before submitting again.`,
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
);
}
throw error;
@@ -178,7 +216,7 @@ export namespace GooglePlayUtils {
try {
// Omit the optional parameter for normal review submission. Some app states
// reject its presence, even when false. True saves changes for review in Console.
await commitOnceAsync({ changesNotSentForReview: changesNotSentForReview || undefined });
await commitAsync({ changesNotSentForReview: changesNotSentForReview || undefined });
} catch (error) {
if (!(error instanceof GooglePlayApiError) || error.status !== 400) {
throw error;
@@ -191,7 +229,7 @@ export namespace GooglePlayUtils {
logger.warn(
'Google does not allow changesNotSentForReview for this app right now. Retrying commit once without it. The changes will be sent for review.'
);
await commitOnceAsync({});
await commitAsync({});
} else if (
!changesNotSentForReview &&
error.apiMessage.includes('Please set the query parameter changesNotSentForReview to true')
@@ -199,29 +237,31 @@ export namespace GooglePlayUtils {
logger.warn(
'Google requires changesNotSentForReview=true. Retrying commit once. Send the saved changes for review from Play Console.'
);
await commitOnceAsync({ changesNotSentForReview: true });
await commitAsync({ changesNotSentForReview: true });
} else {
throw error;
}
}
}
export function mapGooglePlayError(error: unknown): unknown {
export function mapGooglePlayError(error: unknown, packageName: string): unknown {
if (error instanceof AggregateError) {
return mapGooglePlayError(error.cause, packageName);
}
if (
(error instanceof GooglePlayApiError || error instanceof GooglePlayAuthRequestError) &&
(error.status === 408 || error.status === 429 || error.status >= 500)
GooglePlayApiError.isRetryableStatus(error.status)
) {
return new SystemError(
`Google Play is temporarily unavailable or rate limited (HTTP ${error.status}). Try again later.`,
{ cause: error }
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
);
}
if (
error instanceof GooglePlayAuthRequestError &&
error.status >= 400 &&
error.status < 500 &&
error.status !== 408 &&
error.status !== 429
!GooglePlayApiError.isRetryableStatus(error.status)
) {
return new UserError(
'EAS_GOOGLE_PLAY_INVALID_CREDENTIALS',
@@ -235,7 +275,7 @@ export namespace GooglePlayUtils {
const message = error.apiMessage;
const reasons = new Set(error.reasons);
let code = 'EAS_GOOGLE_PLAY_REQUEST_FAILED';
let detail = `Google Play rejected the request (HTTP ${error.status}; reasons: ${error.reasons.join(', ') || 'unspecified'}). Check the app and release settings in Play Console. Concurrent edits or Console changes can invalidate an edit.`;
let detail = `Google Play rejected the request (HTTP ${error.status})${message ? `: ${message}` : '.'} Check the app settings before submitting again.`;
if (message.includes('Package not found')) {
code = 'EAS_GOOGLE_PLAY_FIRST_UPLOAD';
detail =
@@ -247,7 +287,7 @@ export namespace GooglePlayUtils {
) {
code = 'EAS_GOOGLE_PLAY_VERSION_CODE_USED';
detail =
'This version code was already uploaded. Increase the Android version code and build a new binary.';
'This version code was already uploaded. Check whether the intended release already exists. To upload a new binary, increase the Android version code and build again.';
} else if (
/privacy policy/i.test(message) ||
reasons.has('apkNotificationMessageKeyPermissionsRequirePrivacyPolicy') ||
@@ -255,10 +295,13 @@ export namespace GooglePlayUtils {
) {
code = 'EAS_GOOGLE_PLAY_PRIVACY_POLICY_REQUIRED';
detail = 'This app requires a privacy policy. Add it in Play Console before you submit.';
} else if (message.includes('Only releases with status draft may be created on draft app')) {
} else if (
message.includes('Only releases with status draft may be created on draft app') ||
message.includes('Staged rollout is not permitted on draft app')
) {
code = 'EAS_GOOGLE_PLAY_DRAFT_APP';
detail =
'This app is a draft in Play Console. Complete its required metadata or submit with release_status: draft.';
'This app is still a draft in Play Console. Submit a draft release without a rollout, or complete the app setup in Play Console before submitting a release to testers. Use releaseStatus: "draft" in eas.json, or release_status: "draft" in a workflow.';
} else if (error.status === 409 || /edit.*(?:expired|invalid|not found)/i.test(message)) {
code = 'EAS_GOOGLE_PLAY_EDIT_INVALID';
detail =
@@ -269,11 +312,12 @@ export namespace GooglePlayUtils {
'The Google service account cannot submit this app. Check its key and app permissions in Play Console.';
} else if (error.status === 400) {
code = 'EAS_GOOGLE_PLAY_INVALID_RELEASE';
detail =
'Google Play rejected the release or review settings. Check the track, release status, rollout, and review settings in Play Console.';
detail = `Google Play rejected the release or review settings${message ? `: ${message}` : '.'} Check the track, release status, rollout, and review settings in Play Console.`;
}
return new UserError(code, message ? `${detail} Google Play said: ${message}` : detail, {
cause: error,
});
return new UserError(
code,
`${detail}\n\nApp: ${packageName}\nGoogle Play Console: https://play.google.com/console/`,
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
);
}
}
@@ -1,9 +1,9 @@
import nock from 'nock';
import { generateKeyPairSync } from 'node:crypto';
import { SystemError } from '@expo/eas-build-job';
import { ZodError } from 'zod';
import { GooglePlayClient } from '../GooglePlayClient';
import { createMockLogger } from '../../../../__tests__/utils/logger';
const packageName = 'dev.expo.submitfixture';
const editPath = `/androidpublisher/v3/applications/${packageName}/edits/edit`;
@@ -13,6 +13,11 @@ const api = (): nock.Scope =>
});
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 });
});
const serviceAccount = {
client_email: '[email protected]',
@@ -75,11 +80,12 @@ it('rejects invalid rollout requests before sending them', async () => {
).rejects.toBeInstanceOf(ZodError);
});
it('does not retry an ambiguous edit commit', async () => {
it('keeps the outcome unknown when a lost commit response is followed by an invalid edit', async () => {
api()
.post(`${editPath}:commit`, {})
.query({ changesNotSentForReview: false })
.replyWithError('Lost response');
.replyWithError(Object.assign(new Error('Lost response'), { code: 'ECONNRESET' }));
api().post(`${editPath}:commit`, {}).query({ changesNotSentForReview: false }).reply(404);
await expect(
client.postAsync(
'/androidpublisher/v3/applications/:packageName/edits/:editId:commit',
@@ -87,7 +93,62 @@ it('does not retry an ambiguous edit commit', async () => {
{ packageName, editId: 'edit' },
{ query: { changesNotSentForReview: false } }
)
).rejects.toBeInstanceOf(SystemError);
).rejects.toMatchObject({
errors: [expect.any(Error), expect.objectContaining({ status: 404 })],
});
});
it.each([408, 429, 500, 503])('stops after five retries for HTTP %s', async status => {
api().get(editPath).times(6).reply(status);
await expect(
client.getAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
packageName,
editId: 'edit',
})
).rejects.toMatchObject({
cause: expect.objectContaining({ status }),
errors: expect.arrayContaining([expect.objectContaining({ status })]),
});
});
it('retries the same track update body after a temporary error', async () => {
const body = {
track: 'internal',
releases: [{ versionCodes: ['123'], status: 'draft' as const }],
};
api().put(`${editPath}/tracks/internal`, body).reply(503);
api().put(`${editPath}/tracks/internal`, body).reply(200, { track: 'internal' });
await expect(
client.putAsync(
'/androidpublisher/v3/applications/:packageName/edits/:editId/tracks/:track',
body,
{ packageName, editId: 'edit', track: 'internal' }
)
).resolves.toEqual({ track: 'internal' });
});
it('does not add retries to upload-session requests', async () => {
api().post('/upload/session').reply(503);
await expect(client.requestAsync('POST', '/upload/session')).rejects.toMatchObject({
status: 503,
});
});
it('stops retrying when cancelled after a transient response', async () => {
const controller = new AbortController();
api()
.get(editPath)
.reply(() => {
controller.abort();
return [503];
});
await expect(
client.getAsync(
'/androidpublisher/v3/applications/:packageName/edits/:editId',
{ packageName, editId: 'edit' },
controller.signal
)
).rejects.toBeDefined();
});
it('deletes an edit with an empty response', async () => {
@@ -100,6 +161,44 @@ it('deletes an edit with an empty response', async () => {
).resolves.toBeUndefined();
});
it('reports a retry even when the next request succeeds', async () => {
const logger = createMockLogger();
client = new GooglePlayClient(serviceAccount, logger);
api().get(editPath).reply(503);
api().get(editPath).reply(200, { id: 'edit' });
await client.getAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
packageName,
editId: 'edit',
});
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
'Retrying Google Play request (HTTP 503, attempt 2/6)...'
);
});
it('accepts a missing edit after a lost delete response', async () => {
api()
.delete(editPath)
.replyWithError(Object.assign(new Error('Lost response'), { code: 'ECONNRESET' }));
api().delete(editPath).reply(404);
await expect(
client.deleteAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
packageName,
editId: 'edit',
})
).resolves.toBeUndefined();
});
it('retains an initial delete rejection', async () => {
api().delete(editPath).reply(404);
await expect(
client.deleteAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
packageName,
editId: 'edit',
})
).rejects.toMatchObject({ status: 404 });
});
it('rejects malformed success responses without exposing their contents', async () => {
api()
.get(editPath)
@@ -124,6 +223,42 @@ it('retains structured API errors', async () => {
).rejects.toMatchObject({ status: 403, apiMessage: 'Denied', reasons: ['forbidden'] });
});
it.each(['CERT_HAS_EXPIRED', 'EPROTO', 'EACCES', undefined])(
'does not retry a system error with code %s',
async code => {
const logger = createMockLogger();
client = new GooglePlayClient(serviceAccount, logger);
api()
.get(editPath)
.replyWithError(Object.assign(new Error('Request failed'), { code }));
await expect(
client.getAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
packageName,
editId: 'edit',
})
).rejects.toMatchObject({
cause: expect.objectContaining({ type: 'system' }),
});
expect(logger.warn).not.toHaveBeenCalled();
}
);
it.each(['EAI_AGAIN', 'ECONNREFUSED', 'ECONNRESET', 'UND_ERR_SOCKET'])(
'retries a recognised network error %s',
async code => {
api()
.get(editPath)
.replyWithError(Object.assign(new Error('Request failed'), { code }));
api().get(editPath).reply(200, { id: 'edit' });
await expect(
client.getAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
packageName,
editId: 'edit',
})
).resolves.toEqual({ id: 'edit' });
}
);
it.each(['body', 'query'])('accepts extra fields in an empty %s object', async field => {
api()
.post(`/androidpublisher/v3/applications/${packageName}/edits`, {})
@@ -1,13 +1,20 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import nock from 'nock';
import { Response } from 'node-fetch';
import { api, editPath, mockToken, packageName, serviceAccount } from './googlePlayTestUtils';
import { createMockLogger } from '../../../../__tests__/utils/logger';
import { GooglePlayAuthRequestError } from '../GooglePlayAuthClient';
import { GooglePlayApiError, GooglePlayClient } from '../GooglePlayClient';
import { GooglePlayUtils } from '../GooglePlayUtils';
import { GooglePlayApiError } from '../GooglePlayApiError';
import { GooglePlayClient } from '../GooglePlayClient';
import { GooglePlayCommitOutcomeUnknownError, GooglePlayUtils } from '../GooglePlayUtils';
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 });
});
beforeEach(() => nock.disableNetConnect());
afterEach(() => {
const pending = nock.pendingMocks();
@@ -18,15 +25,17 @@ afterEach(() => {
function commit({
signal,
changesNotSentForReview = false,
logger = createMockLogger(),
}: {
signal?: AbortSignal;
changesNotSentForReview?: boolean;
logger?: ReturnType<typeof createMockLogger>;
} = {}) {
return GooglePlayUtils.commitEditAsync(new GooglePlayClient(serviceAccount), {
packageName,
editId: 'edit',
changesNotSentForReview,
logger: createMockLogger(),
logger,
signal,
});
}
@@ -57,16 +66,23 @@ it.each([
] as const)(
'retries the rejected review setting (requested=%s)',
async (changesNotSentForReview, initialQuery, message, retryQuery) => {
const logger = createMockLogger();
mockToken();
api().post(`${editPath}:commit`).query(initialQuery).reply(400, { error: { message } });
api().post(`${editPath}:commit`).query(retryQuery).reply(200, { id: 'edit' });
await expect(commit({ changesNotSentForReview })).resolves.toBeUndefined();
await expect(commit({ changesNotSentForReview, logger })).resolves.toBeUndefined();
expect(logger.info).toHaveBeenLastCalledWith(
changesNotSentForReview
? 'Google Play saved the release using its normal review process. Check its review and publishing status at https://play.google.com/console/.'
: 'Google Play saved the changes without sending them for review. Send them for review at https://play.google.com/console/.'
);
}
);
it.each([408, 429, 503])('does not retry an uncertain HTTP %s commit', async status => {
it.each([408, 429, 503])('retries a transient HTTP %s commit on the same edit', async status => {
mockToken();
api().post(`${editPath}:commit`).query(true).reply(status);
await expect(commit()).rejects.toMatchObject({ cause: expect.objectContaining({ status }) });
api().post(`${editPath}:commit`).query({}).reply(200, { id: 'edit' });
await expect(commit()).resolves.toBeUndefined();
});
it('preserves cancellation before commit is sent', async () => {
const controller = new AbortController();
@@ -74,6 +90,74 @@ it('preserves cancellation before commit is sent', async () => {
controller.abort(reason);
await expect(commit({ signal: controller.signal })).rejects.toBe(reason);
});
const pendingUploadMessage = 'Some of the Android App Bundle uploads are not completed yet.';
it.each([false, true])(
'waits for bundle processing without changing review settings (%s)',
async changesNotSentForReview => {
const logger = createMockLogger();
const query = changesNotSentForReview ? { changesNotSentForReview: 'true' } : {};
mockToken();
api()
.post(`${editPath}:commit`, {})
.query(query)
.reply(400, { error: { message: pendingUploadMessage } });
api().post(`${editPath}:commit`, {}).query(query).reply(200, { id: 'edit' });
await expect(commit({ changesNotSentForReview, logger })).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Google Play is still processing')
);
}
);
it('stops waiting for bundle processing after 14 attempts', async () => {
mockToken();
api()
.post(`${editPath}:commit`)
.query({})
.times(14)
.reply(400, { error: { message: pendingUploadMessage } });
const error = await commit().catch(error => error);
expect(GooglePlayUtils.mapGooglePlayError(error, packageName)).toMatchObject({
message: expect.stringContaining('Google Play did not finish processing'),
metadata: {
googlePlayRequestPath: `${editPath}:commit`,
googlePlayErrorResponse: JSON.stringify({ error: { message: pendingUploadMessage } }),
},
});
});
it('does not retry an unrelated commit precondition', async () => {
mockToken();
api()
.post(`${editPath}:commit`)
.query({})
.reply(400, { error: { message: 'Precondition check failed.' } });
await expect(commit()).rejects.toBeInstanceOf(GooglePlayApiError);
});
it('checks cancellation before sending a processing retry', async () => {
const controller = new AbortController();
const reason = new Error('cancelled while waiting');
const logger = createMockLogger();
jest.spyOn(logger, 'warn').mockImplementation(() => controller.abort(reason));
mockToken();
api()
.post(`${editPath}:commit`)
.query({})
.reply(400, { error: { message: pendingUploadMessage } });
await expect(commit({ signal: controller.signal, logger })).rejects.toBe(reason);
});
it('does not report success if a processing retry has no valid commit receipt', async () => {
mockToken();
api()
.post(`${editPath}:commit`)
.query({})
.reply(400, { error: { message: pendingUploadMessage } });
api().post(`${editPath}:commit`).query({}).reply(200, { id: 'different-edit' });
await expect(commit()).rejects.toBeInstanceOf(GooglePlayCommitOutcomeUnknownError);
});
it('preserves OAuth rejection before commit is sent', async () => {
nock('https://oauth2.googleapis.com').post('/token').reply(400, { error: 'invalid_grant' });
await expect(commit()).rejects.toBeInstanceOf(GooglePlayAuthRequestError);
@@ -86,21 +170,33 @@ it.each([
[403, 'wrong signing key', 'EAS_GOOGLE_PLAY_REQUEST_FAILED'],
[400, 'privacy policy required', 'EAS_GOOGLE_PLAY_PRIVACY_POLICY_REQUIRED'],
[400, 'Only releases with status draft may be created on draft app', 'EAS_GOOGLE_PLAY_DRAFT_APP'],
[400, 'Staged rollout is not permitted on draft app', 'EAS_GOOGLE_PLAY_DRAFT_APP'],
[409, 'edit invalid', 'EAS_GOOGLE_PLAY_EDIT_INVALID'],
[400, 'unknown track', 'EAS_GOOGLE_PLAY_INVALID_RELEASE'],
] as const)('keeps the Google error behind %s %s', (status, message, code) => {
const original = new GooglePlayApiError(status, message, []);
const mapped = GooglePlayUtils.mapGooglePlayError(original);
const mapped = GooglePlayUtils.mapGooglePlayError(original, packageName);
expect(mapped).toBeInstanceOf(UserError);
expect(mapped).toMatchObject({
errorCode: code,
message: expect.stringContaining(`Google Play said: ${message}`),
cause: original,
});
if (code === 'EAS_GOOGLE_PLAY_REQUEST_FAILED' || code === 'EAS_GOOGLE_PLAY_INVALID_RELEASE') {
expect((mapped as Error).message).toContain(message);
} else {
expect((mapped as Error).message).not.toContain(message);
}
expect((mapped as Error).message).toContain(`App: ${packageName}`);
expect((mapped as Error).message).toContain('https://play.google.com/console/');
expect((mapped as Error).message).not.toContain('Google Play error:');
if (code === 'EAS_GOOGLE_PLAY_DRAFT_APP') {
expect((mapped as Error).message).toContain('without a rollout');
expect((mapped as Error).message).toContain('releaseStatus: "draft" in eas.json');
}
});
it('maps a rejected OAuth key to a credential error', () => {
const original = new GooglePlayAuthRequestError(400, 'invalid_grant');
expect(GooglePlayUtils.mapGooglePlayError(original)).toMatchObject({
expect(GooglePlayUtils.mapGooglePlayError(original, packageName)).toMatchObject({
errorCode: 'EAS_GOOGLE_PLAY_INVALID_CREDENTIALS',
cause: original,
});
@@ -110,7 +206,7 @@ it.each([408, 429, 500, 503])('classifies temporary HTTP %s errors as system err
new GooglePlayApiError(status, 'temporary failure', []),
new GooglePlayAuthRequestError(status, 'temporarily_unavailable'),
]) {
const mapped = GooglePlayUtils.mapGooglePlayError(original);
const mapped = GooglePlayUtils.mapGooglePlayError(original, packageName);
expect(mapped).toBeInstanceOf(SystemError);
expect(mapped).toMatchObject({ cause: original });
expect((mapped as Error).message).not.toContain('settings');
@@ -118,5 +214,58 @@ it.each([408, 429, 500, 503])('classifies temporary HTTP %s errors as system err
});
it('keeps non-Google errors unchanged', () => {
const error = new SystemError('file failure');
expect(GooglePlayUtils.mapGooglePlayError(error)).toBe(error);
expect(GooglePlayUtils.mapGooglePlayError(error, packageName)).toBe(error);
});
it('keeps the Google Play error response in metadata, not in the message', async () => {
const response = {
error: {
code: 400,
message: 'Precondition check failed.',
status: 'FAILED_PRECONDITION',
details: [
{
'@type': 'type.googleapis.com/google.rpc.PreconditionFailure',
violations: [{ type: 'APP_SETUP', description: 'Complete the app setup.' }],
},
],
},
};
mockToken();
api().post(`${editPath}:commit`).query({}).reply(400, response);
const error = await commit().catch(error => error);
const mapped = GooglePlayUtils.mapGooglePlayError(error, packageName);
expect(mapped).toMatchObject({
errorCode: 'EAS_GOOGLE_PLAY_INVALID_RELEASE',
metadata: {
googlePlayRequestPath: `${editPath}:commit`,
googlePlayErrorResponse: JSON.stringify(response),
},
});
expect((mapped as Error).message).not.toContain('APP_SETUP');
});
it('keeps the last Google Play error response when the commit outcome is unknown', async () => {
mockToken();
api()
.post(`${editPath}:commit`)
.query({})
.times(6)
.reply(503, { error: { message: 'Backend error' } });
await expect(commit()).rejects.toMatchObject({
metadata: {
googlePlayRequestPath: `${editPath}:commit`,
googlePlayErrorResponse: JSON.stringify({ error: { message: 'Backend error' } }),
},
});
});
it('does not keep the upload session query or a non-JSON response', async () => {
const error = await GooglePlayApiError.fromResponseAsync(
new Response('<html>upload_id=SECRET</html>', {
status: 400,
url: 'https://androidpublisher.googleapis.com/upload/session?upload_id=SECRET',
})
);
expect(GooglePlayApiError.getErrorMetadata(error)).toEqual({
googlePlayRequestPath: '/upload/session',
googlePlayErrorResponse: undefined,
});
});
@@ -89,14 +89,16 @@ it('uploads aligned chunks and reports server-confirmed progress', async () => {
]);
});
it.each(['network', 429, 503])(
it.each(['network', 408, 429, 503])(
'recovers the accepted offset after a %s interruption',
async failure => {
mockToken();
mockStart();
const interrupted = uploadApi().put(session).matchHeader('content-range', 'bytes 0-11/12');
if (failure === 'network') {
interrupted.replyWithError('connection reset');
interrupted.replyWithError(
Object.assign(new Error('connection reset'), { code: 'ECONNRESET' })
);
} else {
interrupted.reply(failure as number, { error: { message: 'retry' } });
}
@@ -121,7 +123,7 @@ it('recovers a completed upload when the final upload response is lost', async (
uploadApi()
.put(session)
.matchHeader('content-length', '12')
.replyWithError('lost final response');
.replyWithError(Object.assign(new Error('lost final response'), { code: 'ECONNRESET' }));
status().reply(200, { versionCode: 42 });
await expect(upload()).resolves.toBe(42);
});
@@ -286,7 +288,7 @@ it('retries a network failure during session creation', async () => {
api()
.post(`/upload${editPath}/apks`)
.query({ uploadType: 'resumable' })
.replyWithError('connection reset');
.replyWithError(Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }));
mockStart();
uploadApi().put(session).reply(200, { versionCode: 42 });
await expect(upload()).resolves.toBe(42);
@@ -347,7 +349,9 @@ it.each([
mockStart();
const finalChunk = uploadApi().put(session).matchHeader('content-length', '12');
if (finalResponse === 'lost') {
finalChunk.replyWithError('lost final response');
finalChunk.replyWithError(
Object.assign(new Error('lost final response'), { code: 'ECONNRESET' })
);
} else {
finalChunk.reply(308, '', { Range: 'bytes=0-11' });
}
@@ -1,31 +1,15 @@
import { SystemError } from '@expo/eas-build-job';
import { graphql } from 'gql.tada';
import { CustomBuildContext } from '../../customBuildContext';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import {
ArtifactPutError,
ArtifactPutNetworkError,
type ArtifactSource,
type ArtifactUploadSession,
putArtifactWithRetriesAsync,
} from './artifactPut';
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
const CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
mutation CreateDeviceRunSessionArtifactUploadSession(
$deviceRunSessionId: ID!
$input: CreateDeviceRunSessionArtifactUploadSessionInput!
) {
deviceRunSession {
createArtifactUploadSession(deviceRunSessionId: $deviceRunSessionId, input: $input) {
uploadSession {
url
headers
}
}
}
}
`);
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
type ArtifactMetadata = {
deviceRunSessionId: string;
@@ -69,29 +53,29 @@ async function createDeviceRunSessionArtifactUploadSessionAsync(
ctx: CustomBuildContext,
{ deviceRunSessionId, artifactId, name, filename, kind, metadata, size }: ArtifactMetadata,
signal: AbortSignal
) {
const result = await ctx.graphqlClient
.mutation(
CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION,
): Promise<ArtifactUploadSession> {
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
let uploadSession: ArtifactUploadSession;
try {
uploadSession = await workerApi.createArtifactUploadSessionAsync(
{
deviceRunSessionId,
input: {
name,
filename,
...(kind !== undefined ? { kind } : {}),
...(metadata !== undefined ? { metadata } : {}),
size,
},
name,
filename,
...(kind !== undefined ? { kind } : {}),
...(metadata !== undefined ? { metadata } : {}),
size,
},
graphqlAbortContext(signal)
)
.toPromise();
signal.throwIfAborted();
if (result.error) {
{ signal }
);
} catch (err) {
signal.throwIfAborted();
throw new SystemError(
`Failed to create upload session for device run session artifact ${artifactId}: ${result.error.message}`,
{ cause: result.error }
`Failed to create upload session for device run session artifact ${artifactId}: ${
err instanceof Error ? err.message : String(err)
}`,
{ cause: err }
);
}
return result.data!.deviceRunSession.createArtifactUploadSession.uploadSession;
signal.throwIfAborted();
return uploadSession;
}
@@ -1,6 +1,5 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { graphql } from 'gql.tada';
import fs from 'node:fs';
import { StringDecoder } from 'node:string_decoder';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
@@ -10,25 +9,13 @@ import HttpLogStream from '../../logging/HttpLogStream';
import RemoteLoggerStream from '../../logging/RemoteLoggerStream';
import { Sentry } from '../../sentry';
import { type SignedUrl } from '../../storage/uploadWithSignedUrl';
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
const POLL_INTERVAL_MS = 1_000;
const UPLOAD_INTERVAL_MS = 5_000;
const EAS_LOGS_THREAD = 'session-events';
const EAS_LOGS_BUFFER_RETENTION_MS = 30_000;
const CREATE_DEVICE_RUN_SESSION_EVENT_LOG_UPLOAD_SESSION_MUTATION = graphql(`
mutation CreateDeviceRunSessionEventLogUploadSession($deviceRunSessionId: ID!) {
deviceRunSession {
createEventLogUploadSession(deviceRunSessionId: $deviceRunSessionId) {
uploadSession {
url
headers
}
}
}
}
`);
/**
* The normalized, producer-agnostic event shape uploaded to the API server.
* Every remote-session producer (agent-device, argent, ...) tails its own
@@ -349,22 +336,17 @@ async function createEventLogUploadSessionAsync(
ctx: CustomBuildContext,
deviceRunSessionId: string
): Promise<SignedUrl> {
const result = await ctx.graphqlClient
.mutation(CREATE_DEVICE_RUN_SESSION_EVENT_LOG_UPLOAD_SESSION_MUTATION, {
deviceRunSessionId,
})
.toPromise();
if (result.error) {
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
try {
return await workerApi.createEventLogUploadSessionAsync();
} catch (err) {
throw new SystemError(
`Failed to create device run session event log upload session: ${result.error.message}`,
{ cause: result.error }
`Failed to create device run session event log upload session: ${
err instanceof Error ? err.message : String(err)
}`,
{ cause: err }
);
}
const uploadSession = result.data!.deviceRunSession.createEventLogUploadSession.uploadSession;
return {
url: uploadSession.url,
headers: uploadSession.headers as Record<string, string>,
};
}
async function collectEventFileAsync({
@@ -2,7 +2,6 @@ import { type bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import spawn, { type SpawnOptions } from '@expo/turtle-spawn';
import { graphql } from 'gql.tada';
import fetch from 'node-fetch';
import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
@@ -12,24 +11,12 @@ import path from 'node:path';
import { type CustomBuildContext } from '../../customBuildContext';
import { killProcessGroup } from '../../utils/processes';
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
const PREVIEW_INTERVAL_MS = 60_000;
const PREVIEW_MAX_DIMENSION = 320;
const PREVIEW_QUALITY = 70;
const MAX_PREVIEW_SIZE_BYTES = 5 * 1024 * 1024;
const CREATE_PREVIEW_UPLOAD_SESSION_MUTATION = graphql(`
mutation CreateDeviceRunSessionPreviewUploadSession($deviceRunSessionId: ID!) {
deviceRunSession {
createPreviewUploadSession(deviceRunSessionId: $deviceRunSessionId) {
uploadSession {
url
headers
}
}
}
}
`);
/**
* node-fetch includes the request URL in its error messages, and this URL is a signed upload URL
* that is valid for the whole session. Replace such errors with one that names only the failure.
@@ -83,31 +70,7 @@ export function startDeviceRunSessionPreview({
throw new Error('Session preview is empty or exceeds the 5 MiB upload limit.');
}
if (!uploadSession) {
const result = await ctx.graphqlClient
.mutation(
CREATE_PREVIEW_UPLOAD_SESSION_MUTATION,
{ deviceRunSessionId },
{
// Keep the client's auth headers and combine cancellation after URQL sets its signal.
fetch: (url, options) =>
globalThis.fetch(url, {
...options,
signal: AbortSignal.any([signal, ...(options?.signal ? [options.signal] : [])]),
}),
}
)
.toPromise();
if (result.error) {
throw result.error;
}
const session = result.data?.deviceRunSession.createPreviewUploadSession.uploadSession;
if (!session) {
throw new Error('No session preview upload URL was returned.');
}
uploadSession = {
url: session.url,
headers: session.headers as Record<string, string>,
};
uploadSession = await createPreviewUploadSessionAsync(ctx, deviceRunSessionId, signal);
}
if (controller.signal.aborted) {
return;
@@ -140,6 +103,14 @@ export function startDeviceRunSessionPreview({
};
}
async function createPreviewUploadSessionAsync(
ctx: CustomBuildContext,
deviceRunSessionId: string,
signal: AbortSignal
): Promise<{ url: string; headers: Record<string, string> }> {
return await requireDeviceRunSessionWorkerApi(ctx).createPreviewUploadSessionAsync({ signal });
}
/**
* Homebrew's FFmpeg is built without libwebp, so macOS encodes previews with `cwebp` from
* Homebrew's webp package instead. Throws when it cannot be installed.
@@ -5,6 +5,7 @@ import spawn from '@expo/turtle-spawn';
import { createReadStream } from 'node:fs';
import { readFile, readdir, stat } from 'node:fs/promises';
import path from 'node:path';
import { Readable } from 'node:stream';
import limitFactory from 'promise-limit';
import { z } from 'zod';
@@ -140,9 +141,8 @@ export async function uploadDeviceRunSessionScreenRecordingsAsync(
recordings.map(recording =>
limit(async () => {
try {
const metadata = RecordingManifestSchema.parse(
JSON.parse(await readFile(path.join(recording.directory, 'session.json'), 'utf-8'))
);
const manifest = await readFile(path.join(recording.directory, 'session.json'));
const metadata = RecordingManifestSchema.parse(JSON.parse(manifest.toString('utf-8')));
if (path.basename(metadata.recording) !== metadata.recording) {
throw new Error('Recording filename must not contain a directory.');
}
@@ -186,6 +186,31 @@ export async function uploadDeviceRunSessionScreenRecordingsAsync(
openStream: () => createReadStream(recordingPath),
attempts: 3,
});
try {
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId,
artifactId: `${recordingId}-metadata`,
name: `${displayName} metadata`,
filename: `${recordingId}.json`,
kind: 'screen-recording-metadata',
metadata: {
__eas_type: 'screen-recording-metadata',
recordingFilename: `${recordingId}.mp4`,
udid: recording.udid,
},
size: manifest.length,
openStream: () => Readable.from(manifest),
attempts: 3,
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not upload device screen recording metadata', error);
logger.warn(
{ err: error },
`Could not upload screen recording metadata for ${recording.deviceName}.`
);
return false;
}
return true;
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
@@ -1,9 +1,8 @@
import type { bunyan } from '@expo/logger';
import { graphql } from 'gql.tada';
import { z } from 'zod';
import { BuildRuntimePlatform } from '@expo/steps';
import type { CustomBuildContext } from '../../customBuildContext';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
const SHORT_COMMIT_HASH_LENGTH = 7;
const MAX_APP_SLUG_LENGTH = 80;
@@ -15,36 +14,15 @@ const LEADING_OR_TRAILING_DOTS_AND_HYPHENS = /^[.-]+|[.-]+$/g;
// A full git commit hash: 40 hex characters for SHA-1 or 64 for SHA-256.
const FULL_GIT_COMMIT_HASH = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i;
const SCREENSHOT_SESSION_QUERY = graphql(`
query ScreenshotSession($deviceRunSessionId: ID!) {
deviceRunSessions {
byId(deviceRunSessionId: $deviceRunSessionId) {
platform
app { slug }
build {
id
gitCommitHash
app { slug }
}
}
}
}
`);
const AppSchema = z.object({ slug: z.string() });
const ScreenshotSessionSchema = z.object({
platform: z.enum(['IOS', 'ANDROID']),
app: AppSchema,
build: z
.object({
id: z.string(),
gitCommitHash: z.string().nullable(),
app: AppSchema,
})
.nullable(),
});
export type ScreenshotSession = z.infer<typeof ScreenshotSessionSchema>;
/**
* Session details used to name screenshots. `app` is always null: the device run session worker
* API does not return the app slug.
*/
export type ScreenshotSession = {
platform: 'IOS' | 'ANDROID';
app: { slug: string } | null;
build: { id: string; gitCommitHash: string | null; app: { slug: string } | null } | null;
};
export async function loadScreenshotSessionAsync(
ctx: CustomBuildContext,
@@ -52,17 +30,23 @@ export async function loadScreenshotSessionAsync(
logger: bunyan
): Promise<ScreenshotSession | null> {
try {
const result = await ctx.graphqlClient
.query(
SCREENSHOT_SESSION_QUERY,
{ deviceRunSessionId },
graphqlAbortContext(AbortSignal.timeout(SESSION_LOOKUP_TIMEOUT_MS))
)
.toPromise();
if (result.error) {
throw result.error;
}
return ScreenshotSessionSchema.parse(result.data?.deviceRunSessions.byId);
const signal = AbortSignal.timeout(SESSION_LOOKUP_TIMEOUT_MS);
const application = await requireDeviceRunSessionWorkerApi(ctx).getApplicationAsync({
signal,
});
return {
// www runs iOS sessions only on macOS workers and Android sessions only on Linux workers,
// so the worker API does not return the platform.
platform: ctx.runtimePlatform === BuildRuntimePlatform.DARWIN ? 'IOS' : 'ANDROID',
app: null,
build: application.build
? {
id: application.build.id,
gitCommitHash: application.build.gitCommitHash,
app: null,
}
: null,
};
} catch (err) {
logger.warn(
{ err, deviceRunSessionId },
@@ -86,18 +70,20 @@ export function screenshotArtifactDetails(
}
const app = session.build?.app ?? session.app;
const appSlug =
app.slug
.replace(FILENAME_UNSAFE_RUN, '-')
.replace(LEADING_OR_TRAILING_DOTS_AND_HYPHENS, '')
.slice(0, MAX_APP_SLUG_LENGTH) || 'app';
const platform = session.platform.toLowerCase();
const filenameParts = [appSlug, platform];
const metadata: Record<string, string> = {
__eas_type: 'screenshot',
appSlug: app.slug,
platform,
};
const filenameParts = [platform];
const metadata: Record<string, string> = { __eas_type: 'screenshot' };
// Without an app slug, the filename starts with the platform.
if (app) {
const appSlug =
app.slug
.replace(FILENAME_UNSAFE_RUN, '-')
.replace(LEADING_OR_TRAILING_DOTS_AND_HYPHENS, '')
.slice(0, MAX_APP_SLUG_LENGTH) || 'app';
filenameParts.unshift(appSlug);
metadata.appSlug = app.slug;
}
metadata.platform = platform;
if (session.build) {
metadata.buildId = session.build.id;
@@ -0,0 +1,326 @@
import { type EnvironmentSecret, EnvironmentSecretType, SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { z } from 'zod';
import { type CustomBuildContext } from '../../customBuildContext';
import { turtleFetch } from '../../utils/turtleFetch';
/**
* The environment secret that holds the device run session token. www gives it only to the job of
* one device run session, and the token names that session.
*/
export const DEVICE_RUN_SESSION_TOKEN_ENV = 'EAS_DEVICE_RUN_SESSION_TOKEN';
/**
* www reads the device run session token only from this header, and only on the
* `/v2/device-run-session-worker/*` routes. It is not an `Authorization` credential.
*/
export const DEVICE_RUN_SESSION_TOKEN_HEADER = 'expo-device-run-session-token';
const DEVICE_RUN_SESSION_WORKER_PATH = 'device-run-session-worker/';
const DeviceRunSessionStatusSchema = z.object({
id: z.string(),
// The status entity values that www returns.
status: z.enum(['new', 'queued', 'starting', 'in-progress', 'stopped', 'errored']),
});
const UploadSessionSchema = z.object({
uploadSession: z.object({
url: z.string(),
headers: z.record(z.string(), z.string()),
}),
});
const TurnIceServersSchema = z.object({
iceServers: z.array(
z.object({
urls: z.array(z.string()),
username: z.string().optional(),
credential: z.string().optional(),
})
),
});
const DeviceRunSessionApplicationSchema = z.object({
build: z
.object({
id: z.string(),
gitCommitHash: z.string().nullable(),
applicationArchiveUrl: z.string().nullable(),
})
.nullable(),
});
const ApiV2ErrorResponseSchema = z.object({
errors: z.array(z.object({ code: z.string().optional(), message: z.string() })).min(1),
});
export type DeviceRunSessionStatus = z.infer<typeof DeviceRunSessionStatusSchema>;
export type DeviceRunSessionUploadSession = z.infer<typeof UploadSessionSchema>['uploadSession'];
export type DeviceRunSessionTurnIceServers = z.infer<typeof TurnIceServersSchema>['iceServers'];
export type DeviceRunSessionApplication = z.infer<typeof DeviceRunSessionApplicationSchema>;
export type CreateDeviceRunSessionArtifactUploadSessionInput = {
name: string;
filename: string;
kind?: string | null;
size: number;
metadata?: Record<string, unknown> | null;
};
type RequestOptions = {
/** Stops the request when aborted. */
signal?: AbortSignal;
/** node-fetch timeout of each attempt, in milliseconds. */
timeoutMs?: number;
/** Additional attempts after a failed one. Defaults to none, so each call sends one request. */
retries?: number;
logger?: bunyan;
};
/**
* A request to the device run session worker API failed. The message names the route and the
* reason. It never includes the device run session token.
*/
export class DeviceRunSessionWorkerApiError extends Error {
constructor(
message: string,
/** The HTTP status of the response, or undefined when no response arrived. */
readonly status: number | undefined
) {
super(message);
this.name = 'DeviceRunSessionWorkerApiError';
}
}
/**
* Client for the www routes under `/v2/device-run-session-worker/`. These routes act only on the
* session that the token names, so no request carries a session ID.
*/
export class DeviceRunSessionWorkerApi {
constructor(
private readonly expoApiV2BaseUrl: string,
private readonly deviceRunSessionToken: string
) {}
public async getSessionAsync(options: RequestOptions = {}): Promise<DeviceRunSessionStatus> {
return await this.requestAsync('GET', 'session', DeviceRunSessionStatusSchema, options);
}
public async startAsync(
{ remoteConfig }: { remoteConfig: Record<string, unknown> },
options: RequestOptions = {}
): Promise<DeviceRunSessionStatus> {
return await this.requestAsync('POST', 'start', DeviceRunSessionStatusSchema, {
...options,
json: { remoteConfig },
});
}
public async stopAsync(options: RequestOptions = {}): Promise<DeviceRunSessionStatus> {
return await this.requestAsync('POST', 'stop', DeviceRunSessionStatusSchema, options);
}
public async createArtifactUploadSessionAsync(
input: CreateDeviceRunSessionArtifactUploadSessionInput,
options: RequestOptions = {}
): Promise<DeviceRunSessionUploadSession> {
const { uploadSession } = await this.requestAsync(
'POST',
'artifact-upload-sessions',
UploadSessionSchema,
{ ...options, json: input }
);
return uploadSession;
}
public async createEventLogUploadSessionAsync(
options: RequestOptions = {}
): Promise<DeviceRunSessionUploadSession> {
const { uploadSession } = await this.requestAsync(
'POST',
'event-log-upload-session',
UploadSessionSchema,
options
);
return uploadSession;
}
public async createPreviewUploadSessionAsync(
options: RequestOptions = {}
): Promise<DeviceRunSessionUploadSession> {
const { uploadSession } = await this.requestAsync(
'POST',
'preview-upload-session',
UploadSessionSchema,
options
);
return uploadSession;
}
public async fetchTurnIceServersAsync(
options: RequestOptions = {}
): Promise<DeviceRunSessionTurnIceServers> {
const { iceServers } = await this.requestAsync(
'POST',
'turn-ice-servers',
TurnIceServersSchema,
options
);
return iceServers;
}
public async getApplicationAsync(
options: RequestOptions = {}
): Promise<DeviceRunSessionApplication> {
return await this.requestAsync(
'GET',
'application',
DeviceRunSessionApplicationSchema,
options
);
}
private async requestAsync<T>(
method: 'GET' | 'POST',
route: string,
dataSchema: z.ZodType<T>,
{
signal,
timeoutMs,
retries = 0,
logger,
json,
}: RequestOptions & { json?: Record<string, unknown> }
): Promise<T> {
const routeDescription = `${method} /v2/${DEVICE_RUN_SESSION_WORKER_PATH}${route}`;
const url = new URL(
`${DEVICE_RUN_SESSION_WORKER_PATH}${route}`,
this.expoApiV2BaseUrl
).toString();
const responseResult = await asyncResult(
turtleFetch(url, method, {
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: this.deviceRunSessionToken },
...(json ? { json } : {}),
...(signal ? { signal } : {}),
...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}),
retries,
shouldThrowOnNotOk: false,
logger,
})
);
if (!responseResult.ok) {
const reason = responseResult.reason;
throw new DeviceRunSessionWorkerApiError(
`${routeDescription} failed before www responded: ${
reason instanceof Error ? reason.message : String(reason)
}. Check the network connection of the worker.`,
undefined
);
}
const response = responseResult.value;
const bodyResult = await asyncResult(response.json());
if (!response.ok) {
const apiError = bodyResult.ok ? ApiV2ErrorResponseSchema.safeParse(bodyResult.value) : null;
const apiErrorMessage = apiError?.success
? apiError.data.errors.map(error => error.message).join(' ')
: response.statusText;
throw new DeviceRunSessionWorkerApiError(
`${routeDescription} failed with status ${response.status}: ${apiErrorMessage}`,
response.status
);
}
if (!bodyResult.ok) {
throw new DeviceRunSessionWorkerApiError(
`${routeDescription} returned a response that is not JSON.`,
response.status
);
}
const parsed = z.object({ data: dataSchema }).safeParse(bodyResult.value);
if (!parsed.success) {
throw new DeviceRunSessionWorkerApiError(
`${routeDescription} returned an unexpected response: ${parsed.error.message}`,
response.status
);
}
return parsed.data.data;
}
}
/**
* Returns the device run session token when www gave one to this job, or null. Without a token,
* callers keep using the robot access token and the account-wide API.
*/
export function getDeviceRunSessionToken(
env: Record<string, string | undefined> | undefined
): string | null {
const token = env?.[DEVICE_RUN_SESSION_TOKEN_ENV];
return token ? token : null;
}
/**
* Returns the device run session token from the job's environment secrets, or null. It resolves
* duplicate names like the build context env does (the last secret wins), so the worker and the
* steps use the same token. A file secret is not a token.
*/
export function getDeviceRunSessionTokenFromEnvironmentSecrets(
environmentSecrets: EnvironmentSecret[] | undefined
): string | null {
const secret = [...(environmentSecrets ?? [])]
.reverse()
.find(({ name }) => name === DEVICE_RUN_SESSION_TOKEN_ENV);
if (!secret || secret.type !== EnvironmentSecretType.STRING || !secret.value) {
return null;
}
return secret.value;
}
/**
* Returns a client for the device run session worker API when this job has a device run session
* token, or null when it does not.
*/
export function getDeviceRunSessionWorkerApi(
ctx: Pick<CustomBuildContext, 'env' | 'expoApiV2BaseUrl'>
): DeviceRunSessionWorkerApi | null {
const token = getDeviceRunSessionToken(ctx.env);
if (!token) {
return null;
}
return new DeviceRunSessionWorkerApi(getExpoApiV2BaseUrl(ctx), token);
}
/**
* Returns the client for the device run session worker API, which every device run session job
* uses to call www about its session.
*/
export function requireDeviceRunSessionWorkerApi(
ctx: Pick<CustomBuildContext, 'env' | 'expoApiV2BaseUrl'>
): DeviceRunSessionWorkerApi {
const workerApi = getDeviceRunSessionWorkerApi(ctx);
if (!workerApi) {
throw new SystemError(
`This device run session job has no ${DEVICE_RUN_SESSION_TOKEN_ENV} secret. ` +
'www gives one to every device run session job, and the worker needs it to call the ' +
'device run session API. Start a new session, and contact support if it happens again.'
);
}
return workerApi;
}
function getExpoApiV2BaseUrl(ctx: Pick<CustomBuildContext, 'env' | 'expoApiV2BaseUrl'>): string {
if (ctx.expoApiV2BaseUrl) {
return ctx.expoApiV2BaseUrl;
}
if (ctx.env.__API_SERVER_URL) {
return new URL('/v2/', ctx.env.__API_SERVER_URL).toString();
}
throw new SystemError(
`${DEVICE_RUN_SESSION_TOKEN_ENV} is set, but the API server URL is not. ` +
'The worker cannot call the device run session API without it. ' +
'Check that the worker sets __API_SERVER_URL for device run session jobs.'
);
}
@@ -1,5 +1,6 @@
import { BuildPhaseResult, SystemError, UserError } from '@expo/eas-build-job';
import type { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import { randomBytes } from 'node:crypto';
import fs from 'node:fs';
@@ -29,16 +30,17 @@ import {
import { startDeviceRunSessionScreenshotsAsync } from './deviceRunSessionScreenshots';
import {
type DetachedProcessHandle,
type ServeSimApplicationOptions,
type ServeSimLaunchOptions,
ensureFfmpegInstalledOnceAsync,
fetchWebPreviewTurnArgsAsync,
findAvailablePortAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
isProcessRunning,
spawnDetached,
startNgrokTunnelAsync,
} from './remoteDeviceRunSession';
import { LOCAL_EGRESS_PROXY_HOST, readLocalEgressHandoffAsync } from './localEgress';
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
import {
IosSimulatorRecordingUtils,
@@ -46,6 +48,11 @@ import {
} from './IosSimulatorRecordingUtils';
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
import { startLogPhase } from '../../utils/logPhase';
import { runServeSimActionAsync, stageServeSimAppAsync } from './serveSimActions';
import { type ServeSimActivityPolling, startServeSimActivityPolling } from './serveSimActivity';
import { verifyLocalEgressGuardAsync } from './localEgressGuard';
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
import { createStartupTasks } from './startupTasks';
const WEB_PREVIEW_HOST = '127.0.0.1';
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
@@ -57,6 +64,8 @@ const EXPO_DEVICE_HUB_PACKAGE_NAME = 'expo-device-hub';
const EXPO_DEVICE_HUB_MAX_DIMENSION = '960';
const EXPO_DEVICE_HUB_VIDEO_BITRATE = '6000000';
const EXPO_DEVICE_HUB_VIDEO_FPS = '60';
// expo-device-hub's record of each running Hub, like serve-sim's: `server-<port>.json`.
export const EXPO_DEVICE_HUB_STATE_DIR = path.join(os.tmpdir(), 'expo-device-hub');
// On SIGTERM the Hub finalizes the recording itself, with this deadline before it force-exits,
// so a stop request that failed here still gets one more chance to write the MP4.
const EXPO_DEVICE_HUB_SIGTERM_FINALIZE_DEADLINE_MS = 60_000;
@@ -81,7 +90,8 @@ export function simulatorPreviewPageUrl(env: BuildStepEnv, subdomainId: string):
// Local website hosts are shared by local and staging simulator sessions.
const WEBSITE_DEV_ORIGINS = ['https://expo.test', 'https://*.expo.test'];
export function websiteOriginServeSimArgs(env: BuildStepEnv): string[] {
// serve-sim and expo-device-hub take the same website origin flags and values.
export function websiteOriginArgs(env: BuildStepEnv): string[] {
const origins = new Set([websiteOrigin(env)]);
if (!env.EXPO_LOCAL && env.EXPO_STAGING) {
origins.add('https://*.expo.dev');
@@ -94,6 +104,16 @@ export function websiteOriginServeSimArgs(env: BuildStepEnv): string[] {
return [...origins].flatMap(origin => ['--cors-origin', origin, '--frame-ancestor', origin]);
}
// Both hosts take the same flag; it only applies under --require-token, which both run with.
export function frameAncestorArgs(frameAncestors: readonly string[]): string[] {
return frameAncestors.flatMap(origin => ['--frame-ancestor', origin]);
}
// Both hosts take the same flag and match the same origin shapes.
export function corsOriginArgs(corsOrigins: readonly string[]): string[] {
return corsOrigins.flatMap(origin => ['--cors-origin', origin]);
}
function createServeSimPackageSpec(packageVersion: string | undefined): string {
return `${SERVE_SIM_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
}
@@ -106,24 +126,37 @@ export function createServeSimArgs({
port,
turnArgs = [],
websiteArgs = [],
frameAncestorArgs: frameArgs = [],
corsOriginArgs: corsArgs = [],
shareUrl,
disableShare = false,
packageVersion,
iosSimulatorUdid,
installAppPath,
launchAppIdentifier,
launchArgs = [],
openUrl,
networkCapture = false,
networkCaptureFields = [],
networkCaptureProxy,
}: {
port: number;
turnArgs?: string[];
websiteArgs?: string[];
frameAncestorArgs?: string[];
corsOriginArgs?: string[];
shareUrl?: string;
disableShare?: boolean;
packageVersion?: string;
iosSimulatorUdid?: string;
installAppPath?: string;
networkCapture?: boolean;
networkCaptureFields?: string[];
networkCaptureProxy?: string;
} & ServeSimLaunchOptions): string[] {
return [
createServeSimPackageSpec(packageVersion),
...(iosSimulatorUdid ? [iosSimulatorUdid] : []),
'--port',
String(port),
'--host',
@@ -143,8 +176,12 @@ export function createServeSimArgs({
SERVE_SIM_VIDEO_FPS,
...turnArgs,
...websiteArgs,
...frameArgs,
...corsArgs,
...(shareUrl ? ['--share-url', shareUrl] : []),
...(disableShare ? ['--disable-share'] : []),
...(launchAppIdentifier ? ['--launch-app-identifier', launchAppIdentifier] : []),
...(installAppPath ? ['--install-app-path', installAppPath] : []),
...launchArgs.flatMap(argument => ['--launch-arg', argument]),
...(openUrl ? ['--open-url', openUrl] : []),
// `--network-capture` also covers an already booted simulator. Fields are repeated, not
@@ -155,17 +192,24 @@ export function createServeSimArgs({
...networkCaptureFields.flatMap(field => ['--network-capture-field', field]),
]
: []),
...(networkCaptureProxy !== undefined ? ['--network-capture-proxy', networkCaptureProxy] : []),
];
}
export function createExpoDeviceHubArgs({
port,
turnArgs = [],
websiteArgs = [],
frameAncestorArgs: frameArgs = [],
corsOriginArgs: corsArgs = [],
packageVersion,
recordingDirectory,
}: {
port: number;
turnArgs?: string[];
websiteArgs?: string[];
frameAncestorArgs?: string[];
corsOriginArgs?: string[];
packageVersion?: string;
recordingDirectory?: string;
}): string[] {
@@ -175,6 +219,7 @@ export function createExpoDeviceHubArgs({
String(port),
'--host',
WEB_PREVIEW_HOST,
'--require-token',
'--platform',
'android',
'--transport',
@@ -193,6 +238,9 @@ export function createExpoDeviceHubArgs({
'--hide-boot-device',
...(recordingDirectory ? ['--android-recording-directory', recordingDirectory] : []),
...turnArgs,
...websiteArgs,
...frameArgs,
...corsArgs,
];
}
@@ -201,25 +249,39 @@ const WebPreviewReadyResponseSchema = z.object({
device: z.string(),
});
// Startup output includes bearer URLs (or a token field with --quiet).
function redactPreviewTokens(output: string): string {
return output
.replace(/([?&#]token=)[^\s&"'<>]+/g, '$1[redacted]')
.replace(/("token"\s*:\s*")[^"]*/g, '$1[redacted]');
}
export async function waitForWebPreviewReadyAsync({
previewServer,
serverName,
port,
timeoutMs,
startupTimeoutMs,
signal,
}: {
previewServer: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
serverName: string;
port: number;
timeoutMs: number;
startupTimeoutMs?: number;
signal?: AbortSignal;
}): Promise<string> {
const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`;
const deadline = Date.now() + timeoutMs;
let deadline = Date.now() + (startupTimeoutMs ?? timeoutMs);
// Preserve the boot budget until the host starts answering.
let waitingForHost = startupTimeoutMs !== undefined;
let lastError: unknown;
while (Date.now() < deadline) {
signal?.throwIfAborted();
if (previewServer.pid !== undefined && !isProcessRunning(previewServer.pid)) {
throw new SystemError(
`${serverName} exited before becoming ready. Last output:\n${
previewServer.getOutput() || '<empty>'
redactPreviewTokens(previewServer.getOutput()) || '<empty>'
}`
);
}
@@ -227,21 +289,52 @@ export async function waitForWebPreviewReadyAsync({
const response = await turtleFetch(readyUrl, 'GET', {
retries: 0,
timeout: 2_000,
shouldThrowOnNotOk: false,
signal,
});
if (!response.ok) {
throw new SystemError(`${serverName} readiness returned HTTP ${response.status}`);
}
const ready = WebPreviewReadyResponseSchema.parse(await response.json());
signal?.throwIfAborted();
return ready.device;
} catch (error) {
const connectionRefused =
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'ECONNREFUSED';
if (waitingForHost && !connectionRefused) {
waitingForHost = false;
deadline = Math.min(deadline, Date.now() + timeoutMs);
}
lastError = error;
}
signal?.throwIfAborted();
await sleepAsync(WEB_PREVIEW_READY_POLL_INTERVAL_MS);
}
signal?.throwIfAborted();
throw new SystemError(
`Timed out waiting for ${serverName} readiness at ${readyUrl}${
lastError instanceof Error ? `: ${lastError.message}` : ''
}. Last output:\n${previewServer.getOutput() || '<empty>'}`
}. Last output:\n${redactPreviewTokens(previewServer.getOutput()) || '<empty>'}`
);
}
export async function readExpoDeviceHubPreviewTokenAsync(
port: number,
stateDir: string = EXPO_DEVICE_HUB_STATE_DIR
): Promise<string | undefined> {
try {
const state = JSON.parse(
await fs.promises.readFile(path.join(stateDir, `server-${port}.json`), 'utf-8')
) as { token?: unknown };
return typeof state.token === 'string' ? state.token : undefined;
} catch {
return undefined;
}
}
export async function readServeSimPreviewTokenAsync(
udid: string,
stateDir: string = SERVE_SIM_STATE_DIR
@@ -252,6 +345,8 @@ export async function readServeSimPreviewTokenAsync(
export type DeviceWebPreview = {
previewPageUrl: string;
/** Human-facing link, absent when a private job has no authenticated URL. */
sharePageUrl?: string;
apiUrl: string;
previewToken?: string;
/** Closes this tunnel only. The session host and recording remain running. */
@@ -259,7 +354,9 @@ export type DeviceWebPreview = {
};
export type DeviceSessionHost = {
openPreviewAsync(options: { baseDomain: string }): Promise<DeviceWebPreview>;
openPreviewAsync(options: { baseDomain: string; authtoken: string }): Promise<DeviceWebPreview>;
/** Last observed preview input. Always `undefined` on Android, which keeps no event log. */
getLastPreviewActivityAt(): Date | undefined;
/**
* Terminal and idempotent. Never rejects: a failed finalization, host stop or upload is logged
* and reported, so callers need no error handling around it.
@@ -280,7 +377,20 @@ type DeviceSessionHostOptions = {
logger: bunyan;
timeoutMs: number;
separateLogPhase?: boolean;
startupTimeoutMs?: number;
signal?: AbortSignal;
packageVersion?: string;
disableShare?: boolean;
privatePreviewUrl?: string;
/** Origins allowed to embed the preview host, in addition to the website origins. */
frameAncestors?: string[];
/** Origins whose pages may call the preview host's API, in addition to the website origins. */
corsOrigins?: string[];
iosSimulatorUdid?: string;
installAppPath?: string;
bootEnv?: Record<string, string>;
application?: Promise<ServeSimApplicationOptions | void>;
onStartupError?: (error: unknown) => void;
networkCapture?: boolean;
networkCaptureFields?: string[];
} & ServeSimLaunchOptions;
@@ -317,17 +427,38 @@ async function startDeviceSessionHostInternalAsync(
env,
logger,
timeoutMs,
startupTimeoutMs,
signal,
packageVersion,
iosSimulatorUdid,
installAppPath,
application,
onStartupError,
disableShare = false,
privatePreviewUrl,
frameAncestors = [],
corsOrigins = [],
launchAppIdentifier,
launchArgs,
openUrl,
networkCapture = false,
networkCaptureFields = [],
bootEnv = {},
}: DeviceSessionHostOptions,
onFinished: (successful: boolean) => void,
artifactLogger: bunyan
): Promise<DeviceSessionHost> {
signal?.throwIfAborted();
const isAndroid = runtimePlatform === BuildRuntimePlatform.LINUX;
if (
Object.keys(bootEnv).length > 0 &&
(runtimePlatform !== BuildRuntimePlatform.DARWIN || !iosSimulatorUdid)
) {
throw new UserError(
'EAS_IOS_SIMULATOR_BOOT_INVALID_INPUT',
'Simulator boot options require an explicit iOS Simulator.'
);
}
// Unreachable from the step functions, which reject a non-Darwin launch while parsing.
// Kept because this function is exported and expo-device-hub cannot launch.
if (isAndroid && networkCapture) {
@@ -342,6 +473,9 @@ async function startDeviceSessionHostInternalAsync(
`Cannot launch ${launchAppIdentifier}: an application launch runs through serve-sim on an iOS simulator, and this session runs expo-device-hub on ${runtimePlatform}.`
);
}
if (application && (isAndroid || !iosSimulatorUdid)) {
throw new SystemError('Deferred startup requires an explicit iOS Simulator.');
}
if (isAndroid) {
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
}
@@ -353,14 +487,20 @@ async function startDeviceSessionHostInternalAsync(
env,
}
: null;
startupTimeoutMs ??= iosSimulatorUdid ? 30 * 60_000 : undefined;
const startup = createStartupTasks(logger, signal);
const serveSimTmpdir = env.TMPDIR ?? os.tmpdir();
const serveSimStateDir = env.SERVE_SIM_STATE_DIR ?? SERVE_SIM_STATE_DIR;
const subdomainId = randomBytes(16).toString('hex');
const previewPageUrl = simulatorPreviewPageUrl(env, subdomainId);
const sharePageUrl = disableShare ? privatePreviewUrl : previewPageUrl;
const port = await findAvailablePortAsync();
const serverName = isAndroid ? 'expo-device-hub' : 'serve-sim';
const packageSpec = isAndroid
? createExpoDeviceHubPackageSpec(packageVersion)
: createServeSimPackageSpec(packageVersion);
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { logger });
const localEgress = !isAndroid ? await readLocalEgressHandoffAsync() : null;
const previewExec = resolvePackageExec(
resolveConfiguredPackageManager(env, PackageManager.NPM),
isAndroid
@@ -369,18 +509,30 @@ async function startDeviceSessionHostInternalAsync(
turnArgs,
packageVersion,
recordingDirectory: recording?.directory,
websiteArgs: websiteOriginArgs(env),
frameAncestorArgs: frameAncestorArgs(frameAncestors),
corsOriginArgs: corsOriginArgs(corsOrigins),
})
: createServeSimArgs({
port,
turnArgs,
packageVersion,
websiteArgs: websiteOriginServeSimArgs(env),
shareUrl: previewPageUrl,
launchAppIdentifier,
launchArgs,
openUrl,
iosSimulatorUdid,
websiteArgs: websiteOriginArgs(env),
frameAncestorArgs: frameAncestorArgs(frameAncestors),
corsOriginArgs: corsOriginArgs(corsOrigins),
// serve-sim adds its bearer token to --share-url; private links are for worker logs only.
shareUrl: disableShare ? undefined : previewPageUrl,
disableShare,
installAppPath: iosSimulatorUdid ? undefined : installAppPath,
launchAppIdentifier: iosSimulatorUdid ? undefined : launchAppIdentifier,
launchArgs: iosSimulatorUdid ? [] : launchArgs,
openUrl: iosSimulatorUdid ? undefined : openUrl,
networkCapture,
networkCaptureFields,
networkCaptureProxy: localEgress
? `http://${LOCAL_EGRESS_PROXY_HOST}:${localEgress.port}`
: undefined,
})
);
logger.info(
@@ -397,13 +549,19 @@ async function startDeviceSessionHostInternalAsync(
});
let previewServer: DetachedProcessHandle;
try {
signal?.throwIfAborted();
previewServer = spawnDetached({
command: previewExec.command,
args: previewExec.args,
env: {
...env,
...bootEnv,
...(isAndroid ? {} : { TMPDIR: serveSimTmpdir, SERVE_SIM_STATE_DIR: serveSimStateDir }),
EXPO_DEVICE_HUB_SCREENSHOT_DIRECTORY: screenshots.directory,
...(recording ? { EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN: recording.controlToken } : {}),
// The Hub would resolve its state directory from the step's env, which may set its own
// TMPDIR or state directory, so it writes its token where the host reads it below.
...(isAndroid ? { EXPO_DEVICE_HUB_STATE_DIR } : {}),
},
// Only an Android host records through expo-device-hub; serve-sim records on iOS.
stopGracePeriodMs: recording
@@ -418,6 +576,10 @@ async function startDeviceSessionHostInternalAsync(
throw error;
}
let stagedAppDirectory: string | undefined;
let beforeLaunch: ReturnType<typeof asyncResult<void>> | undefined;
const readinessController = new AbortController();
const readinessSignal = AbortSignal.any([startup.signal, readinessController.signal]);
let previewToken: string | undefined;
let previewTask: Promise<DeviceWebPreview> | null = null;
let finishTask: Promise<void> | null = null;
@@ -426,9 +588,10 @@ async function startDeviceSessionHostInternalAsync(
const previewSetupController = new AbortController();
let previewSetupTask: Promise<void> | undefined;
let previewFailed = false;
let previewActivity: ServeSimActivityPolling | null = null;
const host: DeviceSessionHost = {
openPreviewAsync({ baseDomain }) {
openPreviewAsync({ baseDomain, authtoken }) {
if (finishTask) {
return Promise.reject(
new SystemError('Cannot open a preview after session host finalization.')
@@ -443,7 +606,7 @@ async function startDeviceSessionHostInternalAsync(
subdomainPrefix: 'web-preview',
subdomainId,
baseDomain,
authtoken: getNgrokAuthtokenOrThrow(env),
authtoken,
logger,
});
if (finishTask) {
@@ -457,6 +620,7 @@ async function startDeviceSessionHostInternalAsync(
previewFailed = false;
return {
previewPageUrl,
sharePageUrl,
apiUrl: tunnel.url,
previewToken,
closeAsync() {
@@ -482,13 +646,18 @@ async function startDeviceSessionHostInternalAsync(
});
return opening;
},
getLastPreviewActivityAt() {
return previewActivity?.getLastActivityObservedAt();
},
finishAsync() {
readinessController.abort(new Error('Simulator preview ended.'));
return (finishTask ??= finishDeviceSessionHostAsync(ctx, {
previewTask,
stopSessionPreviewAsync: async () => {
previewSetupController.abort();
await previewSetupTask;
await sessionPreview?.stopAsync();
await previewActivity?.stopAsync();
},
previewServer,
screenshots,
@@ -499,33 +668,126 @@ async function startDeviceSessionHostInternalAsync(
logger,
artifactLogger,
onStopped: successful => onFinished(successful && !previewFailed),
}).catch(err => {
artifactLogger.warn({ err }, 'Could not finish the simulator preview.');
onFinished(false);
}));
})
.finally(async () => {
await beforeLaunch;
if (stagedAppDirectory) {
await fs.promises.rm(stagedAppDirectory, { recursive: true, force: true });
}
})
.catch(err => {
artifactLogger.warn({ err }, 'Could not finish the simulator preview.');
onFinished(false);
}));
},
};
try {
logger.info(`Waiting for ${serverName} to become ready.`);
const device = await waitForWebPreviewReadyAsync({
previewServer,
serverName,
port,
timeoutMs,
});
hostReady = true;
if (!isAndroid) {
previewToken = await readServeSimPreviewTokenAsync(device);
if (!previewToken) {
throw new SystemError(
`serve-sim became ready but wrote no session token for device ${device}. The preview is ` +
'on a public tunnel and would be reachable without one, so the session cannot continue. ' +
'This usually means the state file was not written as expected; retry the session, and ' +
'report it if it repeats.'
const readiness = asyncResult(
waitForWebPreviewReadyAsync({
previewServer,
serverName,
port,
timeoutMs,
startupTimeoutMs,
signal: readinessSignal,
}).catch(error => {
startup.abort(error);
throw error;
})
);
if (iosSimulatorUdid) {
previewToken = await waitForServeSimControlAsync({
device: iosSimulatorUdid,
port,
previewServer,
stateDir: serveSimStateDir,
timeoutMs: startupTimeoutMs ?? timeoutMs,
signal: readinessSignal,
});
secrets.push(previewToken);
if (Object.keys(bootEnv).length > 0) {
await startup.untilAborted(
verifyLocalEgressGuardAsync({ udid: iosSimulatorUdid as IosSimulatorUuid, env, logger })
);
}
secrets.push(previewToken);
signal?.throwIfAborted();
beforeLaunch = asyncResult(
startup.untilAborted(
startup.run('Simulator setup', async taskLogger => {
try {
await IosSimulatorUtils.disableApsdAsync({
udid: iosSimulatorUdid as IosSimulatorUuid,
env,
});
} catch (err) {
startup.signal.throwIfAborted();
taskLogger.warn({ err }, 'Failed to disable apsd in the Simulator.');
}
startup.signal.throwIfAborted();
})
)
);
const app = (application ? await startup.untilAborted(application) : undefined) ?? {
installAppPath,
launchAppIdentifier,
launchArgs,
openUrl,
};
signal?.throwIfAborted();
const action = (name: string, params: Record<string, unknown>) =>
runServeSimActionAsync({
port,
token: previewToken!,
action: name,
params,
timeoutMs: startupTimeoutMs ?? timeoutMs,
signal: startup.signal,
});
if (app.installAppPath) {
const staged = await stageServeSimAppAsync(app.installAppPath, serveSimTmpdir);
stagedAppDirectory = staged.directory;
signal?.throwIfAborted();
await action('app.install', { udid: iosSimulatorUdid, path: staged.path });
}
(await beforeLaunch)?.enforceValue();
if (app.launchAppIdentifier) {
signal?.throwIfAborted();
await action('app.launch', {
udid: iosSimulatorUdid,
bundleId: app.launchAppIdentifier,
launchArgs: app.launchArgs ?? [],
openUrl: app.openUrl,
});
}
}
logger.info(`Waiting for ${serverName} to become ready.`);
const device = (await readiness).enforceValue();
if (iosSimulatorUdid && device.toLowerCase() !== iosSimulatorUdid.toLowerCase()) {
throw new SystemError(
`serve-sim became ready on ${device}, but this session requested ${iosSimulatorUdid}.`
);
}
hostReady = true;
// Both servers record their session token in a state file once they are ready.
if (isAndroid) {
previewToken = await readExpoDeviceHubPreviewTokenAsync(port, EXPO_DEVICE_HUB_STATE_DIR);
} else {
previewToken ??= await readServeSimPreviewTokenAsync(device, serveSimStateDir);
}
if (!previewToken) {
throw new SystemError(
`${serverName} became ready but wrote no session token for device ${device}. The ` +
'preview is on a public tunnel and would be reachable without one, so the session ' +
'cannot continue. This usually means the state file was not written as expected; ' +
'retry the session, and report it if it repeats.'
);
}
secrets.push(previewToken);
if (!isAndroid) {
IosSimulatorRecordingUtils.useServeSimPackage(packageSpec);
if (!finishTask) {
previewActivity = startServeSimActivityPolling({ port, token: previewToken, logger });
}
}
// Android installed FFmpeg before launching the host. The optional thumbnail must not delay
// readiness on macOS, so it starts after its encoder is installed there.
@@ -556,13 +818,56 @@ async function startDeviceSessionHostInternalAsync(
logger.warn({ err }, 'Could not start refreshing the session preview.');
}
});
signal?.throwIfAborted();
return host;
} catch (error) {
onStartupError?.(error);
await host.finishAsync();
throw error;
}
}
async function waitForServeSimControlAsync({
device,
port,
stateDir,
previewServer,
timeoutMs,
signal,
}: {
device: string;
port: number;
stateDir: string;
previewServer: DetachedProcessHandle;
timeoutMs: number;
signal?: AbortSignal;
}): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
signal?.throwIfAborted();
if (
previewServer.getExitError() !== undefined ||
(previewServer.pid !== undefined && !isProcessRunning(previewServer.pid))
) {
throw new SystemError(
`serve-sim exited before its control API became ready. ${previewServer.getOutput()}`
);
}
const servers = await readServeSimServersAsync(stateDir);
const server = servers.find(
server =>
server.udid.toLowerCase() === device.toLowerCase() &&
server.url === `http://${WEB_PREVIEW_HOST}:${port}`
);
if (server?.token) {
return server.token;
}
await sleepAsync(WEB_PREVIEW_READY_POLL_INTERVAL_MS);
}
signal?.throwIfAborted();
throw new SystemError('Timed out waiting for serve-sim control readiness.');
}
async function finishDeviceSessionHostAsync(
ctx: CustomBuildContext,
{
@@ -642,7 +947,11 @@ async function finishDeviceSessionHostAsync(
if (recording && captured && (finalization === 'failed' || !uploaded)) {
// The Hub reports capture failures on stderr; the stop route answers with only a summary.
artifactLogger.warn(
{ hostOutput: previewServer.getOutput().slice(-HOST_OUTPUT_TAIL_CHARS) || '<empty>' },
{
hostOutput:
redactPreviewTokens(previewServer.getOutput()).slice(-HOST_OUTPUT_TAIL_CHARS) ||
'<empty>',
},
'Session host output around the recording failure.'
);
}
@@ -0,0 +1,41 @@
import { UserError } from '@expo/eas-build-job';
import { type BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import path from 'node:path';
export async function readIosApplicationIdentifierAsync({
artifactPath,
env,
}: {
artifactPath: string;
env: BuildStepEnv;
}): Promise<string> {
const artifactStat = await fs.promises.stat(artifactPath).catch(err => {
throw new UserError(
'EAS_INSTALL_BUILD_INVALID_ARTIFACT',
`Build artifact does not exist at ${artifactPath}.`,
{ cause: err }
);
});
if (path.extname(artifactPath) !== '.app' || !artifactStat.isDirectory()) {
throw new UserError(
'EAS_INSTALL_BUILD_INVALID_ARTIFACT',
'iOS Simulator sessions require a .app build artifact.'
);
}
const infoPlistPath = path.join(artifactPath, 'Info.plist');
const { stdout } = await spawn(
'plutil',
['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', infoPlistPath],
{ stdio: 'pipe', env }
);
const applicationIdentifier = stdout.trim();
if (!applicationIdentifier) {
throw new UserError(
'EAS_INSTALL_BUILD_MISSING_IDENTIFIER',
`Could not read CFBundleIdentifier from ${infoPlistPath}.`
);
}
return applicationIdentifier;
}
@@ -0,0 +1,110 @@
import { UserError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { minBy } from 'lodash';
import {
type IosSimulatorName,
IosSimulatorUtils,
type IosSimulatorUuid,
} from '../../utils/IosSimulatorUtils';
import {
resolveLocalEgressServeSimBootEnvironmentAsync,
startLocalEgressGuardRelayAsync,
} from './localEgressGuard';
import { readIosApplicationIdentifierAsync } from './iosAppArtifact';
import {
type ServeSimLaunchOptions,
parseServeSimLaunchInputs,
validateServeSimLaunchOptions,
} from './remoteDeviceRunSession';
export type IosSessionStartup = {
iosSimulatorUdid: string;
installAppPath?: string;
bootEnv?: Record<string, string>;
};
export async function resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator,
deviceIdentifier,
installAppPath,
launchAppIdentifier,
launchArgs,
openUrl,
env,
logger,
signal,
}: {
runtimePlatform: BuildRuntimePlatform;
bootSimulator?: boolean;
deviceIdentifier?: string;
installAppPath?: string;
launchAppIdentifier?: unknown;
launchArgs?: unknown;
openUrl?: unknown;
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
}): Promise<{ launch: ServeSimLaunchOptions; iosStartup?: IosSessionStartup }> {
signal?.throwIfAborted();
if ((bootSimulator || installAppPath) && runtimePlatform !== BuildRuntimePlatform.DARWIN) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
'install_app_path is only supported for iOS Simulator sessions.'
);
}
const applicationIdentifier =
launchAppIdentifier ??
(installAppPath
? await readIosApplicationIdentifierAsync({ artifactPath: installAppPath, env })
: undefined);
const launch = parseServeSimLaunchInputs(
{ launchAppIdentifier: applicationIdentifier, launchArgs, openUrl },
{ runtimePlatform }
);
if (
runtimePlatform !== BuildRuntimePlatform.DARWIN ||
(!bootSimulator && !deviceIdentifier && !installAppPath)
) {
return { launch };
}
validateServeSimLaunchOptions(launch);
signal?.throwIfAborted();
let selectedIdentifier = (deviceIdentifier ?? undefined) as
| IosSimulatorUuid
| IosSimulatorName
| undefined;
if (selectedIdentifier === undefined) {
const devices = await IosSimulatorUtils.getAvailableDevicesAsync({ env, filter: 'available' });
selectedIdentifier = minBy(
devices.filter(device => device.name.startsWith('iPhone')),
device => device.name.length
)?.udid;
}
if (!selectedIdentifier) {
throw new Error('Could not find an iPhone among available simulator devices.');
}
const iosSimulatorUdid = (
await IosSimulatorUtils.resolveUdidAsync({ deviceIdentifier: selectedIdentifier, env })
).toUpperCase() as IosSimulatorUuid;
const bootEnv = await resolveLocalEgressServeSimBootEnvironmentAsync();
signal?.throwIfAborted();
if (bootEnv) {
const device = await IosSimulatorUtils.getDeviceAsync({ udid: iosSimulatorUdid, env });
signal?.throwIfAborted();
if (device && device.state !== 'Shutdown') {
logger.info(`Shutting down ${iosSimulatorUdid} to apply the local egress guard at boot.`);
await spawn('xcrun', ['simctl', 'shutdown', iosSimulatorUdid], { env, logger, signal });
}
signal?.throwIfAborted();
await startLocalEgressGuardRelayAsync({ logger });
}
signal?.throwIfAborted();
return {
launch,
iosStartup: { iosSimulatorUdid, installAppPath, bootEnv: bootEnv ?? undefined },
};
}
@@ -110,6 +110,18 @@ export async function resolveEgressGuardCheckAsync(
return await resolvePackagedFileAsync(binDir, EGRESS_GUARD_CHECK_FILE);
}
async function requireEgressGuardCheckAsync(checkPath?: string | null): Promise<string> {
const resolvedCheck = checkPath === undefined ? await resolveEgressGuardCheckAsync() : checkPath;
if (!resolvedCheck) {
throw new SystemError(
'The local egress guard self-check is not available on this device host, so this session cannot ' +
'verify that the guard is in effect. The device host image is missing bin/egress-guard-check; ' +
'this is a service problem, please contact support.'
);
}
return resolvedCheck;
}
/**
* Turns guard events into session log lines: one line the first time a
* process reaches a destination through a given call, counts after that, and
@@ -278,6 +290,14 @@ export class GuardLogTailer {
}
}
type LocalEgressBootEnvironmentOptions = {
handoffPath?: string;
/** Explicit library path, `null` for "not available"; resolved from the package when omitted. */
libraryPath?: string | null;
logPath?: string;
mode?: EgressGuardMode;
};
/**
* The environment the simulator's launchd must have from its first process:
* the guard and the proxy variables. Pass it to `IosSimulatorUtils.bootAsync`,
@@ -291,13 +311,7 @@ export async function resolveLocalEgressBootEnvironmentAsync({
libraryPath,
logPath = LOCAL_EGRESS_GUARD_LOG_PATH,
mode = 'block',
}: {
handoffPath?: string;
/** Explicit library path, `null` for "not available"; resolved from the package when omitted. */
libraryPath?: string | null;
logPath?: string;
mode?: EgressGuardMode;
} = {}): Promise<Record<string, string> | null> {
}: LocalEgressBootEnvironmentOptions = {}): Promise<Record<string, string> | null> {
const handoff = await readLocalEgressHandoffAsync(handoffPath);
if (!handoff) {
return null;
@@ -317,6 +331,27 @@ export async function resolveLocalEgressBootEnvironmentAsync({
};
}
/** Supply the guard at boot and require its self-check before starting serve-sim. */
export async function resolveLocalEgressServeSimBootEnvironmentAsync({
checkPath,
...options
}: LocalEgressBootEnvironmentOptions & {
checkPath?: string | null;
} = {}): Promise<Record<string, string> | null> {
const environment = await resolveLocalEgressBootEnvironmentAsync(options);
if (!environment) {
return null;
}
await requireEgressGuardCheckAsync(checkPath);
const { DYLD_INSERT_LIBRARIES, ...childEnvironment } = environment;
return {
SERVE_SIM_ADDITIONAL_DYLIBS: DYLD_INSERT_LIBRARIES,
...Object.fromEntries(
Object.entries(childEnvironment).map(([name, value]) => [`SIMCTL_CHILD_${name}`, value])
),
};
}
type ActiveRelay = {
source: { stopAsync: () => Promise<void> };
relay: GuardEventRelay;
@@ -325,6 +360,44 @@ type ActiveRelay = {
};
const activeRelays = new Map<string, ActiveRelay>();
/** Start reporting guard events without changing the Simulator's launchd environment. */
export async function startLocalEgressGuardRelayAsync({
logger,
logPath = LOCAL_EGRESS_GUARD_LOG_PATH,
tailIntervalMs,
}: {
logger: bunyan;
logPath?: string;
tailIntervalMs?: number;
}): Promise<void> {
try {
await fs.promises.mkdir(path.dirname(logPath), { recursive: true });
await fs.promises.appendFile(logPath, '');
} catch (err) {
logger.warn(
{ err },
`Local egress guard: could not create ${logPath}, so refused connections will not be reported in this log. They are still refused.`
);
return;
}
if (activeRelays.has(logPath)) {
return;
}
const relay = new GuardEventRelay(logger);
const tailer = new GuardLogTailer({
path: logPath,
intervalMs: tailIntervalMs,
onLine: line => {
const event = parseGuardLogLine(line);
if (event) {
relay.handle(event);
}
},
});
tailer.start();
activeRelays.set(logPath, { source: tailer, relay });
}
/**
* Report events from another source, such as the Android emulator's packet
* fence, through the same session log lines, step rebinding and summary as the
@@ -396,17 +469,7 @@ export async function installLocalEgressGuardAsync({
await logAlreadyRunningProcessesAsync({ env, logger });
let logWritable = true;
try {
await fs.promises.mkdir(path.dirname(logPath), { recursive: true });
await fs.promises.appendFile(logPath, '');
} catch (err) {
logWritable = false;
logger.warn(
{ err },
`Local egress guard: could not create ${logPath}, so refused connections will not be reported in this log. They are still refused.`
);
}
await startLocalEgressGuardRelayAsync({ logger, logPath, tailIntervalMs });
try {
await IosSimulatorUtils.setLaunchdEnvironmentAsync({
@@ -423,22 +486,6 @@ export async function installLocalEgressGuardAsync({
);
}
if (logWritable && !activeRelays.has(logPath)) {
const relay = new GuardEventRelay(logger);
const tailer = new GuardLogTailer({
path: logPath,
intervalMs: tailIntervalMs,
onLine: line => {
const event = parseGuardLogLine(line);
if (event) {
relay.handle(event);
}
},
});
tailer.start();
activeRelays.set(logPath, { source: tailer, relay });
}
logger.info(
`Local egress guard installed in the Simulator (mode ${mode}): connections that bypass the system proxy are ${
mode === 'block' ? 'refused' : 'observed'
@@ -658,14 +705,7 @@ export async function verifyLocalEgressGuardAsync({
/** Explicit check binary path, `null` for "not available"; resolved from the package when omitted. */
checkPath?: string | null;
}): Promise<void> {
const resolvedCheck = checkPath === undefined ? await resolveEgressGuardCheckAsync() : checkPath;
if (!resolvedCheck) {
throw new SystemError(
'The local egress guard self-check is not available on this device host, so this session cannot ' +
'verify that the guard is in effect. The device host image is missing bin/egress-guard-check; ' +
'this is a service problem, please contact support.'
);
}
const resolvedCheck = await requireEgressGuardCheckAsync(checkPath);
let output: string;
try {
const result = await spawn('xcrun', ['simctl', 'spawn', udid, resolvedCheck, '--mode', mode], {
@@ -12,13 +12,16 @@ export async function preparePreinstalledWdaAsync({
appiumBinPath,
appiumEnv,
logger,
signal,
}: {
runtimeIdentifier: string | undefined;
appiumHome: string;
appiumBinPath: string;
appiumEnv: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
}): Promise<Record<string, unknown>> {
signal?.throwIfAborted();
const iosMajor = /^com\.apple\.CoreSimulator\.SimRuntime\.iOS-(\d+)(?:-|$)/.exec(
runtimeIdentifier ?? ''
)?.[1];
@@ -52,10 +55,17 @@ export async function preparePreinstalledWdaAsync({
// Appium's downloader inherits its pipes; killing only Appium leaves spawn's
// close event pending. Kill the owned group, then await close before cleanup.
const timeout = setTimeout(() => killProcessGroup(download.child, 'SIGKILL'), 60_000);
const abort = (): void => killProcessGroup(download.child, 'SIGKILL');
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) {
abort();
}
try {
await download;
signal?.throwIfAborted();
} finally {
clearTimeout(timeout);
signal?.removeEventListener('abort', abort);
}
if (!(await fs.promises.stat(runner)).isDirectory()) {
throw new Error('The downloaded WebDriverAgent runner is not an app directory.');
@@ -67,6 +77,7 @@ export async function preparePreinstalledWdaAsync({
};
} catch (err) {
await fs.promises.rm(outdir, { recursive: true, force: true });
signal?.throwIfAborted();
logger.warn(
{ err },
'Could not prepare preinstalled WebDriverAgent; using the default xcodebuild startup.'
@@ -10,8 +10,6 @@ import {
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import * as ngrok from '@ngrok/ngrok';
import { graphql } from 'gql.tada';
import nullthrows from 'nullthrows';
import { z } from 'zod';
import { randomBytes } from 'node:crypto';
import fs from 'node:fs';
@@ -27,51 +25,20 @@ import {
parseOpenUrlInput,
} from '../functions/launchApplication';
import { Sentry } from '../../sentry';
import { isProcessGroupRunning } from '../../utils/processes';
import { isProcessGroupRunning, killProcessGroup } from '../../utils/processes';
import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
import { createProcessOutput } from './processOutput';
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
import {
type DeviceRunSessionStatus,
requireDeviceRunSessionWorkerApi,
} from './deviceRunSessionWorkerApi';
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
deviceRunSession {
startDeviceRunSession(
deviceRunSessionId: $deviceRunSessionId
remoteConfig: $remoteConfig
) {
id
status
}
}
}
`);
const DEVICE_RUN_SESSION_STATUS_QUERY = graphql(`
query DeviceRunSessionStatus($deviceRunSessionId: ID!) {
deviceRunSessions {
byId(deviceRunSessionId: $deviceRunSessionId) {
id
status
}
}
}
`);
const ENSURE_DEVICE_RUN_SESSION_STOPPED_MUTATION = graphql(`
mutation EnsureDeviceRunSessionStopped($deviceRunSessionId: ID!) {
deviceRunSession {
ensureDeviceRunSessionStopped(deviceRunSessionId: $deviceRunSessionId) {
id
status
}
}
}
`);
const DEVICE_RUN_SESSION_STATUS_POLL_INTERVAL_MS = 5_000;
// A request to the device run session worker API that hangs counts as a failed poll or stop.
const DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS = 30_000;
export function getDeviceRunSessionIdOrThrow(env: BuildStepEnv): string {
const deviceRunSessionId = env.DEVICE_RUN_SESSION_ID;
@@ -109,6 +76,17 @@ export function getNgrokAuthtokenOrThrow(env: BuildStepEnv): string {
return authtoken;
}
/**
* Returns the step env without NGROK_AUTHTOKEN, for everything the step spawns.
* Only the step process opens ngrok tunnels. The processes it starts (daemons,
* preview servers, package installs) are reachable by whoever controls the
* session, and the token can run tunnels on Expo's ngrok account.
*/
export function omitNgrokAuthtoken(env: BuildStepEnv): BuildStepEnv {
const { NGROK_AUTHTOKEN: _ngrokAuthtoken, ...childEnv } = env;
return childEnv;
}
const TurnIceServersSchema = z.array(
z.object({
urls: z.array(z.string()),
@@ -148,6 +126,8 @@ export type DeviceRunSessionIdleTimeout = {
* begins, so a session nobody ever connects to still times out.
*/
getLastEventObservedAt: () => Date | undefined;
/** Last observed preview input, counted like a session event. */
getLastPreviewActivityAt?: () => Date | undefined;
};
export async function waitForDeviceRunSessionStoppedAsync({
@@ -201,9 +181,13 @@ export async function waitForDeviceRunSessionStoppedAsync({
while (!signal.aborted) {
if (idleTimeout) {
const lastEventObservedAt = idleTimeout.getLastEventObservedAt();
if (lastEventObservedAt && lastEventObservedAt > lastActivityAt) {
lastActivityAt = lastEventObservedAt;
for (const observedAt of [
idleTimeout.getLastEventObservedAt(),
idleTimeout.getLastPreviewActivityAt?.(),
]) {
if (observedAt && observedAt > lastActivityAt) {
lastActivityAt = observedAt;
}
}
if (Date.now() - lastActivityAt.getTime() >= idleTimeout.maxIdleTimeMinutes * 60_000) {
logger.info(
@@ -215,23 +199,13 @@ export async function waitForDeviceRunSessionStoppedAsync({
}
}
try {
const result = await ctx.graphqlClient
.query(DEVICE_RUN_SESSION_STATUS_QUERY, { deviceRunSessionId })
.toPromise();
if (result.error) {
throw result.error;
}
const status = result.data?.deviceRunSessions?.byId?.status;
if (!status) {
throw new Error(`Device run session ${deviceRunSessionId} status response was missing.`);
}
const status = await fetchDeviceRunSessionStatusAsync(ctx, deviceRunSessionId, signal);
pollErrorCount = 0;
if (status === 'STOPPED') {
if (status === 'stopped') {
logger.info(`Device run session ${deviceRunSessionId} was stopped.`);
return;
}
if (status === 'ERRORED') {
if (status === 'errored') {
throw new SystemError(`Device run session ${deviceRunSessionId} errored.`);
}
} catch (err) {
@@ -258,6 +232,20 @@ export async function waitForDeviceRunSessionStoppedAsync({
}
}
async function fetchDeviceRunSessionStatusAsync(
ctx: CustomBuildContext,
deviceRunSessionId: string,
signal: AbortSignal
): Promise<DeviceRunSessionStatus['status']> {
// One request per poll; the polling loop retries on its own schedule.
return (
await requireDeviceRunSessionWorkerApi(ctx).getSessionAsync({
signal,
timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS,
})
).status;
}
// Best effort: when this fails, the caller still tears the session down and the
// job run finishes, which clients also treat as the session ending.
async function ensureDeviceRunSessionStoppedSafelyAsync({
@@ -270,12 +258,9 @@ async function ensureDeviceRunSessionStoppedSafelyAsync({
logger: bunyan;
}): Promise<void> {
try {
const result = await ctx.graphqlClient
.mutation(ENSURE_DEVICE_RUN_SESSION_STOPPED_MUTATION, { deviceRunSessionId })
.toPromise();
if (result.error) {
throw result.error;
}
await requireDeviceRunSessionWorkerApi(ctx).stopAsync({
timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS,
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not mark idle device run session as stopped', error, {
@@ -332,14 +317,28 @@ function warmUpFfmpeg(env: BuildStepEnv): void {
async function installFfmpegWithHomebrewAsync({
env,
logger,
signal,
}: {
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
}): Promise<void> {
await spawn('brew', ['install', 'ffmpeg'], {
signal?.throwIfAborted();
const install = spawn('brew', ['install', 'ffmpeg'], {
env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1' },
logger,
...(signal ? { detached: true } : {}),
});
const abort = (): void => killProcessGroup(install.child, 'SIGKILL');
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) {
abort();
}
try {
await install;
} finally {
signal?.removeEventListener('abort', abort);
}
}
async function installFfmpegWithAptAsync({
@@ -357,7 +356,7 @@ async function installFfmpegWithAptAsync({
await spawn('sudo', ['apt-get', 'install', '-y', 'ffmpeg'], { env: aptEnv, logger });
}
let ffmpegSetupPromise: Promise<void> | undefined;
let ffmpegSetupPromise: Promise<'aborted' | void> | undefined;
/**
* Install ffmpeg when the runtime does not already provide it. Device-session
@@ -377,13 +376,16 @@ async function ensureFfmpegInstalledAsync({
runtimePlatform,
env,
logger,
signal,
}: {
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
logger: bunyan;
}): Promise<void> {
signal?: AbortSignal;
}): Promise<'aborted' | void> {
try {
const ffmpegPath = await findFfmpegOnPathAsync(env);
signal?.throwIfAborted();
if (ffmpegPath) {
logger.info(`ffmpeg is already installed at ${ffmpegPath}.`);
warmUpFfmpeg(env);
@@ -397,13 +399,17 @@ async function ensureFfmpegInstalledAsync({
} for the device session.`
);
if (isDarwin) {
await installFfmpegWithHomebrewAsync({ env, logger });
await installFfmpegWithHomebrewAsync({ env, logger, signal });
} else {
await installFfmpegWithAptAsync({ env, logger });
}
signal?.throwIfAborted();
logger.info('Installed ffmpeg.');
warmUpFfmpeg(env);
} catch (err) {
if (signal?.aborted) {
return 'aborted';
}
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not install ffmpeg for the device session', error, {
level: 'warning',
@@ -419,20 +425,46 @@ export async function ensureFfmpegInstalledOnceAsync({
runtimePlatform,
env,
logger,
signal,
}: {
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
}): Promise<void> {
signal?.throwIfAborted();
if (ffmpegSetupPromise) {
await ffmpegSetupPromise;
const sharedSetup = ffmpegSetupPromise;
let outcome: 'aborted' | void;
if (signal) {
outcome = await new Promise<'aborted' | void>((resolve, reject) => {
const abort = (): void => reject(signal.reason);
signal.addEventListener('abort', abort, { once: true });
void sharedSetup.then(result => {
signal.removeEventListener('abort', abort);
resolve(result);
});
if (signal.aborted) {
abort();
}
});
} else {
outcome = await sharedSetup;
}
if (outcome === 'aborted') {
if (ffmpegSetupPromise === sharedSetup) {
ffmpegSetupPromise = undefined;
}
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger, signal });
}
return;
}
const setupPromise = ensureFfmpegInstalledAsync({ runtimePlatform, env, logger });
const setupPromise = ensureFfmpegInstalledAsync({ runtimePlatform, env, logger, signal });
ffmpegSetupPromise = setupPromise;
try {
await setupPromise;
signal?.throwIfAborted();
} finally {
if (ffmpegSetupPromise === setupPromise) {
ffmpegSetupPromise = undefined;
@@ -440,12 +472,6 @@ export async function ensureFfmpegInstalledOnceAsync({
}
}
const TurnIceServersResponseSchema = z.object({
data: z.object({
iceServers: TurnIceServersSchema,
}),
});
/**
* Translate Cloudflare ICE servers into web preview CLI flags: `--stun-url` (the
* credential-less entries) and `--turn-url`/`--turn-username`/`--turn-credential`
@@ -487,34 +513,11 @@ export function turnIceServersToWebPreviewArgs(iceServers: TurnIceServers): stri
*/
export async function fetchWebPreviewTurnArgsAsync(
ctx: CustomBuildContext,
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
{ logger }: { logger: bunyan }
): Promise<string[]> {
try {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const expoApiServerUrl = nullthrows(ctx.env.__API_SERVER_URL, '__API_SERVER_URL is not set');
const robotAccessToken = nullthrows(
ctx.job.secrets?.robotAccessToken,
'robot access token is not set'
);
const response = await turtleFetch(
new URL(
`/v2/device-run-sessions/${deviceRunSessionId}/turn-ice-servers`,
expoApiServerUrl
).toString(),
'POST',
{
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
timeout: 5000,
retries: 1,
logger,
}
);
const { data } = TurnIceServersResponseSchema.parse(await response.json());
const args = turnIceServersToWebPreviewArgs(data.iceServers);
const iceServers = await fetchTurnIceServersAsync(ctx, { logger });
const args = turnIceServersToWebPreviewArgs(iceServers);
if (args.length > 0) {
logger.info('Configured the web preview with Cloudflare TURN ICE servers.');
}
@@ -530,6 +533,17 @@ export async function fetchWebPreviewTurnArgsAsync(
}
}
async function fetchTurnIceServersAsync(
ctx: CustomBuildContext,
{ logger }: { logger: bunyan }
): Promise<TurnIceServers> {
return await requireDeviceRunSessionWorkerApi(ctx).fetchTurnIceServersAsync({
timeoutMs: 5000,
retries: 1,
logger,
});
}
export async function uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
@@ -544,12 +558,15 @@ export async function uploadRemoteSessionConfigAsync({
logger.info(
`Reporting remote config to the API server (device run session: ${deviceRunSessionId}).`
);
const result = await ctx.graphqlClient
.mutation(START_DEVICE_RUN_SESSION_MUTATION, { deviceRunSessionId, remoteConfig })
.toPromise();
if (result.error) {
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
try {
await workerApi.startAsync({ remoteConfig });
} catch (err) {
throw new SystemError(
`Failed to start device run session ${deviceRunSessionId}: ${result.error.message}`
`Failed to start device run session ${deviceRunSessionId}: ${
err instanceof Error ? err.message : String(err)
}`,
{ cause: err }
);
}
}
@@ -717,6 +734,53 @@ export function spawnDetached({
};
}
export function createIosSessionStartupInputProviders(): ReturnType<
typeof BuildStepInput.createProvider
>[] {
return [
BuildStepInput.createProvider({
id: 'boot_simulator',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
}),
...['device_identifier', 'install_app_path'].map(id =>
BuildStepInput.createProvider({
id,
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
})
),
];
}
export type ServeSimApplicationOptions = ServeSimLaunchOptions & {
installAppPath: string;
launchAppIdentifier: string;
};
export function validateServeSimLaunchOptions({
launchArgs = [],
openUrl,
}: Pick<ServeSimLaunchOptions, 'launchArgs' | 'openUrl'>): void {
// Reject oversized iOS command arguments before starting the host.
if (
launchArgs.length > 256 ||
launchArgs.some(arg => arg.length > 8192 || arg.includes('\0')) ||
launchArgs.reduce((bytes, arg) => bytes + Buffer.byteLength(arg) + 1, 0) > 128 * 1024
) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
'iOS launch_args supports up to 256 arguments of 8192 characters each, without NUL, and at most 128 KiB total UTF-8 bytes including terminators.'
);
}
if (openUrl && (openUrl.length > 8192 || openUrl.includes('\0'))) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
'iOS open_url supports at most 8192 characters, without NUL.'
);
}
}
export interface ServeSimLaunchOptions {
launchAppIdentifier?: string;
launchArgs?: string[];
@@ -748,6 +812,80 @@ export function createServeSimLaunchInputProviders(): ReturnType<
];
}
/** Older session jobs omit this input and keep their existing Share button. */
export function createPublicPreviewInputProvider(): ReturnType<
typeof BuildStepInput.createProvider
> {
return BuildStepInput.createProvider({
id: 'public_preview',
required: false,
defaultValue: true,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
});
}
export function createPrivatePreviewUrlInputProvider(): ReturnType<
typeof BuildStepInput.createProvider
> {
return BuildStepInput.createProvider({
id: 'private_preview_url',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
});
}
/**
* Origins that may embed the preview host in a frame, next to the website origins. The host
* drops values that are not an origin or a subdomain wildcard, so this parser only guards
* the input shape.
*/
export function createFrameAncestorsInputProvider(): ReturnType<
typeof BuildStepInput.createProvider
> {
return BuildStepInput.createProvider({
id: 'frame_ancestors',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
});
}
export function parseFrameAncestorsInput(value: unknown): string[] {
return parseOriginListInput(value, 'frame_ancestors', 'EAS_FRAME_ANCESTORS_INVALID_INPUT');
}
/**
* Origins whose pages may call the preview host's API cross-origin, such as a page that renders
* the device with @expo/hub-client. Framing alone does not need it. The host refuses values that
* are not an origin or a subdomain wildcard, so this parser only guards the input shape.
*/
export function createCorsOriginsInputProvider(): ReturnType<typeof BuildStepInput.createProvider> {
return BuildStepInput.createProvider({
id: 'cors_origins',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
});
}
export function parseCorsOriginsInput(value: unknown): string[] {
return parseOriginListInput(value, 'cors_origins', 'EAS_CORS_ORIGINS_INVALID_INPUT');
}
function parseOriginListInput(value: unknown, inputId: string, errorCode: string): string[] {
if (value === undefined) {
return [];
}
if (
!Array.isArray(value) ||
!value.every(origin => typeof origin === 'string' && origin.trim().length > 0)
) {
throw new UserError(
errorCode,
`Input "${inputId}" must be an array of non-empty strings, one origin each.`
);
}
return value;
}
export function parseServeSimLaunchInputs(
{
launchAppIdentifier: rawLaunchAppIdentifier,
@@ -0,0 +1,101 @@
import { SystemError } from '@expo/eas-build-job';
import { constants } from 'node:fs';
import { cp, mkdir, mkdtemp, realpath, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import WebSocket from 'ws';
export async function stageServeSimAppAsync(
appPath: string,
tmpdir = os.tmpdir()
): Promise<{
directory: string;
path: string;
}> {
// Clone into app.install's approved root while preserving the caller's app.
const root = path.join(tmpdir, 'serve-sim-uploads');
await mkdir(root, { recursive: true, mode: 0o700 });
const directory = await mkdtemp(path.join(root, 'eas-app-'));
const stagedPath = path.join(directory, path.basename(appPath));
try {
await cp(await realpath(appPath), stagedPath, {
recursive: true,
mode: constants.COPYFILE_FICLONE,
verbatimSymlinks: true,
});
return { directory, path: stagedPath };
} catch (error) {
await rm(directory, { recursive: true, force: true });
throw error;
}
}
export async function runServeSimActionAsync({
port,
token,
action,
params,
timeoutMs,
signal,
}: {
port: number;
token: string;
action: string;
params: Record<string, unknown>;
timeoutMs: number;
signal?: AbortSignal;
}): Promise<void> {
signal?.throwIfAborted();
await new Promise<void>((resolve, reject) => {
const socket = new WebSocket(`ws://127.0.0.1:${port}/exec-ws`, [`serve-sim.token.${token}`]);
let settled = false;
let sent = false;
const finish = (error?: unknown) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
signal?.removeEventListener('abort', aborted);
socket.terminate();
if (error) {
reject(error);
} else {
resolve();
}
};
const aborted = () => finish(signal?.reason ?? new Error('Serve-sim action aborted.'));
const timer = setTimeout(
() => finish(new SystemError(`serve-sim ${action} timed out.`)),
timeoutMs
);
signal?.addEventListener('abort', aborted, { once: true });
socket.on('error', () => finish(new SystemError(`serve-sim ${action} connection failed.`)));
socket.on('close', () =>
finish(new SystemError(`serve-sim ${action} connection closed before completion.`))
);
socket.on('message', data => {
let reply;
try {
reply = JSON.parse(data.toString());
} catch {
return finish(new SystemError(`serve-sim ${action} returned invalid JSON.`));
}
if (reply?.ready === true && !sent) {
sent = true;
socket.send(JSON.stringify({ id: 1, action, params }));
} else if (reply?.id === 1 && sent) {
finish(
reply.exitCode === 0
? undefined
: new SystemError(
`serve-sim ${action} failed: ${(typeof reply.error === 'string' && reply.error) || (typeof reply.stderr === 'string' && reply.stderr) || 'invalid response'}`
)
);
}
});
if (signal?.aborted) {
aborted();
}
});
}
@@ -0,0 +1,131 @@
import { type bunyan } from '@expo/logger';
const POLL_INTERVAL_MS = 5_000;
const REQUEST_TIMEOUT_MS = 5_000;
export type ServeSimActivityPolling = {
/**
* When a new event log entry (input or host action, not stream traffic) was first seen. Reports
* now while the log is unreadable but serve-sim is up, so an unobservable session stays active.
*/
getLastActivityObservedAt: () => Date | undefined;
stopAsync: () => Promise<void>;
};
/** Nothing listens on the port. A reset or dropped response can come from a running serve-sim. */
function isServeSimDownError(err: unknown): boolean {
// Not `instanceof`: undici errors can come from another realm.
let error: unknown = err;
for (let depth = 0; depth < 5 && typeof error === 'object' && error !== null; depth++) {
const { code, cause } = error as { code?: unknown; cause?: unknown };
if (code === 'ECONNREFUSED') {
return true;
}
error = cause;
}
return false;
}
/** A changed newest event ID counts as activity, timed by the local clock. */
export function startServeSimActivityPolling({
port,
token,
logger,
pollIntervalMs = POLL_INTERVAL_MS,
}: {
port: number;
token: string;
logger: bunyan;
pollIntervalMs?: number;
}): ServeSimActivityPolling {
const controller = new AbortController();
let lastEventId: number | undefined;
let lastActivityObservedAt: Date | undefined;
let eventLogUnavailable = false;
let didWarn = false;
let timer: NodeJS.Timeout | undefined;
let pending: Promise<void> = Promise.resolve();
const pollAsync = async (): Promise<void> => {
try {
const eventId = await fetchLatestServeSimEventIdAsync({
port,
token,
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
});
eventLogUnavailable = false;
if (eventId !== undefined && eventId !== lastEventId) {
lastEventId = eventId;
lastActivityObservedAt = new Date();
}
} catch (err) {
if (controller.signal.aborted) {
return;
}
// Only a down serve-sim, which receives no input either, keeps the last observation.
eventLogUnavailable = !isServeSimDownError(err);
if (!didWarn) {
didWarn = true;
logger.warn(
{ err },
eventLogUnavailable
? 'Could not read the serve-sim event log. Preview input cannot be observed, so the ' +
'idle timeout does not stop this session.'
: 'Could not read serve-sim preview activity.'
);
}
}
};
const tick = (): void => {
pending = pollAsync().finally(() => {
if (!controller.signal.aborted) {
timer = setTimeout(tick, pollIntervalMs);
timer.unref();
}
});
};
tick();
return {
getLastActivityObservedAt: () => (eventLogUnavailable ? new Date() : lastActivityObservedAt),
stopAsync: async () => {
controller.abort();
clearTimeout(timer);
await pending;
},
};
}
export async function fetchLatestServeSimEventIdAsync({
port,
token,
signal,
}: {
port: number;
token: string;
signal?: AbortSignal;
}): Promise<number | undefined> {
const response = await fetch(`http://127.0.0.1:${port}/api/event-log?limit=1`, {
headers: { Authorization: `Bearer ${token}` },
signal,
});
if (!response.ok) {
await response.body?.cancel();
throw new Error(`serve-sim event log request failed: ${response.status}`);
}
const text = await response.text();
let body: { events?: unknown } | undefined;
try {
body = JSON.parse(text);
} catch {}
if (!Array.isArray(body?.events)) {
throw new Error('serve-sim event log response has no events array.');
}
if (body.events.length === 0) {
return undefined;
}
const id = (body.events.at(-1) as { id?: unknown } | null)?.id;
if (typeof id !== 'number') {
throw new Error('serve-sim event log entry has no numeric ID.');
}
return id;
}
@@ -30,17 +30,20 @@ export type StartupTasks = {
summary(): string;
};
export function createStartupTasks(logger: bunyan): StartupTasks {
export function createStartupTasks(logger: bunyan, parentSignal?: AbortSignal): StartupTasks {
const startedAt = Date.now();
const finished: { name: string; ms: number }[] = [];
const controller = new AbortController();
const signal = parentSignal
? AbortSignal.any([parentSignal, controller.signal])
: controller.signal;
const abort = (reason: unknown): void => {
if (!controller.signal.aborted) {
controller.abort(reason);
}
};
return {
signal: controller.signal,
signal,
run<T>(name: string, fn: (taskLogger: bunyan) => Promise<T>): Promise<T> {
const taskStartedAt = Date.now();
logger.info(`Starting ${name}.`);
@@ -61,7 +64,6 @@ export function createStartupTasks(logger: bunyan): StartupTasks {
return promise;
},
untilAborted<T>(promise: Promise<T>): Promise<T> {
const { signal } = controller;
const result = new Promise<T>((resolve, reject) => {
if (signal.aborted) {
reject(signal.reason);
@@ -154,8 +154,10 @@ export namespace AndroidEmulatorUtils {
try {
let configIniFileContent = await fs.promises.readFile(configIniFile, 'utf-8');
logger.info('Setting hw.ramSize to 2048.');
configIniFileContent = `${configIniFileContent}\nhw.ramSize=2048\n`;
// Give interactive sessions more headroom for guest VSOCK packet allocations.
const ramSize = env.DEVICE_RUN_SESSION_ID ? 4096 : 2048;
logger.info(`Setting hw.ramSize to ${ramSize}.`);
configIniFileContent = `${configIniFileContent}\nhw.ramSize=${ramSize}\n`;
const shouldResizeScreen =
env.ANDROID_EMULATOR_ADJUST_SCREEN === 'true' || env.ANDROID_EMULATOR_ADJUST_SCREEN === '1';
@@ -136,7 +136,10 @@ describe('AndroidEmulatorUtils', () => {
);
});
it('does not apply an LCD configuration when it is omitted', async () => {
it.each([
[undefined, 2048],
['device-run-session-id', 4096],
])('configures memory for session %s without an LCD override', async (sessionId, ramSize) => {
const deviceName = 'generic-emulator' as AndroidVirtualDeviceName;
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
const initialConfig = ['skin.path=pixel_9', 'showDeviceFrame=yes'].join('\n');
@@ -156,12 +159,14 @@ describe('AndroidEmulatorUtils', () => {
env: {
HOME: '/home/expo',
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
DEVICE_RUN_SESSION_ID: sessionId,
},
logger: createMockLogger(),
});
const config = await fs.promises.readFile(`${avdDirectory}/config.ini`, 'utf8');
expect(config).toContain(initialConfig);
expect(config).toContain(`hw.ramSize=${ramSize}\n`);
expect(config).not.toContain('hw.lcd.');
});
});
@@ -3,7 +3,13 @@ import promiseRetry from 'promise-retry';
export function promiseRetryWithCondition<TFn extends (...args: any[]) => Promise<any>>(
fn: TFn,
retryConditionFn: (error: any) => boolean,
options: { retries: number; factor?: number; minTimeout?: number; maxTimeout?: number } = {
options: {
retries: number;
factor?: number;
minTimeout?: number;
maxTimeout?: number;
randomize?: boolean;
} = {
retries: 3,
factor: 2,
},
@@ -0,0 +1,21 @@
# create-eas-build-function
## 25.3.0
No changes in this release.
## 25.2.0
No changes in this release.
## 25.1.1
No changes in this release.
## 25.1.0
No changes in this release.
## 25.0.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "24.13.0",
"version": "25.3.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "24.13.0"
"@expo/steps": "25.3.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+21
View File
@@ -0,0 +1,21 @@
# @expo/downloader
## 25.3.0
No changes in this release.
## 25.2.0
No changes in this release.
## 25.1.1
No changes in this release.
## 25.1.0
No changes in this release.
## 25.0.0
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "24.13.0",
"version": "25.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -0,0 +1,21 @@
# eas-build-cache-provider
## 25.3.0
No changes in this release.
## 25.2.0
No changes in this release.
## 25.1.1
No changes in this release.
## 25.1.0
No changes in this release.
## 25.0.0
No changes in this release.
Loaded 100 of 246 files, more files were not shown because too many files have changed in this diff. Show more