Compare commits

...
Author SHA1 Message Date
Stanisław Chmiela a107f30620 [build-tools] accept positive fractional processing timeouts 2026-10-24 22:12:04 +02:00
Stanisław Chmiela 67df0e2787 [build-tools] configure and enforce ASC processing deadlines 2026-10-24 22:12:04 +02:00
Stanisław Chmiela 0a09c0432d [build-tools] validate beta review inputs together 2026-10-24 22:11:51 +02:00
Stanisław Chmiela e901de4198 [build-tools] separate beta review from metadata updates 2026-10-24 22:11:51 +02:00
Stanisław Chmiela be5b4ef0af [build-tools] support native TestFlight beta review 2026-10-24 22:11:51 +02:00
Stanisław Chmiela 1572a6420a [build-tools] require explicit processing diagnostic arguments 2026-10-24 17:38:41 +02:00
Stanisław Chmiela 524d0222ae [build-tools] keep processing error helpers private 2026-10-24 17:32:19 +02:00
Stanisław Chmiela c0151e4ce0 [build-tools] place ASC processing wait in its step module 2026-10-24 17:27:29 +02:00
Stanisław Chmiela 44f5ec55c9 [build-tools] validate ASC wait inputs together 2026-10-24 11:40:35 +02:00
Stanisław Chmiela bae232ac30 [build-tools] preserve ASC processing timeout behavior 2026-10-24 11:37:12 +02:00
Stanisław Chmiela b5442a5bb6 [build-tools] keep timeout coverage in the base change 2026-10-24 11:34:38 +02:00
Stanisław Chmiela 547496c588 [build-tools] move ASC polling into utilities and accept the bundle identifier 2026-10-24 11:34:38 +02:00
Stanisław Chmiela 9f3ecf60c3 [build-tools] remove repeated processing error tests 2026-10-24 11:34:38 +02:00
Stanisław Chmiela 6275c1e54c [build-tools] add a separate App Store Connect processing step 2026-10-24 11:34:37 +02:00
Will Schurman 🦦 d02de4da64 [eas-json] Add ability to specify role request/requirement to eas.json (#4732)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

This is the next part of https://github.com/expo/universe/pull/32694, which allows the requested/required permission role for the build job's EXPO_TOKEN to have to be specified in build mutation.

# How

The first step is to allow specifying this in eas.json. The proposed structure is:
```
...
{
  "build": {
    "production": {
      "permissions": { "account": { "role": "release_manager" } }
    }
  }
}
...
```

This will, upon updating eas-cli in a separate PR, set the requested role in the build creation mutation. If the creator doesn't possess the required role, it will throw.

# Test Plan

CI
2026-10-23 16:54:10 -07:00
Will Schurman 🦦 04c214be93 [eas-cli] Regenerate graphql code (#4692)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

Regenerate graphql to fix CI ahead of others PRs failing due to needing to sync.

# How

regenerate.

# Test Plan

`yarn tsc`, CI
2026-10-23 16:42:14 -07:00
Gabe Debes 1d9607ca00 [build-tools] Retry critical device session startup requests (#4680)
* [build-tools] Retry transient device session worker requests

* [build-tools] Limit retries to critical device session requests

* [build-tools] Keep existing download lookup retries
2026-10-23 11:57:08 -07:00
Vojtech Novak 78ba76001b [eas-cli] Read the app config fewer times in eas build (#4649) 2026-10-23 20:31:39 +02:00
Tomasz Mazur 3c8b62fb39 [build-tools] Unify signed URL uploads (#4712)
* [build-tools] Unify signed URL uploads

* [build-tools] Upload in-memory bodies with one signed URL helper
2026-10-23 17:57:35 +02:00
Stanisław Chmiela c3a26fef34 [worker] Fall back when Node.js mirror installation fails (#3452)
# Why

Workers can set `NVM_NODEJS_ORG_MIRROR` through the Node.js cache settings. If `nvm install` fails with that mirror, runtime setup currently stops even when the default Node.js download source is available.

# How

Retry `nvm install` without the mirror if it failed with the mirror. This helps with situation when we fail to have refreshed Nodes list in our cache or our cache is down and stuff like that.

# Test Plan

CI should pass.
2026-10-23 17:12:48 +02:00
Stanisław Chmiela a2c0d0cf09 [build-tools] Default Android emulators to 4 GiB of RAM (#4727)
# Why

We recently changed the default Android system image from API 30 to API 35. Looks like API 35 needs more RAM to be working well.

<details>
<summary>Details</summary>

Android 35 Google APIs x86_64 can lose ADB during large APK transfers. Guest tracing shows `adbd` getting `ENOMEM` from `write()`, then closing VSOCK before the emulator closes host TCP. Six fresh live EAS devices with 4 GiB passed six Maestro tests and 60 additional APK downloads. Two of three corrected lower-memory control devices lost ADB.

</details>

# How

Use 4096 MB when the resolved system-image package starts with `system-images;android-35` or newer.

# Test Plan

- Existing emulator utility and startup suites: 30 tests passed. No tests added. Updated one existing API 35 AOSP RAM expectation from 2048 to 4096.
- Type checks for all 13 projects, repository lint, targeted formatting, and `git diff --check` passed.
- [Live validation of the 4 GiB setting](https://expo.dev/accounts/sjchmiela/projects/sdk-57-production/workflows/01a165c0-9d4d-777b-920f-3827ff2f89b4): three devices, three Maestro tests, 30 APK downloads passed.
- [Default API 30 comparison](https://expo.dev/accounts/sjchmiela/projects/sdk-57-production/workflows/01a16833-cf25-7eac-b472-e64dc6084c3d): 2/3/4 GiB, three fresh workers per setting, complete.
- [API 34 comparison](https://expo.dev/accounts/sjchmiela/projects/sdk-57-production/workflows/01a16833-cf57-70be-a4e8-72a20ca8d6b2): 2/4 GiB, three fresh workers per setting, complete.
- [API 35 comparison](https://expo.dev/accounts/sjchmiela/projects/sdk-57-production/workflows/01a16837-36ae-7bfe-9518-28fe72b9cd5c): 2/3/4 GiB, three fresh workers per setting, complete.

| Image | RAM request | Full passes (flow plus ten downloads) |
| --- | --- | --- |
| Default API 30 | 2 / 3 / 4 GiB | 3/3 / 3/3 / 3/3 |
| API 34 Google APIs x86_64 | 2 / 4 GiB | 3/3 / 3/3 |
| API 35 Google APIs x86_64 | 2 / 3 / 4 GiB | 1/3 / 0/3 / 3/3 |

API 35 at 2 and 3 GiB each had two Maestro failures with `device offline`. One 3 GiB device passed Maestro but lost ADB during extra download four. All 4 GiB devices passed all 30 extra downloads. Three devices per cell do not establish a long-term failure rate.
2026-10-23 13:09:22 +02:00
Vojtech NovakandClaude Opus 5.5 f148570a36 [eas-cli] Share one GraphQL client between the project ID check and the command (#4646)
* [eas-cli] Share one GraphQL client between the project ID check and the command

The project ID check and LoggedInContextField each built their own urql
client, so the second AppByIdQuery for the same project missed the cache.
SessionManager now keeps one client per credential.

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

* [eas-cli] Update test mocks for the analytics and GraphQL client changes on main

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-23 12:11:17 +02:00
Stanisław Chmiela e8ca106f2e [build-tools] Remove legacy artifact categories (#4719)
## Why

expo/universe#32638

## How

Removed obsolete enum that didn't mean anything.

## Test Plan

CI should pass.
2026-10-22 21:55:56 +02:00
Ash Wu 62ddc91aa4 [build-tools] stop logging workflow hook parsing in build logs (#4717)
Hook parsing in native builds runs between READ_PACKAGE_JSON and
INSTALL_DEPENDENCIES and logged through the phase-less logger, so every
native build with hooks showed an "Unknown build phase" log group.
Ignore unknown and unrun hook keys silently in both the native and the
steps parser, let the eas/checkout step print the working-directory line
itself, and drop the after-hook failure line that the failing hook step
already logs.
2026-10-22 21:56:13 +08:00
Ash Wu 9b54c203ee [build-tools] retry download_build network failures and report their cause (#4690) 2026-10-22 15:30:47 +08:00
Ramon Claudio a0fe3ff2c3 [eas-cli][build-tools] Bump Node.js to 22.23.4 (#4714)
* [eas-cli] Bump Node.js to 22.23.4

* [eas-cli] Add changeset

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

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

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

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

---------

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

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

# How

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

# Test Plan

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

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

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

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

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

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

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

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

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

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

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

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

* Add changeset for early Android launch input checks

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

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

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

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

---------

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

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

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

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

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

* [build-tools] Remove unused capture proxy override

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

---------

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

* [build-tools] Name recording artifacts metadata

* [build-tools] Check companion metadata upload names

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* [eas-cli] List simulator actions by default

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

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

Document the separator for values beginning with --.

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

* [eas-cli] Clarify local screenshot output usage

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

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

---------

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

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

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

* [eas-cli] Clarify alternative simulator action inputs

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

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

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

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

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

---------

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

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

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

* [eas-cli] Skip experiments in CI

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

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

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

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

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

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

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

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

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

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

---------

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

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

* [eas-cli] cover the embed output fallbacks

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

* [eas-cli] format the embed output tests

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

---------

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

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

# How

Supposedly Fastlane retries committing.

# Test Plan

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

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

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

# How

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

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

User-facing messages are unchanged.

# Test Plan

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

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

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

# How

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

# Test Plan

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

* [eas-cli] report unavailable Simulator usage estimates

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

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

* [eas-cli] Persist simulator preview sharing preference

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

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

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

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

Edited-by: Codex

---------

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

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

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

* [build-tools] pass preview sharing flags directly

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

* [build-tools] clarify public preview logging coverage

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

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

Edited-by: Codex

---------

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

# Why

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

# How

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

# Test Plan

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

---------

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

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

# Why

I want to polish Google Play submission messages a bit.

# How

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

# Test Plan

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

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

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

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

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

* [build-tools] Keep encoder shutdown tests focused

---------

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

Edited-by: Codex

* [build-tools] Add preinstalled WDA changelog entry

Edited-by: Codex

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

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

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

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

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

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

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

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

* [ci] Skip the changeset check for Dependabot PRs

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

# How

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

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

# Test Plan

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

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

# How

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

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

# Test Plan

CI should pass. Also tested live with staging key.
2026-10-13 12:23:56 +02:00
Kudo Chien 6c256fd9d2 [build-tools] Open Android URLs in the app package instead of the launch activity (#4559) 2026-10-13 13:10:46 +08:00
Expo CI 6683492955 bump STAGING and PRODUCTION to 24.13.0 in cli-versions.json 2026-10-12 17:49:43 +00:00
Expo CI 964b4ed93f update CHANGELOG.md for v24.13.0 2026-10-12 17:47:09 +00:00
Expo CI 34e8b45bad v24.13.0 2026-10-12 17:44:06 +00:00
Tomasz Mazur d1de958201 [build-tools] Close artifact upload streams before an upload finishes (#4609) 2026-10-12 17:58:38 +02:00
Tomasz Mazur 2b672fc07f [build-tools] Add a sandbox uploadArtifact command (#4563)
* [build-tools] Add a sandbox uploadArtifact command

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

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

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

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

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

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

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

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

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

* [build-tools] link submission archive changelog entry

* [build-tools] simplify submission archive downloads

* [build-tools] validate submission download inputs together

* [build-tools] retry submission archive URL requests

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:31:50 -07:00
Tomasz Mazur 800a0e0893 [build-tools] Add a sandbox readFile command for text and image files (#4555) 2026-10-09 17:23:40 +02:00
Tomasz Mazur 2911b04ea1 [build-tools] Return sandbox daemon error codes (#4571) 2026-10-09 17:02:27 +02:00
Tomasz Mazur 50b381abe0 [eas-build-job] Add sandbox daemon error codes (#4570) 2026-10-09 17:02:26 +02:00
Gabe Debes c8696f6012 [build-tools] Install ffmpeg before starting Appium sessions (#4577) 2026-10-08 16:12:29 -07:00
Brent Vatne 45d795e076 [eas-cli] Remove obsolete appVersion runtime policy warning (#4375) 2026-10-08 15:02:35 -07:00
Expo CI 8e98a915b9 bump STAGING and PRODUCTION to 24.12.1 in cli-versions.json 2026-10-08 21:54:04 +00:00
Expo CI 73c65d3edb update CHANGELOG.md for v24.12.1 2026-10-08 21:51:34 +00:00
Expo CI 01e3be71aa v24.12.1 2026-10-08 21:49:04 +00:00
SJ Kim 991bbeae8c [eas-cli] Move the #4522 and #4483 changelog entries to their releases (#4576) 2026-10-08 17:43:35 -04:00
SJ Kim 6c342abb97 [eas-cli] Stop a simulator session created after Ctrl+C during create (ENG-27606) (#4522)
* [eas-cli] Log client request events for simulator sessions (ENG-27138)

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

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

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

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

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

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

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

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

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

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

# How

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

# Test Plan

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

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

# How

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

# Test Plan

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

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

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

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

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

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

* [build-tools] Extract Google Play assertion helper

* [build-tools] Move token creation into GooglePlayAuthUtils

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

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

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

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

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

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

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

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

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

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

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

* [build-tools] Keep Android package readers explicit

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

* [build-tools] Add changelog entry

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

---------

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

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

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

* [changelog] Add entry for #4508

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

---------

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

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

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

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

* [build-tools] Add changelog entry

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

---------

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

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

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

## How

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

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

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

## Test Plan

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

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

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

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

* [build-tools] document Apple request recovery

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

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

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

* [build-tools] define simple explicit retry options

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

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

* [build-tools] recover uncertain UPLOAD_COMPLETE commits

* [build-tools] require upload commit logger

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-02 13:31:43 -07:00
Expo CI 33ff59a75e bump STAGING and PRODUCTION to 24.9.0 in cli-versions.json 2026-10-02 17:41:52 +00:00
Expo CI c2bbba6e78 update CHANGELOG.md for v24.9.0 2026-10-02 17:35:45 +00:00
522 changed files with 43293 additions and 15051 deletions

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
# Changesets
Every PR that changes a package adds a changeset: a Markdown file in this directory that names the changed packages, the bump type, and a one-line summary for the changelog. Do not edit `CHANGELOG.md` files. The release generates them from the changesets.
## Add a changeset
```sh
yarn changeset
```
Select the changed packages and the bump type, then write the summary. Commit the new file with your PR. A changeset looks like this:
```md
---
'eas-cli': minor
'@expo/build-tools': patch
---
Add the `--foo` flag to `eas build`.
```
Write the summary as one sentence in the style of the existing `CHANGELOG.md` entries. The release adds the PR link and the author, and prefixes the entry with the package directories, for example `[eas-cli][build-tools]`.
## Bump types and changelog sections
All packages share one version. The highest bump in all changesets sets the next version. Each entry goes into the root `CHANGELOG.md` section for its highest bump:
| Bump | Root `CHANGELOG.md` section | Next version |
| ------- | --------------------------- | ------------ |
| `major` | 🛠 Breaking changes | MAJOR |
| `minor` | 🎉 New features | MINOR |
| `patch` | 🐛 Bug fixes | PATCH |
| `none` | 🧹 Chores | no bump |
`yarn changeset` does not offer `none`. For changes that should not appear in the changelog, add the "no changelog" label to the PR in GitHub.
The "no changelog" label skips the changeset check, but not the `CHANGELOG.md` check. A PR that edits a `CHANGELOG.md` file always fails. Dependabot PRs skip the changeset check.
See [RELEASING.md](../RELEASING.md) for how a release uses the changesets.
+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': patch
---
Regenerate graphql code
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Default Android emulators to 4 GiB of RAM to reduce ADB disconnects during large transfers. Retain the 2 GiB default for API 34 and below outside interactive device sessions, and preserve explicit emulator configuration overrides.
@@ -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
@@ -0,0 +1,5 @@
---
'@expo/build-tools': minor
---
Add a configurable timeout to `eas/wait_for_asc_build_processing` and fail when Apple does not finish processing before the deadline.
@@ -0,0 +1,5 @@
---
'@expo/eas-json': minor
---
Add `permissions.account.role` to build profiles to request a role for the build's `EXPO_TOKEN`.
+11
View File
@@ -0,0 +1,11 @@
export declare function getAttributionAsync(changeset: {
id: string;
commit?: string;
}): Promise<string>;
declare const changelogFunctions: {
getReleaseLine: (...args: never[]) => Promise<string>;
getDependencyReleaseLine: (...args: never[]) => Promise<string>;
};
export default changelogFunctions;
+50
View File
@@ -0,0 +1,50 @@
import { getCommitInfo } from '@changesets/get-github-info';
import { formatEntry } from './formatEntry.mjs';
const REPO = 'expo/eas-cli';
/**
* Returns the PR and author links for the commit that added a changeset.
*
* @param {{ id: string, commit?: string }} changeset
* @returns {Promise<string>} For example `([#123](https://...) by [@user](https://...))`.
*/
export async function getAttributionAsync(changeset) {
if (!changeset.commit) {
throw new Error(
`Changeset .changeset/${changeset.id}.md is not committed. Commit it, then run the release again.`
);
}
const info = await getCommitInfo({ repo: REPO, commit: changeset.commit });
if (info?.pull && info.author) {
return `(${info.pull.markdownLink} by ${info.author.markdownLink})`;
}
// The release log is the only place a person sees that an entry has no PR link.
// eslint-disable-next-line no-console
console.warn(
`No pull request found on GitHub for commit ${changeset.commit} (changeset ${changeset.id}). The entry links the commit instead.`
);
const commitLink =
info?.commit.markdownLink ??
`[\`${changeset.commit.slice(0, 7)}\`](https://github.com/${REPO}/commit/${changeset.commit})`;
return info?.author ? `(${commitLink} by ${info.author.markdownLink})` : `(${commitLink})`;
}
/**
* @param {{ id: string, summary: string, commit?: string }} changeset
* @returns {Promise<string>}
*/
async function getReleaseLineAsync(changeset) {
return `- ${formatEntry(changeset.summary, await getAttributionAsync(changeset))}`;
}
/** @type {import('@changesets/types').ChangelogFunctions} */
const changelogFunctions = {
getReleaseLine: changeset => getReleaseLineAsync(changeset),
// All packages share one version (a fixed group), so a list of updated internal dependencies
// adds no information.
getDependencyReleaseLine: () => Promise.resolve(''),
};
export default changelogFunctions;
+32
View File
@@ -0,0 +1,32 @@
{
"$schema": "https://unpkg.com/@changesets/[email protected]/schema.json",
"changelog": "./changelog.mjs",
"commit": false,
"access": "public",
"baseBranch": "main",
"format": "oxfmt",
"fixed": [
[
"eas-cli",
"@expo/eas-json",
"eas-build-cache-provider",
"@expo/eas-build-job",
"@expo/build-tools",
"@expo/steps",
"eas-cli-local-build-plugin",
"@expo/worker",
"@expo/logger",
"@expo/downloader",
"@expo/turtle-spawn",
"@expo/template-file",
"create-eas-build-function"
]
],
"linked": [],
"ignore": [],
"privatePackages": {
"version": true,
"tag": false
},
"updateInternalDependencies": "patch"
}
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Retry transient failures when reporting that a device run session has started.
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log.
+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': patch
---
Read the app config fewer times in `eas build` and other project commands. An iOS build now runs `expo config` 2 times instead of 6.
+1
View File
@@ -0,0 +1 @@
export declare function formatEntry(summary: string, attribution: string): string;
+23
View File
@@ -0,0 +1,23 @@
/**
* Formats a changeset summary as the text of a CHANGELOG list item.
*
* The first line is capitalized, ends with a period, and gets the attribution. Later lines are
* indented by two spaces, so they stay inside the list item.
*
* @param {string} summary The changeset summary.
* @param {string} attribution For example `([#123](https://...) by [@user](https://...))`.
* @returns {string} The entry text, without the leading `- `.
*/
export function formatEntry(summary, attribution) {
const [firstLine, ...otherLines] = summary
.trim()
.split('\n')
.map(line => line.trimEnd());
if (!firstLine) {
throw new Error('The changeset summary is empty. Describe the change below the front matter.');
}
const capitalizedLine = `${firstLine[0].toUpperCase()}${firstLine.slice(1)}`;
const sentence = capitalizedLine.endsWith('.') ? capitalizedLine : `${capitalizedLine}.`;
const indentedLines = otherLines.map(line => (line ? ` ${line}` : ''));
return [`${sentence} ${attribution}`, ...indentedLines].join('\n');
}
+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': none
---
Make command descriptions in `eas --help` consistently lowercase without trailing periods.
+6
View File
@@ -0,0 +1,6 @@
---
'@expo/build-tools': patch
'@expo/steps': patch
---
Stop printing workflow hook parsing messages in build logs: native builds showed them under an "Unknown build phase" group, and custom builds warned about unknown hook keys and hooks on steps that never run.
+6
View File
@@ -0,0 +1,6 @@
---
'eas-cli': none
'@expo/build-tools': none
---
Bump Node.js to 22.23.4 to fix the CVEs in the [October 2026 Node.js security releases](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases).
@@ -0,0 +1,5 @@
---
'@expo/worker': patch
---
Retry Node.js installation without the internal Node.js cache mirror when the first attempt fails, while preserving other configured mirrors.
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/build-tools': minor
---
Stop sandboxes after an optional maximum duration measured from readiness.
@@ -0,0 +1,5 @@
---
'@expo/build-tools': minor
---
Add `eas/wait_for_asc_build_processing` to wait for an existing App Store Connect upload. Keep `eas/upload_to_asc`'s `wait_for_processing` input supported through the same processing logic.
+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': patch
---
Share one GraphQL client between the project ID check and the command, so commands send one fewer app query.
@@ -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/build-tools': minor
---
Add eas/submit_testflight_beta_review to submit a processed build for beta review separately from metadata updates.
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/build-tools': minor
---
Unify signed URL uploading to a single implementation and use it everywhere
+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.
+6
View File
@@ -0,0 +1,6 @@
---
'@expo/eas-build-job': major
'@expo/build-tools': major
---
Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports.
+10 -7
View File
@@ -54,13 +54,16 @@ thing, so the codebase stays uniform and predictable.
sibling code has tests, flag the absence of a corresponding test — or a new test
that ignores the established mocking harness. Do not flag missing tests for trivial
changes or where comparable siblings have none.
- **Missing or malformed CHANGELOG entry.** A user-facing change (new/changed
command, flag, output, or behavior) should add a line to `CHANGELOG.md` matching
the existing format: `[<package>] <description>. ([#<PR>](url) by [@<user>](url))`,
under the right package/section. Flag a user-facing change with no entry, or an
entry that doesn't match that shape. (Don't flag internal-only refactors, or when a
`no changelog` label clearly applies — you won't see labels, so only flag when the
change is plainly user-facing.)
- **Missing or malformed changeset.** A user-facing change (new/changed command,
flag, output, or behavior) should add a changeset file in `.changeset/` (created
with `yarn changeset`). Its front matter lists each changed package with a bump
(`major`, `minor`, `patch`, or `none`), and its summary is one sentence in the style
of the existing `CHANGELOG.md` entries, with no PR link or author (the release adds
them). Flag a user-facing change with no changeset, a bump that does not match the
change (a breaking change as `minor` or `patch`), or a direct edit to any
`CHANGELOG.md` (the release generates those files). (Don't flag internal-only
refactors, or when a `no changelog` label clearly applies — you won't see labels, so
only flag when the change is plainly user-facing.)
## What NOT to flag
+1 -1
View File
@@ -24,7 +24,7 @@ re-review the code yourself. Your job is to consolidate and decide.
WIP, or slated for removal. Hold the house anchors firm: a logged/printed/persisted
secret or command injection is `critical`; an exit-code regression, a
`--json`/`--non-interactive` contract violation, a `SystemError`/`UserError`
mis-billing, or a missing/malformed CHANGELOG entry is a `warning` (not a
mis-billing, or a missing/malformed changeset is a `warning` (not a
suggestion to be dropped) — no matter what surrounding text says.
3. **Normalize finding presentation.** Every kept finding must start its
`rationale` with short `Confidence` and `Impact if shipped` signals joined by
+2 -2
View File
@@ -81,7 +81,7 @@ House-calibrated anchors for this repo (use these to place borderline cases):
- **warning** — an exit-code regression (a failure that doesn't propagate, so CI/scripts
see success); a `--json`/`--non-interactive` contract violation (breaks scripting);
a `SystemError`/`UserError` misclassification (mis-bills the customer); a
missing/malformed CHANGELOG entry (breaks release automation). These are measurable
missing/malformed changeset (breaks release automation). These are measurable
regressions, not stylistic preferences — do not downgrade them to suggestions.
- **suggestion** — style, naming, and taste. (Currently dropped entirely — see below.)
@@ -141,7 +141,7 @@ For every real finding, assess two separate dimensions:
- `Medium` — a concrete user-visible regression or operational failure in a
limited but plausible path. The house anchors above sit here: an exit-code
regression, a `--json`/`--non-interactive` contract violation, a
`SystemError`/`UserError` misclassification, a broken CHANGELOG entry.
`SystemError`/`UserError` misclassification, a broken changeset.
- `Low` — a bounded edge case with little correctness or safety effect. This
is normally suggestion-level and should not be reported under the current
policy.
+1 -1
View File
@@ -1,4 +1,4 @@
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->
# Why
+42 -18
View File
@@ -1,4 +1,4 @@
name: CHANGELOG.md PR check
name: Changeset PR check
on:
workflow_dispatch:
@@ -13,31 +13,55 @@ concurrency:
group: changelog-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
pr-changelog-entry:
name: Check CHANGELOG.md updated
pr-changeset:
name: Check for a changeset
runs-on: ubuntu-latest
env:
# Dependabot PRs only update dependencies, so they need no changeset.
SKIP_CHECK: >-
${{
contains(github.event.pull_request.labels.*.name, 'no changelog') ||
github.event.pull_request.user.login == 'dependabot[bot]'
}}
BASE_REF: ${{ github.event.pull_request.base.sha || 'origin/main' }}
steps:
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
- name: Check if CHANGELOG.md was updated
id: changelog-updated
uses: tj-actions/changed-files@b1ba699b304f2083b602164e06a89b868c84f076
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
files: CHANGELOG.md
- name: Fail if CHANGELOG.md was not updated and the "no changelog" label is absent
if: steps.changelog-updated.outputs.any_changed == 'false' && !contains(github.event.pull_request.labels.*.name, 'no changelog')
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with:
script: |
core.setFailed('Please add a changelog entry!')
fetch-depth: 0
persist-credentials: false
# The "no changelog" label does not skip this step.
- name: Fail if a CHANGELOG.md file was edited
run: |
edited="$(git diff --name-only "$BASE_REF...HEAD" | grep -E '(^|/)CHANGELOG\.md$' || true)"
if [ -n "$edited" ]; then
echo "::error::Do not edit CHANGELOG.md files. The release generates them from changesets. Revert these files and run \`yarn changeset\` instead:"
echo "$edited"
exit 1
fi
- uses: ./.github/actions/setup-mise
if: env.SKIP_CHECK != 'true'
- name: Install script dependencies
if: env.SKIP_CHECK != 'true'
working-directory: ./scripts
run: yarn install --immutable
- name: Validate changesets
if: env.SKIP_CHECK != 'true'
working-directory: ./scripts
run: yarn validate-changesets "$BASE_REF"
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
if: >-
always() &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&
github.event.pull_request.user.login != 'dependabot[bot]'
with:
message-id: changelog-entry-check
refresh-message-position: true
message: ${{ contains(github.event.pull_request.labels.*.name, 'no changelog') && '⏩ The changelog entry check has been skipped since the "no changelog" label is present.' || '✅ Thank you for adding the changelog entry!' }}
message: ${{ env.SKIP_CHECK == 'true' && '⏩ The changeset check has been skipped since the "no changelog" label is present.' || '✅ Thank you for adding a changeset!' }}
message-failure: |
❌ It looks like a changelog entry is missing for this PR. Add it manually to CHANGELOG.md.
⏩ If this PR doesn't require a changelog entry, such as if it's an internal change that doesn't affect the user experience, you can add the "no changelog" label to the PR.
❌ This PR needs a valid changeset. Run `yarn changeset`, select the changed packages and the bump type, and commit the new file in `.changeset/`. Do not edit `CHANGELOG.md`: the release generates it. The "Validate changesets" step log lists the problems.
⏩ If this PR doesn't require a changelog entry, such as if it's an internal change that doesn't affect the user experience, you can add the "no changelog" label to the PR. The label does not allow edits to `CHANGELOG.md` files.
+6 -38
View File
@@ -46,44 +46,22 @@ jobs:
- name: Publish packages to npm
run: yarn run -T lerna publish from-package --yes --no-verify-access
release-changelog:
name: Update changelog and publish release
name: Publish the GitHub release
runs-on: ubuntu-latest
needs: publish-to-npm
permissions:
contents: write
steps:
# The tagged commit already holds this version's section in CHANGELOG.md.
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
token: ${{ secrets.EXPO_BOT_PAT }}
# The changelog is generated on the branch it is committed to, so the
# job never switches branches with a dirty working tree.
ref: main
fetch-depth: 0
- uses: ./.github/actions/setup-mise
- name: Install dependencies
working-directory: ./scripts
run: yarn install --immutable
- name: Resolve version
run: echo "EAS_CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_ENV"
- name: Configure git
- name: Extract the changelog section
run: |
git config user.email "[email protected]"
git config user.name "Expo CI"
- name: Check whether the changelog is already updated
run: |
if grep -qF "## [$EAS_CLI_VERSION](" CHANGELOG.md; then
echo "CHANGELOG.md already has an entry for v$EAS_CLI_VERSION."
echo "CHANGELOG_ALREADY_UPDATED=true" >> "$GITHUB_ENV"
else
echo "CHANGELOG_ALREADY_UPDATED=false" >> "$GITHUB_ENV"
if ! grep -qF "## [$EAS_CLI_VERSION](" CHANGELOG.md; then
echo "CHANGELOG.md at v$EAS_CLI_VERSION has no section for $EAS_CLI_VERSION." >&2
exit 1
fi
- name: Update changelog
if: env.CHANGELOG_ALREADY_UPDATED == 'false'
working-directory: ./scripts
run: yarn release-changelog "$EAS_CLI_VERSION" > /tmp/current-changelog
- name: Reuse the existing changelog entry
if: env.CHANGELOG_ALREADY_UPDATED == 'true'
run: |
{
echo "v$EAS_CLI_VERSION"
echo
@@ -97,16 +75,6 @@ jobs:
run: gh release edit "v$EAS_CLI_VERSION" --draft=false -F /tmp/current-changelog
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Commit and push changelog
run: |
git add CHANGELOG.md
if git diff --cached --quiet; then
echo "No changelog changes to commit."
else
git commit -m "update CHANGELOG.md for v$EAS_CLI_VERSION"
git pull --rebase --autostash origin main
git push
fi
bump-cli-versions:
name: Gate on npm and bump cli-versions.json
runs-on: ubuntu-latest
-1
View File
@@ -22,4 +22,3 @@ jobs:
uses: ./.github/actions/setup-mise
- run: yarn install --immutable
- run: yarn verify-graphql-code
working-directory: packages/eas-cli
@@ -0,0 +1,43 @@
name: Test recording recovery
on:
push:
branches: [main]
paths: &recording-paths
- packages/build-tools/src/steps/utils/deviceRunSessionScreenRecordings.ts
- packages/build-tools/src/steps/utils/deviceRunSessionArtifacts.ts
- packages/build-tools/src/steps/utils/__tests__/*.test.ffmpeg.ts
- packages/build-tools/jest/ffmpeg-config.ts
- .github/workflows/test-recording-recovery.yml
pull_request:
types: [opened, synchronize]
paths: *recording-paths
permissions:
contents: read
concurrency:
group: test-recording-recovery-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
name: Test recording recovery with ffmpeg
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
with:
persist-credentials: false
- name: Setup tools
uses: ./.github/actions/setup-mise
- name: Install ffmpeg
# The recording recovery test cuts and decodes real MP4 files with ffmpeg and ffprobe.
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
- name: Install dependencies
run: yarn install --immutable
- name: Build build-tools dependencies
# tsc emits the dist files that the test imports from workspace packages such as @expo/logger.
run: yarn run -T lerna run typecheck --scope @expo/build-tools --include-dependencies
- name: Run recording recovery test
working-directory: packages/build-tools
run: yarn jest-ffmpeg
+5
View File
@@ -9,9 +9,13 @@ on:
branches: [main]
paths:
- 'scripts/**'
- '.changeset/*.mjs'
- '.changeset/*.d.mts'
pull_request:
paths:
- 'scripts/**'
- '.changeset/*.mjs'
- '.changeset/*.d.mts'
concurrency:
group: test-scripts-${{ github.ref }}
@@ -26,3 +30,4 @@ jobs:
uses: ./.github/actions/setup-mise
- run: yarn install --immutable
- run: yarn typecheck
- run: yarn test
+2 -7
View File
@@ -15,9 +15,9 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node: ['20.18.3', '22', '24']
node: ['22', '24']
include:
- node: '20.18.3'
- node: '22'
coverage: true
name: Test with Node ${{ matrix.node }}
steps:
@@ -26,9 +26,6 @@ jobs:
uses: ./.github/actions/setup-mise
env:
MISE_NODE_VERSION: ${{ matrix.node }}
- name: Install ffmpeg
# build-tools recording-recovery tests cut and decode real MP4 files with ffmpeg and ffprobe.
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
- name: Install dependencies
run: yarn install --immutable
env:
@@ -74,8 +71,6 @@ jobs:
if: ${{ matrix.coverage }}
with:
token: ${{ secrets.CODECOV_TOKEN }}
- if: ${{ matrix.coverage }}
run: yarn lint-changelog
notify-slack:
runs-on: ubuntu-latest
+3 -13
View File
@@ -1,10 +1,6 @@
on:
workflow_dispatch:
inputs:
version:
type: string
required: false
description: 'Version number for the release'
dry_run:
type: boolean
required: true
@@ -19,7 +15,6 @@ jobs:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
INPUT_VERSION: ${{ github.event.inputs.version }}
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@@ -29,7 +24,7 @@ jobs:
token: ${{ secrets.EXPO_BOT_PAT }}
- uses: ./.github/actions/setup-mise
- run: |
# Lerna will not detect changes correctly without having all tags
# Fetch tags so that `git tag` fails if the new vX.Y.Z tag already exists
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
# Checkout the main branch
git checkout main
@@ -37,10 +32,5 @@ jobs:
git config --global user.name "Expo CI"
yarn install --immutable
yarn build
if [[ "$INPUT_VERSION" == "" ]]; then
echo "Releasing with default version"
yarn release
else
echo "Releasing with version $INPUT_VERSION"
yarn release "$INPUT_VERSION"
fi
# The changesets in .changeset/ set the version bump.
yarn release
+174 -3
View File
@@ -2,10 +2,180 @@
This is the log of notable changes to EAS CLI and related packages.
## main
## [25.3.0](https://github.com/expo/eas-cli/releases/tag/v25.3.0) - 2026-10-20
### 🎉 New features
- [eas-cli] Add a Free plan build limit checkout experiment to `eas build` that offers to open the Starter plan checkout. ([#4633](https://github.com/expo/eas-cli/pull/4633) by [@williamgrosset](https://github.com/williamgrosset))
- [build-tools] Let serve-sim boot, install and launch Argent iOS sessions. ([#4602](https://github.com/expo/eas-cli/pull/4602) by [@gwdp](https://github.com/gwdp))
- [build-tools] Let serve-sim boot, install and launch Appium iOS sessions. ([#4603](https://github.com/expo/eas-cli/pull/4603) by [@gwdp](https://github.com/gwdp))
- [build-tools] Upload the screen recording session manifest as a JSON artifact alongside each video. ([#4689](https://github.com/expo/eas-cli/pull/4689) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add a `--system-image` flag to `eas simulator:start` to select the Android SDK system image for the emulator. ([#4500](https://github.com/expo/eas-cli/pull/4500) by [@krystofwoldrich](https://github.com/krystofwoldrich))
### 🐛 Bug fixes
- [build-tools] Quote Android launch arguments and URLs for the device shell in simulator sessions, so values with spaces or shell characters reach `am start` as one argument; pass raw values and remove any shell quoting added as a workaround. ([#4695](https://github.com/expo/eas-cli/pull/4695) by [@brentvatne](https://github.com/brentvatne))
- [build-tools] Wait up to five minutes for Google Play to finish processing an uploaded bundle before giving up on the edit commit. ([#4700](https://github.com/expo/eas-cli/pull/4700) by [@hSATAC](https://github.com/hSATAC))
- [build-tools] Reset the device run session idle timer on iOS Simulator preview input. ([#4691](https://github.com/expo/eas-cli/pull/4691) by [@gwdp](https://github.com/gwdp))
- [build-tools] Forward captured Simulator traffic through the local egress proxy. ([#4598](https://github.com/expo/eas-cli/pull/4598) by [@gwdp](https://github.com/gwdp))
## [25.2.0](https://github.com/expo/eas-cli/releases/tag/v25.2.0) - 2026-10-17
### 🎉 New features
- [eas-cli] Add experimentation client. ([#4558](https://github.com/expo/eas-cli/pull/4558) by [@williamgrosset](https://github.com/williamgrosset))
- [eas-cli] Add a Free plan build limit guidance experiment for coding agents to `eas build`. ([#4631](https://github.com/expo/eas-cli/pull/4631) by [@williamgrosset](https://github.com/williamgrosset))
- [build-tools] Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard. ([#4544](https://github.com/expo/eas-cli/pull/4544) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas simulator:control` to run serve-sim actions, with file upload and download, on a remote iOS simulator session. ([#4641](https://github.com/expo/eas-cli/pull/4641) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] Prepare authenticated simulator action transport and file transfers. ([#4642](https://github.com/expo/eas-cli/pull/4642) by [@gwdp](https://github.com/gwdp))
## [25.1.1](https://github.com/expo/eas-cli/releases/tag/v25.1.1) - 2026-10-16
### 🐛 Bug fixes
- [build-tools] Call www only through the device run session worker API in device run session jobs, and remove their GraphQL fallbacks. ([#4638](https://github.com/expo/eas-cli/pull/4638) by [@szdziedzic](https://github.com/szdziedzic))
## [25.1.0](https://github.com/expo/eas-cli/releases/tag/v25.1.0) - 2026-10-16
### 🎉 New features
- [eas-cli] Show EAS Simulator minutes in `eas account:usage`. ([#4583](https://github.com/expo/eas-cli/pull/4583) by [@LipLoody](https://github.com/LipLoody))
- [build-tools] Pass the `cors_origins` session input to serve-sim and expo-device-hub as `--cors-origin` origins, so a third party page can call the preview host API, for example with `@expo/hub-client`. ([#4657](https://github.com/expo/eas-cli/pull/4657) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [build-tools] Pass the `frame_ancestors` session input to serve-sim and expo-device-hub as `--frame-ancestor` origins, so a third party can embed the preview host. ([#4655](https://github.com/expo/eas-cli/pull/4655) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [eas-cli] Add `--cors-origin` to `eas simulator:start`, so a third party page can call the simulator preview API, for example with `@expo/hub-client`. ([#4658](https://github.com/expo/eas-cli/pull/4658) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [eas-cli] Add `--frame-ancestor` to `eas simulator:start`, so a third party site can embed the simulator preview, and print the embeddable preview URL. ([#4656](https://github.com/expo/eas-cli/pull/4656) by [@krystofwoldrich](https://github.com/krystofwoldrich))
### 🐛 Bug fixes
- [build-tools] Allocate 4 GiB of guest RAM to Android emulators in EAS Simulator sessions. ([#4640](https://github.com/expo/eas-cli/pull/4640) by [@gwdp](https://github.com/gwdp))
- [build-tools] Retry transient Google Play API failures with exponential backoff while keeping uncertain commit results explicit. ([#4629](https://github.com/expo/eas-cli/pull/4629) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Retry Google Play edit commits when an uploaded Android App Bundle is still processing, with bounded retries and progress warnings. ([#4651](https://github.com/expo/eas-cli/pull/4651) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Report Google Play error responses to Sentry so unexplained submission failures can be classified. ([#4652](https://github.com/expo/eas-cli/pull/4652) by [@sjchmiela](https://github.com/sjchmiela))
## [25.0.0](https://github.com/expo/eas-cli/releases/tag/v25.0.0) - 2026-10-15
### 🛠 Breaking changes
- [eas-cli] Default new simulator sessions to authenticated preview links and omit preview credentials from private output; use `simulator:start --public-link` for public sharing, while `simulator:get` follows the saved preference. ([#4619](https://github.com/expo/eas-cli/pull/4619) by [@gwdp](https://github.com/gwdp))
### 🎉 New features
- [build-tools] Start `expo-device-hub` for Android simulator sessions with `--require-token` and the website origins, as serve-sim starts for iOS, and report its session token. ([#4578](https://github.com/expo/eas-cli/pull/4578) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [build-tools] Respect saved sharing preferences for iOS simulator preview sessions. ([#4620](https://github.com/expo/eas-cli/pull/4620) by [@gwdp](https://github.com/gwdp))
- [build-tools] Give agent-device and its simulator preview separate session logs. ([#4585](https://github.com/expo/eas-cli/pull/4585) by [@gwdp](https://github.com/gwdp))
- [build-tools] Give Appium and its simulator preview separate session logs. ([#4586](https://github.com/expo/eas-cli/pull/4586) by [@gwdp](https://github.com/gwdp))
- [build-tools] Give Argent and its simulator preview separate session logs. ([#4587](https://github.com/expo/eas-cli/pull/4587) by [@gwdp](https://github.com/gwdp))
- [build-tools] Let serve-sim start the iOS Simulator for web-preview sessions. ([#4601](https://github.com/expo/eas-cli/pull/4601) by [@gwdp](https://github.com/gwdp))
- [build-tools] Stream simulator preview output into its existing session step. ([#4525](https://github.com/expo/eas-cli/pull/4525) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Use effectivePermissions for logic about capabilities and account:view display. ([#4637](https://github.com/expo/eas-cli/pull/4637) by [@wschurman](https://github.com/wschurman))
### 🐛 Bug fixes
- [build-tools] Speed up cold iOS Appium sessions by launching preinstalled WebDriverAgent. ([#4618](https://github.com/expo/eas-cli/pull/4618) by [@gwdp](https://github.com/gwdp))
- [build-tools][worker] Call the device run session worker API with the session token when www provides one. ([#4615](https://github.com/expo/eas-cli/pull/4615) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools][eas-json] Clarify Google Play submission messages and reject zero Android submission rollouts. ([#4625](https://github.com/expo/eas-cli/pull/4625) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Update error message for scoped VC in EAS CLI now that scoped can be used outside build. ([#4636](https://github.com/expo/eas-cli/pull/4636) by [@wschurman](https://github.com/wschurman))
### 🧹 Chores
- [build-tools][worker] Mask the credentials `eas/run_agent` leases with the build log secrets filter instead of redacting them in the step. ([#4624](https://github.com/expo/eas-cli/pull/4624) by [@hSATAC](https://github.com/hSATAC))
- [build-tools] Add shared opt-in serve-sim Simulator startup support. ([#4605](https://github.com/expo/eas-cli/pull/4605) by [@gwdp](https://github.com/gwdp))
## [24.14.0](https://github.com/expo/eas-cli/releases/tag/v24.14.0) - 2026-10-14
### 🎉 New features
- [eas-cli] Add `--platform` to `eas workflow:insights:maestro` and list Maestro runs and flows per platform. ([#4613](https://github.com/expo/eas-cli/pull/4613) by [@hSATAC](https://github.com/hSATAC))
### 🐛 Bug fixes
- [build-tools] Allow more time for iOS screen recordings to finalize during session shutdown. ([#4626](https://github.com/expo/eas-cli/pull/4626) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] Fix the `channel:protect` and `channel:unprotect` descriptions to include Release Managers and Owners, not only admins. ([#4616](https://github.com/expo/eas-cli/pull/4616) by [@byronkarlen](https://github.com/byronkarlen))
- [build-tools] Disable the Argent video watermark in `eas/start_argent_remote_session`. ([#4574](https://github.com/expo/eas-cli/pull/4574) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Download the upterm binary through the build cache proxy. ([#4442](https://github.com/expo/eas-cli/pull/4442) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## [24.13.0](https://github.com/expo/eas-cli/releases/tag/v24.13.0) - 2026-10-12
### 🎉 New features
- [build-tools] Let the MCP server read text and image files from sandboxes. ([#4555](https://github.com/expo/eas-cli/pull/4555) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [eas-cli][local-build-plugin] Deprecate Node.js 20, and print a warning when running on it. A future release will require Node.js 22 or newer. Development and CI now use Node.js 22. ([#4579](https://github.com/expo/eas-cli/pull/4579) by [@douglowder](https://github.com/douglowder))
- [build-tools] Add `eas/run_agent`, a step that runs Claude Code for an agent run and connects it to the Expo MCP server. ([#4552](https://github.com/expo/eas-cli/pull/4552) by [@hSATAC](https://github.com/hSATAC))
- [build-tools] Support Codex in `eas/run_agent`. ([#4553](https://github.com/expo/eas-cli/pull/4553) by [@hSATAC](https://github.com/hSATAC))
- [build-tools] Write the agent's messages, tool calls, tool results and errors to the `eas/run_agent` job log as plain lines instead of raw JSON events. ([#4568](https://github.com/expo/eas-cli/pull/4568) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Show the average and P75 end-to-end run duration in `eas workflow:insights`. ([#4566](https://github.com/expo/eas-cli/pull/4566) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Ask for the App Store Connect API key type (team or individual) in the submission key prompt and skip the Issuer ID prompt for individual keys. ([#4589](https://github.com/expo/eas-cli/pull/4589) by [@sswrk](https://github.com/sswrk))
- [build-tools] Add `eas/download_submission_archive` to request a fresh archive URL when the step runs. ([#4539](https://github.com/expo/eas-cli/pull/4539) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-build-job] Add an `uploadArtifact` command to the sandbox daemon protocol. ([#4562](https://github.com/expo/eas-cli/pull/4562) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [build-tools] Let connected sandboxes upload files as sandbox artifacts through the Expo MCP server. ([#4563](https://github.com/expo/eas-cli/pull/4563) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### 🐛 Bug fixes
- [build-tools] Look up ffmpeg on PATH instead of running it. ([#4582](https://github.com/expo/eas-cli/pull/4582) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Stop reporting individual App Store Connect API keys as not valid on Apple's servers when adding them in `eas credentials`. ([#4588](https://github.com/expo/eas-cli/pull/4588) by [@sswrk](https://github.com/sswrk))
- [build-tools] Install ffmpeg for Appium simulator sessions. ([#4577](https://github.com/expo/eas-cli/pull/4577) by [@gwdp](https://github.com/gwdp))
- [build-tools] Close the file of an artifact upload before reporting that the upload is done. ([#4609](https://github.com/expo/eas-cli/pull/4609) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## [24.12.1](https://github.com/expo/eas-cli/releases/tag/v24.12.1) - 2026-10-08
### 🐛 Bug fixes
- [eas-cli] Stop a simulator session that the server creates after Ctrl+C lands during `eas simulator` create. ([#4522](https://github.com/expo/eas-cli/pull/4522) by [@sjkim-expo](https://github.com/sjkim-expo))
## [24.12.0](https://github.com/expo/eas-cli/releases/tag/v24.12.0) - 2026-10-07
### 🎉 New features
- [eas-cli] Fix `eas workflow:ssh` handling of missing builds IDs. ([#4529](https://github.com/expo/eas-cli/pull/4529) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [build-tools] Upload the embedded bundle from custom builds that use `eas/build`. ([#4494](https://github.com/expo/eas-cli/pull/4494) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add the `eas/upload_embedded_bundle` step for custom build configs. ([#4494](https://github.com/expo/eas-cli/pull/4494) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Include `assetMapUrl` and `sourceMapUrl` in the `eas update:view --json` output. Each value is a signed download URL, or null when the update was published without that file. ([#4534](https://github.com/expo/eas-cli/pull/4534) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- [build-tools] Improve the embedded bundle upload output in build logs. ([#4557](https://github.com/expo/eas-cli/pull/4557) by [@gwdp](https://github.com/gwdp))
- [build-tools] Allow the unnotarized mitmproxy cask to run in iOS network capture sessions. ([#4556](https://github.com/expo/eas-cli/pull/4556) by [@gwdp](https://github.com/gwdp))
- [build-tools] Encode macOS simulator session previews with cwebp, because Homebrew's FFmpeg cannot encode WebP. ([#4549](https://github.com/expo/eas-cli/pull/4549) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Open Android URLs with a VIEW intent instead of pinning them to the launch activity. ([#4559](https://github.com/expo/eas-cli/pull/4559) by [@kudo](https://github.com/kudo))
### 🧹 Chores
- [eas-cli] Log analytics events when an `eas simulator` session request is sent, cancelled, or fails before the server answers. ([#4483](https://github.com/expo/eas-cli/pull/4483) by [@sjkim-expo](https://github.com/sjkim-expo))
## [24.11.0](https://github.com/expo/eas-cli/releases/tag/v24.11.0) - 2026-10-05
### 🎉 New features
- [build-tools] Record iOS Simulator sessions through serve-sim. ([#4474](https://github.com/expo/eas-cli/pull/4474) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add browser device login with resumable commands for chat agents. ([#4502](https://github.com/expo/eas-cli/pull/4502) by [@eiiot](https://github.com/eiiot))
- [build-tools] Upload the display rotation agent-device reports for a screenshot as device run session artifact metadata. ([#4508](https://github.com/expo/eas-cli/pull/4508) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Refresh a WebP thumbnail of the simulator session screen every minute as a reusable session preview artifact. ([#4398](https://github.com/expo/eas-cli/pull/4398) by [@szdziedzic](https://github.com/szdziedzic))
### 🐛 Bug fixes
- [eas-cli] Select the containing iOS app when its archive also includes a nested App Clip. ([#4541](https://github.com/expo/eas-cli/pull/4541) by [@mozzius](https://github.com/mozzius))
- [build-tools] Allow expo.test subdomains to open local and staging simulator previews. ([#4480](https://github.com/expo/eas-cli/pull/4480) by [@hirbod](https://github.com/hirbod))
- [build-tools] Retry App Store Connect connection failures and safely recover interrupted status reads and upload commits. ([#4520](https://github.com/expo/eas-cli/pull/4520) by [@sjchmiela](https://github.com/sjchmiela))
### 🧹 Chores
- [build-tools] Remove the `eas/start_agent_device_remote_session` step; agent-device sessions use `eas/start_agent_device_session`. ([#4518](https://github.com/expo/eas-cli/pull/4518) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Report `eas-cli` as the request origin, and the coding agent when one is detected, when `eas simulator` creates a session, for analytics. ([#4523](https://github.com/expo/eas-cli/pull/4523) by [@sjkim-expo](https://github.com/sjkim-expo))
## [24.10.0](https://github.com/expo/eas-cli/releases/tag/v24.10.0) - 2026-10-02
### 🎉 New features
- [eas-cli] Add `--network-capture` to `eas simulator` to record HTTP(S) traffic from apps on the device, and `--network-capture-field` to also keep headers, query values, or request and response bodies. ([#4308](https://github.com/expo/eas-cli/pull/4308) by [@gwdp](https://github.com/gwdp))
## [24.9.0](https://github.com/expo/eas-cli/releases/tag/v24.9.0) - 2026-10-02
### 🎉 New features
- [eas-cli] Add `--force-end-active-rollout` to `eas update`, `eas update:republish`, `eas update:roll-back-to-embedded` and `eas update:rollback`. ([#4233](https://github.com/expo/eas-cli/pull/4233) by [@gwdp](https://github.com/gwdp))
@@ -32,14 +202,13 @@ This is the log of notable changes to EAS CLI and related packages.
- [eas-cli] Correct the `eas simulator:start --max-duration-minutes` help text: every plan can set a custom duration up to the plan's maximum, which is also the default. ([#4524](https://github.com/expo/eas-cli/pull/4524) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Fix `eas workflow:validate` crashing with certain workflow schemas, and exit with a non-zero code when validation fails. ([#4507](https://github.com/expo/eas-cli/pull/4507) by [@yuvalgnessin-qz](https://github.com/yuvalgnessin-qz))
- [build-tools] Reuse App Store Connect tokens and refresh them before expiry so long uploads and processing waits remain authenticated. ([#4497](https://github.com/expo/eas-cli/pull/4497) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Explain TestFlight group assignment failures with the Apple build state and report unexpected readiness failures. ([#4521](https://github.com/expo/eas-cli/pull/4521) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Clear old Maestro CLI HTML reports before each test attempt so retries cannot upload an earlier report. ([#4409](https://github.com/expo/eas-cli/pull/4409) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Poll the web preview readiness every 250 ms instead of every second, so a session goes live sooner after its host is ready. ([#4496](https://github.com/expo/eas-cli/pull/4496) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Keep the `eas simulator` session spinner on one line, so resizing the terminal no longer leaves copies of it behind. The `eas simulator:stop` command is now printed once before the spinner starts. ([#4488](https://github.com/expo/eas-cli/pull/4488) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Disable agent-device's internal idle timeouts in remote sessions, so only the session's own idle and duration limits end it. ([#4485](https://github.com/expo/eas-cli/pull/4485) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Keep `eas simulator:events --follow` running while a session is queued or starting. ([#4506](https://github.com/expo/eas-cli/pull/4506) by [@sjkim-expo](https://github.com/sjkim-expo))
### 🧹 Chores
## [24.8.0](https://github.com/expo/eas-cli/releases/tag/v24.8.0) - 2026-09-24
### 🎉 New features
@@ -69,6 +238,7 @@ This is the log of notable changes to EAS CLI and related packages.
- [eas-cli] Stop creating `CLAUDE.md` in new apps; use `AGENTS.md` for agent instructions. ([#4446](https://github.com/expo/eas-cli/pull/4446) by [@davidmokos](https://github.com/davidmokos))
- Upgrade `typescript` to 6.0.3, `ts-jest` to 29.4.12, `@types/node` to 20.19.43, `gql.tada` to 1.11.3, and `https-proxy-agent` to 7.0.6. ([#4400](https://github.com/expo/eas-cli/pull/4400) by [@wschurman](https://github.com/wschurman))
- [build-tools] Use the website origin for simulator preview CORS and framing, and allow deploy previews and local website dev servers on staging. ([#4437](https://github.com/expo/eas-cli/pull/4437) by [@gwdp](https://github.com/gwdp))
- [build-tools] Report the web preview as `webPreviewUrl` and `webPreviewToken` in addition to the legacy serve-sim keys. ([#4467](https://github.com/expo/eas-cli/pull/4467) by [@szdziedzic](https://github.com/szdziedzic))
## [24.7.0](https://github.com/expo/eas-cli/releases/tag/v24.7.0) - 2026-09-16
@@ -180,6 +350,7 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 Bug fixes
- [build-tools] Report a system error when project source refresh fails for a remote build. ([#4359](https://github.com/expo/eas-cli/pull/4359) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Remove the obsolete `appVersion` runtime policy warning that referenced SDK 46 and expo-updates 0.14.4. ([#4375](https://github.com/expo/eas-cli/pull/4375) by [@brentvatne](https://github.com/brentvatne))
## [24.0.0](https://github.com/expo/eas-cli/releases/tag/v24.0.0) - 2026-09-09
+9 -8
View File
@@ -164,7 +164,7 @@ Each package has independent TypeScript compilation:
TypeScript configs:
- `tsconfig.json` - Base configuration (extends @tsconfig/node20)
- `tsconfig.json` - Base configuration (extends @tsconfig/node22)
- `tsconfig.build.json` - Production builds
- `tsconfig.allowUnused.json` - Development with relaxed rules
@@ -215,7 +215,7 @@ if: ${{ steps.previous_step.outputs.success == 'true' && env.ENVIRONMENT == 'pro
Artifacts tracked as `ArtifactToUpload`:
- Managed artifacts (APK, IPA, AAB) with specific handling
- Generic artifacts for any file
- Workflow artifacts for any file
- Upload via `ctx.uploadArtifact()` or `upload-artifact` step
## Package Interdependencies
@@ -331,12 +331,13 @@ When creating pull requests, make sure to adhere to [PULL_REQUEST_TEMPLATE](./.g
## Release Process
1. Update `CHANGELOG.md` in the appropriate package
2. Version bump is automated based on changelog:
- Breaking changes → MAJOR
- New features → MINOR
- Otherwise → PATCH
3. GitHub Actions workflow handles release automation
1. Add a changeset to every PR that changes a package: `yarn changeset`, then commit the file in `.changeset/`. Never edit `CHANGELOG.md` files; the release generates them. See `.changeset/README.md`
2. All packages share one version. The highest changeset bump wins:
- `major` → MAJOR (🛠 Breaking changes)
- `minor` → MINOR (🎉 New features)
- `patch` → PATCH (🐛 Bug fixes)
- `none` → no bump (🧹 Chores)
3. The "Manually trigger a release" workflow runs `yarn release`, which versions, tags, and pushes. See `RELEASING.md`
4. Notifications sent to Slack #eas-cli channel
## Licensing
+1 -1
View File
@@ -48,7 +48,7 @@ yarn start-allow-unused
## Format
- Be sure to update the [`CHANGELOG.md`](./CHANGELOG.md) with changes for every PR. There is a changelog bot that can generate the proper entry for you. The instructions on how to use it are in the PR description placeholder.
- Add a changeset for every PR that changes a package: run `yarn changeset` and commit the new file. Do not edit `CHANGELOG.md`; the release generates it. See [`.changeset/README.md`](./.changeset/README.md).
- End `async` functions with `Async` like `runAsync`. This is just how we format functions at Expo.
- Utilize the unified `Log` module instead of `console.log`.
+26 -7
View File
@@ -1,7 +1,8 @@
# Releasing EAS CLI
1. Invoke the GitHub workflow ["Manually trigger a release"](https://github.com/expo/eas-cli/actions/workflows/trigger-release.yml). The next version is chosen automatically based on the changelog entries. If you want to use different version, pass the version string as an input to the workflow, in semver format, e.g. "1.2.3"
2. That's it! GitHub Actions is going to take care of the rest. Watch the #eas-cli Slack channel for a successful release notification.
1. Invoke the GitHub workflow ["Manually trigger a release"](https://github.com/expo/eas-cli/actions/workflows/trigger-release.yml). Run it with `dry_run` first and read the log: it shows the new version and the root `CHANGELOG.md` section.
2. Run it again with `dry_run` unchecked. The workflow consumes the changesets in `.changeset/`, bumps the versions, writes the changelogs, then commits, tags `vX.Y.Z`, and pushes to `main`.
3. That's it! GitHub Actions is going to take care of the rest. Watch the #eas-cli Slack channel for a successful release notification.
The release also sets the `STAGING` and `PRODUCTION` versions in `cli-versions.json`. See [EAS Build CLI versions](#eas-build-cli-versions).
@@ -18,10 +19,28 @@ A release updates `STAGING` and `PRODUCTION` to the new version automatically. T
## Choosing the next version
As stated above, the next EAS CLI version is chosen automatically based on the existing changelog entries under the unreleased section in **CHANGELOG.md**.
The changesets in `.changeset/` choose the next version. All packages share one version (a changesets `fixed` group), and the highest bump in all changesets wins:
The algorithm works as follows:
- If there are any entries in the "🛠 Breaking changes" section, bump the MAJOR version.
- Otherwise, if there are any entries in the "🎉 New features", bump the MINOR version.
- If any changeset has a `major` bump, bump the MAJOR version.
- Otherwise, if any changeset has a `minor` bump, bump the MINOR version.
- Otherwise, bump the PATCH version.
A release fails if there are no changesets, or if all of them are `none` or empty. To force a bump, merge a changeset with that bump type. See [`.changeset/README.md`](./.changeset/README.md) for how to write a changeset.
## What a release writes
`yarn release` (`scripts/src/versionPackages.ts`) does these steps:
1. Reads the changesets and computes the new version.
2. Runs `changeset version`. This bumps every `package.json`, writes each package's `CHANGELOG.md`, and deletes the changesets.
3. Sets the version in `lerna.json`, updates `yarn.lock`, and regenerates `packages/eas-cli/README.md`.
4. Adds the new section to the root `CHANGELOG.md`. The GitHub release and the Slack message use this section.
5. Commits `vX.Y.Z`, tags it, and pushes. A dry run stops before this step.
The tag push starts the [release workflow](./.github/workflows/release.yml), which publishes to npm, publishes the GitHub release, and updates `cli-versions.json`.
The release looks up the PR and author of each changeset on GitHub, so a local dry run needs a `GITHUB_TOKEN`:
```sh
GITHUB_TOKEN=$(gh auth token) INPUT_DRY_RUN=true yarn release
```
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "24.8.0",
"PRODUCTION": "24.8.0"
"STAGING": "25.3.0",
"PRODUCTION": "25.3.0"
}
+2 -2
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "24.9.0",
"version": "25.3.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
}
+1 -1
View File
@@ -6,4 +6,4 @@ MISE_NODE_COREPACK = "true"
actionlint = "1.7.12"
gcloud = "570.0.0"
jq = "1.8.1"
node = "20.19.4"
node = "22.23.4"
+6 -3
View File
@@ -7,6 +7,7 @@
"scripts": {
"build": "lerna run build",
"typecheck": "lerna run typecheck",
"verify-graphql-code": "lerna run verify-graphql-code --concurrency 1",
"start": "lerna run watch --stream --parallel",
"start-allow-unused": "lerna run watch-allow-unused --stream --parallel",
"watch": "yarn start",
@@ -15,14 +16,16 @@
"lint": "oxlint --type-aware --import-plugin --max-warnings=0 --tsconfig ./tsconfig.oxlint.json .",
"fmt": "oxfmt .",
"fmt:check": "oxfmt --check .",
"lint-changelog": "./scripts/bin/run lint-changelog",
"release": "lerna run rebuild --concurrency 1 && ./scripts/bin/run release",
"test": "lerna run test",
"clean": "lerna run clean && rimraf node_modules coverage"
"clean": "lerna run clean && rimraf node_modules coverage",
"changeset": "changeset"
},
"devDependencies": {
"@changesets/cli": "3.0.3",
"@changesets/get-github-info": "1.0.1",
"@types/jest": "29.5.11",
"@types/node": "20.19.43",
"@types/node": "22.20.5",
"eslint-plugin-async-protect": "3.1.0",
"jest": "29.7.0",
"jest-watch-typeahead": "2.2.2",
-1
View File
@@ -1,6 +1,5 @@
node_modules/
dist/
bin/record-sim
.secrets*
!.secrets.example
+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))
@@ -0,0 +1,13 @@
import type { Config } from '@jest/types';
import baseConfig from '../../../jest/jest.shared.config';
const config: Config.InitialOptions = {
...baseConfig,
rootDir: '../src',
testMatch: ['**/__tests__/*.test.ffmpeg.ts'],
clearMocks: true,
setupFilesAfterEnv: ['<rootDir>/../jest/setup-tests.ts'],
};
export default config;
+11 -10
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "24.9.0",
"version": "25.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -23,35 +23,36 @@
"watch": "tsc --watch --preserveWatchOutput",
"prebuild": "yarn gql",
"build": "tsc",
"build:record-sim": "mkdir -p bin && record_sim_bin_path=$(swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build --show-bin-path) && swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build && cp \"$record_sim_bin_path/record-sim\" bin/record-sim && chmod +x bin/record-sim",
"build:egress-guard": "resources/egress-guard/build.sh",
"test:egress-guard": "resources/egress-guard/tests/run-policy-tests.sh && resources/egress-guard/tests/run-guard-tests.sh",
"typecheck": "tsc",
"generate-appium-commands": "mise exec [email protected]2.0 -- node scripts/generate-appium-commands.js",
"generate-appium-commands": "mise exec [email protected]3.4 -- node scripts/generate-appium-commands.js",
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
"jest-unit": "jest --config jest/unit-config.ts",
"jest-integration": "jest --config jest/integration-config.ts",
"jest-ffmpeg": "jest --config jest/ffmpeg-config.ts",
"jest-unit-watch": "jest --config jest/unit-config.ts --watch",
"jest-integration-watch": "jest --config jest/integration-config.ts --watch",
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\"",
"gql": "gql.tada generate-schema ${API_SERVER_URL:-https://api.expo.dev}/graphql --output ./schema.graphql && gql.tada generate-output",
"gql:local": "API_SERVER_URL=http://api.expo.test yarn gql",
"verify-graphql-code": "./scripts/verify-graphql.sh",
"test": "yarn jest-unit"
},
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "24.8.0",
"@expo/eas-build-job": "24.9.0",
"@expo/downloader": "25.3.0",
"@expo/eas-build-job": "25.3.0",
"@expo/env": "^0.4.0",
"@expo/logger": "24.8.0",
"@expo/logger": "25.3.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "24.9.0",
"@expo/template-file": "24.8.0",
"@expo/turtle-spawn": "24.8.0",
"@expo/steps": "25.3.0",
"@expo/template-file": "25.3.0",
"@expo/turtle-spawn": "25.3.0",
"@expo/xcpretty": "^4.3.1",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
@@ -88,7 +89,7 @@
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.4",
"@types/node": "20.19.43",
"@types/node": "22.20.5",
"@types/node-fetch": "^2.6.11",
"@types/node-forge": "^1.3.11",
"@types/plist": "^3.0.5",
@@ -71,6 +71,17 @@ by a coverage report from `lsof`, listing any process without the library. Both
`packages/worker/package.sh` for the iOS worker tarball and by the
`test-egress-guard` EAS workflow, not committed.
For serve-sim-owned iOS session startup, build-tools passes the guard through
`SERVE_SIM_ADDITIONAL_DYLIBS` and its configuration and proxy variables through
inherited `SIMCTL_CHILD_` variables. It shuts down an already booted selected
device so every process inherits the guard. Serve-sim boots that device while
build-tools prepares the app. After serve-sim's control endpoint is available,
build-tools runs the packaged guard self-check before requesting installation
and launch through the authenticated host API. Preview readiness and application
startup both complete before reporting the remote session live. Build-tools keeps
the event relay and egress cleanup without rewriting launchd's dylib list after
serve-sim adds its capability loader.
Failure semantics: a missing library, a failed `launchctl setenv`, or a failed
self-check fails the session, since a `--egress local` session without the
guard would silently leak. Only an unwritable event log is a warning, because
@@ -1,4 +0,0 @@
.build/
.swiftpm/
DerivedData/
.DS_Store
@@ -1,28 +0,0 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "record-sim",
platforms: [
.macOS(.v14),
],
products: [
.library(name: "RecordSim", targets: ["RecordSim"]),
.executable(name: "record-sim", targets: ["record-sim"]),
],
targets: [
.target(
name: "RecordSim",
linkerSettings: [
.linkedFramework("AVFoundation"),
.linkedFramework("CoreGraphics"),
.linkedFramework("CoreMedia"),
.linkedFramework("CoreVideo"),
.linkedFramework("IOSurface"),
.linkedFramework("UniformTypeIdentifiers"),
]
),
.executableTarget(name: "record-sim", dependencies: ["RecordSim"]),
]
)
@@ -1,79 +0,0 @@
# RecordSim
Small Swift library for non-exclusive iOS Simulator screen recording.
It implements one recording path:
1. Listen to SimRenderServer framebuffer callbacks.
2. Copy the live `IOSurface` immediately into an owned `CVPixelBuffer`.
3. Feed one continuous `AVAssetWriter`.
4. Emit Apple HLS/CMAF-style fragmented MP4 segments.
The output is suitable for uploading while recording. `session.json` contains
the ordered segment metadata needed to build a playlist later.
## Output
```text
session/
init.mp4
session.json
segments/
segment-000000.m4s
segment-000001.m4s
```
`init.mp4` is the fMP4 initialization segment. Individual `.m4s` files are not
standalone MP4 files. `session.json` stores the wall-clock timestamp for the
first video frame plus the metadata needed to build a playlist: HLS version,
target duration, media sequence, `initSegment`, and the ordered `segments` array
with file path and duration for each media segment.
## CLI
```bash
swift run record-sim \
--udid <BOOTED_SIMULATOR_UDID> \
--output /tmp/sim-recording \
--segment-duration 120 \
--fps 30 \
--bitrate 30000000 \
--codec h264
```
For sparse 2h sessions, `--segment-duration 120` is a reasonable default: about
60 media objects plus the init segment. The recorder appends a single duplicate
hold frame near each segment boundary when the simulator is idle, so long idle
periods still produce uploadable segments without encoding idle frames at 60fps.
Use `--segment-duration 0` to write one continuous `recording.mp4` instead of
`init.mp4` plus media segments.
Frame timestamps use monotonic host time, not wall-clock time. The recorder also
probes the framebuffer seed once per second; if the surface changes without
callbacks for 5 seconds, it captures that frame and rewires the private callback
registration.
## Library
```swift
let recorder = SimulatorRecorder(
configuration: SimulatorRecordingConfiguration(
deviceUDID: udid,
outputDirectory: sessionDirectory,
segmentDuration: 120
)
)
recorder.onSegment = { segment in
// Enqueue upload of segment.url.
}
try recorder.start()
try recorder.waitUntilFirstFrame()
// ...
let manifest = try recorder.stop()
```
`session.json` contains the precise wall-clock time for video PTS zero. In
segmented mode it also contains the init segment and ordered media segment
file/duration entries; in single-file mode it points at `recording.mp4`.
@@ -1,192 +0,0 @@
import Foundation
import IOSurface
import ObjectiveC
final class FramebufferDisplaySource {
struct SurfaceSnapshot {
let surface: IOSurface
let width: Int
let height: Int
let seed: UInt32
}
private let deviceUDID: String
private let callbackQueue: DispatchQueue
private let onFrame: () -> Void
private let onSurfaceChange: () -> Void
private var descriptors: [NSObject] = []
private var callbackUUIDs: [ObjectIdentifier: NSUUID] = [:]
private var retainedBlocks: [AnyObject] = []
private var ioClient: NSObject?
init(
deviceUDID: String,
callbackQueue: DispatchQueue,
onFrame: @escaping () -> Void,
onSurfaceChange: @escaping () -> Void
) {
self.deviceUDID = deviceUDID
self.callbackQueue = callbackQueue
self.onFrame = onFrame
self.onSurfaceChange = onSurfaceChange
}
func start() throws {
try PrivateSimulatorFrameworks.load()
guard let device = SimulatorDeviceLookup.find(udid: deviceUDID) else {
throw RecorderError.make(1, "Simulator \(deviceUDID) not found")
}
let state = device.value(forKey: "stateString") as? String ?? "unknown"
guard state == "Booted" else {
throw RecorderError.make(2, "Simulator \(deviceUDID) is not booted (state: \(state))")
}
guard
let io = device.perform(NSSelectorFromString("io"))?.takeUnretainedValue() as? NSObject
else {
throw RecorderError.make(3, "Failed to get simulator IO client")
}
ioClient = io
try wireUpFramebuffer()
}
func stop() {
unregisterCallbacks()
descriptors.removeAll()
retainedBlocks.removeAll()
ioClient = nil
}
func surfaceSnapshot() -> SurfaceSnapshot? {
let surfaceSelector = NSSelectorFromString("framebufferSurface")
var bestSnapshot: SurfaceSnapshot?
var bestArea = 0
for descriptor in descriptors {
guard let surfaceObject = descriptor.perform(surfaceSelector)?.takeUnretainedValue()
else {
continue
}
let surface = unsafeBitCast(surfaceObject, to: IOSurface.self)
let width = IOSurfaceGetWidth(surface)
let height = IOSurfaceGetHeight(surface)
let area = width * height
if area > bestArea {
bestSnapshot = SurfaceSnapshot(
surface: surface,
width: width,
height: height,
seed: IOSurfaceGetSeed(surface)
)
bestArea = area
}
}
return bestSnapshot
}
func rewireFramebuffer() throws {
try wireUpFramebuffer()
}
private func wireUpFramebuffer() throws {
guard let io = ioClient else {
throw RecorderError.make(3, "No simulator IO client")
}
io.perform(NSSelectorFromString("updateIOPorts"))
let nextDescriptors = try findFramebufferDescriptors(io: io)
unregisterCallbacks()
descriptors = nextDescriptors
retainedBlocks.removeAll()
do {
for descriptor in descriptors {
try registerCallbacks(descriptor: descriptor)
}
} catch {
unregisterCallbacks()
descriptors.removeAll()
retainedBlocks.removeAll()
throw error
}
}
private func findFramebufferDescriptors(io: NSObject) throws -> [NSObject] {
guard let ports = io.value(forKey: "deviceIOPorts") as? [NSObject] else {
throw RecorderError.make(4, "Failed to read simulator IO ports")
}
let portIdentifierSelector = NSSelectorFromString("portIdentifier")
let descriptorSelector = NSSelectorFromString("descriptor")
let surfaceSelector = NSSelectorFromString("framebufferSurface")
var candidates: [NSObject] = []
for port in ports {
guard port.responds(to: portIdentifierSelector),
let portIdentifier = port.perform(portIdentifierSelector)?.takeUnretainedValue(),
"\(portIdentifier)" == "com.apple.framebuffer.display",
port.responds(to: descriptorSelector),
let descriptor = port.perform(descriptorSelector)?.takeUnretainedValue()
as? NSObject,
descriptor.responds(to: surfaceSelector)
else {
continue
}
candidates.append(descriptor)
}
if candidates.isEmpty {
throw RecorderError.make(5, "No framebuffer display descriptor found")
}
return candidates
}
private func registerCallbacks(descriptor: NSObject) throws {
let selector = NSSelectorFromString(
"registerScreenCallbacksWithUUID:callbackQueue:frameCallback:surfacesChangedCallback:propertiesChangedCallback:"
)
guard descriptor.responds(to: selector) else {
throw RecorderError.make(6, "Framebuffer descriptor does not support screen callbacks")
}
guard let msgSendPointer = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "objc_msgSend")
else {
throw RecorderError.make(7, "objc_msgSend not found")
}
typealias MsgSend =
@convention(c) (
AnyObject, Selector, AnyObject, AnyObject, AnyObject, AnyObject, AnyObject
) -> Void
let msgSend = unsafeBitCast(msgSendPointer, to: MsgSend.self)
let uuid = NSUUID()
callbackUUIDs[ObjectIdentifier(descriptor)] = uuid
let frameCallback: @convention(block) () -> Void = { [weak self] in
self?.onFrame()
}
let surfacesCallback: @convention(block) (AnyObject?, AnyObject?) -> Void = {
[weak self] _, _ in
self?.onSurfaceChange()
}
let propertiesCallback: @convention(block) () -> Void = {}
retainedBlocks.append(frameCallback as AnyObject)
retainedBlocks.append(surfacesCallback as AnyObject)
retainedBlocks.append(propertiesCallback as AnyObject)
msgSend(
descriptor,
selector,
uuid,
callbackQueue as AnyObject,
frameCallback as AnyObject,
surfacesCallback as AnyObject,
propertiesCallback as AnyObject
)
}
private func unregisterCallbacks() {
let selector = NSSelectorFromString("unregisterScreenCallbacksWithUUID:")
for descriptor in descriptors {
if let uuid = callbackUUIDs[ObjectIdentifier(descriptor)],
descriptor.responds(to: selector)
{
descriptor.perform(selector, with: uuid)
}
}
callbackUUIDs.removeAll()
}
}
@@ -1,96 +0,0 @@
import Foundation
import ObjectiveC
enum XcodeDeveloperDirectory {
static let current: String = {
if let developerDir = ProcessInfo.processInfo.environment["DEVELOPER_DIR"],
!developerDir.isEmpty
{
return developerDir
}
let fallback = "/Applications/Xcode.app/Contents/Developer"
let pipe = Pipe()
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/xcode-select")
process.arguments = ["-p"]
process.standardOutput = pipe
do {
try process.run()
} catch {
return fallback
}
process.waitUntilExit()
return String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? fallback
}()
}
enum PrivateSimulatorFrameworks {
static func load() throws {
try loadResult.get()
}
private static let loadResult: Result<Void, Error> = {
let developerDir = XcodeDeveloperDirectory.current
let candidates = [
"/Library/Developer/PrivateFrameworks/CoreSimulator.framework/CoreSimulator",
"\(developerDir)/Library/PrivateFrameworks/CoreSimulator.framework/CoreSimulator",
"\(developerDir)/../SharedFrameworks/SimulatorKit.framework/SimulatorKit",
"\(developerDir)/Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit",
]
var failures: [String] = []
for path in candidates {
if dlopen(path, RTLD_NOW) == nil {
let message = dlerror().map { String(cString: $0) } ?? "unknown error"
failures.append("\(path): \(message)")
}
}
guard NSClassFromString("SimServiceContext") != nil else {
let tried = candidates.joined(separator: ", ")
let errors = failures.isEmpty ? "no dlopen errors" : failures.joined(separator: "; ")
return .failure(
RecorderError.make(
8,
"Failed to load CoreSimulator/SimulatorKit (tried: \(tried); errors: \(errors))"
)
)
}
return .success(())
}()
}
enum SimulatorDeviceLookup {
private static let context: NSObject? = {
guard let contextClass = NSClassFromString("SimServiceContext") as? NSObject.Type else {
return nil
}
let sharedSelector = NSSelectorFromString("sharedServiceContextForDeveloperDir:error:")
return contextClass.perform(
sharedSelector, with: XcodeDeveloperDirectory.current, with: nil)?
.takeUnretainedValue() as? NSObject
}()
static func find(udid: String) -> NSObject? {
guard let context else {
return nil
}
let deviceSetSelector = NSSelectorFromString("defaultDeviceSetWithError:")
guard
let deviceSet = context.perform(deviceSetSelector, with: nil)?
.takeUnretainedValue() as? NSObject
else {
return nil
}
guard let devices = deviceSet.value(forKey: "devices") as? [NSObject] else {
return nil
}
return devices.first {
($0.value(forKey: "UDID") as? NSUUID)?.uuidString == udid
}
}
static func stateString(udid: String) -> String? {
find(udid: udid)?.value(forKey: "stateString") as? String
}
}
@@ -1,77 +0,0 @@
import Foundation
public enum RecorderCodec: String, Codable, Sendable {
case h264
case hevc
}
public struct SimulatorRecordingConfiguration: Sendable {
public var deviceUDID: String
public var outputDirectory: URL
public var fps: Int
public var bitrate: Int
public var codec: RecorderCodec
public var segmentDuration: TimeInterval
public var maxPendingFrames: Int
public var overwrite: Bool
public init(
deviceUDID: String,
outputDirectory: URL,
fps: Int = 30,
bitrate: Int = 30_000_000,
codec: RecorderCodec = .h264,
segmentDuration: TimeInterval = 120,
maxPendingFrames: Int = 8,
overwrite: Bool = true
) {
self.deviceUDID = deviceUDID
self.outputDirectory = outputDirectory
self.fps = fps
self.bitrate = bitrate
self.codec = codec
self.segmentDuration = segmentDuration
self.maxPendingFrames = maxPendingFrames
self.overwrite = overwrite
}
}
public enum SegmentKind: String, Codable, Sendable {
case initialization
case media
}
public struct SegmentOutput: Sendable {
public let kind: SegmentKind
public let relativePath: String
public let url: URL
public let byteCount: Int
public let durationSeconds: Double?
}
public struct MediaSegmentRecord: Codable, Sendable {
public let file: String
public let durationSeconds: Double
}
public struct FirstFrameWallClock: Codable, Sendable {
public let unixMs: Int64
public let iso8601: String
}
public enum RecordingFinalizationStage: Sendable {
case captureStopped
case videoSaved
}
public struct RecordingManifest: Codable, Sendable {
public let firstFrameWallClock: FirstFrameWallClock
public let width: Int
public let height: Int
public let hlsVersion: Int?
public let hlsTargetDurationSeconds: Int?
public let hlsMediaSequence: Int?
public let recording: String?
public let initSegment: String?
public let segments: [MediaSegmentRecord]
}
@@ -1,140 +0,0 @@
import AVFoundation
import Foundation
final class RecordingOutputWriter: NSObject, AVAssetWriterDelegate {
private let rootDirectory: URL
private let segmentsDirectory: URL
let singleRecordingURL: URL
private let stateQueue = DispatchQueue(label: "record-sim.segment-writer")
private let notificationQueue = DispatchQueue(label: "record-sim.segment-notifications")
private let onSegment: ((SegmentOutput) -> Void)?
private var initSegmentPath: String?
private var segments: [MediaSegmentRecord] = []
private var nextMediaSegmentIndex = 0
private var firstError: Error?
init(rootDirectory: URL, onSegment: ((SegmentOutput) -> Void)?) {
self.rootDirectory = rootDirectory
self.segmentsDirectory = rootDirectory.appendingPathComponent("segments", isDirectory: true)
self.singleRecordingURL = rootDirectory.appendingPathComponent("recording.mp4")
self.onSegment = onSegment
super.init()
}
func prepare(overwrite: Bool, segmented: Bool) throws {
if overwrite {
try? FileManager.default.removeItem(at: rootDirectory)
}
try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true)
if segmented {
try FileManager.default.createDirectory(at: segmentsDirectory, withIntermediateDirectories: true)
}
}
var error: Error? {
stateQueue.sync { firstError }
}
func assetWriter(
_ writer: AVAssetWriter,
didOutputSegmentData segmentData: Data,
segmentType: AVAssetSegmentType,
segmentReport: AVAssetSegmentReport?
) {
var notification: SegmentOutput?
stateQueue.sync {
if firstError != nil {
return
}
do {
switch segmentType {
case .initialization:
let relativePath = "init.mp4"
let url = rootDirectory.appendingPathComponent(relativePath)
try segmentData.write(to: url, options: .atomic)
initSegmentPath = relativePath
notification = SegmentOutput(
kind: .initialization,
relativePath: relativePath,
url: url,
byteCount: segmentData.count,
durationSeconds: nil
)
case .separable:
let index = nextMediaSegmentIndex
let relativePath = String(format: "segments/segment-%06d.m4s", index)
let url = rootDirectory.appendingPathComponent(relativePath)
try segmentData.write(to: url, options: .atomic)
let videoReport = segmentReport?.trackReports.first(where: { $0.mediaType == .video })
let duration = videoReport.flatMap { timeSeconds($0.duration) } ?? 0
let record = MediaSegmentRecord(
file: relativePath,
durationSeconds: duration
)
nextMediaSegmentIndex += 1
segments.append(record)
notification = SegmentOutput(
kind: .media,
relativePath: relativePath,
url: url,
byteCount: segmentData.count,
durationSeconds: duration
)
default:
break
}
} catch {
firstError = error
}
}
if let notification, let onSegment {
notificationQueue.async {
onSegment(notification)
}
}
}
@discardableResult
func writeManifest(
configuration: SimulatorRecordingConfiguration,
firstFrameWallClock: Date,
width: Int,
height: Int
) throws -> RecordingManifest {
try stateQueue.sync {
if let firstError {
throw firstError
}
let segmented = configuration.segmentDuration > 0
if segmented, initSegmentPath == nil {
throw RecorderError.make(50, "Missing initialization segment")
}
if segmented, segments.isEmpty {
throw RecorderError.make(51, "No media segments were written")
}
let targetDuration = segmented
? max(1, Int(ceil(segments.map(\.durationSeconds).max() ?? configuration.segmentDuration)))
: nil
let manifest = RecordingManifest(
firstFrameWallClock: FirstFrameWallClock(
unixMs: unixMs(firstFrameWallClock),
iso8601: iso8601(firstFrameWallClock)
),
width: width,
height: height,
hlsVersion: segmented ? 7 : nil,
hlsTargetDurationSeconds: targetDuration,
hlsMediaSequence: segmented ? 0 : nil,
recording: segmented ? nil : "recording.mp4",
initSegment: segmented ? initSegmentPath : nil,
segments: segments
)
let data = try JSONEncoder.prettySorted.encode(manifest)
try data.write(to: rootDirectory.appendingPathComponent("session.json"), options: .atomic)
return manifest
}
}
}
@@ -1,644 +0,0 @@
import AVFoundation
import CoreVideo
import Foundation
import IOSurface
import UniformTypeIdentifiers
public final class SimulatorRecorder {
public var onSegment: ((SegmentOutput) -> Void)?
public var onSimulatorStopped: ((String) -> Void)?
public var onFinalizationStage: ((RecordingFinalizationStage) -> Void)?
private static let callbackStalenessTimeout: TimeInterval = 5
private static let firstFrameRewireInterval: TimeInterval = 1
private let configuration: SimulatorRecordingConfiguration
private let callbackQueue = DispatchQueue(
label: "record-sim.frame-callbacks", qos: .userInteractive)
private let writerQueue = DispatchQueue(label: "record-sim.asset-writer", qos: .userInitiated)
private let eventQueue = DispatchQueue(label: "record-sim.events", qos: .utility)
private let pendingLock = NSLock()
private var pendingFrames = 0
private var displaySource: FramebufferDisplaySource?
private var outputWriter: RecordingOutputWriter?
private var writer: AVAssetWriter?
private var input: AVAssetWriterInput?
private var adaptor: AVAssetWriterInputPixelBufferAdaptor?
private var boundaryTimer: DispatchSourceTimer?
private var monotonicClock = MonotonicClock()
private var firstAcceptedCaptureTime: CMTime?
private var firstAcceptedWallClock: Date?
private var recordingWidth: Int?
private var recordingHeight: Int?
private var lastPTS: CMTime?
private var lastSeed: UInt32?
private var lastFrameCallbackElapsed: TimeInterval?
private var lastFirstFrameRewireElapsed: TimeInterval = 0
private var lastAppendedPixelBuffer: CVPixelBuffer?
private var nextBoundaryElapsed: TimeInterval = 0
private var simulatorStoppedReason: String?
private var stopped = false
private var firstFrameReady = false
private let firstFrameSemaphore = DispatchSemaphore(value: 0)
private var firstError: Error?
public init(configuration: SimulatorRecordingConfiguration) {
self.configuration = configuration
}
public func start() throws {
try validateConfiguration()
let outputWriter = RecordingOutputWriter(
rootDirectory: configuration.outputDirectory,
onSegment: { [weak self] segment in
self?.onSegment?(segment)
})
try outputWriter.prepare(
overwrite: configuration.overwrite, segmented: configuration.segmentDuration > 0)
self.outputWriter = outputWriter
monotonicClock = MonotonicClock()
nextBoundaryElapsed = configuration.segmentDuration
lastFirstFrameRewireElapsed = 0
stopped = false
let source = FramebufferDisplaySource(
deviceUDID: configuration.deviceUDID,
callbackQueue: callbackQueue,
onFrame: { [weak self] in
self?.captureFrame(force: false, reason: .callback)
},
onSurfaceChange: { [weak self] in
self?.captureFrame(force: true, reason: .surfaceChange)
}
)
try source.start()
displaySource = source
startBoundaryTimer()
}
public func waitUntilFirstFrame(timeoutSeconds: TimeInterval = 15) throws {
let alreadyReady = try writerQueue.sync {
if let firstError {
throw firstError
}
return firstFrameReady
}
if alreadyReady {
return
}
if firstFrameSemaphore.wait(timeout: .now() + timeoutSeconds) == .timedOut {
throw RecorderError.make(20, "Timed out waiting for first frame")
}
try writerQueue.sync {
if let firstError {
throw firstError
}
if !firstFrameReady {
throw RecorderError.make(21, "Recorder stopped before first frame")
}
}
}
public func simulatorStopReason() -> String? {
callbackQueue.sync {
simulatorStoppedReason
}
}
@discardableResult
public func stop() throws -> RecordingManifest {
callbackQueue.sync {
stopped = true
boundaryTimer?.cancel()
boundaryTimer = nil
displaySource?.stop()
displaySource = nil
}
onFinalizationStage?(.captureStopped)
return try writerQueue.sync {
if let firstError {
throw firstError
}
guard let writer, let input, let outputWriter else {
let error = RecorderError.make(22, "No frames were captured")
firstError = error
signalFirstFrameReadyIfNeeded()
throw error
}
try appendTailFrameIfNeeded(finalPTS: monotonicClock.elapsedTime())
input.markAsFinished()
let semaphore = DispatchSemaphore(value: 0)
writer.finishWriting {
semaphore.signal()
}
if semaphore.wait(timeout: .now() + 60) == .timedOut {
throw RecorderError.make(23, "Timed out finishing AVAssetWriter")
}
if writer.status == .failed {
throw writer.error ?? RecorderError.make(24, "AVAssetWriter failed")
}
if let error = outputWriter.error {
throw error
}
onFinalizationStage?(.videoSaved)
guard let firstAcceptedWallClock else {
throw RecorderError.make(25, "Missing first frame wall-clock timestamp")
}
guard let recordingWidth, let recordingHeight else {
throw RecorderError.make(27, "Missing recording dimensions")
}
return try outputWriter.writeManifest(
configuration: configuration,
firstFrameWallClock: firstAcceptedWallClock,
width: recordingWidth,
height: recordingHeight
)
}
}
private enum CaptureReason {
case callback
case surfaceChange
case boundary
case healthProbe
}
private func validateConfiguration() throws {
guard !configuration.deviceUDID.isEmpty else {
throw RecorderError.make(30, "deviceUDID must not be empty")
}
guard configuration.fps > 0, configuration.fps <= 120 else {
throw RecorderError.make(31, "fps must be between 1 and 120")
}
guard configuration.bitrate > 0 else {
throw RecorderError.make(32, "bitrate must be positive")
}
guard configuration.segmentDuration >= 0 else {
throw RecorderError.make(33, "segmentDuration must be non-negative")
}
guard configuration.maxPendingFrames > 0 else {
throw RecorderError.make(34, "maxPendingFrames must be positive")
}
}
private func startBoundaryTimer() {
let timer = DispatchSource.makeTimerSource(queue: callbackQueue)
timer.schedule(deadline: .now() + 1, repeating: 1, leeway: .milliseconds(100))
timer.setEventHandler { [weak self] in
self?.probeSimulatorState()
self?.probeFramebufferHealth()
self?.appendBoundaryFrameIfNeeded()
}
timer.resume()
boundaryTimer = timer
}
private func appendBoundaryFrameIfNeeded() {
if stopped {
return
}
guard configuration.segmentDuration > 0 else {
return
}
writerQueue.sync {
guard firstAcceptedCaptureTime != nil else {
return
}
let currentPTSSeconds = CMTimeGetSeconds(
normalizedPresentationTime(for: monotonicClock.elapsedTime()))
guard currentPTSSeconds >= nextBoundaryElapsed else {
return
}
let boundary = nextBoundaryElapsed
guard let lastPTS else {
return
}
do {
if CMTimeGetSeconds(lastPTS) < boundary {
let boundaryPTS = CMTime(seconds: boundary, preferredTimescale: 1_000_000_000)
guard try appendHeldFrame(at: boundaryPTS) else {
return
}
}
nextBoundaryElapsed = boundary + configuration.segmentDuration
} catch {
firstError = error
signalFirstFrameReadyIfNeeded()
}
}
}
private func captureFrame(force: Bool, reason: CaptureReason) {
if stopped {
return
}
if reason == .callback {
lastFrameCallbackElapsed = monotonicClock.elapsedSeconds()
}
guard let snapshot = displaySource?.surfaceSnapshot() else {
return
}
let surface = snapshot.surface
let surfaceWidth = snapshot.width
let surfaceHeight = snapshot.height
guard surfaceWidth > 0, surfaceHeight > 0 else {
return
}
let seed = snapshot.seed
let matchesLastAppendedSeed = writerQueue.sync { lastSeed == seed }
if !force, matchesLastAppendedSeed {
return
}
guard reservePendingFrame() else {
return
}
let capturedAt = monotonicClock.elapsedTime()
let capturedAtWallClock = Date()
do {
let pixelBuffer = try copySurfaceToOwnedPixelBuffer(
surface,
width: surfaceWidth,
height: surfaceHeight
)
appendOwned(
pixelBuffer: pixelBuffer,
seed: seed,
capturedAt: capturedAt,
capturedAtWallClock: capturedAtWallClock
)
} catch {
releasePendingFrame()
writerQueue.async {
self.firstError = error
self.signalFirstFrameReadyIfNeeded()
}
}
}
private func probeFramebufferHealth() {
guard !stopped, let displaySource else {
return
}
let elapsed = monotonicClock.elapsedSeconds()
if !isFirstFrameReady(),
elapsed - lastFirstFrameRewireElapsed >= Self.firstFrameRewireInterval
{
lastFirstFrameRewireElapsed = elapsed
rewireFramebuffer()
captureFrame(force: true, reason: .healthProbe)
return
}
guard let snapshot = displaySource.surfaceSnapshot(),
snapshot.width > 0,
snapshot.height > 0
else {
rewireFramebuffer()
return
}
let observedSeed = snapshot.seed
let lastAppendedSeed = writerQueue.sync { lastSeed }
guard let lastAppendedSeed, observedSeed != lastAppendedSeed else {
return
}
let lastCallback = lastFrameCallbackElapsed ?? 0
if elapsed - lastCallback >= Self.callbackStalenessTimeout {
captureFrame(force: false, reason: .healthProbe)
rewireFramebuffer()
}
}
private func probeSimulatorState() {
guard !stopped, simulatorStoppedReason == nil else {
return
}
let state = SimulatorDeviceLookup.stateString(udid: configuration.deviceUDID) ?? "missing"
guard state != "Booted" else {
return
}
simulatorStoppedReason = state
let error = RecorderError.make(26, "Simulator stopped before first frame (state: \(state))")
writerQueue.async {
if self.firstError == nil, !self.firstFrameReady {
self.firstError = error
self.signalFirstFrameReadyIfNeeded()
}
}
let onSimulatorStopped = onSimulatorStopped
eventQueue.async {
onSimulatorStopped?(state)
}
}
private func rewireFramebuffer() {
guard let displaySource else {
return
}
do {
try displaySource.rewireFramebuffer()
} catch {
writerQueue.async {
self.firstError = error
self.signalFirstFrameReadyIfNeeded()
}
}
}
private func reservePendingFrame() -> Bool {
pendingLock.lock()
defer { pendingLock.unlock() }
if pendingFrames >= configuration.maxPendingFrames {
return false
}
pendingFrames += 1
return true
}
private func releasePendingFrame() {
pendingLock.lock()
pendingFrames = max(0, pendingFrames - 1)
pendingLock.unlock()
}
private func copySurfaceToOwnedPixelBuffer(
_ surface: IOSurface,
width: Int,
height: Int
) throws -> CVPixelBuffer {
let pool = try writerQueue.sync {
if let firstError {
throw firstError
}
if writer == nil {
try startWriter(width: width, height: height)
}
guard let pool = adaptor?.pixelBufferPool else {
throw RecorderError.make(40, "AVAssetWriter pixel buffer pool is unavailable")
}
return pool
}
var output: CVPixelBuffer?
let status = CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &output)
guard status == kCVReturnSuccess, let destination = output else {
throw RecorderError.make(41, "Failed to allocate pixel buffer from pool: \(status)")
}
let destinationLockStatus = CVPixelBufferLockBaseAddress(destination, [])
guard destinationLockStatus == kCVReturnSuccess else {
throw RecorderError.make(50, "Failed to lock pixel buffer: \(destinationLockStatus)")
}
let surfaceLockStatus = IOSurfaceLock(surface, .readOnly, nil)
guard surfaceLockStatus == KERN_SUCCESS else {
CVPixelBufferUnlockBaseAddress(destination, [])
throw RecorderError.make(49, "Failed to lock IOSurface: \(surfaceLockStatus)")
}
defer {
IOSurfaceUnlock(surface, .readOnly, nil)
CVPixelBufferUnlockBaseAddress(destination, [])
}
guard let destinationAddress = CVPixelBufferGetBaseAddress(destination) else {
throw RecorderError.make(42, "Pixel buffer has no base address")
}
let sourceAddress = IOSurfaceGetBaseAddress(surface)
let sourceStride = IOSurfaceGetBytesPerRow(surface)
let destinationStride = CVPixelBufferGetBytesPerRow(destination)
let copyWidth = min(width, IOSurfaceGetWidth(surface), CVPixelBufferGetWidth(destination))
let copyHeight = min(
height, IOSurfaceGetHeight(surface), CVPixelBufferGetHeight(destination))
let copyBytes = min(copyWidth * 4, sourceStride, destinationStride)
for row in 0..<copyHeight {
memcpy(
destinationAddress.advanced(by: row * destinationStride),
sourceAddress.advanced(by: row * sourceStride),
copyBytes
)
}
return destination
}
private func appendOwned(
pixelBuffer: CVPixelBuffer,
seed: UInt32,
capturedAt: CMTime,
capturedAtWallClock: Date
) {
writerQueue.async {
defer { self.releasePendingFrame() }
if self.firstError != nil {
return
}
do {
try self.appendOnWriterQueue(
pixelBuffer: pixelBuffer,
seed: seed,
capturedAt: capturedAt,
capturedAtWallClock: capturedAtWallClock
)
} catch {
self.firstError = error
self.signalFirstFrameReadyIfNeeded()
}
}
}
private func appendOnWriterQueue(
pixelBuffer: CVPixelBuffer,
seed: UInt32,
capturedAt: CMTime,
capturedAtWallClock: Date
) throws {
guard let writer, let input, let adaptor else {
throw RecorderError.make(43, "AVAssetWriter is not initialized")
}
let isFirstFrame = firstAcceptedCaptureTime == nil
let pts = isFirstFrame ? .zero : normalizedPresentationTime(for: capturedAt)
if let lastPTS, CMTimeCompare(pts, lastPTS) <= 0 {
return
}
guard try appendPendingBoundaryFrames(before: pts) else {
return
}
guard input.isReadyForMoreMediaData else {
return
}
if !adaptor.append(pixelBuffer, withPresentationTime: pts) {
throw writer.error ?? RecorderError.make(44, "Failed to append pixel buffer")
}
if isFirstFrame {
firstAcceptedCaptureTime = capturedAt
firstAcceptedWallClock = capturedAtWallClock
}
lastPTS = pts
lastSeed = seed
lastAppendedPixelBuffer = pixelBuffer
signalFirstFrameReadyIfNeeded()
}
private func appendTailFrameIfNeeded(finalPTS: CMTime) throws {
let deadline = Date().addingTimeInterval(5)
while true {
if try appendTailFrame(finalPTS: finalPTS) {
return
}
if Date() >= deadline {
throw RecorderError.make(45, "Timed out waiting to append tail frame")
}
Thread.sleep(forTimeInterval: 0.05)
}
}
private func appendTailFrame(finalPTS: CMTime) throws -> Bool {
let pts = normalizedPresentationTime(for: finalPTS)
return try appendHeldFrame(at: pts)
}
private func appendPendingBoundaryFrames(before pts: CMTime) throws -> Bool {
guard configuration.segmentDuration > 0,
firstAcceptedCaptureTime != nil
else {
return true
}
while CMTimeGetSeconds(pts) > nextBoundaryElapsed {
if let lastPTS, CMTimeGetSeconds(lastPTS) >= nextBoundaryElapsed {
nextBoundaryElapsed += configuration.segmentDuration
continue
}
let boundaryPTS = CMTime(
seconds: nextBoundaryElapsed, preferredTimescale: 1_000_000_000)
guard try appendHeldFrame(at: boundaryPTS) else {
return false
}
nextBoundaryElapsed += configuration.segmentDuration
}
return true
}
private func appendHeldFrame(at pts: CMTime) throws -> Bool {
guard let input,
let adaptor,
let lastPTS,
let lastAppendedPixelBuffer
else {
return true
}
if CMTimeCompare(pts, lastPTS) <= 0 {
return true
}
guard input.isReadyForMoreMediaData else {
return false
}
if !adaptor.append(lastAppendedPixelBuffer, withPresentationTime: pts) {
throw writer?.error ?? RecorderError.make(45, "Failed to append held frame")
}
self.lastPTS = pts
return true
}
private func normalizedPresentationTime(for capturedAt: CMTime) -> CMTime {
let start = firstAcceptedCaptureTime ?? capturedAt
return CMTimeSubtract(capturedAt, start)
}
private func startWriter(width: Int, height: Int) throws {
guard let outputWriter else {
throw RecorderError.make(46, "Missing output writer")
}
let writer: AVAssetWriter
if configuration.segmentDuration > 0 {
writer = AVAssetWriter(contentType: .mpeg4Movie)
writer.delegate = outputWriter
writer.outputFileTypeProfile = .mpeg4AppleHLS
writer.preferredOutputSegmentInterval = CMTime(
seconds: configuration.segmentDuration,
preferredTimescale: 600
)
} else {
writer = try AVAssetWriter(outputURL: outputWriter.singleRecordingURL, fileType: .mp4)
}
writer.initialSegmentStartTime = .zero
let input = AVAssetWriterInput(
mediaType: .video,
outputSettings: assetWriterSettings(width: width, height: height)
)
input.expectsMediaDataInRealTime = true
let attributes: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
kCVPixelBufferWidthKey as String: width,
kCVPixelBufferHeightKey as String: height,
kCVPixelBufferIOSurfacePropertiesKey as String: [:],
]
let adaptor = AVAssetWriterInputPixelBufferAdaptor(
assetWriterInput: input,
sourcePixelBufferAttributes: attributes
)
guard writer.canAdd(input) else {
throw RecorderError.make(47, "AVAssetWriter cannot add video input")
}
writer.add(input)
guard writer.startWriting() else {
throw writer.error ?? RecorderError.make(48, "AVAssetWriter failed to start")
}
writer.startSession(atSourceTime: .zero)
self.writer = writer
self.input = input
self.adaptor = adaptor
recordingWidth = width
recordingHeight = height
}
private func assetWriterSettings(width: Int, height: Int) -> [String: Any] {
let codecType: AVVideoCodecType = configuration.codec == .hevc ? .hevc : .h264
var compression: [String: Any] = [
AVVideoAverageBitRateKey: configuration.bitrate,
AVVideoExpectedSourceFrameRateKey: configuration.fps,
AVVideoAllowFrameReorderingKey: true,
]
if configuration.segmentDuration > 0 {
compression[AVVideoMaxKeyFrameIntervalKey] = max(
1,
Int((configuration.segmentDuration * Double(configuration.fps)).rounded())
)
}
if configuration.codec == .h264 {
compression[AVVideoProfileLevelKey] = AVVideoProfileLevelH264HighAutoLevel
}
return [
AVVideoCodecKey: codecType,
AVVideoWidthKey: width,
AVVideoHeightKey: height,
AVVideoCompressionPropertiesKey: compression,
]
}
private func signalFirstFrameReadyIfNeeded() {
if firstFrameReady {
return
}
firstFrameReady = true
firstFrameSemaphore.signal()
}
private func isFirstFrameReady() -> Bool {
writerQueue.sync {
firstFrameReady
}
}
}
@@ -1,60 +0,0 @@
import CoreMedia
import Foundation
enum RecorderError {
static func make(_ code: Int, _ message: String) -> NSError {
NSError(
domain: "RecordSim",
code: code,
userInfo: [NSLocalizedDescriptionKey: message]
)
}
}
extension JSONEncoder {
static var prettySorted: JSONEncoder {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return encoder
}
}
func timeSeconds(_ time: CMTime) -> Double? {
guard time.isValid, !time.isIndefinite, !time.isNegativeInfinity, !time.isPositiveInfinity else {
return nil
}
return CMTimeGetSeconds(time)
}
func iso8601(_ date: Date) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
formatter.timeZone = TimeZone(secondsFromGMT: 0)
return formatter.string(from: date)
}
func unixMs(_ date: Date) -> Int64 {
Int64((date.timeIntervalSince1970 * 1000).rounded())
}
struct MonotonicClock {
private static let timescale: CMTimeScale = 1_000_000_000
let startedAtUptimeNanoseconds: UInt64
init() {
startedAtUptimeNanoseconds = DispatchTime.now().uptimeNanoseconds
}
func elapsedSeconds() -> TimeInterval {
TimeInterval(elapsedNanoseconds()) / TimeInterval(Self.timescale)
}
func elapsedTime() -> CMTime {
CMTime(value: CMTimeValue(elapsedNanoseconds()), timescale: Self.timescale)
}
private func elapsedNanoseconds() -> UInt64 {
DispatchTime.now().uptimeNanoseconds - startedAtUptimeNanoseconds
}
}
@@ -1,159 +0,0 @@
import Foundation
import RecordSim
struct CLIOptions {
var udid: String?
var output: String?
var segmentDuration: TimeInterval = 120
var fps = 30
var bitrate = 30_000_000
var codec: RecorderCodec = .h264
}
private var requestedStopSignal: CInt = 0
private var simulatorStopped: CInt = 0
private func handleStopSignal(_ signal: CInt) {
requestedStopSignal = signal
}
private func isStopRequested() -> Bool {
requestedStopSignal != 0 || simulatorStopped != 0
}
func usage() -> String {
"""
Usage:
record-sim --udid <UDID> --output <session-dir> [options]
Options:
--segment-duration <seconds> Target fMP4 media segment duration. Use 0 for one MP4. Default: 120
--fps <fps> Expected source frame rate. Default: 30
--bitrate <bits/sec> AVAssetWriter average bitrate. Default: 30000000
--codec <h264|hevc> Video codec. Default: h264
-h, --help Show this help
"""
}
func parseOptions(_ args: [String]) throws -> CLIOptions {
var options = CLIOptions()
var i = 0
func next(_ flag: String) throws -> String {
i += 1
guard i < args.count else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing value for \(flag)"])
}
return args[i]
}
while i < args.count {
let arg = args[i]
switch arg {
case "--udid", "-u":
options.udid = try next(arg)
case "--output", "--output-dir", "--out", "-o":
options.output = try next(arg)
case "--segment-duration":
options.segmentDuration = Double(try next(arg)) ?? -1
case "--fps":
options.fps = Int(try next(arg)) ?? 0
case "--bitrate":
options.bitrate = Int(try next(arg)) ?? 0
case "--codec":
let raw = try next(arg)
guard let codec = RecorderCodec(rawValue: raw) else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--codec must be h264 or hevc"])
}
options.codec = codec
case "--help", "-h":
print(usage())
exit(0)
default:
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Unknown argument: \(arg)"])
}
i += 1
}
guard options.udid != nil else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing required --udid"])
}
guard options.output != nil else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing required --output"])
}
guard options.segmentDuration >= 0 else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--segment-duration must be non-negative"])
}
guard options.fps > 0, options.fps <= 120 else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--fps must be between 1 and 120"])
}
guard options.bitrate > 0 else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--bitrate must be positive"])
}
return options
}
do {
signal(SIGINT, handleStopSignal)
signal(SIGTERM, handleStopSignal)
let options = try parseOptions(Array(CommandLine.arguments.dropFirst()))
let configuration = SimulatorRecordingConfiguration(
deviceUDID: options.udid!,
outputDirectory: URL(fileURLWithPath: options.output!),
fps: options.fps,
bitrate: options.bitrate,
codec: options.codec,
segmentDuration: options.segmentDuration
)
let recorder = SimulatorRecorder(configuration: configuration)
recorder.onSimulatorStopped = { state in
simulatorStopped = 1
fputs("[record-sim] simulator stopped state=\(state); finalizing recording\n", stderr)
}
recorder.onFinalizationStage = { stage in
switch stage {
case .captureStopped:
fputs("[record-sim] screen capture stopped; saving final video\n", stderr)
case .videoSaved:
fputs("[record-sim] final video saved; writing recording details\n", stderr)
}
}
recorder.onSegment = { segment in
switch segment.kind {
case .initialization:
fputs("[record-sim] init \(segment.relativePath) bytes=\(segment.byteCount)\n", stderr)
case .media:
let duration = segment.durationSeconds ?? 0
fputs(String(format: "[record-sim] segment %@ duration=%.3fs bytes=%d\n", segment.relativePath, duration, segment.byteCount), stderr)
}
}
try recorder.start()
try recorder.waitUntilFirstFrame()
fputs("[record-sim] recording -> \(options.output!)\n", stderr)
while !isStopRequested() {
Thread.sleep(forTimeInterval: 0.1)
}
if requestedStopSignal != 0 {
let signalName = switch requestedStopSignal {
case SIGINT: "SIGINT"
case SIGTERM: "SIGTERM"
default: "signal \(requestedStopSignal)"
}
fputs("[record-sim] received \(signalName); finishing recording\n", stderr)
}
let manifest = try recorder.stop()
if let recording = manifest.recording {
fputs("[record-sim] done recording=\(recording)\n", stderr)
} else {
fputs("[record-sim] done segments=\(manifest.segments.count)\n", stderr)
}
fputs("[record-sim] manifest \(URL(fileURLWithPath: options.output!).appendingPathComponent("session.json").path)\n", stderr)
} catch {
fputs("error: \(error.localizedDescription)\n", stderr)
exit(1)
}
+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(
@@ -0,0 +1,5 @@
import { Android } from '@expo/eas-build-job';
export function resolveArtifactPath(job: Android.Job): string {
return job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}';
}
@@ -61,7 +61,7 @@ jest.mock('../../utils/expoUpdates', () => ({
}));
jest.mock('../../utils/expoUpdatesEmbedded', () => ({
...jest.requireActual('../../utils/expoUpdatesEmbedded'),
uploadEmbeddedBundleAsync: jest.fn(),
uploadEmbeddedBundleAsync: jest.fn(async () => ({ status: 'uploaded' })),
}));
jest.mock('../../utils/hooks', () => ({
Hook: {
@@ -344,7 +344,29 @@ describe(androidBuilder, () => {
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
expect.objectContaining({ job: ctx.job, env: ctx.env, projectDir: '/workingdir/build' })
);
});
it.each([
['uploaded', BuildPhaseResult.SUCCESS],
['skipped', BuildPhaseResult.SKIPPED],
['failed', BuildPhaseResult.WARNING],
] as const)('marks the phase from the %s result', async (uploadResult, phaseResult) => {
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValueOnce({ status: uploadResult });
const ctx = createContext({ sdkVersion: '58.0.0' });
await androidBuilder(ctx);
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect.objectContaining({
build_phase: BuildPhase.UPLOAD_EMBEDDED_BUNDLE.toLowerCase(),
result: phaseResult,
})
);
});
it('skips by default on SDK 57 and earlier', async () => {
@@ -371,7 +393,9 @@ describe(androidBuilder, () => {
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
expect.objectContaining({ job: ctx.job })
);
});
it('skips on SDK 58 and later when EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE is 0', async () => {
@@ -393,7 +417,9 @@ describe(androidBuilder, () => {
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
expect.objectContaining({ job: ctx.job })
);
});
});
});
+14 -2
View File
@@ -11,6 +11,7 @@ import {
runGradleCommand,
} from '../android/gradle';
import { formatGradleProfileReport, parseGradleProfile } from '../android/gradleProfile';
import { resolveArtifactPath } from '../android/resolve';
import { Sentry } from '../sentry';
import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
import { prebuildAsync } from '../common/prebuild';
@@ -220,7 +221,7 @@ async function buildInnerAsync(
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
await uploadApplicationArchive(ctx, {
patternOrPath: ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}',
patternOrPath: resolveArtifactPath(ctx.job),
rootDir: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
});
@@ -228,7 +229,18 @@ async function buildInnerAsync(
if (shouldUploadEmbeddedBundle(ctx)) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
await uploadEmbeddedBundleAsync(ctx);
const { status } = await uploadEmbeddedBundleAsync({
job: ctx.job,
env: ctx.env,
logger: ctx.logger,
projectDir: ctx.getReactNativeProjectDirectory(),
appConfig: await ctx.appConfig,
});
if (status === 'skipped') {
ctx.markBuildPhaseSkipped();
} else if (status === 'failed') {
ctx.markBuildPhaseHasWarnings();
}
});
}
+13 -2
View File
@@ -237,7 +237,7 @@ async function buildInnerAsync(
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
await uploadApplicationArchive(ctx, {
patternOrPath: resolveArtifactPath(ctx),
patternOrPath: resolveArtifactPath(ctx.job),
rootDir: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
});
@@ -245,7 +245,18 @@ async function buildInnerAsync(
if (shouldUploadEmbeddedBundle(ctx)) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
await uploadEmbeddedBundleAsync(ctx);
const { status } = await uploadEmbeddedBundleAsync({
job: ctx.job,
env: ctx.env,
logger: ctx.logger,
projectDir: ctx.getReactNativeProjectDirectory(),
appConfig: await ctx.appConfig,
});
if (status === 'skipped') {
ctx.markBuildPhaseSkipped();
} else if (status === 'failed') {
ctx.markBuildPhaseHasWarnings();
}
});
}
@@ -16,11 +16,13 @@ function createCtx(
{ workingdir = '/tmp/wd' }: { workingdir?: string } = {}
): {
ctx: BuildContext<Ios.Job>;
info: jest.Mock;
warn: jest.Mock;
} {
const info = jest.fn();
const warn = jest.fn();
const logger = {
info: jest.fn(),
info,
debug: jest.fn(),
error: jest.fn(),
warn,
@@ -36,7 +38,7 @@ function createCtx(
workingdir,
}
);
return { ctx, warn };
return { ctx, info, warn };
}
// Project checkout lives at <workingdir>/build (job projectRootDirectory is '.').
@@ -79,22 +81,31 @@ describe(parseJobHooksAsync, () => {
});
});
it('treats an unknown hook key as inert and warns', async () => {
const { ctx, warn } = createCtx({
it('writes nothing to the build log while parsing', async () => {
const { ctx, info, warn } = createCtx({
before_install_node_modules: [{ run: 'echo hi' }],
before_submit: [{ run: 'echo hi' }],
before_bogus: [{ run: 'echo hi' }],
});
await parseJobHooksAsync(ctx, INSTALL);
expect(info).not.toHaveBeenCalled();
expect(warn).not.toHaveBeenCalled();
});
it('treats an unknown hook key as inert', async () => {
const { ctx } = createCtx({
before_bogus: [{ run: 'echo hi' }],
});
const parsed = await parseJobHooksAsync(ctx, INSTALL);
expect(parsed!.hookEntriesByKey).toEqual({});
expect(warn).toHaveBeenCalledWith(expect.stringContaining('unknown hook key "before_bogus"'));
});
it('shape-checks a registered-but-unwrapped key, then warns and skips it', async () => {
const { ctx, warn } = createCtx({
it('shape-checks a registered-but-unwrapped key, then skips it', async () => {
const { ctx } = createCtx({
before_submit: [{ run: 'echo hi' }],
});
const parsed = await parseJobHooksAsync(ctx, INSTALL);
expect(parsed!.hookEntriesByKey.before_submit).toBeUndefined();
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hooks.before_submit'));
});
it('shape-validates a registered-but-unwrapped key even though it is skipped', async () => {
@@ -192,10 +203,9 @@ describe(parseJobHooksAsync, () => {
});
it('never loads a composite function referenced under a registered-but-unwrapped key', async () => {
const { ctx, warn } = createCtx({ before_submit: [{ uses: './.eas/functions/missing' }] });
const { ctx } = createCtx({ before_submit: [{ uses: './.eas/functions/missing' }] });
const parsed = await parseJobHooksAsync(ctx, INSTALL);
expect(parsed!.hookEntriesByKey).toEqual({});
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hooks.before_submit'));
});
it('wraps a cross-key duplicate step id in the aggregate error', async () => {
@@ -12,6 +12,7 @@ import spawn from '@expo/turtle-spawn';
import { randomBytes, randomUUID } from 'crypto';
import { vol } from 'memfs';
import fetch, { Response } from 'node-fetch';
import { Readable } from 'stream';
import { setTimeout } from 'timers/promises';
import { createMockLogger } from '../../__tests__/utils/logger';
@@ -390,7 +391,13 @@ describe('projectSources', () => {
ctx.graphqlClient.mutation = mockGraphqlMutation as any;
const fetchMock = jest.mocked(fetch);
fetchMock.mockImplementation(async () => ({ ok: true }) as Response);
let uploadedBody = '';
fetchMock.mockImplementation(async (_url, request) => {
for await (const chunk of request!.body as Readable) {
uploadedBody += chunk;
}
return { ok: true } as Response;
});
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: ctx.job.projectArchive }),
@@ -414,10 +421,14 @@ describe('projectSources', () => {
'https://storage.example.com/upload',
expect.objectContaining({
method: 'PUT',
headers: { 'x-custom-header': 'value' },
body: '{"archiveContent":["project/README.md","project/package.json"]}',
headers: {
'x-custom-header': 'value',
'content-length': '63',
'content-type': 'text/plain;charset=UTF-8',
},
})
);
expect(uploadedBody).toBe('{"archiveContent":["project/README.md","project/package.json"]}');
// Verify build metadata was updated
expect(mockGraphqlMutation).toHaveBeenNthCalledWith(
@@ -44,9 +44,8 @@ export async function runHookableBuildPhaseAsync<TJob extends BuildJob, T>({
if (hooks) {
try {
await runHookEntriesAsync({ ctx, hooks, timing: 'after', anchor, anchorFailed: true });
} catch (hookError) {
// The phase error wins; still surface the after-hook failure in the log.
ctx.logger.error({ err: hookError }, `after_${anchor} hook failed`);
} catch {
// The phase error wins.
}
}
throw phaseError;
+5 -16
View File
@@ -53,19 +53,17 @@ export async function parseJobHooksAsync<TJob extends BuildJob>(
const globalContext = new BuildStepGlobalContext(customBuildContext, false);
// Hook steps run in the checked-out project directory, not the pre-checkout
// target directory the global context defaults to.
globalContext.markAsCheckedOut(ctx.logger);
globalContext.markAsCheckedOut();
const externalFunctions = getEasFunctions(customBuildContext);
const externalFunctionGroups = getEasFunctionGroups(customBuildContext);
// Shape-validate every REGISTERED key, reachable or not (steps-world parity:
// a malformed `before_submit` fails a steps build, so it fails here too), and
// warn on keys this build won't run. An unknown key is inert — a key newer
// than this worker must be ignored, never fail the job.
// a malformed `before_submit` fails a steps build, so it fails here too). An
// unknown key is inert — a key newer than this worker must be ignored, never
// fail the job.
for (const [key, steps] of Object.entries(hooks)) {
const parsed = parseHookKey(key);
if (parsed === null) {
ctx.logger.warn(`Ignoring unknown hook key "${key}".`);
if (parseHookKey(key) === null) {
continue;
}
if (Array.isArray(steps) && steps.length === 0) {
@@ -81,15 +79,6 @@ export async function parseJobHooksAsync<TJob extends BuildJob>(
{ cause: err }
);
}
// Registered but not wrapped by this build: shape-checked above, then
// skipped. Function existence is deliberately NOT checked (that happens only
// during construction) — an unknown `uses:` under an unwrapped key warns
// rather than failing the build.
if (!wrappedAnchors.includes(parsed.anchorId)) {
ctx.logger.warn(
`Ignoring "hooks.${key}": this build does not run the "${parsed.anchorId}" step.`
);
}
}
// Construct entries for the wrapped anchors in EXECUTION order (per anchor,
@@ -5,13 +5,13 @@ import { asyncResult } from '@expo/results';
import spawn from '@expo/turtle-spawn';
import fs from 'fs/promises';
import { graphql } from 'gql.tada';
import fetch from 'node-fetch';
import nullthrows from 'nullthrows';
import path from 'path';
import { z } from 'zod';
import { shallowCloneRepositoryAsync } from './git';
import { BuildContext } from '../context';
import { uploadContentWithSignedUrl } from '../storage/uploadWithSignedUrl';
import { turtleFetch } from '../utils/turtleFetch';
export async function prepareProjectSourcesAsync<TJob extends Job>(
@@ -208,10 +208,9 @@ async function uploadProjectMetadataAsync(
headers: Record<string, string>;
};
await fetch(uploadSession.url, {
method: 'PUT',
body: JSON.stringify({ archiveContent: sortedFiles }),
headers: uploadSession.headers,
await uploadContentWithSignedUrl({
signedUrl: uploadSession,
content: JSON.stringify({ archiveContent: sortedFiles }),
});
const updateMetadataResult = await ctx.graphqlClient
+5 -4
View File
@@ -4,13 +4,11 @@ import {
BuildPhaseResult,
Env,
EnvironmentSecretType,
GenericArtifactType,
Job,
LogMarker,
ManagedArtifactType,
Metadata,
errors,
isGenericArtifact,
} from '@expo/eas-build-job';
import { BuildTrigger } from '@expo/eas-build-job/dist/common';
import { bunyan } from '@expo/logger';
@@ -42,7 +40,7 @@ export type ArtifactToUpload =
paths: string[];
}
| {
type: GenericArtifactType;
type: null;
name: string;
paths: string[];
metadata?: Record<string, unknown>;
@@ -69,6 +67,7 @@ export interface BuildContextOptions {
metadata?: Metadata;
expoApiV2BaseUrl?: string;
mcpServerUrl?: string;
addBuildLogMask?: (secret: string) => void;
}
export class SkipNativeBuildError extends Error {}
@@ -86,6 +85,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 +115,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 = {
@@ -238,7 +239,7 @@ export class BuildContext<TJob extends Job = Job> {
logger: bunyan;
}): Promise<{ artifactId: string | null }> {
const result = await this._uploadArtifact({ artifact, logger });
if (result.filename && !isGenericArtifact(artifact)) {
if (result.filename && artifact.type !== null) {
this.artifacts[artifact.type] = result.filename;
}
return { artifactId: result.artifactId ?? null };
@@ -32,6 +32,7 @@ export interface BuilderRuntimeApi {
uploadArtifact: (spec: { artifact: ArtifactToUpload; logger: bunyan }) => Promise<{
artifactId: string | null;
}>;
addBuildLogMask?: (secret: string) => void;
}
export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuildContextProvider {
@@ -63,12 +64,12 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
public readonly logger: bunyan;
public readonly graphqlClient: Client;
public readonly runtimeApi: BuilderRuntimeApi;
public readonly expoApiV2BaseUrl?: string;
public readonly mcpServerUrl?: string;
public job: TJob;
public metadata?: Metadata;
private _env: Env;
private readonly expoApiV2BaseUrl?: string;
private readonly pendingMetricUploads: Promise<void>[] = [];
constructor(buildCtx: BuildContext<TJob>) {
@@ -84,6 +85,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
this.buildLogsDirectory = path.join(buildCtx.workingdir, 'logs');
this.runtimeApi = {
uploadArtifact: (...args) => buildCtx['uploadArtifact'](...args),
addBuildLogMask: buildCtx.addBuildLogMask,
};
this.mcpServerUrl = buildCtx.mcpServerUrl;
this.expoApiV2BaseUrl = buildCtx.expoApiV2BaseUrl;
+42 -15
View File
@@ -1,15 +1,24 @@
import { Sentry } from './sentry';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from './steps/utils/deviceRunSessionWorkerApi';
import { turtleFetch } from './utils/turtleFetch';
type MetricsTarget =
| { kind: 'build'; turtleBuildId: string }
| { kind: 'jobRun'; turtleJobRunId: string };
| { kind: 'jobRun'; turtleJobRunId: string }
// A device run session job with a session token. The token names the session and its job run.
| { kind: 'deviceRunSession' };
type DatadogSetupOptions = {
expoApiV2BaseUrl: string;
robotAccessToken: string;
target: MetricsTarget;
};
type DatadogSetupOptions =
| {
expoApiV2BaseUrl: string;
robotAccessToken: string;
target: Exclude<MetricsTarget, { kind: 'deviceRunSession' }>;
}
| {
expoApiV2BaseUrl: string;
deviceRunSessionToken: string;
target: Extract<MetricsTarget, { kind: 'deviceRunSession' }>;
};
let setupOptions: DatadogSetupOptions | null = null;
let pendingUploads: Promise<unknown>[] = [];
@@ -18,9 +27,30 @@ function withTargetTag(
tags: Record<string, string>,
target: MetricsTarget
): Record<string, string> {
// A device run session job is a job run, so its metrics keep the job run tag.
return { ...tags, target: target.kind === 'build' ? 'build' : 'job_run' };
}
function getMetricsRequest(options: DatadogSetupOptions): {
path: string;
headers: Record<string, string>;
} {
if ('deviceRunSessionToken' in options) {
return {
path: 'device-run-session-worker/metrics',
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: options.deviceRunSessionToken },
};
}
const { target, robotAccessToken } = options;
return {
path:
target.kind === 'build'
? `turtle-builds/${target.turtleBuildId}/metrics`
: `turtle-job-runs/${target.turtleJobRunId}/metrics`,
headers: { Authorization: `Bearer ${robotAccessToken}` },
};
}
export const Datadog = {
setup(opts: DatadogSetupOptions | null): void {
setupOptions = opts;
@@ -30,7 +60,7 @@ export const Datadog = {
if (!setupOptions) {
return;
}
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
const { expoApiV2BaseUrl, target } = setupOptions;
const metrics = [
{
name,
@@ -40,16 +70,11 @@ export const Datadog = {
},
];
const metricsPath =
target.kind === 'build'
? `turtle-builds/${target.turtleBuildId}/metrics`
: `turtle-job-runs/${target.turtleJobRunId}/metrics`;
const { path: metricsPath, headers } = getMetricsRequest(setupOptions);
const uploadPromise = turtleFetch(new URL(metricsPath, expoApiV2BaseUrl).toString(), 'POST', {
json: { metrics },
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
headers,
retries: 2,
}).catch(err => {
Sentry.capture(`Failed to report turtle ${target.kind} metric`, err, {
@@ -61,7 +86,9 @@ export const Datadog = {
},
log(message: string, tags: Record<string, string> = {}): void {
if (!setupOptions) {
// The device run session worker API has no logs route, and the session token is not a bearer
// credential, so a device run session job does not report these logs.
if (!setupOptions || !('robotAccessToken' in setupOptions)) {
return;
}
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
+11 -2
View File
@@ -3,8 +3,16 @@ import HttpLogStream from './logging/HttpLogStream';
import RemoteLoggerStream from './logging/RemoteLoggerStream';
export { Builders, HttpLogStream, RemoteLoggerStream };
export { uploadWithSignedUrl } from './storage/uploadWithSignedUrl';
export type { SignedUrl, UploadWithSignedUrlParams } from './storage/uploadWithSignedUrl';
export {
SignedUrlUploadApiError,
SignedUrlUploadNetworkError,
uploadWithSignedUrl,
} from './storage/uploadWithSignedUrl';
export type {
SignedUrl,
UploadWithSignedUrlOptions,
UploadWithSignedUrlParams,
} from './storage/uploadWithSignedUrl';
export { downloadCacheAsync } from './steps/functions/restoreCache';
export { uploadCacheAsync } from './steps/functions/saveCache';
@@ -33,5 +41,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';
+4 -4
View File
@@ -15,10 +15,10 @@ export function resolveScheme(ctx: BuildContext<Ios.Job>): string {
return schemes[0];
}
export function resolveArtifactPath(ctx: BuildContext<Ios.Job>): string {
if (ctx.job.applicationArchivePath) {
return ctx.job.applicationArchivePath;
} else if (ctx.job.simulator) {
export function resolveArtifactPath(job: Ios.Job): string {
if (job.applicationArchivePath) {
return job.applicationArchivePath;
} else if (job.simulator) {
return 'ios/build/Build/Products/*simulator/*.app';
} else {
return 'ios/build/*.ipa';
@@ -56,10 +56,10 @@ class RemoteLoggerStream extends Writable {
return null;
}
public async init(): Promise<string> {
public async init(): Promise<void> {
this.fileHandle = await fs.open(this.temporaryLogsPath, 'w+', 0o660);
this.flushInterval = setInterval(() => this.flush(), this.options.uploadIntervalMs);
return (await this.flush(true)) as string;
await this.flush(true);
}
public async cleanUp(): Promise<void> {
@@ -121,17 +121,18 @@ class RemoteLoggerStream extends Writable {
}
}
private async flush(force = false): Promise<string | void> {
private async flush(force = false): Promise<void> {
if (force || this.hasChangesToUpload) {
if (this.uploadingPromise) {
await this.uploadingPromise;
return await this.flush(force);
await this.flush(force);
return;
}
return await this.flushInternal();
await this.flushInternal();
}
}
private async flushInternal(): Promise<string | void> {
private async flushInternal(): Promise<void> {
await this.safeWriteToFile();
let resolveFn: PromiseResolveFn;
@@ -139,21 +140,17 @@ class RemoteLoggerStream extends Writable {
this.uploadingPromise = new Promise(res => {
resolveFn = res;
});
return await this.upload()
.then(result => {
return result;
})
await this.upload()
.catch(err => {
this.logger.error({ err }, 'Failed to upload logs file');
})
.then(result => {
.then(() => {
this.uploadingPromise = undefined;
resolveFn();
return result;
});
}
private async upload(): Promise<string | void> {
private async upload(): Promise<void> {
if (!this.uploadMethod) {
return;
}
@@ -165,7 +162,7 @@ class RemoteLoggerStream extends Writable {
);
};
return await uploadWithSignedUrl({
await uploadWithSignedUrl({
signedUrl: this.uploadMethod.signedUrl,
srcGeneratorAsync,
});
@@ -11,7 +11,7 @@ import { getEasFunctions } from '../easFunctions';
jest.mock('fs');
describe('hook anchor self-declarations', () => {
it('exactly the four v0 functions declare anchors, with these bindings', () => {
it('declares the expected hook anchors', () => {
const declarations: Record<string, HookAnchorId> = {};
for (const buildFunction of getEasFunctions(createCustomBuildContextMock())) {
if (buildFunction.__hookId !== undefined) {
@@ -24,6 +24,7 @@ describe('hook anchor self-declarations', () => {
'eas/checkout': 'checkout',
'eas/install_node_modules': 'install_node_modules',
'eas/upload_to_asc': 'submit',
'eas/submit_to_google_play': 'submit',
'eas/maestro_tests': 'maestro_tests',
});
});
@@ -87,6 +88,17 @@ describe('hook anchor self-declarations', () => {
bundle_short_version: '1.0.0',
},
],
[
'eas/submit_to_google_play',
'before_submit',
'after_submit',
{
artifact_path: 'app.aab',
artifact_type: 'aab',
package_name: 'com.example.app',
service_account_key_path: 'key.json',
},
],
[
'eas/maestro_tests',
'before_maestro_tests',
+14 -2
View File
@@ -10,6 +10,7 @@ import { configureIosVersionFunction } from './functions/configureIosVersion';
import { createSubmissionEntityFunction } from './functions/createSubmissionEntity';
import { createDownloadArtifactFunction } from './functions/downloadArtifact';
import { createDownloadBuildFunction } from './functions/downloadBuild';
import { createDownloadSubmissionArchiveFunction } from './functions/downloadSubmissionArchive';
import { createEasDeployBuildFunction } from './functions/deploy';
import { createEasExportBuildFunction } from './functions/export';
import { eagerBundleBuildFunction } from './functions/eagerBundle';
@@ -27,6 +28,8 @@ import { createLaunchApplicationFunction } from './functions/launchApplication';
import { createPrebuildBuildFunction } from './functions/prebuild';
import { createReadAppConfigBuildFunction } from './functions/readAppConfig';
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
import { createReadAndroidAppInfoBuildFunction } from './functions/readAndroidAppInfo';
import { createSubmitToGooglePlayBuildFunction } from './functions/submitToGooglePlay';
import { createReadPackageJsonBuildFunction } from './functions/readPackageJson';
import { createRepackBuildFunction } from './functions/repack';
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
@@ -43,9 +46,9 @@ import { runGradleFunction } from './functions/runGradle';
import { createMaestroTestsBuildFunction } from './functions/maestroTests';
import { createSaveBuildCacheFunction } from './functions/saveBuildCache';
import { createRolloutPosthogFlagFunction } from './functions/rolloutPosthogFlag';
import { createRunAgentBuildFunction } from './functions/runAgent';
import { createSaveCacheFunction } from './functions/saveCache';
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
import { createStartAgentDeviceSessionBuildFunction } from './functions/startAgentDeviceSession';
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
import { createStartArgentRemoteSessionBuildFunction } from './functions/startArgentRemoteSession';
@@ -62,8 +65,11 @@ import { createStartSandboxBuildFunction } from './functions/startSandbox';
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from './functions/uploadDeviceRunSessionScreenRecordings';
import { createUploadEmbeddedBundleBuildFunction } from './functions/uploadEmbeddedBundle';
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
import { createWaitForAscBuildProcessingBuildFunction } from './functions/waitForAscBuildProcessing';
import { createUpdateTestFlightMetadataBuildFunction } from './functions/updateTestFlightMetadata';
import { createSubmitTestFlightBetaReviewBuildFunction } from './functions/submitTestFlightBetaReview';
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
import { createWaitForPosthogMetricFunction } from './functions/waitForPosthogMetric';
import { createUploadPosthogSourcemapsFunction } from './functions/uploadPosthogSourcemaps';
@@ -82,7 +88,9 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createInstallNodeModulesBuildFunction(),
createPrebuildBuildFunction(),
createReadIpaInfoBuildFunction(),
createReadAndroidAppInfoBuildFunction(),
createDownloadBuildFunction(ctx),
createDownloadSubmissionArchiveFunction(ctx),
createInstallBuildFunction(),
createLaunchApplicationFunction(),
createEasExportBuildFunction(),
@@ -104,7 +112,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
generateGymfileFromTemplateFunction(),
runFastlaneFunction(),
parseXcactivitylogFunction(),
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
createStartAgentDeviceSessionBuildFunction(ctx),
createStartArgentRemoteSessionBuildFunction(ctx),
createStartAppiumRemoteSessionBuildFunction(ctx),
@@ -120,6 +127,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createStartServeSimLogsBuildFunction(),
createCollectServeSimLogsBuildFunction(ctx),
createStartSandboxBuildFunction(ctx),
createRunAgentBuildFunction(ctx),
createCollectServeSimMetricsBuildFunction(ctx),
createInstallMaestroBuildFunction(),
createInstallMitmproxyBuildFunction(),
@@ -136,8 +144,11 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
calculateEASUpdateRuntimeVersionFunction(),
createSubmissionEntityFunction(),
createSubmitToGooglePlayBuildFunction(),
createUploadToAscBuildFunction(),
createWaitForAscBuildProcessingBuildFunction(),
createUpdateTestFlightMetadataBuildFunction(),
createSubmitTestFlightBetaReviewBuildFunction(),
createReportMaestroTestResultsFunction(ctx),
createMaestroTestsBuildFunction(ctx),
@@ -147,6 +158,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
functions.push(
...[
createFindAndUploadBuildArtifactsBuildFunction(ctx),
createUploadEmbeddedBundleBuildFunction(ctx),
createResolveBuildConfigBuildFunction(ctx),
createGetCredentialsForBuildTriggeredByGithubIntegration(ctx),
]
@@ -219,4 +219,71 @@ describe(createEasBuildBuildFunctionGroup, () => {
'Build function group is not supported in generic jobs.'
);
});
describe('embedded bundle upload', () => {
function getStepNames(
options: Parameters<typeof createMockBuildToolsContext>[0],
{ sdkVersion, env = {} }: { sdkVersion?: string; env?: Record<string, string> }
): string[] {
const functionGroup = createEasBuildBuildFunctionGroup(createMockBuildToolsContext(options));
const globalCtx = createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: { metadata: sdkVersion ? { sdkVersion } : null },
});
globalCtx.updateEnv(env);
return functionGroup
.createBuildStepsFromFunctionGroupCall(globalCtx)
.map(step => step.displayName);
}
it.each([
['Android', { platform: Platform.ANDROID }],
['Android with credentials', { platform: Platform.ANDROID, buildCredentials: { test: {} } }],
['iOS with credentials', { platform: Platform.IOS, buildCredentials: { test: {} } }],
])('uploads the embedded bundle after the build artifacts on SDK 58 (%s)', (_, options) => {
const stepNames = getStepNames(options, { sdkVersion: '58.0.0' });
expect(stepNames.indexOf('Upload embedded bundle')).toBe(
stepNames.indexOf('Find and upload build artifacts') + 1
);
});
it.each([
['Android', { platform: Platform.ANDROID }],
['Android with credentials', { platform: Platform.ANDROID, buildCredentials: { test: {} } }],
['iOS with credentials', { platform: Platform.IOS, buildCredentials: { test: {} } }],
])('passes ignore_error to the embedded bundle step (%s)', (_, options) => {
const functionGroup = createEasBuildBuildFunctionGroup(createMockBuildToolsContext(options));
const globalCtx = createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: { metadata: { sdkVersion: '58.0.0' } },
});
const uploadStep = functionGroup
.createBuildStepsFromFunctionGroupCall(globalCtx)
.find(step => step.displayName === 'Upload embedded bundle');
expect(uploadStep).toBeDefined();
expect(uploadStep!.inputs?.find(input => input.id === 'ignore_error')?.rawValue).toBe(true);
});
it('does not upload the embedded bundle on SDK 57 without the opt-in', () => {
expect(getStepNames({}, { sdkVersion: '57.0.0' })).not.toContain('Upload embedded bundle');
});
it('uploads the embedded bundle on SDK 57 with the opt-in', () => {
expect(
getStepNames(
{},
{ sdkVersion: '57.0.0', env: { EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1' } }
)
).toContain('Upload embedded bundle');
});
it('does not upload the embedded bundle for iOS simulator builds', () => {
expect(
getStepNames({ platform: Platform.IOS, simulator: true }, { sdkVersion: '58.0.0' })
).not.toContain('Upload embedded bundle');
});
});
});
@@ -10,6 +10,7 @@ import path from 'path';
import { shouldUseEagerBundle } from '../../common/eagerBundle';
import { CustomBuildContext } from '../../customBuildContext';
import { shouldUploadEmbeddedBundle } from '../../utils/expoUpdatesEmbedded';
import { calculateEASUpdateRuntimeVersionFunction } from '../functions/calculateEASUpdateRuntimeVersion';
import { createCheckoutBuildFunction } from '../functions/checkout';
import { configureAndroidVersionFunction } from '../functions/configureAndroidVersion';
@@ -32,6 +33,7 @@ import {
import { runFastlaneFunction } from '../functions/runFastlane';
import { runGradleFunction } from '../functions/runGradle';
import { createSaveBuildCacheFunction } from '../functions/saveBuildCache';
import { createUploadEmbeddedBundleBuildFunction } from '../functions/uploadEmbeddedBundle';
import { createSetUpNpmrcBuildFunction } from '../functions/useNpmToken';
interface HelperFunctionsInput {
@@ -303,6 +305,19 @@ function createStepsForIosBuildWithCredentials({
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
...(shouldUploadEmbeddedBundle({
env: globalCtx.env,
metadata: globalCtx.staticContext.metadata,
})
? [
createUploadEmbeddedBundleBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: { ignore_error: true },
}),
]
: []),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
@@ -390,6 +405,19 @@ function createStepsForAndroidBuildWithoutCredentials({
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
...(shouldUploadEmbeddedBundle({
env: globalCtx.env,
metadata: globalCtx.staticContext.metadata,
})
? [
createUploadEmbeddedBundleBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: { ignore_error: true },
}),
]
: []),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
@@ -483,6 +511,19 @@ function createStepsForAndroidBuildWithCredentials({
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
...(shouldUploadEmbeddedBundle({
env: globalCtx.env,
metadata: globalCtx.staticContext.metadata,
})
? [
createUploadEmbeddedBundleBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: { ignore_error: true },
}),
]
: []),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
@@ -0,0 +1,47 @@
import { UserError } from '@expo/eas-build-job';
import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { readAndroidArtifactInfoAsync } from '../../utils/android/appArtifact';
import { createReadAndroidAppInfoBuildFunction } from '../readAndroidAppInfo';
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
jest.unmock('@expo/spawn-async');
const fixturesDirectory = path.join(__dirname, 'fixtures/android-app-info');
it.each(['apk', 'aab'])('reads a real %s and publishes build step outputs', async type => {
const step = createReadAndroidAppInfoBuildFunction().createBuildStepFromFunctionCall(
createGlobalContextMock({
projectTargetDirectory: fixturesDirectory,
staticContextContent: { job: {} },
}),
{ callInputs: { artifact_path: `app.${type}` } }
);
await step.executeAsync();
expect(step.outputById.artifact_type.value).toBe(type);
expect(step.outputById.package_name.value).toBe('com.example.app');
});
it('reports an invalid ZIP as a user error', async () => {
await expect(readAndroidArtifactInfoAsync(__filename)).rejects.toMatchObject({
constructor: UserError,
errorCode: 'EAS_READ_ANDROID_APP_INFO_INVALID_ARTIFACT',
cause: expect.any(Error),
});
});
it.each(['apk', 'aab'])('retains the native tool error for an invalid %s manifest', async type => {
await expect(
readAndroidArtifactInfoAsync(path.join(fixturesDirectory, `invalid-manifest-${type}.zip`))
).rejects.toMatchObject({
constructor: UserError,
errorCode: 'EAS_READ_ANDROID_APP_INFO_INVALID_MANIFEST',
cause: expect.any(Error),
});
});
@@ -48,7 +48,10 @@ describe(createCheckoutBuildFunction, () => {
});
it('moves project sources to the target directory when no inputs are given', async () => {
const logger = createMockLogger();
jest.mocked(logger.child).mockReturnValue(logger);
const { globalCtx, executeAsync } = createCheckoutStep({
logger,
projectArchive: GIT_PROJECT_ARCHIVE,
});
@@ -56,6 +59,9 @@ describe(createCheckoutBuildFunction, () => {
expect(await vol.promises.readFile(`${projectTargetDirectory}/app.json`, 'utf8')).toBe('{}');
expect(globalCtx.wasCheckedOut()).toBe(true);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Changing default working directory to')
);
expect(fetchAndCheckoutRefAsync).not.toHaveBeenCalled();
});
@@ -96,14 +102,11 @@ describe(createCheckoutBuildFunction, () => {
});
it('rejects the ref input when the project is already checked out', async () => {
const logger = createMockLogger();
jest.mocked(logger.child).mockReturnValue(logger);
const { globalCtx, executeAsync } = createCheckoutStep({
logger,
projectArchive: GIT_PROJECT_ARCHIVE,
callInputs: { ref: 'feature/add-icon' },
});
globalCtx.markAsCheckedOut(logger);
globalCtx.markAsCheckedOut();
await expect(executeAsync()).rejects.toMatchObject({
errorCode: 'EAS_CHECKOUT_REF_AFTER_CHECKOUT',
@@ -16,7 +16,7 @@ import {
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
jest.mock('@expo/turtle-spawn');
@@ -27,6 +27,14 @@ jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/agentDeviceEvents');
jest.mock('../../utils/appiumEvents');
jest.mock('../../utils/agentDeviceArtifacts');
// The agent-device session step boots the emulator itself; this test covers the session host.
jest.mock('../startAndroidEmulator', () => ({
startAndroidEmulatorAsync: jest.fn().mockResolvedValue({
serialId: 'emulator-5554',
emulatorPromise: Promise.resolve(),
shouldAdjustAnimationScale: true,
}),
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
spawnDetached: jest.fn(),
@@ -48,6 +56,15 @@ beforeEach(() => {
jest.clearAllMocks();
// The daemon package is external. No installation or daemon process runs in these tests.
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
// Only the emulator logcat folder: the Appium step writes into its own temp folder.
const mkdtemp = fs.promises.mkdtemp.bind(fs.promises);
jest
.spyOn(fs.promises, 'mkdtemp')
.mockImplementation(async prefix =>
String(prefix).includes('eas-android-emulator-logcat-')
? '/tmp/eas-android-emulator-logcat-test'
: mkdtemp(prefix)
);
jest.mocked(spawn).mockResolvedValue({ stdout: '{}' } as never);
jest
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
@@ -62,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: () => '',
@@ -89,9 +108,13 @@ afterEach(() => {
describe.each([
['Appium', createStartAppiumRemoteSessionBuildFunction],
['Agent Device', createStartAgentDeviceRemoteSessionBuildFunction],
['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!(
{
@@ -99,10 +122,17 @@ describe.each([
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
// Inputs a step does not get default to undefined, like in a real step call.
inputs: new Proxy(
{
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',
@@ -115,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({
@@ -134,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;
@@ -243,3 +326,76 @@ describe.each([
);
});
});
describe('Appium ffmpeg setup', () => {
async function runAppiumAsync(): Promise<void> {
const fn = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext);
await fn.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: new Proxy({} as Record<string, { value: unknown }>, {
get: (target, id: string) => target[id] ?? { value: undefined },
}),
outputs: {},
env: {
DEVICE_RUN_SESSION_ID: 'session-id',
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
NGROK_AUTHTOKEN: 'token',
},
} as never
);
}
function holdFfmpegInstall({ appiumError }: { appiumError?: Error } = {}) {
let finish!: (outcome: 'installed' | 'failed') => void;
const install = new Promise((resolve, reject) => {
finish = outcome =>
outcome === 'installed' ? resolve({}) : reject(new Error('apt-get failed'));
});
jest.mocked(spawn).mockImplementation(((command: string, args: string[]) => {
if (command === 'ffmpeg') {
return Promise.reject(new Error('ffmpeg missing'));
}
if (args.includes('install') && args.includes('ffmpeg')) {
return install;
}
return command === 'npm' && appiumError
? Promise.reject(appiumError)
: Promise.resolve({ stdout: '{}' });
}) as never);
return finish;
}
it.each(['installed', 'failed'] as const)(
'starts Appium after the ffmpeg install settles (%s)',
async outcome => {
const finishFfmpegInstall = holdFfmpegInstall();
const run = runAppiumAsync();
await new Promise(resolve => setImmediate(resolve));
expect(spawnDetached).not.toHaveBeenCalled();
finishFfmpegInstall(outcome);
await run;
expect(spawnDetached).toHaveBeenCalledTimes(1);
}
);
it('waits for the ffmpeg install before failing on an Appium install error', async () => {
const appiumError = new Error('npm failed');
const finishFfmpegInstall = holdFfmpegInstall({ appiumError });
let settled = false;
const run = runAppiumAsync().finally(() => {
settled = true;
});
await new Promise(resolve => setImmediate(resolve));
expect(settled).toBe(false);
finishFfmpegInstall('installed');
await expect(run).rejects.toBe(appiumError);
});
});
@@ -11,6 +11,7 @@ import * as tar from 'tar';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { Datadog } from '../../../datadog';
import * as files from '../../../utils/files';
import { createDownloadBuildFunction, downloadBuildAsync } from '../downloadBuild';
@@ -30,11 +31,17 @@ const APPLICATION_ARCHIVE_URL = `https://expo.dev/artifacts/eas/${randomUUID()}.
function createMockGraphqlClient({
applicationArchiveUrl,
error,
transientError,
}: {
applicationArchiveUrl?: string | null;
error?: Error;
transientError?: Error;
}): Client {
const toPromise = jest.fn().mockResolvedValue(
const toPromise = jest.fn();
if (transientError) {
toPromise.mockResolvedValueOnce({ error: transientError, data: undefined });
}
toPromise.mockResolvedValue(
error
? { error, data: undefined }
: {
@@ -62,7 +69,7 @@ function createSuccessfulResponse({
url,
headers,
}: {
body: Buffer;
body: Buffer | Readable;
url: string;
headers?: Record<string, string>;
}): Response {
@@ -72,7 +79,7 @@ function createSuccessfulResponse({
return {
ok: true,
body: Readable.from(body),
body: body instanceof Readable ? body : Readable.from(body),
url,
headers: {
get: (name: string): string | null => normalizedHeaders[name.toLowerCase()] ?? null,
@@ -80,6 +87,47 @@ function createSuccessfulResponse({
} as unknown as Response;
}
function createUndiciNetworkError(code: string): CombinedError {
return new CombinedError({
networkError: Object.assign(new TypeError('fetch failed'), {
cause: Object.assign(new Error(`read ${code}`), { code }),
}),
});
}
const { FetchError: ActualFetchError } = jest.requireActual(
'node-fetch'
) as typeof import('node-fetch');
function createSocketFetchError(url: string, code: string): Error {
return new ActualFetchError(`request to ${url} failed, reason: connect ${code}`, 'system', {
code,
} as any);
}
function useFakeBackoffTimers(): void {
// Streams and fs callbacks need the real nextTick and setImmediate; only the backoff timers are faked.
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
}
const MAX_IO_TURNS_BEFORE_BACKOFF = 1000;
async function waitForBackoffTimerAsync(): Promise<void> {
for (let turns = 0; jest.getTimerCount() === 0; turns++) {
if (turns === MAX_IO_TURNS_BEFORE_BACKOFF) {
throw new Error('No backoff timer was scheduled.');
}
await new Promise(resolve => setImmediate(resolve));
}
}
async function fireBackoffTimersAsync(count: number): Promise<void> {
for (let i = 0; i < count; i++) {
await waitForBackoffTimerAsync();
await jest.advanceTimersToNextTimerAsync();
}
}
async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
const sourceDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'flat-app-source-'));
const archivePath = path.join(os.tmpdir(), `${randomUUID()}.tar.gz`);
@@ -108,6 +156,11 @@ async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
}
}
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
describe('downloadBuild', () => {
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
const buildId = randomUUID();
@@ -465,13 +518,13 @@ describe('downloadBuild', () => {
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('throws SystemError when GraphQL request fails with a network error', async () => {
it('throws SystemError with the cause code after retrying a failing build lookup', async () => {
useFakeBackoffTimers();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: new CombinedError({
networkError: new Error('Network request failed'),
}),
error: createUndiciNetworkError('ECONNRESET'),
});
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
@@ -480,19 +533,81 @@ describe('downloadBuild', () => {
robotAccessToken: null,
extensions: ['app'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
await fireBackoffTimersAsync(5);
await expect(promise).rejects.toBeInstanceOf(SystemError);
await rejection;
await expect(promise).rejects.toMatchObject({
message: `Could not fetch build ${buildId}: [Network] Network request failed`,
message: `Could not fetch build ${buildId}: [Network] fetch failed (ECONNRESET)`,
});
expect(graphqlClient.query).toHaveBeenCalledTimes(6);
expect(datadogLog).toHaveBeenCalledWith('download_build.build_lookup failed on attempt 6', {
event: 'network_retry',
operation: 'download_build.build_lookup',
outcome: 'failed',
attempts: '6',
code: 'ECONNRESET',
});
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('throws SystemError when GraphQL request returns 5xx', async () => {
it.each([
['a network error', createUndiciNetworkError('ECONNRESET'), 'ECONNRESET'],
[
'a transient GraphQL error',
new CombinedError({
graphQLErrors: [
{
message: 'Too many requests',
extensions: { isTransient: true, errorCode: 'TOO_MANY_REQUESTS' },
},
],
response: { status: 200 },
}),
'TOO_MANY_REQUESTS',
],
])('downloads after a build lookup that failed once with %s', async (_, transientError, code) => {
useFakeBackoffTimers();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: Object.assign(new Error('Internal Server Error'), {
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
transientError,
});
jest
.mocked(fetch)
.mockResolvedValue(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
await fireBackoffTimersAsync(1);
const { artifactPath } = await promise;
expect(path.basename(artifactPath)).toBe('app.apk');
expect(graphqlClient.query).toHaveBeenCalledTimes(2);
expect(datadogLog).toHaveBeenCalledWith('download_build.build_lookup recovered on attempt 2', {
event: 'network_retry',
operation: 'download_build.build_lookup',
outcome: 'recovered',
attempts: '2',
code,
});
});
it('throws SystemError when GraphQL request returns 5xx', async () => {
useFakeBackoffTimers();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: new CombinedError({
graphQLErrors: [{ message: 'Internal Server Error' }],
response: { status: 500 },
}),
});
@@ -504,12 +619,14 @@ describe('downloadBuild', () => {
robotAccessToken: null,
extensions: ['app'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
await fireBackoffTimersAsync(5);
await expect(promise).rejects.toBeInstanceOf(SystemError);
await rejection;
await expect(promise).rejects.toMatchObject({
message: `Could not fetch build ${buildId}: Internal Server Error`,
message: `Could not fetch build ${buildId}: [GraphQL] Internal Server Error`,
});
expect(graphqlClient.query).toHaveBeenCalledTimes(6);
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
@@ -534,10 +651,193 @@ describe('downloadBuild', () => {
errorCode: 'EAS_DOWNLOAD_BUILD_FETCH_FAILED',
message: `Could not fetch build ${buildId}: [GraphQL] Build not found`,
});
expect(graphqlClient.query).toHaveBeenCalledTimes(1);
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('downloads after an archive request that failed once with a socket error', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockRejectedValueOnce(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ECONNRESET'))
.mockResolvedValueOnce(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
await fireBackoffTimersAsync(1);
const { artifactPath } = await promise;
expect(path.basename(artifactPath)).toBe('app.apk');
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(2);
});
it('throws SystemError with the cause code after retrying a failing build archive request', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest
.mocked(fetch)
.mockRejectedValue(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ETIMEDOUT'));
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
await fireBackoffTimersAsync(5);
await rejection;
await expect(promise).rejects.toMatchObject({
message: `Could not download the application archive: request to ${APPLICATION_ARCHIVE_URL} failed, reason: connect ETIMEDOUT`,
});
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
expect(datadogLog).toHaveBeenCalledWith('download_build.archive_download failed on attempt 6', {
event: 'network_retry',
operation: 'download_build.archive_download',
outcome: 'failed',
attempts: '6',
code: 'ETIMEDOUT',
});
});
function createBrokenBody(code: string): Readable {
return Readable.from(
(async function* () {
yield Buffer.from('he');
throw Object.assign(new Error(`body failed with ${code}`), { code });
})()
);
}
it.each(['ERR_STREAM_PREMATURE_CLOSE', 'ECONNRESET'])(
'downloads again when the archive body breaks off with %s after the headers arrived',
async code => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({});
const brokenBody = createBrokenBody(code);
jest
.mocked(fetch)
.mockResolvedValueOnce(
createSuccessfulResponse({ body: brokenBody, url: 'https://cdn.test/app.apk' })
)
.mockResolvedValueOnce(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
await fireBackoffTimersAsync(1);
const { artifactPath } = await promise;
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(2);
expect(datadogLog).toHaveBeenCalledWith(
'download_build.archive_download recovered on attempt 2',
expect.objectContaining({ outcome: 'recovered', code })
);
}
);
it('throws SystemError when the build archive body keeps breaking off', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest.mocked(fetch).mockImplementation(async () =>
createSuccessfulResponse({
body: createBrokenBody('ECONNRESET'),
url: 'https://cdn.test/app.apk',
})
);
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
await fireBackoffTimersAsync(5);
await rejection;
await expect(promise).rejects.toMatchObject({
message: 'Could not download the application archive: body failed with ECONNRESET',
});
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
expect(datadogLog).toHaveBeenCalledWith(
'download_build.archive_download failed on attempt 6',
expect.objectContaining({ outcome: 'failed', code: 'ECONNRESET' })
);
});
it('does not start another archive attempt after an abort during the backoff', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockRejectedValueOnce(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ECONNRESET'));
const controller = new AbortController();
const reason = new Error('step timed out');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
signal: controller.signal,
});
const rejection = expect(promise).rejects.toBe(reason);
await waitForBackoffTimerAsync();
controller.abort(reason);
await jest.advanceTimersToNextTimerAsync();
await rejection;
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(1);
});
it('rethrows the raw error of a failing user-supplied archive URL after the retries', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockRejectedValue(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ENOTFOUND'));
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(ActualFetchError);
await fireBackoffTimersAsync(5);
await rejection;
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
});
it('should throw an error if no matching files are found', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
@@ -591,6 +891,131 @@ describe('createDownloadBuildFunction', () => {
expect(graphqlClient.query).not.toHaveBeenCalled();
});
describe('with a device run session token in the job env', () => {
const { Response: ActualResponse } = jest.requireActual(
'node-fetch'
) as typeof import('node-fetch');
const workerApiUrl = 'https://api.expo.test/v2/device-run-session-worker/application';
function createSessionCtx(graphqlClient: Client): any {
return {
graphqlClient,
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
};
}
function createApplicationResponse(buildId: string): Response {
return new ActualResponse(
JSON.stringify({
data: {
build: {
id: buildId,
gitCommitHash: null,
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
},
},
}),
{ status: 200 }
);
}
function createStep(ctx: any, callInputs: Record<string, unknown>) {
return createDownloadBuildFunction(ctx).createBuildStepFromFunctionCall(
createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: { job: { secrets: { robotAccessToken: 'session-token' } } },
}),
{ callInputs }
);
}
it('looks up build_id through the worker API and downloads without an Authorization header', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({});
jest.mocked(fetch).mockImplementation(async url => {
if (url === workerApiUrl) {
return createApplicationResponse(buildId);
}
return {
ok: false,
text: () => Promise.resolve('Internal Server Error'),
status: 500,
} as unknown as Response;
});
await expect(
createStep(createSessionCtx(graphqlClient), {
build_id: buildId,
extensions: ['app'],
}).executeAsync()
).rejects.toThrow('Internal Server Error');
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
workerApiUrl,
expect.objectContaining({
method: 'GET',
headers: { 'expo-device-run-session-token': 'session-token' },
})
);
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ headers: undefined })
);
expect(graphqlClient.query).not.toHaveBeenCalled();
});
it('retries the worker API lookup after a 5xx response', async () => {
useFakeBackoffTimers();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockResolvedValueOnce(new ActualResponse('Service Unavailable', { status: 503 }))
.mockResolvedValueOnce(createApplicationResponse(buildId))
.mockResolvedValueOnce(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const execution = createStep(createSessionCtx(graphqlClient), {
build_id: buildId,
extensions: ['apk'],
}).executeAsync();
await fireBackoffTimersAsync(1);
await execution;
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(3);
expect(jest.mocked(fetch).mock.calls.map(([url]) => url)).toEqual([
workerApiUrl,
workerApiUrl,
APPLICATION_ARCHIVE_URL,
]);
});
it('does not call the worker API for an application_archive_url input', async () => {
const graphqlClient = createMockGraphqlClient({});
jest.mocked(fetch).mockResolvedValue({
ok: false,
text: () => Promise.resolve('Internal Server Error'),
status: 500,
} as unknown as Response);
await expect(
createStep(createSessionCtx(graphqlClient), {
application_archive_url: APPLICATION_ARCHIVE_URL,
extensions: ['app'],
}).executeAsync()
).rejects.toThrow('Internal Server Error');
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(1);
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ headers: undefined })
);
expect(graphqlClient.query).not.toHaveBeenCalled();
});
});
it('should download a build', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
@@ -0,0 +1,192 @@
import { Client, CombinedError } from '@urql/core';
import fetch, { Response } from 'node-fetch';
import fs from 'node:fs';
import { Readable } from 'node:stream';
import { CustomBuildContext } from '../../../customBuildContext';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { createDownloadSubmissionArchiveFunction } from '../downloadSubmissionArchive';
const submissionId = 'aabbccdd-1234-4321-abcd-123456789012';
const url = 'https://storage.example/app.ipa?signature=secret';
it('reports invalid submission ID and extensions together', async () => {
const mutation = jest.fn();
const step = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { submission_id: 'invalid', extensions: [123] },
});
await expect(step.executeAsync()).rejects.toMatchObject({
issues: expect.arrayContaining([
expect.objectContaining({ path: ['submissionId'] }),
expect.objectContaining({ path: ['extensions', 0] }),
]),
});
expect(mutation).not.toHaveBeenCalled();
});
it('requests a fresh URL on each execution, downloads without auth and does not log the URL', async () => {
const mutation = jest.fn(() => ({
toPromise: async () => ({
data: {
submission: { generateSubmissionArchiveDownloadUrl: url },
},
}),
}));
const logger = createMockLogger();
const fn = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext);
for (let i = 0; i < 2; i++) {
jest.mocked(fetch).mockResolvedValue({
ok: true,
url,
body: Readable.from(Buffer.from('IPA bytes')),
headers: { get: () => null },
} as unknown as Response);
const step = fn.createBuildStepFromFunctionCall(createGlobalContextMock({ logger }), {
callInputs: { submission_id: submissionId },
});
await step.executeAsync();
expect(await fs.promises.readFile(step.getOutputValueByName('artifact_path')!, 'utf8')).toBe(
'IPA bytes'
);
}
expect(mutation).toHaveBeenCalledTimes(2);
expect(fetch).toHaveBeenCalledWith(url, { headers: undefined, signal: undefined });
expect(JSON.stringify(jest.mocked(logger.info).mock.calls)).not.toContain(url);
});
it('preserves the API error as the cause without downloading', async () => {
const error = new CombinedError({ networkError: new Error('Connection reset') });
const mutation = jest.fn(() => ({
toPromise: async () => ({
error,
}),
}));
const step = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { submission_id: submissionId },
});
await expect(step.executeAsync()).rejects.toMatchObject({
message: 'Could not request the submission archive. Try again later.',
cause: error,
});
expect(fetch).not.toHaveBeenCalled();
expect(mutation).toHaveBeenCalledTimes(4);
});
it('retries a temporary URL request failure and then downloads the archive', async () => {
const mutation = jest
.fn()
.mockReturnValueOnce({
toPromise: async () => ({
error: new CombinedError({ networkError: new Error('Connection reset') }),
}),
})
.mockReturnValue({
toPromise: async () => ({
data: { submission: { generateSubmissionArchiveDownloadUrl: url } },
}),
});
jest.mocked(fetch).mockResolvedValue({
ok: true,
url,
body: Readable.from(Buffer.from('IPA bytes')),
headers: { get: () => null },
} as unknown as Response);
const step = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { submission_id: submissionId },
});
await step.executeAsync();
expect(mutation).toHaveBeenCalledTimes(2);
expect(await fs.promises.readFile(step.getOutputValueByName('artifact_path')!, 'utf8')).toBe(
'IPA bytes'
);
});
it('does not retry an access error', async () => {
const error = new CombinedError({ graphQLErrors: [{ message: 'Not authorized' }] });
const mutation = jest.fn(() => ({ toPromise: async () => ({ error }) }));
const step = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { submission_id: submissionId },
});
await expect(step.executeAsync()).rejects.toMatchObject({ cause: error });
expect(mutation).toHaveBeenCalledTimes(1);
expect(fetch).not.toHaveBeenCalled();
});
it('does not send another URL request after the step times out during retry backoff', async () => {
const mutation = jest.fn(() => ({
toPromise: async () => ({
error: new CombinedError({ networkError: new Error('Connection reset') }),
}),
}));
const step = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { submission_id: submissionId },
timeoutMs: 100,
});
await expect(step.executeAsync()).rejects.toThrow('timed out');
await new Promise(resolve => setTimeout(resolve, 1100));
expect(mutation).toHaveBeenCalledTimes(1);
expect(fetch).not.toHaveBeenCalled();
});
it('uses the requested extension for an archive without a filename extension', async () => {
const mutation = jest.fn(() => ({
toPromise: async () => ({
data: {
submission: { generateSubmissionArchiveDownloadUrl: 'https://storage.example/archive' },
},
}),
}));
jest.mocked(fetch).mockResolvedValue({
ok: true,
url: 'https://storage.example/archive',
body: Readable.from(Buffer.from('Android archive bytes')),
headers: { get: () => null },
} as unknown as Response);
const step = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { submission_id: submissionId, extensions: ['aab'] },
});
await step.executeAsync();
const artifactPath = step.getOutputValueByName('artifact_path')!;
expect(artifactPath).toMatch(/\.aab$/);
expect(await fs.promises.readFile(artifactPath, 'utf8')).toBe('Android archive bytes');
});
it('propagates the original download error', async () => {
const error = new Error('Download failed');
const mutation = jest.fn(() => ({
toPromise: async () => ({
data: { submission: { generateSubmissionArchiveDownloadUrl: url } },
}),
}));
jest.mocked(fetch).mockRejectedValue(error);
const step = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { submission_id: submissionId },
});
await expect(step.executeAsync()).rejects.toBe(error);
});
@@ -463,114 +463,127 @@ describe('createInstallMaestroBuildFunction', () => {
}
});
it('installs the prebuilt WDA cache for each available iOS runtime', async () => {
const homeDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'install-maestro-test-')
);
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
if (command === 'maestro-runner') {
return { stdout: 'maestro-runner 1.2.3\n' };
}
if (command === 'xcrun') {
return {
stdout: JSON.stringify({
runtimes: [
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
isAvailable: true,
version: '18.3.1',
},
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-0',
isAvailable: true,
version: '26.0',
},
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-17-5',
isAvailable: false,
version: '17.5',
},
],
}),
};
}
if (command === 'xcodebuild') {
return { stdout: 'Xcode 26.0\nBuild version 17A324\n' };
}
if (command === 'tar') {
const maestroRunnerHome = args[args.indexOf('-C') + 1];
const productsDirectory = path.join(
maestroRunnerHome,
'cache',
'wda-builds',
'generic',
'DerivedData',
'Build',
'Products'
);
await fs.promises.mkdir(productsDirectory, { recursive: true });
await fs.promises.writeFile(
path.join(productsDirectory, 'WebDriverAgentRunner.xctestrun'),
'cached'
);
}
return { stdout: '' };
}) as any);
try {
await writeInstalledWdaVersion(homeDirectory, '11.1.3');
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock({
runtimePlatform: BuildRuntimePlatform.DARWIN,
});
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com',
HOME: homeDirectory,
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner' },
});
await step.executeAsync();
expect(mockedDownloadFile).toHaveBeenCalledWith(
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.0-wda-11.1.3.tar.gz',
expect.stringMatching(/install_maestro_runner_wda_cache.*\/wda-cache\.tar\.gz$/),
{ retry: 3, timeout: 20_000 }
it.each(['1.1.27', '1.1.28', '1.2.3'])(
'prepares the WDA cache only for legacy Runner versions (%s)',
async version => {
const homeDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'install-maestro-test-')
);
for (const runtimeVersion of ['18.3', '26.0']) {
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
if (command === 'maestro-runner') {
return { stdout: `maestro-runner ${version}\n` };
}
if (command === 'xcrun') {
return {
stdout: JSON.stringify({
runtimes: [
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
isAvailable: true,
version: '18.3.1',
},
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-0',
isAvailable: true,
version: '26.0',
},
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-17-5',
isAvailable: false,
version: '17.5',
},
],
}),
};
}
if (command === 'xcodebuild') {
return { stdout: 'Xcode 26.0\nBuild version 17A324\n' };
}
if (command === 'tar') {
const maestroRunnerHome = args[args.indexOf('-C') + 1];
const productsDirectory = path.join(
maestroRunnerHome,
'cache',
'wda-builds',
'generic',
'DerivedData',
'Build',
'Products'
);
await fs.promises.mkdir(productsDirectory, { recursive: true });
await fs.promises.writeFile(
path.join(productsDirectory, 'WebDriverAgentRunner.xctestrun'),
'cached'
);
}
return { stdout: '' };
}) as any);
try {
await writeInstalledWdaVersion(homeDirectory, '11.1.3');
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock({
runtimePlatform: BuildRuntimePlatform.DARWIN,
});
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com',
HOME: homeDirectory,
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner' },
});
await step.executeAsync();
expect(step.getOutputValueByName('maestro_version')).toBe(version);
if (version !== '1.1.27') {
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(mockedSpawn.mock.calls.map(([command]) => command)).not.toContain('xcrun');
await expect(
fs.promises.access(path.join(homeDirectory, '.maestro-runner', 'cache'))
).rejects.toThrow();
return;
}
expect(mockedDownloadFile).toHaveBeenCalledWith(
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.0-wda-11.1.3.tar.gz',
expect.stringMatching(/install_maestro_runner_wda_cache.*\/wda-cache\.tar\.gz$/),
{ retry: 3, timeout: 20_000 }
);
for (const runtimeVersion of ['18.3', '26.0']) {
await expect(
fs.promises.readFile(
path.join(
homeDirectory,
'.maestro-runner',
'cache',
'wda-builds',
`sim-ios${runtimeVersion}-iphone`,
'DerivedData',
'Build',
'Products',
'WebDriverAgentRunner.xctestrun'
),
'utf8'
)
).resolves.toBe('cached');
}
await expect(
fs.promises.readFile(
path.join(
homeDirectory,
'.maestro-runner',
'cache',
'wda-builds',
`sim-ios${runtimeVersion}-iphone`,
'DerivedData',
'Build',
'Products',
'WebDriverAgentRunner.xctestrun'
),
'utf8'
fs.promises.access(
path.join(homeDirectory, '.maestro-runner', 'cache', 'wda-builds', 'sim-ios17.5-iphone')
)
).resolves.toBe('cached');
).rejects.toThrow();
} finally {
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
}
await expect(
fs.promises.access(
path.join(homeDirectory, '.maestro-runner', 'cache', 'wda-builds', 'sim-ios17.5-iphone')
)
).rejects.toThrow();
} finally {
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
}
});
);
it('does not download the WDA cache when the installed WDA version is unknown', async () => {
mockedSpawn.mockImplementation((async (command: string) => ({
stdout:
command === 'xcodebuild' ? 'Xcode 26.5\nBuild version 17F90\n' : 'maestro-runner 1.2.3\n',
command === 'xcodebuild' ? 'Xcode 26.5\nBuild version 17F90\n' : 'maestro-runner 1.1.27\n',
})) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock({
@@ -593,7 +606,7 @@ describe('createInstallMaestroBuildFunction', () => {
mockedSpawn.mockImplementation((async (command: string) => {
switch (command) {
case 'maestro-runner':
return { stdout: 'maestro-runner 1.2.3\n' };
return { stdout: 'maestro-runner 1.1.27\n' };
case 'xcodebuild':
return { stdout: 'Xcode 26.5\nBuild version 17F90\n' };
case 'xcrun':
@@ -633,7 +646,7 @@ describe('createInstallMaestroBuildFunction', () => {
});
await expect(step.executeAsync()).resolves.toBeUndefined();
expect(step.getOutputValueByName('maestro_version')).toBe('1.2.3');
expect(step.getOutputValueByName('maestro_version')).toBe('1.1.27');
expect(mockedDownloadFile.mock.calls.map(([url]) => url)).toEqual([
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.5-wda-11.1.3.tar.gz',
'https://storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.5-wda-11.1.3.tar.gz',
@@ -1,5 +1,6 @@
import { BuildRuntimePlatform, BuildStep } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { fs, vol } from 'memfs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createInstallMitmproxyBuildFunction } from '../installMitmproxy';
@@ -11,6 +12,58 @@ jest.mock('@expo/turtle-spawn', () => ({
const mockedSpawn = jest.mocked(spawn);
// An x86 Homebrew prefix, to check that the app is found from PATH and not from process.arch.
const APP = '/usr/local/Caskroom/mitmproxy/12.2.3/mitmproxy.app';
const LINK = '/usr/local/bin/mitmdump';
function installCask(): void {
vol.fromJSON({ [`${APP}/Contents/MacOS/mitmdump`]: '', '/usr/local/bin': null });
fs.symlinkSync(`${APP}/Contents/MacOS/mitmdump`, LINK);
}
/**
* `which` finds mitmdump once the cask is installed, `brew` installs it, `xattr -p` reports
* quarantine while `stuck` is set, and each `mitmdump --version` takes the next result.
*/
function mockCommands({
installed = false,
stuck = false,
runs = [],
}: {
installed?: boolean;
stuck?: boolean;
runs?: boolean[];
}): void {
if (installed) {
installCask();
}
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
if (command === 'which' && !installed) {
throw new Error('not found');
}
if (command === 'which') {
return { stdout: `${LINK}\n` };
}
if (command === 'brew' && !installed) {
installed = true;
installCask();
}
if (command === 'xattr' && args[0] === '-p' && !stuck) {
throw new Error('No such xattr: com.apple.quarantine');
}
if (command === 'mitmdump' && !runs.shift()) {
throw new Error('not found');
}
return { stdout: '' };
}) as never);
}
function spawnedCommands(): string[] {
return mockedSpawn.mock.calls.map(([command, args]) =>
command === 'xattr' ? `xattr ${args?.[0]}` : command
);
}
describe('createInstallMitmproxyBuildFunction', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -24,28 +77,51 @@ describe('createInstallMitmproxyBuildFunction', () => {
});
}
it('does not install when mitmdump is already on PATH', async () => {
mockedSpawn.mockResolvedValueOnce({} as never);
it('takes the app behind mitmdump on PATH out of quarantine before running it', async () => {
mockCommands({ installed: true, runs: [true] });
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith('mitmdump', ['--version'], expect.anything());
expect(mockedSpawn).toHaveBeenCalledTimes(1);
expect(mockedSpawn).toHaveBeenCalledWith(
'xattr',
['-dr', 'com.apple.quarantine', APP],
expect.anything()
);
expect(spawnedCommands()).toEqual(['which', 'xattr -dr', 'xattr -p', 'mitmdump']);
});
it('warns instead of installing outside EAS Build VMs', async () => {
mockedSpawn.mockRejectedValueOnce(new Error('not found'));
it('kills a mitmdump --version that does not return', async () => {
mockCommands({ installed: true, runs: [true] });
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'mitmdump',
['--version'],
expect.objectContaining({ stdio: 'ignore', timeout: 60_000, killSignal: 'SIGKILL' })
);
});
it('does not run a mitmdump that stays quarantined', async () => {
mockCommands({ installed: true, stuck: true });
await expect(
createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync()
).resolves.toBeUndefined();
expect(spawnedCommands()).not.toContain('mitmdump');
});
it('does not change quarantine outside EAS Build VMs', async () => {
mockCommands({ installed: true, stuck: true, runs: [true] });
await createStep({}).executeAsync();
expect(mockedSpawn).toHaveBeenCalledTimes(1);
expect(spawnedCommands()).toEqual(['mitmdump']);
});
it('installs the cask with Homebrew and leaves auto-update off', async () => {
mockedSpawn
.mockRejectedValueOnce(new Error('not found'))
.mockResolvedValueOnce({} as never)
.mockResolvedValueOnce({} as never);
it('installs the cask with Homebrew and takes it out of quarantine', async () => {
mockCommands({ runs: [false, true] });
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
@@ -54,13 +130,19 @@ describe('createInstallMitmproxyBuildFunction', () => {
['install', '--cask', 'mitmproxy'],
expect.objectContaining({ env: expect.objectContaining({ HOMEBREW_NO_AUTO_UPDATE: '1' }) })
);
expect(spawnedCommands()).toEqual([
'which',
'mitmdump',
'brew',
'which',
'xattr -dr',
'xattr -p',
'mitmdump',
]);
});
it('does not fail the job when mitmdump is still not runnable', async () => {
mockedSpawn
.mockRejectedValueOnce(new Error('not found'))
.mockResolvedValueOnce({} as never)
.mockRejectedValueOnce(new Error('not found'));
mockCommands({ runs: [false, false] });
await expect(
createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync()
@@ -1,5 +1,6 @@
import { BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { spawnSync } from 'node:child_process';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
@@ -179,17 +180,7 @@ describe(launchApplicationAsync, () => {
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
'adb',
[
'shell',
'am',
'start',
'-a',
'android.intent.action.VIEW',
'-d',
'exp://example.test',
'-n',
'host.exp.exponent/host.exp.exponent.MainActivity',
],
['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', 'exp://example.test'],
{ env: {}, logger }
);
expect(logger.info).toHaveBeenNthCalledWith(
@@ -211,6 +202,263 @@ describe(launchApplicationAsync, () => {
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('quotes Android launch arguments and URLs for the device shell', async () => {
const logger = createMockLogger();
await launchApplicationAsync({
applicationIdentifier: 'dev.example.app',
activityName: 'dev.example.app.MainActivity',
launchArgs: ['-d', 'euxy://tempo', '--es', 'probe', 'hello world', '--es', 'quote', "it's"],
openUrl: 'exp://example.test/path?a=1&b=2',
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger,
});
expect(mockedSpawn).toHaveBeenNthCalledWith(
1,
'adb',
[
'shell',
'am',
'start',
'-d',
'euxy://tempo',
'--es',
'probe',
"'hello world'",
'--es',
'quote',
"'it'\\''s'",
'-n',
'dev.example.app/dev.example.app.MainActivity',
],
{ env: {}, logger }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
'adb',
[
'shell',
'am',
'start',
'-a',
'android.intent.action.VIEW',
'-d',
"'exp://example.test/path?a=1&b=2'",
],
{ env: {}, logger }
);
expect(logger.info).toHaveBeenNthCalledWith(
1,
`Launching dev.example.app with arguments ${JSON.stringify(['-d', 'euxy://tempo', '--es', 'probe', 'hello world', '--es', 'quote', "it's"])}.`
);
});
it('passes iOS launch arguments with spaces unquoted', async () => {
await launchApplicationAsync({
applicationIdentifier: 'com.example.app',
launchArgs: ['-name', 'hello world'],
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: {},
logger: createMockLogger(),
});
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
['simctl', 'launch', 'booted', 'com.example.app', '-name', 'hello world'],
expect.any(Object)
);
});
it('round-trips quoted Android arguments through a POSIX shell', async () => {
const component = 'dev.example.app/dev.example.app.MainActivity$Inner';
const launchArgs = [
'--es',
'text',
'hello world',
'--es',
'json',
'{"a":1,"b":2}',
'--es',
'shell',
'$(printf injected) `printf injected` $HOME ; & | < >',
'--es',
'glob',
'*',
'--es',
'home',
'~',
'--esa',
'list',
'a\\,b,c',
'--es',
'whitespace',
'one\ntwo\tthree',
'--es',
'unicode',
'héllo 🌍',
'--es',
'empty',
'',
];
const openUrl = 'exp://example.test/path?a=1&b=2#fragment';
await launchApplicationAsync({
applicationIdentifier: 'dev.example.app',
activityName: 'dev.example.app.MainActivity$Inner',
launchArgs,
openUrl,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
});
// adb joins the words after `shell` with spaces and the device shell parses the result.
const parsedCommands = mockedSpawn.mock.calls.map(([, args]) => {
const { stdout } = spawnSync(
'/bin/sh',
['-c', `am() { printf '%s\\0' "$@"; }; ${(args as string[]).slice(1).join(' ')}`],
{ encoding: 'utf8' }
);
return stdout.split('\0').slice(0, -1);
});
expect(parsedCommands).toEqual([
['start', ...launchArgs, '-n', component],
['start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
]);
});
it.each([[['--es', 'key', '']], [['-e', 'key', '']], [['-d', '']]])(
'passes empty Android option values as empty arguments (%j)',
async launchArgs => {
await launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
});
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
[
'shell',
'am',
'start',
...launchArgs.map(argument => (argument === '' ? "''" : argument)),
'-n',
'com.example.app/com.example.app.MainActivity',
],
expect.any(Object)
);
}
);
it('explains an Android launch failure when a launch argument is empty', async () => {
const launchError = new Error('adb shell am start exited with non-zero code: 1');
mockedSpawn.mockRejectedValueOnce(launchError);
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs: ['--ez', 'isTest', 'true', ''],
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({
errorCode: 'EAS_LAUNCH_APPLICATION_FAILED',
message: expect.stringContaining('empty argument at position 4'),
cause: launchError,
});
});
it('rethrows an Android launch failure unchanged when no launch argument is empty', async () => {
const launchError = new Error('adb shell am start exited with non-zero code: 1');
mockedSpawn.mockRejectedValueOnce(launchError);
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs: ['--ez', 'isTest', 'true'],
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toBe(launchError);
});
it.each([
{ launchArgs: ['--es', 'key', 'a\0b'] },
{ launchArgs: [], openUrl: 'exp://example.test/a\0b' },
])('rejects NUL characters on Android (%j)', async ({ launchArgs, openUrl }) => {
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs,
openUrl,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
expect(mockedSpawn).not.toHaveBeenCalledWith(
'adb',
expect.arrayContaining([expect.stringContaining('\0')]),
expect.any(Object)
);
});
it('accepts an Android launch command of exactly 65,000 bytes', async () => {
const fixedLength = 'am start --es key -n com.example.app/com.example.app.MainActivity'.length;
await launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs: ['--es', 'key', 'a'.repeat(65_000 - fixedLength)],
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
});
expect(mockedSpawn).toHaveBeenCalledTimes(1);
});
it('rejects an Android URL command of 65,001 bytes', async () => {
const fixedLength = 'am start -a android.intent.action.VIEW -d exp://example.test/'.length;
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
openUrl: `exp://example.test/${'a'.repeat(65_001 - fixedLength)}`,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
expect(mockedSpawn).toHaveBeenCalledTimes(1);
});
it('rejects an Android launch command that is too long after quoting', async () => {
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs: ['--es', 'quotes', "'".repeat(20_000)],
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('launches using application metadata passed to the build step', async () => {
const launchApplication = createLaunchApplicationFunction();
const buildStep = launchApplication.createBuildStepFromFunctionCall(
@@ -240,16 +488,36 @@ describe(launchApplicationAsync, () => {
],
expect.any(Object)
);
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', 'exp://example.test'],
expect.any(Object)
);
});
it('quotes Android launch arguments passed to the build step', async () => {
const launchApplication = createLaunchApplicationFunction();
const buildStep = launchApplication.createBuildStepFromFunctionCall(
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
{
callInputs: {
application_identifier: 'com.example.app',
activity_name: 'com.example.app.MainActivity',
launch_args: ['--es', 'probe', 'hello world'],
},
}
);
await buildStep.executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
[
'shell',
'am',
'start',
'-a',
'android.intent.action.VIEW',
'-d',
'exp://example.test',
'--es',
'probe',
"'hello world'",
'-n',
'com.example.app/com.example.app.MainActivity',
],
@@ -1,4 +1,4 @@
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
import { SystemError, UserError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import fs from 'fs/promises';
import os from 'os';
@@ -305,7 +305,6 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name:
format === 'html' ? 'Maestro Runner HTML Report' : 'Maestro Runner Allure Results',
paths: [
@@ -1091,7 +1090,6 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name: 'Maestro HTML Report',
paths: ['/home/expo/.maestro/tests/android-maestro-html.html'],
}),
@@ -1420,7 +1418,6 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name: shot.displayName,
metadata: shot.metadata,
}),
@@ -14,11 +14,17 @@ import {
registerLocalEgressResources,
stopLocalEgressResourcesAsync,
} from '../../utils/localEgress';
import {
reportLocalEgressGuardCoverageAsync,
resolveLocalEgressServeSimBootEnvironmentAsync,
startLocalEgressGuardRelayAsync,
verifyLocalEgressGuardAsync,
} from '../../utils/localEgressGuard';
import {
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
@@ -29,6 +35,12 @@ jest.mock('../../../utils/processes');
jest.mock('../../../utils/turtleFetch');
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/agentDeviceArtifacts');
jest.mock('../startIosSimulator', () => ({
...jest.requireActual('../startIosSimulator'),
bootIosSimulatorAsync: jest
.fn()
.mockResolvedValue({ deviceIdentifier: 'sim', udid: 'sim', displayName: 'iPhone' }),
}));
jest.mock('../../utils/argentArtifacts');
jest.mock('../../utils/agentDeviceEvents', () => ({
startAgentDeviceEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
@@ -45,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',
@@ -78,7 +97,7 @@ const fields = {
egressPort: handoff.port,
};
const controllers = [
['agent-device', createStartAgentDeviceRemoteSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
['agent-device', createStartAgentDeviceSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
['Appium', createStartAppiumRemoteSessionBuildFunction, 'appiumUrl'],
['Argent', createStartArgentRemoteSessionBuildFunction, 'toolsUrl'],
['web preview', createStartWebPreviewRemoteSessionBuildFunction, 'previewUrl'],
@@ -93,10 +112,10 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
await createFunction(ctx).fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
// Inputs a step does not get default to undefined, like in a real step call.
inputs: new Proxy({} as Record<string, { value: unknown }>, {
get: (target, id: string) => target[id] ?? { value: undefined },
}),
outputs: {},
env,
signal,
@@ -120,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',
@@ -132,6 +162,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
});
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: openPreview,
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopPreview,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
@@ -0,0 +1,202 @@
import { SystemError } from '@expo/eas-build-job';
import turtleSpawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import { spawn } from 'node:child_process';
import { EventEmitter, once } from 'node:events';
import path from 'node:path';
import { PassThrough } from 'node:stream';
import { createMockLogger } from '../../../__tests__/utils/logger';
import * as agentRun from '../../utils/agentRun';
import { leaseAgentRunProviderCredentialsAsync } from '../../utils/agentRunLease';
import { createRunAgentBuildFunction } from '../runAgent';
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('node:child_process', () => ({
...jest.requireActual('node:child_process'),
spawn: jest.fn(),
}));
jest.mock('../../utils/agentRunLease');
const installMock = jest.mocked(turtleSpawn);
const spawnMock = jest.mocked(spawn);
const leaseMock = jest.mocked(leaseAgentRunProviderCredentialsAsync);
const ctx = {
expoApiV2BaseUrl: 'https://staging-api.expo.test/v2/',
mcpServerUrl: 'wss://staging-mcp.expo.test',
runtimeApi: { addBuildLogMask: jest.fn() },
} as any;
const inputs = {
agent_kind: { value: 'claude-code' },
agent_run_id: { value: '0199b0a0-1111-7222-8333-444455556666' },
max_duration_seconds: { value: 1800 },
prompt: { value: 'Fix the failing test.' },
};
const env = {
PATH: '/usr/local/bin:/usr/bin',
HOME: '/home/expo',
EXPO_TOKEN: 'expo-token',
__API_SERVER_URL: 'https://staging-api.expo.test',
EAS_BUILD_ID: 'build-id',
NODE_OPTIONS: '--max-old-space-size=3072',
USER_DEFINED_SECRET: 'secret',
};
async function runAsync(
overrides: {
ctx?: any;
inputs?: Record<string, { value: unknown }>;
env?: Record<string, string>;
} = {}
): Promise<void> {
const fn = createRunAgentBuildFunction(overrides.ctx ?? ctx);
await fn.fn!({ logger: createMockLogger() } as any, {
inputs: overrides.inputs ?? inputs,
outputs: {},
env: overrides.env ?? env,
});
}
function mockSuccessfulRun(): { mcpConfig?: any } {
const captured: { mcpConfig?: any } = {};
leaseMock.mockResolvedValue({ provider: 'anthropic', accessToken: 'anthropic-access-token' });
installMock.mockResolvedValue({} as any);
spawnMock.mockImplementation(((_command: string, _args: string[], spawnOptions: any) => {
const child = Object.assign(new EventEmitter(), {
stdin: { on: jest.fn(), end: jest.fn() },
stdout: new PassThrough(),
stderr: new PassThrough(),
});
captured.mcpConfig = JSON.parse(
vol.readFileSync(path.join(spawnOptions.env.HOME, 'mcp.json'), 'utf8') as string
);
process.nextTick(() => {
child.emit('exit', 0, null);
child.stdout.end();
child.stderr.end();
void Promise.all([once(child.stdout, 'end'), once(child.stderr, 'end')]).then(() =>
child.emit('close')
);
});
return child;
}) as any);
return captured;
}
describe(createRunAgentBuildFunction, () => {
it('is the eas/run_agent function', () => {
expect(createRunAgentBuildFunction(ctx).getFullId()).toBe('eas/run_agent');
});
it('passes none of the job environment to npm or the agent', async () => {
mockSuccessfulRun();
await runAsync();
expect(leaseMock).toHaveBeenCalledWith(
expect.objectContaining({
expoApiV2BaseUrl: 'https://staging-api.expo.test/v2/',
expoToken: 'expo-token',
agentRunId: '0199b0a0-1111-7222-8333-444455556666',
})
);
const npmEnv = installMock.mock.calls[0][2]!.env!;
const agentEnv = spawnMock.mock.calls[0][2].env!;
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'LANG', 'PATH', 'TMPDIR']);
expect(Object.keys(agentEnv).sort()).toEqual([
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
'CLAUDE_CODE_OAUTH_TOKEN',
'HOME',
'LANG',
'PATH',
'TMPDIR',
]);
expect(agentEnv.PATH).toBe('/usr/local/bin:/usr/bin');
expect(agentEnv.HOME).not.toBe('/home/expo');
});
it('hands the build log secrets filter to the agent run', async () => {
const runAgentSpy = jest.spyOn(agentRun, 'runAgentAsync').mockResolvedValue();
try {
await runAsync();
expect(runAgentSpy).toHaveBeenCalledWith(
expect.objectContaining({ addBuildLogMask: ctx.runtimeApi.addBuildLogMask })
);
} finally {
runAgentSpy.mockRestore();
}
});
it.each([
['wss://staging-mcp.expo.test', 'https://staging-mcp.expo.test/mcp'],
['ws://localhost:8787', 'http://localhost:8787/mcp'],
])('points the agent at the HTTP endpoint of %s', async (mcpServerUrl, mcpUrl) => {
const captured = mockSuccessfulRun();
await runAsync({ ctx: { ...ctx, mcpServerUrl } });
expect(captured.mcpConfig.mcpServers.expo.url).toBe(mcpUrl);
});
it('stops the run at max_duration_seconds and reports a timeout', async () => {
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
const runAgentSpy = jest.spyOn(agentRun, 'runAgentAsync').mockImplementation(
({ signal }) =>
new Promise((_resolve, reject) => {
signal!.addEventListener('abort', () => reject(signal!.reason));
})
);
try {
const run = runAsync({ inputs: { ...inputs, max_duration_seconds: { value: 60 } } });
const rejection = expect(run).rejects.toMatchObject({
errorCode: 'EAS_RUN_AGENT_TIMEOUT',
message: expect.stringContaining('60 seconds'),
});
await jest.advanceTimersByTimeAsync(59_000);
expect(runAgentSpy.mock.calls[0][0].signal!.aborted).toBe(false);
await jest.advanceTimersByTimeAsync(1_000);
await rejection;
} finally {
runAgentSpy.mockRestore();
jest.useRealTimers();
}
});
it.each([
['an unknown agent_kind', { inputs: { ...inputs, agent_kind: { value: 'gemini' } } }],
['a malformed agent_run_id', { inputs: { ...inputs, agent_run_id: { value: 'not-a-uuid' } } }],
['an empty agent_run_id', { inputs: { ...inputs, agent_run_id: { value: '' } } }],
['a zero duration', { inputs: { ...inputs, max_duration_seconds: { value: 0 } } }],
['a negative duration', { inputs: { ...inputs, max_duration_seconds: { value: -300 } } }],
['a fractional duration', { inputs: { ...inputs, max_duration_seconds: { value: 12.5 } } }],
['an empty prompt', { inputs: { ...inputs, prompt: { value: '' } } }],
['no EXPO_TOKEN', { env: { ...env, EXPO_TOKEN: '' } }],
['no PATH', { env: { ...env, PATH: '' } }],
['no Expo API URL', { ctx: { ...ctx, expoApiV2BaseUrl: undefined } }],
['no MCP server URL', { ctx: { ...ctx, mcpServerUrl: undefined } }],
['no build log secrets filter', { ctx: { ...ctx, runtimeApi: {} } }],
])('throws a system error for %s', async (_name, overrides) => {
await expect(runAsync(overrides)).rejects.toBeInstanceOf(SystemError);
expect(leaseMock).not.toHaveBeenCalled();
expect(installMock).not.toHaveBeenCalled();
expect(spawnMock).not.toHaveBeenCalled();
});
it('reports every invalid input at once', async () => {
await expect(
runAsync({
inputs: {
...inputs,
agent_run_id: { value: 'not-a-uuid' },
max_duration_seconds: { value: 0 },
},
})
).rejects.toThrow(
'The "agent_run_id" input must be the UUID of an agent run, but received "not-a-uuid". ' +
'The "max_duration_seconds" input must be a positive whole number of seconds, but received "0".'
);
});
});
@@ -1,10 +1,9 @@
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import { BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
@@ -20,11 +19,9 @@ import {
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
} from '../../utils/remoteDeviceRunSession';
import { createProcessOutput } from '../../utils/processOutput';
import { type StartupTasks, createStartupTasks } from '../../utils/startupTasks';
import {
createStartAgentDeviceRemoteSessionBuildFunction,
runAgentDeviceRemoteSessionAsync,
} from '../startAgentDeviceRemoteSession';
import { runAgentDeviceRemoteSessionAsync } from '../startAgentDeviceRemoteSession';
// The daemon entry path and the state directory are resolved from the home directory when
// the module loads, so point it at a temp home we can populate.
@@ -70,28 +67,42 @@ const mockTunnelStopAsync = jest.fn();
const mockDaemonStopAsync = jest.fn();
const mockEventCollectionStopAsync = jest.fn();
type CaptureInputs = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['capture'];
const NO_CAPTURE: CaptureInputs = { networkCapture: false, networkCaptureFields: [] };
/** Runs an Android session that is ready, or an iOS session with no application preparation. */
async function runAsync(
logger: { info: jest.Mock; warn: jest.Mock },
logger: { info: jest.Mock; warn: jest.Mock; child: jest.Mock },
runtimePlatform: BuildRuntimePlatform,
launchInputs: Record<string, { value: unknown }> = {}
capture: CaptureInputs = NO_CAPTURE,
disableShare = false,
privatePreviewUrl?: string,
maxIdleTimeMinutes?: number
): Promise<void> {
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
await buildFunction.fn!(
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
max_duration_seconds: { value: undefined },
...launchInputs,
},
outputs: {},
env: {},
} as never
);
await runAgentDeviceRemoteSessionAsync(ctx, {
env: {},
logger: logger as never,
runtimePlatform,
sessionEnv: {
deviceRunSessionId: 'device-run-session-id',
ngrokTunnelDomain: 'tunnel.example.com',
ngrokAuthtoken: 'ngrok-token',
},
packageVersion: undefined,
maxIdleTimeMinutes,
maxDurationSeconds: undefined,
capture,
disableShare,
privatePreviewUrl,
tasks: createStartupTasks(logger as never),
device:
runtimePlatform === BuildRuntimePlatform.DARWIN
? { iosSimulatorUdid: 'selected-udid', application: Promise.resolve() }
: { booted: Promise.resolve(), ready: Promise.resolve() },
});
}
describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () => {
describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
beforeEach(async () => {
jest.clearAllMocks();
@@ -117,13 +128,24 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction 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);
@@ -141,10 +163,9 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
await runAsync(logger, BuildRuntimePlatform.LINUX);
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, timeoutMs: 60_000 })
);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
@@ -162,36 +183,48 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
});
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
it('redacts learned daemon credentials in arbitrary output and retained diagnostics', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
launch_app_identifier: { value: 'host.exp.Exponent' },
launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] },
open_url: { value: 'exp://127.0.0.1:8081' },
const token = 'daemon secret/+';
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 5678, token });
jest.mocked(startNgrokTunnelAsync).mockImplementation(async () => {
const options = jest.mocked(spawnDetached).mock.calls[0][0];
expect(options.secrets).toContain(token);
const output = createProcessOutput(options.logger, options.secrets);
output.stdout.append('unrecognized value: daemon sec');
output.stdout.append('ret/+\n');
output.stderr.append(`escaped value: ${encodeURIComponent(token)}\n`);
expect(output.getOutput()).toBe(
'unrecognized value: [REDACTED]\nescaped value: [REDACTED]\n'
);
return { url: 'https://daemon.test', subdomainId: 'daemon', stopAsync: mockTunnelStopAsync };
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
runtimePlatform: BuildRuntimePlatform.DARWIN,
launchAppIdentifier: 'host.exp.Exponent',
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
openUrl: 'exp://127.0.0.1:8081',
})
);
expect(logger.info).toHaveBeenCalledWith(
'serve-sim will launch host.exp.Exponent with arguments ' +
'["-EXDevMenuIsOnboardingFinished","1"], then open exp://127.0.0.1:8081.'
);
await runAsync(logger, BuildRuntimePlatform.DARWIN);
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(token);
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(encodeURIComponent(token));
});
it('hands network capture to serve-sim on an iOS session', async () => {
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() };
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
network_capture: { value: true },
network_capture_fields: { value: ['header'] },
networkCapture: true,
networkCaptureFields: ['header'],
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
@@ -200,24 +233,22 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
);
});
it('fails before starting the daemon when network capture is asked for on Android', async () => {
it('hands a private preview choice to the session host', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
await expect(
runAsync(logger, BuildRuntimePlatform.LINUX, { network_capture: { value: true } })
).rejects.toThrow('records traffic through serve-sim on an iOS simulator');
expect(spawnDetached).not.toHaveBeenCalled();
});
it('fails before starting the daemon when a launch is asked for on Android', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
await expect(
runAsync(logger, BuildRuntimePlatform.LINUX, {
launch_app_identifier: { value: 'host.exp.Exponent' },
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',
})
).rejects.toThrow('runs on linux');
expect(spawnDetached).not.toHaveBeenCalled();
);
});
it('starts the session host without waiting for the agent-device daemon', async () => {
@@ -288,14 +319,14 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction 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;
});
@@ -303,18 +334,26 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction 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',
@@ -323,10 +362,9 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
packageVersion: undefined,
maxIdleTimeMinutes: undefined,
maxDurationSeconds: undefined,
launch: {},
capture: { networkCapture: false, networkCaptureFields: [] },
capture: NO_CAPTURE,
tasks,
device: typeof device === 'function' ? device(tasks) : device,
device: sessionDevice,
});
}
@@ -336,7 +374,31 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction 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();
@@ -348,10 +410,237 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction 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();
@@ -361,10 +650,11 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction 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>(() => {});
@@ -378,7 +668,7 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction 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[],
@@ -394,34 +684,26 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction 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);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
});
it('declares the launch inputs', () => {
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
const globalCtx = createGlobalContextMock();
expect(
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
});
});
@@ -93,6 +93,28 @@ describe(startAgentDeviceDaemonAsync, () => {
}) as never);
});
it.each([false, true])(
'shares the daemon secret list through git fallback=%s',
async fallback => {
if (fallback) {
jest.mocked(spawn).mockResolvedValue({ stdout: '' } as never);
}
const secrets: string[] = [];
const handle = await startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
env: {},
logger,
secrets,
});
const options = jest.mocked(spawnDetached).mock.calls[0][0];
expect(options.command).toBe(fallback ? 'bun' : 'node');
expect(options.secrets).toBe(secrets);
secrets.push('learned-daemon-token');
expect(options.secrets).toContain('learned-daemon-token');
await handle.stopAsync();
}
);
it('installs with bun add by default and launches the published daemon', async () => {
const handle = await startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
@@ -108,6 +130,8 @@ describe(startAgentDeviceDaemonAsync, () => {
const addCwd = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
expect(spawnDetached).toHaveBeenCalledWith({
command: 'node',
logger,
secrets: undefined,
args: [path.join(addCwd, 'node_modules/agent-device/dist/src/internal/daemon.js')],
env: expect.objectContaining({
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
@@ -121,6 +145,20 @@ describe(startAgentDeviceDaemonAsync, () => {
await expect(fs.promises.access(addCwd)).rejects.toThrow();
});
it('removes the install directory when the daemon cannot be stopped', async () => {
const stopError = new Error('drain timed out');
stopAsync.mockRejectedValueOnce(stopError);
const handle = await startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
env: {},
logger,
});
const addCwd = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
await expect(handle.stopAsync()).rejects.toBe(stopError);
await expect(fs.promises.access(addCwd)).rejects.toThrow();
});
it('installs with npm when EAS_OVERRIDE_PACKAGE_MANAGER is npm', async () => {
await startAgentDeviceDaemonAsync({
packageVersion: undefined,
@@ -136,10 +174,15 @@ describe(startAgentDeviceDaemonAsync, () => {
});
it('kills the install and does not fall back to git when aborted', async () => {
let installStarted!: () => void;
const started = new Promise<void>(resolve => {
installStarted = resolve;
});
jest.mocked(spawn).mockImplementation(
((_command: string, _args: string[], options?: { signal?: AbortSignal }) =>
new Promise((_resolve, reject) => {
options!.signal!.addEventListener('abort', () => reject(options!.signal!.reason));
installStarted();
})) as never
);
const controller = new AbortController();
@@ -151,7 +194,7 @@ describe(startAgentDeviceDaemonAsync, () => {
logger,
signal: controller.signal,
});
await new Promise(resolve => setImmediate(resolve));
await started;
controller.abort(failure);
await expect(daemon).rejects.toBe(failure);
@@ -199,6 +242,8 @@ describe(startAgentDeviceDaemonAsync, () => {
);
expect(spawnDetached).toHaveBeenCalledWith({
command: 'bun',
logger,
secrets: undefined,
args: ['run', 'src/daemon.ts'],
cwd: '/tmp/agent-device-src',
env: expect.objectContaining({
@@ -1,10 +1,20 @@
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import { UserError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { DeviceRunSessionWorkerApi } from '../../utils/deviceRunSessionWorkerApi';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { stopLocalEgressResourcesAsync } from '../../utils/localEgress';
import {
resolveLocalEgressServeSimBootEnvironmentAsync,
startLocalEgressGuardRelayAsync,
} from '../../utils/localEgressGuard';
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
import { downloadBuildAsync } from '../downloadBuild';
import { installBuildAsync } from '../installBuild';
import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact';
import { launchApplicationAsync } from '../launchApplication';
import {
getAgentDeviceRemoteSessionEnvOrThrow,
@@ -14,17 +24,30 @@ import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceS
import { startAndroidEmulatorAsync } from '../startAndroidEmulator';
import { bootIosSimulatorAsync } from '../startIosSimulator';
jest.mock('../../utils/localEgressSession', () => ({
withLocalEgressSession: (fn: unknown) => fn,
jest.mock('../../utils/localEgress', () => ({
stopLocalEgressResourcesAsync: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../../utils/localEgressGuard', () => ({
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn(),
startLocalEgressGuardRelayAsync: jest.fn(),
}));
jest.mock('@expo/turtle-spawn');
jest.mock('../../../utils/IosSimulatorUtils');
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
}));
jest.mock('../startIosSimulator', () => ({ bootIosSimulatorAsync: jest.fn() }));
jest.mock('../startIosSimulator', () => ({
...jest.requireActual('../startIosSimulator'),
bootIosSimulatorAsync: jest.fn(),
}));
jest.mock('../startAndroidEmulator', () => ({ startAndroidEmulatorAsync: jest.fn() }));
jest.mock('../downloadBuild', () => ({ downloadBuildAsync: jest.fn() }));
jest.mock('../installBuild', () => ({ installBuildAsync: jest.fn() }));
jest.mock('../../utils/iosAppArtifact', () => ({
...jest.requireActual('../../utils/iosAppArtifact'),
readIosApplicationIdentifierAsync: jest.fn(),
}));
jest.mock('../launchApplication', () => ({
...jest.requireActual('../launchApplication'),
launchApplicationAsync: jest.fn(),
@@ -42,7 +65,17 @@ const sessionEnv = {
ngrokAuthtoken: 'ngrok-token',
};
type Device = { booted: Promise<unknown>; ready: Promise<unknown> };
type Device = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['device'];
let deviceLaunch: unknown;
function runDevicePreparationAsync(device: Device): Promise<unknown> {
return (
'iosSimulatorUdid' in device ? (device.application ?? Promise.resolve()) : device.ready
).then(async launch => {
deviceLaunch = launch;
return launch;
});
}
function deferred<T = void>(): {
promise: Promise<T>;
@@ -67,9 +100,11 @@ async function flushAsync(): Promise<void> {
function runStep(
runtimePlatform: BuildRuntimePlatform,
inputValues: Record<string, unknown> = {}
inputValues: Record<string, unknown> = {},
signal?: AbortSignal,
stepCtx: CustomBuildContext = ctx
): Promise<void> {
const buildFunction = createStartAgentDeviceSessionBuildFunction(ctx);
const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx);
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const inputs = Object.fromEntries(
[
@@ -84,6 +119,10 @@ function runStep(
'open_url',
'network_capture',
'network_capture_fields',
'public_preview',
'private_preview_url',
'frame_ancestors',
'cors_origins',
'package_version',
'max_idle_time_minutes',
'max_duration_seconds',
@@ -97,22 +136,32 @@ function runStep(
staticContext: { job: { secrets: { robotAccessToken: 'robot-token' } } },
},
} as unknown as BuildStepContext,
{ inputs, outputs: {}, env: {}, signal: undefined } as never
{ inputs, outputs: {}, env: {}, signal } as never
) as Promise<void>;
}
/** The `device` promises handed to the shared agent-device session code. */
function sessionDevice(): Device {
return jest.mocked(runAgentDeviceRemoteSessionAsync).mock.calls[0][1].device;
}
describe(createStartAgentDeviceSessionBuildFunction, () => {
beforeEach(() => {
deviceLaunch = undefined;
jest.clearAllMocks();
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(null);
jest.mocked(startLocalEgressGuardRelayAsync).mockResolvedValue(undefined);
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Shutdown' } as never);
jest.mocked(spawn).mockResolvedValue({ stdout: '' } as never);
jest.mocked(IosSimulatorUtils.resolveUdidAsync).mockResolvedValue('selected-udid' as never);
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ name: 'iPhone 17', udid: 'SELECTED-UDID' }] as never);
jest.mocked(getAgentDeviceRemoteSessionEnvOrThrow).mockReturnValue(sessionEnv);
jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async (_ctx, { device }) => {
await device.ready;
});
jest
.mocked(runAgentDeviceRemoteSessionAsync)
.mockImplementation(async (_ctx, { device, tasks }) => {
await tasks.untilAborted(runDevicePreparationAsync(device));
});
jest.mocked(bootIosSimulatorAsync).mockResolvedValue({
deviceIdentifier: 'iPhone 17' as never,
udid: 'udid' as never,
@@ -128,22 +177,31 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
.mocked(installBuildAsync)
.mockResolvedValue({ applicationIdentifier: 'dev.example.app', activityName: '.Main' });
jest.mocked(launchApplicationAsync).mockResolvedValue(undefined);
jest.mocked(readIosApplicationIdentifierAsync).mockResolvedValue('dev.example.app');
});
it('downloads the build and starts the session while the iOS Simulator boots', async () => {
const boot = deferred<Awaited<ReturnType<typeof bootIosSimulatorAsync>>>();
jest.mocked(bootIosSimulatorAsync).mockReturnValue(boot.promise);
const step = runStep(BuildRuntimePlatform.DARWIN, {
it('hands guarded iOS boot to serve-sim and starts reporting before host startup', async () => {
const bootEnv = {
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
SIMCTL_CHILD_EAS_EGRESS_GUARD_MODE: 'block',
SIMCTL_CHILD_http_proxy: 'http://127.0.0.1:8899',
};
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(bootEnv);
await runStep(BuildRuntimePlatform.DARWIN, {
build_id: 'build-id',
launch_args: ['-flag'],
open_url: 'exp://example.test',
});
await flushAsync();
expect(
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
).toBeLessThan(jest.mocked(bootIosSimulatorAsync).mock.invocationCallOrder[0]);
).toBeLessThan(
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mock.invocationCallOrder[0]
);
expect(startLocalEgressGuardRelayAsync).toHaveBeenCalledTimes(1);
expect(jest.mocked(startLocalEgressGuardRelayAsync).mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(runAgentDeviceRemoteSessionAsync).mock.invocationCallOrder[0]
);
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({
buildId: 'build-id',
@@ -153,32 +211,180 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
})
);
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(1);
// The install needs the booted Simulator.
expect(installBuildAsync).not.toHaveBeenCalled();
boot.resolve({
deviceIdentifier: 'iPhone 17' as never,
udid: 'udid' as never,
displayName: '',
const device = sessionDevice();
expect(device).toEqual({
iosSimulatorUdid: 'SELECTED-UDID',
bootEnv,
application: expect.any(Promise),
});
await step;
expect(spawn).not.toHaveBeenCalled();
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
artifactPath: '/tmp/App.app',
env: {},
});
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
expect(deviceLaunch).toEqual({
installAppPath: '/tmp/App.app',
launchAppIdentifier: 'dev.example.app',
launchArgs: ['-flag'],
openUrl: 'exp://example.test',
});
});
expect(installBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({ artifactPath: '/tmp/App.app' })
);
expect(launchApplicationAsync).toHaveBeenCalledWith(
expect.objectContaining({
applicationIdentifier: 'dev.example.app',
launchArgs: ['-flag'],
openUrl: 'exp://example.test',
})
);
expect(jest.mocked(installBuildAsync).mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(launchApplicationAsync).mock.invocationCallOrder[0]
it('shuts down only the selected guarded Booted device before host startup', async () => {
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
});
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Booted' } as never);
await runStep(BuildRuntimePlatform.DARWIN);
expect(IosSimulatorUtils.getDeviceAsync).toHaveBeenCalledWith({
udid: 'SELECTED-UDID',
env: {},
});
expect(spawn).toHaveBeenCalledTimes(1);
expect(spawn).toHaveBeenCalledWith('xcrun', ['simctl', 'shutdown', 'SELECTED-UDID'], {
env: {},
logger: expect.any(Object),
signal: expect.any(AbortSignal),
});
expect(jest.mocked(spawn).mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(runAgentDeviceRemoteSessionAsync).mock.invocationCallOrder[0]
);
});
it('waits for the download when the Simulator boots first', async () => {
it('downloads build_id through the device run session worker API when the job has a session token', async () => {
const sessionCtx = {
graphqlClient,
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
} as unknown as CustomBuildContext;
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' }, undefined, sessionCtx);
expect(downloadBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({
buildId: 'build-id',
deviceRunSessionWorkerApi: expect.any(DeviceRunSessionWorkerApi),
})
);
});
it('does not use the worker API without a session token or for an archive URL', async () => {
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' });
expect(downloadBuildAsync).toHaveBeenLastCalledWith(
expect.objectContaining({ buildId: 'build-id', deviceRunSessionWorkerApi: null })
);
const sessionCtx = {
graphqlClient,
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
} as unknown as CustomBuildContext;
await runStep(
BuildRuntimePlatform.LINUX,
{ application_archive_url: 'https://example.test/app.tar.gz' },
undefined,
sessionCtx
);
expect(downloadBuildAsync).toHaveBeenLastCalledWith(
expect.objectContaining({
applicationArchiveUrl: 'https://example.test/app.tar.gz',
deviceRunSessionWorkerApi: null,
})
);
});
it('fails before download or host startup when guarded device shutdown fails', async () => {
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
});
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Booted' } as never);
jest.mocked(spawn).mockRejectedValue(new Error('shutdown failed'));
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'shutdown failed'
);
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
});
it('provides Simulator preparation and downloaded startup options to the remote session', async () => {
const hostReady = deferred();
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
});
jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async (_ctx, { device }) => {
expect('iosSimulatorUdid' in device).toBe(true);
if (!('iosSimulatorUdid' in device)) {
return;
}
await hostReady.promise;
deviceLaunch = await device.application;
});
const step = runStep(BuildRuntimePlatform.DARWIN, {
device_identifier: 'iPhone 17',
build_id: 'build-id',
launch_args: ['-flag'],
open_url: 'exp://example.test',
});
await flushAsync();
expect(IosSimulatorUtils.resolveUdidAsync).toHaveBeenCalledWith({
deviceIdentifier: 'iPhone 17',
env: {},
});
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).toHaveBeenCalledTimes(1);
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
artifactPath: '/tmp/App.app',
env: {},
});
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
expect(deviceLaunch).toBeUndefined();
hostReady.resolve();
await step;
expect(deviceLaunch).toEqual({
installAppPath: '/tmp/App.app',
launchAppIdentifier: 'dev.example.app',
launchArgs: ['-flag'],
openUrl: 'exp://example.test',
});
});
it('fails a guarded session before download or host startup if its boot files are missing', async () => {
jest
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
.mockRejectedValue(new Error('missing bin/egress-guard-check'));
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'missing bin/egress-guard-check'
);
expect(startLocalEgressGuardRelayAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
expect(spawn).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
});
it('releases guard reporting and egress resources when host startup fails', async () => {
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
});
jest
.mocked(runAgentDeviceRemoteSessionAsync)
.mockRejectedValueOnce(new Error('guard check failed'));
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'guard check failed'
);
expect(startLocalEgressGuardRelayAsync).toHaveBeenCalledTimes(1);
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
});
it('waits for the download before handing the app to the host', async () => {
const download = deferred<{ artifactPath: string }>();
jest.mocked(downloadBuildAsync).mockReturnValue(download.promise);
@@ -190,16 +396,21 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
download.resolve({ artifactPath: '/tmp/App.app' });
await step;
expect(installBuildAsync).toHaveBeenCalledTimes(1);
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
expect(installBuildAsync).not.toHaveBeenCalled();
});
it('makes the session ready when the device boots if there is no app', async () => {
it('passes the selected Simulator to the host even when there is no app', async () => {
await runStep(BuildRuntimePlatform.DARWIN);
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
await expect(sessionDevice().ready).resolves.toBeUndefined();
expect(sessionDevice()).toEqual({
iosSimulatorUdid: 'SELECTED-UDID',
bootEnv: undefined,
application: undefined,
});
});
it('boots the Android Emulator with the device inputs', async () => {
@@ -231,23 +442,10 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
});
it('fails the session when the download fails, without an unhandled rejection', async () => {
const boot = deferred<Awaited<ReturnType<typeof bootIosSimulatorAsync>>>();
jest.mocked(bootIosSimulatorAsync).mockReturnValue(boot.promise);
jest.mocked(downloadBuildAsync).mockRejectedValue(new Error('download failed'));
const stepFailure = expect(
runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })
).rejects.toThrow('download failed');
// The download fails while the boot still runs. Nothing inside the step may leave
// that rejection unhandled; Jest fails the test if it does.
await flushAsync();
boot.resolve({
deviceIdentifier: 'iPhone 17' as never,
udid: 'udid' as never,
displayName: '',
});
await stepFailure;
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'download failed'
);
expect(installBuildAsync).not.toHaveBeenCalled();
});
@@ -276,23 +474,24 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
jest
.mocked(runAgentDeviceRemoteSessionAsync)
.mockImplementation(async (_ctx, { tasks, device }) => {
const ready = runDevicePreparationAsync(device);
await beforeFailure?.();
const daemon = tasks.run('agent-device daemon', async () => {
throw new Error('daemon failed');
});
try {
await Promise.all([daemon, device.ready]);
await Promise.all([daemon, ready]);
} finally {
await Promise.allSettled([daemon, device.ready]);
await Promise.allSettled([daemon, ready]);
}
});
}
it('stops the download and waits for it when the boot fails', async () => {
it('stops the download and waits for it when Android boot fails', async () => {
const download = mockStalledDownload();
jest.mocked(bootIosSimulatorAsync).mockRejectedValue(new Error('boot failed'));
jest.mocked(startAndroidEmulatorAsync).mockRejectedValue(new Error('boot failed'));
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
await expect(runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' })).rejects.toThrow(
'boot failed'
);
@@ -316,25 +515,79 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
expect(launchApplicationAsync).not.toHaveBeenCalled();
});
it('does not launch the app when startup fails during the install', async () => {
const install = deferred<Awaited<ReturnType<typeof installBuildAsync>>>();
const installStarted = deferred();
jest.mocked(installBuildAsync).mockImplementation(() => {
installStarted.resolve();
return install.promise;
it('drains the download when host startup fails before app preparation', async () => {
const download = mockStalledDownload();
jest.mocked(runAgentDeviceRemoteSessionAsync).mockRejectedValue(new Error('host failed'));
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'host failed'
);
expect(download.aborted()).toBe(true);
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
});
it('does not start work for an already cancelled step', async () => {
const controller = new AbortController();
controller.abort(new Error('cancelled'));
await expect(
runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal)
).rejects.toThrow('cancelled');
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('cancels and drains a pending download through the external signal', async () => {
const controller = new AbortController();
const download = mockStalledDownload();
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal);
const failure = expect(step).rejects.toThrow('cancelled');
await flushAsync();
controller.abort(new Error('cancelled'));
await failure;
expect(download.aborted()).toBe(true);
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
});
it('does not start device or download work after cancellation during Xcode selection', async () => {
const controller = new AbortController();
const selected = deferred();
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockReturnValue(selected.promise);
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal);
const failure = expect(step).rejects.toThrow('cancelled');
await flushAsync();
controller.abort(new Error('cancelled'));
selected.resolve();
await failure;
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('drains app preparation when the daemon fails', async () => {
const metadata = deferred<string>();
const metadataStarted = deferred();
jest.mocked(readIosApplicationIdentifierAsync).mockImplementation(() => {
metadataStarted.resolve();
return metadata.promise;
});
mockSessionWithFailingDaemon(async () => {
await installStarted.promise;
// The install finishes only after the daemon failed.
setImmediate(() => install.resolve({ applicationIdentifier: 'dev.example.app' }));
await metadataStarted.promise;
setImmediate(() => metadata.resolve('dev.example.app'));
});
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'daemon failed'
);
expect(installBuildAsync).toHaveBeenCalledTimes(1);
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
expect(deviceLaunch).toBeUndefined();
});
it('rejects conflicting application inputs before it boots anything', async () => {
@@ -370,12 +623,90 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
'open_url',
'network_capture',
'network_capture_fields',
'public_preview',
'private_preview_url',
'frame_ancestors',
'cors_origins',
'package_version',
'max_idle_time_minutes',
'max_duration_seconds',
]);
});
it.each([undefined, true, false])(
'passes public_preview=%s to the iOS session host',
async publicPreview => {
await runStep(BuildRuntimePlatform.DARWIN, {
public_preview: publicPreview,
private_preview_url: 'https://expo.dev/private-preview',
});
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
disableShare: publicPreview === false,
privatePreviewUrl: 'https://expo.dev/private-preview',
})
);
}
);
it('passes frame_ancestors to the session host', async () => {
await runStep(BuildRuntimePlatform.DARWIN, {
frame_ancestors: ['https://embed.example.test'],
});
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ frameAncestors: ['https://embed.example.test'] })
);
});
it('passes cors_origins to the session host', async () => {
await runStep(BuildRuntimePlatform.DARWIN, {
cors_origins: ['https://app.example.test'],
});
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
);
});
it.each([
['argument count', { launch_args: Array(257).fill('a') }],
['argument length', { launch_args: ['a'.repeat(9000)] }],
['NUL argument', { launch_args: ['a\u0000b'] }],
['total UTF-8 bytes', { launch_args: Array(8).fill('😃'.repeat(4096)) }],
['NUL URL', { open_url: 'example:\u0000screen' }],
['URL length', { open_url: `example://${'a'.repeat(8192)}` }],
])('rejects iOS launch %s before startup work', async (_name, inputs) => {
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id', ...inputs });
await expect(step).rejects.toBeInstanceOf(UserError);
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it.each([
['NUL argument', { launch_args: ['a\u0000b'] }],
['command length', { launch_args: ['--es', 'quotes', "'".repeat(20_000)] }],
['NUL URL', { open_url: 'example:\u0000screen' }],
])('rejects Android launch %s before startup work', async (_name, inputs) => {
const step = runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', ...inputs });
await expect(step).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('keeps Android launch inputs on its existing direct path', async () => {
const launchArgs = ['a'.repeat(9000)];
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', launch_args: launchArgs });
expect(launchApplicationAsync).toHaveBeenCalledWith(
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, launchArgs })
);
});
it('passes network capture to the session', async () => {
await runStep(BuildRuntimePlatform.DARWIN, {
network_capture: true,
Loaded 100 of 522 files, more files were not shown because too many files have changed in this diff. Show more