Compare commits

...
Author SHA1 Message Date
Expo CI 1e112fee24 v27.10.0 2026-11-13 22:56:24 +00:00
Brent Vatne 6d3e79c255 [eas-cli] Add simulator:devices command (#4879)
* [eas-cli] Add simulator:devices command

* [eas-cli] Use offered devices in the simulator:start --device help

* [eas-cli] Show simulator:devices as a table of suggested devices
2026-11-13 15:19:37 -07:00
Ramon Claudio 04e909c4d9 [eas-cli][build-tools] Bump Node.js to 22.23.5 (#4889)
* [eas-cli] Bump Node.js to 22.23.5

* [eas-cli] Add changeset

* [build-tools] Bump Node.js to 22.23.5
2026-11-13 17:16:06 -05:00
David Mokos 1a6e94123a [eas-cli] Report the parent CLI command in telemetry (#4876) 2026-11-13 21:01:05 +01:00
Szymon Dziedzic 544cd18bac [build-tools] Retry the first ngrok tunnel open in device run sessions (#4874)
* [build-tools] Retry the first ngrok tunnel open in device run sessions

* [build-tools] Retry the first ngrok open only after the failed open settles

@ngrok/ngrok runs every forward behind one process-wide lock, so a retry
while a timed-out open is still pending only waits behind it. Keep the rule
that no open starts while another is pending: after a time-out, wait a
bounded time for the open to settle, then retry, or fail at once.

Retry only time-outs and ERR_NGROK_802, and let callers cancel startup
retries with a signal.

* [build-tools] Fail ngrok startup with the abort reason and keep the endpoint free

An abort during the wait for a failed open to settle threw the time-out
error, so callers that compare against the startup signal's reason reported
a failed session instead of a stop. Throw the abort reason. Do not retry
while a late listener from the failed open is still open. Add the startup
abort listener only after the local target listens, so it is always removed.

* [build-tools] Log why the first ngrok open is not retried

Say whether the failed open is still pending or its late listener did not
close. Test that an abort while the local target starts prevents the open.

* [build-tools] Keep a late first ngrok open and retry only rejected opens

The first open now waits 19 s instead of retrying a timed-out open. ngrok runs one
open at a time, so a retry after a time-out could only start after the stuck open
settled, and it closed a listener that had arrived late. Only a rejected open with
an exact retryable ngrok error code (ERR_NGROK_802) is retried.

The preview tunnel in the session host now stops retrying when the host finishes.
An open in progress continues, so preview retirement still waits for it.

* [build-tools] Test a non-retryable first-open failure after 15 seconds

* [build-tools] Give ngrok open retries the reopen time-out and log late opens

Retries use the 15 s reopen time-out, so all three attempts fit in the 60 s
preview opening bound. A timed-out or cancelled open logs its outcome and
elapsed time when it settles, so the first-open time-out can be tuned.

* [build-tools] Document why ngrok open retries use a new agent session
2026-11-13 11:52:23 -07:00
krystofwoldrich-agentandClaude Opus 5.5 8dc514e9f2 [build-tools] Preload the experimental emulator capture into the Android Emulator (#4827)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-13 18:35:40 +01:00
Tomasz Mazur f4b4f035b0 [build-tools] Run the client's command in worker SSH sessions (#4863) 2026-11-12 13:47:29 +01:00
Tomasz Mazur 92397fd3d4 [build-tools] Always download upterm for worker SSH sessions (#4859) 2026-11-12 13:47:29 +01:00
Tomasz Mazur 154d90d3ce [build-tools] Run worker SSH sessions as detached upterm sessions (#4857)
* [build-tools] Run worker SSH sessions as detached upterm sessions

* [build-tools] Redact the upterm session id by its JSON field and known value

* [build-tools] Remove the known session id redaction

* [build-tools] Remove the URL userinfo redaction fallback

* [build-tools] Test that a failed upterm start stops the session

* [build-tools] Keep using an upterm found on PATH for SSH sessions

* [build-tools] Report the SSH reconnecting state from the supervisor

* [build-tools] Count SSH guests with upterm's guestCount
2026-11-12 13:47:28 +01:00
Expo CI 5a0908533b bump STAGING and PRODUCTION to 27.9.0 in cli-versions.json 2026-11-12 12:31:34 +00:00
Expo CI 14e9f1af44 v27.9.0 2026-11-12 12:16:18 +00:00
Szymon Dziedzic 291ec6f2c8 [eas-cli] Warn about EAS Simulator outages in simulator:start (#4873)
* [eas-cli] warn about EAS Simulator outages in simulator:start

* [eas-cli] name the simulator status page service Expo Simulators
2026-11-12 13:13:42 +01:00
Expo CI a4edee8178 bump STAGING and PRODUCTION to 27.8.0 in cli-versions.json 2026-11-12 11:11:46 +00:00
Expo CI 6fa3f47f91 v27.8.0 2026-11-12 11:04:19 +00:00
Szymon DziedzicandClaude Opus 5.5 71338cbaa2 [eas-cli] Remove Android in-development warning from eas simulator (#4870)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-12 11:48:16 +01:00
Szymon DziedzicandClaude Opus 5.5 36f83dee90 [eas-cli] Drop the experimental label from simulator commands except local egress (#4869)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-12 11:48:01 +01:00
Szymon Dziedzic 352c3b68f0 [eas-cli] Point gated simulator accounts at paid plans instead of the waitlist (#4866)
* [eas-cli] Point gated simulator accounts at paid plans instead of the waitlist

* [eas-cli] Tell annual Enterprise accounts to contact their account executive

The server does not include annual Enterprise prices at simulator GA. The CLI cannot see the plan, so the gated message covers this case too.
2026-11-12 11:47:36 +01:00
Gabe DebesandCodex 5dafefd484 [eas-cli] Upload local Simulator app bundles (#4684)
* [eas-cli] Upload local Simulator app bundles

* [eas-cli] Reject unsupported Simulator install archives before upload

* [eas-cli] Keep upload folder guidance specific to the action

* [eas-cli] Clean interrupted app archives and serialize packaging

Start progress before packaging, require archive support for local archives, and keep unknown capability metadata from rejecting the action catalog. Add child-process interruption and packaging coverage.

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

---------

Co-authored-by: Codex <[email protected]>
2026-11-11 21:02:10 -07:00
Vojtech NovakandClaude Fable 5.2 b7ff9b10d8 [eas-build-job][build-tools][eas-cli] Share one formatBytes helper and fix sizes of 102.4 GB and larger (#4854)
* [build-tools] Fix formatBytes for sizes of 102.4 GB and larger

The last branch divided by 1024 instead of by the GB multiplier, so
200 GB was formatted as "209715200 GB". Add table tests for the helper.

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

* [eas-build-job][build-tools][eas-cli] Share one formatBytes helper

build-tools and eas-cli each had the same formatBytes copy. Move it to
@expo/eas-build-job, migrate all callers, and delete both copies.
eas-cli now also gets the fix for sizes of 102.4 GB and larger.

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

* [build-tools] Import formatBytes from eas-build-job in androidEmulatorLogs

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

* Merge the two formatBytes changesets

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

---------

Co-authored-by: Claude Fable 5.2 <[email protected]>
2026-11-11 20:41:11 -07:00
SJ Kim 55d1e2942d [build-tools] Point Android device sessions at the scrcpy server copy (#4848) 2026-11-11 05:34:51 -05:00
Ash Wu a7f9742a03 [build-tools] report maestro-runner flow tags from report.json (#4853) 2026-11-11 16:10:16 +08:00
Hirbod f615fc5ba3 [build-tools] Send session preview size metadata (#4864)
* [build-tools] Send session preview size metadata

* [build-tools] Store a new preview size only after its image uploads
2026-11-10 23:45:44 +00:00
Hirbod 00ef775b2b [build-tools] Send screenshot size metadata (#4858)
* [build-tools] Send screenshot size metadata

* [build-tools] Keep only the screenshot metadata fields that are set

* [build-tools] Send screenshot size metadata for Argent screenshots

* [build-tools] Send screenshot size metadata for Argent screenshot diffs
2026-11-10 22:32:39 +00:00
Gabe Debes e914819052 [build-tools] Fix iOS session thumbnails on multi-display Simulators (#4777)
* [build-tools] Capture iOS session thumbnails through serve-sim

Edited-by: Codex

* [build-tools] Mark thumbnail captures as automation

Edited-by: Codex

* [build-tools] Keep the default screenshot Desktop copy

* [build-tools] Clarify session preview errors

* [build-tools] Capture session previews without probing the catalog

Edited-by: Codex

* [build-tools] Check automated screenshot support before previews
2026-11-10 10:52:09 -07:00
Expo CI ab32f0c241 bump STAGING and PRODUCTION to 27.7.2 in cli-versions.json 2026-11-10 17:07:58 +00:00
Expo CI 951ccba62c v27.7.2 2026-11-10 17:00:49 +00:00
Tomasz Mazur 8c0e01d64c [eas-cli] Show the ID argument and examples in workflow:ssh help (#4829)
* [eas-cli] Fix workflow SSH help and error text

* [eas-cli] Revert worker wording in workflow:run SSH text

* [eas-cli] Revert workflow:ssh error text
2026-11-10 17:36:59 +01:00
Vojtech NovakandClaude Fable 5.1 539acb8c23 [eas-cli] Add --log-source to eas simulator (#4823)
* [eas-cli] Add --android-log-source to eas simulator

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

* [eas-cli] Drop the retention period from the --android-log-source help

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

* [eas-cli] Shorten the --android-log-source changeset

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

* [eas-cli] Rename --android-log-source to --log-source

The flag is unprefixed like the step input it feeds. Passing it without
--platform android fails at runtime.

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

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-11-10 17:18:53 +01:00
Expo CI 18d85d571a bump STAGING and PRODUCTION to 27.7.1 in cli-versions.json 2026-11-10 13:36:49 +00:00
Tomasz Mazur f2cf409b21 [build-tools] Start SSH session shells in the build directory (#4843) 2026-11-10 13:40:14 +01:00
Tomasz Mazur 0cf8cd014f [eas-cli] Show the ended message when workflow:ssh is given a finished build ID (#4842) 2026-11-10 13:38:46 +01:00
Expo CI 8df11a04ee v27.7.1 2026-11-10 11:28:19 +00:00
Tomek Czajecki c9d198a894 [eas-cli] update dependencies with security advisories (#4847)
* [eas-cli] update dependencies with security advisories

* [eas-cli] remove unrelated lockfile dedupe
2026-11-10 13:03:43 +02:00
Szymon Świerk 4b70eb14fb [build-tools] Strip the repository access token from the git remote before the sandbox agent starts (#4826)
* [build-tools] Strip the repository access token from the git remote before the sandbox agent starts

* [build-tools] improve the comment explaining why the repository token is stripped before the sandbox starts
2026-11-10 11:25:41 +01:00
Expo CI c224ba58ff bump STAGING and PRODUCTION to 27.7.0 in cli-versions.json 2026-11-10 08:32:43 +00:00
Expo CI 8d5a2e3332 v27.7.0 2026-11-10 08:25:21 +00:00
Vojtech NovakandClaude Fable 5.1 b95b7c930a [build-tools] Record and upload selected Android emulator logs (#4822)
* [build-tools] Record and upload selected Android emulator logs

Select Android log sources at session startup, attach the bounded capture
to every emulator launch, and upload the finalized logs as simulator
session artifacts with truncation metadata.

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

* [build-tools] Upload one Android log artifact per source

Pass the source selection through the capture instead of a second
parameter, let the capture create the launch files, and name artifacts
after the source ring. Finalize the other-workflow capture in the
collection test so no handle closes at garbage collection.

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

* [build-tools] Shorten the Android log changeset

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

* [build-tools] Choose the Android log writer by session

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

* [build-tools] Rename android_log_sources to log_sources

The input is unprefixed like every other step input. A non-empty list on an
iOS session fails at runtime, the way network_capture does on Android. The
agent-device step drops its default so an omitted input stays distinguishable
from an explicit selection; the fallback is resolved in code.

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

* [build-tools] Format the retained log size with formatBytes

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

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-11-10 09:01:58 +01:00
Vojtech NovakandClaude Fable 5.1 8ed5c16c7b [build-tools] Add bounded Android emulator log capture (#4821)
* [build-tools] Add bounded Android emulator log capture

Capture emulator output and logcat into fixed-size ring files that keep
the last 20 MiB per source across launches, with logcat delivered through
a named pipe. No caller yet; the emulator wiring follows.

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

* [build-tools] Keep one ring file per Android log source

Sessions write every launch of a source into one ring, `<source>.log`,
with a marker line per launch that produced output. This replaces the
per-launch rings and their cross-file eviction, and bounds disk use at the
budget. Ordinary builds keep plain per-launch files closed per launch. The
capture now owns the source selection.

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

* [build-tools] Split the plain Android log writer from the ring

`AndroidEmulatorLogCapture` is now the interface both writers implement.
`AndroidEmulatorLogRing` serves sessions and has no mode checks;
`AndroidEmulatorLogFiles` serves ordinary builds with one appended file
per launch. Document what the logcat output's `closed` promise is for.

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

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-11-10 09:01:57 +01:00
Vojtech NovakandClaude Fable 5.1 fa5dee437f [build-tools] Share device run session file uploads (#4820)
Move the stat, size-zero skip and per-attempt stream reopening into one
helper, and use it for serve-sim app logs.

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-11-10 09:01:57 +01:00
Ash Wu 65ef172a78 [eas-cli] add --git-branch and --git-commit-hash to workflow:run (#4815)
`eas workflow:run <file>` uploads the local project, so the run carries
no branch or commit and the EAS dashboard cannot list it under a branch.
The two flags record the branch and commit the uploaded
project comes from. They must be given together and cannot be combined
with `--ref`. The server verifies the commit in the project's linked
GitHub repository and reads the commit message from GitHub.

Generated GraphQL types come from production plus the pending www
schema change, so the diff also carries unrelated schema drift.
2026-11-10 15:11:23 +08:00
Brent Vatne b15ea359b6 [eas-cli] Send each spinner frame as one write to stop flicker (#4850) 2026-11-09 22:16:21 -07:00
Brent Vatne ab86b647cf [ci] Compare the changeset check with the base branch, not a stale base SHA (#4851) 2026-11-09 20:06:47 -07:00
Expo CI f6669461cd bump STAGING and PRODUCTION to 27.6.0 in cli-versions.json 2026-11-10 01:22:04 +00:00
Expo CI 5f560f8fc2 v27.6.0 2026-11-10 01:17:13 +00:00
Gabe Debes a3e4617d1c [simulator-launcher] Add Simulator and native tool warmup (#4838)
* [simulator-launcher] Add Simulator and native tool warmup

Edited-by: Codex

* [simulator-launcher] Simplify Expo Go preparation call
2026-11-09 17:44:56 -07:00
Gabe Debes 2e45595250 [build-tools] Reuse prefetched Simulator tools (#4837)
* [build-tools] Reuse prepared Simulator tools

Edited-by: Codex

* [build-tools] Preserve cold Appium setup and validate cached WDA

* [build-tools] Retry cancelled Appium version lookups after adoption fails

* [build-tools] Clarify prepared Appium version matching
2026-11-09 17:44:56 -07:00
Gabe Debes 0c768729a9 [simulator-launcher] Report caller-supplied tool versions (#4845)
* [simulator-launcher] Report caller-supplied tool versions

Edited-by: Codex

* [simulator-launcher] Use semver for version validation

Edited-by: Codex
2026-11-09 17:44:56 -07:00
Gabe Debes f37ad5e137 [simulator-launcher] Prefetch Appium and WebDriverAgent files (#4836)
* [simulator-launcher] Prefetch Appium and WebDriverAgent files

Edited-by: Codex

* [simulator-launcher] Validate the downloaded WDA bundle
2026-11-09 17:44:55 -07:00
Gabe Debes 4d3cba95af [simulator-launcher] Prefetch the selected Expo Go archive (#4834)
* [simulator-launcher] Prefetch the selected Expo Go archive

Edited-by: Codex

* [simulator-launcher] Simplify the changeset

Edited-by: Codex
2026-11-09 17:44:55 -07:00
Gabe Debes d57eadf887 [vm-tools] Use interactive scheduling for Simulator sessions (#4833) 2026-11-09 12:55:32 -07:00
Jakub Tkacz ccba9a0c8d [eas-cli] Add --property filter to observe:events (#4812)
* [eas-cli] Add --property filter to observe:events

Adds a repeatable --property key=value flag that filters events by an exact property match.

Closes ENG-28851

* [eas-cli] Test that observe:events --property forwards an empty value

* [eas-cli] Address review: reject empty, repeated, and more than 5 --property filters
2026-11-09 09:00:31 +01:00
Jakub Tkacz 3fc8dadd63 [eas-cli] Stop suggesting event names when observe:events --session-id finds nothing (#4813)
* [eas-cli] Stop suggesting event names when observe:events --session-id finds nothing

With an event name and --session-id, an empty result does not mean the event name is wrong, so show the plain empty result instead of other event names.

* [eas-cli] Address review: scope comment to --session-id, add --json test

* [eas-cli] Address review: skip suggestions for --app-version, --build-number and --update-id
2026-11-09 09:00:30 +01:00
Tomasz Mazur 50dcb89a0c [build-tools] Require the sandbox worker token for sandbox jobs (#4808) 2026-11-06 15:25:32 +01:00
Ash Wu 84b3bc6354 [build-tools] make iOS Simulator readiness work on the iOS 27.2 runtime (#4816)
On iOS 27.2 `com.apple.datamigrator` stays resident as an idle launchd_sim
service after it reports that no migration is needed, so waiting for it to
exit never ends. The wait is now skipped on iOS 27.2 and later and kept on
earlier runtimes, where the process exits within about a minute of boot and
the wait still keeps the data-migration screen from appearing mid-test
(eas-build #579). The version is the one `simctl list runtimes` reports for
the device's runtime, compared with semver. `simctl list devices` lists UDIDs
in upper case, so a UDID given in lower case is upper-cased first.

The iPhone Duo on iOS 27.2 exposes two integrated screens, and
`simctl io screenshot` without `--display` exits 22, so the readiness
screenshot names the first integrated screen from `simctl io enumerate` when
more than one is listed. Single-screen devices keep the plain command: the
simctl in Xcode 15.3 rejects a numeric `--display`.
2026-11-06 14:46:08 +08:00
Expo CI 2749518a31 bump STAGING and PRODUCTION to 27.5.0 in cli-versions.json 2026-11-05 23:08:41 +00:00
Expo CI 7819779519 v27.5.0 2026-11-05 23:03:34 +00:00
Gabe Debes 1eee88fc8f [eas-cli] Import simulator startup configuration (#4687) 2026-11-05 15:33:26 -07:00
Gabe Debes ab50bdf55a [eas-cli] Import simulator control actions from a file (#4686) 2026-11-05 15:33:25 -07:00
Gabe DebesandCodex ec9896ae23 [eas-cli] Share simulator controls and keep connections alive (#4791)
Co-authored-by: Codex <[email protected]>
2026-11-05 15:33:25 -07:00
Byron KarlenandClaude Opus 5.5 21a16849c1 [eas-cli] Decouple username from account name (#3922)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-05 13:57:23 -08:00
Tomasz Mazur 8501f3e7d8 [build-tools][worker] Use the sandbox worker API when www provides a sandbox worker token (#4807) 2026-11-05 15:44:27 +01:00
Szymon DziedzicandClaude eedff0c99d [eas-cli] Add simulators, emulator and emulators aliases for simulator commands (#4794)
Claude-Session: https://claude.ai/code/session_018fQcYvCuJxvWc1i889MW2r

Co-authored-by: Claude <[email protected]>
2026-11-05 14:05:30 +01:00
Tomasz Mazur c7e9fe56de [build-tools] Extract a shared worker API client from the device run session client (#4806) 2026-11-05 13:59:40 +01:00
Tomasz Mazur b8505a90f1 [eas-cli] Unhide the workflow:ssh command (#4793) 2026-11-05 12:28:52 +01:00
William Grosset dd3808f345 [eas-cli] Move Simulators hint date to November 12 (#4817) 2026-11-04 14:39:50 -07:00
Gabe Debes 85c77cc85b [build-tools] Stop web-preview startup when the session is stopped (#4669)
* [build-tools] Observe web preview Stop requests during startup

* test(build-tools): Cover parent cancellation before web preview starts
2026-11-04 09:59:15 -07:00
Gabe Debes b38126abb3 [build-tools] Stop Appium startup when the session is stopped (#4668)
* [build-tools] Observe Appium Stop requests during startup

* test(build-tools): Cover Appium parent cancellation and cleanup errors
2026-11-04 09:59:14 -07:00
Gabe Debes c68978acd8 [build-tools] Stop Argent startup when the session is stopped (#4667)
* [build-tools] Observe Argent Stop requests during startup

* test(build-tools): Cover Argent parent cancellation and cleanup errors
2026-11-04 09:59:14 -07:00
Gabe Debes 2dda59d57c [build-tools] Observe agent-device Stop requests during startup (#4666) 2026-11-04 09:59:13 -07:00
Expo CI 156e38eb54 bump STAGING and PRODUCTION to 27.4.1 in cli-versions.json 2026-11-04 00:28:01 +00:00
Expo CI 3263ecfa36 v27.4.1 2026-11-04 00:23:17 +00:00
Gabe Debes 1da5a21f42 [simulator-launcher] Wait for startup before claiming a session (#4810)
* [simulator-launcher] Wait for startup before claiming a session

* [simulator-launcher] Report startup wait progress in job logs

* [build-tools] Log warm Simulator claim waits directly
2026-11-03 16:55:02 -07:00
Expo CI 64b9b14d09 bump STAGING and PRODUCTION to 27.4.0 in cli-versions.json 2026-11-03 17:44:51 +00:00
Expo CI 5cca4faff5 v27.4.0 2026-11-03 17:37:28 +00:00
Kadi Kraman 80fb6df91c [build-tools] report app and build version from final archive (#3499)
* Add function to report resolved version

* Add tests for the parser functions

* Format

* Use fast-xml-parser

* Read archive path from previous step

* Also report app versions in builder files

* Format

* Add tests for the extract functions

* Address review: report the version inside the upload phase, omit missing versions, add changeset

* Fix builder test mock and step order after merging main

* Skip version reporting when there is no build ID, and test the step

* Rename the version metadata helper to use the update verb

* [build-tools] Set the upload step ID in eas/build

* [build-tools] Resolve archive path, keep apostrophes, log to Sentry

* [build-tools] Stop versionName at the split field, treat empty as missing

* [build-tools] Rename the resolved version step to eas/update_resolved_version
2026-11-03 11:07:38 +00:00
Expo CI 16ed2a92f7 bump STAGING and PRODUCTION to 27.3.0 in cli-versions.json 2026-11-03 02:59:05 +00:00
Gabe Debes 68fd6027b7 [build-tools] Recover warm recordings with a late manifest (#4789) 2026-11-02 15:03:22 -07:00
f15fe03a71 [build-tools][simulator-launcher] Add Appium sessions on a warm Simulator (#4735)
* [build-tools][simulator-launcher] Add Appium sessions on a warm Simulator

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

Edited-by: Codex

Edited-by: Codex

* [build-tools] Await Appium cleanup before handling its error

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

* [build-tools] Drain Appium before stopping the warm host

---------

Co-authored-by: Codex <[email protected]>
2026-11-02 15:03:22 -07:00
Gabe DebesandClaude Opus 5.5 5907c64556 [build-tools] Upload the last agent-device artifacts before the daemon stops (#4750)
* [build-tools] Drain agent-device artifacts before the daemon stops

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

* [build-tools] Drain warm agent-device artifacts before host shutdown

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-02 15:01:20 -07:00
William Grosset ddbde07029 [eas-cli] Move Simulators hint date to November 10 (#4801) 2026-11-02 14:24:19 -07:00
Gabe DebesandClaude Opus 5.5 5e59c64231 [build-tools] Add warm iOS Simulator session steps (#4723)
* [build-tools] Add warm iOS Simulator session steps

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

Edited-by: Codex

Edited-by: Codex

* [build-tools] Stop claimed warm sessions after callback failures
2026-11-02 13:12:35 -07:00
Gabe DebesandClaude Opus 5.5 0efaffd982 [build-tools] Claim the warm Simulator and adopt its serve-sim (#4724)
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Edited-by: Codex

Edited-by: Codex
2026-11-02 13:12:34 -07:00
Gabe Debes 2dbaa12750 [build-tools] Move iOS Simulator startup into its own file (#4752)
* [build-tools] Move iOS Simulator startup into its own file

Edited-by: Codex

* [build-tools] Match cold startup tests to the VM boot

* [build-tools] Keep preview selection fixtures after cold startup extraction

Edited-by: Codex

* [build-tools] Keep setup draining during preview finalization

Keep expected late preview retirement from aborting setup that finalization is draining. Adapt host retirement fixtures to the tunnel handle contract while retaining separate SDK retry coverage.

Edited-by: Codex
2026-11-02 12:51:24 -07:00
Expo CI bc25602d65 v27.3.0 2026-11-02 19:28:08 +00:00
Mustafa Abban 261836aaf6 [eas-build-job] Add Observe workflow event context (#4787) 2026-11-02 13:57:12 -05:00
55e746a0e7 [worker][build-tools] Stop warmup in the background for jobs that do not use a warm Simulator (#4721)
* [worker] Manage warm Simulator services

Edited-by: Codex

* [worker] Use the shared warm Simulator terms in the changeset

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

* [worker] Stop the recorded Simulator launcher version

* [worker] Skip Simulator stop on images without a startup script

* [worker] Stop current-boot warmup when the script is absent

* [worker] Skip launcher resolution after confirmed cleanup

* [worker] Recognize Bun cache misses alongside warning output

* [worker] Honor the package manager for versioned launcher stop

* [worker] Ignore invalid launcher state when no startup script exists

* [worker] Use existing package-manager helpers directly

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

* [worker] Keep the warmup stop changeset scoped to worker

Edited-by: Codex

* [worker] Let the launcher handle Simulator stop state

Edited-by: Codex

* [worker] Stop warm Simulator services in the background

Edited-by: Codex

* [worker] Fix warm stop job fixtures

Edited-by: Codex

* [worker] Let build-tools wait on the launcher state for warm Simulator cleanup

The worker stops warmup in the background and shares nothing with build-tools. Before it boots,
starts or clones a Simulator, build-tools waits until the launcher's state file says disabled.

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

* [build-tools] Require cleanup confirmation from the current boot

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Codex <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-11-02 10:56:53 -07:00
Gabe DebesandGPT-6.1-Sol 821e7f85f5 [build-tools] Recover failed device-session tunnels (#4526)
* [build-tools] Supervise device-session tunnels

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

* [build-tools] Retire tunnel listeners while reopening

* [build-tools] Wait for replacement tunnel retirement

* [build-tools] Block tunnel forwarding when shutdown starts

* [build-tools] Shorten tunnel recovery changeset

---------

Co-authored-by: GPT-6.1-Sol <[email protected]>
2026-10-30 15:47:16 -07:00
Douglas LowderandClaude Opus 5.5 6f0f90bd99 [eas-cli] Bump @expo/apple-utils to 2.3.0 (#4790)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-30 05:51:23 -07:00
Tomasz Mazur 6c545d3460 [build-tools] Stop sending the deprecated SSH host to the API (#4784) 2026-10-30 12:26:09 +01:00
Ash Wu 243c8b8023 [worker] mask secrets in the records written to worker stdout (#4782) 2026-10-30 16:53:29 +08:00
Ceyda AkınandDouglas Lowder fe6c35ef73 [eas-cli] Keep linked capability identifiers when syncing another one (#4584)
The capability update replaces the identifiers linked to a bundle ID, but only the newly linked ones were sent. Adding a second App Group, Merchant ID or iCloud container unlinked the ones that were already linked.

Fixes #4445

Co-authored-by: Douglas Lowder <[email protected]>
2026-10-29 20:16:03 -07:00
Expo CI a29b1d1d70 bump STAGING and PRODUCTION to 27.2.0 in cli-versions.json 2026-10-30 00:41:43 +00:00
Expo CI 92e1b01bd7 v27.2.0 2026-10-29 23:51:05 +00:00
9adcdfab1c [simulator-launcher] Preinstall Expo Go and warm additional Simulators (#4764)
* [simulator-launcher] Preinstall Expo Go and warm additional Simulators

* [simulator-launcher] Describe the shared serve-sim port in the changesets

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

* [simulator-launcher] Keep the prewarming layer to the launcher

Move the warm Simulator cleanup wait and the Expo Go input out of this layer.

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

* [simulator-launcher] Set up every warm device with one function

startSimulatorSetup disables apsd, then preinstalls Expo Go, once per device. The default device,
the extra devices and the claimed device all use it; a cancelled setup can run again.

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

* [simulator-launcher] Set up all warm devices from one list

startSimulatorsSetup takes the device list: the first device starts when serve-sim is ready, the
others after it and the tools. This removes the separate background warmup and its readiness check.
A setup that a stop or claim aborts is no longer reported as a background failure.

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

* [simulator-launcher] Wait for the default device once before the other devices

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

* [simulator-launcher] Start device setup without waiting for it

Ready no longer waits for the apsd step. The claim still joins it before it returns, so the job
launches apps only after it. Expo Go failures are logged; stop still reports an unconfirmed install.

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

* [simulator-launcher] Set up all warm devices with one call

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

* [simulator-launcher] Run apsd and the Expo Go preinstall in parallel after boot

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

* [simulator-launcher] Finish the default device apsd step before the claim returns

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

* [simulator-launcher] Say which claim waits for the apsd step

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

* [simulator-launcher] Keep warmup running after a claim

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

* [simulator-launcher] Return the claim without waiting for warmup

The claim no longer waits for apsd or Expo Go installs. prepare-tools
waits for an install still running on the claimed device and reports
its version. The default device reuses serve-sim's boot check, and
EXPO_SIMULATOR_PREINSTALL_EXPO_GO=0 turns off the Expo Go preinstall.

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

* [simulator-launcher] Describe installs that adoption can stop

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

* [simulator-launcher] Remove non-null assertions

Edited-by: Codex

* [simulator-launcher] Return the validated Argent service

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

---------

Co-authored-by: Codex <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-29 16:27:00 -07:00
444c926bb8 [simulator-launcher] Add the start command for warm Simulators (#4722)
* [simulator-launcher] Prepare Simulator services at guest startup

Edited-by: Codex

* [simulator-launcher] Claim warm sessions once serve-sim is ready

Report ready after boot and a ready serve-sim. A claim waits only for its
requested tool and stops the others, even mid-startup. Replacement boots
the new device while the old one is released.

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

* [simulator-launcher] Disable apsd during warmup

Disable apsd after each warm device boots, before it is reported ready,
so a job does not do it while it installs and launches its app.

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

* [simulator-launcher] Stop installing ffmpeg during warmup

The VM image provides ffmpeg. Warmup only checks for it and warns when it
is missing, instead of running Homebrew.

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

* [simulator-launcher] Start replacement services without waiting for shutdown

Start serve-sim as its own process group. In replacement, stop the
unclaimed serve-sim by signalling that group, so the children it respawns
stop too and no stale PID is signalled. Start the new serve-sim once the
group is gone and its port is free, and shut the old device down in the
background.

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

* [simulator-launcher] Report the preinstalled Expo Go in the claim record

Read the bundle identifier and versions from the Expo Go app the launcher
installed, and include them in the claim record for the same device.

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

* [simulator-launcher] Handle a failed serve-sim exit while waiting for readiness

Observe both outcomes of the serve-sim process. A rejected exit no longer
becomes an unhandled rejection that ends the launcher.

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

* [simulator-launcher] Read serve-sim readiness from its configured state

Use SERVE_SIM_STATE_DIR when it is set, as serve-sim does. Drop the
bearer header from /readyz, which serve-sim does not gate.

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

* [simulator-launcher] Settle every old stop before failing a replacement

A fast tool-stop failure no longer rejects the release while the old
serve-sim is still stopping, so cleanup cannot race that stop.

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

* [simulator-launcher] Treat an already shut down Simulator as cleaned up

A retried shutdown of a device that is already shut down failed forever.
Check the device state when simctl shutdown fails.

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

* [simulator-launcher] Simplify warm Simulator cleanup

Stop services and the agent-device daemon once, then shut down each owned
device. Remove tool abort listeners when startup settles, and drop the
port wait: the old serve-sim's listener is in the group that was stopped.

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

* [simulator-launcher] Update the README for warm claim and cleanup

Describe per-engine claims, background replacement shutdown, apsd and
ffmpeg handling, and the preinstalled Expo Go record.

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

* [simulator-launcher] Simplify apsd disabling and the claim result

Route the apsd launchctl calls through one helper and build the claim
result in one expression. No behavior change.

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

* [simulator-launcher] Start Simulator tools after serve-sim is ready

Argent and agent-device spawn into the Simulator while serve-sim arms
its own launchctl settings; in round 2 that overlap coincided with simctl
stalls. Start them once serve-sim is ready; readiness does not move.

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

* [simulator-launcher] Treat exited processes awaiting reap as stopped

macOS answers kill with EPERM for a process group whose only members
have exited and await reaping. Check for a live member instead of
failing, in both the replacement group stop and the terminal tree stop.
A live member that cannot be signalled still fails cleanup.

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

* [simulator-launcher] Probe readiness only at the local serve-sim address

Reject serve-sim state whose url is not http://127.0.0.1:3200 instead of
fetching it, the same rule the job applies to the warm session.

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

* [simulator-launcher] Split the warm session into simulator and tool modules

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

* [simulator-launcher] Use the shared warm Simulator terms in the changeset

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

* [simulator-launcher] Use the shared warm Simulator terms in the README

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

* [simulator-launcher] Name the warm session handle type

* [simulator-launcher] Use the package version instead of a copied bundle

* [simulator-launcher] Accept a list of warm Simulator devices

* [simulator-launcher] Warm more devices after the default is ready

* [simulator-launcher] Claim either warm device and release the others

* [simulator-launcher] Prime the launcher cache before ready

* [simulator-launcher] Keep unused-device cleanup from stopping the job

* [simulator-launcher] Keep default warmup after an additional device fails

* [simulator-launcher] Keep the default device for blank configuration

* [simulator-launcher] Bound cache priming and retry once

* [simulator-launcher] Shut owned devices down in parallel at terminal stop

* [simulator-launcher] Finish default warmup before sharing Expo Go with another device

* [simulator-launcher] Start warm serve-sim with explicit orphan ownership

* [simulator-launcher] Start the selected Simulator before returning its claim

* [simulator-launcher] Keep Expo Go failures scoped to their device

* [simulator-launcher] Keep one selected device in session state

Explain claim, stop and replacement ordering.

* [simulator-launcher] Make session startup and cleanup explicit

* [stack] Retain the launcher foundation release entry

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

* [simulator-launcher] Add braces to the single-device selection guard

Edited-by: Codex

* [simulator-launcher] Merge the launcher changesets into one

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

* [simulator-launcher] Shorten the launcher changeset

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

* [simulator-launcher] Keep the claimed Simulator when an unused tool stop fails

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

* [simulator-launcher] Prove the stop retries an unused tool

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Codex <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-29 16:26:59 -07:00
3e73b077ce [simulator-launcher] Add simulator state and service helpers (#4766)
* [vm-tools] Run guest startup scripts

Edited-by: Codex

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

* [vm-tools] Use the shared warm Simulator terms in the changeset

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

* [vm-tools] Keep the startup process handle explicit

* [vm-tools] Describe the VM startup script loader

* [vm-tools] Refresh checks against the current base

No source changes. Refresh the pull-request event after its old base SHA
was compared with a newer GitHub merge checkout.

* [ci] Check the pull-request source for changesets

Do not include main-only changelogs from GitHub merge checkouts when the
stack reports an older base SHA. Keep all existing validation rules.

* [ci] Compare changes with the current base branch

* [vm-tools] Read startup scripts from the local shared directory

Restore the unchanged changelog workflow as requested.

* [simulator-launcher] Add Simulator state and service helpers

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

* [simulator-launcher] Keep the package public and versioned

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

* [simulator-launcher] Verify readiness and drain intentional tool stops

Edited-by: Codex

* [simulator-launcher] Consolidate package changeset

Edited-by: Codex

* [simulator-launcher] Name process options and remove unused boot helper

Edited-by: Codex

* [simulator-launcher] Keep the launcher private until it has a CLI

Start at the fixed-group version. A release cannot publish this layer alone; #4722 makes the
package public together with its entry point.

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

* [simulator-launcher] Check the claimed device and bound stop commands

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

* [simulator-launcher] Attach the claim rejection checks before the claim settles

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Codex <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-29 16:26:59 -07:00
3350e5c6f3 [vm-tools] Add a VM startup script loader (#4725)
* [vm-tools] Run guest startup scripts

Edited-by: Codex

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

* [vm-tools] Use the shared warm Simulator terms in the changeset

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

* [vm-tools] Keep the startup process handle explicit

* [vm-tools] Describe the VM startup script loader

* [vm-tools] Refresh checks against the current base

No source changes. Refresh the pull-request event after its old base SHA
was compared with a newer GitHub merge checkout.

* [ci] Check the pull-request source for changesets

Do not include main-only changelogs from GitHub merge checkouts when the
stack reports an older base SHA. Keep all existing validation rules.

* [ci] Compare changes with the current base branch

* [vm-tools] Read startup scripts from the local shared directory

Restore the unchanged changelog workflow as requested.

* [vm-tools] Start at the fixed-group version

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-29 16:26:58 -07:00
Juwan Wheatley 3ffbbb2f45 [eas-cli] show a one-time Expo Simulators hint after a successful build (#4768)
* [eas-cli] show a one-time Expo Simulators hint after a successful build

* [eas-cli] list Android before iOS in the Simulators launch hint

* [eas-cli] bound Simulators hint requests and require a finished build

* [eas-cli] show the Simulators hint only to user actors
2026-10-29 19:25:56 -04:00
Gabe Debes 4e453e5d9c [build-tools] Fix recording cleanup after early session cancellation (#4761)
* [build-tools] Fix recording cleanup after early session cancellation

* [build-tools] Detect recording starts separately on each output stream
2026-10-29 15:03:12 -07:00
Gabe Debes 4f7ba2d5a3 [build-tools] Handle stopped sessions when fetching TURN credentials (#4762) 2026-10-29 14:29:15 -07:00
Gabe Debes f65472ae0a [worker] Run package-manager checks in parallel during worker startup (#4710)
* [worker] Allow jobs to skip unused package-manager queries

* [worker] Run package-manager version queries in parallel
2026-10-29 12:21:41 -07:00
Gabe Debes 40f2b55f96 [worker] Add an env flag to skip CocoaPods proxy setup (#4709)
* [worker] Skip CocoaPods proxy setup for device sessions

* [worker] Add an opt-out for CocoaPods proxy setup
2026-10-29 12:21:40 -07:00
Gabe Debes dc759c0854 [worker] Run the Xcode check in the background (#4707)
* [worker] Skip Xcode upload workaround for device sessions

* [worker] Identify device sessions by their job session ID

* [worker] Add an opt-out for the Xcode version check

* [worker] Run the Xcode check alongside tool preparation

* [worker] Start the Xcode check without waiting

* [worker] Move Xcode preparation into runtimeEnvironment
2026-10-29 12:21:40 -07:00
William Grosset e38a605d89 [eas-cli] Add an Expo Update tip experiment for agents near the Free plan build limit (#4758)
* [eas-cli] Add an EAS Update tip experiment for agents near the Free plan build limit

* [eas-cli] Address review feedback

- Write the shown marker only in the treatment arm, after the tip prints
- Keep checking the next platform when a marker write fails
- Log the tip event only in the treatment arm; the exposure covers both arms
- Reject robot and partner actors, which cannot store user preferences
- Evaluate each platform against its own build context
- Exclude development builds
- Bound the tip's requests with a 3s timeout and no retries
- Drop the update:configure success event

* [eas-cli] Rename EAS Update to Expo Update
2026-10-29 11:16:41 -07:00
Gabe Debes a586f0cb68 [build-tools] Support single-device preview sessions (#4733)
* [build-tools] Support single-device preview sessions

* [build-tools] Pin Android session thumbnail capture

* [build-tools] Name session test options

* [build-tools] Remove extra preview documentation

Edited-by: Codex

* [build-tools] Always pin the preview host at startup

Edited-by: Codex

* [build-tools] Only pin previews with a valid known device

Edited-by: Codex

* [build-tools] Keep Appium preview on its selected iOS device

Edited-by: Codex
2026-10-29 11:01:24 -07:00
Kadi Kraman eebfa728a7 [eas-cli] add commands for observe traces and spans (#4772)
* [eas-cli] add Observe span queries and fragment

* [eas-cli] add Observe span fetch, filter, and format helpers

* [eas-cli] add observe:traces command

* [eas-cli] add observe:trace command

* [eas-cli] show request trace spans in observe:session

* [eas-cli] add changeset for Observe traces

* [eas-cli] order the observe span list newest first explicitly

* [eas-cli] strip control characters from observe span text before printing
2026-10-29 15:06:09 +00:00
Stanisław Chmiela bf478c719c [build-tools] Preserve Xcode compiler errors with xcpretty 4.4.7 (#4365)
# Why

xcpretty is what we use to parse build logs and I think there were some improvements in https://github.com/expo/third-party/pull/162 and https://github.com/expo/third-party/pull/170 so I think we should upgrade.

# How

Asked Codex to upgrade and adjust anything we'd need.

> EAS stops `tail` with SIGINT, which rejects `logReaderPromise`. That skips the summary call placed after `await`.
> 
> The new formatter uses `getBuildSummary()` to emit any pending final compiler error. Putting it in `finally` ensures that error is printed even when `tail` is interrupted.

# Test Plan

CI should pass.
2026-10-29 12:55:23 +01:00
Stanisław Chmiela a7fcb5140c [build-tools] Show failed attempts for uncertain Google Play commits (#4660)
* [build-tools] show final Google Play commit rejection

* [build-tools] Append the original API error to uncertain commits

* [build-tools] List failed Google Play commit attempts in order
2026-10-29 11:07:17 +01:00
Stanisław Chmiela f0a7377069 [build-tools] Explain ASC unexpected server errors (#3467)
* [build-tools] Handle ASC server-side unexpected errors (500)

* [build-tools] Link Apple service status in ASC server errors

* [build-tools] make ASC server errors operation independent
2026-10-29 11:07:17 +01:00
Stanisław Chmiela b755469716 [build-tools] Handle ASC missing icon validation errors (#3466)
* [build-tools] Handle ASC missing icon validation errors

* [build-tools] Preserve ASC icon errors and cover missing icon files

* [build-tools] sort combined ASC error imports
2026-10-29 11:07:16 +01:00
Stanisław Chmiela 1830f88603 [build-tools] Handle ASC 90771 missing BG task identifiers (#3465)
* [build-tools] Handle ASC 90771 missing BG task identifiers

* [build-tools] Preserve Apple background-task validation details
2026-10-29 11:07:16 +01:00
Stanisław Chmiela 75f83829d8 [build-tools] Explain ASC authentication failures (#3462)
* [build-tools] Handle ASC NOT_AUTHORIZED with actionable UserFacingError

* [build-tools] use a shared ASC authentication error code
2026-10-29 11:07:15 +01:00
William Grosset 08c92bf937 [eas-cli] Remove the Free plan limit agent guidance experiment (#4757) 2026-10-28 11:13:06 -07:00
Expo CI 1615d36cf9 bump STAGING and PRODUCTION to 27.1.0 in cli-versions.json 2026-10-28 17:18:00 +00:00
Expo CI 88e23aaa09 v27.1.0 2026-10-28 16:52:14 +00:00
Will Schurman 🦦 0a274a6fef [eas-cli] Throw when environment variable pull fails (#4774)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

With protected environment variables (and even regular usage without protected environment variables), we need to prevent the build running when they can't be pulled. Otherwise the build can end up in a unexpected state where env variables are empty or unset.

# How

Re-throw the error when environment variable pulling fails. The original PR that added this (#2495) didn't specify why it was caught and allowed to proceed.

# Test Plan

Before:

```
$ neas build
✔ Select platform › Android

⚠️ Detected that your app uses Expo Go for development, this is not recommended when building production apps.
Learn more
To suppress this warning, set EAS_BUILD_NO_EXPO_GO_WARNING=true.

Resolved "production" environment for the build. Learn more
Failed to pull env variables for environment production from EAS servers
[GraphQL] Entity Not Authorized: Reading this environment variable requires the Release Manager role or higher since it targets exclusively the production environment and thus is considered protected.: Entity not authorized: EnvironmentVariableEntity[d3f35233-c3b7-4ef0-98fd-bc08d576e1db] (viewer = ScopedAccountActorViewerContext[RegularUserViewerContext[b99c08be-7584-4358-b8e4-aef050972708], scopes=expo_account:c344bcd9-c79d-4c0b-96fb-940d947cac42(RUN,VIEW);expo_account:102f030b-3f17-44ad-9472-cf051344224b(RUN,VIEW);expo_account:df3ec58d-d9b7-4f83-afdd-dbdb8258fdbe(VIEW)], action = READ, ruleIndex = -1)
This can possibly be a bug in EAS/EAS CLI. Report it here: https://github.com/expo/eas-cli/issues
✔ Incremented versionCode from 11 to 12.


✔ Using remote Android credentials (Expo server)
```

After:
```
$ neas build
✔ Select platform › Android

⚠️ Detected that your app uses Expo Go for development, this is not recommended when building production apps.
Learn more
To suppress this warning, set EAS_BUILD_NO_EXPO_GO_WARNING=true.

Resolved "production" environment for the build. Learn more
Failed to pull env variables for environment production from EAS servers
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: Reading this environment variable requires the Release Manager role or higher since it targets exclusively the production environment and thus is considered protected.: Entity not authorized: EnvironmentVariableEntity[d3f35233-c3b7-4ef0-98fd-bc08d576e1db] (viewer = ScopedAccountActorViewerContext[RegularUserViewerContext[b99c08be-7584-4358-b8e4-aef050972708], scopes=expo_account:c344bcd9-c79d-4c0b-96fb-940d947cac42(RUN,VIEW);expo_account:102f030b-3f17-44ad-9472-cf051344224b(RUN,VIEW);expo_account:df3ec58d-d9b7-4f83-afdd-dbdb8258fdbe(VIEW)], action = READ, ruleIndex = -1)
Request ID: 11ade772-f079-4515-84bc-3c5239bf35b7
```

Note that this ^^ is just the env evaluation for config, but the same function is used in `eas build:dev`, `build:resign`, `build:version:get/set/sync`, `eas config`, and `eas fingerprint:generate`.
2026-10-28 09:03:43 -07:00
Stanisław Chmiela 78f6782970 [build-tools] Handle ASC 90189 as version duplicate (#3461)
# Why

ASC error 90189 arrives after upload creation. The existing duplicate-version handler covers the creation request, so it does not handle this processing failure.

# How

Map error 90189 to the existing duplicate-build UserError. Include Apple’s original descriptions and codes in the public error message and retain the full failed-upload state as `cause`, including errors, warnings, and information messages. Keep the current SDK-version handler. Remove unrelated message edits copied into the old PR.

# Examples
- https://expo.dev/uuid/019cbf7e-e0f8-7212-9b72-37e97d43b7a2
- https://expo.dev/uuid/019cbf7d-28fa-747b-bd36-81f81b8d8d1b
- https://expo.dev/uuid/019cbf7a-b73a-7310-b845-4e2f8c58224c

# Datadog evidence

Checked on 23 October 2026. Count window: 16 October 2026 12:00 UTC to 23 October 2026 12:00 UTC (14:00 Warsaw time). Counts refer to distinct runs or builds with failed phases, not raw log lines.

187 distinct failed runs contain error 90189. In 173 runs, the validation error list has no other code, so it matches this PR’s condition. Mixed validation errors remain outside that condition.

- [Datadog: matching failed-phase logs](https://app.datadoghq.com/logs?query=service%3Aeas-build+build_phase_result%3Afailed+submission_platform%3Aios+%2290189%22&from_ts=1792152000000&to_ts=1792756800000&live=false&stream_sort=desc)
- [Datadog: example failed run or build](https://app.datadoghq.com/logs?query=service%3Aeas-build+job_run_id%3A01a16866-c973-7288-b710-eb556df52e1c&from_ts=1792152000000&to_ts=1792756800000&live=false&stream_sort=desc)

# Test Plan

- `yarn --cwd packages/build-tools jest-unit --runInBand src/steps/functions/__tests__/uploadToAsc.test.ts`
- `yarn --cwd packages/build-tools typecheck`
- Root type checks, lint, and formatting checks passed.
- All 19 focused tests passed. The new test for public error details and cause preservation fails on the previous code and passes with this change.
2026-10-28 14:21:02 +01:00
Szymon Świerk 5757091b33 [eas-cli] support nested workflow calls in local file workflow run (#4769)
* [eas-cli] Collect workflow files referenced by uses jobs

* [eas-cli] Upload referenced workflow files in workflow:run

* [eas-cli] Regenerate GraphQL types
2026-10-28 11:40:21 +01:00
Ash Wu dd6ad17179 [eas-cli] recognize queued workflow runs and report finishedAt only for final runs (#4258)
Signed-off-by: Ash Wu <[email protected]>
2026-10-28 18:01:28 +08:00
Ash Wu 695f567760 [build-tools][worker] keep NPM_TOKEN working on newer pnpm and explain pnpm Node.js mismatches (#4729)
pnpm 10.34.2+ and 11.5.3+ ignore ${NPM_TOKEN} placeholders in a project-level .npmrc (GHSA-3qhv-2rgh-x77r) but still expand them in the npm user config. On EAS Build the NPM_TOKEN credential now goes into the file `npm config get userconfig` reports for the build env (~/.npmrc unless NPM_CONFIG_USERCONFIG points elsewhere) whenever the job has NPM_TOKEN, and the project .npmrc is left to the user. Local builds keep creating the project .npmrc, because the home directory there belongs to the developer. The legacy PREPARE_PROJECT path and the eas/use_npm_token step share one helper.

Projects that pin pnpm 11 through packageManager on a Node 20 image crash on node:sqlite right after pnpm's "requires at least Node.js v22.13" banner. A new user error names the eas.json node value to set or the pnpm version to change. The worker raises the same error when it reinstalls pnpm under a node version pinned in eas.json, instead of the generic "Failed to install pnpm" message: the typed error it threw before never reached the log handlers, and the probe's piped output never reached the log.
2026-10-28 17:19:16 +08:00
Ash Wu 15e2302575 [build-tools] install the repack package with npm in an isolated sandbox (#4728) 2026-10-28 17:10:27 +08:00
Gabe Debes ee4f03d44e [ci] Format the generated changelog before release (#4773) 2026-10-27 19:18:51 -07:00
Expo CI 5e192ecba6 v27.0.0 2026-10-27 22:10:54 +00:00
Gabe DebesandClaude Opus 5.5 864b11effe [eas-cli] Add simulator:logs for iOS sessions (#4545)
* [eas-cli] Add simulator:logs

Read buffered or live logs from a running iOS simulator session through
its preview API, or save the logs artifact once the session has stopped.

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

* [eas-cli] Use oclif exclusive for simulator:logs --follow and --json

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

* [eas-cli] Suggest redirecting --follow to save live simulator logs

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

* [eas-cli] Report log source failures while following simulator logs

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

* [eas-cli] Close the connection of a rejected simulator preview response

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

* [eas-cli] Write simulator log health notices to stderr

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

* [eas-cli] Explain that live simulator logs need an iOS session

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-27 15:02:52 -07:00
Gabe DebesandCodex 62df1bae1c [eas-cli] Require explicit simulator-host upload paths (#4683)
* [eas-cli] Require explicit simulator-host upload paths

* [eas-cli] Cover simulator upload path help and filesystem failures

* [eas-cli] Reject host paths for upload ID actions

Keep upload guidance consistent in text and JSON help, and explain local filenames beginning with host:. Add rejection and help regressions.

Co-authored-by: Codex <[email protected]>
2026-10-27 12:34:27 -07:00
Vojtech NovakandClaude Opus 5.5 8070663faa [eas-cli] Write the reason a command failed to stderr (#4592)
* [eas-cli] Write the reason a command failed to stderr

EasCommand.catch printed the reason with Log.error, which writes to
stdout. oclif then printed "<command> command failed." to stderr. A
program that reads stderr for the error found only that generic line.

Only the catch changes. Other Log.error and Log.warn calls stay on
stdout, because some of them are not failures and the spinner keeps
non-TTY output off stderr on purpose.

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

* [eas-cli] Drop the errorToStderr unit test and doc comment

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

* [eas-cli] Color the failure reason by stderr's color support

chalk's default instance takes its color level from stdout, so
`eas ... 2>err.log` in a terminal wrote color codes to the file.

Also spy on errorToStderr in the already-printed catch test. The catch
no longer calls Log.error, so the test passed even without the
messageAlreadyPrinted guard.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-27 10:11:39 +01:00
Vojtech NovakandClaude Opus 5.5 e38a5a88d2 [eas-cli] Print the eas-cli dependency notice only when stderr is a terminal (#4591)
The notice is advice for a person. A program that runs eas-cli reads
stderr for the command's error and found this notice first.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-27 10:11:39 +01:00
Gabe DebesandClaude Opus 5.5 82845def18 [eas-cli] Fix simulator control action help (#4681)
* [eas-cli] Fix simulator control action help

* [eas-cli] Exercise real simulator help lookup and hooks

* [eas-cli] Batch source-loaded help lifecycle probes

* [build-tools] Expect headless Appium default on iOS in the launch test

#4622 added this test while #4699 added appium:isHeadless to the iOS
default capabilities. Each PR passed CI without the other, so main fails.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-26 17:00:03 -07:00
Gabe DebesandClaude Opus 5.5 476b4e9b09 [build-tools] Run Appium iOS sessions with a headless Simulator (#4699)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-26 16:42:34 -07:00
Gabe Debes 9315aebef5 [build-tools] run apsd cleanup in parallel for iOS Simulator sessions (#4706)
* [build-tools] Keep APSD setup off preview readiness

* [build-tools] Run APSD setup alongside app launch

* [build-tools] Lowercase apsd in the changeset
2026-10-26 16:30:46 -07:00
Gabe Debes 786d8bac85 [build-tools] Start Appium with the prepared Full Disk Access helper (#4622) 2026-10-26 15:41:02 -07:00
Gabe Debes 8f67105e5a [build-tools] Improve simulator sessions failed app downloads error (#4703)
* [build-tools] Explain application archive download failures

* [build-tools] Update download error expectations

* [build-tools] Use neutral archive download advice
2026-10-26 15:06:37 -07:00
Will Schurman 🦦 3bfb405c87 [eas-cli] Fix graphql CI step on stacked PRs (#4763)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

Noticed that the required "Ensure GraphQL schema and generated code" CI job wasn't running on stacked PRs, and thus prevented stacked land.

# How

Allow it to run on stacked PRs by removing the `main` condition from PR.

# Test Plan

This PR was stacked and it ran!
2026-10-26 14:59:35 -07:00
Will Schurman 🦦 73335e8233 [eas-cli] Handle unauthorized error during build command (#4760)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

As requested by @sjkim-expo in #4734, when the user has insufficient permissions to create a build due to the required role being higher than the user's granted role, we can show a better error message.

# How

Handle the `UNAUTHORIZED` error code and show a better error.

Note that this doesn't yet gracefully handle protected environment variable errors or protected credential errors.

# Test Plan

`neas build` for granted role lower than required role from eas.json

Output:
```
Cannot create an EXPO_TOKEN for the job: the job requires the "Release Manager" role on the account "real-org-will", but the credentials of the actor that started the job are scoped to the "Developer" role. Re-authenticate to grant the credentials a scope that includes the "Release Manager" role, or request a lower role.
Request ID: bd12f9af-8950-498b-a69b-5d89255ca4f4

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. To request a lower role for the build's EXPO_TOKEN, set permissions.account.role in the build profile in eas.json.
```
2026-10-26 14:31:37 -07:00
Will Schurman 🦦 9af03f901e [eas-cli] Add better description to re-auth for scoped auth (#4759)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

Now that there's scoping, when a permissions error occurs and the user is scoped, they are told to run `eas login`.

But, when running `eas login` the experience was suboptimal:
```
You are already logged in as wschurman.
  ✔ Do you want to continue? … yes
```

# How

Update this message to be more descriptive and instructing to continue if the user wants to change scope.

# Test Plan

`neas login`

```
You are already logged in as wschurman.

  Accounts:
  • wschurman (Role: Owner)
  • expo (Role: Admin, limited to Viewer by the current credentials)

  The permissions granted during authentication limit your access to some accounts. Continue to log in again and change the
  permission grant.
  ✔ Do you want to continue? … yes
```
2026-10-26 14:07:42 -07:00
Gabe Debes 13ab40e271 [build-tools] Support idle timeouts for iOS web preview sessions (#4731)
* [build-tools] Support idle timeouts for iOS web preview sessions

* [build-tools] Reject Android web preview idle timeouts
2026-10-26 12:23:37 -07:00
Will Schurman 🦦 355f3f2012 [eas-cli] Send requested role from eas.json to build mutation (#4734)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

Builds on top of #4732 to send the new field to graphql for build creation. The eas.json field has been deployed on the server.

# How

Read the requested role from config, send to mutation if specified.

# Test Plan

Run the new test.

Manual test:
1. Add `"permissions": { "account": { "role": "runner" } }` to eas.json
2. `neas build`, see build runs!
2026-10-26 11:51:46 -07:00
Gabe Debes 736e1aeb90 [build-tools] Handle device sessions stopped during startup (#4716) 2026-10-26 11:31:14 -07:00
Expo CI 58d372a096 bump STAGING and PRODUCTION to 26.0.0 in cli-versions.json 2026-10-26 16:52:09 +00:00
Expo CI 56d01c4129 v26.0.0 2026-10-26 16:14:04 +00:00
Stanisław Chmiela bcb5d96ea8 [cocoapods-proxy] Add Sonatype snapshot CocoaPods proxy support (#3736)
## Summary

Adds `central.sonatype.com` support to the packaged `expo-cocoapods-proxy` gem used by iOS workers.

## Change

- Adds `central.sonatype.com` to the `Pod::Downloader::Http` proxy allowlist.
- Adds spec coverage for Sonatype repository artifact URLs.

## Code Requiring This

This PR is for CocoaPods `:http` pod source downloads. It does not proxy React Native's `Net::HTTP` metadata lookup.

React Native returns Sonatype artifact URLs to CocoaPods as `:http` pod sources here:

- RNCore returns `{:http => url}` after resolving the Sonatype nightly artifact URL: https://github.com/facebook/react-native/blob/7bbb13c9be014c0e63f0deaad809d669c15325ad/packages/react-native/scripts/cocoapods/rncore.rb#L170-L173
- ReactNativeDependencies returns `{:http => url}` after resolving the Sonatype nightly artifact URL: https://github.com/facebook/react-native/blob/7bbb13c9be014c0e63f0deaad809d669c15325ad/packages/react-native/scripts/cocoapods/rndependencies.rb#L205-L208
- Hermes returns `{:http => url}` after resolving the Sonatype nightly artifact URL: https://github.com/facebook/react-native/blob/7bbb13c9be014c0e63f0deaad809d669c15325ad/packages/react-native/sdks/hermes-engine/hermes-utils.rb#L190-L193

CocoaPods/cocoapods-downloader then uses `Pod::Downloader::Http` for those `:http` params:

- CocoaPods passes the pod source params to `Downloader.for_target(...).download`: https://github.com/CocoaPods/CocoaPods/blob/1.12.1/lib/cocoapods/downloader.rb#L107-L110
- `cocoapods-downloader` maps `:http` to `Http`: https://github.com/CocoaPods/cocoapods-downloader/blob/1.6.3/lib/cocoapods-downloader.rb#L22-L30
- `Downloader.for_target` instantiates that downloader with the `:http` URL: https://github.com/CocoaPods/cocoapods-downloader/blob/1.6.3/lib/cocoapods-downloader.rb#L51-L64
- `Pod::Downloader::Http#download_file` uses curl for that URL: https://github.com/CocoaPods/cocoapods-downloader/blob/1.6.3/lib/cocoapods-downloader/http.rb#L12-L22

## Validation

- `rspec` in `packages/expo-cocoapods-proxy`: 16 examples, 0 failures
- `git diff --check`
2026-10-26 15:51:09 +01: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
Expo CI 86b85e7d8d v24.9.0 2026-10-02 17:32:47 +00:00
Gabe DebesandClaude 03a7d3a6ee [eas-cli] Only forward --non-interactive from update:rollback in non-interactive mode (#4504)
* [eas-cli] only forward --non-interactive from update:rollback when it was passed

* [eas-cli] fix the changelog entry

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

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

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

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

* [eas-cli] cover the active rollout paths

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

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

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

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

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

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

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

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

* [eas-cli] tweak changelog

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

---------

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

Just a small improvement.

## How

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

## Test Plan

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

* [build-tools] link token fix changelog

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

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

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

* [build-tools] extract upload API key variable

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* [build-tools] Remove duplicated preview screenshot tests

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* [build-tools] Format the preview screenshot changes

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

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

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

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

---------

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

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

# How

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

# Test Plan

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

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

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

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

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

* [build-tools] Add changelog entry

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

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

Review follow-ups for `eas/start_agent_device_session`:

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

# How

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

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

# Test Plan

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

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

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

* [build-tools] Add changelog entry

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

---------

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

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

# Why

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

# How

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

# Test Plan

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

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

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

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

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

* [ci] Update the ffmpeg install comment

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

---------

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

* [build-tools] Stop automation alongside recording cleanup

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

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

* [build-tools] Extract session host shutdown operations

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

* [eas-cli] Add changelog entry

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 00:21:04 +02:00
811 changed files with 101670 additions and 17604 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.
+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;
+34
View File
@@ -0,0 +1,34 @@
{
"$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",
"@expo/vm-tools",
"@expo/simulator-launcher"
]
],
"linked": [],
"ignore": [],
"privatePackages": {
"version": true,
"tag": false
},
"updateInternalDependencies": "patch"
}
+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');
}
+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
+44 -18
View File
@@ -1,4 +1,4 @@
name: CHANGELOG.md PR check
name: Changeset PR check
on:
workflow_dispatch:
@@ -13,31 +13,57 @@ 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]'
}}
# The base branch, not `pull_request.base.sha`: that SHA can be older than the base that the
# checked-out merge commit contains, and the diff then includes other people's changes.
BASE_REF: origin/${{ github.base_ref || '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
-2
View File
@@ -4,7 +4,6 @@ on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '0 8 * * *' # 8:00 AM UTC time every day
@@ -22,4 +21,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 -4
View File
@@ -15,9 +15,9 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node: ['20.18.3', '22', '24']
node: ['22', '24']
include:
- node: '20.18.3'
- node: '22'
coverage: true
name: Test with Node ${{ matrix.node }}
steps:
@@ -71,8 +71,6 @@ jobs:
if: ${{ matrix.coverage }}
with:
token: ${{ secrets.CODECOV_TOKEN }}
- if: ${{ matrix.coverage }}
run: yarn lint-changelog
notify-slack:
runs-on: ubuntu-latest
+3 -13
View File
@@ -1,10 +1,6 @@
on:
workflow_dispatch:
inputs:
version:
type: string
required: false
description: 'Version number for the release'
dry_run:
type: boolean
required: true
@@ -19,7 +15,6 @@ jobs:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
INPUT_VERSION: ${{ github.event.inputs.version }}
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@@ -29,7 +24,7 @@ jobs:
token: ${{ secrets.EXPO_BOT_PAT }}
- uses: ./.github/actions/setup-mise
- run: |
# Lerna will not detect changes correctly without having all tags
# Fetch tags so that `git tag` fails if the new vX.Y.Z tag already exists
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
# Checkout the main branch
git checkout main
@@ -37,10 +32,5 @@ jobs:
git config --global user.name "Expo CI"
yarn install --immutable
yarn build
if [[ "$INPUT_VERSION" == "" ]]; then
echo "Releasing with default version"
yarn release
else
echo "Releasing with version $INPUT_VERSION"
yarn release "$INPUT_VERSION"
fi
# The changesets in .changeset/ set the version bump.
yarn release
+478 -4
View File
@@ -2,16 +2,484 @@
This is the log of notable changes to EAS CLI and related packages.
## main
### 🛠 Breaking changes
## [27.10.0](https://github.com/expo/eas-cli/releases/tag/v27.10.0) - 2026-11-13
### 🎉 New features
- [build-tools] Add the experimental `experimental_emulator_capture_version` input to `eas/start_android_emulator`. The Android Emulator then preloads that version of the `expo-emulator-capture` agent and uses the lavapipe renderer, and later steps start expo-device-hub with the emulator capture on. When the agent cannot install, the emulator starts as before. Only web preview sessions support the capture for now, so other session types fail with an error when the input is set. ([#4827](https://github.com/expo/eas-cli/pull/4827) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
- [eas-cli] Add `eas simulator:devices` to list the devices Expo suggests for a simulator session. ([#4879](https://github.com/expo/eas-cli/pull/4879) by [@brentvatne](https://github.com/brentvatne))
### 🐛 Bug fixes
- [build-tools] Retry the first ngrok tunnel open of a device run session up to two times when ngrok rejects it with ERR_NGROK_802. The first open now waits 19 seconds instead of 15, so a tunnel that opens late is kept instead of closed. Startup can be cancelled during the retries, and a preview tunnel stops retrying when its session host finishes. ([#4874](https://github.com/expo/eas-cli/pull/4874) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Include the parent CLI command in telemetry to distinguish nested commands from direct usage. ([#4876](https://github.com/expo/eas-cli/pull/4876) by [@davidmokos](https://github.com/davidmokos))
- [build-tools] Run worker SSH sessions as detached upterm sessions that reconnect on their own under the same session ID, and stop them with `upterm session stop`. ([#4857](https://github.com/expo/eas-cli/pull/4857) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [build-tools] Run the command a client passes with `eas workflow:ssh <id> <command>` in worker SSH sessions, in the build directory. ([#4863](https://github.com/expo/eas-cli/pull/4863) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [build-tools] Always download upterm for worker SSH sessions instead of using an `upterm` found on `PATH`. ([#4859](https://github.com/expo/eas-cli/pull/4859) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### 🧹 Chores
- [eas-cli][build-tools] Bump Node.js to 22.23.5 to fix the CVEs in the [November 2026 Node.js security releases](https://nodejs.org/en/blog/vulnerability/november-2026-security-releases). ([#4889](https://github.com/expo/eas-cli/pull/4889) by [@ramonclaudio](https://github.com/ramonclaudio))
## [27.9.0](https://github.com/expo/eas-cli/releases/tag/v27.9.0) - 2026-11-12
### 🎉 New features
- [eas-cli] Warn about Expo Simulators and EAS Workflows outages from status.expo.dev before `eas simulator:start` creates a session, and describe degraded performance and maintenance correctly in all outage warnings. ([#4873](https://github.com/expo/eas-cli/pull/4873) by [@szdziedzic](https://github.com/szdziedzic))
## [27.8.0](https://github.com/expo/eas-cli/releases/tag/v27.8.0) - 2026-11-12
### 🎉 New features
- [eas-cli] Tell accounts without EAS Simulator access that it is available on the Starter, Production, and Enterprise plans and link to the billing page, instead of pointing at the waitlist. `simulator:availability --json` now returns `billingUrl` instead of `waitlistUrl`. ([#4866](https://github.com/expo/eas-cli/pull/4866) by [@szdziedzic](https://github.com/szdziedzic))
### 🐛 Bug fixes
- [build-tools] Point expo-device-hub at the scrcpy server copy in the `turtle-v2` bucket in Android device sessions, so a failed GitHub release download no longer fails the session. expo-device-hub falls back to GitHub when the copy is missing. ([#4848](https://github.com/expo/eas-cli/pull/4848) by [@sjkim-expo](https://github.com/sjkim-expo))
- [build-tools] Fix black iOS session preview artifacts on multi-display Simulators by capturing the active display through serve-sim. ([#4777](https://github.com/expo/eas-cli/pull/4777) by [@gwdp](https://github.com/gwdp))
Skip automatic previews on older serve-sim versions that cannot exclude them from idle activity.
- [build-tools] Report flow tags with the Maestro test results of jobs that run `maestro-runner`. The tags are read from the runner's `report.json`. ([#4853](https://github.com/expo/eas-cli/pull/4853) by [@hSATAC](https://github.com/hSATAC))
- [build-tools] Send the session preview size to `preview-upload-session` and refresh it when the size changes. ([#4864](https://github.com/expo/eas-cli/pull/4864) by [@hirbod](https://github.com/hirbod))
- [eas-cli] Remove the "Android emulator support is still in development" warning from `eas simulator`. ([#4870](https://github.com/expo/eas-cli/pull/4870) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Send the width, height and source of simulator session screenshots (manual, agent-device and Argent, including Argent screenshot diffs) as artifact metadata, read from the image header. ([#4858](https://github.com/expo/eas-cli/pull/4858) by [@hirbod](https://github.com/hirbod))
- [eas-cli][build-tools][eas-build-job] Fix sizes of 102.4 GB and larger being printed with a value about a million times too large, and share one `formatBytes` helper between `eas-cli` and `build-tools`. ([#4854](https://github.com/expo/eas-cli/pull/4854) by [@vonovak](https://github.com/vonovak))
- [eas-cli] Package local Simulator app bundles for upload when the host supports gzip tar archives, and reject unsupported IPA and ZIP installs before uploading. ([#4684](https://github.com/expo/eas-cli/pull/4684) by [@gwdp](https://github.com/gwdp))
Fix packaging stalls for app bundles containing hard links and clean temporary archives when interrupted. Local app directories and `.tar.gz`/`.tgz` files require a host advertising archive support.
- [eas-cli] Remove the `[EXPERIMENTAL]` label from `eas simulator` commands, except `eas simulator:egress` and the `--egress` flag. ([#4869](https://github.com/expo/eas-cli/pull/4869) by [@szdziedzic](https://github.com/szdziedzic))
## [27.7.2](https://github.com/expo/eas-cli/releases/tag/v27.7.2) - 2026-11-10
### 🐛 Bug fixes
- [eas-cli] Add `--log-source` to `eas simulator` to select which Android logs a session records. ([#4823](https://github.com/expo/eas-cli/pull/4823) by [@vonovak](https://github.com/vonovak))
- [build-tools] Start SSH session shells in the job's build directory instead of the worker's directory. ([#4843](https://github.com/expo/eas-cli/pull/4843) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [eas-cli] Show that the ssh session has ended, instead of saying SSH was not enabled, when `eas workflow:ssh` is given the ID of a finished build. ([#4842](https://github.com/expo/eas-cli/pull/4842) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [eas-cli] Show the ID argument and examples in `eas workflow:ssh --help`. ([#4829](https://github.com/expo/eas-cli/pull/4829) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## [27.7.1](https://github.com/expo/eas-cli/releases/tag/v27.7.1) - 2026-11-10
### 🐛 Bug fixes
- [eas-cli][eas-json][build-tools][eas-build-job][steps][local-build-plugin][worker][simulator-launcher] Update `node-forge`, `tar`, `nanoid`, `minimatch`, `joi`, `ajv`, `yaml`, and `zod` to versions with fixes for published security advisories. ([#4847](https://github.com/expo/eas-cli/pull/4847) by [@tchayen](https://github.com/tchayen))
- [build-tools] Strip the GitHub repository access token from the git remote before a sandbox agent starts. ([#4826](https://github.com/expo/eas-cli/pull/4826) by [@sswrk](https://github.com/sswrk))
## [27.7.0](https://github.com/expo/eas-cli/releases/tag/v27.7.0) - 2026-11-10
### 🎉 New features
- [eas-cli] Add `--git-branch` and `--git-commit-hash` to `eas workflow:run` to record the branch and commit of the uploaded project. ([#4815](https://github.com/expo/eas-cli/pull/4815) by [@hSATAC](https://github.com/hSATAC))
### 🐛 Bug fixes
- [build-tools] Record Android emulator output and, when selected, device logcat during simulator sessions, and upload the last 20 MiB of each as a session artifact. ([#4822](https://github.com/expo/eas-cli/pull/4822) by [@vonovak](https://github.com/vonovak))
- [eas-cli] Stop spinners from flickering when their text wraps onto a second line, such as the `simulator:start` queue message in a narrow terminal. ([#4850](https://github.com/expo/eas-cli/pull/4850) by [@brentvatne](https://github.com/brentvatne))
## [27.6.0](https://github.com/expo/eas-cli/releases/tag/v27.6.0) - 2026-11-10
### 🎉 New features
- [build-tools] Reuse compatible prefetched Appium and XCUITest files without removing them during cleanup. Resolve explicit package versions, including `latest`, and reuse prepared tools only when they match. Use the launcher's runtime serve-sim version for warm adoption and recordings, including prereleases. ([#4837](https://github.com/expo/eas-cli/pull/4837) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add the `--property key=value` flag to `eas observe:events` to filter events by a property value. ([#4812](https://github.com/expo/eas-cli/pull/4812) by [@Ubax](https://github.com/Ubax))
- [simulator-launcher] Prepare pinned Appium, XCUITest and WebDriverAgent files with `prefetch`. ([#4836](https://github.com/expo/eas-cli/pull/4836) by [@gwdp](https://github.com/gwdp))
- [simulator-launcher] Add `simulator-launcher prefetch` to cache Expo Go downloads. ([#4834](https://github.com/expo/eas-cli/pull/4834) by [@gwdp](https://github.com/gwdp))
- [simulator-launcher] Use caller-supplied serve-sim, agent-device and Argent versions and include exact version inputs in claims. ([#4845](https://github.com/expo/eas-cli/pull/4845) by [@gwdp](https://github.com/gwdp))
- [simulator-launcher] Add `simulator-launcher warmup` so consumers can warm up Simulators, install Expo Go and prepare native tool caches. The command stops its tools and shuts down the selected Simulators before returning. ([#4838](https://github.com/expo/eas-cli/pull/4838) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [build-tools] Stop waiting for `com.apple.datamigrator` to exit before an iOS Simulator on iOS 27.2 or later counts as ready; there it stays resident, so the wait never finished. Earlier runtimes keep the wait. Take the readiness screenshot from the first integrated screen when the device lists several, so the iPhone Duo on iOS 27.2 passes the check. ([#4816](https://github.com/expo/eas-cli/pull/4816) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Stop suggesting other event names in `eas observe:events` when an event name with `--session-id`, `--app-version`, `--build-number`, or `--update-id` returns no events. ([#4813](https://github.com/expo/eas-cli/pull/4813) by [@Ubax](https://github.com/Ubax))
- [build-tools] Require the sandbox worker token for sandbox jobs and remove the sandbox GraphQL fallback. ([#4808](https://github.com/expo/eas-cli/pull/4808) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [vm-tools] Run the macOS VM-tools service with interactive scheduling so Simulator startup and preview processes avoid launchd's default CPU and I/O throttling. ([#4833](https://github.com/expo/eas-cli/pull/4833) by [@gwdp](https://github.com/gwdp))
## [27.5.0](https://github.com/expo/eas-cli/releases/tag/v27.5.0) - 2026-11-05
### 🎉 New features
- [eas-cli] Import ordered action configurations with `eas simulator:control --import` and show progress for each action. ([#4686](https://github.com/expo/eas-cli/pull/4686) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Reuse simulator control connections with `--keep-alive` or `simulator:control:connection open`. Set an idle timeout, inspect the connection, or close it without stopping the simulator session. Keep-alive currently supports macOS and Linux. ([#4791](https://github.com/expo/eas-cli/pull/4791) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Start simulator sessions from saved settings and apply control actions after readiness. ([#4687](https://github.com/expo/eas-cli/pull/4687) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Run `eas simulator` commands also as `eas simulators`, `eas emulator` and `eas emulators`, for example `eas emulators:start` or `eas simulators:list`. These aliases are hidden from help. ([#4794](https://github.com/expo/eas-cli/pull/4794) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] List `eas workflow:ssh` in `eas --help`. ([#4793](https://github.com/expo/eas-cli/pull/4793) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### 🐛 Bug fixes
- [build-tools] Stop an agent-device session that is still starting up when it is stopped. ([#4666](https://github.com/expo/eas-cli/pull/4666) by [@gwdp](https://github.com/gwdp))
- [build-tools] Stop an Appium session that is still starting up when it is stopped. ([#4668](https://github.com/expo/eas-cli/pull/4668) by [@gwdp](https://github.com/gwdp))
Report temporary-directory cleanup failures when Stop interrupts Appium installation.
- [build-tools] Stop an Argent session that is still starting up when it is stopped. ([#4667](https://github.com/expo/eas-cli/pull/4667) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Find the personal account by its owner instead of assuming that its name equals the username. ([#3922](https://github.com/expo/eas-cli/pull/3922) by [@byronkarlen](https://github.com/byronkarlen))
- [build-tools][worker] Use the sandbox worker token for sandbox jobs when www provides one, and keep Expo credentials out of the commands of those jobs. ([#4807](https://github.com/expo/eas-cli/pull/4807) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [eas-cli] Show the Expo Simulators hint after a successful build from November 12 instead of November 10. ([#4817](https://github.com/expo/eas-cli/pull/4817) by [@williamgrosset](https://github.com/williamgrosset))
- [build-tools] Stop a web-preview session that is still starting up when it is stopped. ([#4669](https://github.com/expo/eas-cli/pull/4669) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [build-tools] Extract the worker API client core from the device run session worker API client. ([#4806](https://github.com/expo/eas-cli/pull/4806) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## [27.4.1](https://github.com/expo/eas-cli/releases/tag/v27.4.1) - 2026-11-04
### 🐛 Bug fixes
- [simulator-launcher][build-tools] Wait for a starting warm Simulator launcher before claiming its session, instead of falling back to a cold start. ([#4810](https://github.com/expo/eas-cli/pull/4810) by [@gwdp](https://github.com/gwdp))
## [27.4.0](https://github.com/expo/eas-cli/releases/tag/v27.4.0) - 2026-11-03
### 🎉 New features
- [simulator-launcher][build-tools] Add `eas/start_warm_appium_remote_session`, which runs the Appium session on the warm Simulator, or starts cold. An `appium` claim stops the unused tools first, so WDA does not share the Simulator with the agent-device runner. ([#4735](https://github.com/expo/eas-cli/pull/4735) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add warm iOS session steps (`eas/start_warm_serve_sim_remote_session`, `eas/start_warm_agent_device_session`, `eas/start_warm_argent_remote_session`). They run the existing session on the warm Simulator, or start cold. `eas/start_agent_device_session` also accepts `install_app_path` and `launch_app_identifier`. ([#4723](https://github.com/expo/eas-cli/pull/4723) by [@gwdp](https://github.com/gwdp))
- [build-tools] Update the app version and build number from the final application archive, so the build shows the values the native build resolved. ([#3499](https://github.com/expo/eas-cli/pull/3499) by [@kadikraman](https://github.com/kadikraman))
### 🐛 Bug fixes
- [build-tools] Upload the final agent-device session artifacts before stopping the daemon, including sessions that reuse a warm Simulator. ([#4750](https://github.com/expo/eas-cli/pull/4750) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add internal helpers that claim the warm Simulator through the launcher and adopt it on the warm serve-sim. Skip the Expo Go install when the warm Simulator already has the same Expo Go version. ([#4724](https://github.com/expo/eas-cli/pull/4724) by [@gwdp](https://github.com/gwdp))
- [build-tools] Recover warm Simulator recordings when their MP4 exists before the shutdown manifest is ready. ([#4789](https://github.com/expo/eas-cli/pull/4789) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Show the Expo Simulators hint after a successful build from November 10 instead of November 5. ([#4801](https://github.com/expo/eas-cli/pull/4801) by [@williamgrosset](https://github.com/williamgrosset))
### 🧹 Chores
- [build-tools] Move iOS Simulator startup behind a shared session host contract. ([#4752](https://github.com/expo/eas-cli/pull/4752) by [@gwdp](https://github.com/gwdp))
## [27.3.0](https://github.com/expo/eas-cli/releases/tag/v27.3.0) - 2026-11-02
### 🎉 New features
- [eas-build-job] Add optional Observe error context to workflow jobs. ([#4787](https://github.com/expo/eas-cli/pull/4787) by [@AbbanMustafa](https://github.com/AbbanMustafa))
### 🐛 Bug fixes
- [eas-cli] Bump `@expo/apple-utils` to `2.3.0` to fix iOS capability sync when App Store Connect rejects enabling, disabling or changing a capability. ([#4790](https://github.com/expo/eas-cli/pull/4790) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Keep already linked App Groups, Merchant IDs and iCloud containers when capability sync links another identifier. ([#4584](https://github.com/expo/eas-cli/pull/4584) by [@ceydaakin](https://github.com/ceydaakin))
- [worker] Mask secrets in every record the worker process writes to its stdout: the build log records and the worker's own records. ([#4782](https://github.com/expo/eas-cli/pull/4782) by [@hSATAC](https://github.com/hSATAC))
- [worker][build-tools] Stop warmup in the background for jobs that do not use the warm Simulator, so job setup does not wait for it. Before build-tools boots or clones a Simulator, it waits until the launcher's state file records that cleanup finished, and fails if cleanup does not finish in time. ([#4721](https://github.com/expo/eas-cli/pull/4721) by [@gwdp](https://github.com/gwdp))
- [build-tools] Recover unhealthy device-session tunnels without changing their URLs and block further traffic when shutdown starts, even if ngrok cleanup times out. ([#4526](https://github.com/expo/eas-cli/pull/4526) by [@gwdp](https://github.com/gwdp))
- [build-tools] Stop sending the SSH host when registering an SSH session. ([#4784](https://github.com/expo/eas-cli/pull/4784) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## [27.2.0](https://github.com/expo/eas-cli/releases/tag/v27.2.0) - 2026-10-29
### 🎉 New features
- [build-tools] Use single-device previews when the session already has a valid device ID. ([#4733](https://github.com/expo/eas-cli/pull/4733) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add an experiment that suggests Expo Update to coding agents after a build when the Free plan account is within 5 builds of the platform limit. ([#4758](https://github.com/expo/eas-cli/pull/4758) by [@williamgrosset](https://github.com/williamgrosset))
- [eas-cli] Add `eas observe:traces` to list request trace spans (or a summary of span names and counts), `eas observe:trace` to display one trace as a span tree, and span rows in the `eas observe:session` timeline. ([#4772](https://github.com/expo/eas-cli/pull/4772) by [@kadikraman](https://github.com/kadikraman))
- [simulator-launcher] Accept a comma-separated list of warm Simulators in `EXPO_SIMULATOR_DEVICE`; the first is the default. One serve-sim serves every listed device on one port. After the default device is ready, warmup preinstalls the latest stable Expo Go from one download and tries to disable apsd on the other devices, until a job claims one. A claim can select any listed device and reports its preinstalled Expo Go. ([#4764](https://github.com/expo/eas-cli/pull/4764) by [@gwdp](https://github.com/gwdp))
- [simulator-launcher] Add `@expo/simulator-launcher`, which warms an iOS Simulator at VM boot and hands it to one EAS job (`start`, `claim`, `prepare-tools`, `stop`). ([#4722](https://github.com/expo/eas-cli/pull/4722) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Show a one-time Expo Simulators hint after a successful build. ([#4768](https://github.com/expo/eas-cli/pull/4768) by [@FiberJW](https://github.com/FiberJW))
- [vm-tools] Add a macOS startup script loader that runs `/usr/local/share/vm-tools/startup.sh` once per VM boot. ([#4725](https://github.com/expo/eas-cli/pull/4725) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [build-tools] Explain missing background task identifiers reported by App Store Connect. ([#3465](https://github.com/expo/eas-cli/pull/3465) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Explain App Store Connect NOT_AUTHORIZED responses in the shared API client. ([#3462](https://github.com/expo/eas-cli/pull/3462) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Explain App Store Connect UNEXPECTED_ERROR responses in the shared API client. ([#3467](https://github.com/expo/eas-cli/pull/3467) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Explain missing app icons reported by App Store Connect, including codes 90022 and 90023, while preserving Apple’s validation details. ([#3466](https://github.com/expo/eas-cli/pull/3466) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] List failed Google Play commit attempts in order, including network failures, when the outcome is uncertain. ([#4660](https://github.com/expo/eas-cli/pull/4660) by [@sjchmiela](https://github.com/sjchmiela))
- [worker] Run package-manager version queries in parallel during worker preparation. ([#4710](https://github.com/expo/eas-cli/pull/4710) by [@gwdp](https://github.com/gwdp))
- [worker] Start the Xcode version check in the background during worker tool preparation. Apply the Xcode 26.0 upload workaround when the query finishes, without delaying job startup. ([#4707](https://github.com/expo/eas-cli/pull/4707) by [@gwdp](https://github.com/gwdp))
- [build-tools] Fix a five-minute cleanup delay when a device session is canceled during startup and its screen recorder fails before connecting. ([#4761](https://github.com/expo/eas-cli/pull/4761) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Remove the Free plan build limit guidance experiment for coding agents from `eas build`. ([#4757](https://github.com/expo/eas-cli/pull/4757) by [@williamgrosset](https://github.com/williamgrosset))
- [worker] Allow jobs to skip installing the CocoaPods cache proxy with EAS_BUILD_DISABLE_COCOAPODS_CACHE_PLUGIN=1. ([#4709](https://github.com/expo/eas-cli/pull/4709) by [@gwdp](https://github.com/gwdp))
- [build-tools] Handle TURN requests for a stopped device session without reporting a warning. ([#4762](https://github.com/expo/eas-cli/pull/4762) by [@gwdp](https://github.com/gwdp))
- [build-tools] Preserve indented and incomplete Xcode compiler errors in formatted build logs, including the final diagnostic when logging stops. ([#4365](https://github.com/expo/eas-cli/pull/4365) by [@sjchmiela](https://github.com/sjchmiela))
### 🧹 Chores
- [simulator-launcher] Add the state, request command and service helpers for the warm Simulator launcher. The package stays private until #4722 adds its CLI entry point. ([#4766](https://github.com/expo/eas-cli/pull/4766) by [@gwdp](https://github.com/gwdp))
## [27.1.0](https://github.com/expo/eas-cli/releases/tag/v27.1.0) - 2026-10-28
### 🎉 New features
- [eas-cli] Display queued workflow runs: `workflow:run --wait` and `workflow:status` show a run waiting for its concurrency group and the run blocking it, and `workflow:cancel` includes queued runs in its selection list. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Upload the workflow files that `uses` jobs reference, including nested ones, when running a local workflow file with `eas workflow:run`. ([#4769](https://github.com/expo/eas-cli/pull/4769) by [@sswrk](https://github.com/sswrk))
### 🐛 Bug fixes
- [build-tools] Explain App Store Connect error 90189 as a duplicate build number. ([#3461](https://github.com/expo/eas-cli/pull/3461) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Fail the command when EAS CLI cannot pull environment variables from EAS servers, instead of continuing without them. ([#4774](https://github.com/expo/eas-cli/pull/4774) by [@wschurman](https://github.com/wschurman))
- [eas-cli] Format the generated root changelog before committing a release. ([#4773](https://github.com/expo/eas-cli/pull/4773) by [@gwdp](https://github.com/gwdp))
- [build-tools][worker] Put the `NPM_TOKEN` registry credential in the builder's npm user config on EAS Build (`~/.npmrc`, or the file `NPM_CONFIG_USERCONFIG` names), where pnpm 10.34.2+ and 11.5.3+ still expand `${NPM_TOKEN}` (they ignore it in a project-level `.npmrc`). Explain what to change when the project's pnpm version needs a newer Node.js than the build runs, including when eas.json sets `node` or `pnpm`. ([#4729](https://github.com/expo/eas-cli/pull/4729) by [@hSATAC](https://github.com/hSATAC))
- [build-tools] Install `@expo/repack-app` for the `eas/repack` step with npm in a sandbox that sees only `PATH` and its own `HOME`, so the build machine's npm and Yarn configuration cannot break the install, fetch it through the EAS npm cache when the job uses one, and show the installer output in the build log. The step no longer looks the version up separately before installing. When the npm cache fails, the install retries against the public registry like the project install does; the `eas/run_agent` agent CLI install shares the same helper and gains the same retry. ([#4728](https://github.com/expo/eas-cli/pull/4728) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Honor the `--non-interactive` flag in `workflow:cancel`; it was parsed but never applied, so automation could hang on the interactive picker. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Stop reporting a finish time for workflow runs that have not finished: `workflow:runs` reports `finishedAt` only for runs in a final status. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
## [27.0.0](https://github.com/expo/eas-cli/releases/tag/v27.0.0) - 2026-10-27
### 🛠 Breaking changes
- [eas-cli] Write the reason a command failed to stderr, next to the `command failed.` line, instead of to stdout. A program that reads the reason from stdout must read stderr instead. ([#4592](https://github.com/expo/eas-cli/pull/4592) by [@vonovak](https://github.com/vonovak))
### 🎉 New features
- [eas-cli] Send the `permissions.account.role` from the build profile to request a role for the build's `EXPO_TOKEN`. ([#4734](https://github.com/expo/eas-cli/pull/4734) by [@wschurman](https://github.com/wschurman))
- [eas-cli] Add `simulator:logs` to read and follow logs from an iOS simulator session and download saved logs after it stops. ([#4545](https://github.com/expo/eas-cli/pull/4545) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [build-tools] Keep the launched app open when an Appium iOS session starts. ([#4699](https://github.com/expo/eas-cli/pull/4699) by [@gwdp](https://github.com/gwdp))
- [build-tools] Run Appium through the prepared Full Disk Access helper when available on macOS. ([#4622](https://github.com/expo/eas-cli/pull/4622) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Show permission guidance, including `permissions.account.role` in eas.json, when a build request fails with an authorization error. ([#4760](https://github.com/expo/eas-cli/pull/4760) by [@wschurman](https://github.com/wschurman))
- [eas-cli] Print the notice about eas-cli in project dependencies only when stderr is a terminal, so programs that read stderr get the command's error. ([#4591](https://github.com/expo/eas-cli/pull/4591) by [@vonovak](https://github.com/vonovak))
- [eas-cli] Show account roles and credential permission limits in `eas login` when you are already logged in. ([#4759](https://github.com/expo/eas-cli/pull/4759) by [@wschurman](https://github.com/wschurman))
- [build-tools] Identify the build or application archive when a download fails, and explain when the archive is no longer available. ([#4703](https://github.com/expo/eas-cli/pull/4703) by [@gwdp](https://github.com/gwdp))
- [build-tools] Run apsd setup in the background without delaying iOS app launch or preview readiness. ([#4706](https://github.com/expo/eas-cli/pull/4706) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Fix simulator control action help routing and show help when a flag is incomplete. ([#4681](https://github.com/expo/eas-cli/pull/4681) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Require explicit `host:` simulator-host upload paths and report missing local files. ([#4683](https://github.com/expo/eas-cli/pull/4683) by [@gwdp](https://github.com/gwdp))
Missing local absolute paths now require the `host:` prefix. Actions that require upload IDs accept local files and reject host paths with an explanation.
- [build-tools] Handle a device session stopped during startup as normal cleanup instead of reporting a startup error. ([#4716](https://github.com/expo/eas-cli/pull/4716) by [@gwdp](https://github.com/gwdp))
- [build-tools] Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives. ([#4731](https://github.com/expo/eas-cli/pull/4731) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] Run the GraphQL schema and generated code check on pull requests to any base branch, including stacked pull requests. ([#4763](https://github.com/expo/eas-cli/pull/4763) by [@wschurman](https://github.com/wschurman))
## [26.0.0](https://github.com/expo/eas-cli/releases/tag/v26.0.0) - 2026-10-26
### 🛠 Breaking changes
- [eas-build-job][build-tools] Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports. ([#4719](https://github.com/expo/eas-cli/pull/4719) by [@sjchmiela](https://github.com/sjchmiela))
### 🎉 New features
- [eas-build-job][build-tools] 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. ([#4696](https://github.com/expo/eas-cli/pull/4696) by [@brentvatne](https://github.com/brentvatne))
- [eas-json] Add `permissions.account.role` to build profiles to request a role for the build's `EXPO_TOKEN`. ([#4732](https://github.com/expo/eas-cli/pull/4732) by [@wschurman](https://github.com/wschurman))
- [build-tools] Stop sandboxes after an optional maximum duration measured from readiness. ([#4702](https://github.com/expo/eas-cli/pull/4702) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Unify signed URL uploading to a single implementation and use it everywhere. ([#4712](https://github.com/expo/eas-cli/pull/4712) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### 🐛 Bug fixes
- [eas-cli] Regenerate graphql code. ([#4692](https://github.com/expo/eas-cli/pull/4692) by [@wschurman](https://github.com/wschurman))
- [build-tools] 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. ([#4727](https://github.com/expo/eas-cli/pull/4727) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Retry transient failures when reporting that a device run session has started. ([#4680](https://github.com/expo/eas-cli/pull/4680) by [@gwdp](https://github.com/gwdp))
- [build-tools] Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log. ([#4690](https://github.com/expo/eas-cli/pull/4690) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] 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. ([`78ba760`](https://github.com/expo/eas-cli/commit/78ba76001b67c9971236af1a1d02e46c2f82d24a) by [@vonovak](https://github.com/vonovak))
- [build-tools][steps] 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. ([#4717](https://github.com/expo/eas-cli/pull/4717) by [@hSATAC](https://github.com/hSATAC))
- [worker] Retry Node.js installation without the internal Node.js cache mirror when the first attempt fails, while preserving other configured mirrors. ([#3452](https://github.com/expo/eas-cli/pull/3452) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Share one GraphQL client between the project ID check and the command, so commands send one fewer app query. ([#4646](https://github.com/expo/eas-cli/pull/4646) by [@vonovak](https://github.com/vonovak))
- [eas-cli] 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. ([#4694](https://github.com/expo/eas-cli/pull/4694) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status. ([#4693](https://github.com/expo/eas-cli/pull/4693) by [@brentvatne](https://github.com/brentvatne))
- [worker] Mask secrets in every field of uploaded job log records, such as logged errors, not only in log messages. ([#4698](https://github.com/expo/eas-cli/pull/4698) by [@brentvatne](https://github.com/brentvatne))
### 🧹 Chores
- [eas-cli] Make command descriptions in `eas --help` consistently lowercase without trailing periods. ([#4385](https://github.com/expo/eas-cli/pull/4385) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli][build-tools] 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). ([#4714](https://github.com/expo/eas-cli/pull/4714) by [@ramonclaudio](https://github.com/ramonclaudio))
## [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))
- [eas-cli] Allow `eas simulator:start --platform android --egress local`. On Android, `--egress-allow localhost:<port>` also covers `10.0.2.2:<port>`, and refused destinations are shown as warnings. ([#4519](https://github.com/expo/eas-cli/pull/4519) by [@brentvatne](https://github.com/brentvatne))
- [build-tools] Support `eas/start_local_egress` on Linux for Android emulators: an always-listening relay for the emulator's proxy, a systemd scope and nftables fence that refuse traffic that cannot use the tunnel, and fence refusals in the session log. ([#4519](https://github.com/expo/eas-cli/pull/4519) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Add `--upload-source-maps` to `eas update`, which uploads the generated source maps to EAS so that stack traces from an update can be symbolicated. Source maps are stripped of `sourcesContent` before upload. ([#4436](https://github.com/expo/eas-cli/pull/4436) by [@douglowder](https://github.com/douglowder))
- [build-tools] Upload the embedded bundle after build by default for projects on SDK 58 and later. Set `EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE` to `0` to disable it. On SDK 57 and below the feature remains experimental and opt-in via `EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE`. ([#4433](https://github.com/expo/eas-cli/pull/4433) by [@brentvatne](https://github.com/brentvatne))
- [build-tools] Save manual iOS and Android live preview screenshots as session artifacts when supported by the preview server. ([#4481](https://github.com/expo/eas-cli/pull/4481) by [@vonovak](https://github.com/vonovak))
- [build-tools] Add the `eas/install_mitmproxy` step. ([#4307](https://github.com/expo/eas-cli/pull/4307) by [@gwdp](https://github.com/gwdp))
- [build-tools] Start serve-sim with `--network-capture`, and with `--network-capture-field` for each requested field, when a simulator session requests network capture. ([#4307](https://github.com/expo/eas-cli/pull/4307) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Allow workflow submissions to skip loading app config when project and application identifiers are supplied. ([#4471](https://github.com/expo/eas-cli/pull/4471) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Record Android device sessions and upload the screen recording, including the partial recording a killed Device Hub leaves behind. ([#4418](https://github.com/expo/eas-cli/pull/4418) by [@vonovak](https://github.com/vonovak))
- [build-tools] Add start and collect steps that attach bounded simulator logs from serve-sim to device run sessions. ([#4463](https://github.com/expo/eas-cli/pull/4463) by [@sjkim-expo](https://github.com/sjkim-expo))
- [build-tools] Add `eas/start_agent_device_session`, one step for agent-device sessions that boots the device, downloads, installs and launches the app, and starts the session, with the independent parts in parallel. ([#4491](https://github.com/expo/eas-cli/pull/4491) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-build-job] Add `sentry` workflow interpolation context schema for Sentry-triggered workflow runs. ([#4453](https://github.com/expo/eas-cli/pull/4453) by [@sswrk](https://github.com/sswrk))
### 🐛 Bug fixes
- [eas-cli] Stop `eas update:rollback` from always forcing non-interactive mode. ([#4504](https://github.com/expo/eas-cli/pull/4504) by [@gwdp](https://github.com/gwdp))
- [build-tools] Pass the step cancellation signal to `eas/download_build`. ([#4511](https://github.com/expo/eas-cli/pull/4511) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Accept `null` for the TestFlight group `hasAccessToAllBuilds` attribute from App Store Connect. ([#4528](https://github.com/expo/eas-cli/pull/4528) by [@sswrk](https://github.com/sswrk))
- [eas-cli] Support `queued` and `starting` status filters in `eas simulator:list` and keep simulator commands compatible with the current GraphQL schema. ([#4527](https://github.com/expo/eas-cli/pull/4527) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Explain how to resolve missing or expired Apple agreements when App Store Connect rejects a request. ([#4513](https://github.com/expo/eas-cli/pull/4513) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Correct the `eas simulator:start --max-duration-minutes` help text: every plan can set a custom duration up to the plan's maximum, which is also the default. ([#4524](https://github.com/expo/eas-cli/pull/4524) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Fix `eas workflow:validate` crashing with certain workflow schemas, and exit with a non-zero code when validation fails. ([#4507](https://github.com/expo/eas-cli/pull/4507) by [@yuvalgnessin-qz](https://github.com/yuvalgnessin-qz))
- [build-tools] Reuse App Store Connect tokens and refresh them before expiry so long uploads and processing waits remain authenticated. ([#4497](https://github.com/expo/eas-cli/pull/4497) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Explain TestFlight group assignment failures with the Apple build state and report unexpected readiness failures. ([#4521](https://github.com/expo/eas-cli/pull/4521) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Clear old Maestro CLI HTML reports before each test attempt so retries cannot upload an earlier report. ([#4409](https://github.com/expo/eas-cli/pull/4409) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Poll the web preview readiness every 250 ms instead of every second, so a session goes live sooner after its host is ready. ([#4496](https://github.com/expo/eas-cli/pull/4496) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Keep the `eas simulator` session spinner on one line, so resizing the terminal no longer leaves copies of it behind. The `eas simulator:stop` command is now printed once before the spinner starts. ([#4488](https://github.com/expo/eas-cli/pull/4488) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Disable agent-device's internal idle timeouts in remote sessions, so only the session's own idle and duration limits end it. ([#4485](https://github.com/expo/eas-cli/pull/4485) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Keep `eas simulator:events --follow` running while a session is queued or starting. ([#4506](https://github.com/expo/eas-cli/pull/4506) by [@sjkim-expo](https://github.com/sjkim-expo))
## [24.8.0](https://github.com/expo/eas-cli/releases/tag/v24.8.0) - 2026-09-24
### 🎉 New features
@@ -19,6 +487,7 @@ This is the log of notable changes to EAS CLI and related packages.
- [eas-cli] Add `--build-fingerprint` to `eas simulator` to install the newest build with a given fingerprint. ([#4472](https://github.com/expo/eas-cli/pull/4472) by [@tchayen](https://github.com/tchayen))
- [eas-cli] Accept build IDs in `workflow:ssh <id>`. ([#4452](https://github.com/expo/eas-cli/pull/4452) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [eas-cli] Make `eas simulator:*` commands visible in help output. ([#4456](https://github.com/expo/eas-cli/pull/4456) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Add `eas/update_testflight_metadata` to set changelogs and TestFlight groups for an uploaded build with an App Store Connect API key. The step fails if a requested group does not exist. ([#4475](https://github.com/expo/eas-cli/pull/4475) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Pass `--share-url` when launching serve-sim so Share copies the expo.dev preview page instead of the tunnel URL. ([#4434](https://github.com/expo/eas-cli/pull/4434) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas integrations:supabase:advisors` to list unresolved Supabase Security and Performance Advisor findings for the linked Supabase project. ([#4342](https://github.com/expo/eas-cli/pull/4342) by [@fiberjw](https://github.com/fiberjw))
- [eas-cli] Accept individual App Store Connect API keys for submissions, TestFlight setup, and metadata. ([#4249](https://github.com/expo/eas-cli/pull/4249) by [@sswrk](https://github.com/sswrk))
@@ -27,16 +496,20 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 Bug fixes
- [build-tools] Report early Argent server exits during simulator startup instead of waiting for the full readiness timeout. ([#4454](https://github.com/expo/eas-cli/pull/4454) by [@sjkim-expo](https://github.com/sjkim-expo))
- [eas-cli] Stop spinners from printing a new line on every frame after the terminal gets narrower. ([#4470](https://github.com/expo/eas-cli/pull/4470) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Exclude time waiting for simulator concurrency from the session startup timeout and show queue progress. ([#4411](https://github.com/expo/eas-cli/pull/4411) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] List `eas simulator` only once in `eas --help`. The `sim` shorthand is now a hidden alias, so it still runs but no longer duplicates the entry. ([#4457](https://github.com/expo/eas-cli/pull/4457) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [build-tools] Start the Argent tool-server with `ARGENT_EMULATOR_NO_WINDOW=1`, so Argent can boot the Android emulator again after it stops. Without it, the windowed emulator fails on the Linux image because `libpulse.so.0` is missing. ([#4465](https://github.com/expo/eas-cli/pull/4465) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Fix `eas account:view` printing `Role: undefined` for the new Release Manager role. ([#4300](https://github.com/expo/eas-cli/pull/4300) by [@byronkarlen](https://github.com/byronkarlen))
- [eas-cli] Include legacy serve-sim sessions in `eas simulator:list --type web-preview-only`, and list `web-preview-only` only once in the `--type` options. ([#4466](https://github.com/expo/eas-cli/pull/4466) by [@szdziedzic](https://github.com/szdziedzic))
### 🧹 Chores
- [eas-cli] Stop creating `CLAUDE.md` in new apps; use `AGENTS.md` for agent instructions. ([#4446](https://github.com/expo/eas-cli/pull/4446) by [@davidmokos](https://github.com/davidmokos))
- Upgrade `typescript` to 6.0.3, `ts-jest` to 29.4.12, `@types/node` to 20.19.43, `gql.tada` to 1.11.3, and `https-proxy-agent` to 7.0.6. ([#XXXX](https://github.com/expo/eas-cli/pull/XXXX) by [@wschurman](https://github.com/wschurman))
- Upgrade `typescript` to 6.0.3, `ts-jest` to 29.4.12, `@types/node` to 20.19.43, `gql.tada` to 1.11.3, and `https-proxy-agent` to 7.0.6. ([#4400](https://github.com/expo/eas-cli/pull/4400) by [@wschurman](https://github.com/wschurman))
- [build-tools] Use the website origin for simulator preview CORS and framing, and allow deploy previews and local website dev servers on staging. ([#4437](https://github.com/expo/eas-cli/pull/4437) by [@gwdp](https://github.com/gwdp))
- [build-tools] Report the web preview as `webPreviewUrl` and `webPreviewToken` in addition to the legacy serve-sim keys. ([#4467](https://github.com/expo/eas-cli/pull/4467) by [@szdziedzic](https://github.com/szdziedzic))
## [24.7.0](https://github.com/expo/eas-cli/releases/tag/v24.7.0) - 2026-09-16
@@ -148,6 +621,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`.
+1 -1
View File
@@ -12,7 +12,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that you d
to access the functionality of and directly benefit from the
functionality of the Licensed Work.
Change Date: 2029-07-15
Change Date: 2029-10-01
Change License: MIT
+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": "27.9.0",
"PRODUCTION": "27.9.0"
}
+2 -2
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "24.8.0",
"version": "27.10.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.5"
+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
+215
View File
@@ -0,0 +1,215 @@
# @expo/build-tools
## 27.10.0
### Minor Changes
- Add the experimental `experimental_emulator_capture_version` input to `eas/start_android_emulator`. The Android Emulator then preloads that version of the `expo-emulator-capture` agent and uses the lavapipe renderer, and later steps start expo-device-hub with the emulator capture on. When the agent cannot install, the emulator starts as before. Only web preview sessions support the capture for now, so other session types fail with an error when the input is set. ([#4827](https://github.com/expo/eas-cli/pull/4827) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
### Patch Changes
- Retry the first ngrok tunnel open of a device run session up to two times when ngrok rejects it with ERR_NGROK_802. The first open now waits 19 seconds instead of 15, so a tunnel that opens late is kept instead of closed. Startup can be cancelled during the retries, and a preview tunnel stops retrying when its session host finishes. ([#4874](https://github.com/expo/eas-cli/pull/4874) by [@szdziedzic](https://github.com/szdziedzic))
- Run worker SSH sessions as detached upterm sessions that reconnect on their own under the same session ID, and stop them with `upterm session stop`. ([#4857](https://github.com/expo/eas-cli/pull/4857) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- Run the command a client passes with `eas workflow:ssh <id> <command>` in worker SSH sessions, in the build directory. ([#4863](https://github.com/expo/eas-cli/pull/4863) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- Always download upterm for worker SSH sessions instead of using an `upterm` found on `PATH`. ([#4859](https://github.com/expo/eas-cli/pull/4859) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## 27.9.0
No changes in this release.
## 27.8.0
### Patch Changes
- Point expo-device-hub at the scrcpy server copy in the `turtle-v2` bucket in Android device sessions, so a failed GitHub release download no longer fails the session. expo-device-hub falls back to GitHub when the copy is missing. ([#4848](https://github.com/expo/eas-cli/pull/4848) by [@sjkim-expo](https://github.com/sjkim-expo))
- Fix black iOS session preview artifacts on multi-display Simulators by capturing the active display through serve-sim. ([#4777](https://github.com/expo/eas-cli/pull/4777) by [@gwdp](https://github.com/gwdp))
Skip automatic previews on older serve-sim versions that cannot exclude them from idle activity.
- Report flow tags with the Maestro test results of jobs that run `maestro-runner`. The tags are read from the runner's `report.json`. ([#4853](https://github.com/expo/eas-cli/pull/4853) by [@hSATAC](https://github.com/hSATAC))
- Send the session preview size to `preview-upload-session` and refresh it when the size changes. ([#4864](https://github.com/expo/eas-cli/pull/4864) by [@hirbod](https://github.com/hirbod))
- Send the width, height and source of simulator session screenshots (manual, agent-device and Argent, including Argent screenshot diffs) as artifact metadata, read from the image header. ([#4858](https://github.com/expo/eas-cli/pull/4858) by [@hirbod](https://github.com/hirbod))
- Fix sizes of 102.4 GB and larger being printed with a value about a million times too large, and share one `formatBytes` helper between `eas-cli` and `build-tools`. ([#4854](https://github.com/expo/eas-cli/pull/4854) by [@vonovak](https://github.com/vonovak))
## 27.7.2
### Patch Changes
- Start SSH session shells in the job's build directory instead of the worker's directory. ([#4843](https://github.com/expo/eas-cli/pull/4843) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## 27.7.1
### Patch Changes
- Update `node-forge`, `tar`, `nanoid`, `minimatch`, `joi`, `ajv`, `yaml`, and `zod` to versions with fixes for published security advisories. ([#4847](https://github.com/expo/eas-cli/pull/4847) by [@tchayen](https://github.com/tchayen))
- Strip the GitHub repository access token from the git remote before a sandbox agent starts. ([#4826](https://github.com/expo/eas-cli/pull/4826) by [@sswrk](https://github.com/sswrk))
## 27.7.0
### Patch Changes
- Record Android emulator output and, when selected, device logcat during simulator sessions, and upload the last 20 MiB of each as a session artifact. ([#4822](https://github.com/expo/eas-cli/pull/4822) by [@vonovak](https://github.com/vonovak))
## 27.6.0
### Minor Changes
- Reuse compatible prefetched Appium and XCUITest files without removing them during cleanup. Resolve explicit package versions, including `latest`, and reuse prepared tools only when they match. Use the launcher's runtime serve-sim version for warm adoption and recordings, including prereleases. ([#4837](https://github.com/expo/eas-cli/pull/4837) by [@gwdp](https://github.com/gwdp))
### Patch Changes
- Stop waiting for `com.apple.datamigrator` to exit before an iOS Simulator on iOS 27.2 or later counts as ready; there it stays resident, so the wait never finished. Earlier runtimes keep the wait. Take the readiness screenshot from the first integrated screen when the device lists several, so the iPhone Duo on iOS 27.2 passes the check. ([#4816](https://github.com/expo/eas-cli/pull/4816) by [@hSATAC](https://github.com/hSATAC))
- Require the sandbox worker token for sandbox jobs and remove the sandbox GraphQL fallback. ([#4808](https://github.com/expo/eas-cli/pull/4808) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## 27.5.0
### Patch Changes
- Stop an agent-device session that is still starting up when it is stopped. ([#4666](https://github.com/expo/eas-cli/pull/4666) by [@gwdp](https://github.com/gwdp))
- Stop an Appium session that is still starting up when it is stopped. ([#4668](https://github.com/expo/eas-cli/pull/4668) by [@gwdp](https://github.com/gwdp))
Report temporary-directory cleanup failures when Stop interrupts Appium installation.
- Stop an Argent session that is still starting up when it is stopped. ([#4667](https://github.com/expo/eas-cli/pull/4667) by [@gwdp](https://github.com/gwdp))
- Use the sandbox worker token for sandbox jobs when www provides one, and keep Expo credentials out of the commands of those jobs. ([#4807](https://github.com/expo/eas-cli/pull/4807) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- Stop a web-preview session that is still starting up when it is stopped. ([#4669](https://github.com/expo/eas-cli/pull/4669) by [@gwdp](https://github.com/gwdp))
## 27.4.1
### Patch Changes
- Wait for a starting warm Simulator launcher before claiming its session, instead of falling back to a cold start. ([#4810](https://github.com/expo/eas-cli/pull/4810) by [@gwdp](https://github.com/gwdp))
## 27.4.0
### Minor Changes
- Add `eas/start_warm_appium_remote_session`, which runs the Appium session on the warm Simulator, or starts cold. An `appium` claim stops the unused tools first, so WDA does not share the Simulator with the agent-device runner. ([#4735](https://github.com/expo/eas-cli/pull/4735) by [@gwdp](https://github.com/gwdp))
- Add warm iOS session steps (`eas/start_warm_serve_sim_remote_session`, `eas/start_warm_agent_device_session`, `eas/start_warm_argent_remote_session`). They run the existing session on the warm Simulator, or start cold. `eas/start_agent_device_session` also accepts `install_app_path` and `launch_app_identifier`. ([#4723](https://github.com/expo/eas-cli/pull/4723) by [@gwdp](https://github.com/gwdp))
- Update the app version and build number from the final application archive, so the build shows the values the native build resolved. ([#3499](https://github.com/expo/eas-cli/pull/3499) by [@kadikraman](https://github.com/kadikraman))
### Patch Changes
- Upload the final agent-device session artifacts before stopping the daemon, including sessions that reuse a warm Simulator. ([#4750](https://github.com/expo/eas-cli/pull/4750) by [@gwdp](https://github.com/gwdp))
- Add internal helpers that claim the warm Simulator through the launcher and adopt it on the warm serve-sim. Skip the Expo Go install when the warm Simulator already has the same Expo Go version. ([#4724](https://github.com/expo/eas-cli/pull/4724) by [@gwdp](https://github.com/gwdp))
- Recover warm Simulator recordings when their MP4 exists before the shutdown manifest is ready. ([#4789](https://github.com/expo/eas-cli/pull/4789) by [@gwdp](https://github.com/gwdp))
## 27.3.0
### Patch Changes
- Stop warmup in the background for jobs that do not use the warm Simulator, so job setup does not wait for it. Before build-tools boots or clones a Simulator, it waits until the launcher's state file records that cleanup finished, and fails if cleanup does not finish in time. ([#4721](https://github.com/expo/eas-cli/pull/4721) by [@gwdp](https://github.com/gwdp))
- Recover unhealthy device-session tunnels without changing their URLs and block further traffic when shutdown starts, even if ngrok cleanup times out. ([#4526](https://github.com/expo/eas-cli/pull/4526) by [@gwdp](https://github.com/gwdp))
- Stop sending the SSH host when registering an SSH session. ([#4784](https://github.com/expo/eas-cli/pull/4784) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## 27.2.0
### Minor Changes
- Use single-device previews when the session already has a valid device ID. ([#4733](https://github.com/expo/eas-cli/pull/4733) by [@gwdp](https://github.com/gwdp))
### Patch Changes
- Explain missing background task identifiers reported by App Store Connect. ([#3465](https://github.com/expo/eas-cli/pull/3465) by [@sjchmiela](https://github.com/sjchmiela))
- Explain App Store Connect NOT_AUTHORIZED responses in the shared API client. ([#3462](https://github.com/expo/eas-cli/pull/3462) by [@sjchmiela](https://github.com/sjchmiela))
- Explain App Store Connect UNEXPECTED_ERROR responses in the shared API client. ([#3467](https://github.com/expo/eas-cli/pull/3467) by [@sjchmiela](https://github.com/sjchmiela))
- Explain missing app icons reported by App Store Connect, including codes 90022 and 90023, while preserving Apple’s validation details. ([#3466](https://github.com/expo/eas-cli/pull/3466) by [@sjchmiela](https://github.com/sjchmiela))
- List failed Google Play commit attempts in order, including network failures, when the outcome is uncertain. ([#4660](https://github.com/expo/eas-cli/pull/4660) by [@sjchmiela](https://github.com/sjchmiela))
- Fix a five-minute cleanup delay when a device session is canceled during startup and its screen recorder fails before connecting. ([#4761](https://github.com/expo/eas-cli/pull/4761) by [@gwdp](https://github.com/gwdp))
- Handle TURN requests for a stopped device session without reporting a warning. ([#4762](https://github.com/expo/eas-cli/pull/4762) by [@gwdp](https://github.com/gwdp))
- Preserve indented and incomplete Xcode compiler errors in formatted build logs, including the final diagnostic when logging stops. ([#4365](https://github.com/expo/eas-cli/pull/4365) by [@sjchmiela](https://github.com/sjchmiela))
## 27.1.0
### Patch Changes
- Explain App Store Connect error 90189 as a duplicate build number. ([#3461](https://github.com/expo/eas-cli/pull/3461) by [@sjchmiela](https://github.com/sjchmiela))
- Put the `NPM_TOKEN` registry credential in the builder's npm user config on EAS Build (`~/.npmrc`, or the file `NPM_CONFIG_USERCONFIG` names), where pnpm 10.34.2+ and 11.5.3+ still expand `${NPM_TOKEN}` (they ignore it in a project-level `.npmrc`). Explain what to change when the project's pnpm version needs a newer Node.js than the build runs, including when eas.json sets `node` or `pnpm`. ([#4729](https://github.com/expo/eas-cli/pull/4729) by [@hSATAC](https://github.com/hSATAC))
- Install `@expo/repack-app` for the `eas/repack` step with npm in a sandbox that sees only `PATH` and its own `HOME`, so the build machine's npm and Yarn configuration cannot break the install, fetch it through the EAS npm cache when the job uses one, and show the installer output in the build log. The step no longer looks the version up separately before installing. When the npm cache fails, the install retries against the public registry like the project install does; the `eas/run_agent` agent CLI install shares the same helper and gains the same retry. ([#4728](https://github.com/expo/eas-cli/pull/4728) by [@hSATAC](https://github.com/hSATAC))
## 27.0.0
### Patch Changes
- Keep the launched app open when an Appium iOS session starts. ([#4699](https://github.com/expo/eas-cli/pull/4699) by [@gwdp](https://github.com/gwdp))
- Run Appium through the prepared Full Disk Access helper when available on macOS. ([#4622](https://github.com/expo/eas-cli/pull/4622) by [@gwdp](https://github.com/gwdp))
- Identify the build or application archive when a download fails, and explain when the archive is no longer available. ([#4703](https://github.com/expo/eas-cli/pull/4703) by [@gwdp](https://github.com/gwdp))
- Run apsd setup in the background without delaying iOS app launch or preview readiness. ([#4706](https://github.com/expo/eas-cli/pull/4706) by [@gwdp](https://github.com/gwdp))
- Handle a device session stopped during startup as normal cleanup instead of reporting a startup error. ([#4716](https://github.com/expo/eas-cli/pull/4716) by [@gwdp](https://github.com/gwdp))
- Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives. ([#4731](https://github.com/expo/eas-cli/pull/4731) by [@gwdp](https://github.com/gwdp))
## 26.0.0
### Major Changes
- Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports. ([#4719](https://github.com/expo/eas-cli/pull/4719) by [@sjchmiela](https://github.com/sjchmiela))
### Minor Changes
- Stop sandboxes after an optional maximum duration measured from readiness. ([#4702](https://github.com/expo/eas-cli/pull/4702) by [@sjchmiela](https://github.com/sjchmiela))
- Unify signed URL uploading to a single implementation and use it everywhere. ([#4712](https://github.com/expo/eas-cli/pull/4712) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### Patch Changes
- 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. ([#4727](https://github.com/expo/eas-cli/pull/4727) by [@sjchmiela](https://github.com/sjchmiela))
- 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. ([#4696](https://github.com/expo/eas-cli/pull/4696) by [@brentvatne](https://github.com/brentvatne))
- Retry transient failures when reporting that a device run session has started. ([#4680](https://github.com/expo/eas-cli/pull/4680) by [@gwdp](https://github.com/gwdp))
- Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log. ([#4690](https://github.com/expo/eas-cli/pull/4690) by [@hSATAC](https://github.com/hSATAC))
- 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. ([#4717](https://github.com/expo/eas-cli/pull/4717) by [@hSATAC](https://github.com/hSATAC))
## 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;
+17 -16
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "24.8.0",
"version": "27.10.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -23,36 +23,37 @@
"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.5 -- 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.8.0",
"@expo/downloader": "27.10.0",
"@expo/eas-build-job": "27.10.0",
"@expo/env": "^0.4.0",
"@expo/logger": "24.8.0",
"@expo/logger": "27.10.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "24.8.0",
"@expo/template-file": "24.8.0",
"@expo/turtle-spawn": "24.8.0",
"@expo/xcpretty": "^4.3.1",
"@expo/steps": "27.10.0",
"@expo/template-file": "27.10.0",
"@expo/turtle-spawn": "27.10.0",
"@expo/xcpretty": "^4.4.7",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
"@urql/core": "^6.0.1",
@@ -62,11 +63,11 @@
"fast-xml-parser": "^4.4.1",
"fs-extra": "^11.2.0",
"gql.tada": "^1.11.3",
"joi": "^17.13.1",
"joi": "^17.13.9",
"jose": "5.10.0",
"lodash": "^4.18.1",
"node-fetch": "^2.7.0",
"node-forge": "^1.3.1",
"node-forge": "^1.4.3",
"node-pty": "1.2.0-beta.15",
"node-stream-zip": "1.15.0",
"nullthrows": "^1.1.1",
@@ -76,11 +77,11 @@
"resolve-from": "^5.0.0",
"retry": "^0.13.1",
"semver": "^7.6.2",
"tar": "7.5.19",
"tar": "7.5.22",
"uuid": "9.0.1",
"ws": "8.21.1",
"yaml": "^2.8.1",
"zod": "^4.3.5"
"yaml": "^2.9.1",
"zod": "^4.6.7"
},
"devDependencies": {
"@expo/repack-app": "~0.9.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
@@ -2,6 +2,7 @@ import {
BuildPhase,
BuildPhaseResult,
BuildTrigger,
EnvironmentSecretType,
Job,
Metadata,
Platform,
@@ -31,6 +32,34 @@ describe('BuildContext', () => {
(fs.readdir as unknown as jest.Mock).mockResolvedValue([]);
});
it('keeps the sandbox worker token out of the env and keeps the other secrets', () => {
const ctx = new BuildContext(
{
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
secrets: {
environmentSecrets: [
{
name: 'EAS_SANDBOX_WORKER_TOKEN',
value: 'sandbox-worker-token',
type: EnvironmentSecretType.STRING,
},
{ name: 'KEEP', value: 'value', type: EnvironmentSecretType.STRING },
],
},
} as Job,
{
env: { __API_SERVER_URL: 'https://api.expo.test' },
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
expect(ctx.env.EAS_SANDBOX_WORKER_TOKEN).toBeUndefined();
expect(ctx.env.KEEP).toBe('value');
});
it('should merge secrets', async () => {
const robotAccessToken = randomUUID();
await vol.promises.mkdir('/workingdir/eas-environment-secrets/', { recursive: true });
@@ -318,6 +318,110 @@ 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('POSTs distribution metrics to the sandbox worker API with the sandbox worker token', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
sandboxWorkerToken: 'sandbox-token',
target: { kind: 'sandbox' },
});
Datadog.distribution('eas.workflow.hook', 1, { hook: 'on_start' });
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/sandbox-worker/metrics',
'POST',
{
json: {
metrics: [
{
name: 'eas.workflow.hook',
type: 'distribution',
value: 1,
tags: { hook: 'on_start', target: 'job_run' },
},
],
},
headers: { 'expo-sandbox-worker-token': 'sandbox-token' },
retries: 2,
}
);
});
it('labels metric upload failures for sandbox targets with the sandbox kind', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
sandboxWorkerToken: 'sandbox-token',
target: { kind: 'sandbox' },
});
Datadog.distribution('eas.workflow.hook', 1);
await flushPromises();
expect(sentryCaptureMock).toHaveBeenCalledWith(
'Failed to report turtle sandbox metric',
expect.any(Error),
expect.anything()
);
});
it('does not report logs for sandbox targets', () => {
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
sandboxWorkerToken: 'sandbox-token',
target: { kind: 'sandbox' },
});
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,22 @@
import { type watchDeviceRunSessionStartup } from '../../steps/utils/deviceRunSessionStartup';
import { DeviceRunSessionStoppedError } from '../../steps/utils/remoteDeviceRunSession';
/**
* A `watchDeviceRunSessionStartup` stand-in that never polls. A test calls `stop` on the
* returned watch (`jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value`) to act like
* a Stop request during startup.
*/
export function createStartupWatchMock({
startup,
}: Parameters<typeof watchDeviceRunSessionStartup>[0]): ReturnType<
typeof watchDeviceRunSessionStartup
> & { stop: () => void } {
const stopped = new DeviceRunSessionStoppedError('Stopped during startup.');
return {
stopped,
stop: () => startup.abort(stopped),
isStopped: () => startup.signal.reason === stopped,
completeAsync: jest.fn(async () => startup.signal.throwIfAborted()),
finishAsync: jest.fn(async () => {}),
};
}
@@ -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}';
}
@@ -125,6 +125,31 @@ describe(resolveBuildPhaseErrorAsync, () => {
expect(err.trackingCode).toBeUndefined();
});
it('explains that the pnpm version needs a newer Node.js than the build runs', async () => {
const fakeError = new Error();
const err = await resolveBuildPhaseErrorAsync(
fakeError,
[
'Running "pnpm install --frozen-lockfile" in /home/expo/workingdir/build directory',
'warn: This version of pnpm requires at least Node.js v22.13',
'warn: The current version of Node.js is v20.19.4',
'warn: Visit https://r.pnpm.io/comp to see the list of past pnpm versions with respective Node.js version support.',
'Error [ERR_UNKNOWN_BUILTIN_MODULE]: No such built-in module: node:sqlite',
],
{
job: { platform: Platform.ANDROID } as Job,
phase: BuildPhase.INSTALL_DEPENDENCIES,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_PNPM_UNSUPPORTED_NODE_VERSION_ERROR');
expect(err.message).toBe(
'This build uses a pnpm version that requires Node.js 22.13.0 or newer, but runs Node.js 20.19.4. You can fix it by either:\n- setting "node" to "22.13.0" or a newer version in the build profile in eas.json\n- using a pnpm version that supports Node.js 20.19.4: change "packageManager" in package.json if your project sets it, and set "pnpm" in the build profile in eas.json'
);
expect(err.docsUrl).toBe('https://docs.expo.dev/eas/json/');
});
it('detects xcode line error', async () => {
vol.fromJSON({
'/path/to/xcodelogs.log': originalFs.readFileSync(
@@ -0,0 +1,33 @@
import { errors } from '@expo/eas-build-job';
import nullthrows from 'nullthrows';
import semver from 'semver';
import { ErrorHandler } from './errors.types';
// example log: "warn: This version of pnpm requires at least Node.js v22.13" followed by "warn: The current version of Node.js is v20.19.4"
const PNPM_UNSUPPORTED_NODE_VERSION_REGEXP =
/This version of pnpm requires at least Node\.js v([\d.]+)\n.*The current version of Node\.js is v([\d.]+)/;
function createPnpmUnsupportedNodeVersionError(matchResult: RegExpMatchArray): errors.UserError {
const [, requiredNodeVersion, currentNodeVersion] = matchResult;
const minimumNodeVersion = nullthrows(semver.coerce(requiredNodeVersion)).version;
return new errors.UserError(
'EAS_BUILD_PNPM_UNSUPPORTED_NODE_VERSION_ERROR',
`This build uses a pnpm version that requires Node.js ${minimumNodeVersion} or newer, but runs Node.js ${currentNodeVersion}. You can fix it by either:
- setting "node" to "${minimumNodeVersion}" or a newer version in the build profile in eas.json
- using a pnpm version that supports Node.js ${currentNodeVersion}: change "packageManager" in package.json if your project sets it, and set "pnpm" in the build profile in eas.json`,
{ docsUrl: 'https://docs.expo.dev/eas/json/' }
);
}
export const pnpmUnsupportedNodeVersionErrorHandler: ErrorHandler<errors.UserError> = {
regexp: PNPM_UNSUPPORTED_NODE_VERSION_REGEXP,
createError: createPnpmUnsupportedNodeVersionError,
};
export function detectPnpmUnsupportedNodeVersionError(
output: string
): errors.UserError | undefined {
const match = output.match(PNPM_UNSUPPORTED_NODE_VERSION_REGEXP);
return match ? createPnpmUnsupportedNodeVersionError(match) : undefined;
}
@@ -1,6 +1,7 @@
import { BuildMode, BuildPhase, Platform, Workflow, errors } from '@expo/eas-build-job';
import { ErrorHandler, XCODE_BUILD_PHASE } from './errors.types';
import { pnpmUnsupportedNodeVersionErrorHandler } from './pnpmUnsupportedNodeVersionError';
import UserError = errors.UserError;
@@ -211,6 +212,7 @@ You are seeing this error because either:
return undefined;
},
},
pnpmUnsupportedNodeVersionErrorHandler,
{
platform: Platform.IOS,
phase: XCODE_BUILD_PHASE,
@@ -53,14 +53,15 @@ jest.mock('../../steps/utils/android/gradleConfig', () => ({
injectCredentialsGradleConfig: jest.fn(),
}));
jest.mock('../../utils/artifacts', () => ({
uploadApplicationArchive: jest.fn(),
uploadApplicationArchive: jest.fn(async () => []),
}));
jest.mock('../../utils/expoUpdates', () => ({
configureExpoUpdatesIfInstalledAsync: jest.fn(),
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync: jest.fn(async () => null),
}));
jest.mock('../../utils/expoUpdatesEmbedded', () => ({
uploadEmbeddedBundleAsync: jest.fn(),
...jest.requireActual('../../utils/expoUpdatesEmbedded'),
uploadEmbeddedBundleAsync: jest.fn(async () => ({ status: 'uploaded' })),
}));
jest.mock('../../utils/hooks', () => ({
Hook: {
@@ -317,36 +318,108 @@ describe(androidBuilder, () => {
expect(runBuilderWithHooksAsync).toHaveBeenCalledWith(ctx, expect.any(Function));
});
it('runs the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is set', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1',
},
uploadArtifact: jest.fn(),
describe('embedded bundle upload phase', () => {
function createContext({
env = {},
sdkVersion,
}: {
env?: Record<string, string>;
sdkVersion?: string;
}): BuildContext<Android.Job> {
return new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
...env,
},
uploadArtifact: jest.fn(),
metadata: sdkVersion ? { sdkVersion } : undefined,
});
}
it('runs by default on SDK 58 and later', async () => {
const ctx = createContext({ sdkVersion: '58.0.0' });
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
expect.objectContaining({ job: ctx.job, env: ctx.env, projectDir: '/workingdir/build' })
);
});
await androidBuilder(ctx);
it.each([
['uploaded', BuildPhaseResult.SUCCESS],
['skipped', BuildPhaseResult.SKIPPED],
['failed', BuildPhaseResult.WARNING],
] as const)('marks the phase from the %s result', async (uploadResult, phaseResult) => {
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValueOnce({ status: uploadResult });
const ctx = createContext({ sdkVersion: '58.0.0' });
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
});
await androidBuilder(ctx);
it('skips the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is not set', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect.objectContaining({
build_phase: BuildPhase.UPLOAD_EMBEDDED_BUNDLE.toLowerCase(),
result: phaseResult,
})
);
});
await androidBuilder(ctx);
it('skips by default on SDK 57 and earlier', async () => {
const ctx = createContext({ sdkVersion: '57.0.0' });
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
it('skips by default when the SDK version is unknown', async () => {
const ctx = createContext({});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
it('runs on SDK 57 and earlier when EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE is 1', async () => {
const ctx = createContext({
sdkVersion: '57.0.0',
env: { EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE: '1' },
});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
expect.objectContaining({ job: ctx.job })
);
});
it('skips on SDK 58 and later when EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE is 0', async () => {
const ctx = createContext({
sdkVersion: '58.0.0',
env: { EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE: '0' },
});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
it('runs on SDK 57 and earlier when the experimental EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is 1', async () => {
const ctx = createContext({
sdkVersion: '57.0.0',
env: { EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1' },
});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
expect.objectContaining({ job: ctx.job })
);
});
});
});
+43 -5
View File
@@ -11,11 +11,16 @@ 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';
import { JobHooksRef, setupAsync } from '../common/setup';
import { Artifacts, BuildContext, SkipNativeBuildError } from '../context';
import {
extractAndroidVersionAsync,
updateResolvedVersionAsync,
} from '../steps/functions/updateResolvedVersion';
import {
cacheStatsAsync,
restoreCcacheAsync,
@@ -32,7 +37,10 @@ import {
configureExpoUpdatesIfInstalledAsync,
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
} from '../utils/expoUpdates';
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
import {
shouldUploadEmbeddedBundle,
uploadEmbeddedBundleAsync,
} from '../utils/expoUpdatesEmbedded';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
@@ -216,16 +224,46 @@ async function buildInnerAsync(
});
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
await uploadApplicationArchive(ctx, {
patternOrPath: ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}',
const [archivePath] = await uploadApplicationArchive(ctx, {
patternOrPath: resolveArtifactPath(ctx.job),
rootDir: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
});
const buildId = ctx.env.EAS_BUILD_ID;
if (!archivePath || !buildId) {
return;
}
try {
const { appVersion, appBuildVersion } = await extractAndroidVersionAsync(archivePath);
if (appVersion || appBuildVersion) {
await updateResolvedVersionAsync(ctx.graphqlClient, buildId, {
appVersion,
appBuildVersion,
});
ctx.logger.info(
`Updated resolved version: ${appVersion ?? 'N/A'} (${appBuildVersion ?? 'N/A'})`
);
}
} catch (err: any) {
Sentry.capture('Failed to update resolved version', err);
ctx.logger.warn({ err }, 'Failed to update resolved version (non-fatal)');
}
});
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
if (shouldUploadEmbeddedBundle(ctx)) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
await uploadEmbeddedBundleAsync(ctx);
const { status } = await uploadEmbeddedBundleAsync({
job: ctx.job,
env: ctx.env,
logger: ctx.logger,
projectDir: ctx.getReactNativeProjectDirectory(),
appConfig: await ctx.appConfig,
});
if (status === 'skipped') {
ctx.markBuildPhaseSkipped();
} else if (status === 'failed') {
ctx.markBuildPhaseHasWarnings();
}
});
}
+42 -5
View File
@@ -18,6 +18,10 @@ import { downloadApplicationArchiveAsync } from '../ios/resign';
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
import { Sentry } from '../sentry';
import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
import {
extractIosVersionAsync,
updateResolvedVersionAsync,
} from '../steps/functions/updateResolvedVersion';
import {
cacheStatsAsync,
restoreCcacheAsync,
@@ -29,7 +33,10 @@ import {
configureExpoUpdatesIfInstalledAsync,
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
} from '../utils/expoUpdates';
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
import {
shouldUploadEmbeddedBundle,
uploadEmbeddedBundleAsync,
} from '../utils/expoUpdatesEmbedded';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
@@ -233,16 +240,46 @@ async function buildInnerAsync(
});
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
await uploadApplicationArchive(ctx, {
patternOrPath: resolveArtifactPath(ctx),
const [archivePath] = await uploadApplicationArchive(ctx, {
patternOrPath: resolveArtifactPath(ctx.job),
rootDir: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
});
const buildId = ctx.env.EAS_BUILD_ID;
if (!archivePath || !buildId) {
return;
}
try {
const { appVersion, appBuildVersion } = await extractIosVersionAsync(archivePath);
if (appVersion || appBuildVersion) {
await updateResolvedVersionAsync(ctx.graphqlClient, buildId, {
appVersion,
appBuildVersion,
});
ctx.logger.info(
`Updated resolved version: ${appVersion ?? 'N/A'} (${appBuildVersion ?? 'N/A'})`
);
}
} catch (err: any) {
Sentry.capture('Failed to update resolved version', err);
ctx.logger.warn({ err }, 'Failed to update resolved version (non-fatal)');
}
});
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
if (shouldUploadEmbeddedBundle(ctx)) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
await uploadEmbeddedBundleAsync(ctx);
const { status } = await uploadEmbeddedBundleAsync({
job: ctx.job,
env: ctx.env,
logger: ctx.logger,
projectDir: ctx.getReactNativeProjectDirectory(),
appConfig: await ctx.appConfig,
});
if (status === 'skipped') {
ctx.markBuildPhaseSkipped();
} else if (status === 'failed') {
ctx.markBuildPhaseHasWarnings();
}
});
}
@@ -1,8 +1,8 @@
import { UserError } from '@expo/eas-build-job';
import { SystemError, UserError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import { fetchAndCheckoutRefAsync } from '../git';
import { fetchAndCheckoutRefAsync, stripRepositoryCredentialsAsync } from '../git';
jest.mock('@expo/turtle-spawn');
@@ -147,3 +147,64 @@ describe(fetchAndCheckoutRefAsync, () => {
expect(error.message).not.toContain('ghs_secret123');
});
});
describe(stripRepositoryCredentialsAsync, () => {
const token = 'ghs_secret123';
function mockRemoteUrl(url: string): void {
jest.mocked(spawn).mockResolvedValueOnce({ stdout: `${url}\n` } as never);
}
it('strips the token from the origin remote', async () => {
mockRemoteUrl(`https://x-access-token:${token}@github.com/expo/app.git`);
await stripRepositoryCredentialsAsync({ repositoryDirectory });
expect(spawn).toHaveBeenNthCalledWith(1, 'git', ['remote', 'get-url', 'origin'], {
cwd: repositoryDirectory,
});
expect(spawn).toHaveBeenNthCalledWith(
2,
'git',
['remote', 'set-url', 'origin', 'https://github.com/expo/app.git'],
{ cwd: repositoryDirectory }
);
expect(spawn).toHaveBeenCalledTimes(2);
});
it('keeps the host and port of a GitHub Enterprise remote', async () => {
mockRemoteUrl(`https://x-access-token:${token}@ghe.example.com:8443/org/app.git`);
await stripRepositoryCredentialsAsync({ repositoryDirectory });
expect(spawn).toHaveBeenNthCalledWith(
2,
'git',
['remote', 'set-url', 'origin', 'https://ghe.example.com:8443/org/app.git'],
{ cwd: repositoryDirectory }
);
});
it('does nothing when the origin remote carries no credentials', async () => {
mockRemoteUrl('https://github.com/expo/app.git');
await stripRepositoryCredentialsAsync({ repositoryDirectory });
expect(spawn).toHaveBeenCalledTimes(1);
});
it('throws when the remote URL cannot be rewritten', async () => {
mockRemoteUrl(`https://x-access-token:${token}@github.com/expo/app.git`);
const gitError = Object.assign(new Error('git exited with non-zero code: 128'), {
stderr: "fatal: No such remote 'origin'",
});
jest.mocked(spawn).mockRejectedValueOnce(gitError);
const promise = stripRepositoryCredentialsAsync({ repositoryDirectory });
await expect(promise).rejects.toBeInstanceOf(SystemError);
await expect(promise).rejects.toMatchObject({ cause: gitError });
const error = await promise.catch(err => err);
expect(error.message).not.toContain(token);
});
});
@@ -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 () => {
@@ -2,6 +2,7 @@ import {
ArchiveSourceType,
BuildMode,
BuildTrigger,
EnvironmentSecretType,
ErrorCode,
Job,
Platform,
@@ -12,6 +13,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';
@@ -115,6 +117,73 @@ describe('projectSources', () => {
);
});
it('fetches a sandbox project source from the sandbox worker router with only the worker token', async () => {
const gitCommitHash = randomBytes(20).toString('hex');
const ctx = new BuildContext(
{
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
projectArchive: {
type: ArchiveSourceType.GIT,
gitRef: 'refs/heads/main',
gitCommitHash,
},
secrets: {
robotAccessToken: 'sandbox-worker-token',
environmentSecrets: [
{
name: 'EAS_SANDBOX_WORKER_TOKEN',
type: EnvironmentSecretType.STRING,
value: 'sandbox-worker-token',
},
],
},
} as unknown as Job,
{
env: {
__API_SERVER_URL: 'https://api.expo.dev',
EAS_BUILD_ID: randomUUID(),
EAS_BUILD_RUNNER: 'eas-build',
},
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
const fetchMock = jest.mocked(fetch);
fetchMock.mockImplementation(
async () =>
({
ok: true,
json: async () => ({
data: {
gitRef: 'refs/heads/main',
gitCommitHash,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
type: ArchiveSourceType.GIT,
},
}),
}) as Response
);
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
expect(fetchMock).toHaveBeenCalledWith(
'https://api.expo.dev/v2/sandbox-worker/project-archive',
expect.objectContaining({
method: 'POST',
headers: { 'expo-sandbox-worker-token': 'sandbox-worker-token' },
})
);
expect(shallowCloneRepositoryAsync).toHaveBeenCalledWith(
expect.objectContaining({
archiveSource: expect.objectContaining({
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
}),
})
);
});
it.each(['http', 'network', 'json', 'schema', 'missing-url', 'PATH', 'GCS', 'R2'])(
'throws a system error if refresh fails (%s)',
async failure => {
@@ -390,7 +459,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 +489,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(
@@ -3,6 +3,7 @@ import { EventEmitter } from 'events';
import spawnAsync from '@expo/spawn-async';
import { ExpoRunFormatter } from '@expo/xcpretty';
import fg from 'fast-glob';
import { vol } from 'memfs';
import { createMockLogger } from '../../__tests__/utils/logger';
import { Sentry } from '../../sentry';
@@ -59,6 +60,47 @@ describe(XcodeBuildLogger, () => {
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('flushes the final incomplete compiler error when the log reader is interrupted', async () => {
const { ExpoRunFormatter: RealFormatter } =
jest.requireActual<typeof import('@expo/xcpretty')>('@expo/xcpretty');
jest.mocked(ExpoRunFormatter.create).mockImplementation(RealFormatter.create);
let rejectReader!: (error: Error) => void;
const spawnPromise = Object.assign(
new Promise<never>((_resolve, reject) => {
rejectReader = reject;
}),
{
child: {
stdout,
kill: () =>
rejectReader(Object.assign(new Error('tail interrupted'), { signal: 'SIGINT' })),
},
}
);
jest.mocked(spawnAsync).mockReturnValue(spawnPromise as any);
const diagnostics =
'/project/First.swift:12:3: error: first compiler error\n' +
' /project/Last.swift:20:4: error: final compiler error\n';
vol.fromJSON({ '/logs/build.log': diagnostics });
const logger = createMockLogger();
const buildLogger = new XcodeBuildLogger(logger, '/project');
await buildLogger.watchLogFiles('/logs');
stdout.emit('data', diagnostics);
await buildLogger.flush();
const output = jest
.mocked(logger.info)
.mock.calls.map(([line]) => String(line))
.join('\n');
expect(output).toContain('first compiler error');
expect(output).toContain('final compiler error');
expect(output).toContain('Last.swift:20:4');
expect(output).toContain('2 error(s)');
expect(output.match(/final compiler error/g)).toHaveLength(1);
});
it('falls back to raw logs when the formatter throws', async () => {
const logger = createMockLogger();
await startWatchingAsync(logger);
+40 -1
View File
@@ -1,4 +1,4 @@
import { ArchiveSource, ArchiveSourceType, UserError } from '@expo/eas-build-job';
import { ArchiveSource, ArchiveSourceType, SystemError, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
@@ -99,6 +99,45 @@ export async function fetchAndCheckoutRefAsync({
}
}
export async function stripRepositoryCredentialsAsync({
repositoryDirectory,
}: {
repositoryDirectory: string;
}): Promise<void> {
let remoteUrl: URL;
try {
const { stdout } = await spawn('git', ['remote', 'get-url', 'origin'], {
cwd: repositoryDirectory,
});
remoteUrl = new URL(stdout.trim());
} catch (err) {
throw new SystemError('Failed to read the origin remote URL of the project repository.', {
cause: err,
});
}
if (!remoteUrl.username && !remoteUrl.password) {
return;
}
try {
await spawn('git', ['remote', 'set-url', 'origin', stripCredentialsFromGitUrl(remoteUrl)], {
cwd: repositoryDirectory,
});
} catch (err) {
throw new SystemError('Failed to strip credentials from the origin remote URL.', {
cause: err,
});
}
}
function stripCredentialsFromGitUrl(url: URL): string {
const strippedUrl = new URL(url.toString());
strippedUrl.username = '';
strippedUrl.password = '';
return strippedUrl.toString();
}
function getSanitizedGitUrl(maybeGitUrl: string): string | null {
try {
const url = new URL(maybeGitUrl);
@@ -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;
@@ -4,6 +4,7 @@ import path from 'path';
import { BuildContext } from '../context';
import { Sentry } from '../sentry';
import { NpmCacheRegistryInstallError, isNpmCacheInstallFailure } from '../utils/npmCacheRegistry';
import { PackageManager, findPackagerRootDir } from '../utils/packageManager';
import { isUsingModernYarnVersion } from '../utils/project';
@@ -196,10 +197,6 @@ export function resolvePackagerDir(ctx: BuildContext<Job>): string {
return packagerRunDir;
}
function isNpmCacheInstallFailure(err: unknown, npmCacheUrl: string): boolean {
return getErrorOutput(err).includes(npmCacheUrl);
}
function isNpmCacheRegistryErrorLine(line: string, npmCacheUrl: string): boolean {
return (
line.includes(npmCacheUrl) &&
@@ -207,24 +204,9 @@ function isNpmCacheRegistryErrorLine(line: string, npmCacheUrl: string): boolean
);
}
function getErrorOutput(err: unknown): string {
if (!(err instanceof Error)) {
return '';
}
const { stdout, stderr } = err as Error & { stdout?: string; stderr?: string };
return [stdout, stderr].filter(Boolean).join('\n');
}
class NpmCacheRegistryNonFatalError extends SystemError {
override name = 'NpmCacheRegistryNonFatalError';
constructor() {
super('Non-fatal npm cache registry error during dependency install');
}
}
class NpmCacheRegistryInstallError extends SystemError {
override name = 'NpmCacheRegistryInstallError';
constructor(cause: unknown) {
super('Failed to install dependencies using npm cache registry', { cause });
}
}
+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,
@@ -0,0 +1,15 @@
import { ArchiveSourceType } from '@expo/eas-build-job';
import { z } from 'zod';
export const ProjectArchiveSourceSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal(ArchiveSourceType.GIT),
repositoryUrl: z.string().url(),
gitRef: z.string().nullable(),
gitCommitHash: z.string(),
}),
z.object({ type: z.literal(ArchiveSourceType.URL), url: z.string().url() }),
z.object({ type: z.literal(ArchiveSourceType.NONE) }),
]);
export type ProjectArchiveSource = z.infer<typeof ProjectArchiveSourceSchema>;
@@ -5,13 +5,15 @@ 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 { type ProjectArchiveSource, ProjectArchiveSourceSchema } from './projectArchiveSource';
import { BuildContext } from '../context';
import { getSandboxWorkerApi } from '../steps/utils/sandboxWorkerApi';
import { uploadContentWithSignedUrl } from '../storage/uploadWithSignedUrl';
import { turtleFetch } from '../utils/turtleFetch';
export async function prepareProjectSourcesAsync<TJob extends Job>(
@@ -208,10 +210,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
@@ -246,7 +247,18 @@ async function uploadProjectMetadataAsync(
}
}
async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>) {
async function fetchProjectArchiveSourceAsync(
ctx: BuildContext<Job>
): Promise<ProjectArchiveSource> {
const sandboxWorkerApi = getSandboxWorkerApi(ctx);
if (sandboxWorkerApi) {
return await sandboxWorkerApi.getProjectArchiveSourceAsync({
timeoutMs: 20000,
retries: 3,
logger: ctx.logger,
});
}
const taskId = nullthrows(ctx.env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
const expoApiServerURL = nullthrows(ctx.env.__API_SERVER_URL, '__API_SERVER_URL is not set');
const robotAccessToken = nullthrows(
@@ -284,20 +296,7 @@ async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>) {
);
}
const dataResult = z
.object({
data: z.discriminatedUnion('type', [
z.object({
type: z.literal(ArchiveSourceType.GIT),
repositoryUrl: z.string().url(),
gitRef: z.string().nullable(),
gitCommitHash: z.string(),
}),
z.object({ type: z.literal(ArchiveSourceType.URL), url: z.string().url() }),
z.object({ type: z.literal(ArchiveSourceType.NONE) }),
]),
})
.safeParse(jsonResult.value);
const dataResult = z.object({ data: ProjectArchiveSourceSchema }).safeParse(jsonResult.value);
if (!dataResult.success) {
throw new Error(
`Unexpected data from server (${response.status}): ${z.prettifyError(dataResult.error)}`
+6 -1
View File
@@ -71,7 +71,12 @@ export async function setupAsync<TJob extends BuildJob>(
}
);
await setUpNpmrcAsync(ctx, ctx.logger);
await setUpNpmrcAsync({
projectTargetDirectory: ctx.buildDirectory,
workingDirectory: ctx.getReactNativeProjectDirectory(),
env: ctx.env,
logger: ctx.logger,
});
if (ctx.job.platform === Platform.IOS && ctx.env.EAS_BUILD_RUNNER === 'eas-build') {
await deleteXcodeEnvLocalIfExistsAsync(ctx as BuildContext<Ios.Job>);
}
+6 -3
View File
@@ -80,9 +80,12 @@ export class XcodeBuildLogger {
this.logger.info(data.toString());
}
});
await this.logReaderPromise;
this.logger.info(formatter.getBuildSummary());
try {
await this.logReaderPromise;
} finally {
// flush() interrupts tail, but the formatter may still hold a final compiler diagnostic.
this.logger.info(formatter.getBuildSummary());
}
} catch (err: any) {
if (!this.flushing) {
this.loggerError = err;
+14 -5
View File
@@ -3,14 +3,13 @@ import {
BuildPhase,
BuildPhaseResult,
Env,
type EnvironmentSecret,
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';
@@ -20,6 +19,7 @@ import path from 'path';
import { resolveBuildPhaseErrorAsync } from './buildErrors/detectError';
import { Datadog } from './datadog';
import { SANDBOX_WORKER_TOKEN_ENV } from './steps/utils/sandboxWorkerApi';
import { readAppConfig } from './utils/appConfig';
import { createTemporaryEnvironmentSecretFile } from './utils/environmentSecrets';
import { PackageManager, resolvePackageManager } from './utils/packageManager';
@@ -42,7 +42,7 @@ export type ArtifactToUpload =
paths: string[];
}
| {
type: GenericArtifactType;
type: null;
name: string;
paths: string[];
metadata?: Record<string, unknown>;
@@ -69,6 +69,7 @@ export interface BuildContextOptions {
metadata?: Metadata;
expoApiV2BaseUrl?: string;
mcpServerUrl?: string;
addBuildLogMask?: (secret: string) => void;
}
export class SkipNativeBuildError extends Error {}
@@ -86,6 +87,7 @@ export class BuildContext<TJob extends Job = Job> {
public readonly skipNativeBuild?: boolean;
public readonly expoApiV2BaseUrl?: string;
public readonly mcpServerUrl?: string;
public readonly addBuildLogMask?: (secret: string) => void;
public artifacts: Artifacts = {};
private readonly _isLocal: boolean;
@@ -115,6 +117,7 @@ export class BuildContext<TJob extends Job = Job> {
this.skipNativeBuild = options.skipNativeBuild;
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
this.mcpServerUrl = options.mcpServerUrl;
this.addBuildLogMask = options.addBuildLogMask;
const environmentSecrets = this.getEnvironmentSecrets(job);
this._env = {
@@ -238,7 +241,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 };
@@ -407,7 +410,7 @@ export class BuildContext<TJob extends Job = Job> {
const environmentSecretsDirectory = path.join(this.workingdir, 'eas-environment-secrets');
const environmentSecrets: Record<string, string> = {};
for (const { name, type, value } of job.secrets.environmentSecrets) {
for (const { name, type, value } of job.secrets.environmentSecrets.filter(isJobSecret)) {
if (type === EnvironmentSecretType.STRING) {
environmentSecrets[name] = value;
} else {
@@ -421,3 +424,9 @@ export class BuildContext<TJob extends Job = Job> {
return environmentSecrets;
}
}
const EXCLUDED_JOB_SECRET_NAMES: ReadonlySet<string> = new Set([SANDBOX_WORKER_TOKEN_ENV]);
function isJobSecret({ name }: EnvironmentSecret): boolean {
return !EXCLUDED_JOB_SECRET_NAMES.has(name);
}
@@ -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;
+55 -15
View File
@@ -1,15 +1,31 @@
import { Sentry } from './sentry';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from './steps/utils/deviceRunSessionWorkerApi';
import { SANDBOX_WORKER_TOKEN_HEADER } from './steps/utils/sandboxWorkerApi';
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' }
| { kind: 'sandbox' };
type DatadogSetupOptions = {
expoApiV2BaseUrl: string;
robotAccessToken: string;
target: MetricsTarget;
};
type DatadogSetupOptions =
| {
expoApiV2BaseUrl: string;
robotAccessToken: string;
target: Exclude<MetricsTarget, { kind: 'deviceRunSession' | 'sandbox' }>;
}
| {
expoApiV2BaseUrl: string;
deviceRunSessionToken: string;
target: Extract<MetricsTarget, { kind: 'deviceRunSession' }>;
}
| {
expoApiV2BaseUrl: string;
sandboxWorkerToken: string;
target: Extract<MetricsTarget, { kind: 'sandbox' }>;
};
let setupOptions: DatadogSetupOptions | null = null;
let pendingUploads: Promise<unknown>[] = [];
@@ -18,9 +34,36 @@ 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 },
};
}
if ('sandboxWorkerToken' in options) {
return {
path: 'sandbox-worker/metrics',
headers: { [SANDBOX_WORKER_TOKEN_HEADER]: options.sandboxWorkerToken },
};
}
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 +73,7 @@ export const Datadog = {
if (!setupOptions) {
return;
}
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
const { expoApiV2BaseUrl, target } = setupOptions;
const metrics = [
{
name,
@@ -40,16 +83,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 +99,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;
+13 -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';
@@ -26,6 +34,7 @@ export { formatSecondsForLog } from './utils/formatDuration';
export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
export { Hook, runHookIfPresent } from './utils/hooks';
export { detectPnpmUnsupportedNodeVersionError } from './buildErrors/pnpmUnsupportedNodeVersionError';
export { parseGradleProfile, formatGradleProfileReport } from './android/gradleProfile';
export type { GradleProfileTask } from './android/gradleProfile';
@@ -33,5 +42,7 @@ export type { GradleProfileTask } from './android/gradleProfile';
export * from './generic';
export { Datadog } from './datadog';
export { getDeviceRunSessionTokenFromEnvironmentSecrets } from './steps/utils/deviceRunSessionWorkerApi';
export { getSandboxWorkerTokenFromEnvironmentSecrets } from './steps/utils/sandboxWorkerApi';
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',
@@ -0,0 +1,18 @@
import { type CustomBuildContext } from '../../customBuildContext';
import { getEasFunctions } from '../easFunctions';
it('registers opt-in session functions with the existing inputs', () => {
const functions = getEasFunctions({ hasBuildJob: () => false } as CustomBuildContext);
for (const name of ['serve_sim_remote', 'agent_device', 'argent_remote', 'appium_remote']) {
const existing = functions.find(fn => fn.id === `start_${name}_session`)!;
const warm = functions.find(fn => fn.id === `start_warm_${name}_session`)!;
expect(warm.getFullId()).toBe(`eas/start_warm_${name}_session`);
const warmInputs = warm.inputProviders!.map(input => input(undefined as never, 'warm').id);
const existingInputs = existing.inputProviders!.map(
input => input(undefined as never, 'existing').id
);
expect(warmInputs).toEqual(existingInputs);
}
const ids = functions.map(fn => fn.getFullId());
expect(new Set(ids).size).toBe(ids.length);
});
+28 -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';
@@ -19,6 +20,7 @@ import { generateGymfileFromTemplateFunction } from './functions/generateGymfile
import { createGetCredentialsForBuildTriggeredByGithubIntegration } from './functions/getCredentialsForBuildTriggeredByGitHubIntegration';
import { injectAndroidCredentialsFunction } from './functions/injectAndroidCredentials';
import { createInstallMaestroBuildFunction } from './functions/installMaestro';
import { createInstallMitmproxyBuildFunction } from './functions/installMitmproxy';
import { createInstallBuildFunction } from './functions/installBuild';
import { createInstallNodeModulesBuildFunction } from './functions/installNodeModules';
import { createInstallPodsBuildFunction } from './functions/installPods';
@@ -26,9 +28,12 @@ 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';
import { createUpdateResolvedVersionBuildFunction } from './functions/updateResolvedVersion';
import { resolveAppleTeamIdFromCredentialsFunction } from './functions/resolveAppleTeamIdFromCredentials';
import { createResolveBuildConfigBuildFunction } from './functions/resolveBuildConfig';
import {
@@ -42,9 +47,10 @@ 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';
import { createStartAppiumRemoteSessionBuildFunction } from './functions/startAppiumRemoteSession';
@@ -54,11 +60,16 @@ import { createStartIosSimulatorRecordingsBuildFunction } from './functions/star
import { createStartLocalEgressBuildFunction } from './functions/startLocalEgress';
import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/startWebPreviewRemoteSession';
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
import { createStartServeSimLogsBuildFunction } from './functions/startServeSimLogs';
import { createCollectServeSimLogsBuildFunction } from './functions/collectServeSimLogs';
import { createCollectAndroidEmulatorLogsBuildFunction } from './functions/collectAndroidEmulatorLogs';
import { createStartSandboxBuildFunction } from './functions/startSandbox';
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from './functions/uploadDeviceRunSessionScreenRecordings';
import { createUploadEmbeddedBundleBuildFunction } from './functions/uploadEmbeddedBundle';
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
import { createUpdateTestFlightMetadataBuildFunction } from './functions/updateTestFlightMetadata';
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
import { createWaitForPosthogMetricFunction } from './functions/waitForPosthogMetric';
import { createUploadPosthogSourcemapsFunction } from './functions/uploadPosthogSourcemaps';
@@ -77,7 +88,9 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createInstallNodeModulesBuildFunction(),
createPrebuildBuildFunction(),
createReadIpaInfoBuildFunction(),
createReadAndroidAppInfoBuildFunction(),
createDownloadBuildFunction(ctx),
createDownloadSubmissionArchiveFunction(ctx),
createInstallBuildFunction(),
createLaunchApplicationFunction(),
createEasExportBuildFunction(),
@@ -99,9 +112,12 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
generateGymfileFromTemplateFunction(),
runFastlaneFunction(),
parseXcactivitylogFunction(),
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
createStartAgentDeviceSessionBuildFunction(ctx),
createStartAgentDeviceSessionBuildFunction(ctx, { warm: true }),
createStartArgentRemoteSessionBuildFunction(ctx),
createStartArgentRemoteSessionBuildFunction(ctx, { warm: true }),
createStartAppiumRemoteSessionBuildFunction(ctx),
createStartAppiumRemoteSessionBuildFunction(ctx, { warm: true }),
createStartAndroidEmulatorBuildFunction(),
createStartCuttlefishDeviceBuildFunction(),
createStartIosSimulatorBuildFunction(),
@@ -110,10 +126,16 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createFinishIosSimulatorRecordingsBuildFunction(),
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
createStartWebPreviewRemoteSessionBuildFunction(ctx),
createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true }),
createStartServeSimMetricsBuildFunction(),
createStartServeSimLogsBuildFunction(),
createCollectServeSimLogsBuildFunction(ctx),
createCollectAndroidEmulatorLogsBuildFunction(ctx),
createStartSandboxBuildFunction(ctx),
createRunAgentBuildFunction(ctx),
createCollectServeSimMetricsBuildFunction(ctx),
createInstallMaestroBuildFunction(),
createInstallMitmproxyBuildFunction(),
createInstallPodsBuildFunction(),
createSendSlackMessageFunction(),
@@ -127,7 +149,9 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
calculateEASUpdateRuntimeVersionFunction(),
createSubmissionEntityFunction(),
createSubmitToGooglePlayBuildFunction(),
createUploadToAscBuildFunction(),
createUpdateTestFlightMetadataBuildFunction(),
createReportMaestroTestResultsFunction(ctx),
createMaestroTestsBuildFunction(ctx),
@@ -137,6 +161,8 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
functions.push(
...[
createFindAndUploadBuildArtifactsBuildFunction(ctx),
createUpdateResolvedVersionBuildFunction(ctx),
createUploadEmbeddedBundleBuildFunction(ctx),
createResolveBuildConfigBuildFunction(ctx),
createGetCredentialsForBuildTriggeredByGithubIntegration(ctx),
]
@@ -130,6 +130,35 @@ describe(createEasBuildBuildFunctionGroup, () => {
);
});
it.each([
['Android without credentials', { platform: Platform.ANDROID }],
['Android with credentials', { platform: Platform.ANDROID, buildCredentials: { test: {} } }],
['iOS simulator', { platform: Platform.IOS, simulator: true }],
['iOS with credentials', { platform: Platform.IOS, buildCredentials: { test: {} } }],
])('wires the uploaded archive path into the update step (%s)', (_label, overrides) => {
const buildToolsContext = createMockBuildToolsContext(overrides);
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
const updateStep = steps.find(s => s.displayName === 'Update resolved version');
const archivePathInput = updateStep?.inputs?.find(
input => input.id === 'application_archive_path'
);
expect(updateStep).toBeDefined();
expect(archivePathInput?.rawValue).toBe(
'${ steps.find_and_upload_build_artifacts.application_archive_path }'
);
// The step ID must be assigned explicitly: an auto-generated ID makes the input
// interpolation throw "Step ... does not exist" before the function body runs.
const uploadStepIndex = steps.findIndex(s => s.id === 'find_and_upload_build_artifacts');
expect(uploadStepIndex).toBeGreaterThan(-1);
expect(steps[uploadStepIndex].displayName).toBe('Find and upload build artifacts');
expect(uploadStepIndex).toBeLessThan(steps.indexOf(updateStep!));
});
it('composes working directory with installPods step-level ./ios dir (iOS)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
@@ -219,4 +248,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';
@@ -18,6 +19,7 @@ import { configureIosCredentialsFunction } from '../functions/configureIosCreden
import { configureIosVersionFunction } from '../functions/configureIosVersion';
import { eagerBundleBuildFunction } from '../functions/eagerBundle';
import { createFindAndUploadBuildArtifactsBuildFunction } from '../functions/findAndUploadBuildArtifacts';
import { createUpdateResolvedVersionBuildFunction } from '../functions/updateResolvedVersion';
import { generateGymfileFromTemplateFunction } from '../functions/generateGymfileFromTemplate';
import { injectAndroidCredentialsFunction } from '../functions/injectAndroidCredentials';
import { createInstallNodeModulesBuildFunction } from '../functions/installNodeModules';
@@ -32,6 +34,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 {
@@ -181,7 +184,20 @@ function createStepsForIosSimulatorBuild({
runFastlane,
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
).createBuildStepFromFunctionCall(globalCtx, {
id: 'find_and_upload_build_artifacts',
workingDirectory,
}),
createUpdateResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
application_archive_path:
'${ steps.find_and_upload_build_artifacts.application_archive_path }',
},
}
),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
@@ -302,7 +318,33 @@ function createStepsForIosBuildWithCredentials({
runFastlane,
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
).createBuildStepFromFunctionCall(globalCtx, {
id: 'find_and_upload_build_artifacts',
workingDirectory,
}),
...(shouldUploadEmbeddedBundle({
env: globalCtx.env,
metadata: globalCtx.staticContext.metadata,
})
? [
createUploadEmbeddedBundleBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: { ignore_error: true },
}),
]
: []),
createUpdateResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
application_archive_path:
'${ steps.find_and_upload_build_artifacts.application_archive_path }',
},
}
),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
@@ -389,7 +431,33 @@ function createStepsForAndroidBuildWithoutCredentials({
runGradle,
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
).createBuildStepFromFunctionCall(globalCtx, {
id: 'find_and_upload_build_artifacts',
workingDirectory,
}),
...(shouldUploadEmbeddedBundle({
env: globalCtx.env,
metadata: globalCtx.staticContext.metadata,
})
? [
createUploadEmbeddedBundleBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: { ignore_error: true },
}),
]
: []),
createUpdateResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
application_archive_path:
'${ steps.find_and_upload_build_artifacts.application_archive_path }',
},
}
),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
@@ -482,7 +550,33 @@ function createStepsForAndroidBuildWithCredentials({
: []),
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
).createBuildStepFromFunctionCall(globalCtx, {
id: 'find_and_upload_build_artifacts',
workingDirectory,
}),
...(shouldUploadEmbeddedBundle({
env: globalCtx.env,
metadata: globalCtx.staticContext.metadata,
})
? [
createUploadEmbeddedBundleBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: { ignore_error: true },
}),
]
: []),
createUpdateResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
application_archive_path:
'${ steps.find_and_upload_build_artifacts.application_archive_path }',
},
}
),
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',
@@ -0,0 +1,48 @@
import { BuildRuntimePlatform } from '@expo/steps';
import {
createCustomBuildContextMock,
createGlobalContextMock,
} from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { Sentry } from '../../../sentry';
import { collectAndroidEmulatorLogsAsync } from '../../utils/androidEmulatorLogs';
import { createCollectAndroidEmulatorLogsBuildFunction } from '../collectAndroidEmulatorLogs';
jest.mock('../../../sentry');
jest.mock('../../utils/androidEmulatorLogs');
function createStep(callInputs: Record<string, unknown> = {}) {
const logger = createMockLogger();
const ctx = createCustomBuildContextMock();
const global = createGlobalContextMock({ logger });
const fn = createCollectAndroidEmulatorLogsBuildFunction(ctx);
const step = fn.createBuildStepFromFunctionCall(global, {
callInputs,
env: { DEVICE_RUN_SESSION_ID: 'session' },
});
return { ctx, global, logger, fn, step };
}
it('collects captured logs without a second source selection', async () => {
const { ctx, global, fn, step } = createStep();
expect(fn.supportedRuntimePlatforms).toEqual([BuildRuntimePlatform.LINUX]);
await step.executeAsync();
expect(collectAndroidEmulatorLogsAsync).toHaveBeenCalledWith(ctx, {
global,
deviceRunSessionId: 'session',
logger: expect.anything(),
});
});
it('reports collection failure without changing the session result', async () => {
jest
.mocked(collectAndroidEmulatorLogsAsync)
.mockRejectedValueOnce(new Error('collection failed'));
const { step } = createStep();
await expect(step.executeAsync()).resolves.toBeUndefined();
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not collect Android emulator logs',
expect.any(Error)
);
});
@@ -0,0 +1,441 @@
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import type { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
import { turtleFetch } from '../../../utils/turtleFetch';
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import {
spawnDetached,
startNgrokTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
jest.mock('@expo/turtle-spawn');
jest.mock('../../../sentry');
jest.mock('../../../utils/turtleFetch');
jest.mock('../../../utils/AndroidEmulatorUtils');
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/agentDeviceEvents');
jest.mock('../../utils/appiumEvents');
jest.mock('../../utils/agentDeviceArtifacts');
// The agent-device session step boots the emulator itself; this test covers the session host.
jest.mock('../startAndroidEmulator', () => ({
startAndroidEmulatorAsync: jest.fn().mockResolvedValue({
serialId: 'emulator-5554',
emulatorPromise: Promise.resolve(),
shouldAdjustAnimationScale: true,
}),
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
spawnDetached: jest.fn(),
startNgrokTunnelAsync: jest.fn(),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
waitForFileAsync: jest.fn(),
}));
// Agent-device also polls for Stop while it starts up. That watch sets neither an idle timeout nor
// a duration; keep it pending until startup retires it, and give each lifetime wait its own mock.
const lifetimeWait = jest.fn<
ReturnType<typeof waitForDeviceRunSessionStoppedAsync>,
Parameters<typeof waitForDeviceRunSessionStoppedAsync>
>();
function routeStartupWatch(): void {
lifetimeWait.mockReset();
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async args => {
if ('idleTimeout' in args || 'maxDurationSeconds' in args) {
return await lifetimeWait(args);
}
if (!args.signal?.aborted) {
await new Promise<void>(resolve =>
args.signal?.addEventListener('abort', () => resolve(), { once: true })
);
}
});
}
const finishHost = jest.fn();
const closePreview = jest.fn();
const stopTool = jest.fn();
const stopTunnel = jest.fn();
const stopEvents = jest.fn();
const openPreview = jest.fn();
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockResolvedValue();
routeStartupWatch();
// The daemon package is external. No installation or daemon process runs in these tests.
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
// Only the emulator logcat folder: the Appium step writes into its own temp folder.
const mkdtemp = fs.promises.mkdtemp.bind(fs.promises);
jest
.spyOn(fs.promises, 'mkdtemp')
.mockImplementation(async prefix =>
String(prefix).includes('eas-android-emulator-logcat-')
? '/tmp/eas-android-emulator-logcat-test'
: mkdtemp(prefix)
);
jest.mocked(spawn).mockResolvedValue({ stdout: '{}' } as never);
jest
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
.mockResolvedValue([{ serialId: 'emulator-5554', state: 'device' } as never]);
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 4567, token: 'daemon-token' });
for (const stop of [finishHost, closePreview, stopTool, stopTunnel, stopEvents]) {
stop.mockResolvedValue(undefined);
}
openPreview.mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://preview.example.test',
closeAsync: closePreview,
});
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: openPreview,
getLastPreviewActivityAt: jest.fn(),
finishAsync: finishHost,
});
jest.mocked(spawnDetached).mockReturnValue({
pid: undefined,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: stopTool,
});
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
url: 'https://tool.example.test',
subdomainId: 'tool-id',
stopAsync: stopTunnel,
});
const events = { stopAsync: stopEvents, getLastEventObservedAt: () => undefined };
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue(events);
jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue(events);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
lifetimeWait.mockResolvedValue(undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe.each([
['Appium', createStartAppiumRemoteSessionBuildFunction],
['Agent Device', createStartAgentDeviceSessionBuildFunction],
] as const)('%s host ownership', (name, createFunction) => {
async function runAsync(
publicPreview?: boolean,
frameAncestors?: string[],
corsOrigins?: string[]
) {
const fn = createFunction({} as CustomBuildContext);
const global = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX });
const defaults = new Map(
fn.inputProviders?.map(provider => {
const input = provider(global, 'Test session');
return [input.id, input.defaultValue];
})
);
await fn.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
// Match step execution: omitted inputs use their declared defaults.
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: defaults.get(id) },
}
),
outputs: {},
env: {
DEVICE_RUN_SESSION_ID: 'session-id',
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
NGROK_AUTHTOKEN: 'token',
},
} as never
);
}
it('holds the host until session completion', async () => {
await runAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ preview: { baseDomain: 'example.test', authtoken: 'token' } })
);
expect(openPreview).toHaveBeenCalledWith();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewApiUrl: 'https://preview.example.test',
}),
})
);
expect(finishHost).toHaveBeenCalledTimes(1);
expect(closePreview).not.toHaveBeenCalled();
expect(finishHost.mock.invocationCallOrder[0]).toBeGreaterThan(
lifetimeWait.mock.invocationCallOrder[0]
);
expect(stopTool).toHaveBeenCalledTimes(1);
expect(stopEvents).toHaveBeenCalledTimes(1);
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;
const pendingFinish = new Promise<void>(resolve => {
release = resolve;
});
const toolStopped = new Promise<void>(resolve => {
stopped = resolve;
});
finishHost.mockReturnValueOnce(pendingFinish);
stopTool.mockImplementationOnce(async () => {
stopped();
});
let completed = false;
const running = runAsync().then(() => {
completed = true;
});
try {
await toolStopped;
expect(stopTunnel).toHaveBeenCalledTimes(1);
expect(finishHost).toHaveBeenCalledTimes(1);
expect(completed).toBe(false);
} finally {
release();
await running;
}
});
it('finalizes recording and stops automation while the tunnel close is pending', async () => {
let release!: () => void;
let finished!: () => void;
const pendingClose = new Promise<void>(resolve => {
release = resolve;
});
const hostFinished = new Promise<void>(resolve => {
finished = resolve;
});
const toolStopped = new Promise<void>(resolve => {
stopTool.mockImplementationOnce(async () => resolve());
});
stopTunnel.mockReturnValueOnce(pendingClose);
finishHost.mockImplementationOnce(async () => {
finished();
});
const running = runAsync();
try {
// Both settle while the tunnel close is still held open.
await Promise.all([hostFinished, toolStopped]);
expect(stopTunnel).toHaveBeenCalledTimes(1);
} finally {
release();
await running;
}
});
it('still stops the automation process when event collection stop rejects', async () => {
stopEvents.mockRejectedValueOnce(new Error('collector failed'));
// Agent Device wraps its collector stop in a catch-all helper; Appium does not.
const outcome = await runAsync().then(
() => 'resolved',
(err: Error) => err.message
);
expect(outcome).toBe(name === 'Appium' ? 'collector failed' : 'resolved');
expect(stopTool).toHaveBeenCalledTimes(1);
expect(stopTunnel).toHaveBeenCalledTimes(1);
expect(finishHost).toHaveBeenCalledTimes(1);
});
it('still stops automation when recording finalization rejects', async () => {
finishHost.mockRejectedValueOnce(new Error('recording cleanup failed'));
await expect(runAsync()).rejects.toThrow('recording cleanup failed');
expect(stopTool).toHaveBeenCalledTimes(1);
expect(stopTunnel).toHaveBeenCalledTimes(1);
expect(Sentry.capture).not.toHaveBeenCalled();
});
it.each(['preview', 'config', 'wait'])('finishes the host after %s fails', async phase => {
const error = new Error(`${phase} failed`);
// A teardown failure must not replace the error that ended the session.
finishHost.mockRejectedValueOnce(new Error('recording cleanup failed'));
if (phase === 'preview') {
openPreview.mockRejectedValueOnce(error);
} else if (phase === 'config') {
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
} else {
lifetimeWait.mockRejectedValueOnce(error);
}
await expect(runAsync()).rejects.toBe(error);
expect(finishHost).toHaveBeenCalledTimes(1);
// An agent-device preview failure aborts the daemon task, which then may not start its
// daemon or tunnel. Everything that started must be stopped.
const abortsDaemonTask = name === 'Agent Device' && phase === 'preview';
expect(stopTool).toHaveBeenCalledTimes(
abortsDaemonTask ? jest.mocked(spawnDetached).mock.calls.length : 1
);
expect(stopTunnel).toHaveBeenCalledTimes(
abortsDaemonTask ? jest.mocked(startNgrokTunnelAsync).mock.calls.length : 1
);
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.objectContaining({ message: 'recording cleanup failed' }) },
'Could not stop the session host during remote session teardown.'
);
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not stop the session host after the remote session failed',
expect.objectContaining({ message: 'recording cleanup failed' }),
{ level: 'warning' }
);
});
});
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 markStarted!: () => void;
const started = new Promise<void>(resolve => {
markStarted = resolve;
});
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')) {
markStarted();
return install;
}
return command === 'npm' && appiumError
? Promise.reject(appiumError)
: Promise.resolve({ stdout: '{}' });
}) as never);
return { finish, started };
}
it.each(['installed', 'failed'] as const)(
'starts Appium after the ffmpeg install settles (%s)',
async outcome => {
const { finish, started } = holdFfmpegInstall();
const run = runAppiumAsync();
await started;
expect(spawnDetached).not.toHaveBeenCalled();
finish(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 { finish, started } = holdFfmpegInstall({ appiumError });
let settled = false;
const run = runAppiumAsync().finally(() => {
settled = true;
});
await started;
expect(settled).toBe(false);
finish('installed');
await expect(run).rejects.toBe(appiumError);
});
});
@@ -11,6 +11,8 @@ 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';
// contains a 'TestApp.app/TestApp' file with 'i am executable' content
@@ -29,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 }
: {
@@ -61,7 +69,7 @@ function createSuccessfulResponse({
url,
headers,
}: {
body: Buffer;
body: Buffer | Readable;
url: string;
headers?: Record<string, string>;
}): Response {
@@ -71,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,
@@ -79,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`);
@@ -107,7 +156,65 @@ async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
}
}
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
describe('downloadBuild', () => {
it.each([404, 410])('identifies the unavailable build on HTTP %s', async status => {
const buildId = randomUUID();
const { Response: ActualResponse } =
jest.requireActual<typeof import('node-fetch')>('node-fetch');
jest
.mocked(fetch)
.mockResolvedValue(new ActualResponse('<Error>NoSuchKey</Error>', { status }));
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient: createMockGraphqlClient({ applicationArchiveUrl: APPLICATION_ARCHIVE_URL }),
robotAccessToken: null,
extensions: ['app'],
})
).rejects.toMatchObject({
errorCode: 'EAS_DOWNLOAD_BUILD_ARCHIVE_UNAVAILABLE',
message:
`Failed to download the application archive for build ${buildId} (HTTP ${status}). ` +
'The archive is unavailable. Check that the archive still exists and try again.',
});
expect(fetch).toHaveBeenCalledTimes(1);
});
it.each([404, 410, 500])(
'identifies the application URL without credentials on HTTP %s',
async status => {
const { Response: ActualResponse } =
jest.requireActual<typeof import('node-fetch')>('node-fetch');
jest.mocked(fetch).mockResolvedValue(new ActualResponse('download failed', { status }));
const download = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl:
'https://user:[email protected]/app.tar.gz?token=secret#fragment',
graphqlClient: createMockGraphqlClient({}),
robotAccessToken: null,
extensions: ['app'],
});
const prefix = `Failed to download the archive at https://example.test/app.tar.gz (HTTP ${status})`;
await expect(download).rejects.toThrow(
status === 500
? `${prefix}: download failed`
: `${prefix}. The archive is unavailable. ` +
'Check that the URL is correct and the archive still exists, then try again.'
);
await expect(download).rejects.not.toThrow(/password|secret|fragment/);
expect(fetch).toHaveBeenCalledTimes(1);
}
);
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
@@ -255,6 +362,144 @@ describe('downloadBuild', () => {
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
});
it('passes the abort signal to the build lookup', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest
.mocked(fetch)
.mockResolvedValue(
createSuccessfulResponse({ body: APP_TAR_GZ_BUFFER, url: APPLICATION_ARCHIVE_URL })
);
const buildId = randomUUID();
await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
signal: new AbortController().signal,
});
expect(graphqlClient.query).toHaveBeenCalledWith(
expect.anything(),
{ buildId },
{ fetch: expect.any(Function) }
);
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ signal: expect.any(Object) })
);
});
it('stops a pending build lookup when aborted', async () => {
// The lookup request runs through the abort-aware fetch from graphqlAbortContext and
// hangs until its signal fires, like a stalled API call.
const realFetch = globalThis.fetch;
globalThis.fetch = jest.fn(
(_input: unknown, init?: RequestInit) =>
new Promise<never>((_resolve, reject) => {
init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason));
})
) as typeof globalThis.fetch;
const graphqlClient = {
query: jest.fn((_query, _variables, context: { fetch: typeof globalThis.fetch }) => ({
toPromise: () =>
context.fetch('https://api.expo.dev/graphql', {}).then(
() => ({ data: undefined }),
(networkError: Error) => ({ error: new CombinedError({ networkError }) })
),
})),
} as unknown as Client;
const controller = new AbortController();
const failure = new Error('daemon failed');
try {
const download = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
signal: controller.signal,
});
await new Promise(resolve => setImmediate(resolve));
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
controller.abort(failure);
await expect(download).rejects.toBe(failure);
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
} finally {
globalThis.fetch = realFetch;
}
});
it('lets a started extraction finish before it reports an abort', async () => {
jest
.mocked(fetch)
.mockResolvedValue(
createSuccessfulResponse({ body: APP_TAR_GZ_BUFFER, url: APPLICATION_ARCHIVE_URL })
);
const { decompressTarAsync } = jest.requireActual<typeof files>('../../../utils/files');
const controller = new AbortController();
const failure = new Error('daemon failed');
let extractionDirectory: string | undefined;
let extractionFinished = false;
const decompressSpy = jest.spyOn(files, 'decompressTarAsync').mockImplementation(async args => {
extractionDirectory = args.destinationDirectory;
// The startup fails while the archive is being extracted.
controller.abort(failure);
await decompressTarAsync(args);
extractionFinished = true;
});
try {
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient: createMockGraphqlClient({}),
robotAccessToken: null,
extensions: ['app'],
signal: controller.signal,
})
).rejects.toBe(failure);
// The download settled only after the extraction wrote its last file.
expect(extractionFinished).toBe(true);
expect(
await fs.promises.readFile(
path.join(extractionDirectory!, 'TestApp.app', 'TestApp'),
'utf8'
)
).toBe('i am executable\n');
} finally {
decompressSpy.mockRestore();
}
});
it('does not look up the build when already aborted', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
const failure = new Error('boot failed');
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
signal: AbortSignal.abort(failure),
})
).rejects.toBe(failure);
expect(graphqlClient.query).not.toHaveBeenCalled();
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('rejects missing or ambiguous build sources', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
@@ -326,13 +571,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' }),
@@ -341,19 +586,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 },
}),
});
@@ -365,12 +672,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();
});
@@ -395,10 +704,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,
@@ -424,6 +916,159 @@ describe('downloadBuild', () => {
});
describe('createDownloadBuildFunction', () => {
it('aborts a pending archive request when the step times out', async () => {
let requestAborted = false;
jest.mocked(fetch).mockImplementation(
async (_url, options) =>
await new Promise<Response>((_resolve, reject) => {
options?.signal?.addEventListener('abort', () => {
requestAborted = true;
reject(new Error('Archive request aborted'));
});
})
);
const graphqlClient = createMockGraphqlClient({});
const step = createDownloadBuildFunction({
graphqlClient,
} as any).createBuildStepFromFunctionCall(
createGlobalContextMock({ staticContextContent: { job: {} } }),
{
callInputs: { application_archive_url: APPLICATION_ARCHIVE_URL, extensions: ['ipa'] },
timeoutMs: 100,
}
);
await expect(step.executeAsync()).rejects.toThrow('timed out');
expect(fetch).toHaveBeenCalledTimes(1);
expect(requestAborted).toBe(true);
expect(graphqlClient.query).not.toHaveBeenCalled();
});
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);
});
@@ -51,6 +51,24 @@ describe(createFindAndUploadBuildArtifactsBuildFunction, () => {
await expect(buildStep.executeAsync()).resolves.not.toThrow();
});
it('sets application_archive_path output to first archive', async () => {
const globalContext = createGlobalContextMock({});
vol.fromJSON(
{
'ios/build/test.ipa': '',
},
globalContext.defaultWorkingDirectory
);
const buildStep = findAndUploadBuildArtifacts.createBuildStepFromFunctionCall(
globalContext,
{}
);
await buildStep.executeAsync();
expect(buildStep.outputById.application_archive_path.value).toMatch(/ios\/build\/test\.ipa$/);
});
it('throws build artifacts error', async () => {
const globalContext = createGlobalContextMock({});
ctx.job.buildArtifactPaths = ['worker.log'];
@@ -463,114 +463,127 @@ describe('createInstallMaestroBuildFunction', () => {
}
});
it('installs the prebuilt WDA cache for each available iOS runtime', async () => {
const homeDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'install-maestro-test-')
);
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
if (command === 'maestro-runner') {
return { stdout: 'maestro-runner 1.2.3\n' };
}
if (command === 'xcrun') {
return {
stdout: JSON.stringify({
runtimes: [
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
isAvailable: true,
version: '18.3.1',
},
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-0',
isAvailable: true,
version: '26.0',
},
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-17-5',
isAvailable: false,
version: '17.5',
},
],
}),
};
}
if (command === 'xcodebuild') {
return { stdout: 'Xcode 26.0\nBuild version 17A324\n' };
}
if (command === 'tar') {
const maestroRunnerHome = args[args.indexOf('-C') + 1];
const productsDirectory = path.join(
maestroRunnerHome,
'cache',
'wda-builds',
'generic',
'DerivedData',
'Build',
'Products'
);
await fs.promises.mkdir(productsDirectory, { recursive: true });
await fs.promises.writeFile(
path.join(productsDirectory, 'WebDriverAgentRunner.xctestrun'),
'cached'
);
}
return { stdout: '' };
}) as any);
try {
await writeInstalledWdaVersion(homeDirectory, '11.1.3');
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock({
runtimePlatform: BuildRuntimePlatform.DARWIN,
});
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com',
HOME: homeDirectory,
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner' },
});
await step.executeAsync();
expect(mockedDownloadFile).toHaveBeenCalledWith(
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.0-wda-11.1.3.tar.gz',
expect.stringMatching(/install_maestro_runner_wda_cache.*\/wda-cache\.tar\.gz$/),
{ retry: 3, timeout: 20_000 }
it.each(['1.1.27', '1.1.28', '1.2.3'])(
'prepares the WDA cache only for legacy Runner versions (%s)',
async version => {
const homeDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'install-maestro-test-')
);
for (const runtimeVersion of ['18.3', '26.0']) {
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
if (command === 'maestro-runner') {
return { stdout: `maestro-runner ${version}\n` };
}
if (command === 'xcrun') {
return {
stdout: JSON.stringify({
runtimes: [
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
isAvailable: true,
version: '18.3.1',
},
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-0',
isAvailable: true,
version: '26.0',
},
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-17-5',
isAvailable: false,
version: '17.5',
},
],
}),
};
}
if (command === 'xcodebuild') {
return { stdout: 'Xcode 26.0\nBuild version 17A324\n' };
}
if (command === 'tar') {
const maestroRunnerHome = args[args.indexOf('-C') + 1];
const productsDirectory = path.join(
maestroRunnerHome,
'cache',
'wda-builds',
'generic',
'DerivedData',
'Build',
'Products'
);
await fs.promises.mkdir(productsDirectory, { recursive: true });
await fs.promises.writeFile(
path.join(productsDirectory, 'WebDriverAgentRunner.xctestrun'),
'cached'
);
}
return { stdout: '' };
}) as any);
try {
await writeInstalledWdaVersion(homeDirectory, '11.1.3');
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock({
runtimePlatform: BuildRuntimePlatform.DARWIN,
});
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com',
HOME: homeDirectory,
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner' },
});
await step.executeAsync();
expect(step.getOutputValueByName('maestro_version')).toBe(version);
if (version !== '1.1.27') {
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(mockedSpawn.mock.calls.map(([command]) => command)).not.toContain('xcrun');
await expect(
fs.promises.access(path.join(homeDirectory, '.maestro-runner', 'cache'))
).rejects.toThrow();
return;
}
expect(mockedDownloadFile).toHaveBeenCalledWith(
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.0-wda-11.1.3.tar.gz',
expect.stringMatching(/install_maestro_runner_wda_cache.*\/wda-cache\.tar\.gz$/),
{ retry: 3, timeout: 20_000 }
);
for (const runtimeVersion of ['18.3', '26.0']) {
await expect(
fs.promises.readFile(
path.join(
homeDirectory,
'.maestro-runner',
'cache',
'wda-builds',
`sim-ios${runtimeVersion}-iphone`,
'DerivedData',
'Build',
'Products',
'WebDriverAgentRunner.xctestrun'
),
'utf8'
)
).resolves.toBe('cached');
}
await expect(
fs.promises.readFile(
path.join(
homeDirectory,
'.maestro-runner',
'cache',
'wda-builds',
`sim-ios${runtimeVersion}-iphone`,
'DerivedData',
'Build',
'Products',
'WebDriverAgentRunner.xctestrun'
),
'utf8'
fs.promises.access(
path.join(homeDirectory, '.maestro-runner', 'cache', 'wda-builds', 'sim-ios17.5-iphone')
)
).resolves.toBe('cached');
).rejects.toThrow();
} finally {
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
}
await expect(
fs.promises.access(
path.join(homeDirectory, '.maestro-runner', 'cache', 'wda-builds', 'sim-ios17.5-iphone')
)
).rejects.toThrow();
} finally {
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
}
});
);
it('does not download the WDA cache when the installed WDA version is unknown', async () => {
mockedSpawn.mockImplementation((async (command: string) => ({
stdout:
command === 'xcodebuild' ? 'Xcode 26.5\nBuild version 17F90\n' : 'maestro-runner 1.2.3\n',
command === 'xcodebuild' ? 'Xcode 26.5\nBuild version 17F90\n' : 'maestro-runner 1.1.27\n',
})) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock({
@@ -593,7 +606,7 @@ describe('createInstallMaestroBuildFunction', () => {
mockedSpawn.mockImplementation((async (command: string) => {
switch (command) {
case 'maestro-runner':
return { stdout: 'maestro-runner 1.2.3\n' };
return { stdout: 'maestro-runner 1.1.27\n' };
case 'xcodebuild':
return { stdout: 'Xcode 26.5\nBuild version 17F90\n' };
case 'xcrun':
@@ -633,7 +646,7 @@ describe('createInstallMaestroBuildFunction', () => {
});
await expect(step.executeAsync()).resolves.toBeUndefined();
expect(step.getOutputValueByName('maestro_version')).toBe('1.2.3');
expect(step.getOutputValueByName('maestro_version')).toBe('1.1.27');
expect(mockedDownloadFile.mock.calls.map(([url]) => url)).toEqual([
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.5-wda-11.1.3.tar.gz',
'https://storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.5-wda-11.1.3.tar.gz',
@@ -0,0 +1,151 @@
import { BuildRuntimePlatform, BuildStep } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { fs, vol } from 'memfs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createInstallMitmproxyBuildFunction } from '../installMitmproxy';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockedSpawn = jest.mocked(spawn);
// An x86 Homebrew prefix, to check that the app is found from PATH and not from process.arch.
const APP = '/usr/local/Caskroom/mitmproxy/12.2.3/mitmproxy.app';
const LINK = '/usr/local/bin/mitmdump';
function installCask(): void {
vol.fromJSON({ [`${APP}/Contents/MacOS/mitmdump`]: '', '/usr/local/bin': null });
fs.symlinkSync(`${APP}/Contents/MacOS/mitmdump`, LINK);
}
/**
* `which` finds mitmdump once the cask is installed, `brew` installs it, `xattr -p` reports
* quarantine while `stuck` is set, and each `mitmdump --version` takes the next result.
*/
function mockCommands({
installed = false,
stuck = false,
runs = [],
}: {
installed?: boolean;
stuck?: boolean;
runs?: boolean[];
}): void {
if (installed) {
installCask();
}
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
if (command === 'which' && !installed) {
throw new Error('not found');
}
if (command === 'which') {
return { stdout: `${LINK}\n` };
}
if (command === 'brew' && !installed) {
installed = true;
installCask();
}
if (command === 'xattr' && args[0] === '-p' && !stuck) {
throw new Error('No such xattr: com.apple.quarantine');
}
if (command === 'mitmdump' && !runs.shift()) {
throw new Error('not found');
}
return { stdout: '' };
}) as never);
}
function spawnedCommands(): string[] {
return mockedSpawn.mock.calls.map(([command, args]) =>
command === 'xattr' ? `xattr ${args?.[0]}` : command
);
}
describe('createInstallMitmproxyBuildFunction', () => {
beforeEach(() => {
jest.clearAllMocks();
});
function createStep(env: Record<string, string>): BuildStep {
const globalCtx = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.DARWIN });
globalCtx.updateEnv(env);
return createInstallMitmproxyBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: {},
});
}
it('takes the app behind mitmdump on PATH out of quarantine before running it', async () => {
mockCommands({ installed: true, runs: [true] });
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'xattr',
['-dr', 'com.apple.quarantine', APP],
expect.anything()
);
expect(spawnedCommands()).toEqual(['which', 'xattr -dr', 'xattr -p', 'mitmdump']);
});
it('kills a mitmdump --version that does not return', async () => {
mockCommands({ installed: true, runs: [true] });
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'mitmdump',
['--version'],
expect.objectContaining({ stdio: 'ignore', timeout: 60_000, killSignal: 'SIGKILL' })
);
});
it('does not run a mitmdump that stays quarantined', async () => {
mockCommands({ installed: true, stuck: true });
await expect(
createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync()
).resolves.toBeUndefined();
expect(spawnedCommands()).not.toContain('mitmdump');
});
it('does not change quarantine outside EAS Build VMs', async () => {
mockCommands({ installed: true, stuck: true, runs: [true] });
await createStep({}).executeAsync();
expect(spawnedCommands()).toEqual(['mitmdump']);
});
it('installs the cask with Homebrew and takes it out of quarantine', async () => {
mockCommands({ runs: [false, true] });
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'brew',
['install', '--cask', 'mitmproxy'],
expect.objectContaining({ env: expect.objectContaining({ HOMEBREW_NO_AUTO_UPDATE: '1' }) })
);
expect(spawnedCommands()).toEqual([
'which',
'mitmdump',
'brew',
'which',
'xattr -dr',
'xattr -p',
'mitmdump',
]);
});
it('does not fail the job when mitmdump is still not runnable', async () => {
mockCommands({ runs: [false, false] });
await expect(
createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync()
).resolves.toBeUndefined();
});
});
@@ -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',
],
@@ -15,6 +15,7 @@ import {
parseMaestroResults,
parseMaestroResultsFromFileAttrs,
parseMaestroRunnerReport,
parseMaestroRunnerResults,
} from '../maestroResultParser';
describe(parseFailedFlowsFromMaestroRunnerReport, () => {
@@ -483,6 +484,271 @@ describe(parseMaestroResultsFromFileAttrs, () => {
});
});
describe(parseMaestroRunnerResults, () => {
function runnerJUnit(testcases: { name: string; file: string; failed?: boolean }[]): string {
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<testsuites>',
' <testsuite name="maestro-runner">',
...testcases.flatMap(({ name, file, failed }) => [
` <testcase id="${name}" name="${name}" classname="${name}" time="1.000" status="${failed ? 'ERROR' : 'SUCCESS'}">`,
' <properties>',
` <property name="file" value="${file}"/>`,
' <property name="device.platform" value="android"/>',
' </properties>',
...(failed ? [' <failure message="Element not found">tapOn</failure>'] : []),
' </testcase>',
]),
' </testsuite>',
'</testsuites>',
].join('\n');
}
it('adds the tags of each flow from the report.json of its attempt', async () => {
vol.fromJSON({
'/junit/android-maestro-junit-attempt-0.xml': runnerJUnit([
{ name: 'sign-in', file: 'flows/auth/sign-in.yaml', failed: true },
{ name: 'settings', file: 'flows/settings.yaml' },
]),
'/tests/android-maestro-runner-attempt-0/report.json': JSON.stringify({
flows: [
{
name: 'sign-in',
sourceFile: 'flows/auth/sign-in.yaml',
status: 'failed',
tags: ['auth', 'smoke'],
},
{ name: 'settings', sourceFile: 'flows/settings.yaml', status: 'passed' },
],
}),
'/junit/android-maestro-junit-attempt-1.xml': runnerJUnit([
{ name: 'sign-in', file: 'flows/auth/sign-in.yaml' },
]),
'/tests/android-maestro-runner-attempt-1/report.json': JSON.stringify({
flows: [
{
name: 'sign-in',
sourceFile: 'flows/auth/sign-in.yaml',
status: 'passed',
tags: ['auth'],
},
],
}),
});
const results = await parseMaestroRunnerResults({
junitDirectory: '/junit',
testsDirectory: '/tests',
workingDirectory: '/project',
});
expect(results).toEqual([
{
name: 'sign-in',
path: 'flows/auth/sign-in.yaml',
status: 'failed',
errorMessage: 'Element not found',
duration: 1000,
retryCount: 0,
tags: ['auth', 'smoke'],
properties: { file: 'flows/auth/sign-in.yaml', 'device.platform': 'android' },
},
expect.objectContaining({ path: 'flows/auth/sign-in.yaml', retryCount: 1, tags: ['auth'] }),
expect.objectContaining({ path: 'flows/settings.yaml', retryCount: 0, tags: [] }),
]);
});
it('matches an absolute sourceFile with a JUnit path relative to the working directory', async () => {
vol.fromJSON({
'/junit/ios-maestro-junit-attempt-0.xml': runnerJUnit([
{ name: 'sign-in', file: 'flows/sign-in.yaml' },
]),
'/tests/ios-maestro-runner-attempt-0/report.json': JSON.stringify({
flows: [
{
name: 'sign-in',
sourceFile: '/project/flows/sign-in.yaml',
status: 'passed',
tags: ['auth'],
},
],
}),
});
const results = await parseMaestroRunnerResults({
junitDirectory: '/junit',
testsDirectory: '/tests',
workingDirectory: '/project',
});
expect(results).toEqual([
expect.objectContaining({ path: 'flows/sign-in.yaml', tags: ['auth'] }),
]);
});
it('matches a sourceFile under a symlinked directory with the JUnit path of its target', async () => {
vol.fromJSON({
'/project/flows/sign-in.yaml': '',
'/junit/ios-maestro-junit-attempt-0.xml': runnerJUnit([
{ name: 'sign-in', file: 'flows/sign-in.yaml' },
]),
'/tests/ios-maestro-runner-attempt-0/report.json': JSON.stringify({
flows: [
{ name: 'sign-in', sourceFile: 'linked/sign-in.yaml', status: 'passed', tags: ['auth'] },
],
}),
});
vol.symlinkSync('/project/flows', '/project/linked');
const results = await parseMaestroRunnerResults({
junitDirectory: '/junit',
testsDirectory: '/tests',
workingDirectory: '/project',
});
expect(results).toEqual([
expect.objectContaining({ path: 'flows/sign-in.yaml', tags: ['auth'] }),
]);
});
it('matches a sourceFile whose symlink leaves the working directory by the path as given', async () => {
vol.fromJSON({
'/project/.keep': '',
'/shared/sign-in.yaml': '',
'/junit/ios-maestro-junit-attempt-0.xml': runnerJUnit([
{ name: 'sign-in', file: 'linked/sign-in.yaml' },
]),
'/tests/ios-maestro-runner-attempt-0/report.json': JSON.stringify({
flows: [
{ name: 'sign-in', sourceFile: 'linked/sign-in.yaml', status: 'passed', tags: ['auth'] },
],
}),
});
vol.symlinkSync('/shared', '/project/linked');
const results = await parseMaestroRunnerResults({
junitDirectory: '/junit',
testsDirectory: '/tests',
workingDirectory: '/project',
});
expect(results).toEqual([
expect.objectContaining({ path: 'linked/sign-in.yaml', tags: ['auth'] }),
]);
});
it('does not follow a symlink from the bare file name that maestro-runner <= 1.1.17 writes', async () => {
vol.fromJSON({
'/project/flows/sign-in.yaml': '',
'/project/settings.yaml': '',
'/junit/ios-maestro-junit-attempt-0.xml': runnerJUnit([
{ name: 'sign-in', file: 'sign-in.yaml' },
{ name: 'settings', file: 'settings.yaml' },
]),
'/tests/ios-maestro-runner-attempt-0/report.json': JSON.stringify({
flows: [
{ name: 'sign-in', sourceFile: 'flows/sign-in.yaml', status: 'passed', tags: ['auth'] },
{ name: 'settings', sourceFile: 'settings.yaml', status: 'passed', tags: ['settings'] },
],
}),
});
vol.symlinkSync('/project/settings.yaml', '/project/sign-in.yaml');
const results = await parseMaestroRunnerResults({
junitDirectory: '/junit',
testsDirectory: '/tests',
workingDirectory: '/project',
});
expect(results).toEqual([
expect.objectContaining({ path: 'sign-in.yaml', tags: ['auth'] }),
expect.objectContaining({ path: 'settings.yaml', tags: ['settings'] }),
]);
});
it('matches by file name when JUnit has only the file name and it is unique', async () => {
vol.fromJSON({
'/junit/ios-maestro-junit-attempt-0.xml': runnerJUnit([
{ name: 'sign-in', file: 'sign-in.yaml' },
]),
'/tests/ios-maestro-runner-attempt-0/report.json': JSON.stringify({
flows: [
{
name: 'sign-in',
sourceFile: 'flows/auth/sign-in.yaml',
status: 'passed',
tags: ['auth'],
},
{
name: 'settings',
sourceFile: 'flows/settings.yaml',
status: 'passed',
tags: ['settings'],
},
],
}),
});
const results = await parseMaestroRunnerResults({
junitDirectory: '/junit',
testsDirectory: '/tests',
workingDirectory: '/project',
});
expect(results).toEqual([expect.objectContaining({ path: 'sign-in.yaml', tags: ['auth'] })]);
});
it('adds no tags when two flows of the attempt have the same file name', async () => {
vol.fromJSON({
'/junit/ios-maestro-junit-attempt-0.xml': runnerJUnit([
{ name: 'sign-in', file: 'sign-in.yaml' },
]),
'/tests/ios-maestro-runner-attempt-0/report.json': JSON.stringify({
flows: [
{ name: 'a', sourceFile: 'flows/a/sign-in.yaml', status: 'passed', tags: ['a'] },
{ name: 'b', sourceFile: 'flows/b/sign-in.yaml', status: 'passed', tags: ['b'] },
],
}),
});
const results = await parseMaestroRunnerResults({
junitDirectory: '/junit',
testsDirectory: '/tests',
workingDirectory: '/project',
});
expect(results).toEqual([expect.objectContaining({ path: 'sign-in.yaml', tags: [] })]);
});
it.each([
{ name: 'is not valid JSON', content: '{' },
{ name: 'has an unknown shape', content: JSON.stringify({ flows: [{ name: 'sign-in' }] }) },
{
name: 'does not have the flow',
content: JSON.stringify({
flows: [{ name: 'other', sourceFile: 'flows/other.yaml', status: 'passed', tags: ['x'] }],
}),
},
])('keeps the JUnit result without tags when report.json $name', async ({ content }) => {
vol.fromJSON({
'/junit/android-maestro-junit-attempt-0.xml': runnerJUnit([
{ name: 'sign-in', file: 'flows/sign-in.yaml' },
]),
'/tests/android-maestro-runner-attempt-0/report.json': content,
});
const results = await parseMaestroRunnerResults({
junitDirectory: '/junit',
testsDirectory: '/tests',
workingDirectory: '/project',
});
expect(results).toEqual([
expect.objectContaining({ name: 'sign-in', path: 'flows/sign-in.yaml', tags: [] }),
]);
});
});
describe('junitFileHasFileAttrs', () => {
it('returns true when every testcase has a non-empty file= attribute', async () => {
vol.fromJSON({
@@ -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';
@@ -271,20 +271,123 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockedSpawn.mock.calls[0][1]).toContain('--parallel=2');
});
it('rejects a non-junit output_format with maestro-runner', async () => {
it.each(['html', 'allure'])(
'accepts %s output_format with maestro-runner and keeps JUnit',
async format => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const copyFileSpy = jest.spyOn(fs, 'copyFile').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
backend: 'maestro-runner',
output_format: format,
});
await step.executeAsync();
expect(mockedSpawn.mock.calls[0][1]).toEqual(
expect.arrayContaining([
'--output=/home/expo/.maestro/tests/android-maestro-runner-attempt-0',
])
);
expect(copyFileSpy).toHaveBeenCalledWith(
'/home/expo/.maestro/tests/android-maestro-runner-attempt-0/junit-report.xml',
'/home/expo/.maestro/tests/junit-reports/android-maestro-junit-attempt-0.xml'
);
expect(parser.mergeJUnitReports).not.toHaveBeenCalled();
expect(step.getOutputValueByName('final_report_path')).toBe(
format === 'html'
? '/home/expo/.maestro/tests/android-maestro-runner-attempt-0/report.html'
: '/home/expo/.maestro/tests/android-maestro-runner-attempt-0/allure-results'
);
expect(mockedRunnerHarvest).not.toHaveBeenCalled();
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
name:
format === 'html' ? 'Maestro Runner HTML Report' : 'Maestro Runner Allure Results',
paths: [
format === 'html'
? '/home/expo/.maestro/tests/android-maestro-runner-attempt-0'
: '/home/expo/.maestro/tests/android-maestro-runner-attempt-0/allure-results',
],
}),
})
);
}
);
it('does not expose a selected runner report before an attempt starts', async () => {
const step = createStep({
flow_path: ['flows/a.yaml'],
flow_path: [],
platform: 'android',
backend: 'maestro-runner',
output_format: 'html',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(step.getOutputValueByName('final_report_path')).toBeUndefined();
expect(step.getOutputValueByName('junit_report_directory')).toBe(
'/home/expo/.maestro/tests/junit-reports'
);
});
it('rejects an unknown output_format with maestro-runner', async () => {
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
backend: 'maestro-runner',
output_format: 'csv',
});
await expect(step.executeAsync()).rejects.toThrow(
'maestro-runner only supports the "junit" output_format'
'maestro-runner supports "junit", "html", and "allure" output_format values'
);
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('uploads the last runner HTML report even when the test fails', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
jest.spyOn(fs, 'copyFile').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
backend: 'maestro-runner',
output_format: 'html',
retries: 1,
retry_failed_only: false,
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
name: 'Maestro Runner HTML Report',
paths: ['/home/expo/.maestro/tests/android-maestro-runner-attempt-1'],
}),
})
);
expect(step.getOutputValueByName('final_report_path')).toBe(
'/home/expo/.maestro/tests/android-maestro-runner-attempt-1/report.html'
);
});
it('does not fail a passing test when the runner report upload fails', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest.spyOn(fs, 'copyFile').mockResolvedValue();
mockUploadArtifact.mockRejectedValue(new Error('upload failed'));
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
backend: 'maestro-runner',
output_format: 'allure',
});
await expect(step.executeAsync()).resolves.toBeUndefined();
});
it('logs that maestro-runner does not support direct DADB', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest.spyOn(fs, 'copyFile').mockResolvedValue();
@@ -966,7 +1069,7 @@ describe('createMaestroTestsBuildFunction', () => {
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('uses $HOME/.maestro/tests output path for non-junit formats (e.g. html)', async () => {
it('uses $HOME/.maestro/tests output path and uploads the Maestro CLI HTML report', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
@@ -977,11 +1080,118 @@ describe('createMaestroTestsBuildFunction', () => {
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests so the
// whole-directory upload picks it up.
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests.
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(/\.maestro\/tests\/android-maestro-html\.html$/);
expect(outputArg).not.toMatch(/junit-reports/);
expect(step.getOutputValueByName('final_report_path')).toBe(
'/home/expo/.maestro/tests/android-maestro-html.html'
);
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
name: 'Maestro HTML Report',
paths: ['/home/expo/.maestro/tests/android-maestro-html.html'],
}),
})
);
});
it('uploads the Maestro CLI HTML report even when tests fail', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'html',
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
name: 'Maestro HTML Report',
paths: ['/home/expo/.maestro/tests/android-maestro-html.html'],
}),
})
);
});
it('warns that Maestro CLI does not support Allure reports', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
const logger = createMockLogger();
jest.mocked(logger.child).mockReturnValue(logger);
const step = createStep(
{
flow_path: ['flows/a.yaml'],
output_format: 'allure',
platform: 'android',
},
{ logger }
);
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(logger.warn).toHaveBeenCalledWith(
'Maestro CLI does not support Allure reports; no Allure artifact was uploaded.'
);
expect(mockUploadArtifact).not.toHaveBeenCalled();
});
it('clears the Maestro CLI HTML report before every retry attempt', async () => {
const removeSpy = jest.spyOn(fs, 'rm').mockResolvedValue();
mockedSpawn.mockRejectedValue(rejectExit1());
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'html',
platform: 'android',
retries: 1,
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(
removeSpy.mock.calls.filter(
([target]) => target === '/home/expo/.maestro/tests/android-maestro-html.html'
)
).toHaveLength(2);
expect(removeSpy).toHaveBeenNthCalledWith(
1,
'/home/expo/.maestro/tests/android-maestro-html.html',
{
force: true,
}
);
expect(removeSpy).toHaveBeenNthCalledWith(
2,
'/home/expo/.maestro/tests/android-maestro-html.html',
{
force: true,
}
);
expect(removeSpy.mock.invocationCallOrder[0]).toBeLessThan(
mockedSpawn.mock.invocationCallOrder[0]
);
expect(removeSpy.mock.invocationCallOrder[1]).toBeLessThan(
mockedSpawn.mock.invocationCallOrder[1]
);
});
it('does not run Maestro CLI or upload old HTML if the report cannot be cleared', async () => {
jest.spyOn(fs, 'rm').mockImplementation(async target => {
if (target === '/home/expo/.maestro/tests/android-maestro-html.html') {
throw new Error('permission denied');
}
});
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'html',
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(SystemError);
expect(mockedSpawn).not.toHaveBeenCalled();
expect(mockUploadArtifact).not.toHaveBeenCalled();
});
it('uses lowercase extension for non-junit formats regardless of input casing', async () => {
@@ -1208,7 +1418,6 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name: shot.displayName,
metadata: shot.metadata,
}),
@@ -1216,28 +1425,31 @@ describe('createMaestroTestsBuildFunction', () => {
);
});
it('discovers maestro-runner flow results from the attempt report directory', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const shot = makeShot(0);
jest.spyOn(fs, 'copyFile').mockResolvedValue();
mockedRunnerHarvest.mockResolvedValue([shot]);
const parseFlowResultsSpy = jest.spyOn(parser, 'parseMaestroRunnerReport').mockResolvedValue({
flows: [{ name: 'Login', sourceFile: 'a.yaml', status: 'failed' }],
});
it.each(['junit', undefined])(
'uploads runner screenshots with output_format=%s',
async format => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const shot = makeShot(0);
jest.spyOn(fs, 'copyFile').mockResolvedValue();
mockedRunnerHarvest.mockResolvedValue([shot]);
const parseFlowResultsSpy = jest.spyOn(parser, 'parseMaestroRunnerReport').mockResolvedValue({
flows: [{ name: 'Login', sourceFile: 'a.yaml', status: 'failed' }],
});
const step = createStep({
flow_path: ['a.yaml'],
platform: 'ios',
output_format: 'junit',
backend: 'maestro-runner',
});
await step.executeAsync();
const step = createStep({
flow_path: ['a.yaml'],
platform: 'ios',
...(format === undefined ? {} : { output_format: format }),
backend: 'maestro-runner',
});
await step.executeAsync();
expect(parseFlowResultsSpy).toHaveBeenCalledWith(
'/home/expo/.maestro/tests/ios-maestro-runner-attempt-0'
);
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
});
expect(parseFlowResultsSpy).toHaveBeenCalledWith(
'/home/expo/.maestro/tests/ios-maestro-runner-attempt-0'
);
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
}
);
it('uploads screenshots even when all attempts fail, before throwing ERR_MAESTRO_TESTS_FAILED', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
@@ -1301,14 +1513,15 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockUploadArtifact).toHaveBeenCalledTimes(30);
});
it('does not harvest or upload screenshots when output_format is not junit', async () => {
it('does not harvest or upload screenshots for Maestro CLI HTML', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'html' });
await step.executeAsync();
expect(mockedHarvest).not.toHaveBeenCalled();
expect(mockUploadArtifact).not.toHaveBeenCalled();
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
expect(mockUploadArtifact.mock.calls[0][0].artifact.name).toBe('Maestro HTML Report');
});
it('uploads only the final attempt for a pure-failure flow, but every attempt for a flaky flow', async () => {
@@ -7,6 +7,8 @@ import { type CustomBuildContext } from '../../../customBuildContext';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { isProcessDescendantOfAsync } from '../../../utils/processes';
import { turtleFetch } from '../../../utils/turtleFetch';
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import {
monitorLocalEgressAsync,
readLocalEgressHandoffAsync,
@@ -14,11 +16,16 @@ import {
stopLocalEgressResourcesAsync,
} from '../../utils/localEgress';
import {
startDeviceWebPreviewWithTunnelAsync,
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';
@@ -27,7 +34,14 @@ jest.mock('@expo/turtle-spawn');
jest.mock('../../../utils/IosSimulatorUtils');
jest.mock('../../../utils/processes');
jest.mock('../../../utils/turtleFetch');
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/agentDeviceArtifacts');
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() }),
@@ -44,6 +58,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',
@@ -58,12 +79,35 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
subdomainId: 'controller-id',
stopAsync: jest.fn(),
}),
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
// Agent-device also polls for Stop while it starts up. That watch sets neither an idle timeout nor
// a duration; keep it pending until startup retires it, and give each lifetime wait its own mock.
const lifetimeWait = jest.fn<
ReturnType<typeof waitForDeviceRunSessionStoppedAsync>,
Parameters<typeof waitForDeviceRunSessionStoppedAsync>
>();
function routeStartupWatch(): void {
lifetimeWait.mockReset();
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async args => {
if ('idleTimeout' in args || 'maxDurationSeconds' in args) {
return await lifetimeWait(args);
}
if (!args.signal?.aborted) {
await new Promise<void>(resolve =>
args.signal?.addEventListener('abort', () => resolve(), { once: true })
);
}
});
}
const logger = {
info: jest.fn(),
warn: jest.fn(),
child: jest.fn().mockReturnThis(),
} as unknown as bunyan;
const ctx = {} as CustomBuildContext;
const env = { DEVICE_RUN_SESSION_ID: 'session-id' };
const handoff = { url: 'https://egress.test', token: 'secret', fingerprint: 'key=', port: 8899 };
@@ -74,7 +118,7 @@ const fields = {
egressPort: handoff.port,
};
const controllers = [
['agent-device', createStartAgentDeviceRemoteSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
['agent-device', createStartAgentDeviceSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
['Appium', createStartAppiumRemoteSessionBuildFunction, 'appiumUrl'],
['Argent', createStartArgentRemoteSessionBuildFunction, 'toolsUrl'],
['web preview', createStartWebPreviewRemoteSessionBuildFunction, 'previewUrl'],
@@ -82,16 +126,17 @@ const controllers = [
describe.each(controllers)('%s local egress', (_name, createFunction, controllerField) => {
const stopPreview = jest.fn();
const openPreview = jest.fn();
const stopEgress = jest.fn();
let lifetime: AbortSignal;
async function run(signal?: AbortSignal): Promise<void> {
await createFunction(ctx).fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
// Inputs a step does not get default to undefined, like in a real step call.
inputs: new Proxy({} as Record<string, { value: unknown }>, {
get: (target, id: string) => target[id] ?? { value: undefined },
}),
outputs: {},
env,
signal,
@@ -100,6 +145,8 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
}
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockResolvedValue();
routeStartupWatch();
lifetime = registerLocalEgressResources(stopEgress);
stopEgress.mockResolvedValue(undefined);
stopPreview.mockResolvedValue(undefined);
@@ -115,18 +162,34 @@ 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);
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
openPreview.mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://preview.test',
previewToken: 'preview-secret',
stopAsync: stopPreview,
closeAsync: jest.fn(),
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: openPreview,
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopPreview,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
lifetimeWait.mockImplementation(async () => {
expect(lifetime.aborted).toBe(false);
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(1);
});
@@ -136,18 +199,30 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
jest.restoreAllMocks();
});
it('releases resources without monitoring when the session stopped during startup', async () => {
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValueOnce('stopped');
await run();
expect(lifetimeWait).not.toHaveBeenCalled();
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
expect(stopPreview).toHaveBeenCalledTimes(1);
expect(stopEgress).toHaveBeenCalledTimes(1);
expect(lifetime.aborted).toBe(true);
});
it('reports credentials and starts monitoring before waiting, then releases resources', async () => {
await run();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
ctx,
deviceRunSessionId: 'session-id',
logger,
remoteConfig: expect.objectContaining({
...fields,
[controllerField]: expect.any(String),
[controllerField === 'previewUrl' ? 'previewToken' : 'webPreviewToken']: 'preview-secret',
}),
});
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
ctx,
deviceRunSessionId: 'session-id',
logger,
remoteConfig: expect.objectContaining({
...fields,
[controllerField]: expect.any(String),
[controllerField === 'previewUrl' ? 'previewToken' : 'webPreviewToken']: 'preview-secret',
}),
})
);
expect(monitorLocalEgressAsync).toHaveBeenCalledWith({
port: handoff.port,
env,
@@ -161,7 +236,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
await stopLocalEgressResourcesAsync(logger);
stopEgress.mockClear();
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(null);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
lifetimeWait.mockResolvedValue(undefined);
await run();
const { remoteConfig } = jest.mocked(uploadRemoteSessionConfigAsync).mock.calls[0][0];
expect(remoteConfig).toHaveProperty(controllerField);
@@ -169,16 +244,18 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
expect(stopEgress).not.toHaveBeenCalled();
});
it.each(['startup', 'report', 'wait', 'teardown'])(
it.each(['startup', 'preview', 'report', 'wait', 'teardown'])(
'releases egress after %s fails',
async phase => {
const error = new Error(`${phase} failed`);
if (phase === 'startup') {
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockRejectedValue(error);
jest.mocked(startDeviceSessionHostAsync).mockRejectedValue(error);
} else if (phase === 'preview') {
openPreview.mockRejectedValue(error);
} else if (phase === 'report') {
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValue(error);
} else if (phase === 'wait') {
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValue(error);
lifetimeWait.mockRejectedValue(error);
} else {
stopPreview.mockRejectedValue(error);
}
@@ -186,17 +263,16 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
expect(lifetime.aborted).toBe(true);
expect(stopEgress).toHaveBeenCalledTimes(1);
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(
phase === 'startup' || phase === 'report' ? 0 : 1
phase === 'startup' || phase === 'preview' || phase === 'report' ? 0 : 1
);
}
);
it('passes cancellation to the monitor and releases the lifetime', async () => {
const abort = new AbortController();
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => abort.abort());
lifetimeWait.mockImplementation(async () => abort.abort());
await run(abort.signal);
expect(monitorLocalEgressAsync).toHaveBeenCalledWith(
expect.objectContaining({ signal: abort.signal })
);
// Agent-device hands the monitor its startup signal, which follows the step signal.
expect(jest.mocked(monitorLocalEgressAsync).mock.calls[0][0].signal?.aborted).toBe(true);
expect(lifetime.aborted).toBe(true);
expect(stopEgress).toHaveBeenCalledTimes(1);
});
@@ -1,8 +1,10 @@
import { type bunyan } from '@expo/logger';
import turtleSpawnAsync from '@expo/turtle-spawn';
import assert from 'node:assert';
import fg from 'fast-glob';
import { vol } from 'memfs';
import path from 'node:path';
import resolveFrom from 'resolve-from';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createTestAndroidJob, createTestIosJob } from '../../../__tests__/utils/job';
@@ -17,17 +19,100 @@ import {
} from '../repack';
jest.mock('fs');
jest.mock('@expo/spawn-async');
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('@expo/repack-app');
jest.mock('resolve-from', () => jest.fn((_, moduleName) => moduleName));
jest.mock('resolve-from', () => jest.fn(() => '@expo/repack-app'));
jest.mock('../../utils/ios/credentials/manager');
describe(createRepackBuildFunction, () => {
beforeEach(() => {
jest.mocked(turtleSpawnAsync).mockImplementation((async (_command: string, args: string[]) => {
const sandbox = args[args.indexOf('--prefix') + 1];
const packageName = args[args.length - 1].replace(/@[^@/]*$/, '');
vol.fromJSON({
[path.join(sandbox, 'node_modules', packageName, 'package.json')]: JSON.stringify({
version: '2.0.0-next.1',
}),
});
}) as unknown as typeof turtleSpawnAsync);
});
afterEach(() => {
jest.restoreAllMocks();
vol.reset();
});
it('installs the repack package with npm in a sandbox whose env is only PATH and HOME, through the npm cache registry', async () => {
const globalCtx = createGlobalContextMock({
staticContextContent: {
job: {},
},
});
globalCtx.updateEnv({
PATH: '/usr/local/bin:/usr/bin',
EAS_BUILD_NPM_CACHE_URL: 'https://npm-cache.expo.test',
NPM_TOKEN: 'secret',
});
const repack = createRepackBuildFunction();
const repackStep = repack.createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
platform: 'ios',
source_app_path: '/path/to/source_app.ipa',
repack_package: '@scope/fork',
repack_version: 'next',
},
});
const logInfo = jest.spyOn(repackStep.ctx.logger, 'info');
await repackStep.executeAsync();
const [installCall] = jest.mocked(turtleSpawnAsync).mock.calls;
const npmEnv = installCall[2]?.env;
assert(npmEnv);
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'PATH']);
expect(npmEnv.PATH).toMatch(/\/usr\/local\/bin:\/usr\/bin$/);
expect(installCall).toEqual([
'npm',
[
'install',
'--prefix',
npmEnv.HOME,
'--no-save',
'--package-lock=false',
'--no-audit',
'--no-fund',
'--no-update-notifier',
'--registry',
'https://npm-cache.expo.test',
'@scope/fork@next',
],
{ env: npmEnv, logger: repackStep.ctx.logger },
]);
expect(logInfo).toHaveBeenCalledWith('Using repack from: @scope/fork@next (2.0.0-next.1)');
expect(resolveFrom).toHaveBeenCalledWith(npmEnv.HOME, '@scope/fork');
});
it('installs the repack package from the default registry when the job has no npm cache', async () => {
const globalCtx = createGlobalContextMock({
staticContextContent: {
job: {},
},
});
globalCtx.updateEnv({ PATH: '/usr/local/bin:/usr/bin' });
const repack = createRepackBuildFunction();
const repackStep = repack.createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
platform: 'ios',
source_app_path: '/path/to/source_app.ipa',
},
});
await repackStep.executeAsync();
const [installCall] = jest.mocked(turtleSpawnAsync).mock.calls;
expect(installCall[1]).not.toContain('--registry');
});
it('should set generated output path for successful repack', async () => {
const repack = createRepackBuildFunction();
const repackStep = repack.createBuildStepFromFunctionCall(
@@ -358,6 +358,51 @@ describe(createReportMaestroTestResultsFunction, () => {
);
});
it('reports maestro-runner results with the tags from report.json', async () => {
vol.fromJSON({
'/junit/android-maestro-junit-attempt-0.xml': [
'<testsuites><testsuite name="maestro-runner">',
' <testcase id="sign-in" name="sign-in" classname="sign-in" time="2.000" status="SUCCESS">',
' <properties><property name="file" value="flows/sign-in.yaml"/></properties>',
' </testcase>',
'</testsuite></testsuites>',
].join('\n'),
'/tests/android-maestro-runner-attempt-0/report.json': JSON.stringify({
flows: [
{
name: 'sign-in',
sourceFile: 'flows/sign-in.yaml',
status: 'passed',
tags: ['auth', 'smoke'],
},
],
}),
});
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
},
},
});
await createStep().executeAsync();
const variables = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0][1];
expect(variables.input.testCaseResults).toEqual([
{
name: 'sign-in',
path: 'flows/sign-in.yaml',
status: 'PASSED',
errorMessage: null,
duration: 2000,
retryCount: 0,
tags: ['auth', 'smoke'],
properties: { file: 'flows/sign-in.yaml' },
},
]);
});
it('uses default directories when inputs are not provided', async () => {
vol.fromJSON({
'/home/expo/.maestro/tests/report.xml': JUNIT_PASS,
@@ -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', 'PATH']);
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".'
);
});
});
@@ -0,0 +1,70 @@
import { type bunyan } from '@expo/logger';
import { type BuildStepContext, type BuildStepEnv } from '@expo/steps';
import { type CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { getDeviceRunSessionIdOrThrow } from '../../utils/remoteDeviceRunSession';
import { uploadServeSimLogsFileAsync } from '../../utils/serveSimLogsArtifacts';
import { ServeSimLogsRecorder } from '../../utils/serveSimLogsRecorder';
import { createCollectServeSimLogsBuildFunction } from '../collectServeSimLogs';
import { createStartServeSimLogsBuildFunction } from '../startServeSimLogs';
jest.mock('../../../sentry');
jest.mock('../../utils/serveSimLogsRecorder');
jest.mock('../../utils/serveSimLogsArtifacts');
jest.mock('../../utils/remoteDeviceRunSession');
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const ctx = {} as CustomBuildContext;
const step = { logger } as BuildStepContext;
const args = { inputs: {}, outputs: {}, env: {} as BuildStepEnv };
beforeEach(() => {
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('session-id');
jest.mocked(ServeSimLogsRecorder.finishAsync).mockResolvedValue([]);
});
it('starts collection and tolerates startup failures', async () => {
jest.mocked(ServeSimLogsRecorder.startAsync).mockRejectedValueOnce(new Error('disk failure'));
await expect(createStartServeSimLogsBuildFunction().fn?.(step, args)).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not start serve-sim simulator logs',
expect.any(Error)
);
});
it('uploads each device with its own bounded signal', async () => {
jest.mocked(ServeSimLogsRecorder.finishAsync).mockResolvedValue([
{ udid: 'A', filePath: '/tmp/A.ndjson' },
{ udid: 'B', filePath: '/tmp/B.ndjson' },
]);
await createCollectServeSimLogsBuildFunction(ctx).fn?.(step, args);
expect(uploadServeSimLogsFileAsync).toHaveBeenCalledTimes(2);
expect(uploadServeSimLogsFileAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
deviceRunSessionId: 'session-id',
udid: 'A',
filePath: '/tmp/A.ndjson',
signal: expect.any(AbortSignal),
})
);
expect(jest.mocked(uploadServeSimLogsFileAsync).mock.calls[0][1].signal).not.toBe(
jest.mocked(uploadServeSimLogsFileAsync).mock.calls[1][1].signal
);
});
it('skips empty collection and warns on finalization failure', async () => {
await createCollectServeSimLogsBuildFunction(ctx).fn?.(step, args);
expect(uploadServeSimLogsFileAsync).not.toHaveBeenCalled();
jest.mocked(ServeSimLogsRecorder.finishAsync).mockRejectedValueOnce(new Error('failure'));
await expect(
createCollectServeSimLogsBuildFunction(ctx).fn?.(step, args)
).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not finalize serve-sim simulator logs',
expect.any(Error)
);
});
@@ -75,6 +75,7 @@ describe(startAgentDeviceDaemonAsync, () => {
jest.mocked(spawnDetached).mockReturnValue({
pid: 1234,
getOutput: () => '',
getExitError: () => undefined,
stopAsync,
});
jest.mocked(spawn).mockImplementation((async (
@@ -92,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',
@@ -107,14 +130,35 @@ 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' }),
env: expect.objectContaining({
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0',
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS: '0',
AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS: '0',
}),
});
await handle.stopAsync();
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,
@@ -129,6 +173,41 @@ 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();
const failure = new Error('app install failed');
const daemon = startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
env: {},
logger,
signal: controller.signal,
});
await started;
controller.abort(failure);
await expect(daemon).rejects.toBe(failure);
expect(spawn).toHaveBeenCalledTimes(1);
expect(spawn).toHaveBeenCalledWith(
'bun',
['add', '[email protected]'],
expect.objectContaining({ signal: controller.signal })
);
expect(spawnDetached).not.toHaveBeenCalled();
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('falls back to git clone when the published daemon is missing', async () => {
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => {
if (command === 'bun' && args[0] === '--version') {
@@ -163,9 +242,16 @@ describe(startAgentDeviceDaemonAsync, () => {
);
expect(spawnDetached).toHaveBeenCalledWith({
command: 'bun',
logger,
secrets: undefined,
args: ['run', 'src/daemon.ts'],
cwd: '/tmp/agent-device-src',
env: expect.objectContaining({ AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' }),
env: expect.objectContaining({
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0',
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS: '0',
AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS: '0',
}),
});
expect(Sentry.capture).toHaveBeenCalledWith(
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
@@ -0,0 +1,990 @@
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 { AndroidEmulatorLogRing } from '../../../utils/androidEmulatorLogCapture';
import { getAndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogs';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { stopLocalEgressResourcesAsync } from '../../utils/localEgress';
import {
resolveLocalEgressServeSimBootEnvironmentAsync,
startLocalEgressGuardRelayAsync,
} from '../../utils/localEgressGuard';
import {
selectXcodeDeveloperDirectoryAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import { downloadBuildAsync } from '../downloadBuild';
import { installBuildAsync } from '../installBuild';
import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact';
import { launchApplicationAsync } from '../launchApplication';
import {
getAgentDeviceRemoteSessionEnvOrThrow,
runAgentDeviceRemoteSessionAsync,
} from '../startAgentDeviceRemoteSession';
import { runOnWarmIosSimulatorAsync } from '../../utils/warmDeviceSessionHost';
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
import { startAndroidEmulatorAsync } from '../startAndroidEmulator';
import { bootIosSimulatorAsync } from '../startIosSimulator';
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/androidEmulatorLogs');
jest.mock('../../../utils/IosSimulatorUtils');
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
jest.mock('../startIosSimulator', () => ({
...jest.requireActual('../startIosSimulator'),
bootIosSimulatorAsync: jest.fn(),
}));
jest.mock('../startAndroidEmulator', () => ({ startAndroidEmulatorAsync: jest.fn() }));
jest.mock('../../utils/warmDeviceSessionHost');
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(),
}));
jest.mock('../startAgentDeviceRemoteSession', () => ({
getAgentDeviceRemoteSessionEnvOrThrow: jest.fn(),
runAgentDeviceRemoteSessionAsync: jest.fn(),
}));
const graphqlClient = { query: jest.fn() };
const ctx = { graphqlClient } as unknown as CustomBuildContext;
const sessionEnv = {
deviceRunSessionId: 'device-run-session-id',
ngrokTunnelDomain: 'tunnel.example.com',
ngrokAuthtoken: 'ngrok-token',
};
type Device = 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>;
resolve: (value: T) => void;
reject: (err: Error) => void;
} {
let resolve!: (value: T) => void;
let reject!: (err: Error) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
promise.catch(() => {});
return { promise, resolve, reject };
}
async function flushAsync(): Promise<void> {
for (let i = 0; i < 20; i++) {
await new Promise(resolve => setImmediate(resolve));
}
}
function runStep(
runtimePlatform: BuildRuntimePlatform,
inputValues: Record<string, unknown> = {},
signal?: AbortSignal,
stepCtx: CustomBuildContext = ctx,
warm = false,
env: NodeJS.ProcessEnv = {}
): Promise<void> {
const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx, { warm });
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const global = createGlobalContextMock({
runtimePlatform,
staticContextContent: { job: { secrets: { robotAccessToken: 'robot-token' } } },
});
const inputs = Object.fromEntries(
(buildFunction.inputProviders ?? []).map(provider => {
const input = provider(global, 'test');
input.set(inputValues[input.id]);
return [input.id, { value: input.rawValue }];
})
);
return buildFunction.fn!(
{
logger,
global,
} as unknown as BuildStepContext,
{ inputs, outputs: {}, env, signal } as never
) as Promise<void>;
}
function sessionDevice(): Device {
return jest.mocked(runAgentDeviceRemoteSessionAsync).mock.calls[0][1].device;
}
describe(createStartAgentDeviceSessionBuildFunction, () => {
function mockStalledDownload(): { aborted: () => boolean; settled: () => boolean } {
let aborted = false;
let settled = false;
jest.mocked(downloadBuildAsync).mockImplementation(
({ signal }) =>
new Promise((_resolve, reject) => {
signal!.addEventListener('abort', () => {
aborted = true;
// Settles a bit later, like a request that winds down.
setImmediate(() => {
settled = true;
reject(new Error('The user aborted a request.'));
});
});
})
);
return { aborted: () => aborted, settled: () => settled };
}
beforeEach(() => {
jest
.mocked(getAndroidEmulatorLogCapture)
.mockImplementation(
jest.requireActual('../../utils/androidEmulatorLogs').getAndroidEmulatorLogCapture
);
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(waitForDeviceRunSessionStoppedAsync).mockImplementation(async ({ signal }) => {
if (!signal?.aborted) {
await new Promise<void>(resolve =>
signal?.addEventListener('abort', () => resolve(), { once: true })
);
}
});
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,
displayName: 'iPhone 17',
});
jest.mocked(startAndroidEmulatorAsync).mockResolvedValue({
serialId: 'emulator-5554' as never,
emulatorPromise: Promise.resolve(),
shouldAdjustAnimationScale: true,
});
jest
.mocked(installBuildAsync)
.mockResolvedValue({ applicationIdentifier: 'dev.example.app', activityName: '.Main' });
jest.mocked(launchApplicationAsync).mockResolvedValue(undefined);
jest.mocked(readIosApplicationIdentifierAsync).mockResolvedValue('dev.example.app');
});
it.each([false, true])(
'observes Stop during warm claim and preserves cleanup failure=%s',
async failCleanup => {
const stopped = deferred();
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise);
let entered!: () => void;
const claiming = new Promise<void>(resolve => {
entered = resolve;
});
const cleanup = new Error('warm cleanup failed');
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async claim => {
entered();
await new Promise<void>((_resolve, reject) => {
claim.signal!.addEventListener(
'abort',
() =>
reject(
failCleanup
? new AggregateError([claim.signal!.reason, cleanup])
: claim.signal!.reason
),
{ once: true }
);
});
});
const running = runStep(BuildRuntimePlatform.DARWIN, {}, undefined, ctx, true);
running.catch(() => {});
await claiming;
stopped.resolve();
if (failCleanup) {
await expect(running).rejects.toMatchObject({ errors: expect.arrayContaining([cleanup]) });
} else {
await expect(running).resolves.toBeUndefined();
}
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
}
);
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, {
install_app_path: '/tmp/App.app',
launch_args: ['-flag'],
open_url: 'exp://example.test',
});
expect(
jest.mocked(selectXcodeDeveloperDirectoryAsync).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(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(1);
const device = sessionDevice();
expect(device).toEqual({
iosSimulatorUdid: 'SELECTED-UDID',
bootEnv,
application: expect.any(Promise),
});
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',
});
});
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('fails before app installation 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, { install_app_path: '/tmp/App.app' })
).rejects.toThrow('shutdown failed');
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
});
it('treats Stop during guarded device shutdown as normal cancellation', async () => {
const stopped = deferred();
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise);
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).mockImplementation(
(_command, _args, options) =>
new Promise((_resolve, reject) => {
options!.signal!.addEventListener('abort', () => reject(new Error('shutdown aborted')), {
once: true,
});
}) as ReturnType<typeof spawn>
);
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' });
step.catch(() => {});
await flushAsync();
expect(spawn).toHaveBeenCalled();
stopped.resolve();
await expect(step).resolves.toBeUndefined();
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
expect(startLocalEgressGuardRelayAsync).not.toHaveBeenCalled();
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
});
it('provides Simulator preparation and local app 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',
install_app_path: '/tmp/App.app',
launch_args: ['-flag'],
open_url: 'exp://example.test',
});
await flushAsync();
expect(IosSimulatorUtils.resolveUdidAsync).toHaveBeenCalledWith({
deviceIdentifier: 'iPhone 17',
env: {},
});
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
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 app installation 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, { install_app_path: '/tmp/App.app' })
).rejects.toThrow('missing bin/egress-guard-check');
expect(startLocalEgressGuardRelayAsync).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, { install_app_path: '/tmp/App.app' })
).rejects.toThrow('guard check failed');
expect(startLocalEgressGuardRelayAsync).toHaveBeenCalledTimes(1);
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
});
it('passes the selected Simulator to the host even when there is no app', async () => {
await runStep(BuildRuntimePlatform.DARWIN);
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
expect(getAndroidEmulatorLogCapture).not.toHaveBeenCalled();
expect(sessionDevice()).toEqual({
iosSimulatorUdid: 'SELECTED-UDID',
bootEnv: undefined,
application: undefined,
});
});
it('defaults an omitted session log input to emulator output through the step boundary', async () => {
const buildFunction = createStartAgentDeviceSessionBuildFunction(ctx);
const global = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX });
const step = buildFunction.createBuildStepFromFunctionCall(global, {
callInputs: {},
env: { DEVICE_RUN_SESSION_ID: 'session' },
});
await step.executeAsync();
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({
logCapture: expect.objectContaining({ sources: ['emulator-output'] }),
})
);
});
it('passes an explicit empty source list and the workflow capture to Android boot', async () => {
const capture = new AndroidEmulatorLogRing({ sources: [] });
jest.mocked(getAndroidEmulatorLogCapture).mockReturnValue(capture);
const env = { DEVICE_RUN_SESSION_ID: 'session' };
await runStep(BuildRuntimePlatform.LINUX, { log_sources: [] }, undefined, ctx, false, env);
expect(getAndroidEmulatorLogCapture).toHaveBeenCalledWith(expect.anything(), env, []);
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({ logCapture: capture })
);
});
it('rejects invalid Android log sources before boot or downloading', async () => {
await expect(
runStep(BuildRuntimePlatform.LINUX, {
log_sources: ['unknown'],
build_id: 'build-id',
})
).rejects.toThrow();
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
});
it('rejects Android log sources on an iOS session before boot or downloading', async () => {
await expect(
runStep(BuildRuntimePlatform.DARWIN, {
log_sources: ['device-logcat'],
build_id: 'build-id',
})
).rejects.toThrow('Input "log_sources" selects Android emulator logs');
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
});
it('boots an iOS session with an explicit empty source list', async () => {
await runStep(BuildRuntimePlatform.DARWIN, { log_sources: [] });
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalled();
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
});
it('boots the Android Emulator with the device inputs', async () => {
await runStep(BuildRuntimePlatform.LINUX, {
device_identifier: 'pixel_7',
system_image_package: 'system-images;android-35-ext15;google_apis_playstore;x86_64',
lcd_width: 720,
lcd_height: 1600,
lcd_density: 300,
install_app_path: '/tmp/App.app',
});
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({
deviceIdentifier: 'pixel_7',
systemImagePackage: 'system-images;android-35-ext15;google_apis_playstore;x86_64',
lcdWidth: 720,
lcdHeight: 1600,
lcdDensity: 300,
})
);
expect(launchApplicationAsync).toHaveBeenCalledWith(
expect.objectContaining({ applicationIdentifier: 'dev.example.app', activityName: '.Main' })
);
});
it('stops the download and waits for it when Android boot fails', async () => {
const download = mockStalledDownload();
jest.mocked(startAndroidEmulatorAsync).mockRejectedValue(new Error('boot failed'));
await expect(
runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' }, undefined, ctx, false, {
DEVICE_RUN_SESSION_ID: 'session',
})
).rejects.toThrow('boot failed');
expect(download.aborted()).toBe(true);
// The step returned only after the download stopped.
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
});
it('does not start work for an already cancelled step', async () => {
const controller = new AbortController();
controller.abort(new Error('cancelled'));
await expect(
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app' }, controller.signal)
).rejects.toThrow('cancelled');
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'observes Stop during a held download on %s and drains startup normally',
async runtimePlatform => {
const download = mockStalledDownload();
const stopped = deferred();
const controller = new AbortController();
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise);
const step = runStep(runtimePlatform, { build_id: 'build-id' }, controller.signal);
step.catch(() => {});
try {
await flushAsync();
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
expect.objectContaining({
deviceRunSessionId: sessionEnv.deviceRunSessionId,
signal: expect.any(AbortSignal),
})
);
stopped.resolve();
await expect(step).resolves.toBeUndefined();
expect(download.aborted()).toBe(true);
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
expect(readIosApplicationIdentifierAsync).not.toHaveBeenCalled();
} finally {
controller.abort(new Error('test cleanup'));
stopped.resolve();
await step.catch(() => {});
}
}
);
it('waits for an in-flight Android boot after Stop without installing or launching', async () => {
const boot = deferred<Awaited<ReturnType<typeof startAndroidEmulatorAsync>>>();
const stopped = deferred();
jest.mocked(startAndroidEmulatorAsync).mockReturnValue(boot.promise);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise);
let settled = false;
const step = runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' });
void step
.finally(() => {
settled = true;
})
.catch(() => {});
try {
await flushAsync();
expect(startAndroidEmulatorAsync).toHaveBeenCalled();
stopped.resolve();
await flushAsync();
expect(settled).toBe(false);
} finally {
boot.resolve({} as Awaited<ReturnType<typeof startAndroidEmulatorAsync>>);
stopped.resolve();
await step;
}
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
});
it('retires the startup watch without treating its abort as user Stop', async () => {
jest
.mocked(runAgentDeviceRemoteSessionAsync)
.mockImplementation(async (_ctx, { onStarted, tasks }) => {
await onStarted!();
expect(tasks.signal.aborted).toBe(false);
});
await expect(runStep(BuildRuntimePlatform.DARWIN)).resolves.toBeUndefined();
const options = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
expect(options.maxDurationSeconds).toBeUndefined();
expect(options.idleTimeout).toBeUndefined();
expect(options.signal!.aborted).toBe(true);
});
it('preserves a download failure that wins before Stop', async () => {
const failed = new Error('download failed first');
const stopped = deferred();
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise);
jest.mocked(downloadBuildAsync).mockRejectedValue(failed);
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' });
step.catch(() => {});
await flushAsync();
stopped.resolve();
await expect(step).rejects.toBe(failed);
});
it('fails when the startup status watch rejects', async () => {
const failed = new Error('session errored');
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValue(failed);
await expect(runStep(BuildRuntimePlatform.DARWIN)).rejects.toBe(failed);
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 app 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,
{ install_app_path: '/tmp/App.app' },
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(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('declares the step inputs', () => {
const buildFunction = createStartAgentDeviceSessionBuildFunction(ctx);
const globalCtx = createGlobalContextMock();
expect(
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual([
'log_sources',
'device_identifier',
'system_image_package',
'lcd_width',
'lcd_height',
'lcd_density',
'install_app_path',
'build_id',
'application_archive_url',
'launch_app_identifier',
'launch_args',
'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, {
install_app_path: '/tmp/App.app',
...inputs,
});
await expect(step).rejects.toBeInstanceOf(UserError);
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
expect(bootIosSimulatorAsync).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, {
install_app_path: '/tmp/App.app',
...inputs,
});
await expect(step).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
expect(startAndroidEmulatorAsync).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, {
install_app_path: '/tmp/App.app',
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,
network_capture_fields: ['header', 'response-body'],
});
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
capture: { networkCapture: true, networkCaptureFields: ['header', 'response-body'] },
})
);
});
it('rejects network capture on Android before it boots anything', async () => {
await expect(
runStep(BuildRuntimePlatform.LINUX, { network_capture: true, system_image_package: 'x' })
).rejects.toThrow('records traffic through serve-sim on an iOS simulator');
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('requires an application for launch options before starting the device', async () => {
await expect(
runStep(BuildRuntimePlatform.DARWIN, { open_url: 'fixture://route' })
).rejects.toThrow('pass install_app_path or launch_app_identifier');
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('launches an installed iOS application without reading an app artifact', async () => {
await runStep(BuildRuntimePlatform.DARWIN, {
launch_app_identifier: 'dev.installed.app',
launch_args: ['--test'],
open_url: 'fixture://route',
});
expect(readIosApplicationIdentifierAsync).not.toHaveBeenCalled();
expect(deviceLaunch).toEqual({
installAppPath: undefined,
launchAppIdentifier: 'dev.installed.app',
launchArgs: ['--test'],
openUrl: 'fixture://route',
});
});
it('keeps explicit application identifier launch iOS-only', async () => {
await expect(
runStep(BuildRuntimePlatform.LINUX, { launch_app_identifier: 'dev.app' })
).rejects.toThrow('Pass install_app_path for Android');
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
});
it('still downloads a build_id app and launches it on iOS', async () => {
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/Downloaded.app' });
await runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id', launch_args: ['-flag'] });
expect(downloadBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({ buildId: 'build-id', extensions: ['app'] })
);
expect(deviceLaunch).toEqual({
installAppPath: '/tmp/Downloaded.app',
launchAppIdentifier: 'dev.example.app',
launchArgs: ['-flag'],
openUrl: undefined,
});
});
it('still downloads an application_archive_url app and installs it on Android', async () => {
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/Downloaded.apk' });
await runStep(BuildRuntimePlatform.LINUX, {
application_archive_url: 'https://example.test/app.tar.gz',
});
expect(downloadBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({
applicationArchiveUrl: 'https://example.test/app.tar.gz',
extensions: ['apk'],
})
);
expect(installBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({ artifactPath: '/tmp/Downloaded.apk' })
);
expect(launchApplicationAsync).toHaveBeenCalledTimes(1);
});
it('runs the warm step on the warm Simulator for a local app only', async () => {
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation((_claim, run) => run());
const runWarm = (inputs: Record<string, unknown>) =>
runStep(BuildRuntimePlatform.DARWIN, inputs, undefined, ctx, true);
await runWarm({ install_app_path: '/tmp/App.app', device_identifier: 'iPhone 17' });
expect(runOnWarmIosSimulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({ engine: 'agent-device', requestedDevice: 'iPhone 17' }),
expect.any(Function)
);
jest.mocked(runOnWarmIosSimulatorAsync).mockClear();
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/Downloaded.app' });
await runWarm({ build_id: 'build-id' });
expect(runOnWarmIosSimulatorAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(2);
});
it('passes the resolved version to agent-device after a warm version mismatch', async () => {
jest
.mocked(runOnWarmIosSimulatorAsync)
.mockImplementation((_claim, run) => run(undefined, '999.1.0'));
await runStep(BuildRuntimePlatform.DARWIN, { package_version: 'latest' }, undefined, ctx, true);
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ packageVersion: '999.1.0' })
);
});
it('accepts only one application source', async () => {
await expect(
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app', build_id: 'id' })
).rejects.toThrow('Pass only one of install_app_path, build_id or application_archive_url.');
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('waits for Android boot before installing the supplied local APK', async () => {
const boot = deferred();
jest.mocked(startAndroidEmulatorAsync).mockReturnValue(boot.promise as never);
const step = runStep(BuildRuntimePlatform.LINUX, { install_app_path: '/tmp/App.apk' });
await flushAsync();
expect(installBuildAsync).not.toHaveBeenCalled();
boot.resolve();
await step;
expect(installBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({ artifactPath: '/tmp/App.apk' })
);
expect(launchApplicationAsync).toHaveBeenCalledTimes(1);
});
it('drains an in-flight Android install on cancellation and never launches it', async () => {
const controller = new AbortController();
const installation = deferred<{ applicationIdentifier: string; activityName: string }>();
jest.mocked(installBuildAsync).mockReturnValue(installation.promise);
const step = runStep(
BuildRuntimePlatform.LINUX,
{ install_app_path: '/tmp/App.apk' },
controller.signal
);
let settled = false;
void step.then(
() => (settled = true),
() => (settled = true)
);
const failure = expect(step).rejects.toThrow('cancelled');
await flushAsync();
expect(installBuildAsync).toHaveBeenCalledTimes(1);
controller.abort(new Error('cancelled'));
await flushAsync();
expect(settled).toBe(false);
installation.resolve({ applicationIdentifier: 'dev.app', activityName: '.Main' });
await failure;
expect(launchApplicationAsync).not.toHaveBeenCalled();
});
it('drains an in-flight Android install when session startup fails', async () => {
const entered = deferred();
const installation = deferred<{ applicationIdentifier: string; activityName: string }>();
jest.mocked(installBuildAsync).mockImplementation(() => {
entered.resolve();
return installation.promise;
});
jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async () => {
await entered.promise;
throw new Error('session startup failed');
});
const step = runStep(BuildRuntimePlatform.LINUX, { install_app_path: '/tmp/App.apk' });
let settled = false;
void step.then(
() => (settled = true),
() => (settled = true)
);
const failure = expect(step).rejects.toThrow('session startup failed');
await entered.promise;
await flushAsync();
expect(settled).toBe(false);
installation.resolve({ applicationIdentifier: 'dev.app', activityName: '.Main' });
await failure;
expect(launchApplicationAsync).not.toHaveBeenCalled();
});
it('drains iOS metadata reading on cancellation without starting the session', async () => {
const controller = new AbortController();
const metadata = deferred<string>();
jest.mocked(readIosApplicationIdentifierAsync).mockReturnValue(metadata.promise);
const step = runStep(
BuildRuntimePlatform.DARWIN,
{ install_app_path: '/tmp/App.app' },
controller.signal
);
const failure = expect(step).rejects.toThrow('cancelled');
await flushAsync();
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
controller.abort(new Error('cancelled'));
metadata.resolve('dev.app');
await failure;
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
});
@@ -8,6 +8,10 @@ import {
AndroidVirtualDeviceName,
} from '../../../utils/AndroidEmulatorUtils';
import { retryAsync } from '../../../utils/retry';
import { resolveAndroidEmulatorLocalEgressAsync } from '../../utils/androidLocalEgress';
import { AndroidEmulatorLogRing } from '../../../utils/androidEmulatorLogCapture';
import { getAndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogs';
import { installEmulatorCaptureAsync } from '../../utils/emulatorCapture';
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
jest.mock('@expo/turtle-spawn', () => ({
@@ -19,6 +23,17 @@ jest.mock('../../../utils/retry', () => ({
retryAsync: jest.fn(),
}));
jest.mock('../../utils/androidLocalEgress', () => ({
resolveAndroidEmulatorLocalEgressAsync: jest.fn(),
}));
jest.mock('../../utils/androidEmulatorLogs');
jest.mock('../../utils/emulatorCapture', () => ({
...jest.requireActual('../../utils/emulatorCapture'),
installEmulatorCaptureAsync: jest.fn(),
}));
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
AndroidEmulatorUtils: {
defaultSystemImagePackage: 'system-images;android-30;default;x86_64',
@@ -29,12 +44,14 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
waitForReadyAsync: jest.fn(),
disableWindowAndTransitionAnimationsAsync: jest.fn(),
deleteAsync: jest.fn(),
findFreeConsolePortAsync: jest.fn(),
},
}));
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
const mockedResolveLocalEgress = jest.mocked(resolveAndroidEmulatorLocalEgressAsync);
const mockedMkdtemp = jest.spyOn(fs.promises, 'mkdtemp');
function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.ProcessEnv) {
const logger = createMockLogger();
@@ -44,7 +61,12 @@ function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.
const step = fn.createBuildStepFromFunctionCall(globalCtx, {
callInputs,
});
return Object.assign(step, { logger });
return Object.assign(step, { logger, globalCtx });
}
/** The logger that the step function gets: a child of the base logger. */
function stepLogger(step: ReturnType<typeof createStep>) {
return jest.mocked(step.logger.child).mock.results[0].value;
}
function createStartResult(serialId: string) {
@@ -56,6 +78,11 @@ function createStartResult(serialId: string) {
describe(createStartAndroidEmulatorBuildFunction, () => {
beforeEach(() => {
jest
.mocked(getAndroidEmulatorLogCapture)
.mockImplementation(
jest.requireActual('../../utils/androidEmulatorLogs').getAndroidEmulatorLogCapture
);
mockedMkdtemp.mockResolvedValue('/tmp/logcat-directory');
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedAndroidUtils.getAvailableDevicesAsync.mockResolvedValue([]);
@@ -65,6 +92,7 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined);
mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync.mockResolvedValue(undefined);
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined);
mockedResolveLocalEgress.mockResolvedValue(null);
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
let lastErr: unknown;
@@ -83,6 +111,107 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
mockedMkdtemp.mockRestore();
});
it('preserves an explicit empty source list and one capture through boot retries and clones', async () => {
mockedAndroidUtils.waitForReadyAsync.mockRejectedValueOnce(new Error('retry boot'));
const capture = new AndroidEmulatorLogRing({ sources: [] });
jest.mocked(getAndroidEmulatorLogCapture).mockReturnValue(capture);
await createStep(
{ log_sources: [], count: 2 },
{ DEVICE_RUN_SESSION_ID: 'session' }
).executeAsync();
expect(getAndroidEmulatorLogCapture).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ DEVICE_RUN_SESSION_ID: 'session' }),
[]
);
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledTimes(4);
for (const [options] of mockedAndroidUtils.startAsync.mock.calls) {
expect(options.logCapture).toBe(capture);
}
});
it('rejects invalid sources before starting the emulator', async () => {
await expect(createStep({ log_sources: ['unknown'] }).executeAsync()).rejects.toThrow();
expect(mockedAndroidUtils.startAsync).not.toHaveBeenCalled();
});
describe('local egress', () => {
function createLocalEgress() {
return {
launchGate: {
emulatorArgs: ['-http-proxy', '127.0.0.1:8898', '-no-metrics'],
wrapperCommand: 'bash',
wrapperArgs: ['-c', 'gate', 'eas-egress-gate'],
admitAsync: jest.fn(),
},
networkReadyTarget: { host: '192.0.2.1', port: 443 },
configureBootedEmulatorAsync: jest.fn().mockResolvedValue(undefined),
};
}
it('starts the emulator exactly as before without a local egress session', async () => {
await createStep().executeAsync();
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith({
deviceName: 'EasAndroidDevice01',
env: expect.any(Object),
logcatDirectory: '/tmp/logcat-directory',
logCapture: expect.objectContaining({ sources: ['emulator-output', 'device-logcat'] }),
});
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith({
env: expect.any(Object),
serialId: 'emulator-default',
timeoutMs: 60_000,
logger: expect.anything(),
});
expect(
mockedSpawn.mock.calls.filter(
([command, args]) =>
['sudo', 'nft', 'systemd-run'].includes(command) ||
(command === 'adb' && (args.includes('reverse') || args.includes('http_proxy')))
)
).toEqual([]);
});
it('fences, gates and configures the emulator', async () => {
const localEgress = createLocalEgress();
mockedResolveLocalEgress.mockResolvedValue(localEgress);
mockedAndroidUtils.startAsync.mockResolvedValueOnce(createStartResult('emulator-base'));
await createStep().executeAsync();
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
expect.objectContaining({ launchGate: localEgress.launchGate })
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith(
expect.objectContaining({ networkReadyTarget: { host: '192.0.2.1', port: 443 } })
);
expect(localEgress.configureBootedEmulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({ serialId: 'emulator-base' })
);
});
it('retries a boot whose local egress configuration fails', async () => {
const localEgress = createLocalEgress();
localEgress.configureBootedEmulatorAsync
.mockRejectedValueOnce(new Error('netsimd outside the fence'))
.mockResolvedValueOnce(undefined);
mockedResolveLocalEgress.mockResolvedValue(localEgress);
mockedAndroidUtils.startAsync
.mockResolvedValueOnce(createStartResult('emulator-1111'))
.mockResolvedValueOnce(createStartResult('emulator-2222'));
await createStep().executeAsync();
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({ serialId: 'emulator-1111' })
);
expect(localEgress.configureBootedEmulatorAsync).toHaveBeenLastCalledWith(
expect.objectContaining({ serialId: 'emulator-2222' })
);
});
});
it('passes profile and LCD inputs to emulator creation', async () => {
const systemImagePackage = 'system-images;android-35;default;x86_64';
await createStep({
@@ -353,4 +482,170 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
}
);
});
describe('experimental emulator capture', () => {
const LIBRARY = '/tmp/package/libexpo_emulator_capture.so';
const mockedInstall = jest.mocked(installEmulatorCaptureAsync);
function captureStart(port: number) {
return {
deviceName: 'EasAndroidDevice01',
env: expect.any(Object),
logcatDirectory: '/tmp/logcat-directory',
logCapture: expect.objectContaining({ sources: ['emulator-output', 'device-logcat'] }),
consolePort: port,
extraArgs: ['-gpu', 'lavapipe'],
extraEnv: {
LD_PRELOAD: LIBRARY,
EXPO_EMULATOR_CAPTURE_SOCKET: `/tmp/job/expo-emulator-capture/emulator-${port}.sock`,
},
};
}
beforeEach(() => {
mockedInstall.mockReset().mockResolvedValue(LIBRARY);
mockedAndroidUtils.findFreeConsolePortAsync
.mockReset()
.mockResolvedValueOnce(5554)
.mockResolvedValueOnce(5556);
mockedAndroidUtils.startAsync.mockResolvedValue(createStartResult('emulator-5554'));
});
it('preloads the agent and turns the capture on for expo-device-hub', async () => {
const step = createStep(
{ experimental_emulator_capture_version: '0.1.0' },
{ TMPDIR: '/tmp/job' }
);
await step.executeAsync();
expect(mockedInstall).toHaveBeenCalledWith({
version: '0.1.0',
env: expect.any(Object),
logger: expect.anything(),
});
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(captureStart(5554));
expect(step.globalCtx.env).toMatchObject({
EXPO_DEVICE_HUB_EXPERIMENTAL_EMULATOR_CAPTURE: '1',
EXPO_DEVICE_HUB_EMULATOR_CAPTURE_LIBRARY: LIBRARY,
});
expect(stepLogger(step).info).toHaveBeenCalledWith(
'emulator-5554 preloads [email protected] with -gpu lavapipe, and its agent socket is /tmp/job/expo-emulator-capture/emulator-5554.sock. Later steps start expo-device-hub with the emulator capture on.'
);
});
it('uses the temporary directory rule of Node when TMPDIR is empty', async () => {
await createStep(
{ experimental_emulator_capture_version: '0.1.0' },
{ TMPDIR: '', TMP: '/tmp/from-tmp' }
).executeAsync();
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
expect.objectContaining({
extraEnv: expect.objectContaining({
EXPO_EMULATOR_CAPTURE_SOCKET: '/tmp/from-tmp/expo-emulator-capture/emulator-5554.sock',
}),
})
);
});
it('uses /tmp when the job env names no temporary directory', async () => {
await createStep({ experimental_emulator_capture_version: '0.1.0' }).executeAsync();
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
expect.objectContaining({
extraEnv: expect.objectContaining({
EXPO_EMULATOR_CAPTURE_SOCKET: '/tmp/expo-emulator-capture/emulator-5554.sock',
}),
})
);
});
it('does not turn the capture on when the emulator runs on another console port', async () => {
mockedAndroidUtils.startAsync.mockResolvedValue(createStartResult('emulator-5560'));
const step = createStep(
{ experimental_emulator_capture_version: '0.1.0' },
{ TMPDIR: '/tmp/job' }
);
await step.executeAsync();
expect(step.globalCtx.env.EXPO_DEVICE_HUB_EXPERIMENTAL_EMULATOR_CAPTURE).toBeUndefined();
expect(stepLogger(step).warn).toHaveBeenCalledWith(
expect.stringContaining('emulator-5560 does not use console port 5554')
);
});
it('keeps a library that the job already preloads, after the agent', async () => {
await createStep(
{ experimental_emulator_capture_version: '0.1.0' },
{ TMPDIR: '/tmp/job', LD_PRELOAD: '/opt/other.so' }
).executeAsync();
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
expect.objectContaining({
extraEnv: expect.objectContaining({ LD_PRELOAD: `${LIBRARY}:/opt/other.so` }),
})
);
});
it('chooses a new console port for each start attempt', async () => {
mockedAndroidUtils.startAsync
.mockRejectedValueOnce(new Error('emulator did not start'))
.mockResolvedValue(createStartResult('emulator-5556'));
await createStep(
{ experimental_emulator_capture_version: '0.1.0' },
{ TMPDIR: '/tmp/job' }
).executeAsync();
expect(mockedAndroidUtils.startAsync).toHaveBeenNthCalledWith(2, captureStart(5556));
});
it('starts the emulator as without the input when the install fails', async () => {
mockedInstall.mockResolvedValue(undefined);
const step = createStep({ experimental_emulator_capture_version: '0.1.0' });
await step.executeAsync();
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith({
deviceName: 'EasAndroidDevice01',
env: expect.any(Object),
logcatDirectory: '/tmp/logcat-directory',
logCapture: expect.objectContaining({ sources: ['emulator-output', 'device-logcat'] }),
});
expect(mockedAndroidUtils.findFreeConsolePortAsync).not.toHaveBeenCalled();
expect(step.globalCtx.env.EXPO_DEVICE_HUB_EXPERIMENTAL_EMULATOR_CAPTURE).toBeUndefined();
// The session step still rejects session types without the capture.
expect(step.globalCtx.env.EAS_EXPERIMENTAL_EMULATOR_CAPTURE_VERSION).toBe('0.1.0');
});
it('installs nothing without the input', async () => {
const step = createStep();
await step.executeAsync();
expect(mockedInstall).not.toHaveBeenCalled();
expect(mockedAndroidUtils.findFreeConsolePortAsync).not.toHaveBeenCalled();
expect(step.globalCtx.env.EXPO_DEVICE_HUB_EXPERIMENTAL_EMULATOR_CAPTURE).toBeUndefined();
expect(step.globalCtx.env.EAS_EXPERIMENTAL_EMULATOR_CAPTURE_VERSION).toBeUndefined();
});
it('rejects the capture for more than one emulator before it starts one', async () => {
await expect(
createStep({ experimental_emulator_capture_version: '0.1.0', count: 2 }).executeAsync()
).rejects.toThrow('experimental_emulator_capture_version supports one emulator');
expect(mockedInstall).not.toHaveBeenCalled();
expect(mockedAndroidUtils.startAsync).not.toHaveBeenCalled();
});
it('rejects a version that is not an exact npm version or a dist-tag', async () => {
await expect(
createStep({ experimental_emulator_capture_version: 'npm:[email protected]' }).executeAsync()
).rejects.toThrow('must be an npm version or dist-tag of expo-emulator-capture');
expect(mockedInstall).not.toHaveBeenCalled();
});
});
});
Loaded 100 of 811 files, more files were not shown because too many files have changed in this diff. Show more