Compare commits

...
196 Commits
Author SHA1 Message Date
Gabe Debes e34c0cb3f6 [eas-cli] Mark default devices in simulator:devices (#4895) 2026-11-13 23:52:38 +00:00
Szymon Dziedzic c7f766568c [build-tools] Count Android web preview input toward the session idle timeout (#4890)
* [build-tools] Count Android web preview input toward the session idle timeout

* [build-tools] Count an expo-device-hub read timeout as unobservable, not as a down device
2026-11-14 00:32:58 +01:00
Szymon Dziedzic a328e59ee4 [build-tools] Do not fail a successful session when its tool tunnel cannot stop (#4893)
* [build-tools] Do not fail a successful session when its tool tunnel cannot stop

* Shorten the changeset to one sentence

* Name the options for tool tunnels and test a startup Stop
2026-11-14 00:27:04 +01:00
Expo CI f843a97165 bump STAGING and PRODUCTION to 27.10.0 in cli-versions.json 2026-11-13 23:05:29 +00:00
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
564 changed files with 65957 additions and 7902 deletions

No files matched your search

@@ -0,0 +1,5 @@
---
'@expo/build-tools': minor
---
Count Android web preview input as session activity, so `max_idle_time_minutes` works on Android sessions. `eas/start_serve_sim_remote_session` no longer rejects it on Android; the API does not accept an idle timeout for Android web-preview-only sessions yet.
+3 -1
View File
@@ -19,7 +19,9 @@
"@expo/downloader",
"@expo/turtle-spawn",
"@expo/template-file",
"create-eas-build-function"
"create-eas-build-function",
"@expo/vm-tools",
"@expo/simulator-launcher"
]
],
"linked": [],
+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': patch
---
Mark the default device for each platform in `eas simulator:devices` and include `isDefault` in JSON output.
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
A successful remote device run session no longer fails when its agent-device, Appium, or Argent tunnel does not stop within its time-out.
+3 -1
View File
@@ -28,7 +28,9 @@ jobs:
contains(github.event.pull_request.labels.*.name, 'no changelog') ||
github.event.pull_request.user.login == 'dependabot[bot]'
}}
BASE_REF: ${{ github.event.pull_request.base.sha || 'origin/main' }}
# 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
-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
+324
View File
@@ -2,6 +2,330 @@
This is the log of notable changes to EAS CLI and related packages.
## [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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "24.14.0",
"PRODUCTION": "24.14.0"
"STAGING": "27.10.0",
"PRODUCTION": "27.10.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "25.0.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 = "22.23.3"
node = "22.23.5"
+1
View File
@@ -7,6 +7,7 @@
"scripts": {
"build": "lerna run build",
"typecheck": "lerna run typecheck",
"verify-graphql-code": "lerna run verify-graphql-code --concurrency 1",
"start": "lerna run watch --stream --parallel",
"start-allow-unused": "lerna run watch-allow-unused --stream --parallel",
"watch": "yarn start",
+196
View File
@@ -1,5 +1,201 @@
# @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
+15 -14
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "25.0.0",
"version": "27.10.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -26,7 +26,7 @@
"build:egress-guard": "resources/egress-guard/build.sh",
"test:egress-guard": "resources/egress-guard/tests/run-policy-tests.sh && resources/egress-guard/tests/run-guard-tests.sh",
"typecheck": "tsc",
"generate-appium-commands": "mise exec [email protected]2.0 -- node scripts/generate-appium-commands.js",
"generate-appium-commands": "mise exec [email protected]3.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",
@@ -36,23 +36,24 @@
"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": "25.0.0",
"@expo/eas-build-job": "25.0.0",
"@expo/downloader": "27.10.0",
"@expo/eas-build-job": "27.10.0",
"@expo/env": "^0.4.0",
"@expo/logger": "25.0.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": "25.0.0",
"@expo/template-file": "25.0.0",
"@expo/turtle-spawn": "25.0.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",
+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 });
@@ -361,6 +361,67 @@ describe('Datadog singleton', () => {
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 () => {}),
};
}
@@ -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,7 +53,7 @@ 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(),
+24 -1
View File
@@ -17,6 +17,10 @@ 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,
@@ -220,11 +224,30 @@ async function buildInnerAsync(
});
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
await uploadApplicationArchive(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 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 (shouldUploadEmbeddedBundle(ctx)) {
+24 -1
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,
@@ -236,11 +240,30 @@ async function buildInnerAsync(
});
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
await uploadApplicationArchive(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 (shouldUploadEmbeddedBundle(ctx)) {
@@ -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;
+11 -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>;
@@ -241,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 };
@@ -410,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 {
@@ -424,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);
}
+15 -2
View File
@@ -1,23 +1,30 @@
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 }
// A device run session job with a session token. The token names the session and its job run.
| { kind: 'deviceRunSession' };
| { kind: 'deviceRunSession' }
| { kind: 'sandbox' };
type DatadogSetupOptions =
| {
expoApiV2BaseUrl: string;
robotAccessToken: string;
target: Exclude<MetricsTarget, { kind: 'deviceRunSession' }>;
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;
@@ -41,6 +48,12 @@ function getMetricsRequest(options: DatadogSetupOptions): {
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:
+12 -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';
@@ -34,5 +43,6 @@ 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';
@@ -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,
});
@@ -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);
});
@@ -33,6 +33,7 @@ import { createSubmitToGooglePlayBuildFunction } from './functions/submitToGoogl
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 {
@@ -61,6 +62,7 @@ import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/sta
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';
@@ -111,8 +113,11 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
runFastlaneFunction(),
parseXcactivitylogFunction(),
createStartAgentDeviceSessionBuildFunction(ctx),
createStartAgentDeviceSessionBuildFunction(ctx, { warm: true }),
createStartArgentRemoteSessionBuildFunction(ctx),
createStartArgentRemoteSessionBuildFunction(ctx, { warm: true }),
createStartAppiumRemoteSessionBuildFunction(ctx),
createStartAppiumRemoteSessionBuildFunction(ctx, { warm: true }),
createStartAndroidEmulatorBuildFunction(),
createStartCuttlefishDeviceBuildFunction(),
createStartIosSimulatorBuildFunction(),
@@ -121,9 +126,11 @@ 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),
@@ -154,6 +161,7 @@ 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,
@@ -19,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';
@@ -183,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,
@@ -304,7 +318,10 @@ 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,
@@ -318,6 +335,16 @@ function createStepsForIosBuildWithCredentials({
}),
]
: []),
createUpdateResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
application_archive_path:
'${ steps.find_and_upload_build_artifacts.application_archive_path }',
},
}
),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
@@ -404,7 +431,10 @@ 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,
@@ -418,6 +448,16 @@ function createStepsForAndroidBuildWithoutCredentials({
}),
]
: []),
createUpdateResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
application_archive_path:
'${ steps.find_and_upload_build_artifacts.application_archive_path }',
},
}
),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
@@ -510,7 +550,10 @@ function createStepsForAndroidBuildWithCredentials({
: []),
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
).createBuildStepFromFunctionCall(globalCtx, {
id: 'find_and_upload_build_artifacts',
workingDirectory,
}),
...(shouldUploadEmbeddedBundle({
env: globalCtx.env,
metadata: globalCtx.staticContext.metadata,
@@ -524,6 +567,16 @@ function createStepsForAndroidBuildWithCredentials({
}),
]
: []),
createUpdateResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
globalCtx,
{
workingDirectory,
callInputs: {
application_archive_path:
'${ steps.find_and_upload_build_artifacts.application_archive_path }',
},
}
),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
@@ -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)
);
});
@@ -2,10 +2,12 @@ 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';
@@ -44,6 +46,26 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
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();
@@ -54,6 +76,8 @@ const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnTh
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.
@@ -79,9 +103,11 @@ beforeEach(() => {
apiUrl: 'https://preview.example.test',
closeAsync: closePreview,
});
jest
.mocked(startDeviceSessionHostAsync)
.mockResolvedValue({ openPreviewAsync: openPreview, finishAsync: finishHost });
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: openPreview,
getLastPreviewActivityAt: jest.fn(),
finishAsync: finishHost,
});
jest.mocked(spawnDetached).mockReturnValue({
pid: undefined,
getOutput: () => '',
@@ -96,8 +122,8 @@ beforeEach(() => {
const events = { stopAsync: stopEvents, getLastEventObservedAt: () => undefined };
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue(events);
jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue(events);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
lifetimeWait.mockResolvedValue(undefined);
});
afterEach(() => {
@@ -108,19 +134,34 @@ describe.each([
['Appium', createStartAppiumRemoteSessionBuildFunction],
['Agent Device', createStartAgentDeviceSessionBuildFunction],
] as const)('%s host ownership', (name, createFunction) => {
async function runAsync(publicPreview?: boolean) {
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,
{
// Inputs a step does not get default to undefined, like in a real step call.
// Match step execution: omitted inputs use their declared defaults.
inputs: new Proxy(
{ public_preview: { value: publicPreview } } as Record<string, { value: unknown }>,
{
get: (target, id: string) => target[id] ?? { value: undefined },
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: {},
@@ -135,7 +176,11 @@ describe.each([
it('holds the host until session completion', async () => {
await runAsync();
expect(openPreview).toHaveBeenCalledWith({ baseDomain: 'example.test', authtoken: 'token' });
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ preview: { baseDomain: 'example.test', authtoken: 'token' } })
);
expect(openPreview).toHaveBeenCalledWith();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
@@ -147,7 +192,7 @@ describe.each([
expect(finishHost).toHaveBeenCalledTimes(1);
expect(closePreview).not.toHaveBeenCalled();
expect(finishHost.mock.invocationCallOrder[0]).toBeGreaterThan(
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
lifetimeWait.mock.invocationCallOrder[0]
);
expect(stopTool).toHaveBeenCalledTimes(1);
expect(stopEvents).toHaveBeenCalledTimes(1);
@@ -179,6 +224,34 @@ describe.each([
);
});
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;
@@ -264,7 +337,7 @@ describe.each([
} else if (phase === 'config') {
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
} else {
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
lifetimeWait.mockRejectedValueOnce(error);
}
await expect(runAsync()).rejects.toBe(error);
expect(finishHost).toHaveBeenCalledTimes(1);
@@ -312,6 +385,10 @@ describe('Appium ffmpeg setup', () => {
}
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 =>
@@ -322,25 +399,26 @@ describe('Appium ffmpeg setup', () => {
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;
return { finish, started };
}
it.each(['installed', 'failed'] as const)(
'starts Appium after the ffmpeg install settles (%s)',
async outcome => {
const finishFfmpegInstall = holdFfmpegInstall();
const { finish, started } = holdFfmpegInstall();
const run = runAppiumAsync();
await new Promise(resolve => setImmediate(resolve));
await started;
expect(spawnDetached).not.toHaveBeenCalled();
finishFfmpegInstall(outcome);
finish(outcome);
await run;
expect(spawnDetached).toHaveBeenCalledTimes(1);
}
@@ -348,16 +426,16 @@ describe('Appium ffmpeg setup', () => {
it('waits for the ffmpeg install before failing on an Appium install error', async () => {
const appiumError = new Error('npm failed');
const finishFfmpegInstall = holdFfmpegInstall({ appiumError });
const { finish, started } = holdFfmpegInstall({ appiumError });
let settled = false;
const run = runAppiumAsync().finally(() => {
settled = true;
});
await new Promise(resolve => setImmediate(resolve));
await started;
expect(settled).toBe(false);
finishFfmpegInstall('installed');
finish('installed');
await expect(run).rejects.toBe(appiumError);
});
});
@@ -11,6 +11,7 @@ import * as tar from 'tar';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { Datadog } from '../../../datadog';
import * as files from '../../../utils/files';
import { createDownloadBuildFunction, downloadBuildAsync } from '../downloadBuild';
@@ -30,11 +31,17 @@ const APPLICATION_ARCHIVE_URL = `https://expo.dev/artifacts/eas/${randomUUID()}.
function createMockGraphqlClient({
applicationArchiveUrl,
error,
transientError,
}: {
applicationArchiveUrl?: string | null;
error?: Error;
transientError?: Error;
}): Client {
const toPromise = jest.fn().mockResolvedValue(
const toPromise = jest.fn();
if (transientError) {
toPromise.mockResolvedValueOnce({ error: transientError, data: undefined });
}
toPromise.mockResolvedValue(
error
? { error, data: undefined }
: {
@@ -62,7 +69,7 @@ function createSuccessfulResponse({
url,
headers,
}: {
body: Buffer;
body: Buffer | Readable;
url: string;
headers?: Record<string, string>;
}): Response {
@@ -72,7 +79,7 @@ function createSuccessfulResponse({
return {
ok: true,
body: Readable.from(body),
body: body instanceof Readable ? body : Readable.from(body),
url,
headers: {
get: (name: string): string | null => normalizedHeaders[name.toLowerCase()] ?? null,
@@ -80,6 +87,47 @@ function createSuccessfulResponse({
} as unknown as Response;
}
function createUndiciNetworkError(code: string): CombinedError {
return new CombinedError({
networkError: Object.assign(new TypeError('fetch failed'), {
cause: Object.assign(new Error(`read ${code}`), { code }),
}),
});
}
const { FetchError: ActualFetchError } = jest.requireActual(
'node-fetch'
) as typeof import('node-fetch');
function createSocketFetchError(url: string, code: string): Error {
return new ActualFetchError(`request to ${url} failed, reason: connect ${code}`, 'system', {
code,
} as any);
}
function useFakeBackoffTimers(): void {
// Streams and fs callbacks need the real nextTick and setImmediate; only the backoff timers are faked.
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
}
const MAX_IO_TURNS_BEFORE_BACKOFF = 1000;
async function waitForBackoffTimerAsync(): Promise<void> {
for (let turns = 0; jest.getTimerCount() === 0; turns++) {
if (turns === MAX_IO_TURNS_BEFORE_BACKOFF) {
throw new Error('No backoff timer was scheduled.');
}
await new Promise(resolve => setImmediate(resolve));
}
}
async function fireBackoffTimersAsync(count: number): Promise<void> {
for (let i = 0; i < count; i++) {
await waitForBackoffTimerAsync();
await jest.advanceTimersToNextTimerAsync();
}
}
async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
const sourceDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'flat-app-source-'));
const archivePath = path.join(os.tmpdir(), `${randomUUID()}.tar.gz`);
@@ -108,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({
@@ -465,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' }),
@@ -480,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 },
}),
});
@@ -504,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();
});
@@ -534,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,
@@ -605,6 +958,21 @@ describe('createDownloadBuildFunction', () => {
};
}
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({
@@ -620,18 +988,7 @@ describe('createDownloadBuildFunction', () => {
const graphqlClient = createMockGraphqlClient({});
jest.mocked(fetch).mockImplementation(async url => {
if (url === workerApiUrl) {
return new ActualResponse(
JSON.stringify({
data: {
build: {
id: buildId,
gitCommitHash: null,
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
},
},
}),
{ status: 200 }
);
return createApplicationResponse(buildId);
}
return {
ok: false,
@@ -661,6 +1018,33 @@ describe('createDownloadBuildFunction', () => {
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({
@@ -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'];
@@ -1,5 +1,6 @@
import { BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { spawnSync } from 'node:child_process';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
@@ -201,6 +202,263 @@ describe(launchApplicationAsync, () => {
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('quotes Android launch arguments and URLs for the device shell', async () => {
const logger = createMockLogger();
await launchApplicationAsync({
applicationIdentifier: 'dev.example.app',
activityName: 'dev.example.app.MainActivity',
launchArgs: ['-d', 'euxy://tempo', '--es', 'probe', 'hello world', '--es', 'quote', "it's"],
openUrl: 'exp://example.test/path?a=1&b=2',
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger,
});
expect(mockedSpawn).toHaveBeenNthCalledWith(
1,
'adb',
[
'shell',
'am',
'start',
'-d',
'euxy://tempo',
'--es',
'probe',
"'hello world'",
'--es',
'quote',
"'it'\\''s'",
'-n',
'dev.example.app/dev.example.app.MainActivity',
],
{ env: {}, logger }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
'adb',
[
'shell',
'am',
'start',
'-a',
'android.intent.action.VIEW',
'-d',
"'exp://example.test/path?a=1&b=2'",
],
{ env: {}, logger }
);
expect(logger.info).toHaveBeenNthCalledWith(
1,
`Launching dev.example.app with arguments ${JSON.stringify(['-d', 'euxy://tempo', '--es', 'probe', 'hello world', '--es', 'quote', "it's"])}.`
);
});
it('passes iOS launch arguments with spaces unquoted', async () => {
await launchApplicationAsync({
applicationIdentifier: 'com.example.app',
launchArgs: ['-name', 'hello world'],
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: {},
logger: createMockLogger(),
});
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
['simctl', 'launch', 'booted', 'com.example.app', '-name', 'hello world'],
expect.any(Object)
);
});
it('round-trips quoted Android arguments through a POSIX shell', async () => {
const component = 'dev.example.app/dev.example.app.MainActivity$Inner';
const launchArgs = [
'--es',
'text',
'hello world',
'--es',
'json',
'{"a":1,"b":2}',
'--es',
'shell',
'$(printf injected) `printf injected` $HOME ; & | < >',
'--es',
'glob',
'*',
'--es',
'home',
'~',
'--esa',
'list',
'a\\,b,c',
'--es',
'whitespace',
'one\ntwo\tthree',
'--es',
'unicode',
'héllo 🌍',
'--es',
'empty',
'',
];
const openUrl = 'exp://example.test/path?a=1&b=2#fragment';
await launchApplicationAsync({
applicationIdentifier: 'dev.example.app',
activityName: 'dev.example.app.MainActivity$Inner',
launchArgs,
openUrl,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
});
// adb joins the words after `shell` with spaces and the device shell parses the result.
const parsedCommands = mockedSpawn.mock.calls.map(([, args]) => {
const { stdout } = spawnSync(
'/bin/sh',
['-c', `am() { printf '%s\\0' "$@"; }; ${(args as string[]).slice(1).join(' ')}`],
{ encoding: 'utf8' }
);
return stdout.split('\0').slice(0, -1);
});
expect(parsedCommands).toEqual([
['start', ...launchArgs, '-n', component],
['start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
]);
});
it.each([[['--es', 'key', '']], [['-e', 'key', '']], [['-d', '']]])(
'passes empty Android option values as empty arguments (%j)',
async launchArgs => {
await launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
});
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
[
'shell',
'am',
'start',
...launchArgs.map(argument => (argument === '' ? "''" : argument)),
'-n',
'com.example.app/com.example.app.MainActivity',
],
expect.any(Object)
);
}
);
it('explains an Android launch failure when a launch argument is empty', async () => {
const launchError = new Error('adb shell am start exited with non-zero code: 1');
mockedSpawn.mockRejectedValueOnce(launchError);
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs: ['--ez', 'isTest', 'true', ''],
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({
errorCode: 'EAS_LAUNCH_APPLICATION_FAILED',
message: expect.stringContaining('empty argument at position 4'),
cause: launchError,
});
});
it('rethrows an Android launch failure unchanged when no launch argument is empty', async () => {
const launchError = new Error('adb shell am start exited with non-zero code: 1');
mockedSpawn.mockRejectedValueOnce(launchError);
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs: ['--ez', 'isTest', 'true'],
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toBe(launchError);
});
it.each([
{ launchArgs: ['--es', 'key', 'a\0b'] },
{ launchArgs: [], openUrl: 'exp://example.test/a\0b' },
])('rejects NUL characters on Android (%j)', async ({ launchArgs, openUrl }) => {
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs,
openUrl,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
expect(mockedSpawn).not.toHaveBeenCalledWith(
'adb',
expect.arrayContaining([expect.stringContaining('\0')]),
expect.any(Object)
);
});
it('accepts an Android launch command of exactly 65,000 bytes', async () => {
const fixedLength = 'am start --es key -n com.example.app/com.example.app.MainActivity'.length;
await launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs: ['--es', 'key', 'a'.repeat(65_000 - fixedLength)],
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
});
expect(mockedSpawn).toHaveBeenCalledTimes(1);
});
it('rejects an Android URL command of 65,001 bytes', async () => {
const fixedLength = 'am start -a android.intent.action.VIEW -d exp://example.test/'.length;
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
openUrl: `exp://example.test/${'a'.repeat(65_001 - fixedLength)}`,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
expect(mockedSpawn).toHaveBeenCalledTimes(1);
});
it('rejects an Android launch command that is too long after quoting', async () => {
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs: ['--es', 'quotes', "'".repeat(20_000)],
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('launches using application metadata passed to the build step', async () => {
const launchApplication = createLaunchApplicationFunction();
const buildStep = launchApplication.createBuildStepFromFunctionCall(
@@ -237,6 +495,36 @@ describe(launchApplicationAsync, () => {
);
});
it('quotes Android launch arguments passed to the build step', async () => {
const launchApplication = createLaunchApplicationFunction();
const buildStep = launchApplication.createBuildStepFromFunctionCall(
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
{
callInputs: {
application_identifier: 'com.example.app',
activity_name: 'com.example.app.MainActivity',
launch_args: ['--es', 'probe', 'hello world'],
},
}
);
await buildStep.executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
[
'shell',
'am',
'start',
'--es',
'probe',
"'hello world'",
'-n',
'com.example.app/com.example.app.MainActivity',
],
expect.any(Object)
);
});
it.each([{}, ['valid', 42]])('rejects invalid launch arguments (%j)', async launchArgs => {
const launchApplication = createLaunchApplicationFunction();
const buildStep = launchApplication.createBuildStepFromFunctionCall(
@@ -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';
@@ -305,7 +305,6 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name:
format === 'html' ? 'Maestro Runner HTML Report' : 'Maestro Runner Allure Results',
paths: [
@@ -1091,7 +1090,6 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name: 'Maestro HTML Report',
paths: ['/home/expo/.maestro/tests/android-maestro-html.html'],
}),
@@ -1420,7 +1418,6 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name: shot.displayName,
metadata: shot.metadata,
}),
@@ -7,6 +7,7 @@ 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,
@@ -14,6 +15,12 @@ import {
registerLocalEgressResources,
stopLocalEgressResourcesAsync,
} from '../../utils/localEgress';
import {
reportLocalEgressGuardCoverageAsync,
resolveLocalEgressServeSimBootEnvironmentAsync,
startLocalEgressGuardRelayAsync,
verifyLocalEgressGuardAsync,
} from '../../utils/localEgressGuard';
import {
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
@@ -51,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',
@@ -69,6 +83,26 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
waitForDeviceRunSessionStoppedAsync: 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 logger = {
info: jest.fn(),
warn: jest.fn(),
@@ -111,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);
@@ -126,9 +162,20 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
jest.mocked(isProcessDescendantOfAsync).mockResolvedValue(true);
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ udid: 'sim' }] as never);
.mockResolvedValue([{ udid: 'sim', name: 'iPhone 17' }] as never);
jest.mocked(IosSimulatorUtils.resolveUdidAsync).mockResolvedValue('sim' as never);
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(handoff);
jest
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
.mockImplementation(async () =>
(await readLocalEgressHandoffAsync())
? { SERVE_SIM_ADDITIONAL_DYLIBS: '/test/egress-guard.dylib' }
: null
);
jest.mocked(startLocalEgressGuardRelayAsync).mockResolvedValue(undefined);
jest.mocked(reportLocalEgressGuardCoverageAsync).mockResolvedValue(null);
jest.mocked(verifyLocalEgressGuardAsync).mockResolvedValue(undefined);
jest.mocked(monitorLocalEgressAsync).mockResolvedValue(undefined);
openPreview.mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
@@ -138,10 +185,11 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
});
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: openPreview,
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopPreview,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
lifetimeWait.mockImplementation(async () => {
expect(lifetime.aborted).toBe(false);
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(1);
});
@@ -151,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,
@@ -176,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);
@@ -195,7 +255,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
} 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);
}
@@ -209,11 +269,10 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
);
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,
@@ -103,7 +103,7 @@ describe(createRunAgentBuildFunction, () => {
);
const npmEnv = installMock.mock.calls[0][2]!.env!;
const agentEnv = spawnMock.mock.calls[0][2].env!;
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'LANG', 'PATH', 'TMPDIR']);
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'PATH']);
expect(Object.keys(agentEnv).sort()).toEqual([
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
'CLAUDE_CODE_OAUTH_TOKEN',
@@ -1,14 +1,18 @@
import { BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fetch from 'node-fetch';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Readable } from 'node:stream';
import { type CustomBuildContext } from '../../../customBuildContext';
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
import { uploadDeviceRunSessionArtifactAsync } from '../../utils/deviceRunSessionArtifacts';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import {
DeviceRunSessionStoppedError,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
@@ -21,6 +25,8 @@ import {
} from '../../utils/remoteDeviceRunSession';
import { createProcessOutput } from '../../utils/processOutput';
import { type StartupTasks, createStartupTasks } from '../../utils/startupTasks';
import { createWarmDeviceSessionServer } from '../../utils/warmDeviceSessionHost';
import { type WarmIosSession } from '../../utils/warmIosSession';
import { runAgentDeviceRemoteSessionAsync } from '../startAgentDeviceRemoteSession';
// The daemon entry path and the state directory are resolved from the home directory when
@@ -38,10 +44,14 @@ jest.mock('../../../sentry');
jest.mock('../../utils/agentDeviceArtifacts', () => ({
pollAgentDeviceArtifactsForUploadAsync: jest.fn(),
}));
jest.mock('../../utils/deviceRunSessionArtifacts', () => ({
uploadDeviceRunSessionArtifactAsync: jest.fn(),
}));
jest.mock('../../utils/agentDeviceEvents', () => ({
startAgentDeviceEventCollectionAsync: jest.fn(),
}));
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/warmDeviceSessionHost');
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
getDeviceRunSessionIdOrThrow: jest.fn(),
@@ -70,13 +80,11 @@ const mockEventCollectionStopAsync = jest.fn();
type CaptureInputs = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['capture'];
const NO_CAPTURE: CaptureInputs = { networkCapture: false, networkCaptureFields: [] };
/** Runs the session for a device that is already booted, with the app (if any) launched. */
/** Runs an Android session that is ready, or an iOS session with no application preparation. */
async function runAsync(
logger: { info: jest.Mock; warn: jest.Mock; child: jest.Mock },
runtimePlatform: BuildRuntimePlatform,
capture: CaptureInputs = NO_CAPTURE,
disableShare = false,
privatePreviewUrl?: string
options: Partial<Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]> = {}
): Promise<void> {
await runAgentDeviceRemoteSessionAsync(ctx, {
env: {},
@@ -90,15 +98,29 @@ async function runAsync(
packageVersion: undefined,
maxIdleTimeMinutes: undefined,
maxDurationSeconds: undefined,
capture,
disableShare,
privatePreviewUrl,
tasks: createStartupTasks(logger as never),
device: { booted: Promise.resolve(), ready: Promise.resolve() },
capture: NO_CAPTURE,
tasks: createStartupTasks(logger as never, options.signal),
device:
runtimePlatform === BuildRuntimePlatform.DARWIN
? { iosSimulatorUdid: 'selected-udid', application: Promise.resolve() }
: { booted: Promise.resolve(), ready: Promise.resolve() },
...options,
});
}
describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
it('cleans up a stopped startup without starting live-session polling', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValueOnce('stopped');
await runAsync(logger, BuildRuntimePlatform.DARWIN);
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
expect(pollAgentDeviceArtifactsForUploadAsync).not.toHaveBeenCalled();
expect(startAgentDeviceEventCollectionAsync).not.toHaveBeenCalled();
expect(mockDaemonStopAsync).toHaveBeenCalled();
expect(mockTunnelStopAsync).toHaveBeenCalled();
expect(mockPreviewStopAsync).toHaveBeenCalled();
});
beforeEach(async () => {
jest.clearAllMocks();
@@ -124,15 +146,26 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
subdomainId: 'agent-device-abc',
stopAsync: mockTunnelStopAsync,
});
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
const host = {
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.tunnel.example.com',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: mockPreviewStopAsync,
};
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async (_ctx, options) => {
try {
await options.application;
options.signal?.throwIfAborted();
} catch (error) {
await mockPreviewStopAsync();
throw error;
}
return host;
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
await fs.promises.mkdir(path.dirname(DAEMON_ENTRY_PATH), { recursive: true });
@@ -150,7 +183,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, timeoutMs: 60_000 })
);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
@@ -168,6 +201,228 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
});
it('does not fail a session when only the agent-device tunnel cannot stop', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const tunnelError = new Error('Ngrok tunnel stop timed out after 4000ms.');
mockTunnelStopAsync.mockRejectedValueOnce(tunnelError);
await expect(runAsync(logger, BuildRuntimePlatform.LINUX)).resolves.toBeUndefined();
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: tunnelError },
'Could not stop the agent-device tunnel during remote session teardown.'
);
});
it('uses the warm daemon after adoption and starts no daemon of its own', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const warmServer = { port: 3200 } as never;
jest.mocked(createWarmDeviceSessionServer).mockReturnValue(warmServer);
const warm = {
agentDeviceStateDir: '/warm/agent',
tools: { pid: 20, port: 4310, token: 'warm-token' },
} as WarmIosSession;
await runAgentDeviceRemoteSessionAsync(ctx, {
env: {},
logger: logger as never,
runtimePlatform: BuildRuntimePlatform.DARWIN,
sessionEnv: {
deviceRunSessionId: 'device-run-session-id',
ngrokTunnelDomain: 'tunnel.example.com',
ngrokAuthtoken: 'ngrok-token',
},
packageVersion: undefined,
maxIdleTimeMinutes: undefined,
maxDurationSeconds: undefined,
capture: NO_CAPTURE,
tasks: createStartupTasks(logger as never),
warm,
device: { iosSimulatorUdid: 'UDID', application: Promise.resolve() },
});
expect(spawnDetached).not.toHaveBeenCalled();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(ctx, expect.anything(), warmServer);
expect(startNgrokTunnelAsync).toHaveBeenCalledWith(expect.objectContaining({ port: 4310 }));
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({ agentDeviceRemoteSessionToken: 'warm-token' }),
})
);
expect(startAgentDeviceEventCollectionAsync).toHaveBeenCalledWith(
expect.objectContaining({ stateDir: '/warm/agent' })
);
expect(mockEventCollectionStopAsync).toHaveBeenCalledTimes(1);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
it.each([false, true])(
'finishes the final artifact upload before stopping its owner (warm=%s)',
async warm => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const { pollAgentDeviceArtifactsForUploadAsync: pollActual } = jest.requireActual<
typeof import('../../utils/agentDeviceArtifacts')
>('../../utils/agentDeviceArtifacts');
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockImplementation(pollActual);
const { Response } = jest.requireActual<typeof import('node-fetch')>('node-fetch');
const artifacts: { id: string; filename: string }[] = [];
let releaseUpload = () => {};
const pendingUpload = new Promise<void>(resolve => {
releaseUpload = resolve;
});
let markUploadStarted = () => {};
const uploadStarted = new Promise<void>(resolve => {
markUploadStarted = resolve;
});
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementationOnce(async () => {
markUploadStarted();
await pendingUpload;
});
let inventories = 0;
jest.mocked(fetch).mockImplementation(async url => {
if (String(url).endsWith('/artifacts')) {
inventories += 1;
return new Response(JSON.stringify({ artifacts }));
}
return new Response(Readable.from([Buffer.from('screenshot')]));
});
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
while (inventories === 0) {
await new Promise(resolve => setTimeout(resolve, 5));
}
// The next periodic inventory is 5 seconds away, so only the final one can see this file.
artifacts.push({ id: 'late', filename: 'late.png' });
});
const running = runAsync(
logger,
warm ? BuildRuntimePlatform.DARWIN : BuildRuntimePlatform.LINUX,
{
warm: warm
? ({
agentDeviceStateDir: '/warm/agent',
tools: { port: 4310, token: 'warm-token' },
} as WarmIosSession)
: undefined,
}
);
try {
await uploadStarted;
expect(mockDaemonStopAsync).not.toHaveBeenCalled();
if (warm) {
expect(mockPreviewStopAsync).not.toHaveBeenCalled();
}
} finally {
releaseUpload();
await running;
}
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ artifactId: 'late', filename: 'late.png' })
);
expect(inventories).toBe(2);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(warm ? 0 : 1);
}
);
it('stops the daemon when the final artifact drain fails', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const failure = new Error('drain failed');
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockRejectedValue(failure);
await expect(runAsync(logger, BuildRuntimePlatform.LINUX)).rejects.toBe(failure);
expect(pollAgentDeviceArtifactsForUploadAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ stopPollingSignal: expect.any(AbortSignal) })
);
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
});
it.each([false, true])(
'stops the warm host after a failed drain (collector startup fails=%s)',
async startupFails => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const drainFailure = new Error('drain failed');
const startupFailure = new Error('event collector failed');
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockRejectedValueOnce(drainFailure);
if (startupFails) {
jest.mocked(startAgentDeviceEventCollectionAsync).mockRejectedValueOnce(startupFailure);
}
await expect(
runAsync(logger, BuildRuntimePlatform.DARWIN, {
warm: {
agentDeviceStateDir: '/warm/agent',
tools: { port: 4310, token: 'warm-token' },
} as WarmIosSession,
})
).rejects.toBe(startupFails ? startupFailure : drainFailure);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(mockDaemonStopAsync).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
{ err: drainFailure },
'Could not stop the agent-device daemon during remote session teardown.'
);
}
);
it('cancels a warm upload and drains its poller before stopping the host', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const controller = new AbortController();
const cancellation = new Error('job cancelled');
const { pollAgentDeviceArtifactsForUploadAsync: pollActual } = jest.requireActual<
typeof import('../../utils/agentDeviceArtifacts')
>('../../utils/agentDeviceArtifacts');
const pollingFinished = jest.fn();
jest
.mocked(pollAgentDeviceArtifactsForUploadAsync)
.mockImplementation((...args) => pollActual(...args).finally(pollingFinished));
const { Response } = jest.requireActual<typeof import('node-fetch')>('node-fetch');
jest
.mocked(fetch)
.mockImplementation(async url =>
String(url).endsWith('/artifacts')
? new Response(JSON.stringify({ artifacts: [{ id: 'active', filename: 'active.png' }] }))
: new Response(Readable.from(['png']))
);
let markUploadStarted = () => {};
const uploadStarted = new Promise<void>(resolve => {
markUploadStarted = resolve;
});
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { signal }) => {
markUploadStarted();
await new Promise<void>((_resolve, reject) => {
signal?.addEventListener('abort', () => reject(signal.reason), { once: true });
});
});
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementationOnce(async () => {
await uploadStarted;
controller.abort(cancellation);
throw cancellation;
});
await expect(
runAsync(logger, BuildRuntimePlatform.DARWIN, {
signal: controller.signal,
warm: {
agentDeviceStateDir: '/warm/agent',
tools: { port: 4310, token: 'warm-token' },
} as WarmIosSession,
})
).rejects.toBe(cancellation);
expect(pollingFinished).toHaveBeenCalledTimes(1);
expect(pollingFinished.mock.invocationCallOrder[0]).toBeLessThan(
mockPreviewStopAsync.mock.invocationCallOrder[0]
);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(mockDaemonStopAsync).not.toHaveBeenCalled();
});
it('redacts learned daemon credentials in arbitrary output and retained diagnostics', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const token = 'daemon secret/+';
@@ -189,12 +444,26 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(encodeURIComponent(token));
});
it('resets the idle timer on preview input from the session host', async () => {
const previewInputAt = new Date('2026-10-19T12:00:00Z');
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(ctx, {
application: Promise.resolve(),
} as never);
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(previewInputAt);
await runAsync(logger, BuildRuntimePlatform.LINUX, { maxIdleTimeMinutes: 5 });
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(previewInputAt);
});
it('hands network capture to serve-sim', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
networkCapture: true,
networkCaptureFields: ['header'],
capture: { networkCapture: true, networkCaptureFields: ['header'] },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
@@ -205,13 +474,10 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
it('hands a private preview choice to the session host', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
await runAsync(
logger,
BuildRuntimePlatform.DARWIN,
NO_CAPTURE,
true,
'https://expo.dev/private-preview'
);
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
disableShare: true,
privatePreviewUrl: 'https://expo.dev/private-preview',
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
@@ -289,14 +555,14 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
});
describe('runAgentDeviceRemoteSessionAsync with a device that is still starting', () => {
function deferred(): {
promise: Promise<void>;
resolve: () => void;
function deferred<T = void>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (e: Error) => void;
} {
let resolve!: () => void;
let resolve!: (value: T) => void;
let reject!: (e: Error) => void;
const promise = new Promise<void>((res, rej) => {
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
@@ -304,18 +570,27 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
return { promise, resolve, reject };
}
type Device = { booted: Promise<unknown>; ready: Promise<unknown> };
function startSession(device: Device | ((tasks: StartupTasks) => Device)) {
type Device = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['device'];
function startSession(
device: Device | ((tasks: StartupTasks) => Device),
signal?: AbortSignal,
onStarted?: () => Promise<void>
) {
const logger = {
info: jest.fn(),
warn: jest.fn(),
child: jest.fn().mockReturnThis(),
} as never;
const tasks = createStartupTasks(logger);
const tasks = createStartupTasks(logger, signal);
const sessionDevice = typeof device === 'function' ? device(tasks) : device;
return runAgentDeviceRemoteSessionAsync(ctx, {
env: {},
logger,
runtimePlatform: BuildRuntimePlatform.DARWIN,
signal,
runtimePlatform:
'iosSimulatorUdid' in sessionDevice
? BuildRuntimePlatform.DARWIN
: BuildRuntimePlatform.LINUX,
sessionEnv: {
deviceRunSessionId: 'device-run-session-id',
ngrokTunnelDomain: 'tunnel.example.com',
@@ -326,7 +601,8 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
maxDurationSeconds: undefined,
capture: NO_CAPTURE,
tasks,
device: typeof device === 'function' ? device(tasks) : device,
device: sessionDevice,
onStarted,
});
}
@@ -336,7 +612,31 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
}
}
it('starts the daemon during the boot and the session host after it', async () => {
it('aborts parallel startup before host failure cleanup finishes', async () => {
const failure = new Error('guard refused startup');
const cleanup = deferred();
const notified = deferred();
let startupTasks!: StartupTasks;
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async (_ctx, options) => {
options.onStartupError?.(failure);
notified.resolve();
await cleanup.promise;
throw failure;
});
const session = startSession(tasks => {
startupTasks = tasks;
return { iosSimulatorUdid: 'selected-udid' };
});
const rejected = expect(session).rejects.toBe(failure);
await notified.promise;
expect(startupTasks.signal.aborted).toBe(true);
expect(startupTasks.signal.reason).toBe(failure);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
cleanup.resolve();
await rejected;
});
it('starts the daemon during the Android boot and the session host after it', async () => {
const booted = deferred();
const session = startSession({ booted: booted.promise, ready: booted.promise });
await flushAsync();
@@ -348,10 +648,349 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
booted.resolve();
await session;
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
runtimePlatform: BuildRuntimePlatform.LINUX,
timeoutMs: 60_000,
iosSimulatorUdid: undefined,
})
);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
});
it('reports the session as ready only after the app is launched', async () => {
it('preserves a failed download when aborting daemon installation rejects first', async () => {
const failure = new Error('download failed: build not found');
const installStarted = deferred();
jest.mocked(spawn).mockImplementation((async (_command, _args, options) => {
return await new Promise((_resolve, reject) => {
options!.signal!.addEventListener(
'abort',
() => {
const error = new Error('The operation was aborted');
error.name = 'AbortError';
reject(error);
},
{ once: true }
);
installStarted.resolve();
});
}) as typeof spawn);
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async (_ctx, { signal }) => {
return await new Promise((_resolve, reject) => {
signal!.addEventListener(
'abort',
() => {
setTimeout(() => reject(signal!.reason), 20);
},
{ once: true }
);
});
});
const applicationReady = Promise.resolve();
const session = startSession(tasks => {
void tasks.run('build download', async () => {
await installStarted.promise;
throw failure;
});
return { iosSimulatorUdid: 'selected-udid', application: applicationReady };
});
await expect(session).rejects.toBe(failure);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('boots the host during download and releases startup only after preparation', async () => {
const hostReady = deferred();
const application = deferred<{
installAppPath: string;
launchAppIdentifier: string;
launchArgs: string[];
openUrl: string;
}>();
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
ctx,
{} as never
);
const release = jest.fn();
jest
.mocked(startDeviceSessionHostAsync)
.mockClear()
.mockImplementation(async (_ctx, options) => {
release(await options.application);
await hostReady.promise;
return host;
});
const applicationReady = application.promise;
const session = startSession({
iosSimulatorUdid: 'selected-udid',
application: applicationReady,
});
await flushAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
expect(release).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
const startup = {
installAppPath: '/tmp/App.app',
launchAppIdentifier: 'dev.example.app',
launchArgs: ['--flag'],
openUrl: 'example://screen',
};
application.resolve(startup);
await flushAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
application: applicationReady,
iosSimulatorUdid: 'selected-udid',
timeoutMs: 60_000,
signal: expect.any(AbortSignal),
})
);
expect(release).toHaveBeenCalledWith(startup);
expect(host.openPreviewAsync).not.toHaveBeenCalled();
hostReady.resolve();
await session;
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
it('passes the guard boot environment with the startup installation and launch', async () => {
const startup = {
installAppPath: '/tmp/App.app',
launchAppIdentifier: 'dev.example.app',
launchArgs: ['--flag'],
openUrl: 'example://screen',
};
const bootEnv = {
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
SIMCTL_CHILD_EAS_EGRESS_GUARD_MODE: 'block',
SIMCTL_CHILD_EAS_EGRESS_GUARD_LOG: '/tmp/egress-guard.log',
SIMCTL_CHILD_HTTP_PROXY: 'http://127.0.0.1:8899',
};
await startSession({
iosSimulatorUdid: 'guarded-udid',
bootEnv,
application: Promise.resolve(startup),
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
application: expect.any(Promise),
bootEnv,
iosSimulatorUdid: 'guarded-udid',
})
);
const hostOptions = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
expect(hostOptions.bootEnv).toBe(bootEnv);
expect(hostOptions.bootEnv).not.toHaveProperty('DYLD_INSERT_LIBRARIES');
expect(hostOptions.bootEnv).not.toHaveProperty('SIMCTL_CHILD_DYLD_INSERT_LIBRARIES');
});
it('cleans up a host returned after cancellation without configuring the device', async () => {
const hostReady = deferred();
const controller = new AbortController();
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
ctx,
{} as never
);
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async () => {
await hostReady.promise;
return host;
});
const applicationReady = Promise.resolve();
const session = startSession(
{ iosSimulatorUdid: 'selected-udid', application: applicationReady },
controller.signal
);
const failure = expect(session).rejects.toThrow('cancelled');
await flushAsync();
controller.abort(new Error('cancelled'));
hostReady.resolve();
await failure;
expect(host.openPreviewAsync).not.toHaveBeenCalled();
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('drains in-flight application preparation and stops the waiting host after cancellation', async () => {
const controller = new AbortController();
const appReady = deferred();
const session = startSession(
{ iosSimulatorUdid: 'selected-udid', application: appReady.promise },
controller.signal
);
const failure = expect(session).rejects.toThrow('cancelled');
await flushAsync();
controller.abort(new Error('cancelled'));
await flushAsync();
expect(mockPreviewStopAsync).not.toHaveBeenCalled();
appReady.resolve();
await failure;
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('does not report readiness when cancellation arrives during preview startup', async () => {
const controller = new AbortController();
const previewReady = deferred();
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
ctx,
{} as never
);
const preview = await host.openPreviewAsync();
jest.mocked(host.openPreviewAsync).mockImplementation(async () => {
await previewReady.promise;
return preview;
});
const session = startSession(
{ iosSimulatorUdid: 'selected-udid', application: Promise.resolve() },
controller.signal
);
const failure = expect(session).rejects.toThrow('cancelled');
await flushAsync();
controller.abort(new Error('cancelled'));
previewReady.resolve();
await failure;
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('does not start resources for an already cancelled session', async () => {
const controller = new AbortController();
controller.abort(new Error('cancelled'));
const applicationReady = Promise.resolve();
await expect(
startSession(
{ iosSimulatorUdid: 'selected-udid', application: applicationReady },
controller.signal
)
).rejects.toThrow('cancelled');
expect(spawn).not.toHaveBeenCalled();
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
});
it('reports late host cleanup failure after startup Stop', async () => {
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
const failed = new AggregateError([stopped, new Error('host cleanup failed')]);
let entered!: () => void;
const starting = new Promise<void>(resolve => {
entered = resolve;
});
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
entered();
return await new Promise((_resolve, reject) => {
options.signal!.addEventListener('abort', () => setImmediate(() => reject(failed)), {
once: true,
});
});
});
let tasks!: StartupTasks;
const running = startSession(startup => {
tasks = startup;
return { booted: Promise.resolve(), ready: Promise.resolve() };
});
running.catch(() => {});
await starting;
tasks.abort(stopped);
await expect(running).rejects.toBe(failed);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('drains resources and preserves the explicit startup Stop reason', async () => {
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
let tasks!: StartupTasks;
const session = startSession(startup => {
tasks = startup;
return { booted: Promise.resolve(), ready: tasks.untilAborted(new Promise(() => {})) };
});
session.catch(() => {});
await flushAsync();
tasks.abort(stopped);
await expect(session).rejects.toBe(stopped);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
});
it('does not hide a teardown failure after an explicit startup Stop', async () => {
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
const failed = new Error('daemon teardown failed');
let tasks!: StartupTasks;
const session = startSession(startup => {
tasks = startup;
return { booted: Promise.resolve(), ready: tasks.untilAborted(new Promise(() => {})) };
});
session.catch(() => {});
await flushAsync();
mockDaemonStopAsync.mockRejectedValueOnce(failed);
tasks.abort(stopped);
await expect(session).rejects.toBe(failed);
});
it('reports a startup Stop when only the agent-device tunnel cannot stop', async () => {
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
let tasks!: StartupTasks;
const session = startSession(startup => {
tasks = startup;
return { booted: Promise.resolve(), ready: tasks.untilAborted(new Promise(() => {})) };
});
session.catch(() => {});
await flushAsync();
mockTunnelStopAsync.mockRejectedValueOnce(new Error('tunnel stop timed out'));
tasks.abort(stopped);
await expect(session).rejects.toBe(stopped);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
});
it('retires startup monitoring after reporting and before lifetime waiting', async () => {
const retired = deferred();
const retiring = jest.fn(() => retired.promise);
const session = startSession(
{ booted: Promise.resolve(), ready: Promise.resolve() },
undefined,
retiring
);
await flushAsync();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
expect(retiring).toHaveBeenCalledTimes(1);
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
retired.resolve();
await session;
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledTimes(1);
});
it('aborts a pending ready report when startup is stopped', async () => {
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
let tasks!: StartupTasks;
jest.mocked(uploadRemoteSessionConfigAsync).mockImplementation(
async ({ signal }) =>
new Promise((_resolve, reject) => {
signal!.addEventListener('abort', () => reject(new Error('request aborted')), {
once: true,
});
})
);
const session = startSession(startup => {
tasks = startup;
return { booted: Promise.resolve(), ready: Promise.resolve() };
});
session.catch(() => {});
await flushAsync();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({ signal: tasks.signal })
);
tasks.abort(stopped);
await expect(session).rejects.toBe(stopped);
expect(startAgentDeviceEventCollectionAsync).not.toHaveBeenCalled();
});
it('starts the Android host before application readiness and waits before reporting ready', async () => {
const ready = deferred();
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
await flushAsync();
@@ -361,10 +1000,11 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
ready.resolve();
await session;
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
});
it('stops waiting for the boot when the daemon fails', async () => {
it('stops waiting for the Android boot when the daemon fails', async () => {
jest.mocked(waitForFileAsync).mockRejectedValue(new Error('no daemon credentials'));
const neverBooted = new Promise<void>(() => {});
@@ -378,7 +1018,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('stops the session host when the app fails while the daemon install hangs', async () => {
it('cancels daemon installation and stops the Android host when the download fails', async () => {
jest.mocked(spawn).mockImplementation(((
command: string,
args: string[],
@@ -394,21 +1034,22 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
await flushAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
ready.reject(new Error('simctl install failed'));
ready.reject(new Error('download failed'));
await expect(session).rejects.toThrow('simctl install failed');
await expect(session).rejects.toThrow('download failed');
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(spawnDetached).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('stops everything it started when the app install fails', async () => {
it('stops the Android host, daemon and tunnel when the download fails', async () => {
const ready = deferred();
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
await flushAsync();
ready.reject(new Error('simctl install failed'));
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
ready.reject(new Error('download failed'));
await expect(session).rejects.toThrow('simctl install failed');
await expect(session).rejects.toThrow('download failed');
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
File diff suppressed because it is too large. Load diff
@@ -9,6 +9,9 @@ import {
} 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', () => ({
@@ -24,6 +27,13 @@ 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',
@@ -34,6 +44,7 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
waitForReadyAsync: jest.fn(),
disableWindowAndTransitionAnimationsAsync: jest.fn(),
deleteAsync: jest.fn(),
findFreeConsolePortAsync: jest.fn(),
},
}));
@@ -50,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) {
@@ -62,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([]);
@@ -90,6 +111,30 @@ 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 {
@@ -111,6 +156,7 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
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),
@@ -436,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();
});
});
});
File diff suppressed because it is too large. Load diff
@@ -7,8 +7,9 @@ import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import { isProcessDescendantOfAsync } from '../../../utils/processes';
import { isProcessDescendantOfAsync, killProcessGroup } from '../../../utils/processes';
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
import { startArgentEventCollectionAsync } from '../../utils/argentEvents';
import { createProcessOutput } from '../../utils/processOutput';
@@ -23,7 +24,15 @@ import {
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import {
createWarmDeviceSessionServer,
runOnWarmIosSimulatorAsync,
} from '../../utils/warmDeviceSessionHost';
import { type WarmIosSession } from '../../utils/warmIosSession';
import { watchDeviceRunSessionStartup } from '../../utils/deviceRunSessionStartup';
import { createStartupWatchMock } from '../../../__tests__/utils/startupWatch';
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact';
// Redirect ~/.argent (where the tool-server writes its state file and event log) to a temp
// home so waitForArgentToolServerStateAsync — which lives in the module under test and cannot
@@ -38,13 +47,33 @@ jest.mock('node:os', () => {
});
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('../../../sentry');
jest.mock('../../../utils/processes', () => ({ isProcessDescendantOfAsync: jest.fn() }));
jest.mock('../../../utils/processes', () => ({
isProcessDescendantOfAsync: jest.fn(),
killProcessGroup: jest.fn(),
}));
jest.mock('../../utils/argentArtifacts', () => ({ pollArgentArtifactsForUploadAsync: jest.fn() }));
jest.mock('../../utils/argentEvents', () => ({
...jest.requireActual('../../utils/argentEvents'),
startArgentEventCollectionAsync: jest.fn(),
}));
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/warmDeviceSessionHost');
jest.mock('../../utils/iosAppArtifact', () => ({
...jest.requireActual('../../utils/iosAppArtifact'),
readIosApplicationIdentifierAsync: jest.fn().mockResolvedValue('dev.example.app'),
}));
jest.mock('../../../utils/IosSimulatorUtils', () => ({
IosSimulatorUtils: {
resolveUdidAsync: jest.fn().mockResolvedValue('selected-ios-udid'),
getAvailableDevicesAsync: jest
.fn()
.mockResolvedValue([{ name: 'iPhone 17', udid: 'selected-ios-udid' }]),
},
}));
jest.mock('../../utils/localEgressGuard', () => ({
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn().mockResolvedValue(null),
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
ensureFfmpegInstalledOnceAsync: jest.fn(),
@@ -59,6 +88,9 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
jest.mock('../../utils/deviceRunSessionStartup', () => ({
watchDeviceRunSessionStartup: jest.fn(),
}));
const TEST_HOME = path.join(os.tmpdir(), 'eas-argent-orchestration-home');
const ARGENT_STATE_DIR = path.join(TEST_HOME, '.argent');
@@ -104,10 +136,12 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
apiUrl: 'https://web-preview.tunnel.example.com',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: mockPreviewStopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock);
// The (real) tool-server state wait reads this file from the redirected ~/.argent.
await fs.promises.mkdir(ARGENT_STATE_DIR, { recursive: true });
@@ -121,6 +155,386 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
await fs.promises.rm(TEST_HOME, { recursive: true, force: true });
});
it.each([
[BuildRuntimePlatform.DARWIN, false],
[BuildRuntimePlatform.LINUX, false],
[BuildRuntimePlatform.DARWIN, true],
])(
'handles Stop during %s setup, keeping teardown failure=%s visible',
async (runtimePlatform, teardownFails) => {
let rejectSetup!: (error: Error) => void;
const setup = Object.assign(
new Promise<never>((_resolve, reject) => {
rejectSetup = reject;
}),
{ child: { pid: 12345 } }
);
jest.mocked(spawn).mockReturnValueOnce(setup as unknown as ReturnType<typeof spawn>);
jest
.mocked(killProcessGroup)
.mockImplementationOnce(() => rejectSetup(new Error('setup killed')));
const teardownError = new Error('host teardown failed');
if (teardownFails) {
mockPreviewStopAsync.mockRejectedValueOnce(teardownError);
}
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
const running = Promise.resolve(
buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
boot_simulator: {
value: runtimePlatform === BuildRuntimePlatform.DARWIN ? true : undefined,
},
},
outputs: {},
env: {},
} as never
)
);
running.catch(() => {});
try {
for (let i = 0; i < 20 && !jest.mocked(spawn).mock.calls.length; i++) {
await new Promise(resolve => setImmediate(resolve));
}
expect(spawn).toHaveBeenCalledTimes(1);
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
watch.stop();
if (teardownFails) {
await expect(running).rejects.toBe(teardownError);
} else {
await expect(running).resolves.toBeUndefined();
}
expect(killProcessGroup).toHaveBeenCalledTimes(1);
expect(spawnDetached).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
expect(watch.finishAsync).toHaveBeenCalled();
} finally {
rejectSetup(new Error('test cleanup'));
await running.catch(() => {});
}
}
);
it('does not allocate Argent or a host after parent cancellation during Xcode selection', async () => {
const logger = { info: jest.fn(), warn: jest.fn() };
const controller = new AbortController();
const failure = new Error('parent step cancelled');
jest
.mocked(selectXcodeDeveloperDirectoryAsync)
.mockImplementationOnce(async () => controller.abort(failure));
await expect(
createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
signal: controller.signal,
} as never
)
).rejects.toBe(failure);
expect(spawnDetached).not.toHaveBeenCalled();
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
});
it('preserves an aggregate host startup failure', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const failure = new AggregateError([
new Error('host failed'),
new Error('host cleanup failed'),
]);
jest.mocked(startDeviceSessionHostAsync).mockRejectedValueOnce(failure);
await expect(
createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
boot_simulator: { value: true },
},
outputs: {},
env: {},
} as never
)
).rejects.toBe(failure);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('reports late host cleanup failure after startup Stop', async () => {
const ctx = {} as CustomBuildContext;
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
let entered!: () => void;
const starting = new Promise<void>(resolve => {
entered = resolve;
});
let failed!: AggregateError;
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
entered();
return await new Promise((_resolve, reject) => {
options.signal!.addEventListener(
'abort',
() => {
failed = new AggregateError([options.signal!.reason, new Error('host cleanup failed')]);
setImmediate(() => reject(failed));
},
{ once: true }
);
});
});
const running = createStartArgentRemoteSessionBuildFunction(ctx).fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
{
inputs: {
boot_simulator: { value: true },
device_identifier: { value: 'selected-ios-udid' },
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
) as Promise<void>;
running.catch(() => {});
await starting;
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
const failure = await running.then(
() => undefined,
error => error
);
expect(failure).toBe(failed);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('uses the resolved Argent version after a warm mismatch', async () => {
jest
.mocked(runOnWarmIosSimulatorAsync)
.mockImplementation((_claim, run) => run(undefined, '999.1.0'));
await createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext, { warm: true }).fn!(
{
logger: { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() },
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as never,
{
inputs: {
package_version: { value: 'latest' },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
);
expect(spawnDetached).toHaveBeenCalledWith(
expect.objectContaining({ args: expect.arrayContaining(['@swmansion/[email protected]']) })
);
});
it('keeps watching startup when warm adoption retries cold', async () => {
const ctx = {} as CustomBuildContext;
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const adoption = new Error('warm adoption failed');
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async (_claim, run) => {
try {
return await run({
device: { udid: 'selected-ios-udid', name: 'iPhone 17' },
} as WarmIosSession);
} catch (error) {
if (error !== adoption) {
throw error;
}
return await run(undefined);
}
});
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
options.onStartupError?.(adoption);
throw adoption;
});
await expect(
Promise.resolve(
createStartArgentRemoteSessionBuildFunction(ctx, { warm: true }).fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
{
inputs: {
boot_simulator: { value: true },
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
)
)
).resolves.toBeUndefined();
expect(watchDeviceRunSessionStartup).toHaveBeenCalledTimes(1);
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(2);
});
it.each([false, true])(
'observes Stop during warm claim and preserves cleanup failure=%s',
async failCleanup => {
const ctx = {} as CustomBuildContext;
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
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 = createStartArgentRemoteSessionBuildFunction(ctx, { warm: true }).fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
) as Promise<void>;
running.catch(() => {});
await claiming;
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
if (failCleanup) {
await expect(running).rejects.toMatchObject({ errors: expect.arrayContaining([cleanup]) });
} else {
await expect(running).resolves.toBeUndefined();
}
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
}
);
it('does not open the preview after Stop while the tools tunnel is opening', async () => {
let tunnelEntered!: () => void;
const entered = new Promise<void>(resolve => {
tunnelEntered = resolve;
});
let finishTunnel!: () => void;
const pendingTunnel = new Promise<void>(resolve => {
finishTunnel = resolve;
});
jest.mocked(startNgrokTunnelAsync).mockImplementationOnce(async () => {
tunnelEntered();
await pendingTunnel;
return {
url: 'https://argent.example.com',
subdomainId: 'argent',
stopAsync: mockTunnelStopAsync,
};
});
const openPreviewAsync = jest.fn();
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync,
finishAsync: mockPreviewStopAsync,
getLastPreviewActivityAt: jest.fn(),
});
const running = Promise.resolve(
createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
boot_simulator: { value: true },
},
outputs: {},
env: {},
} as never
)
);
running.catch(() => {});
try {
await entered;
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
} finally {
finishTunnel();
}
await expect(running).resolves.toBeUndefined();
expect(openPreviewAsync).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'preserves an earlier prepared %s host failure when Stop arrives during cleanup',
async runtimePlatform => {
const failure = new Error('Host became ready without a session token');
let cleanupEntered!: () => void;
const entered = new Promise<void>(resolve => {
cleanupEntered = resolve;
});
let finishCleanup!: () => void;
const cleanup = new Promise<void>(resolve => {
finishCleanup = resolve;
});
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
options.onStartupError?.(failure);
cleanupEntered();
await cleanup;
throw failure;
});
const running = Promise.resolve(
createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
)
);
running.catch(() => {});
try {
await entered;
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
} finally {
finishCleanup();
}
await expect(running).rejects.toBe(failure);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
}
);
it('reports an early exit with output and stops Argent before opening tunnels', async () => {
const stopError = new Error('drain timed out');
const stopServer = jest.fn().mockRejectedValue(stopError);
@@ -217,6 +631,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
if (phase === 'preview') {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: jest.fn().mockRejectedValue(error),
getLastPreviewActivityAt: jest.fn(),
finishAsync: mockPreviewStopAsync,
});
} else if (phase === 'config') {
@@ -252,7 +667,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
['succeeded', false],
['failed', true],
])(
'fails after stopping the host and tools when the tools tunnel cannot close and the session %s',
'stops the host and tools when the tools tunnel cannot close and the session %s',
async (_description, sessionFails) => {
const closeError = new Error('ngrok close failed');
const sessionError = new Error('session failed');
@@ -262,23 +677,26 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
}
const logger = { info: jest.fn(), warn: jest.fn() };
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
// The tunnel may still be serving, so its failure fails a session that otherwise succeeded.
await expect(
buildFunction.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
)
).rejects.toBe(sessionFails ? sessionError : closeError);
// A stopped tunnel's proxy drops every connection, so its failure does not fail a session.
const running = buildFunction.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
);
if (sessionFails) {
await expect(running).rejects.toBe(sessionError);
} else {
await expect(running).resolves.toBeUndefined();
}
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockStopAsync).toHaveBeenCalledTimes(1);
@@ -287,17 +705,13 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
{ err: closeError },
'Could not stop the Argent tunnel during remote session teardown.'
);
expect(jest.mocked(Sentry.capture).mock.calls).toEqual(
sessionFails
? [
[
'Could not stop the Argent tunnel after the remote session failed',
closeError,
{ level: 'warning' },
],
]
: []
);
expect(jest.mocked(Sentry.capture).mock.calls).toEqual([
[
`Could not stop the Argent tunnel after the remote session ${sessionFails ? 'failed' : 'ended'}`,
closeError,
{ level: 'warning' },
],
]);
}
);
@@ -422,6 +836,54 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
);
});
it('passes frame_ancestors to the session host', async () => {
const ctx = {} as CustomBuildContext;
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
await buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
frame_ancestors: { value: ['https://embed.example.test'] },
},
outputs: {},
env: {},
} as never
);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ frameAncestors: ['https://embed.example.test'] })
);
});
it('passes cors_origins to the session host', async () => {
const ctx = {} as CustomBuildContext;
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
await buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
cors_origins: { value: ['https://app.example.test'] },
},
outputs: {},
env: {},
} as never
);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
);
});
it('continues the session when disabling the video watermark fails', async () => {
const error = new Error('bun x failed');
jest
@@ -459,6 +921,35 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledTimes(1);
});
it('resets the idle timer on preview input from the session host', async () => {
const previewInputAt = new Date('2026-10-19T12:00:00Z');
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
{} as never,
{} as never
);
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(previewInputAt);
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
await buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: 5 },
},
outputs: {},
env: {},
} as never
);
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(previewInputAt);
});
it('redacts startup credentials and registers the state token for later output', async () => {
const token = 'tool/server "secret"';
await fs.promises.writeFile(
@@ -505,6 +996,72 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
expect(JSON.stringify(logger.info.mock.calls)).not.toContain('secret');
});
it('uses the warm Argent server and drains its artifacts before stopping the host', async () => {
const ctx = {} as unknown as CustomBuildContext;
const warmServer = { port: 3200 } as never;
const warm = {
device: { udid: 'WARM-UDID', name: 'iPhone 17' },
argentEventLogPath: '/warm/argent-events',
tools: { pid: 30, port: 3201, token: 'warm-token' },
} as WarmIosSession;
jest.mocked(createWarmDeviceSessionServer).mockReturnValue(warmServer);
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation((_claim, run) => run(warm));
let releaseArtifacts = () => {};
const artifactsDrained = new Promise<void>(resolve => {
releaseArtifacts = resolve;
});
let pollingStarted = () => {};
const polling = new Promise<void>(resolve => {
pollingStarted = resolve;
});
jest.mocked(pollArgentArtifactsForUploadAsync).mockImplementationOnce(async () => {
pollingStarted();
await artifactsDrained;
});
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx, { warm: true });
expect(buildFunction.id).toBe('start_warm_argent_remote_session');
if (!buildFunction.fn) {
throw new Error('Expected the Argent session function.');
}
const running = buildFunction.fn(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
max_duration_seconds: { value: undefined },
},
outputs: {},
env: {},
} as never
);
try {
await polling;
await new Promise<void>(resolve => setImmediate(resolve));
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalled();
expect(mockPreviewStopAsync).not.toHaveBeenCalled();
} finally {
releaseArtifacts();
await running;
}
expect(spawn).not.toHaveBeenCalled();
expect(spawnDetached).not.toHaveBeenCalled();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(ctx, expect.anything(), warmServer);
expect(startNgrokTunnelAsync).toHaveBeenCalledWith(expect.objectContaining({ port: 3201 }));
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({ toolsAuthToken: 'warm-token' }),
})
);
expect(startArgentEventCollectionAsync).toHaveBeenCalledWith(
expect.objectContaining({ eventLogPath: '/warm/argent-events' })
);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
const ctx = {} as unknown as CustomBuildContext;
const logger = { info: jest.fn(), warn: jest.fn() };
@@ -544,6 +1101,280 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
);
});
it('boots and prepares the downloaded app while Argent installation is pending, and closes the host if installation fails', async () => {
let rejectInstall!: (error: Error) => void;
let enteredInstall!: () => void;
const installEntered = new Promise<void>(resolve => {
enteredInstall = resolve;
});
jest.mocked(spawn).mockImplementationOnce(() => {
enteredInstall();
return new Promise((_resolve, reject) => {
rejectInstall = reject;
}) as never;
});
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
const running = buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
device_identifier: { value: 'chosen-device' },
install_app_path: { value: '/tmp/App.app' },
launch_args: { value: ['--literal'] },
},
outputs: {},
env: {},
} as never
);
await installEntered;
expect(
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
).toBeLessThan(jest.mocked(IosSimulatorUtils.resolveUdidAsync).mock.invocationCallOrder[0]);
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
artifactPath: '/tmp/App.app',
env: {},
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
iosSimulatorUdid: 'SELECTED-IOS-UDID',
installAppPath: '/tmp/App.app',
launchAppIdentifier: 'dev.example.app',
launchArgs: ['--literal'],
})
);
const error = new Error('Argent install failed');
rejectInstall(error);
await expect(running).rejects.toBe(error);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it.each(['artifacts-list-endpoint', 'tool-server-event-log', 'video-watermark'])(
'stops and drains the pending %s command when the concurrent host fails',
async flag => {
const hostError = new Error('serve-sim startup failed');
const commandError = new Error('Argent command exited after cancellation');
let failHost!: (error: Error) => void;
let finishHostCleanup!: () => void;
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(
async (_ctx, options) =>
await new Promise((_resolve, reject) => {
failHost = error => {
options.onStartupError?.(error);
finishHostCleanup = () => reject(error);
};
})
);
let failCommand!: (error: Error) => void;
let commandStarted!: () => void;
const commandEntered = new Promise<void>(resolve => {
commandStarted = resolve;
});
const child = { pid: 4242, kill: jest.fn() };
jest.mocked(spawn).mockImplementation((_command, args) => {
if (!args.includes(flag)) {
return Promise.resolve(undefined) as never;
}
const pending = new Promise((_resolve, reject) => {
failCommand = reject;
});
commandStarted();
return Object.assign(pending, { child }) as never;
});
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
const running = Promise.resolve(
buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
boot_simulator: { value: true },
},
outputs: {},
env: {},
} as never
)
);
let settled = false;
void running
.finally(() => {
settled = true;
})
.catch(() => {});
try {
await commandEntered;
failHost(hostError);
await new Promise(resolve => setImmediate(resolve));
expect(killProcessGroup).toHaveBeenCalledWith(child, 'SIGKILL');
expect(settled).toBe(false);
expect(spawnDetached).not.toHaveBeenCalled();
failCommand(commandError);
await new Promise(resolve => setImmediate(resolve));
expect(settled).toBe(false);
finishHostCleanup();
await expect(running).rejects.toBe(hostError);
expect(spawnDetached).not.toHaveBeenCalled();
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
} finally {
failCommand?.(commandError);
finishHostCleanup?.();
await running.catch(() => {});
}
}
);
it('interrupts tool-server state polling and retains the original host error', async () => {
await fs.promises.rm(path.join(ARGENT_STATE_DIR, 'tool-server-orchestration.json'));
const hostError = new Error('serve-sim startup failed');
let failHost!: (error: Error) => void;
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(
async () =>
await new Promise((_resolve, reject) => {
failHost = reject;
})
);
let pollingStarted!: () => void;
const pollingEntered = new Promise<void>(resolve => {
pollingStarted = resolve;
});
const getExitError = jest.fn<Error | undefined, []>(() => {
pollingStarted();
return undefined;
});
const stopServer = jest.fn().mockResolvedValue(undefined);
jest.mocked(spawnDetached).mockReturnValueOnce({
pid: 4242,
getOutput: () => 'later controller diagnostic',
getExitError,
stopAsync: stopServer,
});
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
const running = Promise.resolve(
buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
boot_simulator: { value: true },
},
outputs: {},
env: {},
} as never
)
);
void running.catch(() => {});
try {
await pollingEntered;
failHost(hostError);
await new Promise(resolve => setImmediate(resolve));
const options = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
expect(options.signal?.aborted).toBe(true);
getExitError.mockReturnValue(new Error('later Argent exit'));
await expect(running).rejects.toBe(hostError);
expect(stopServer).toHaveBeenCalledTimes(1);
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
} finally {
getExitError.mockReturnValue(new Error('stop pending state poll'));
await running.catch(() => {});
}
});
it('keeps Argent setup concurrent but waits for the iOS host before opening tools', async () => {
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
{} as CustomBuildContext,
{} as never
);
let releaseHost!: () => void;
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
new Promise(resolve => {
releaseHost = () => resolve(host);
})
);
let stateReady!: () => void;
const stateRead = new Promise<void>(resolve => {
stateReady = resolve;
});
jest.mocked(isProcessDescendantOfAsync).mockImplementationOnce(async () => {
stateReady();
return true;
});
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
const running = Promise.resolve(
buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
boot_simulator: { value: true },
},
outputs: {},
env: {},
} as never
)
);
try {
await stateRead;
await new Promise(resolve => setImmediate(resolve));
expect(spawnDetached).toHaveBeenCalledTimes(1);
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
} finally {
releaseHost();
await running;
}
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'makes %s prepared setup commands cancellable',
async runtimePlatform => {
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
await buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
);
expect(spawn).toHaveBeenCalledTimes(3);
for (const [, , options] of jest.mocked(spawn).mock.calls) {
expect(options).toHaveProperty('detached', true);
expect(options).not.toHaveProperty('signal');
}
expect(killProcessGroup).not.toHaveBeenCalled();
}
);
it('fails before starting anything when a launch is asked for on Android', async () => {
const ctx = {} as unknown as CustomBuildContext;
const logger = { info: jest.fn(), warn: jest.fn() };
@@ -1,4 +1,3 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import fs from 'node:fs';
import os from 'node:os';
@@ -6,79 +5,12 @@ import path from 'node:path';
import { Sentry } from '../../../sentry';
import {
MIN_ARGENT_REMOTE_SESSION_VERSION,
stopArgentEventCollectionSafelyAsync,
waitForArgentToolServerStateAsync,
warnIfArgentPackageVersionCannotBeVerified,
} from '../startArgentRemoteSession';
jest.mock('../../../sentry');
describe(warnIfArgentPackageVersionCannotBeVerified, () => {
const warn = jest.fn();
const logger = { warn } as unknown as bunyan;
const supportedVersion = '999.0.0';
const oldVersion = '0.0.0';
beforeEach(() => {
warn.mockClear();
});
it('allows the default latest package version', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: undefined, logger })
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: 'latest', logger })
).not.toThrow();
expect(warn).not.toHaveBeenCalled();
});
it('allows exact supported semver versions', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({
packageVersion: MIN_ARGENT_REMOTE_SESSION_VERSION,
logger,
})
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: supportedVersion, logger })
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({
packageVersion: `v${supportedVersion}`,
logger,
})
).not.toThrow();
expect(warn).not.toHaveBeenCalled();
});
it('rejects exact semver versions that are too old', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: oldVersion, logger })
).toThrow(SystemError);
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: oldVersion, logger })
).toThrow(`Use "latest" or pass an exact version >= ${MIN_ARGENT_REMOTE_SESSION_VERSION}`);
});
it('warns for package tags or ranges that cannot be verified', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: 'next', logger })
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({
packageVersion: `^${MIN_ARGENT_REMOTE_SESSION_VERSION}`,
logger,
})
).not.toThrow();
expect(warn).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Continuing and letting the package manager resolve it.')
);
});
});
describe(waitForArgentToolServerStateAsync, () => {
let stateDir: string;
@@ -1,159 +1,319 @@
import { SystemError } from '@expo/eas-build-job';
import { Client, fetchExchange } from '@urql/core';
import {
ArchiveSourceType,
EnvironmentSecretType,
SystemError,
UserError,
} from '@expo/eas-build-job';
import fetch from 'node-fetch';
import { stripRepositoryCredentialsAsync } from '../../../common/git';
import * as sandboxDaemon from '../../utils/sandboxDaemon';
import { SandboxWorkerApi } from '../../utils/sandboxWorkerApi';
import { createStartSandboxBuildFunction } from '../startSandbox';
import { createStartSandboxBuildFunction, markSandboxReadyAsync } from '../startSandbox';
const { AbortError, Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
jest.mock('../../../common/git');
const checkedOutStepCtx = { logger: {}, global: { wasCheckedOut: () => true } } as any;
const notCheckedOutStepCtx = { logger: {}, global: { wasCheckedOut: () => false } } as any;
const URL_JOB = {
projectArchive: { type: ArchiveSourceType.URL, url: 'https://example.com/project.tar.gz' },
};
const context = {
job: {
...URL_JOB,
secrets: {
environmentSecrets: [
{
name: 'EAS_SANDBOX_WORKER_TOKEN',
type: EnvironmentSecretType.STRING,
value: 'worker-token',
},
],
},
},
env: {},
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
mcpServerUrl: 'ws://localhost:8787',
};
const inputs = { sandbox_id: { value: 'sandbox-id' } };
describe('sandbox build functions', () => {
it('aborts an active ready request and keeps its authentication headers', async () => {
afterEach(() => {
jest.restoreAllMocks();
});
it.each([
{ completion: 'duration', duration: 0.02 },
{ completion: 'cancellation', duration: 60 },
{ completion: 'duration', duration: 0.0001 },
])(
'stops after $completion with $duration seconds only after readiness',
async ({ completion, duration }) => {
const controller = new AbortController();
let ready!: () => void;
const stopAsync = jest.fn(async () => {});
jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockResolvedValue({
ready: new Promise<void>(resolve => {
ready = resolve;
}),
stopAsync,
});
const markReady = jest
.spyOn(SandboxWorkerApi.prototype, 'markReadyAsync')
.mockResolvedValue(undefined);
const logger = { info: jest.fn(), child: jest.fn() };
logger.child.mockReturnValue(logger);
const fn = createStartSandboxBuildFunction(context as any);
const pending = fn.fn!({ logger } as any, {
inputs: { ...inputs, max_duration_seconds: { value: duration } },
outputs: {},
env: {},
signal: controller.signal,
});
await new Promise(resolve => setTimeout(resolve, 40));
expect(markReady).not.toHaveBeenCalled();
expect(stopAsync).not.toHaveBeenCalled();
ready();
if (completion === 'cancellation') {
await new Promise(resolve => setImmediate(resolve));
expect(stopAsync).not.toHaveBeenCalled();
controller.abort();
}
await pending;
expect(markReady).toHaveBeenCalledTimes(1);
expect(stopAsync).toHaveBeenCalledTimes(1);
const timeoutMessage = `Sandbox reached its maximum runtime of ${duration} seconds. Stopping.`;
if (completion === 'duration') {
expect(logger.info).toHaveBeenCalledWith(timeoutMessage);
} else {
expect(logger.info).not.toHaveBeenCalledWith(timeoutMessage);
}
}
);
it.each([0, -1, Infinity, NaN])(
'rejects invalid maximum duration %s before starting the daemon',
async duration => {
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync');
const fn = createStartSandboxBuildFunction(context as any);
const result = fn.fn!({} as any, {
inputs: { ...inputs, max_duration_seconds: { value: duration } },
outputs: {},
env: {},
});
await expect(result).rejects.toBeInstanceOf(UserError);
await expect(result).rejects.toThrow(
`Invalid sandbox maximum duration: ${duration}. Provide a finite number greater than zero, or omit max_duration_seconds.`
);
expect(start).not.toHaveBeenCalled();
}
);
it('connects with the worker token from the job secrets, which the step env does not have', async () => {
const env = { PREPARED_BY_EARLIER_STEP: 'value' };
const startupError = new Error('stop after checking options');
const start = jest
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
.mockRejectedValue(startupError);
const fn = createStartSandboxBuildFunction(context as any);
await expect(fn.fn!({ logger: {} } as any, { inputs, outputs: {}, env })).rejects.toBe(
startupError
);
expect(start.mock.calls[0][0]).toMatchObject({ credential: 'worker-token', env });
});
it('does not mark the sandbox ready if startup was canceled', async () => {
const controller = new AbortController();
const stopAsync = jest.fn(async () => {});
jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockImplementation(async options => {
expect(options.signal).toBe(controller.signal);
controller.abort();
return { ready: Promise.resolve(), stopAsync };
});
const fn = createStartSandboxBuildFunction(context as any);
await expect(
fn.fn!({ logger: {} } as any, { inputs, outputs: {}, env: {}, signal: controller.signal })
).rejects.toMatchObject({ name: 'AbortError' });
expect(fetch).not.toHaveBeenCalled();
expect(stopAsync).toHaveBeenCalledTimes(1);
});
it('marks the sandbox ready with only the worker token and aborts the request when canceled', async () => {
const controller = new AbortController();
const stopAsync = jest.fn(async () => {});
jest
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
.mockResolvedValue({ ready: Promise.resolve(), stopAsync });
let requestStarted!: () => void;
const started = new Promise<void>(resolve => {
requestStarted = resolve;
});
const fetchMock = jest.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => {
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer test-token');
return await new Promise<Response>((_resolve, reject) => {
init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason), { once: true });
jest.mocked(fetch).mockImplementationOnce(async (url, init) => {
expect(url).toBe('https://api.expo.test/v2/sandbox-worker/ready');
expect(init?.headers).toEqual({ 'expo-sandbox-worker-token': 'worker-token' });
return await new Promise((_resolve, reject) => {
init!.signal!.addEventListener('abort', () => reject(new AbortError('Aborted')));
requestStarted();
});
});
const graphqlClient = new Client({
url: 'https://api.expo.test/graphql',
exchanges: [fetchExchange],
fetchOptions: { headers: { Authorization: 'Bearer test-token' } },
const fn = createStartSandboxBuildFunction(context as any);
const pending = fn.fn!({ logger: {} } as any, {
inputs,
outputs: {},
env: {},
signal: controller.signal,
});
try {
const pending = markSandboxReadyAsync(
{ graphqlClient } as any,
'sandbox-id',
controller.signal
);
const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' });
await started;
controller.abort();
await rejected;
} finally {
fetchMock.mockRestore();
}
const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' });
await started;
controller.abort();
await rejected;
expect(stopAsync).toHaveBeenCalledTimes(1);
});
it('does not mark the sandbox ready if startup was canceled', async () => {
const controller = new AbortController();
it('throws a system error and stops the daemon when readiness fails', async () => {
const stopAsync = jest.fn(async () => {});
const start = jest
jest
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
.mockImplementation(async options => {
expect(options.signal).toBe(controller.signal);
controller.abort();
return { ready: Promise.resolve(), stopAsync };
});
const mutation = jest.fn();
const fn = createStartSandboxBuildFunction({
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
mcpServerUrl: 'ws://localhost:8787',
graphqlClient: { mutation },
} as any);
try {
await expect(
fn.fn!({ logger: {} } as any, {
inputs: { sandbox_id: { value: 'sandbox-id' } },
outputs: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
signal: controller.signal,
})
).rejects.toMatchObject({ name: 'AbortError' });
expect(mutation).not.toHaveBeenCalled();
expect(stopAsync).toHaveBeenCalledTimes(1);
} finally {
start.mockRestore();
}
.mockResolvedValue({ ready: Promise.resolve(), stopAsync });
jest.mocked(fetch).mockResolvedValueOnce(
new Response(JSON.stringify({ errors: [{ message: 'Sandbox has stopped.' }] }), {
status: 400,
})
);
const fn = createStartSandboxBuildFunction(context as any);
const result = fn.fn!({ logger: {} } as any, { inputs, outputs: {}, env: {} });
await expect(result).rejects.toBeInstanceOf(SystemError);
await expect(result).rejects.toMatchObject({
message: 'Failed to mark sandbox sandbox-id as ready.',
cause: expect.objectContaining({ status: 400 }),
});
expect(stopAsync).toHaveBeenCalledTimes(1);
});
it('excludes the MCP token from the command environment', async () => {
const env = {
PREPARED_BY_EARLIER_STEP: 'value',
EXPO_TOKEN: 'expo-token',
__EAS_SANDBOX_MCP_TOKEN: 'token',
};
it('strips the repository credentials before starting the daemon for git sources', async () => {
const startupError = new Error('stop after checking options');
const start = jest
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
.mockRejectedValue(startupError);
const fn = createStartSandboxBuildFunction({
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
mcpServerUrl: 'ws://localhost:8787',
} as any);
try {
await expect(
fn.fn!({ logger: {} } as any, {
inputs: { sandbox_id: { value: 'sandbox-id' } },
outputs: {},
env,
})
).rejects.toBe(startupError);
expect(start.mock.calls[0][0]).toMatchObject({ credential: 'token' });
expect(start.mock.calls[0][0].env).toEqual({
PREPARED_BY_EARLIER_STEP: 'value',
EXPO_TOKEN: 'expo-token',
});
expect(env.__EAS_SANDBOX_MCP_TOKEN).toBe('token');
} finally {
start.mockRestore();
}
});
it('throws a system error when the sandbox token is missing', async () => {
const fn = createStartSandboxBuildFunction({
job: {},
env: {},
mcpServerUrl: 'ws://localhost:8787',
...context,
job: {
...context.job,
projectArchive: {
type: ArchiveSourceType.GIT,
gitRef: 'refs/heads/main',
gitCommitHash: '0123456789abcdef0123456789abcdef01234567',
},
},
projectTargetDirectory: '/workingdir/build',
} as any);
await expect(
fn.fn!({} as any, {
inputs: { sandbox_id: { value: 'sandbox-id' } },
outputs: {},
env: {},
})
).rejects.toBeInstanceOf(SystemError);
});
it('throws a system error when the MCP server URL is missing', async () => {
const fn = createStartSandboxBuildFunction({
job: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'sandbox-token' },
} as any);
await expect(
fn.fn!({} as any, {
inputs: { sandbox_id: { value: 'sandbox-id' } },
outputs: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'sandbox-token' },
})
).rejects.toThrow('MCP server URL is required to start the sandbox daemon.');
});
it('marks the sandbox as ready', async () => {
const mutation = jest.fn(() => ({
toPromise: async () => ({ data: { sandbox: { markSandboxReady: { id: 'sandbox-id' } } } }),
}));
await markSandboxReadyAsync({ graphqlClient: { mutation } } as any, 'sandbox-id');
expect(mutation).toHaveBeenCalledWith(
expect.anything(),
{ sandboxId: 'sandbox-id' },
undefined
await expect(fn.fn!(checkedOutStepCtx, { inputs, outputs: {}, env: {} })).rejects.toBe(
startupError
);
expect(stripRepositoryCredentialsAsync).toHaveBeenCalledWith({
repositoryDirectory: '/workingdir/build',
});
expect(jest.mocked(stripRepositoryCredentialsAsync).mock.invocationCallOrder[0]).toBeLessThan(
start.mock.invocationCallOrder[0]
);
});
it('throws a system error when marking the sandbox as ready fails', async () => {
const mutation = jest.fn(() => ({
toPromise: async () => ({ error: new Error('request failed') }),
}));
it('strips the repository credentials from the source directory before eas/checkout', async () => {
const startupError = new Error('stop after checking options');
jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockRejectedValue(startupError);
const fn = createStartSandboxBuildFunction({
...context,
job: {
...context.job,
projectArchive: {
type: ArchiveSourceType.GIT,
gitRef: null,
gitCommitHash: '0123456789abcdef0123456789abcdef01234567',
},
},
projectSourceDirectory: '/workingdir/temporary-custom-build',
projectTargetDirectory: '/workingdir/build',
} as any);
await expect(
markSandboxReadyAsync({ graphqlClient: { mutation } } as any, 'sandbox-id')
).rejects.toThrow('Failed to mark sandbox sandbox-id as ready.');
await expect(fn.fn!(notCheckedOutStepCtx, { inputs, outputs: {}, env: {} })).rejects.toBe(
startupError
);
expect(stripRepositoryCredentialsAsync).toHaveBeenCalledWith({
repositoryDirectory: '/workingdir/temporary-custom-build',
});
});
it('does not start the daemon when stripping the repository credentials fails', async () => {
const stripError = new SystemError('Failed to strip credentials from the origin remote URL.');
jest.mocked(stripRepositoryCredentialsAsync).mockRejectedValueOnce(stripError);
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync');
const fn = createStartSandboxBuildFunction({
...context,
job: {
...context.job,
projectArchive: {
type: ArchiveSourceType.GIT,
gitRef: null,
gitCommitHash: '0123456789abcdef0123456789abcdef01234567',
},
},
projectTargetDirectory: '/workingdir/build',
} as any);
await expect(fn.fn!(checkedOutStepCtx, { inputs, outputs: {}, env: {} })).rejects.toBe(
stripError
);
expect(start).not.toHaveBeenCalled();
});
it('leaves the remote alone for non-git sources', async () => {
const startupError = new Error('stop after checking options');
jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockRejectedValue(startupError);
const fn = createStartSandboxBuildFunction({
...context,
projectTargetDirectory: '/workingdir/build',
} as any);
await expect(fn.fn!({ logger: {} } as any, { inputs, outputs: {}, env: {} })).rejects.toBe(
startupError
);
expect(stripRepositoryCredentialsAsync).not.toHaveBeenCalled();
});
it.each([
{
missing: 'the sandbox worker token',
overrides: { job: {} },
message: 'This sandbox job has no EAS_SANDBOX_WORKER_TOKEN secret.',
},
{
missing: 'the MCP server URL',
overrides: { mcpServerUrl: undefined },
message: 'MCP server URL is required to start the sandbox daemon.',
},
{
missing: 'the API server URL',
overrides: { expoApiV2BaseUrl: undefined },
message: 'The API server URL is not set',
},
])('throws a system error without $missing', async ({ overrides, message }) => {
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync');
const fn = createStartSandboxBuildFunction({ ...context, ...overrides } as any);
const result = fn.fn!({} as any, { inputs, outputs: {}, env: {} });
await expect(result).rejects.toBeInstanceOf(SystemError);
await expect(result).rejects.toThrow(message);
expect(start).not.toHaveBeenCalled();
});
});
@@ -12,9 +12,17 @@ import {
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import {
createWarmDeviceSessionServer,
runOnWarmIosSimulatorAsync,
} from '../../utils/warmDeviceSessionHost';
import { type WarmIosSession } from '../../utils/warmIosSession';
import { watchDeviceRunSessionStartup } from '../../utils/deviceRunSessionStartup';
import { createStartupWatchMock } from '../../../__tests__/utils/startupWatch';
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/warmDeviceSessionHost');
jest.mock('../../../utils/IosSimulatorUtils', () => ({
IosSimulatorUtils: {
resolveUdidAsync: jest.fn().mockResolvedValue('selected-ios-udid'),
@@ -39,6 +47,9 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
jest.mock('../../utils/deviceRunSessionStartup', () => ({
watchDeviceRunSessionStartup: jest.fn(),
}));
const ctx = {} as CustomBuildContext;
const env = { NGROK_AUTHTOKEN: 'ngrok-token', PATH: '/usr/bin' } as BuildStepEnv;
@@ -48,7 +59,8 @@ const stopAsync = jest.fn();
async function runAsync(
runtimePlatform: BuildRuntimePlatform,
launchInputs: Record<string, { value: unknown }> = {}
launchInputs: Record<string, { value: unknown }> = {},
signal?: AbortSignal
): Promise<void> {
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx);
await buildFunction.fn!(
@@ -63,6 +75,7 @@ async function runAsync(
...launchInputs,
},
outputs: {},
signal,
env,
} as never
);
@@ -81,13 +94,294 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
apiUrl: 'https://web-preview.example.test',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock);
stopAsync.mockResolvedValue(undefined);
});
it('does not start a host when the parent aborts during Xcode selection', async () => {
const controller = new AbortController();
const failure = new Error('parent step cancelled');
jest
.mocked(selectXcodeDeveloperDirectoryAsync)
.mockImplementationOnce(async () => controller.abort(failure));
await expect(runAsync(BuildRuntimePlatform.DARWIN, {}, controller.signal)).rejects.toBe(
failure
);
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
expect(watchDeviceRunSessionStartup).toHaveBeenCalledTimes(1);
expect(
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.finishAsync
).toHaveBeenCalled();
});
it('keeps watching startup when warm adoption retries cold', async () => {
const adoption = new Error('warm adoption failed');
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation(async (_claim, run) => {
try {
return await run({
device: { udid: 'selected-ios-udid', name: 'iPhone 17' },
} as WarmIosSession);
} catch (error) {
if (error !== adoption) {
throw error;
}
return await run(undefined);
}
});
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
options.onStartupError?.(adoption);
throw adoption;
});
await expect(
Promise.resolve(
createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true }).fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
{
inputs: {
boot_simulator: { value: true },
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env,
} as never
)
)
).resolves.toBeUndefined();
expect(watchDeviceRunSessionStartup).toHaveBeenCalledTimes(1);
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(2);
});
it.each([false, true])(
'observes Stop during warm claim and preserves cleanup failure=%s',
async failCleanup => {
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 = createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true }).fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env,
} as never
) as Promise<void>;
running.catch(() => {});
await claiming;
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
if (failCleanup) {
await expect(running).rejects.toMatchObject({ errors: expect.arrayContaining([cleanup]) });
} else {
await expect(running).resolves.toBeUndefined();
}
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
}
);
it('adopts the claimed warm Simulator when the warm step runs', async () => {
const warmServer = { port: 3200 } as never;
const warm = { device: { udid: 'SELECTED-IOS-UDID', name: 'iPhone 17' } } as WarmIosSession;
jest.mocked(createWarmDeviceSessionServer).mockReturnValue(warmServer);
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation((_claim, run) => run(warm));
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true });
expect(buildFunction.id).toBe('start_warm_serve_sim_remote_session');
await buildFunction.fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
{ inputs: { device_identifier: { value: 'iPhone 17' } }, outputs: {}, env } as never
);
expect(runOnWarmIosSimulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({ engine: 'serve-sim', requestedDevice: 'iPhone 17' }),
expect.any(Function)
);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ iosSimulatorUdid: 'SELECTED-IOS-UDID' }),
warmServer
);
});
it('passes the resolved version to cold setup after a warm version mismatch', async () => {
jest
.mocked(runOnWarmIosSimulatorAsync)
.mockImplementation((_claim, run) => run(undefined, '0.14.0'));
await createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true }).fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never,
{ inputs: { package_version: { value: 'latest' } }, outputs: {}, env } as never
);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ packageVersion: '0.14.0' })
);
});
it('lets the session host use the experimental emulator capture', async () => {
await runAsync(BuildRuntimePlatform.LINUX);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ emulatorCapture: true })
);
});
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'uses preview activity to reset the configured idle timeout on %s',
async runtimePlatform => {
await runAsync(runtimePlatform, { max_idle_time_minutes: { value: 5 } });
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
expect(idleTimeout?.getLastEventObservedAt()).toBeUndefined();
const host = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
const observedAt = new Date();
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(observedAt);
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(observedAt);
expect(stopAsync).toHaveBeenCalledTimes(1);
}
);
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'leaves the idle timer disabled when omitted on %s',
async runtimePlatform => {
await runAsync(runtimePlatform);
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
expect.objectContaining({ idleTimeout: undefined, maxDurationSeconds: 120 })
);
}
);
it('leaves the idle timer disabled for zero', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
max_idle_time_minutes: { value: 0 },
});
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
expect.objectContaining({ idleTimeout: undefined, maxDurationSeconds: 120 })
);
});
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'handles Stop while the %s host starts',
async runtimePlatform => {
let hostEntered!: () => void;
const entered = new Promise<void>(resolve => {
hostEntered = resolve;
});
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce((_ctx, { signal }) => {
hostEntered();
return new Promise((_resolve, reject) =>
signal!.addEventListener('abort', () => reject(new Error('host aborted')), { once: true })
);
});
const running = runAsync(runtimePlatform);
running.catch(() => {});
await entered;
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
watch.stop();
await expect(running).resolves.toBeUndefined();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
expect(watch.finishAsync).toHaveBeenCalled();
}
);
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'preserves an earlier %s host failure when Stop arrives during cleanup',
async runtimePlatform => {
const failure = new Error('Host became ready without a session token');
let cleanupEntered!: () => void;
const entered = new Promise<void>(resolve => {
cleanupEntered = resolve;
});
let finishCleanup!: () => void;
const cleanup = new Promise<void>(resolve => {
finishCleanup = resolve;
});
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(async (_ctx, options) => {
options.onStartupError?.(failure);
cleanupEntered();
await cleanup;
throw failure;
});
const running = runAsync(runtimePlatform);
running.catch(() => {});
try {
await entered;
jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value.stop();
} finally {
finishCleanup();
}
await expect(running).rejects.toBe(failure);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
}
);
it('keeps a teardown failure visible after Stop during preview setup', async () => {
let previewEntered!: () => void;
let finishPreview!: (value: {
previewPageUrl: string;
apiUrl: string;
closeAsync: () => Promise<void>;
}) => void;
const entered = new Promise<void>(resolve => {
previewEntered = resolve;
});
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: () => {
previewEntered();
return new Promise(resolve => {
finishPreview = resolve;
});
},
finishAsync: stopAsync,
getLastPreviewActivityAt: jest.fn(),
});
const failed = new Error('host teardown failed');
stopAsync.mockRejectedValueOnce(failed);
const running = runAsync(BuildRuntimePlatform.LINUX);
running.catch(() => {});
await entered;
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
watch.stop();
finishPreview({
previewPageUrl: 'https://preview.test',
apiUrl: 'https://server.test',
closeAsync: async () => {},
});
await expect(running).rejects.toBe(failed);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(stopAsync).toHaveBeenCalledTimes(1);
});
it('retires the startup watcher before lifetime waiting', async () => {
await runAsync(BuildRuntimePlatform.LINUX);
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
expect(watch.completeAsync.mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
);
});
it('reports the session token when serve-sim minted one', async () => {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
@@ -97,6 +391,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
previewToken: 'tok-1',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
@@ -124,6 +419,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
apiUrl: 'https://web-preview.example.test',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
@@ -151,6 +447,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
apiUrl: 'https://web-preview.example.test',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
await runAsync(BuildRuntimePlatform.DARWIN, { public_preview: { value: false } });
@@ -168,6 +465,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
if (phase === 'preview') {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: jest.fn().mockRejectedValue(error),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
} else if (phase === 'config') {
@@ -200,7 +498,9 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
env: childEnv,
logger,
timeoutMs: 60_000,
signal: undefined,
preview: { baseDomain: 'tunnel.example.com', authtoken: 'ngrok-token' },
signal: expect.any(AbortSignal),
onStartupError: expect.any(Function),
...(selectsXcode
? {
iosSimulatorUdid: 'SELECTED-IOS-UDID',
@@ -209,7 +509,10 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
}
: {}),
packageVersion: '1.2.3',
emulatorCapture: true,
disableShare: false,
frameAncestors: [],
corsOrigins: [],
launchAppIdentifier: undefined,
launchArgs: [],
openUrl: undefined,
@@ -217,10 +520,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
networkCaptureFields: [],
});
const host = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
expect(host.openPreviewAsync).toHaveBeenCalledWith({
baseDomain: 'tunnel.example.com',
authtoken: 'ngrok-token',
});
expect(host.openPreviewAsync).toHaveBeenCalledWith();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
ctx,
deviceRunSessionId: 'device-run-session-id',
@@ -230,6 +530,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
previewApiUrl: 'https://web-preview.example.test',
},
logger,
signal: expect.any(AbortSignal),
});
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith({
ctx,
@@ -258,7 +559,10 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
'network_capture_fields',
'public_preview',
'private_preview_url',
'frame_ancestors',
'cors_origins',
'package_version',
'max_idle_time_minutes',
'max_duration_seconds',
]);
});
@@ -322,6 +626,26 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
}
);
it('hands frame_ancestors to the session host', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
frame_ancestors: { value: ['https://embed.example.test'] },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ frameAncestors: ['https://embed.example.test'] })
);
});
it('hands cors_origins to the session host', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
cors_origins: { value: ['https://app.example.test'] },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
);
});
it('hands network capture to the session host', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
network_capture: { value: true },
@@ -15,6 +15,11 @@ import {
import { createSubmitToGooglePlayBuildFunction } from '../submitToGooglePlay';
jest.unmock('node-fetch');
jest.mock('promise-retry', () => {
const actual = jest.requireActual('promise-retry');
return (fn: Parameters<typeof actual>[0], options: object) =>
actual(fn, { ...options, minTimeout: 1, maxTimeout: 1, randomize: false });
});
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
@@ -174,7 +179,7 @@ it('preserves the original failure when cleanup fails', async () => {
api()
.put(`${editPath}/tracks/internal`)
.reply(400, { error: { message: 'unknown track' } });
api().delete(editPath).reply(500);
api().delete(editPath).times(6).reply(500);
await expect(submission().run()).rejects.toMatchObject({
cause: expect.objectContaining({ apiMessage: 'unknown track' }),
});
@@ -183,6 +188,7 @@ it('does not delete the edit or publish outputs when commit outcome is unknown',
upload();
api().put(`${editPath}/tracks/internal`).reply(200, { track: 'internal' });
api().post(`${editPath}:commit`).query(true).reply(503);
api().post(`${editPath}:commit`).query(true).reply(404);
let deleted = false;
api()
.delete(editPath)
@@ -192,7 +198,10 @@ it('does not delete the edit or publish outputs when commit outcome is unknown',
return [204];
});
const task = submission();
await expect(task.run()).rejects.toBeInstanceOf(SystemError);
const error = await task.run().catch(error => error);
expect(error).toBeInstanceOf(SystemError);
expect((error as Error).message).toContain('HTTP 404');
expect((error as Error).message).toContain('Could not confirm whether Google Play saved');
expect(deleted).toBe(false);
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
});
@@ -0,0 +1,407 @@
import { BuildStep, spawnAsync } from '@expo/steps';
import fs from 'node:fs';
import path from 'node:path';
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
jest.mock('@expo/steps', () => ({
...jest.requireActual('@expo/steps'),
spawnAsync: jest.fn(),
}));
const mockedSpawnAsync = jest.mocked(spawnAsync);
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createTestAndroidJob } from '../../../__tests__/utils/job';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { BuildContext } from '../../../context';
import { CustomBuildContext } from '../../../customBuildContext';
import { parseInfoPlistBuffer } from '../readIpaInfo';
import {
createUpdateResolvedVersionBuildFunction,
extractAndroidVersionAsync,
extractIosVersionAsync,
parseAaptOutput,
parseManifestXml,
} from '../updateResolvedVersion';
describe(parseAaptOutput, () => {
it('extracts versionName and versionCode from aapt2 output', () => {
const output = `package: name='com.example.app' versionCode='42' versionName='2.5.0' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34' compileSdkVersionCodename='14'
sdkVersion:'21'
targetSdkVersion:'34'
uses-permission: name='android.permission.INTERNET'`;
expect(parseAaptOutput(output)).toEqual({
appVersion: '2.5.0',
appBuildVersion: '42',
});
});
it('handles output with no version info', () => {
expect(parseAaptOutput('some unrelated output')).toEqual({
appVersion: undefined,
appBuildVersion: undefined,
});
});
it('handles versionName with prerelease suffix', () => {
const output = `package: name='com.example' versionCode='1' versionName='1.0.0-beta.1'`;
expect(parseAaptOutput(output)).toEqual({
appVersion: '1.0.0-beta.1',
appBuildVersion: '1',
});
});
it('keeps an unescaped apostrophe in versionName', () => {
const output = `package: name='com.example' versionCode='1' versionName='1.0-O'Brien' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34'
sdkVersion:'21'`;
expect(parseAaptOutput(output)).toEqual({
appVersion: "1.0-O'Brien",
appBuildVersion: '1',
});
});
it('keeps a versionName that contains a quote and an equals sign', () => {
const output = `package: name='com.example' versionCode='1' versionName='1.0' build=beta'
sdkVersion:'21'`;
expect(parseAaptOutput(output)).toEqual({
appVersion: "1.0' build=beta",
appBuildVersion: '1',
});
});
it('stops versionName at the split field of a split APK', () => {
const output = `package: name='com.example' versionCode='42' versionName='2.5.0' split='feature' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34'
sdkVersion:'21'`;
expect(parseAaptOutput(output)).toEqual({
appVersion: '2.5.0',
appBuildVersion: '42',
});
});
it('treats an empty versionName as missing', () => {
const output = `package: name='com.example' versionCode='42' versionName='' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34'
sdkVersion:'21'`;
expect(parseAaptOutput(output)).toEqual({
appVersion: undefined,
appBuildVersion: '42',
});
});
});
describe(parseManifestXml, () => {
it('extracts versionName and versionCode from bundletool manifest XML', () => {
const xml = `<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:versionCode="10"
android:versionName="3.1.0"
package="com.example.app">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
</manifest>`;
expect(parseManifestXml(xml)).toEqual({
appVersion: '3.1.0',
appBuildVersion: '10',
});
});
it('handles XML with no version attributes', () => {
const xml = `<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.app">
</manifest>`;
expect(parseManifestXml(xml)).toEqual({
appVersion: undefined,
appBuildVersion: undefined,
});
});
});
describe('simulator .app Info.plist parsing', () => {
const FIXTURES_DIR = path.join(__dirname, 'fixtures');
const SIMULATOR_APP_DIR = path.join(FIXTURES_DIR, 'TestSimulator.app');
const INFO_PLIST_PATH = path.join(SIMULATOR_APP_DIR, 'Info.plist');
beforeAll(async () => {
// Create a minimal XML Info.plist fixture
await fs.promises.mkdir(SIMULATOR_APP_DIR, { recursive: true });
await fs.promises.writeFile(
INFO_PLIST_PATH,
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>com.example.test</string>
<key>CFBundleShortVersionString</key>
<string>4.2.0</string>
<key>CFBundleVersion</key>
<string>99</string>
</dict>
</plist>`
);
});
afterAll(async () => {
await fs.promises.rm(SIMULATOR_APP_DIR, { recursive: true, force: true });
});
it('reads version from a .app Info.plist', async () => {
const buffer = await fs.promises.readFile(INFO_PLIST_PATH);
const infoPlist = parseInfoPlistBuffer(buffer);
expect(infoPlist.CFBundleShortVersionString).toBe('4.2.0');
expect(infoPlist.CFBundleVersion).toBe('99');
});
});
describe(extractIosVersionAsync, () => {
const FIXTURES_DIR = path.join(__dirname, 'fixtures');
const SIMULATOR_APP_DIR = path.join(FIXTURES_DIR, 'ExtractVersion.app');
const INFO_PLIST_PATH = path.join(SIMULATOR_APP_DIR, 'Info.plist');
const IPA_FIXTURE_PATH = path.join(FIXTURES_DIR, 'SmallestAppExample.ipa');
beforeAll(async () => {
await fs.promises.mkdir(SIMULATOR_APP_DIR, { recursive: true });
await fs.promises.writeFile(
INFO_PLIST_PATH,
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleShortVersionString</key>
<string>2.0.1</string>
<key>CFBundleVersion</key>
<string>55</string>
</dict>
</plist>`
);
});
afterAll(async () => {
await fs.promises.rm(SIMULATOR_APP_DIR, { recursive: true, force: true });
});
it('extracts version from a .app directory', async () => {
const result = await extractIosVersionAsync(SIMULATOR_APP_DIR);
expect(result).toEqual({
appVersion: '2.0.1',
appBuildVersion: '55',
});
});
it('extracts version from an .ipa file', async () => {
const result = await extractIosVersionAsync(IPA_FIXTURE_PATH);
expect(result).toEqual({
appVersion: '1.0',
appBuildVersion: '1',
});
});
});
describe(extractAndroidVersionAsync, () => {
afterEach(() => {
mockedSpawnAsync.mockReset();
});
it('extracts version from an .apk via aapt2', async () => {
mockedSpawnAsync.mockResolvedValue({
stdout: `package: name='com.example.app' versionCode='42' versionName='2.5.0'`,
stderr: '',
} as any);
const result = await extractAndroidVersionAsync('/path/to/app.apk');
expect(mockedSpawnAsync).toHaveBeenCalledWith(
'aapt2',
['dump', 'badging', '/path/to/app.apk'],
{
stdio: 'pipe',
}
);
expect(result).toEqual({
appVersion: '2.5.0',
appBuildVersion: '42',
});
});
it('extracts version from an .aab via bundletool', async () => {
mockedSpawnAsync.mockResolvedValue({
stdout: `<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:versionCode="10"
android:versionName="3.1.0"
package="com.example.app">
</manifest>`,
stderr: '',
} as any);
const result = await extractAndroidVersionAsync('/path/to/app.aab');
expect(mockedSpawnAsync).toHaveBeenCalledWith(
'bundletool',
['dump', 'manifest', '--bundle', '/path/to/app.aab'],
{ stdio: 'pipe' }
);
expect(result).toEqual({
appVersion: '3.1.0',
appBuildVersion: '10',
});
});
it('returns empty object for unknown extension', async () => {
const result = await extractAndroidVersionAsync('/path/to/archive.zip');
expect(mockedSpawnAsync).not.toHaveBeenCalled();
expect(result).toEqual({});
});
});
describe(createUpdateResolvedVersionBuildFunction, () => {
afterEach(() => {
mockedSpawnAsync.mockReset();
});
function createStep({
env,
archivePath,
workingDirectory,
mutationResult = { data: {}, error: undefined },
}: {
env: Record<string, string>;
archivePath?: string;
workingDirectory?: string;
mutationResult?: { data?: unknown; error?: Error };
}): { step: BuildStep; mutation: jest.Mock } {
const mutation = jest.fn(() => ({ toPromise: async () => mutationResult }));
const ctx = new BuildContext(createTestAndroidJob({}), {
env: { __API_SERVER_URL: 'http://api.expo.test', ...env },
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
uploadArtifact: jest.fn(),
workingdir: '',
});
ctx.graphqlClient.mutation = mutation as any;
const step = createUpdateResolvedVersionBuildFunction(
new CustomBuildContext(ctx)
).createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs: archivePath ? { application_archive_path: archivePath } : {},
workingDirectory,
});
return { step, mutation };
}
it('resolves a relative archive path against the step working directory', async () => {
mockedSpawnAsync.mockResolvedValue({
stdout: `package: name='com.example.app' versionCode='42' versionName='2.5.0'`,
stderr: '',
} as any);
const { step, mutation } = createStep({
env: { EAS_BUILD_ID: 'build-id' },
archivePath: 'android/app/build/outputs/apk/release/app-release.apk',
workingDirectory: './my-app',
});
await step.executeAsync();
const expectedPath = path.join(
step.ctx.workingDirectory,
'android/app/build/outputs/apk/release/app-release.apk'
);
expect(path.isAbsolute(expectedPath)).toBe(true);
expect(mockedSpawnAsync).toHaveBeenCalledWith('aapt2', ['dump', 'badging', expectedPath], {
stdio: 'pipe',
});
expect(mutation).toHaveBeenCalledTimes(1);
});
it('updates the resolved versions for the build', async () => {
mockedSpawnAsync.mockResolvedValue({
stdout: `package: name='com.example.app' versionCode='42' versionName='2.5.0'`,
stderr: '',
} as any);
const { step, mutation } = createStep({
env: { EAS_BUILD_ID: 'build-id' },
archivePath: '/path/to/app.apk',
});
await step.executeAsync();
expect(mutation).toHaveBeenCalledTimes(1);
expect(mutation.mock.calls[0][1]).toEqual({
buildId: 'build-id',
appVersion: '2.5.0',
appBuildVersion: '42',
});
});
it('omits a missing value instead of sending null', async () => {
mockedSpawnAsync.mockResolvedValue({
stdout: `package: name='com.example.app' versionCode='42'`,
stderr: '',
} as any);
const { step, mutation } = createStep({
env: { EAS_BUILD_ID: 'build-id' },
archivePath: '/path/to/app.apk',
});
await step.executeAsync();
const variables = mutation.mock.calls[0][1];
expect(variables.appBuildVersion).toBe('42');
expect(variables.appVersion).toBeUndefined();
expect(variables.appVersion).not.toBeNull();
});
it('does nothing without a build ID', async () => {
const { step, mutation } = createStep({ env: {}, archivePath: '/path/to/app.apk' });
await step.executeAsync();
expect(mockedSpawnAsync).not.toHaveBeenCalled();
expect(mutation).not.toHaveBeenCalled();
});
it('does nothing without an archive path', async () => {
const { step, mutation } = createStep({ env: { EAS_BUILD_ID: 'build-id' } });
await step.executeAsync();
expect(mockedSpawnAsync).not.toHaveBeenCalled();
expect(mutation).not.toHaveBeenCalled();
});
it('does not fail the step when extraction or the mutation fails', async () => {
mockedSpawnAsync.mockRejectedValueOnce(new Error('aapt2: command not found'));
const failingExtraction = createStep({
env: { EAS_BUILD_ID: 'build-id' },
archivePath: '/path/to/app.apk',
});
await expect(failingExtraction.step.executeAsync()).resolves.not.toThrow();
expect(failingExtraction.mutation).not.toHaveBeenCalled();
mockedSpawnAsync.mockResolvedValue({
stdout: `package: name='com.example.app' versionCode='42' versionName='2.5.0'`,
stderr: '',
} as any);
const failingMutation = createStep({
env: { EAS_BUILD_ID: 'build-id' },
archivePath: '/path/to/app.apk',
mutationResult: { error: new Error('GraphQL error') },
});
await expect(failingMutation.step.executeAsync()).resolves.not.toThrow();
expect(failingMutation.mutation).toHaveBeenCalledTimes(1);
});
});
@@ -121,26 +121,32 @@ describe(createUploadArtifactBuildFunction, () => {
expect(typeInput.isRawValueOneOfAllowedValues()).toBe(false);
});
it('passes the metadata input through to runtimeApi.uploadArtifact', async () => {
contextUploadArtifact.mockClear();
const metadata = { kind: 'screen-recording', startedAtMs: '123', device: 'emulator-5554' };
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs: {
// Generic ('other') artifact: only generic artifacts persist metadata in the worker
// (the managed-type upload paths drop it), so that's the type metadata is meant for.
type: 'other',
key: 'Screen Recording',
path: '/',
metadata,
},
});
it.each(['other', 'android-apk', 'android-aab', 'ios-simulator-app', 'ios-ipa'])(
'uploads %s as a workflow artifact with metadata',
async type => {
contextUploadArtifact.mockClear();
const metadata = { kind: 'screen-recording', startedAtMs: '123', device: 'emulator-5554' };
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(
createGlobalContextMock({}),
{
callInputs: {
type,
key: 'Screen Recording',
path: '/',
metadata,
},
}
);
await buildStep.executeAsync();
await buildStep.executeAsync();
expect(contextUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({ artifact: expect.objectContaining({ metadata }) })
);
});
expect(contextUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({ type: null, metadata }),
})
);
}
);
it('does not attach metadata to managed artifact types (the worker drops it)', async () => {
contextUploadArtifact.mockClear();
@@ -140,9 +140,12 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
await buildStep.executeAsync();
const names = jest
const uploads = jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mock.calls.map(([, options]) => options.name);
.mock.calls.map(([, options]) => options);
const names = uploads
.filter(options => options.kind === 'screen-recording')
.map(options => options.name);
expect(names).toEqual(
expect.arrayContaining([
'iPhone 16 screen recording (01234567-…, started at Jul 10, 2026, 10:00:00.000 UTC)',
@@ -151,6 +154,12 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
])
);
expect(new Set(names).size).toBe(3);
const metadata = uploads.filter(options => options.kind === 'screen-recording-metadata');
expect(metadata.map(options => options.name)).toEqual(
expect.arrayContaining(names.map(name => `${name} metadata`))
);
expect(metadata).toHaveLength(3);
expect(uploads).toHaveLength(6);
} finally {
await Promise.all(
recordingDirectories.map(directory => fs.rm(directory, { recursive: true, force: true }))
@@ -1,7 +1,17 @@
import { UserError } from '@expo/eas-build-job';
import fs from 'fs-extra';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { AscApiClient } from '../../utils/ios/AscApiClient';
import { AscApiUtils } from '../../utils/ios/AscApiUtils';
import {
createUploadToAscBuildFunction,
isClosedVersionTrainError,
isInvalidBundleIdentifierError,
isMissingAppIconsError,
isMissingBackgroundTaskIdentifiersError,
isMissingPurposeStringError,
isRedundantBinaryUploadError,
isSdkVersionIssueError,
parseMissingUsageDescriptionKeys,
} from '../uploadToAsc';
@@ -45,6 +55,20 @@ describe(isInvalidBundleIdentifierError, () => {
});
});
describe(isRedundantBinaryUploadError, () => {
it('returns true when all errors are redundant-binary-upload codes', () => {
expect(isRedundantBinaryUploadError([{ code: '90189' }, { code: '90189' }])).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isRedundantBinaryUploadError([{ code: '90189' }, { code: '90062' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isRedundantBinaryUploadError([])).toBe(false);
});
});
describe(isMissingPurposeStringError, () => {
it('returns true when all errors are missing-purpose-string codes', () => {
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90683' }])).toBe(true);
@@ -73,6 +97,41 @@ describe(isSdkVersionIssueError, () => {
});
});
describe(isMissingBackgroundTaskIdentifiersError, () => {
it('returns true when all errors are missing BG task identifiers codes', () => {
expect(isMissingBackgroundTaskIdentifiersError([{ code: '90771' }, { code: '90771' }])).toBe(
true
);
});
it('returns false when any other error code is present', () => {
expect(isMissingBackgroundTaskIdentifiersError([{ code: '90771' }, { code: '90062' }])).toBe(
false
);
});
it('returns false when there are no errors', () => {
expect(isMissingBackgroundTaskIdentifiersError([])).toBe(false);
});
});
describe(isMissingAppIconsError, () => {
it.each([['90391', '90713', '90713'], ['90713', '90022', '90023'], ['90022'], ['90023']])(
'recognizes missing-icon validation codes %j',
(...codes) => {
expect(isMissingAppIconsError(codes.map(code => ({ code })))).toBe(true);
}
);
it('returns false when any other error code is present', () => {
expect(isMissingAppIconsError([{ code: '90391' }, { code: '90771' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isMissingAppIconsError([])).toBe(false);
});
});
describe(parseMissingUsageDescriptionKeys, () => {
it('extracts missing UsageDescription keys from ASC messages', () => {
expect(
@@ -110,3 +169,176 @@ describe(parseMissingUsageDescriptionKeys, () => {
).toEqual([]);
});
});
describe(createUploadToAscBuildFunction, () => {
it('keeps Apple duplicate-build details in the public error and the full state as its cause', async () => {
const state = {
state: 'FAILED',
errors: [
{
code: '90189',
description: 'Redundant Binary Upload. Build number 1 was already uploaded.',
},
],
warnings: [{ code: 'WARNING', description: 'Apple warning.' }],
infos: [{ code: 'INFO', description: 'Apple information.' }],
};
await fs.writeFile('/app.ipa', 'ipa');
await fs.writeFile('/key.json', '{}');
jest.spyOn(AscApiUtils, 'loadApiKeyAsync').mockResolvedValue({} as never);
jest.spyOn(AscApiUtils, 'getAppInfoAsync').mockResolvedValue({
data: { attributes: { name: 'App', bundleId: 'com.example.app' } },
} as never);
jest.spyOn(AscApiUtils, 'createBuildUploadAsync').mockResolvedValue({
data: { id: 'upload-id' },
} as never);
jest.spyOn(AscApiUtils, 'commitBuildUploadFileAsync').mockResolvedValue(undefined as never);
jest.spyOn(AscApiClient.prototype, 'postAsync').mockResolvedValue({
data: { id: 'file-id', attributes: { uploadOperations: [] } },
} as never);
jest
.spyOn(AscApiClient.prototype, 'getAsync')
.mockResolvedValueOnce({
data: { attributes: { assetDeliveryState: { state: 'COMPLETE' } } },
} as never)
.mockResolvedValueOnce({ data: { attributes: { state } } } as never);
try {
const step = createUploadToAscBuildFunction().createBuildStepFromFunctionCall(
createGlobalContextMock({ staticContextContent: { job: {} } }),
{
callInputs: {
ipa_path: '/app.ipa',
asc_api_key_path: '/key.json',
apple_app_identifier: 'app-id',
bundle_version: '1',
bundle_short_version: '1.0',
wait_for_processing: true,
},
}
);
const error = await step.executeAsync().catch(error => error);
expect(error).toBeInstanceOf(UserError);
expect(error.errorCode).toBe('EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE');
expect(error.cause).toEqual(state);
expect(error.toExternalExpoError().message).toContain(
'Redundant Binary Upload. Build number 1 was already uploaded. (90189)'
);
} finally {
jest.restoreAllMocks();
}
});
});
describe(createUploadToAscBuildFunction, () => {
it('keeps Apple background-task details in the public error and the full state as its cause', async () => {
const state = {
state: 'FAILED',
errors: [
{
code: '90771',
description:
'Missing BGTaskSchedulerPermittedIdentifiers while UIBackgroundModes includes processing.',
},
],
warnings: [{ code: 'WARNING', description: 'Apple warning.' }],
infos: [{ code: 'INFO', description: 'Apple information.' }],
};
await fs.writeFile('/app.ipa', 'ipa');
await fs.writeFile('/key.json', '{}');
jest.spyOn(AscApiUtils, 'loadApiKeyAsync').mockResolvedValue({} as never);
jest.spyOn(AscApiUtils, 'getAppInfoAsync').mockResolvedValue({
data: { attributes: { name: 'App', bundleId: 'com.example.app' } },
} as never);
jest.spyOn(AscApiUtils, 'createBuildUploadAsync').mockResolvedValue({
data: { id: 'upload-id' },
} as never);
jest.spyOn(AscApiUtils, 'commitBuildUploadFileAsync').mockResolvedValue(undefined as never);
jest.spyOn(AscApiClient.prototype, 'postAsync').mockResolvedValue({
data: { id: 'file-id', attributes: { uploadOperations: [] } },
} as never);
jest
.spyOn(AscApiClient.prototype, 'getAsync')
.mockResolvedValueOnce({
data: { attributes: { assetDeliveryState: { state: 'COMPLETE' } } },
} as never)
.mockResolvedValueOnce({ data: { attributes: { state } } } as never);
try {
const step = createUploadToAscBuildFunction().createBuildStepFromFunctionCall(
createGlobalContextMock({ staticContextContent: { job: {} } }),
{
callInputs: {
ipa_path: '/app.ipa',
asc_api_key_path: '/key.json',
apple_app_identifier: 'app-id',
bundle_version: '1',
bundle_short_version: '1.0',
wait_for_processing: true,
},
}
);
const error = await step.executeAsync().catch(error => error);
expect(error).toBeInstanceOf(UserError);
expect(error.errorCode).toBe('EAS_UPLOAD_TO_ASC_MISSING_BG_TASK_IDENTIFIERS');
expect(error.cause).toEqual(state);
expect(error.toExternalExpoError().message).toContain(
'Missing BGTaskSchedulerPermittedIdentifiers while UIBackgroundModes includes processing. (90771)'
);
} finally {
jest.restoreAllMocks();
}
});
});
describe(createUploadToAscBuildFunction, () => {
it('keeps Apple validation details in the public error and the full state as its cause', async () => {
const state = {
state: 'FAILED',
errors: [{ code: '90713', description: 'Missing CFBundleIconName in Watch.app.' }],
warnings: [{ code: 'WARNING', description: 'Apple warning.' }],
infos: [{ code: 'INFO', description: 'Apple information.' }],
};
await fs.writeFile('/app.ipa', 'ipa');
await fs.writeFile('/key.json', '{}');
jest.spyOn(AscApiUtils, 'loadApiKeyAsync').mockResolvedValue({} as never);
jest.spyOn(AscApiUtils, 'getAppInfoAsync').mockResolvedValue({
data: { attributes: { name: 'App', bundleId: 'com.example.app' } },
} as never);
jest.spyOn(AscApiUtils, 'createBuildUploadAsync').mockResolvedValue({
data: { id: 'upload-id' },
} as never);
jest.spyOn(AscApiUtils, 'commitBuildUploadFileAsync').mockResolvedValue(undefined as never);
jest.spyOn(AscApiClient.prototype, 'postAsync').mockResolvedValue({
data: { id: 'file-id', attributes: { uploadOperations: [] } },
} as never);
jest
.spyOn(AscApiClient.prototype, 'getAsync')
.mockResolvedValueOnce({
data: { attributes: { assetDeliveryState: { state: 'COMPLETE' } } },
} as never)
.mockResolvedValueOnce({ data: { attributes: { state } } } as never);
try {
const step = createUploadToAscBuildFunction().createBuildStepFromFunctionCall(
createGlobalContextMock({ staticContextContent: { job: {} } }),
{
callInputs: {
ipa_path: '/app.ipa',
asc_api_key_path: '/key.json',
apple_app_identifier: 'app-id',
bundle_version: '1',
bundle_short_version: '1.0',
wait_for_processing: true,
},
}
);
const error = await step.executeAsync().catch(error => error);
expect(error).toBeInstanceOf(UserError);
expect(error.errorCode).toBe('EAS_UPLOAD_TO_ASC_MISSING_APP_ICONS');
expect(error.cause).toEqual(state);
expect(error.toExternalExpoError().message).toContain(
'Missing CFBundleIconName in Watch.app. (90713)'
);
} finally {
jest.restoreAllMocks();
}
});
});
@@ -0,0 +1,39 @@
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { NpmrcAuthTokenLine } from '../../../templates/npmrc';
import { createSetUpNpmrcBuildFunction } from '../useNpmToken';
jest.mock('fs');
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
const projectTargetDirectory = '/workingdir/project';
const homeDirectory = '/home/expo';
describe(createSetUpNpmrcBuildFunction, () => {
beforeEach(async () => {
vol.reset();
await vol.promises.mkdir(projectTargetDirectory, { recursive: true });
await vol.promises.mkdir(homeDirectory, { recursive: true });
jest.mocked(spawn).mockResolvedValue({ stdout: `${homeDirectory}/.npmrc\n` } as never);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('adds the NPM_TOKEN placeholder from the step env to ~/.npmrc on EAS Build', async () => {
const globalCtx = createGlobalContextMock({ projectTargetDirectory });
globalCtx.updateEnv({ NPM_TOKEN: 'npm_secret_token', EAS_BUILD_RUNNER: 'eas-build' });
const step = createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: {},
});
await step.executeAsync();
expect(await vol.promises.readFile(`${homeDirectory}/.npmrc`, 'utf8')).toContain(
NpmrcAuthTokenLine
);
});
});
@@ -58,7 +58,11 @@ export function createCheckoutBuildFunction(): BuildFunction {
});
}
stepsCtx.global.markAsCheckedOut(stepsCtx.logger);
const previousWorkingDirectory = stepsCtx.global.defaultWorkingDirectory;
stepsCtx.global.markAsCheckedOut();
stepsCtx.logger.info(
`Changing default working directory to ${stepsCtx.global.defaultWorkingDirectory} (was ${previousWorkingDirectory})`
);
},
});
}
@@ -0,0 +1,34 @@
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
import { type CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { collectAndroidEmulatorLogsAsync } from '../utils/androidEmulatorLogs';
import { getDeviceRunSessionIdOrThrow } from '../utils/remoteDeviceRunSession';
export function createCollectAndroidEmulatorLogsBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'collect_android_emulator_logs',
name: 'Collect Android emulator logs',
__metricsId: 'eas/collect_android_emulator_logs',
supportedRuntimePlatforms: [BuildRuntimePlatform.LINUX],
fn: async ({ logger, global }, { env }) => {
try {
await collectAndroidEmulatorLogsAsync(ctx, {
global,
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
logger,
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not collect Android emulator logs', error);
logger.warn(
{ err: error },
'Could not collect Android logs; the session result is unchanged.'
);
}
},
});
}
@@ -1,4 +1,4 @@
import { SystemError } from '@expo/eas-build-job';
import { SystemError, formatBytes } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -15,7 +15,6 @@ import stream from 'stream';
import { promisify } from 'util';
import { z } from 'zod';
import { formatBytes } from '../../utils/artifacts';
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
@@ -1,4 +1,4 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { SystemError, UserError, formatBytes } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -7,7 +7,7 @@ import {
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import { Client } from '@urql/core';
import { Client, CombinedError } from '@urql/core';
import contentDisposition from 'content-disposition';
import { glob } from 'fast-glob';
import { graphql } from 'gql.tada';
@@ -23,15 +23,20 @@ import bplistParser from 'bplist-parser';
import plist from 'plist';
import { CustomBuildContext } from '../../customBuildContext';
import { formatBytes } from '../../utils/artifacts';
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
import {
describeError,
isTransferFailure,
isTransientGraphqlError,
} from '../../utils/networkErrors';
import { retryOnNetworkFailureAsync } from '../../utils/retryOnNetworkFailure';
import { pluralize } from '../../utils/strings';
import {
type DeviceRunSessionWorkerApi,
getDeviceRunSessionWorkerApi,
} from '../utils/deviceRunSessionWorkerApi';
import { WorkerApiError } from '../utils/workerApi';
const streamPipeline = promisify(stream.pipeline);
@@ -152,21 +157,34 @@ async function fetchApplicationArchiveUrlAsync({
signal,
});
}
const result = await graphqlClient
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
.toPromise();
let data;
try {
data = await retryOnNetworkFailureAsync(
async () => {
const queryResult = await graphqlClient
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
.toPromise();
if (queryResult.error) {
throw queryResult.error;
}
return queryResult.data;
},
{
operation: 'download_build.build_lookup',
signal,
isRetryable: error => error instanceof CombinedError && isTransientGraphqlError(error),
}
);
} catch (error) {
signal?.throwIfAborted();
const message = `Could not fetch build ${buildId}: ${describeError(error)}`;
throw error instanceof CombinedError && !isTransientGraphqlError(error)
? new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error })
: new SystemError(message, { cause: error });
}
signal?.throwIfAborted();
if (result.error) {
const { error } = result;
const message = `Could not fetch build ${buildId}: ${error.message}`;
throw error.networkError || result.error.response?.status >= 500
? new SystemError(message, { cause: error })
: new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error });
}
const applicationArchiveUrl = result.data?.builds.byId?.artifacts?.applicationArchiveUrl;
const applicationArchiveUrl = data?.builds.byId?.artifacts?.applicationArchiveUrl;
if (!applicationArchiveUrl) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
@@ -195,13 +213,18 @@ async function fetchDeviceRunSessionApplicationArchiveUrlAsync({
}): Promise<string> {
let application;
try {
application = await deviceRunSessionWorkerApi.getApplicationAsync({ signal });
application = await retryOnNetworkFailureAsync(
() => deviceRunSessionWorkerApi.getApplicationAsync({ signal }),
{
operation: 'download_build.session_build_lookup',
signal,
isRetryable: error => error instanceof WorkerApiError && error.isTransient,
}
);
} catch (err) {
signal?.throwIfAborted();
throw new SystemError(
`Could not fetch build ${buildId} for this device run session: ${
err instanceof Error ? err.message : String(err)
}`,
`Could not fetch build ${buildId} for this device run session: ${describeError(err)}`,
{ cause: err }
);
}
@@ -281,18 +304,48 @@ export async function downloadBuildAsync(
);
signal?.throwIfAborted();
const response = await retryOnDNSFailure(fetch)(downloadUrl, { headers, signal });
if (!response.ok) {
const textResult = await asyncResult(response.text());
throw new Error(`Unexpected response from server (${response.status}): ${textResult.value}`);
let archivePath: string;
try {
archivePath = await retryOnNetworkFailureAsync(
async () => {
const response = await fetch(downloadUrl, { headers, signal });
if (!response.ok) {
const textResult = await asyncResult(response.text());
const archiveUrl = new URL(downloadUrl);
const source = params.buildId
? `application archive for build ${params.buildId}`
: `archive at ${archiveUrl.origin}${archiveUrl.pathname}`;
if (response.status === 404 || response.status === 410) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_ARCHIVE_UNAVAILABLE',
`Failed to download the ${source} (HTTP ${response.status}). ` +
'The archive is unavailable. ' +
(params.buildId
? 'Check that the archive still exists and try again.'
: 'Check that the URL is correct and the archive still exists, then try again.')
);
}
throw new Error(
`Failed to download the ${source} (HTTP ${response.status}): ${textResult.value}`
);
}
const archiveFilename = resolveArchiveFilename({ response, extensions });
const attemptArchivePath = path.join(downloadDestinationDirectory, archiveFilename);
// On abort, node-fetch emits an error on the body stream, which fails the pipeline.
await streamPipeline(response.body, fs.createWriteStream(attemptArchivePath));
return attemptArchivePath;
},
{ operation: 'download_build.archive_download', signal, isRetryable: isTransferFailure }
);
} catch (error) {
signal?.throwIfAborted();
if (params.buildId && isTransferFailure(error)) {
throw new SystemError(`Could not download the application archive: ${describeError(error)}`, {
cause: error,
});
}
throw error;
}
const archiveFilename = resolveArchiveFilename({ response, extensions });
const archivePath = path.join(downloadDestinationDirectory, archiveFilename);
// On abort, node-fetch emits an error on the body stream, which fails the pipeline.
await streamPipeline(response.body, fs.createWriteStream(archivePath));
signal?.throwIfAborted();
const { size } = await fs.promises.stat(archivePath);
@@ -1,5 +1,5 @@
import { BuildJob, Ios, ManagedArtifactType, Platform } from '@expo/eas-build-job';
import { BuildFunction, BuildStepContext } from '@expo/steps';
import { BuildFunction, BuildStepContext, BuildStepOutput } from '@expo/steps';
import { CustomBuildContext } from '../../customBuildContext';
import { findXcodeBuildLogsPathAsync } from '../../ios/xcodeBuildLogs';
@@ -13,7 +13,13 @@ export function createFindAndUploadBuildArtifactsBuildFunction(
id: 'find_and_upload_build_artifacts',
name: 'Find and upload build artifacts',
__metricsId: 'eas/find_and_upload_build_artifacts',
fn: async stepCtx => {
outputProviders: [
BuildStepOutput.createProvider({
id: 'application_archive_path',
required: false,
}),
],
fn: async (stepCtx, { outputs }) => {
// We want each upload to print logs on its own
// and we don't want to interleave logs from different uploads
// so we execute uploads consecutively.
@@ -24,7 +30,10 @@ export function createFindAndUploadBuildArtifactsBuildFunction(
let firstError: any = null;
try {
await uploadApplicationArchivesAsync({ ctx, stepCtx });
const [firstArchivePath] = await uploadApplicationArchivesAsync({ ctx, stepCtx });
if (firstArchivePath) {
outputs.application_archive_path.set(firstArchivePath);
}
} catch (err: unknown) {
stepCtx.logger.error({ err }, `Failed to upload application archives.`);
firstError ||= err;
@@ -68,7 +77,7 @@ async function uploadApplicationArchivesAsync({
}: {
ctx: CustomBuildContext<BuildJob>;
stepCtx: BuildStepContext;
}): Promise<void> {
}): Promise<string[]> {
const applicationArchivePatternOrPath =
ctx.job.platform === Platform.ANDROID
? (ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}')
@@ -99,6 +108,8 @@ async function uploadApplicationArchivesAsync({
logger,
});
logger.info('Done.');
return applicationArchives;
}
async function uploadBuildArtifacts({
@@ -1,4 +1,4 @@
import { UserError } from '@expo/eas-build-job';
import { UserError, quoteAndroidShellCommand } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import {
BuildFunction,
@@ -106,24 +106,40 @@ export async function launchApplicationAsync({
logApplicationLaunch(logger, applicationIdentifier, launchArgs);
// Android does not support process arguments like iOS. Pass raw `am start` Intent
// arguments instead, such as `--es key value` or `--ez key true`.
await spawn(
'adb',
['shell', 'am', 'start', ...launchArgs, '-n', `${applicationIdentifier}/${activityName}`],
{
env,
logger,
try {
await spawnAndroidShellCommandAsync(
['am', 'start', ...launchArgs, '-n', `${applicationIdentifier}/${activityName}`],
{ env, logger }
);
} catch (err) {
// An empty argument that is not an option value reaches `am start` as an empty package, and
// `am start` then fails to resolve the Intent without saying why.
const emptyArgumentIndex = launchArgs.indexOf('');
if (emptyArgumentIndex === -1 || err instanceof UserError) {
throw err;
}
);
throw new UserError(
'EAS_LAUNCH_APPLICATION_FAILED',
`Launching ${applicationIdentifier} failed. Input "launch_args" has an empty argument at position ${emptyArgumentIndex + 1}, which Android passes to "am start" as an empty argument. Remove it unless it is an option value, such as "--es", "key", "".`,
{ cause: err }
);
}
if (openUrl) {
logger.info(`Opening ${openUrl} in ${applicationIdentifier}.`);
await spawn(
'adb',
['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
await spawnAndroidShellCommandAsync(
['am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
{ env, logger }
);
}
}
async function spawnAndroidShellCommandAsync(
command: string[],
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
): Promise<void> {
await spawn('adb', ['shell', ...quoteAndroidShellCommand(command)], { env, logger });
}
async function preapproveIosUrlSchemeAsync({
applicationIdentifier,
openUrl,
@@ -211,7 +211,9 @@ const MaestroRunnerRecordedFlowSchema = z.object({
name: z.string().min(1),
sourceFile: z.string().min(1),
status: z.enum(['passed', 'failed']),
tags: z.array(z.string()).optional(),
});
type MaestroRunnerRecordedFlow = z.infer<typeof MaestroRunnerRecordedFlowSchema>;
const MaestroRunnerReportSchema = z.object({
flows: z
.array(
@@ -379,6 +381,111 @@ export async function parseFailedFlowsFromFileAttrs(args: {
return paths;
}
const MAESTRO_RUNNER_ATTEMPT_DIRECTORY_PATTERN = /-maestro-runner-attempt-(\d+)$/;
export function maestroRunnerAttemptDirectoryName(args: {
platform: string;
attempt: number;
}): string {
return `${args.platform}-maestro-runner-attempt-${args.attempt}`;
}
async function listMaestroRunnerAttemptDirectories(
testsDirectory: string
): Promise<{ attempt: number; reportDirectory: string }[]> {
let entries: string[];
try {
entries = await fs.readdir(testsDirectory);
} catch {
return [];
}
return entries.flatMap(entry => {
const match = entry.match(MAESTRO_RUNNER_ATTEMPT_DIRECTORY_PATTERN);
return match
? [{ attempt: parseInt(match[1], 10), reportDirectory: path.join(testsDirectory, entry) }]
: [];
});
}
export async function hasMaestroRunnerAttemptDirectories(testsDirectory: string): Promise<boolean> {
return (await listMaestroRunnerAttemptDirectories(testsDirectory)).length > 0;
}
export async function parseMaestroRunnerResults(args: {
junitDirectory: string;
testsDirectory: string;
workingDirectory: string;
}): Promise<MaestroFlowResult[]> {
const results = await parseMaestroResultsFromFileAttrs(args.junitDirectory);
const attempts = await listMaestroRunnerAttemptDirectories(args.testsDirectory);
const flows = await Promise.all(
attempts.map(({ reportDirectory }) =>
parseMaestroRunnerFlowsWithPaths({
reportDirectory,
workingDirectory: args.workingDirectory,
})
)
);
const flowsByAttempt = new Map(attempts.map(({ attempt }, index) => [attempt, flows[index]]));
const realWorkingDirectory = await resolveRealPath(args.workingDirectory);
return results.map(result => {
const tags = findMaestroRunnerFlowTags({
flows: flowsByAttempt.get(result.retryCount) ?? [],
junitFlowPath: result.path,
workingDirectory: args.workingDirectory,
realWorkingDirectory,
});
return tags ? { ...result, tags } : result;
});
}
type MaestroRunnerFlowWithPaths = MaestroRunnerRecordedFlow & {
resolvedPath: string;
realPath: string;
};
// maestro-runner >= 1.1.18 resolves symlinks in the JUnit path, but not in report.json.
async function parseMaestroRunnerFlowsWithPaths(args: {
reportDirectory: string;
workingDirectory: string;
}): Promise<MaestroRunnerFlowWithPaths[]> {
const report = await parseMaestroRunnerReport(args.reportDirectory);
return await Promise.all(
(report?.flows ?? []).map(async flow => {
const resolvedPath = path.resolve(args.workingDirectory, flow.sourceFile);
return { ...flow, resolvedPath, realPath: await resolveRealPath(resolvedPath) };
})
);
}
async function resolveRealPath(filePath: string): Promise<string> {
const realPathResult = await asyncResult(fs.realpath(filePath));
return realPathResult.ok ? realPathResult.value : filePath;
}
function findMaestroRunnerFlowTags(args: {
flows: MaestroRunnerFlowWithPaths[];
junitFlowPath: string;
workingDirectory: string;
realWorkingDirectory: string;
}): string[] | undefined {
const junitPath = path.resolve(args.workingDirectory, args.junitFlowPath);
const junitRealPath = path.resolve(args.realWorkingDirectory, args.junitFlowPath);
const samePath = args.flows.find(
flow => flow.resolvedPath === junitPath || flow.realPath === junitRealPath
);
if (samePath) {
return samePath.tags;
}
// maestro-runner <= 1.1.17 writes only the file name to JUnit.
const sameFileName = args.flows.filter(
flow => path.basename(flow.sourceFile) === args.junitFlowPath
);
return sameFileName.length === 1 ? sameFileName[0].tags : undefined;
}
export async function parseMaestroResults(
junitDirectory: string,
nameToPath: Map<string, string> | null
@@ -1,4 +1,4 @@
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
import { SystemError, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import {
BuildFunction,
@@ -18,6 +18,7 @@ import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
import {
copyLatestAttemptXml,
junitFileHasFileAttrs,
maestroRunnerAttemptDirectoryName,
mergeJUnitReports,
parseFailedFlowNamesFromJUnitFile,
parseFailedFlowsFromFileAttrs,
@@ -353,7 +354,7 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
// maestro-runner writes all report formats and screenshot metadata here.
const runnerOutputDirectory = path.join(
testsDirectory,
`${platform}-maestro-runner-attempt-${attempt}`
maestroRunnerAttemptDirectoryName({ platform, attempt })
);
const outputPath = mayHaveJUnitReports
? path.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
@@ -631,7 +632,7 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
try {
await ctx.runtimeApi.uploadArtifact({
artifact: {
type: GenericArtifactType.OTHER,
type: null,
name: selectedReport.name,
paths: [selectedReport.artifactPath],
},
@@ -738,7 +739,7 @@ async function uploadFailureScreenshotsAsync({
await fs.copyFile(shot.fileAbsPath, safePath);
await ctx.runtimeApi.uploadArtifact({
artifact: {
type: GenericArtifactType.OTHER,
type: null,
name: shot.displayName,
paths: [safePath],
metadata: shot.metadata,
@@ -117,7 +117,7 @@ export async function readIpaInfoAsync(ipaPath: string): Promise<IpaInfo> {
}
}
function parseInfoPlistBuffer(data: Buffer): Record<string, unknown> {
export function parseInfoPlistBuffer(data: Buffer): Record<string, unknown> {
const isBinaryPlist = data.subarray(0, 8).toString('ascii') === 'bplist00';
if (isBinaryPlist) {
const parsedBinaryPlists = bplistParser.parseBuffer(data);
@@ -23,7 +23,7 @@ import resolveFrom from 'resolve-from';
import { COMMON_FASTLANE_ENV } from '../../common/fastlane';
import IosCredentialsManager from '../utils/ios/credentials/manager';
import { resolvePackageVersionAsync } from '../../utils/packageManager';
import { installIsolatedNpmPackageAsync } from '../../utils/installIsolatedNpmPackageAsync';
export function createRepackBuildFunction(): BuildFunction {
return new BuildFunction({
@@ -121,18 +121,12 @@ export function createRepackBuildFunction(): BuildFunction {
: undefined;
const jsBundleOnly = (inputs.js_bundle_only.value as boolean | undefined) ?? false;
const resolvedRepackVersion =
(await resolvePackageVersionAsync({
logger: stepsCtx.logger,
packageName: inputs.repack_package.value as string,
distTag: inputs.repack_version.value as string,
})) ?? (inputs.repack_version.value as string);
stepsCtx.logger.info(
`Using repack from: ${inputs.repack_package.value}@${inputs.repack_version.value} (${resolvedRepackVersion})`
);
const repackApp = await installAndImportRepackAsync({
packageName: inputs.repack_package.value as string,
version: resolvedRepackVersion,
versionSpec: inputs.repack_version.value as string,
pathEnv: env.PATH,
npmCacheUrl: env.EAS_BUILD_NPM_CACHE_URL,
logger: stepsCtx.logger,
});
const { repackAppIosAsync, repackAppAndroidAsync } = repackApp;
@@ -226,16 +220,28 @@ function createOutputPath({
*/
async function installAndImportRepackAsync({
packageName,
version,
versionSpec,
pathEnv,
npmCacheUrl,
logger,
}: {
packageName: string;
version: string;
versionSpec: string;
pathEnv: string | undefined;
npmCacheUrl: string | undefined;
logger: bunyan;
}): Promise<typeof import('@expo/repack-app')> {
const sandbox = await fs.promises.mkdtemp(path.join(os.tmpdir(), `repack-package-root-`));
await spawnAsync('yarn', ['add', `${packageName}@${version}`], {
stdio: 'inherit',
cwd: sandbox,
await installIsolatedNpmPackageAsync({
packageSpec: `${packageName}@${versionSpec}`,
directory: sandbox,
npmCacheUrl,
pathEnv,
logger,
});
const packageJsonPath = path.join(sandbox, 'node_modules', packageName, 'package.json');
const { version } = JSON.parse(await fs.promises.readFile(packageJsonPath, 'utf8'));
logger.info(`Using repack from: ${packageName}@${versionSpec} (${version})`);
return require(resolveFrom(sandbox, packageName));
}
@@ -5,10 +5,12 @@ import { z } from 'zod';
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
import {
MaestroFlowResult,
hasMaestroRunnerAttemptDirectories,
isFileAttrRun,
parseJUnitTestCases,
parseMaestroResults,
parseMaestroResultsFromFileAttrs,
parseMaestroRunnerResults,
} from './maestroResultParser';
import { CustomBuildContext } from '../../customBuildContext';
@@ -73,8 +75,15 @@ export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext):
// map testcase names back to paths.
const usedFileAttrs = isFileAttrRun(await parseJUnitTestCases(junitDirectory));
const testsDirectory = (inputs.tests_directory.value as string | undefined) ?? '';
let flowResults: MaestroFlowResult[];
if (usedFileAttrs) {
if (usedFileAttrs && (await hasMaestroRunnerAttemptDirectories(testsDirectory))) {
flowResults = await parseMaestroRunnerResults({
junitDirectory,
testsDirectory,
workingDirectory: stepsCtx.workingDirectory,
});
} else if (usedFileAttrs) {
flowResults = await parseMaestroResultsFromFileAttrs(junitDirectory);
} else {
// Legacy (Maestro < 2.6.0) — DELETE this arm once the fleet is on >= 2.6.0.
@@ -1,4 +1,4 @@
import { Platform } from '@expo/eas-build-job';
import { Platform, formatBytes } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -17,7 +17,6 @@ import * as tar from 'tar';
import { promisify } from 'util';
import z from 'zod';
import { formatBytes } from '../../utils/artifacts';
import { PUBLIC_CACHE_KEY_PREFIX_BY_PLATFORM } from '../../utils/cacheKey';
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
import { TurtleFetchError, turtleFetch } from '../../utils/turtleFetch';
@@ -1,4 +1,4 @@
import { Platform } from '@expo/eas-build-job';
import { Platform, formatBytes } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -14,7 +14,6 @@ import os from 'os';
import path from 'path';
import { compressCacheAsync, uploadCacheAsync } from './saveCache';
import { formatBytes } from '../../utils/artifacts';
import {
CcacheBuildTarget,
generateDefaultBuildCacheKeyAsync,
@@ -1,4 +1,4 @@
import { Platform } from '@expo/eas-build-job';
import { Platform, formatBytes } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
@@ -11,7 +11,7 @@ import path from 'path';
import * as tar from 'tar';
import z from 'zod';
import { formatBytes } from '../../utils/artifacts';
import { uploadWithSignedUrl } from '../../storage/uploadWithSignedUrl';
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
import { getCacheVersion } from '../utils/cache';
@@ -161,16 +161,13 @@ export async function uploadCacheAsync({
logger.info(`Uploading cache...`);
const uploadResponse = await retryOnDNSFailure(fetch)(new URL(url), {
method: 'PUT',
headers,
body: fs.createReadStream(archivePath),
});
if (!uploadResponse.ok) {
throw new Error(
`Unexpected response from cache server (${uploadResponse.status}): ${uploadResponse.statusText}`
);
}
await uploadWithSignedUrl(
{
signedUrl: { url, headers },
srcGeneratorAsync: async () => fs.createReadStream(archivePath),
},
{ retries: 3, retryIntervalMs: 100 }
);
logger.info(`Uploaded cache archive to ${archivePath} (${formatBytes(size)}).`);
}
@@ -226,16 +223,13 @@ export async function uploadPublicCacheAsync({
logger.info(`Uploading public cache...`);
const uploadResponse = await retryOnDNSFailure(fetch)(new URL(url), {
method: 'PUT',
headers,
body: fs.createReadStream(archivePath),
});
if (!uploadResponse.ok) {
throw new Error(
`Unexpected response from cache server (${uploadResponse.status}): ${uploadResponse.statusText}`
);
}
await uploadWithSignedUrl(
{
signedUrl: { url, headers },
srcGeneratorAsync: async () => fs.createReadStream(archivePath),
},
{ retries: 3, retryIntervalMs: 100 }
);
logger.info(`Uploaded cache archive to ${archivePath} (${formatBytes(size)}).`);
}
@@ -1,6 +1,6 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { type BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
@@ -9,6 +9,8 @@ import path from 'node:path';
import { type CustomBuildContext } from '../../customBuildContext';
import { uploadRemoteSessionConfigWithLocalEgressAsync } from '../utils/localEgressSession';
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import { createWarmDeviceSessionServer } from '../utils/warmDeviceSessionHost';
import { type WarmIosSession } from '../utils/warmIosSession';
import { Sentry } from '../../sentry';
import {
PackageManager,
@@ -19,9 +21,12 @@ import {
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
import { type parseNetworkCaptureInputs } from '../utils/networkCaptureFields';
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
import { type ServeSimApplicationOptions } from '../utils/remoteDeviceRunSession';
import { type StartupTasks } from '../utils/startupTasks';
import {
type DetachedProcessHandle,
DeviceRunSessionStoppedError,
TOOL_TUNNEL_TEARDOWN_OPTIONS,
finishRemoteSessionAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
@@ -76,13 +81,9 @@ export function getAgentDeviceRemoteSessionEnvOrThrow(
* Starts the agent-device daemon, its tunnel, the session host and the web preview,
* reports the session as ready, and keeps it alive until it stops.
*
* The daemon does not need the device, so it starts at once. The session host starts
* when `device.booted` resolves. The session is reported as ready only when both are
* up and `device.ready` (the app is installed and launched) resolved too.
*
* The first failure aborts `tasks.signal`: each part stops before its next stage, and
* the teardown then stops whatever was started. `device.ready` must settle soon after
* an abort, because the teardown waits for it.
* Serve-sim boots while the iOS download runs. After device preparation verifies
* any egress guard, its control API installs and launches the app.
* Startup failures abort the other tasks; teardown drains them before stopping acquired resources.
*/
export async function runAgentDeviceRemoteSessionAsync(
ctx: CustomBuildContext,
@@ -98,7 +99,11 @@ export async function runAgentDeviceRemoteSessionAsync(
capture,
disableShare = false,
privatePreviewUrl,
frameAncestors,
corsOrigins,
tasks,
warm,
onStarted,
device,
}: {
env: BuildStepEnv;
@@ -112,10 +117,27 @@ export async function runAgentDeviceRemoteSessionAsync(
capture: ReturnType<typeof parseNetworkCaptureInputs>;
disableShare?: boolean;
privatePreviewUrl?: string;
frameAncestors?: string[];
corsOrigins?: string[];
tasks: StartupTasks;
device: { booted: Promise<unknown>; ready: Promise<unknown> };
/** A claimed warm Simulator: adopt it and use its agent-device daemon. */
warm?: WarmIosSession;
onStarted?: () => Promise<void>;
device:
| {
booted: Promise<unknown>;
ready: Promise<unknown>;
}
| {
iosSimulatorUdid: string;
bootEnv?: Record<string, string>;
application?: Promise<ServeSimApplicationOptions | void>;
};
}
): Promise<void> {
signal?.throwIfAborted();
tasks.signal.throwIfAborted();
logger.info(
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
);
@@ -124,12 +146,81 @@ export async function runAgentDeviceRemoteSessionAsync(
let agentDeviceTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let eventCollection: Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>> | undefined;
let artifactUploads: Promise<void> | undefined;
// Ends periodic polling; the poller then runs one bounded final inventory and upload drain.
const stopArtifactPolling = new AbortController();
let sessionFailed = false;
const daemonSecrets = [ngrokAuthtoken];
const hostBootedDevice = 'iosSimulatorUdid' in device ? device : undefined;
const externallyBootedDevice = 'booted' in device ? device : undefined;
// Each task stores what it started, so the teardown below can stop it even when
// another task failed first.
const sessionHostStartup = tasks.run('session host', async taskLogger => {
if (externallyBootedDevice) {
await tasks.untilAborted(externallyBootedDevice.booted);
}
tasks.signal.throwIfAborted();
sessionHost = await startDeviceSessionHostAsync(
ctx,
{
separateLogPhase: true,
runtimePlatform,
env,
logger: taskLogger,
timeoutMs: STARTUP_TIMEOUT_MS,
preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken },
disableShare,
privatePreviewUrl,
frameAncestors,
corsOrigins,
signal: tasks.signal,
onStartupError: tasks.abort,
networkCapture: capture.networkCapture,
networkCaptureFields: capture.networkCaptureFields,
iosSimulatorUdid: hostBootedDevice?.iosSimulatorUdid,
bootEnv: hostBootedDevice?.bootEnv,
application: hostBootedDevice?.application,
},
...(warm ? [createWarmDeviceSessionServer(ctx, taskLogger, warm)] : [])
);
tasks.signal.throwIfAborted();
const webPreview = await sessionHost.openPreviewAsync();
tasks.signal.throwIfAborted();
taskLogger.info(
webPreview.sharePageUrl
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
return webPreview;
});
const agentDeviceStartup = tasks.run('agent-device daemon', async taskLogger => {
let daemonInfo: { port: number; token: string };
if (warm) {
// Adoption prepares the launcher's daemon for the claimed device.
await tasks.untilAborted(sessionHostStartup);
if (!warm.tools) {
throw new Error('The warm agent-device daemon was not prepared.');
}
daemonInfo = warm.tools;
} else {
daemonInfo = await startDaemonAsync(taskLogger);
}
tasks.signal.throwIfAborted();
agentDeviceTunnel = await startNgrokTunnelAsync({
port: daemonInfo.port,
subdomainPrefix: 'agent-device',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
healthCheck: { path: '/health' },
logger: taskLogger,
signal: tasks.signal,
});
tasks.signal.throwIfAborted();
taskLogger.info(`Tunnel is ready at ${agentDeviceTunnel.url}.`);
return { ...daemonInfo, remoteSessionUrl: agentDeviceTunnel.url };
});
async function startDaemonAsync(taskLogger: bunyan) {
taskLogger.info('Launching agent-device daemon.');
daemonProcess = await startAgentDeviceDaemonAsync({
packageVersion,
@@ -145,62 +236,27 @@ export async function runAgentDeviceRemoteSessionAsync(
const daemonInfo = await tasks.untilAborted(waitForDaemonInfoAsync({ daemonProcess }));
daemonSecrets.push(daemonInfo.token);
taskLogger.info(`Daemon is listening on port ${daemonInfo.port}; loaded auth token.`);
tasks.signal.throwIfAborted();
agentDeviceTunnel = await startNgrokTunnelAsync({
port: daemonInfo.port,
subdomainPrefix: 'agent-device',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
logger: taskLogger,
});
taskLogger.info(`Tunnel is ready at ${agentDeviceTunnel.url}.`);
return { ...daemonInfo, remoteSessionUrl: agentDeviceTunnel.url };
});
const sessionHostStartup = tasks.run('session host', async taskLogger => {
// A boot cannot be cancelled, so stop waiting for it when startup is aborted.
await tasks.untilAborted(device.booted);
tasks.signal.throwIfAborted();
sessionHost = await startDeviceSessionHostAsync(ctx, {
separateLogPhase: true,
runtimePlatform,
env,
logger: taskLogger,
timeoutMs: STARTUP_TIMEOUT_MS,
disableShare,
privatePreviewUrl,
networkCapture: capture.networkCapture,
networkCaptureFields: capture.networkCaptureFields,
});
tasks.signal.throwIfAborted();
const webPreview = await sessionHost.openPreviewAsync({
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
});
taskLogger.info(
webPreview.sharePageUrl
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
return webPreview;
});
return daemonInfo;
}
const deviceReady = externallyBootedDevice?.ready;
try {
const [
{ port: daemonPort, token: daemonToken, remoteSessionUrl: agentDeviceRemoteSessionUrl },
webPreview,
] = await Promise.all([agentDeviceStartup, sessionHostStartup, device.ready]).catch(
] = await Promise.all([agentDeviceStartup, sessionHostStartup, deviceReady]).catch(
(err: unknown) => {
// Also aborts for a `device.ready` that does not come from `tasks.run`.
tasks.abort(err);
throw err;
throw tasks.signal.reason;
}
);
tasks.signal.throwIfAborted();
logger.info(tasks.summary());
await uploadRemoteSessionConfigWithLocalEgressAsync({
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
signal: tasks.signal,
ctx,
deviceRunSessionId,
remoteConfig: {
@@ -212,17 +268,26 @@ export async function runAgentDeviceRemoteSessionAsync(
},
logger,
});
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
if (sessionStart === 'stopped') {
return;
}
await onStarted?.();
tasks.signal.throwIfAborted();
artifactUploads = pollAgentDeviceArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
daemonUrl: `http://127.0.0.1:${daemonPort}`,
daemonToken,
logger,
signal,
stopPollingSignal: stopArtifactPolling.signal,
});
// Attach a handler at once; the teardown still awaits and reports a rejection.
void artifactUploads.catch(() => {});
eventCollection = await startAgentDeviceEventCollectionAsync({
ctx,
deviceRunSessionId,
stateDir: AGENT_DEVICE_STATE_DIR,
stateDir: warm?.agentDeviceStateDir ?? AGENT_DEVICE_STATE_DIR,
logger,
});
@@ -237,41 +302,69 @@ export async function runAgentDeviceRemoteSessionAsync(
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
}
: undefined,
});
} catch (error) {
if (
tasks.signal.reason instanceof DeviceRunSessionStoppedError &&
!(error instanceof AggregateError)
) {
throw tasks.signal.reason;
}
sessionFailed = true;
throw error;
} finally {
// Promise.all rejects on the first failure while other tasks can still be starting.
// They stop at their next abort check. Wait for all of them, so the teardown sees
// everything that was started.
await Promise.allSettled([agentDeviceStartup, sessionHostStartup, device.ready]);
const startupResults = await Promise.allSettled([
agentDeviceStartup,
sessionHostStartup,
deviceReady,
]);
const startedDaemon = daemonProcess;
const toolTeardown = (async () => {
try {
if (eventCollection) {
await stopAgentDeviceEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
}
} finally {
try {
// The final inventory reads from the daemon, so it finishes before the daemon stops.
stopArtifactPolling.abort();
await artifactUploads;
} finally {
await startedDaemon?.stopAsync();
}
}
})();
await finishRemoteSessionAsync({
logger,
sessionFailed,
teardown: [
['agent-device tunnel', agentDeviceTunnel?.stopAsync()],
...startupResults.flatMap((result): [string, Promise<unknown>][] =>
result.status === 'rejected' && result.reason instanceof AggregateError
? [['startup cleanup', Promise.reject(result.reason)]]
: []
),
['agent-device tunnel', agentDeviceTunnel?.stopAsync(), TOOL_TUNNEL_TEARDOWN_OPTIONS],
['agent-device daemon', toolTeardown],
[
'agent-device daemon',
startedDaemon &&
(async () => {
try {
if (eventCollection) {
await stopAgentDeviceEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
}
} finally {
await startedDaemon.stopAsync();
}
})(),
'session host',
(async () => {
if (warm) {
// The warm host owns the daemon too; keep it alive through the final downloads.
await Promise.allSettled([toolTeardown]);
}
await sessionHost?.finishAsync();
})(),
],
['session host', sessionHost?.finishAsync()],
],
});
}
@@ -291,6 +384,7 @@ export async function startAgentDeviceDaemonAsync({
signal?: AbortSignal;
secrets?: string[];
}): Promise<DetachedProcessHandle> {
signal?.throwIfAborted();
const packageSpec = createAgentDevicePackageSpec(packageVersion);
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
const installDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-agent-device-'));
@@ -300,6 +394,7 @@ export async function startAgentDeviceDaemonAsync({
);
try {
signal?.throwIfAborted();
const add = resolvePackageAdd(packageManager, packageSpec);
logger.info(`Installing ${packageSpec} with ${add.command}.`);
await spawn(add.command, add.args, { cwd: installDir, env, logger, signal });
@@ -1,4 +1,4 @@
import { UserError } from '@expo/eas-build-job';
import { UserError, validateAndroidLaunchOptions } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
@@ -15,14 +15,27 @@ import {
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorName, IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
import { getAndroidEmulatorLogCapture } from '../utils/androidEmulatorLogs';
import { parseLogSourcesInput } from '../utils/logSourcesInput';
import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi';
import { readIosApplicationIdentifierAsync } from '../utils/iosAppArtifact';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
import { runOnWarmIosSimulatorAsync } from '../utils/warmDeviceSessionHost';
import { type WarmIosSession } from '../utils/warmIosSession';
import { withLocalEgressSession } from '../utils/localEgressSession';
import {
type ServeSimApplicationOptions,
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createPrivatePreviewUrlInputProvider,
createPublicPreviewInputProvider,
createServeSimLaunchInputProviders,
omitNgrokAuthtoken,
parseCorsOriginsInput,
parseFrameAncestorsInput,
selectXcodeDeveloperDirectoryAsync,
validateServeSimLaunchOptions,
} from '../utils/remoteDeviceRunSession';
import {
createNetworkCaptureInputProviders,
@@ -42,34 +55,30 @@ import {
runAgentDeviceRemoteSessionAsync,
} from './startAgentDeviceRemoteSession';
import { startAndroidEmulatorAsync } from './startAndroidEmulator';
import { bootIosSimulatorAsync } from './startIosSimulator';
const ANDROID_DEVICE_NAME = 'EasAndroidDevice01' as AndroidVirtualDeviceName;
/**
* One step for a whole agent-device session: boot the device, download, install and
* launch the app, and start the agent-device daemon and the web preview.
*
* It does the work of eas/start_ios_simulator or eas/start_android_emulator,
* eas/download_build, eas/install_build and eas/launch_application, plus the agent-device
* session, so the parts that do not depend on each other can run at the same time:
*
* boot ─────────────┬─► install ─► launch ─┐
* download ─────────┘ │
* boot ─► session host ─► web preview ─────┼─► ready
* agent-device daemon ─► tunnel ───────────┘
*
* The first failure aborts the rest: the download stops, and nothing installs, launches
* or starts after it. A boot cannot be cancelled, so it can still run when a failed
* step returns.
* Starts a device session using an optional local app artifact, or one it downloads.
* On iOS, serve-sim boots the selected Simulator, then installs and launches
* the app. Build-tools verifies any egress guard before install and launch.
*/
export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildContext): BuildFunction {
export function createStartAgentDeviceSessionBuildFunction(
ctx: CustomBuildContext,
{ warm = false }: { warm?: boolean } = {}
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_agent_device_session',
id: warm ? 'start_warm_agent_device_session' : 'start_agent_device_session',
name: 'Start agent-device session',
__metricsId: 'eas/start_agent_device_session',
__metricsId: warm ? 'eas/start_warm_agent_device_session' : 'eas/start_agent_device_session',
inputProviders: [
// No default: an omitted input must stay distinguishable from an explicit selection.
BuildStepInput.createProvider({
id: 'log_sources',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
// Device. On iOS a Simulator name or UDID, on Android an AVD hardware profile.
BuildStepInput.createProvider({
id: 'device_identifier',
@@ -97,7 +106,13 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
// Application. Pass at most one of build_id and application_archive_url.
// Application. Pass at most one of install_app_path, build_id and application_archive_url.
BuildStepInput.createProvider({
id: 'install_app_path',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
// Deprecated: remove once workflows pass install_app_path.
BuildStepInput.createProvider({
id: 'build_id',
required: false,
@@ -108,20 +123,13 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'launch_args',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
BuildStepInput.createProvider({
id: 'open_url',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
...createServeSimLaunchInputProviders(),
// Session.
...createNetworkCaptureInputProviders(),
createPublicPreviewInputProvider(),
createPrivatePreviewUrlInputProvider(),
createFrameAncestorsInputProvider(),
createCorsOriginsInputProvider(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -146,24 +154,52 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
// Validate everything before any expensive work starts.
const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(stepEnv);
const env = omitNgrokAuthtoken(stepEnv);
const installAppPath = inputs.install_app_path.value as string | undefined;
const buildId = inputs.build_id.value as string | undefined;
const applicationArchiveUrl = inputs.application_archive_url.value as string | undefined;
if (buildId && applicationArchiveUrl) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
'Pass only one of build_id or application_archive_url.'
);
}
const hasApplication = Boolean(buildId || applicationArchiveUrl);
const launchAppIdentifier = inputs.launch_app_identifier.value as string | undefined;
const launchArgs = parseLaunchArgsInput(inputs.launch_args.value);
const openUrl =
inputs.open_url.value === undefined ? undefined : parseOpenUrlInput(inputs.open_url.value);
if (!hasApplication && (launchArgs.length > 0 || openUrl)) {
const download = buildId || applicationArchiveUrl;
if ([installAppPath, buildId, applicationArchiveUrl].filter(Boolean).length > 1) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
'launch_args and open_url need an application: pass build_id or application_archive_url.'
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
'Pass only one of install_app_path, build_id or application_archive_url.'
);
}
if (download && launchAppIdentifier !== undefined) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
'launch_app_identifier cannot be combined with build_id or application_archive_url.'
);
}
if (
!installAppPath &&
!download &&
!launchAppIdentifier &&
(launchArgs.length > 0 || openUrl)
) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
'launch_args and open_url need an application: pass install_app_path or launch_app_identifier.'
);
}
if (!isIos && launchAppIdentifier !== undefined) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
'launch_app_identifier is only supported for iOS Simulator sessions. Pass install_app_path for Android.'
);
}
if (isIos) {
validateServeSimLaunchOptions({ launchArgs, openUrl });
} else {
validateAndroidLaunchOptions({ launchArgs, openUrl });
}
const logSources = parseLogSourcesInput(inputs.log_sources.value, {
runtimePlatform,
fallback: isIos ? [] : ['emulator-output'],
});
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
const capture = parseNetworkCaptureInputs(
{
@@ -173,107 +209,212 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
{ runtimePlatform }
);
if (isIos) {
// Before the boot, so every Xcode tool below uses the same developer directory.
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const tasks = createStartupTasks(logger);
const booted = tasks.run(
isIos ? 'iOS Simulator boot' : 'Android Emulator boot',
async taskLogger => {
if (isIos) {
await bootIosSimulatorAsync({
deviceIdentifier: deviceIdentifier as IosSimulatorUuid | IosSimulatorName | undefined,
env,
logger: taskLogger,
});
return;
}
await startAndroidEmulatorAsync({
deviceName: ANDROID_DEVICE_NAME,
systemImagePackage: `${inputs.system_image_package.value}`,
deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined,
lcdWidth: inputs.lcd_width.value as number | undefined,
lcdHeight: inputs.lcd_height.value as number | undefined,
lcdDensity: inputs.lcd_density.value as number | undefined,
logcatDirectory: await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
),
env,
logger: taskLogger,
});
}
);
const downloaded = hasApplication
? tasks.run('build download', async taskLogger => {
const { artifactPath } = await downloadBuildAsync({
logger: taskLogger,
...(buildId ? { buildId } : { applicationArchiveUrl: applicationArchiveUrl! }),
graphqlClient: ctx.graphqlClient,
deviceRunSessionWorkerApi: buildId ? getDeviceRunSessionWorkerApi(ctx) : null,
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
extensions: isIos ? ['app'] : ['apk'],
signal: tasks.signal,
});
return artifactPath;
})
: undefined;
// `ready` settles only after everything it started has settled, so the session
// teardown, which waits for it, never leaves a download running. It must also settle
// soon after an abort: it stops waiting for the boot then, and the download stops.
const ready = downloaded
? tasks.run('app install and launch', async taskLogger => {
const [download, boot] = await Promise.allSettled([
downloaded,
tasks.untilAborted(booted),
]);
// After a failure elsewhere, report that failure, not the abort it caused here.
tasks.signal.throwIfAborted();
if (download.status === 'rejected') {
throw download.reason;
}
if (boot.status === 'rejected') {
throw boot.reason;
}
const { applicationIdentifier, activityName } = await installBuildAsync({
artifactPath: download.value,
runtimePlatform,
env,
logger: taskLogger,
});
tasks.signal.throwIfAborted();
await launchApplicationAsync({
applicationIdentifier,
activityName,
launchArgs,
openUrl,
runtimePlatform,
env,
logger: taskLogger,
});
})
: tasks.untilAborted(booted);
await runAgentDeviceRemoteSessionAsync(ctx, {
env,
const startup = new AbortController();
const startupSignal = signal ? AbortSignal.any([signal, startup.signal]) : startup.signal;
const startupWatch = watchDeviceRunSessionStartup({
ctx,
deviceRunSessionId: sessionEnv.deviceRunSessionId,
logger,
signal,
runtimePlatform,
sessionEnv,
packageVersion: inputs.package_version.value as string | undefined,
// A missing or non-positive value disables the idle timeout (opt-in feature).
maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined,
maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined,
capture,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
tasks,
device: { booted, ready },
startup: { signal: startupSignal, abort: reason => startup.abort(reason) },
});
const startSessionAsync = async (
warmSession?: WarmIosSession,
resolvedPackageVersion = inputs.package_version.value as string | undefined
): Promise<void> => {
const tasks = createStartupTasks(logger, startupSignal);
let booted: Promise<void> | undefined;
let sessionOwnsTeardown = false;
let downloaded: Promise<string> | undefined;
let applicationReady: Promise<ServeSimApplicationOptions | void> | undefined;
try {
if (isIos) {
// Select Xcode before serve-sim boots the device or any Simulator tools run.
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
tasks.signal.throwIfAborted();
const { launch, iosStartup } = isIos
? await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: true,
deviceIdentifier: warmSession?.device.udid ?? deviceIdentifier,
installAppPath,
launchAppIdentifier,
// A downloaded app gets its launch options after the download.
launchArgs: download ? [] : launchArgs,
openUrl: download ? undefined : openUrl,
env,
logger,
signal: tasks.signal,
})
: {};
const iosSimulatorUdid = iosStartup?.iosSimulatorUdid;
if (iosSimulatorUdid) {
logger.info(`Selected iOS Simulator: ${iosSimulatorUdid}.`);
}
booted = iosSimulatorUdid
? Promise.resolve()
: tasks.run('Android Emulator boot', async taskLogger => {
const logcatDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
);
tasks.signal.throwIfAborted();
await startAndroidEmulatorAsync({
deviceName: ANDROID_DEVICE_NAME,
systemImagePackage: `${inputs.system_image_package.value}`,
deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined,
lcdWidth: inputs.lcd_width.value as number | undefined,
lcdHeight: inputs.lcd_height.value as number | undefined,
lcdDensity: inputs.lcd_density.value as number | undefined,
logcatDirectory,
logCapture: getAndroidEmulatorLogCapture(global, env, logSources),
env,
logger: taskLogger,
});
});
downloaded = download
? tasks.run('build download', async taskLogger => {
const source = buildId
? { buildId }
: applicationArchiveUrl
? { applicationArchiveUrl }
: undefined;
if (!source) {
throw new Error('No app source was provided for download.');
}
const { artifactPath } = await downloadBuildAsync({
logger: taskLogger,
...source,
graphqlClient: ctx.graphqlClient,
deviceRunSessionWorkerApi: buildId ? getDeviceRunSessionWorkerApi(ctx) : null,
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
extensions: isIos ? ['app'] : ['apk'],
signal: tasks.signal,
});
return artifactPath;
})
: undefined;
const downloadedApp = downloaded;
if (isIos && downloadedApp) {
applicationReady = tasks.run('app preparation', async () => {
const artifactPath = await downloadedApp;
tasks.signal.throwIfAborted();
const applicationIdentifier = await readIosApplicationIdentifierAsync({
artifactPath,
env,
});
tasks.signal.throwIfAborted();
return {
installAppPath: artifactPath,
launchAppIdentifier: applicationIdentifier,
launchArgs,
openUrl,
};
});
} else if (isIos && launch?.launchAppIdentifier) {
applicationReady = Promise.resolve({
...launch,
launchAppIdentifier: launch.launchAppIdentifier,
installAppPath,
});
} else if (!isIos && (installAppPath || downloadedApp)) {
applicationReady = tasks.run('app install and launch', async taskLogger => {
await tasks.untilAborted(booted!);
const artifactPath = installAppPath ?? (await downloadedApp);
if (!artifactPath) {
throw new Error('No app artifact was prepared.');
}
tasks.signal.throwIfAborted();
const { applicationIdentifier, activityName } = await installBuildAsync({
artifactPath,
runtimePlatform,
env,
logger: taskLogger,
});
tasks.signal.throwIfAborted();
await launchApplicationAsync({
applicationIdentifier,
activityName,
launchArgs,
openUrl,
runtimePlatform,
env,
logger: taskLogger,
});
});
}
sessionOwnsTeardown = true;
await runAgentDeviceRemoteSessionAsync(ctx, {
env,
logger,
signal,
runtimePlatform,
sessionEnv,
packageVersion: resolvedPackageVersion,
// A missing or non-positive value disables the idle timeout (opt-in feature).
maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined,
maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined,
capture,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
tasks,
warm: warmSession,
onStarted: () => startupWatch.completeAsync(),
device: iosSimulatorUdid
? {
iosSimulatorUdid,
bootEnv: iosStartup?.bootEnv,
application: applicationReady,
}
: {
booted,
ready: applicationReady ?? tasks.untilAborted(booted),
},
});
} catch (error) {
if (
!sessionOwnsTeardown &&
tasks.signal.reason === startupWatch.stopped &&
!(error instanceof AggregateError)
) {
throw startupWatch.stopped;
}
throw error;
} finally {
tasks.abort(new Error('Agent-device session ended.'));
await Promise.allSettled([booted, downloaded, applicationReady]);
}
};
try {
startupSignal.throwIfAborted();
if (warm && isIos && !download) {
await runOnWarmIosSimulatorAsync(
{
engine: 'agent-device',
requestedDevice: deviceIdentifier,
packageVersion: inputs.package_version.value as string | undefined,
env,
logger,
signal: startupSignal,
},
startSessionAsync
);
} else {
await startSessionAsync();
}
} catch (error) {
if (error !== startupWatch.stopped) {
throw error;
}
} finally {
await startupWatch.finishAsync();
}
}),
});
}
@@ -20,8 +20,17 @@ import {
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../../utils/AndroidEmulatorUtils';
import { type AndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogCapture';
import { retryAsync } from '../../utils/retry';
import { resolveAndroidEmulatorLocalEgressAsync } from '../utils/androidLocalEgress';
import { ALL_ANDROID_EMULATOR_LOG_SOURCES } from '../../utils/androidEmulatorLogSources';
import { getAndroidEmulatorLogCapture } from '../utils/androidEmulatorLogs';
import {
EMULATOR_CAPTURE_REQUESTED_ENV,
installEmulatorCaptureAsync,
parseExperimentalEmulatorCaptureVersionInput,
} from '../utils/emulatorCapture';
import { parseLogSourcesInput } from '../utils/logSourcesInput';
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
@@ -33,6 +42,12 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
name: 'Start Android Emulator',
__metricsId: 'eas/start_android_emulator',
inputProviders: [
BuildStepInput.createProvider({
id: 'log_sources',
required: false,
defaultValue: [...ALL_ANDROID_EMULATOR_LOG_SOURCES],
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
BuildStepInput.createProvider({
id: 'device_name',
required: false,
@@ -71,6 +86,12 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
defaultValue: 1,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
// Experimental: the expo-emulator-capture version that the emulator preloads.
BuildStepInput.createProvider({
id: 'experimental_emulator_capture_version',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
outputProviders: [
BuildStepOutput.createProvider({
@@ -78,14 +99,41 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
required: true,
}),
],
fn: async ({ logger }, { inputs, outputs, env }) => {
fn: async ({ logger, global }, { inputs, outputs, env }) => {
const count = Number(inputs.count.value ?? 1);
const emulatorCaptureVersion = parseExperimentalEmulatorCaptureVersionInput(
inputs.experimental_emulator_capture_version.value
);
if (emulatorCaptureVersion && count > 1) {
throw new UserError(
'EAS_EMULATOR_CAPTURE_INVALID_INPUT',
'experimental_emulator_capture_version supports one emulator. Remove it, or set count to 1.'
);
}
const logSources = parseLogSourcesInput(inputs.log_sources.value, {
runtimePlatform: global.runtimePlatform,
fallback: ALL_ANDROID_EMULATOR_LOG_SOURCES,
});
if (emulatorCaptureVersion) {
// Marks the request for the session step, which rejects session types that cannot use
// the capture. The mark does not depend on the install, so the rejection does not either.
global.updateEnv({
...global.env,
[EMULATOR_CAPTURE_REQUESTED_ENV]: emulatorCaptureVersion,
});
}
const emulatorCaptureLibrary = emulatorCaptureVersion
? await installEmulatorCaptureAsync({ version: emulatorCaptureVersion, env, logger })
: undefined;
const logcatDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
);
const logCapture = getAndroidEmulatorLogCapture(global, env, logSources);
outputs.logcat_directory.set(logcatDirectory);
const deviceName = `${inputs.device_name.value}` as AndroidVirtualDeviceName;
const { serialId, emulatorPromise, shouldAdjustAnimationScale } =
const { serialId, emulatorPromise, shouldAdjustAnimationScale, emulatorCaptureSocketPath } =
await startAndroidEmulatorAsync({
deviceName,
systemImagePackage: `${inputs.system_image_package.value}`,
@@ -95,11 +143,24 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
lcdHeight: inputs.lcd_height.value as number | undefined,
lcdDensity: inputs.lcd_density.value as number | undefined,
logcatDirectory,
logCapture,
env,
logger,
emulatorCaptureLibrary,
});
if (emulatorCaptureLibrary && emulatorCaptureSocketPath) {
// The session step starts expo-device-hub with the job env. A Hub version without the
// emulator capture ignores these variables.
global.updateEnv({
...global.env,
EXPO_DEVICE_HUB_EXPERIMENTAL_EMULATOR_CAPTURE: '1',
EXPO_DEVICE_HUB_EMULATOR_CAPTURE_LIBRARY: emulatorCaptureLibrary,
});
logger.info(
`${serialId} preloads expo-emulator-capture@${emulatorCaptureVersion} with -gpu lavapipe, and its agent socket is ${emulatorCaptureSocketPath}. Later steps start expo-device-hub with the emulator capture on.`
);
}
const count = Number(inputs.count.value ?? 1);
if (count > 1) {
logger.info(`Requested ${count} emulators, shutting down ${deviceName} for cloning.`);
await spawn('adb', ['-s', serialId, 'shell', 'reboot', '-p'], {
@@ -133,6 +194,7 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
deviceName: cloneIdentifier,
env,
logcatDirectory,
logCapture,
});
cloneSerialId = startResult.serialId;
@@ -202,8 +264,10 @@ export async function startAndroidEmulatorAsync({
lcdHeight,
lcdDensity,
logcatDirectory,
logCapture,
env,
logger,
emulatorCaptureLibrary,
}: {
deviceName: AndroidVirtualDeviceName;
systemImagePackage: string;
@@ -212,12 +276,17 @@ export async function startAndroidEmulatorAsync({
lcdHeight?: number;
lcdDensity?: number;
logcatDirectory: string;
logCapture: AndroidEmulatorLogCapture;
env: BuildStepEnv;
logger: bunyan;
/** The expo-emulator-capture agent library that the emulator preloads (experimental). */
emulatorCaptureLibrary?: string;
}): Promise<{
serialId: AndroidDeviceSerialId;
emulatorPromise: Promise<unknown>;
shouldAdjustAnimationScale: boolean;
/** The capture agent's socket, when the emulator preloads the agent on the expected serial. */
emulatorCaptureSocketPath?: string;
}> {
if (env.EAS_NO_EMULATOR_HOST_SUPPORT_CHECK !== '1') {
await assertAndroidEmulatorHostSupportAsync({ env });
@@ -265,8 +334,16 @@ export async function startAndroidEmulatorAsync({
}
);
// expo-device-hub gets the job env and finds the agent sockets in `<tmpdir>/expo-emulator-capture`,
// where Node's os.tmpdir() applies this rule to that env. On EAS, TMPDIR is not set.
const captureSocketDirectory = path.join(
env.TMPDIR || env.TMP || env.TEMP || '/tmp',
'expo-emulator-capture'
);
let emulatorPromise = null;
let serialId = null;
// Set by the attempt that starts; the cast keeps the type wide after the retry callback.
let captureLaunch = undefined as { consolePort: number; socketPath: string } | undefined;
await retryAsync(
async attemptCount => {
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
@@ -289,11 +366,37 @@ export async function startAndroidEmulatorAsync({
});
logger.info(`Starting emulator device${attemptSuffix}.`);
// expo-device-hub finds the capture agent's socket by the serial, so the console port,
// which sets the serial, is chosen before the start, again for each attempt.
const consolePort = emulatorCaptureLibrary
? await AndroidEmulatorUtils.findFreeConsolePortAsync()
: undefined;
const launch =
consolePort !== undefined
? {
consolePort,
socketPath: path.join(captureSocketDirectory, `emulator-${consolePort}.sock`),
}
: undefined;
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName,
env,
logcatDirectory,
logCapture,
...(localEgress ? { launchGate: localEgress.launchGate } : {}),
...(emulatorCaptureLibrary && launch
? {
consolePort: launch.consolePort,
// The Linux agent kept 60 fps with the lavapipe renderer.
extraArgs: ['-gpu', 'lavapipe'],
extraEnv: {
// A library that the job already preloads stays preloaded, after the agent.
LD_PRELOAD: [emulatorCaptureLibrary, env.LD_PRELOAD].filter(Boolean).join(':'),
// The agent creates the directory.
EXPO_EMULATOR_CAPTURE_SOCKET: launch.socketPath,
},
}
: {}),
});
attemptSerialId = startResult.serialId;
await AndroidEmulatorUtils.waitForReadyAsync({
@@ -315,6 +418,7 @@ export async function startAndroidEmulatorAsync({
serialId = attemptSerialId;
emulatorPromise = startResult.emulatorPromise;
captureLaunch = launch;
} catch (err) {
logger.warn({ err }, `${deviceName} failed to start on attempt ${attempt}/${maxAttempts}.`);
try {
@@ -348,7 +452,19 @@ export async function startAndroidEmulatorAsync({
if (!serialId || !emulatorPromise) {
throw new Error(`Failed to start emulator ${deviceName}.`);
}
return { serialId, emulatorPromise, shouldAdjustAnimationScale };
// The Hub looks for the socket by serial. $ANDROID_EMULATOR_EXTRA_ARGS can override -port.
if (captureLaunch && serialId !== `emulator-${captureLaunch.consolePort}`) {
logger.warn(
`${serialId} does not use console port ${captureLaunch.consolePort}, so expo-device-hub cannot find its capture agent socket ${captureLaunch.socketPath}. The session streams without the emulator capture. Remove -port from $ANDROID_EMULATOR_EXTRA_ARGS.`
);
captureLaunch = undefined;
}
return {
serialId,
emulatorPromise,
shouldAdjustAnimationScale,
emulatorCaptureSocketPath: captureLaunch?.socketPath,
};
}
async function assertAndroidEmulatorHostSupportAsync({
@@ -1,6 +1,6 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { type Result, asyncResult } from '@expo/results';
import {
BuildFunction,
BuildRuntimePlatform,
@@ -8,11 +8,14 @@ import {
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import spawn, { type SpawnOptions, type SpawnResult } from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import semver from 'semver';
import bplistParser from 'bplist-parser';
import plist from 'plist';
import { z } from 'zod';
import { type CustomBuildContext } from '../../customBuildContext';
@@ -21,22 +24,38 @@ import {
withLocalEgressSession,
} from '../utils/localEgressSession';
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import {
WarmSessionAdoptionError,
createWarmDeviceSessionServer,
runOnWarmIosSimulatorAsync,
} from '../utils/warmDeviceSessionHost';
import { type WarmIosSession } from '../utils/warmIosSession';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
import {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
} from '../utils/networkCaptureFields';
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
import {
PackageManager,
resolveConfiguredPackageManager,
resolvePackageAdd,
} from '../../utils/packageManager';
import { killProcessGroup } from '../../utils/processes';
import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
import { resolveSimulatorPackageVersionAsync } from '../../utils/simulatorPackageVersion';
import { readPreparedSimulatorToolAsync } from '../../utils/warmSimulator';
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
import { preparePreinstalledWdaAsync } from '../utils/preinstalledWda';
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
import {
DeviceRunSessionStoppedError,
TOOL_TUNNEL_TEARDOWN_OPTIONS,
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createIosSessionStartupInputProviders,
createPrivatePreviewUrlInputProvider,
createPublicPreviewInputProvider,
createServeSimLaunchInputProviders,
@@ -47,7 +66,8 @@ import {
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
omitNgrokAuthtoken,
parseServeSimLaunchInputs,
parseCorsOriginsInput,
parseFrameAncestorsInput,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startNgrokTunnelAsync,
@@ -58,25 +78,36 @@ const APPIUM_HOST = '127.0.0.1';
const APPIUM_PORT = 4723;
const APPIUM_STARTUP_TIMEOUT_MS = 120_000;
const DEFAULT_APPIUM_VERSION = '^3';
const FDA_HELPER = '/usr/local/libexec/turtle-tcc-fda';
const AppiumInstalledDriversSchema = z.record(
z.string(),
z.object({ installed: z.boolean().optional() }).passthrough()
);
const PreparedAppiumSchema = z.object({
version: z.string(),
xcuitest: z.string(),
home: z.string(),
});
const PackageVersionSchema = z.object({ version: z.string() });
export function createStartAppiumRemoteSessionBuildFunction(
ctx: CustomBuildContext
ctx: CustomBuildContext,
{ warm = false }: { warm?: boolean } = {}
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_appium_remote_session',
id: warm ? 'start_warm_appium_remote_session' : 'start_appium_remote_session',
name: 'Start Appium remote session',
__metricsId: 'eas/start_appium_remote_session',
__metricsId: warm ? 'eas/start_warm_appium_remote_session' : 'eas/start_appium_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
...createIosSessionStartupInputProviders(),
...createNetworkCaptureInputProviders(),
createPublicPreviewInputProvider(),
createPrivatePreviewUrlInputProvider(),
createFrameAncestorsInputProvider(),
createCorsOriginsInputProvider(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -96,171 +127,347 @@ export function createStartAppiumRemoteSessionBuildFunction(
const packageVersion = inputs.package_version.value as string | undefined;
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
const { runtimePlatform } = global;
const launch = parseServeSimLaunchInputs(
{
const startupStop = new AbortController();
const watchSignal = signal
? AbortSignal.any([signal, startupStop.signal])
: startupStop.signal;
const startupWatch = watchDeviceRunSessionStartup({
ctx,
deviceRunSessionId,
logger,
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
});
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
let requestedVersion: string | undefined;
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
const startupAbortController = new AbortController();
const startupSignal = AbortSignal.any([watchSignal, startupAbortController.signal]);
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
deviceIdentifier: warmSession?.device.udid ?? deviceIdentifier,
installAppPath: inputs.install_app_path?.value as string | undefined,
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
},
{ runtimePlatform }
);
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
{ runtimePlatform }
);
const versionSpec = resolveAppium3VersionSpec(packageVersion);
logger.info(
`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
const device = await resolveAppiumDeviceAsync({ runtimePlatform, env, logger });
// Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the
// simulator stream with it, and UiAutomator2 merges long recordings with it. The
// macOS session image does not ship ffmpeg, so install it concurrently with Appium.
// Both installs settle before the step continues or fails, so no install outlives it.
const [appiumInstall] = await Promise.all([
asyncResult(
installAppiumAsync({ versionSpec, driverName: device.driverName, env, logger })
),
ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger }),
]);
const { appiumHome, appiumBinPath, appiumEnv } = appiumInstall.enforceValue();
const wdaCapabilities = await preparePreinstalledWdaAsync({
runtimeIdentifier: device.runtimeIdentifier,
appiumHome,
appiumBinPath,
appiumEnv,
logger,
});
const appiumProcess = spawnDetached({
command: appiumBinPath,
args: [
'--address',
APPIUM_HOST,
'--port',
String(APPIUM_PORT),
'--base-path',
'/',
'--log-level',
'error',
// Appium 3 gates session listing (GET /appium/sessions) behind the
// session_discovery insecure feature. We rely on it to poll for
// Appium Event Timings, so enable it for all drivers.
'--allow-insecure',
'*:session_discovery',
'--default-capabilities',
JSON.stringify({ 'appium:eventTimings': true, ...wdaCapabilities }),
],
env: appiumEnv,
logger,
});
let eventCollection: Awaited<ReturnType<typeof startAppiumEventCollectionAsync>> | undefined;
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let sessionFailed = false;
try {
await waitForAppiumReadyAsync({ appiumProcess, logger });
eventCollection = await startAppiumEventCollectionAsync({
ctx,
deviceRunSessionId,
appiumUrl: `http://${APPIUM_HOST}:${APPIUM_PORT}/`,
logger,
});
appiumTunnel = await startNgrokTunnelAsync({
port: APPIUM_PORT,
subdomainPrefix: 'appium',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
logger,
});
// expo-device-hub has no serial-selection flag. Device run session workflows must expose
// a single booted Android emulator so the Hub and Appium resolve the same device.
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
sessionHost = await startDeviceSessionHostAsync(ctx, {
separateLogPhase: true,
runtimePlatform,
env,
logger,
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
networkCapture,
networkCaptureFields,
signal: startupSignal,
});
const webPreview = await sessionHost.openPreviewAsync({
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
});
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
deviceRunSessionId,
remoteConfig: {
appiumUrl: appiumTunnel.url,
capabilities: {
platformName: device.platformName,
'appium:automationName': device.automationName,
'appium:udid': device.udid,
},
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
logger,
});
{ runtimePlatform }
);
const resolveRequestedVersionAsync =
packageVersion === undefined
? undefined
: async (): Promise<string> =>
(requestedVersion ??= await resolveSimulatorPackageVersionAsync(
'appium',
packageVersion,
{ env, signal: startupSignal }
));
// Keep cold ranges on the configured installer's path. Tags need resolution to enforce Appium 3.
const versionSpec = resolveAppium3VersionSpec(
packageVersion !== undefined && !semver.validRange(packageVersion)
? await resolveRequestedVersionAsync!()
: packageVersion
);
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
}
: undefined,
});
} catch (error) {
sessionFailed = true;
throw error;
} finally {
await finishRemoteSessionAsync({
logger,
sessionFailed,
teardown: [
['Appium tunnel', appiumTunnel?.stopAsync()],
[
'Appium server',
(async () => {
try {
await eventCollection?.stopAsync();
} finally {
try {
await appiumProcess.stopAsync();
} finally {
await fs.promises.rm(appiumHome, { recursive: true, force: true });
}
}
})(),
logger.info(
`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
const device: AppiumDevice = iosStartup
? {
platformName: 'iOS',
automationName: 'XCUITest',
driverName: 'xcuitest',
udid: iosStartup.iosSimulatorUdid,
}
: await resolveAppiumDeviceAsync({ runtimePlatform, env });
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let appiumProcess: ReturnType<typeof spawnDetached> | undefined;
let appiumHome: string | undefined;
let eventCollection:
| Awaited<ReturnType<typeof startAppiumEventCollectionAsync>>
| undefined;
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let sessionFailed = false;
let sessionError: unknown;
const startHost = () =>
startDeviceSessionHostAsync(
ctx,
{
...iosStartup,
previewDeviceId: device.udid,
separateLogPhase: true,
runtimePlatform,
env,
logger,
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken },
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
signal: startupSignal,
onStartupError: error => startupAbortController.abort(error),
...launch,
networkCapture,
networkCaptureFields,
},
...(warmSession ? [createWarmDeviceSessionServer(ctx, logger, warmSession)] : [])
).catch(error => {
startupAbortController.abort(error);
throw error;
});
try {
if (iosStartup) {
hostStartup = asyncResult(startHost());
}
// Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the
// simulator stream with it, and UiAutomator2 merges long recordings with it. The
// macOS session image does not ship ffmpeg, so install it concurrently with Appium.
// Both owned installs settle before proceeding; a shared FFmpeg install keeps its owner.
const [appiumInstall] = await Promise.all([
asyncResult(
installAppiumAsync({
versionSpec,
resolveRequestedVersionAsync,
driverName: device.driverName,
env,
logger,
signal: startupSignal,
onError: error => startupAbortController.abort(error),
}).catch(error => {
startupAbortController.abort(error);
throw error;
})
),
asyncResult(
ensureFfmpegInstalledOnceAsync({
runtimePlatform,
env,
logger,
signal: startupSignal,
})
),
]);
const installation = appiumInstall.enforceValue();
// A prepared home is shared across sessions; remove only a job-owned home after a session.
appiumHome = installation.prepared ? undefined : installation.appiumHome;
startupSignal?.throwIfAborted();
const { appiumBinPath, appiumEnv } = installation;
const wdaCapabilities = await preparePreinstalledWdaAsync({
runtimeIdentifier: iosStartup
? (
await IosSimulatorUtils.getDeviceAsync({
udid: iosStartup.iosSimulatorUdid as IosSimulatorUuid,
env,
})
)?.runtime
: device.runtimeIdentifier,
appiumHome: installation.appiumHome,
prepared: installation.prepared,
appiumBinPath,
appiumEnv,
logger,
signal: startupSignal,
});
startupSignal?.throwIfAborted();
const useFdaHelper =
runtimePlatform === BuildRuntimePlatform.DARWIN && fs.existsSync(FDA_HELPER);
appiumProcess = spawnDetached({
command: useFdaHelper ? FDA_HELPER : appiumBinPath,
args: [
...(useFdaHelper ? [appiumBinPath] : []),
'--address',
APPIUM_HOST,
'--port',
String(APPIUM_PORT),
'--base-path',
'/',
'--log-level',
'error',
// Appium 3 gates session listing (GET /appium/sessions) behind the
// session_discovery insecure feature. We rely on it to poll for
// Appium Event Timings, so enable it for all drivers.
'--allow-insecure',
'*:session_discovery',
'--default-capabilities',
JSON.stringify({
'appium:eventTimings': true,
// Without it, XCUITest restarted the booted Simulator at session start, which
// closed the app serve-sim had launched. EAS VMs never show the Simulator window.
...(device.platformName === 'iOS' ? { 'appium:isHeadless': true } : {}),
...wdaCapabilities,
}),
],
['session host', sessionHost?.finishAsync()],
],
});
env: appiumEnv,
logger,
});
await waitForAppiumReadyAsync({ appiumProcess, logger, signal: startupSignal });
if (hostStartup) {
sessionHost = (await hostStartup).enforceValue();
}
startupSignal?.throwIfAborted();
eventCollection = await startAppiumEventCollectionAsync({
ctx,
deviceRunSessionId,
appiumUrl: `http://${APPIUM_HOST}:${APPIUM_PORT}/`,
logger,
});
appiumTunnel = await startNgrokTunnelAsync({
port: APPIUM_PORT,
subdomainPrefix: 'appium',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
healthCheck: { path: '/status' },
logger,
signal: startupSignal,
});
// The preview host uses the same device selected for Appium.
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
startupSignal.throwIfAborted();
const webPreview = await sessionHost.openPreviewAsync();
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal: startupSignal,
ctx,
deviceRunSessionId,
remoteConfig: {
appiumUrl: appiumTunnel.url,
capabilities: {
platformName: device.platformName,
'appium:automationName': device.automationName,
'appium:udid': device.udid,
},
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
await startupWatch.completeAsync();
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
}
: undefined,
});
} catch (error) {
if (
error === startupSignal.reason &&
startupSignal.reason instanceof DeviceRunSessionStoppedError
) {
throw startupSignal.reason;
}
sessionFailed = true;
sessionError =
error instanceof AggregateError
? error
: startupSignal.aborted &&
!(startupSignal.reason instanceof DeviceRunSessionStoppedError)
? startupSignal.reason
: error;
throw sessionError;
} finally {
startupAbortController.abort(new Error('Appium session ended.'));
const appiumStopErrors: unknown[] = [];
const appiumTeardown = (async () => {
try {
await eventCollection?.stopAsync();
} finally {
try {
await appiumProcess?.stopAsync();
} catch (error) {
appiumStopErrors.push(error);
throw error;
} finally {
if (appiumHome) {
await fs.promises.rm(appiumHome, { recursive: true, force: true });
}
}
}
})();
await finishRemoteSessionAsync({
logger,
sessionFailed,
teardown: [
['Appium tunnel', appiumTunnel?.stopAsync(), TOOL_TUNNEL_TEARDOWN_OPTIONS],
['Appium server', appiumTeardown],
[
'session host',
hostStartup?.then(async result => {
if (warmSession) {
await Promise.allSettled([appiumTeardown]);
}
if (result.reason instanceof AggregateError) {
throw result.reason;
}
await result.value?.finishAsync();
}),
],
],
});
if (sessionError instanceof WarmSessionAdoptionError && appiumStopErrors.length) {
throw new AggregateError(
[sessionError, ...appiumStopErrors],
'Could not stop Appium after warm adoption failed; a cold session cannot start safely.'
);
}
}
};
try {
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
watchSignal.throwIfAborted();
if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) {
await runOnWarmIosSimulatorAsync(
// package_version is the Appium version. The job installs it or uses the prepared
// home; the warm claim does not depend on it.
{
engine: 'appium',
requestedDevice: deviceIdentifier,
env,
logger,
signal: watchSignal,
},
startSessionAsync
);
} else {
await startSessionAsync();
}
} catch (error) {
if (error !== startupWatch.stopped) {
throw error;
}
} finally {
await startupWatch.finishAsync();
}
}),
});
@@ -288,15 +495,12 @@ export type AppiumDevice = {
export async function resolveAppiumDeviceAsync({
runtimePlatform,
env,
logger,
}: {
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
logger: bunyan;
}): Promise<AppiumDevice> {
switch (runtimePlatform) {
case BuildRuntimePlatform.DARWIN: {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
const [bootedDevice] = await IosSimulatorUtils.getAvailableDevicesAsync({
env,
filter: 'booted',
@@ -330,68 +534,241 @@ export async function resolveAppiumDeviceAsync({
export async function installAppiumAsync({
versionSpec,
resolveRequestedVersionAsync,
driverName,
env,
logger,
signal,
onError,
}: {
versionSpec: string;
resolveRequestedVersionAsync?: () => Promise<string>;
driverName: AppiumDevice['driverName'];
env: BuildStepEnv;
logger: bunyan;
}): Promise<{ appiumHome: string; appiumBinPath: string; appiumEnv: BuildStepEnv }> {
signal?: AbortSignal;
onError?: (error: unknown) => void;
}): Promise<{
appiumHome: string;
appiumBinPath: string;
appiumEnv: BuildStepEnv;
prepared: boolean;
}> {
signal?.throwIfAborted();
let prepared =
driverName === 'xcuitest'
? await readPreparedSimulatorToolAsync('appium', PreparedAppiumSchema)
: undefined;
if (
prepared &&
!(await isPreparedAppiumCompatibleAsync(prepared, { versionSpec, env, signal }))
) {
prepared = undefined;
}
if (prepared && resolveRequestedVersionAsync) {
versionSpec = resolveAppium3VersionSpec(await resolveRequestedVersionAsync());
if (!semver.satisfies(prepared.version, versionSpec)) {
prepared = undefined;
}
}
if (prepared) {
try {
// An invalid package manager override fails as it does without a prepared home.
resolveConfiguredPackageManager(env, PackageManager.NPM);
} catch (error) {
onError?.(error);
throw error;
}
logger.info(
`Using the prefetched Appium home (appium ${prepared.version}, xcuitest ${prepared.xcuitest}).`
);
return {
appiumHome: prepared.home,
appiumBinPath: path.join(prepared.home, 'node_modules', '.bin', 'appium'),
appiumEnv: { ...env, APPIUM_HOME: prepared.home },
prepared: true,
};
}
const appiumHome = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-appium-home-'));
await fs.promises.writeFile(
path.join(appiumHome, 'package.json'),
`${JSON.stringify({ name: 'eas-appium-home', private: true })}\n`
);
const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome };
const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium');
const add = resolvePackageAdd(
resolveConfiguredPackageManager(env, PackageManager.NPM),
`appium@${versionSpec}`
);
try {
signal?.throwIfAborted();
await fs.promises.writeFile(
path.join(appiumHome, 'package.json'),
`${JSON.stringify({ name: 'eas-appium-home', private: true })}\n`
);
const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome };
const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium');
const add = resolvePackageAdd(
resolveConfiguredPackageManager(env, PackageManager.NPM),
`appium@${versionSpec}`
);
logger.info(`Installing appium@${versionSpec} with ${add.command}.`);
await spawn(add.command, add.args, {
cwd: appiumHome,
env: appiumEnv,
logger,
});
const { stdout } = await spawn(appiumBinPath, ['driver', 'list', '--installed', '--json'], {
env: appiumEnv,
stdio: 'pipe',
});
const installedDrivers = AppiumInstalledDriversSchema.parse(JSON.parse(stdout));
if (installedDrivers[driverName]?.installed) {
logger.info(`Updating the installed Appium ${driverName} driver.`);
await spawn(appiumBinPath, ['driver', 'update', driverName], { env: appiumEnv, logger });
} else {
logger.info(`Installing the Appium ${driverName} driver.`);
await spawn(appiumBinPath, ['driver', 'install', driverName], { env: appiumEnv, logger });
logger.info(`Installing appium@${versionSpec} with ${add.command}.`);
await runAppiumSetupCommandAsync(
add.command,
add.args,
{
cwd: appiumHome,
env: appiumEnv,
logger,
},
signal
);
const { stdout } = await runAppiumSetupCommandAsync(
appiumBinPath,
['driver', 'list', '--installed', '--json'],
{
env: appiumEnv,
stdio: 'pipe',
},
signal
);
const installedDrivers = AppiumInstalledDriversSchema.parse(JSON.parse(stdout));
if (installedDrivers[driverName]?.installed) {
logger.info(`Updating the installed Appium ${driverName} driver.`);
await runAppiumSetupCommandAsync(
appiumBinPath,
['driver', 'update', driverName],
{ env: appiumEnv, logger },
signal
);
} else {
logger.info(`Installing the Appium ${driverName} driver.`);
await runAppiumSetupCommandAsync(
appiumBinPath,
['driver', 'install', driverName],
{ env: appiumEnv, logger },
signal
);
}
return { appiumHome, appiumBinPath, appiumEnv, prepared: false };
} catch (error) {
onError?.(error);
if (signal) {
await fs.promises.rm(appiumHome, { recursive: true, force: true });
signal.throwIfAborted();
}
throw error;
}
}
/** A metadata hit still needs Appium-specific version, driver and runner checks before reuse. */
async function isPreparedAppiumCompatibleAsync(
prepared: z.infer<typeof PreparedAppiumSchema>,
{ versionSpec, env, signal }: { versionSpec: string; env: BuildStepEnv; signal?: AbortSignal }
): Promise<boolean> {
const { home } = prepared;
if (home !== path.join(os.homedir(), 'Library', 'Caches', 'expo-simulator-startup', 'appium')) {
return false;
}
try {
const installedVersionAsync = async (name: string): Promise<string> =>
PackageVersionSchema.parse(
JSON.parse(
await fs.promises.readFile(path.join(home, 'node_modules', name, 'package.json'), 'utf8')
)
).version;
if (
!semver.satisfies(prepared.version, versionSpec) ||
(await installedVersionAsync('appium')) !== prepared.version ||
(await installedVersionAsync('appium-xcuitest-driver')) !== prepared.xcuitest
) {
return false;
}
const runner = path.join(home, 'preinstalled-wda', 'WebDriverAgentRunner-Runner.app');
await fs.promises.access(runner, fs.constants.R_OK | fs.constants.X_OK);
if (!(await fs.promises.stat(runner)).isDirectory()) {
return false;
}
const metadata = await fs.promises.readFile(path.join(runner, 'Info.plist'));
const info =
metadata.subarray(0, 8).toString('ascii') === 'bplist00'
? bplistParser.parseBuffer(metadata)[0]
: plist.parse(metadata.toString('utf8'));
const executable = z
.object({ CFBundleExecutable: z.string().min(1) })
.parse(info).CFBundleExecutable;
if (path.basename(executable) !== executable || executable === '.' || executable === '..') {
return false;
}
const executablePath = path.join(runner, executable);
await fs.promises.access(executablePath, fs.constants.R_OK | fs.constants.X_OK);
const executableStat = await fs.promises.stat(executablePath);
if (!executableStat.isFile() || executableStat.size === 0) {
return false;
}
// Today's driver list shows that the Appium CLI runs from this home. It does not load the driver.
const { stdout } = await runAppiumSetupCommandAsync(
path.join(home, 'node_modules', '.bin', 'appium'),
['driver', 'list', '--installed', '--json'],
{ env: { ...env, APPIUM_HOME: home }, stdio: 'pipe' },
signal
);
if (!AppiumInstalledDriversSchema.parse(JSON.parse(stdout)).xcuitest?.installed) {
return false;
}
return true;
} catch {
return false;
}
}
async function runAppiumSetupCommandAsync(
command: string,
args: string[],
options: SpawnOptions,
signal?: AbortSignal
): Promise<SpawnResult> {
if (!signal) {
return await spawn(command, args, options);
}
signal.throwIfAborted();
const task = spawn(command, args, { ...options, detached: true });
const abort = (): void => killProcessGroup(task.child, 'SIGKILL');
signal.addEventListener('abort', abort, { once: true });
if (signal.aborted) {
abort();
}
try {
const result = await task;
signal.throwIfAborted();
return result;
} finally {
signal.removeEventListener('abort', abort);
}
return { appiumHome, appiumBinPath, appiumEnv };
}
async function waitForAppiumReadyAsync({
appiumProcess,
logger,
signal,
}: {
appiumProcess: { getOutput: () => string };
logger: bunyan;
signal: AbortSignal;
}): Promise<void> {
const deadline = Date.now() + APPIUM_STARTUP_TIMEOUT_MS;
while (Date.now() < deadline) {
signal.throwIfAborted();
try {
const response = await turtleFetch(`http://${APPIUM_HOST}:${APPIUM_PORT}/status`, 'GET', {
timeout: 2_000,
retries: 0,
logger,
signal,
});
signal.throwIfAborted();
if (response.ok) {
return;
}
} catch {}
await sleepAsync(1_000);
signal.throwIfAborted();
try {
await setTimeoutAsync(1_000, undefined, { signal });
} catch (error) {
signal.throwIfAborted();
throw error;
}
}
const output = appiumProcess.getOutput();
throw new SystemError(
@@ -1,16 +1,17 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { type Result, asyncResult } from '@expo/results';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import spawn, { type SpawnOptions } from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import semver from 'semver';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
@@ -19,6 +20,13 @@ import {
withLocalEgressSession,
} from '../utils/localEgressSession';
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import {
createWarmDeviceSessionServer,
runOnWarmIosSimulatorAsync,
} from '../utils/warmDeviceSessionHost';
import { type WarmIosSession } from '../utils/warmIosSession';
import { validateControllerVersion } from '../utils/controllerVersion';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
import {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
@@ -29,11 +37,17 @@ import {
resolveConfiguredPackageManager,
resolvePackageExec,
} from '../../utils/packageManager';
import { isProcessDescendantOfAsync } from '../../utils/processes';
import { isProcessDescendantOfAsync, killProcessGroup } from '../../utils/processes';
import { sleepAsync } from '../../utils/retry';
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents';
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
import {
DeviceRunSessionStoppedError,
TOOL_TUNNEL_TEARDOWN_OPTIONS,
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createIosSessionStartupInputProviders,
createPrivatePreviewUrlInputProvider,
createPublicPreviewInputProvider,
createServeSimLaunchInputProviders,
@@ -44,7 +58,8 @@ import {
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
omitNgrokAuthtoken,
parseServeSimLaunchInputs,
parseCorsOriginsInput,
parseFrameAncestorsInput,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startNgrokTunnelAsync,
@@ -77,18 +92,22 @@ const ArgentToolServerStateSchema = z.object({
type ArgentToolServerState = z.infer<typeof ArgentToolServerStateSchema>;
export function createStartArgentRemoteSessionBuildFunction(
ctx: CustomBuildContext
ctx: CustomBuildContext,
{ warm = false }: { warm?: boolean } = {}
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_argent_remote_session',
id: warm ? 'start_warm_argent_remote_session' : 'start_argent_remote_session',
name: 'Start argent remote session',
__metricsId: 'eas/start_argent_remote_session',
__metricsId: warm ? 'eas/start_warm_argent_remote_session' : 'eas/start_argent_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
...createIosSessionStartupInputProviders(),
...createNetworkCaptureInputProviders(),
createPublicPreviewInputProvider(),
createPrivatePreviewUrlInputProvider(),
createFrameAncestorsInputProvider(),
createCorsOriginsInputProvider(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -119,263 +138,396 @@ export function createStartArgentRemoteSessionBuildFunction(
// A missing or non-positive value disables the idle timeout (opt-in feature).
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
const versionSpec = packageVersion ?? 'latest';
const { runtimePlatform } = global;
const launch = parseServeSimLaunchInputs(
{
launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value as string | undefined,
},
{ runtimePlatform }
);
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
{ runtimePlatform }
);
logger.info(
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
// Start the potentially slow installation while Argent is being prepared.
// On Linux expo-device-hub calls this again and awaits the same in-flight
// setup before launching. On macOS this remains non-blocking, so only a
// recording started in the first moments may miss ffmpeg.
// Never rejects, so `void` is safe.
void ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
const argentExec = (args: string[]): { command: string; args: string[] } =>
resolvePackageExec(packageManager, args);
logger.info('Enabling the Argent artifacts list endpoint flag.');
const enableArtifacts = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
]);
await spawn(enableArtifacts.command, enableArtifacts.args, { env, logger });
logger.info('Enabling the Argent tool-server event log flag.');
const enableEventLog = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_EVENT_LOG_FLAG,
]);
await spawn(enableEventLog.command, enableEventLog.args, { env, logger });
logger.info('Disabling the Argent video watermark flag.');
const disableVideoWatermark = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'disable',
ARGENT_VIDEO_WATERMARK_FLAG,
]);
// The watermark is cosmetic, so a failure here must not fail the session.
try {
await spawn(disableVideoWatermark.command, disableVideoWatermark.args, { env, logger });
} catch (err) {
logger.warn(
{ err },
'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.'
);
}
const startServer = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'server',
'start',
'--port',
'0',
'--idle-timeout',
'0',
'--force',
]);
logger.info(
`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.`
);
// Keep Argent itself in foreground mode under the detached process. This preserves
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
const secrets: string[] = [];
const argentServer = spawnDetached({
command: startServer.command,
args: startServer.args,
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH, ARGENT_EMULATOR_NO_WINDOW: '1' },
const startupStop = new AbortController();
const watchSignal = signal
? AbortSignal.any([signal, startupStop.signal])
: startupStop.signal;
const startupWatch = watchDeviceRunSessionStartup({
ctx,
deviceRunSessionId,
logger,
secrets,
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
});
if (argentServer.pid === undefined) {
throw new SystemError(
'Failed to start Argent: could not determine the PID of the launched process.'
);
}
const artifactPollAbortController = new AbortController();
let artifactPollingPromise: Promise<void> | undefined;
let eventCollection: Awaited<ReturnType<typeof startArgentEventCollectionAsync>> | undefined;
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let sessionFailed = false;
try {
logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`);
let toolServerPort: number;
let toolServerToken: string | undefined;
try {
const toolServerState = await waitForArgentToolServerStateAsync({
stateDir: ARGENT_STATE_DIR,
ancestorPid: argentServer.pid,
timeoutMs: STARTUP_TIMEOUT_MS,
getExitError: argentServer.getExitError,
});
toolServerPort = toolServerState.port;
toolServerToken = toolServerState.token;
if (toolServerToken) {
secrets.push(toolServerToken);
}
} catch (err) {
const output = argentServer.getOutput();
throw new SystemError(
`${
err instanceof Error ? err.message : `Timed out waiting for argent tool-server state.`
}${output ? `\nArgent tool-server output:\n${output}` : ''}`
);
}
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
const artifactPollSignal = signal
? AbortSignal.any([signal, artifactPollAbortController.signal])
: artifactPollAbortController.signal;
artifactPollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
toolsUrl: `http://127.0.0.1:${toolServerPort}`,
toolsAuthToken: toolServerToken,
logger,
signal: artifactPollSignal,
});
eventCollection = await startArgentEventCollectionAsync({
ctx,
deviceRunSessionId,
eventLogPath: ARGENT_EVENT_LOG_PATH,
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
const startSessionAsync = async (
warmSession?: WarmIosSession,
resolvedPackageVersion = packageVersion
): Promise<void> => {
const versionSpec = resolvedPackageVersion ?? 'latest';
validateControllerVersion({
packageName: ARGENT_PACKAGE_NAME,
minimumVersion: MIN_ARGENT_REMOTE_SESSION_VERSION,
packageVersion: resolvedPackageVersion,
logger,
});
toolsTunnel = await startNgrokTunnelAsync({
port: toolServerPort,
subdomainPrefix: 'argent',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
rewriteHostHeader: true,
logger,
});
const publicToolsUrl = toolsTunnel.url;
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
sessionHost = await startDeviceSessionHostAsync(ctx, {
separateLogPhase: true,
const startupAbortController = new AbortController();
const startupSignal = AbortSignal.any([watchSignal, startupAbortController.signal]);
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
deviceIdentifier: warmSession?.device.udid ?? deviceIdentifier,
installAppPath: inputs.install_app_path?.value as string | undefined,
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
networkCapture,
networkCaptureFields,
});
const webPreview = await sessionHost.openPreviewAsync({
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
signal: startupSignal,
});
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
{ runtimePlatform }
);
logger.info(
webPreview.sharePageUrl
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
deviceRunSessionId,
remoteConfig: {
toolsUrl: publicToolsUrl,
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
});
// Start the potentially slow installation while Argent is being prepared.
// On Linux expo-device-hub calls this again and awaits the same in-flight
// setup before launching. On macOS this remains non-blocking, so only a
// recording started in the first moments may miss ffmpeg.
// Never rejects, so `void` is safe.
void ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
maxDurationSeconds,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
}
: undefined,
});
} catch (error) {
sessionFailed = true;
throw error;
} finally {
await finishRemoteSessionAsync({
logger,
sessionFailed,
teardown: [
['Argent tunnel', toolsTunnel?.stopAsync()],
[
'Argent tool-server',
(async () => {
try {
if (eventCollection) {
await stopArgentEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
const argentExec = (args: string[]): { command: string; args: string[] } =>
resolvePackageExec(packageManager, args);
const artifactPollAbortController = new AbortController();
let artifactPollingPromise: Promise<void> | undefined;
let eventCollection:
| Awaited<ReturnType<typeof startArgentEventCollectionAsync>>
| undefined;
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let argentServer: ReturnType<typeof spawnDetached> | undefined;
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let sessionFailed = false;
const startHost = () =>
startDeviceSessionHostAsync(
ctx,
{
...iosStartup,
separateLogPhase: true,
runtimePlatform,
env,
logger,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
timeoutMs: STARTUP_TIMEOUT_MS,
preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken },
signal: startupSignal,
onStartupError: error => startupAbortController.abort(error),
...launch,
networkCapture,
networkCaptureFields,
},
...(warmSession ? [createWarmDeviceSessionServer(ctx, logger, warmSession)] : [])
).catch(error => {
startupAbortController.abort(error);
throw error;
});
try {
if (iosStartup) {
hostStartup = asyncResult(startHost());
}
let toolServerPort: number;
let toolServerToken: string | undefined;
if (warmSession) {
// Adoption prepares the launcher's Argent server for the claimed device.
sessionHost = (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
const tools = warmSession.tools;
if (!tools) {
throw new Error('The warm Argent server was not prepared.');
}
toolServerPort = tools.port;
toolServerToken = tools.token;
} else {
logger.info('Enabling the Argent artifacts list endpoint flag.');
const enableArtifacts = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
]);
await runArgentSetupCommandAsync(
enableArtifacts.command,
enableArtifacts.args,
{ env, logger },
startupSignal
);
logger.info('Enabling the Argent tool-server event log flag.');
const enableEventLog = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_EVENT_LOG_FLAG,
]);
await runArgentSetupCommandAsync(
enableEventLog.command,
enableEventLog.args,
{ env, logger },
startupSignal
);
logger.info('Disabling the Argent video watermark flag.');
const disableVideoWatermark = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'disable',
ARGENT_VIDEO_WATERMARK_FLAG,
]);
// The watermark is cosmetic, so a failure here must not fail the session.
try {
await runArgentSetupCommandAsync(
disableVideoWatermark.command,
disableVideoWatermark.args,
{ env, logger },
startupSignal
);
} catch (err) {
startupSignal?.throwIfAborted();
logger.warn(
{ err },
'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.'
);
}
startupSignal?.throwIfAborted();
const startServer = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'server',
'start',
'--port',
'0',
'--idle-timeout',
'0',
'--force',
]);
logger.info(
`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.`
);
// Keep Argent itself in foreground mode under the detached process. This preserves
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
const secrets: string[] = [];
argentServer = spawnDetached({
command: startServer.command,
args: startServer.args,
env: {
...env,
ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH,
ARGENT_EMULATOR_NO_WINDOW: '1',
},
logger,
secrets,
});
if (argentServer.pid === undefined) {
throw new SystemError(
'Failed to start Argent: could not determine the PID of the launched process.'
);
}
logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`);
try {
const toolServerState = await waitForArgentToolServerStateAsync({
stateDir: ARGENT_STATE_DIR,
ancestorPid: argentServer.pid,
timeoutMs: STARTUP_TIMEOUT_MS,
getExitError: argentServer.getExitError,
signal: startupSignal,
});
toolServerPort = toolServerState.port;
toolServerToken = toolServerState.token;
if (toolServerToken) {
secrets.push(toolServerToken);
}
} catch (err) {
const output = argentServer.getOutput();
throw new SystemError(
`${
err instanceof Error
? err.message
: `Timed out waiting for argent tool-server state.`
}${output ? `\nArgent tool-server output:\n${output}` : ''}`
);
}
if (hostStartup) {
sessionHost = (await hostStartup).enforceValue();
}
startupSignal?.throwIfAborted();
}
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
const artifactPollSignal = signal
? AbortSignal.any([signal, artifactPollAbortController.signal])
: artifactPollAbortController.signal;
artifactPollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
toolsUrl: `http://127.0.0.1:${toolServerPort}`,
toolsAuthToken: toolServerToken,
logger,
signal: artifactPollSignal,
});
eventCollection = await startArgentEventCollectionAsync({
ctx,
deviceRunSessionId,
eventLogPath: warmSession?.argentEventLogPath ?? ARGENT_EVENT_LOG_PATH,
logger,
});
toolsTunnel = await startNgrokTunnelAsync({
port: toolServerPort,
subdomainPrefix: 'argent',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
rewriteHostHeader: true,
healthCheck: { path: '/tools', statuses: [200, 401] },
logger,
signal: startupSignal,
});
const publicToolsUrl = toolsTunnel.url;
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
startupSignal.throwIfAborted();
const webPreview = await sessionHost.openPreviewAsync();
logger.info(
webPreview.sharePageUrl
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal: startupSignal,
ctx,
deviceRunSessionId,
remoteConfig: {
toolsUrl: publicToolsUrl,
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
await startupWatch.completeAsync();
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
maxDurationSeconds,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
}
artifactPollAbortController.abort();
try {
await artifactPollingPromise;
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture(
'Could not finish Argent remote session artifact polling',
error
);
logger.warn(
{ err: error },
'Could not finish Argent remote session artifact polling.'
);
: undefined,
});
} catch (error) {
if (
startupSignal.reason instanceof DeviceRunSessionStoppedError &&
!(error instanceof AggregateError)
) {
throw startupSignal.reason;
}
sessionFailed = true;
throw error instanceof AggregateError
? error
: startupSignal.aborted &&
!(startupSignal.reason instanceof DeviceRunSessionStoppedError)
? startupSignal.reason
: error;
} finally {
startupAbortController.abort(new Error('Argent session ended.'));
const toolTeardown = (async () => {
try {
if (eventCollection) {
await stopArgentEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
}
artifactPollAbortController.abort();
try {
await artifactPollingPromise;
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not finish Argent remote session artifact polling', error);
logger.warn(
{ err: error },
'Could not finish Argent remote session artifact polling.'
);
}
} finally {
await argentServer?.stopAsync();
}
})();
await finishRemoteSessionAsync({
logger,
sessionFailed,
teardown: [
['Argent tunnel', toolsTunnel?.stopAsync(), TOOL_TUNNEL_TEARDOWN_OPTIONS],
['Argent tool-server', toolTeardown],
[
'session host',
hostStartup?.then(async result => {
if (warmSession) {
// The warm host owns Argent too; leave it alive until the final downloads finish.
await Promise.allSettled([toolTeardown]);
}
} finally {
await argentServer.stopAsync();
}
})(),
if (result.reason instanceof AggregateError) {
throw result.reason;
}
await result.value?.finishAsync();
}),
],
],
['session host', sessionHost?.finishAsync()],
],
});
});
}
};
try {
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
watchSignal.throwIfAborted();
if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) {
await runOnWarmIosSimulatorAsync(
{
engine: 'argent',
requestedDevice: deviceIdentifier,
packageVersion,
env,
logger,
signal: watchSignal,
},
startSessionAsync
);
} else {
await startSessionAsync();
}
} catch (error) {
if (error !== startupWatch.stopped) {
throw error;
}
} finally {
await startupWatch.finishAsync();
}
}),
});
@@ -403,33 +555,28 @@ export async function stopArgentEventCollectionSafelyAsync({
}
}
export function warnIfArgentPackageVersionCannotBeVerified({
packageVersion,
logger,
}: {
packageVersion: string | undefined;
logger: bunyan;
}): void {
if (!packageVersion || packageVersion === 'latest') {
async function runArgentSetupCommandAsync(
command: string,
args: string[],
options: SpawnOptions,
signal?: AbortSignal
): Promise<void> {
if (!signal) {
await spawn(command, args, options);
return;
}
const validVersion = semver.valid(packageVersion);
if (!validVersion) {
logger.warn(
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer, ` +
`but package_version "${packageVersion}" is not an exact semver version that EAS can verify. ` +
`Continuing and letting the package manager resolve it.`
);
return;
signal.throwIfAborted();
const task = spawn(command, args, { ...options, detached: true });
const abort = (): void => killProcessGroup(task.child, 'SIGKILL');
signal.addEventListener('abort', abort, { once: true });
if (signal.aborted) {
abort();
}
if (semver.lt(validVersion, MIN_ARGENT_REMOTE_SESSION_VERSION)) {
throw new SystemError(
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer. ` +
`The requested package_version "${packageVersion}" is too old for the EAS remote-session API. ` +
`Use "latest" or pass an exact version >= ${MIN_ARGENT_REMOTE_SESSION_VERSION}.`
);
try {
await task;
signal.throwIfAborted();
} finally {
signal.removeEventListener('abort', abort);
}
}
@@ -455,13 +602,16 @@ export async function waitForArgentToolServerStateAsync({
timeoutMs,
pollIntervalMs = 1_000,
getExitError,
signal,
}: {
stateDir: string;
ancestorPid: number;
timeoutMs: number;
pollIntervalMs?: number;
getExitError?: () => Error | undefined;
signal?: AbortSignal;
}): Promise<ArgentToolServerState> {
signal?.throwIfAborted();
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
const throwIfExited = (): void => {
@@ -472,6 +622,7 @@ export async function waitForArgentToolServerStateAsync({
};
while (Date.now() < deadline) {
signal?.throwIfAborted();
throwIfExited();
let matchingState: ArgentToolServerState | undefined;
try {
@@ -497,13 +648,19 @@ export async function waitForArgentToolServerStateAsync({
lastError = err;
}
// Keep process failures outside the retryable state-file read errors.
signal?.throwIfAborted();
throwIfExited();
if (matchingState) {
return matchingState;
}
await sleepAsync(pollIntervalMs);
if (signal) {
await setTimeoutAsync(pollIntervalMs, undefined, { signal });
} else {
await sleepAsync(pollIntervalMs);
}
}
signal?.throwIfAborted();
throw new SystemError(
`Timed out waiting for an argent tool-server state file belonging to process ${ancestorPid} in ${stateDir}${
lastError instanceof Error ? `: ${lastError.message}` : ''
@@ -170,6 +170,7 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
logger,
signal: startupSignal,
}),
startupSignal,
async listener => await listener?.stopAsync(),
@@ -1,21 +1,11 @@
import { SystemError } from '@expo/eas-build-job';
import { ArchiveSourceType, SystemError, UserError } from '@expo/eas-build-job';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { graphql } from 'gql.tada';
import { stripRepositoryCredentialsAsync } from '../../common/git';
import { CustomBuildContext } from '../../customBuildContext';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import { withLogPhaseAsync } from '../../utils/logPhase';
import { startSandboxDaemonAsync } from '../utils/sandboxDaemon';
const MARK_SANDBOX_READY_MUTATION = graphql(`
mutation MarkSandboxReady($sandboxId: ID!) {
sandbox {
markSandboxReady(sandboxId: $sandboxId) {
id
}
}
}
`);
import { requireSandboxWorkerApi } from '../utils/sandboxWorkerApi';
const RECONNECT_DELAY_MS = 1_000;
const AWAIT_SANDBOX_STEP_NAME = 'Await sandbox completion';
@@ -32,38 +22,78 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'max_duration_seconds',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: async (stepCtx, { inputs, signal, env }) => {
// The daemon needs this credential, but shell commands do not.
const { __EAS_SANDBOX_MCP_TOKEN: sandboxToken, ...commandEnv } = env;
if (!sandboxToken) {
throw new SystemError('__EAS_SANDBOX_MCP_TOKEN is required to start the sandbox daemon.');
}
const workerApi = requireSandboxWorkerApi(ctx);
const mcpServerUrl = ctx.mcpServerUrl;
if (!mcpServerUrl) {
throw new SystemError('MCP server URL is required to start the sandbox daemon.');
}
const sandboxId = String(inputs.sandbox_id.value);
const maxDurationSeconds = inputs.max_duration_seconds?.value;
if (
maxDurationSeconds !== undefined &&
(typeof maxDurationSeconds !== 'number' ||
!Number.isFinite(maxDurationSeconds) ||
maxDurationSeconds <= 0)
) {
throw new UserError(
'EAS_SANDBOX_INVALID_DURATION',
`Invalid sandbox maximum duration: ${String(maxDurationSeconds)}. Provide a finite number greater than zero, or omit max_duration_seconds.`
);
}
if (ctx.job.projectArchive.type === ArchiveSourceType.GIT) {
// Scripts and commands inside the sandbox could read the token from .git/config.
await stripRepositoryCredentialsAsync({
repositoryDirectory: stepCtx.global.wasCheckedOut()
? ctx.projectTargetDirectory
: ctx.projectSourceDirectory,
});
}
const daemon = await startSandboxDaemonAsync({
credential: sandboxToken,
credential: workerApi.token,
serverUrl: mcpServerUrl,
reconnectDelayMs: RECONNECT_DELAY_MS,
logger: stepCtx.logger,
signal,
workingDirectory: stepCtx.workingDirectory,
env: commandEnv,
graphqlClient: ctx.graphqlClient,
sandboxId,
env,
workerApi,
});
try {
await daemon.ready;
signal?.throwIfAborted();
await markSandboxReadyAsync(ctx, sandboxId, signal);
await workerApi.markReadyAsync({ signal }).catch((error: unknown) => {
signal?.throwIfAborted();
throw new SystemError(
`Failed to mark sandbox ${String(inputs.sandbox_id.value)} as ready.`,
{ cause: error }
);
});
signal?.throwIfAborted();
stepCtx.logger.info('Sandbox daemon started.');
await withLogPhaseAsync(stepCtx.logger, AWAIT_SANDBOX_STEP_NAME, async () => {
await waitForCancellationAsync(signal);
const timeoutSignal =
maxDurationSeconds === undefined
? undefined
: AbortSignal.timeout(Math.ceil(maxDurationSeconds * 1000));
await waitForCompletionAsync(
AbortSignal.any([
...(signal ? [signal] : []),
...(timeoutSignal ? [timeoutSignal] : []),
])
);
if (timeoutSignal?.aborted) {
stepCtx.logger.info(
`Sandbox reached its maximum runtime of ${maxDurationSeconds} seconds. Stopping.`
);
}
});
} finally {
await daemon.stopAsync();
@@ -72,28 +102,11 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
});
}
export async function markSandboxReadyAsync(
ctx: CustomBuildContext,
sandboxId: string,
signal?: AbortSignal
): Promise<void> {
signal?.throwIfAborted();
const result = await ctx.graphqlClient
.mutation(MARK_SANDBOX_READY_MUTATION, { sandboxId }, graphqlAbortContext(signal))
.toPromise();
signal?.throwIfAborted();
if (result.error) {
throw new SystemError(`Failed to mark sandbox ${sandboxId} as ready.`, {
cause: result.error,
});
}
}
async function waitForCancellationAsync(signal?: AbortSignal): Promise<void> {
if (signal?.aborted) {
async function waitForCompletionAsync(signal: AbortSignal): Promise<void> {
if (signal.aborted) {
return;
}
await new Promise<void>(resolve => {
signal?.addEventListener('abort', () => resolve(), { once: true });
signal.addEventListener('abort', () => resolve(), { once: true });
});
}
@@ -6,7 +6,13 @@ import {
} from '@expo/steps';
import { CustomBuildContext } from '../../customBuildContext';
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
import { startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import {
createWarmDeviceSessionServer,
runOnWarmIosSimulatorAsync,
} from '../utils/warmDeviceSessionHost';
import { type WarmIosSession } from '../utils/warmIosSession';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
import {
createNetworkCaptureInputProviders,
@@ -17,6 +23,8 @@ import {
withLocalEgressSession,
} from '../utils/localEgressSession';
import {
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createIosSessionStartupInputProviders,
createPrivatePreviewUrlInputProvider,
createPublicPreviewInputProvider,
@@ -26,6 +34,8 @@ import {
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
omitNgrokAuthtoken,
parseCorsOriginsInput,
parseFrameAncestorsInput,
selectXcodeDeveloperDirectoryAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
@@ -33,24 +43,34 @@ import {
const STARTUP_TIMEOUT_MS = 60_000;
export function createStartWebPreviewRemoteSessionBuildFunction(
ctx: CustomBuildContext
ctx: CustomBuildContext,
{ warm = false }: { warm?: boolean } = {}
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_serve_sim_remote_session',
id: warm ? 'start_warm_serve_sim_remote_session' : 'start_serve_sim_remote_session',
name: 'Start web preview remote session',
__metricsId: 'eas/start_serve_sim_remote_session',
__metricsId: warm
? 'eas/start_warm_serve_sim_remote_session'
: 'eas/start_serve_sim_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
...createIosSessionStartupInputProviders(),
...createNetworkCaptureInputProviders(),
createPublicPreviewInputProvider(),
createPrivatePreviewUrlInputProvider(),
createFrameAncestorsInputProvider(),
createCorsOriginsInputProvider(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'max_idle_time_minutes',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'max_duration_seconds',
required: false,
@@ -62,93 +82,163 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(stepEnv);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(stepEnv);
const env = omitNgrokAuthtoken(stepEnv);
const maxIdleTimeMinutes = inputs.max_idle_time_minutes?.value as number | undefined;
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
const packageVersion = inputs.package_version?.value as string | undefined;
const { runtimePlatform } = global;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
installAppPath: inputs.install_app_path?.value as string | undefined,
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
env,
const startupStop = new AbortController();
const watchSignal = signal
? AbortSignal.any([signal, startupStop.signal])
: startupStop.signal;
const startupWatch = watchDeviceRunSessionStartup({
ctx,
deviceRunSessionId,
logger,
signal,
startup: { signal: watchSignal, abort: reason => startupStop.abort(reason) },
});
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
{ runtimePlatform }
);
logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`);
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
const sessionHost = await startDeviceSessionHostAsync(ctx, {
...iosStartup,
runtimePlatform,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
signal,
packageVersion,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
networkCapture,
networkCaptureFields,
});
try {
const webPreview = await sessionHost.openPreviewAsync({
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
});
logger.info(
webPreview.sharePageUrl
? `Preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
await uploadRemoteSessionConfigWithLocalEgressAsync({
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
const startSessionAsync = async (
warmSession?: WarmIosSession,
resolvedPackageVersion = packageVersion
): Promise<void> => {
const startupAbortController = new AbortController();
const startupSignal = AbortSignal.any([watchSignal, startupAbortController.signal]);
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
deviceIdentifier: warmSession?.device.udid ?? deviceIdentifier,
installAppPath: inputs.install_app_path?.value as string | undefined,
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
env,
signal,
ctx,
deviceRunSessionId,
// WEB_PREVIEW_ONLY sessions read webPreviewUrl and webPreviewToken. Legacy SERVE_SIM
// sessions read previewUrl and previewToken. The server drops the keys that the
// session type does not use.
remoteConfig: {
webPreviewUrl: webPreview.previewPageUrl,
previewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken
? { webPreviewToken: webPreview.previewToken, previewToken: webPreview.previewToken }
: {}),
},
logger,
signal: startupSignal,
});
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
{ runtimePlatform }
);
logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`);
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
const sessionHost = await startDeviceSessionHostAsync(
ctx,
{
...iosStartup,
runtimePlatform,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken },
signal: startupSignal,
onStartupError: error => startupAbortController.abort(error),
packageVersion: resolvedPackageVersion,
emulatorCapture: true,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
networkCapture,
networkCaptureFields,
},
...(warmSession ? [createWarmDeviceSessionServer(ctx, logger, warmSession)] : [])
).catch(error => {
if (startupSignal.reason === startupWatch.stopped && !(error instanceof AggregateError)) {
throw startupWatch.stopped;
}
throw error;
});
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
maxDurationSeconds,
signal,
});
try {
startupSignal.throwIfAborted();
const webPreview = await sessionHost.openPreviewAsync();
logger.info(
webPreview.sharePageUrl
? `Preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal: startupSignal,
ctx,
deviceRunSessionId,
// WEB_PREVIEW_ONLY sessions read webPreviewUrl and webPreviewToken. Legacy SERVE_SIM
// sessions read previewUrl and previewToken. The server drops the keys that the
// session type does not use.
remoteConfig: {
webPreviewUrl: webPreview.previewPageUrl,
previewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken
? {
webPreviewToken: webPreview.previewToken,
previewToken: webPreview.previewToken,
}
: {}),
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
await startupWatch.completeAsync();
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
maxDurationSeconds,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: () => undefined,
getLastPreviewActivityAt: () => sessionHost.getLastPreviewActivityAt(),
}
: undefined,
});
} finally {
await sessionHost.finishAsync();
}
};
try {
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
watchSignal.throwIfAborted();
if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) {
await runOnWarmIosSimulatorAsync(
{
engine: 'serve-sim',
requestedDevice: deviceIdentifier,
packageVersion,
env,
logger,
signal: watchSignal,
},
startSessionAsync
);
} else {
await startSessionAsync();
}
} catch (error) {
if (error !== startupWatch.stopped) {
throw error;
}
} finally {
await sessionHost.finishAsync();
await startupWatch.finishAsync();
}
}),
});
@@ -131,7 +131,7 @@ export function createSubmitToGooglePlayBuildFunction(): BuildFunction {
keyPath: path.resolve(ctx.workingDirectory, submission.service_account_key_path),
});
const artifactPath = path.resolve(ctx.workingDirectory, submission.artifact_path);
const client = new GooglePlayClient(credentials);
const client = new GooglePlayClient(credentials, ctx.logger);
const versionCode = await submitToGooglePlayAsync({
artifactType: submission.artifact_type,
client,
@@ -0,0 +1,224 @@
import { BuildJob, Platform } from '@expo/eas-build-job';
import {
BuildFunction,
BuildStepInput,
BuildStepInputValueTypeName,
spawnAsync,
} from '@expo/steps';
import { XMLParser } from 'fast-xml-parser';
import fs from 'fs-extra';
import { Client } from '@urql/core';
import { graphql } from 'gql.tada';
import path from 'node:path';
import { parseInfoPlistBuffer, readIpaInfoAsync } from './readIpaInfo';
import { CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
export function createUpdateResolvedVersionBuildFunction(
ctx: CustomBuildContext<BuildJob>
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'update_resolved_version',
name: 'Update resolved version',
__metricsId: 'eas/update_resolved_version',
inputProviders: [
BuildStepInput.createProvider({
id: 'application_archive_path',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async (stepCtx, { inputs }) => {
try {
const archivePathInput = inputs.application_archive_path.value as string | undefined;
if (!archivePathInput) {
stepCtx.logger.info('No application archive path provided, skipping.');
return;
}
const archivePath = path.resolve(stepCtx.workingDirectory, archivePathInput);
const buildId = ctx.env.EAS_BUILD_ID;
if (!buildId) {
stepCtx.logger.info('EAS_BUILD_ID not set, skipping.');
return;
}
const { appVersion, appBuildVersion } =
ctx.job.platform === Platform.IOS
? await extractIosVersionAsync(archivePath)
: await extractAndroidVersionAsync(archivePath);
if (!appVersion && !appBuildVersion) {
stepCtx.logger.info('No resolved version found, skipping.');
return;
}
stepCtx.logger.info(
`Resolved version: ${appVersion ?? 'N/A'} (${appBuildVersion ?? 'N/A'})`
);
await updateResolvedVersionAsync(ctx.graphqlClient, buildId, {
appVersion,
appBuildVersion,
});
stepCtx.logger.info('Updated resolved version on EAS.');
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Failed to update resolved version', error);
stepCtx.logger.warn({ err: error }, 'Failed to update resolved version (non-fatal)');
}
},
});
}
export async function extractIosVersionAsync(
archivePath: string
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
const ext = path.extname(archivePath).toLowerCase();
if (ext === '.app') {
return await extractSimulatorAppVersionAsync(archivePath);
}
const ipaInfo = await readIpaInfoAsync(archivePath);
return {
appVersion: ipaInfo.bundleShortVersion,
appBuildVersion: ipaInfo.bundleVersion,
};
}
async function extractSimulatorAppVersionAsync(
appPath: string
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
const infoPlistPath = path.join(appPath, 'Info.plist');
if (!(await fs.pathExists(infoPlistPath))) {
return {};
}
const infoPlistBuffer = await fs.readFile(infoPlistPath);
const infoPlist = parseInfoPlistBuffer(infoPlistBuffer);
return {
appVersion:
typeof infoPlist.CFBundleShortVersionString === 'string'
? infoPlist.CFBundleShortVersionString
: undefined,
appBuildVersion:
typeof infoPlist.CFBundleVersion === 'string' ? infoPlist.CFBundleVersion : undefined,
};
}
export async function extractAndroidVersionAsync(
archivePath: string
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
const ext = path.extname(archivePath).toLowerCase();
if (ext === '.apk') {
return await extractVersionFromApkAsync(archivePath);
} else if (ext === '.aab') {
return await extractVersionFromAabAsync(archivePath);
}
return {};
}
async function extractVersionFromApkAsync(
apkPath: string
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
const result = await spawnAsync('aapt2', ['dump', 'badging', apkPath], {
stdio: 'pipe',
});
return parseAaptOutput(result.stdout);
}
async function extractVersionFromAabAsync(
aabPath: string
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
const result = await spawnAsync('bundletool', ['dump', 'manifest', '--bundle', aabPath], {
stdio: 'pipe',
});
return parseManifestXml(result.stdout);
}
export function parseAaptOutput(output: string): { appVersion?: string; appBuildVersion?: string } {
// aapt2 does not escape apostrophes inside versionName, so stop at the next known
// badging field (or the end of the line) rather than at the first closing quote.
const versionNameMatch = output.match(
/versionName='(.*?)'(?= split=| platformBuildVersionName=| platformBuildVersionCode=| compileSdkVersion=|\s*$)/m
);
const versionCodeMatch = output.match(/versionCode='([^']+)'/);
return {
appVersion: versionNameMatch?.[1] || undefined,
appBuildVersion: versionCodeMatch?.[1],
};
}
export function parseManifestXml(xml: string): { appVersion?: string; appBuildVersion?: string } {
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_' });
const parsed = parser.parse(xml);
const manifest = parsed?.manifest;
if (!manifest) {
return {};
}
const versionName = manifest['@_android:versionName'];
const versionCode = manifest['@_android:versionCode'];
return {
appVersion: versionName != null ? String(versionName) : undefined,
appBuildVersion: versionCode != null ? String(versionCode) : undefined,
};
}
export async function updateResolvedVersionAsync(
graphqlClient: Client,
buildId: string,
{
appVersion,
appBuildVersion,
}: {
appVersion: string | undefined;
appBuildVersion: string | undefined;
}
): Promise<void> {
const result = await graphqlClient
.mutation(
graphql(`
mutation UpdateResolvedVersionMutation(
$buildId: ID!
$appVersion: String
$appBuildVersion: String
) {
build {
updateBuildMetadata(
buildId: $buildId
metadata: {
appVersion: $appVersion
appBuildVersion: $appBuildVersion
}
) {
id
}
}
}
`),
{
buildId,
appVersion,
appBuildVersion,
}
)
.toPromise();
if (result.error) {
throw result.error;
}
}
@@ -1,9 +1,4 @@
import {
GenericArtifactType,
Job,
ManagedArtifactType,
isGenericArtifact,
} from '@expo/eas-build-job';
import { Job, ManagedArtifactType } from '@expo/eas-build-job';
import {
BuildFunction,
BuildStepInput,
@@ -33,7 +28,12 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
ManagedArtifactType.APPLICATION_ARCHIVE,
ManagedArtifactType.BUILD_ARTIFACTS,
...Object.keys(artifactTypeInputToManagedArtifactType),
...Object.values(GenericArtifactType),
// Keep accepting these legacy artifact types for compatibility.
'android-apk',
'android-aab',
'ios-simulator-app',
'ios-ipa',
'other',
],
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
@@ -126,12 +126,13 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
paths: artifactPaths,
name: (inputs.name.value || inputs.key.value) as string,
};
const artifact = isGenericArtifact(artifactSpec)
? {
...artifactSpec,
metadata: inputs.metadata.value as Record<string, unknown> | undefined,
}
: artifactSpec;
const artifact =
artifactSpec.type === null
? {
...artifactSpec,
metadata: inputs.metadata.value as Record<string, unknown> | undefined,
}
: artifactSpec;
try {
const { artifactId } = await ctx.runtimeApi.uploadArtifact({ artifact, logger });
@@ -141,7 +142,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
}
} catch (error) {
if (inputs.ignore_error.value) {
logger.error({ err: error }, `Failed to upload ${artifact.type}. Ignoring error.`);
logger.error({ err: error }, 'Failed to upload artifact. Ignoring error.');
// Ignoring error.
return;
}
@@ -158,7 +159,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
* values. This makes sure we support all:
* - kebab-case managed artifact types (the original)
* - snake-caps-case managed artifact types (the mistake)
* - generic artifact types.
* - workflow artifact types.
*/
function parseArtifactTypeInput({
inputValue,
@@ -166,19 +167,15 @@ function parseArtifactTypeInput({
}: {
inputValue: string;
platform: Job['platform'];
}): GenericArtifactType | ManagedArtifactType {
}): ManagedArtifactType | null {
if (!inputValue) {
// In build jobs the default artifact type is application-archive.
return platform ? ManagedArtifactType.APPLICATION_ARCHIVE : GenericArtifactType.OTHER;
return platform ? ManagedArtifactType.APPLICATION_ARCHIVE : null;
}
// Step's allowedValues ensures input is either
// a key of artifactTypeInputToManagedArtifactType
// or a value of an artifact type.
const translatedManagedArtifactType = artifactTypeInputToManagedArtifactType[inputValue];
if (translatedManagedArtifactType) {
return translatedManagedArtifactType;
}
return inputValue as GenericArtifactType | ManagedArtifactType;
return (
artifactTypeInputToManagedArtifactType[inputValue] ??
Object.values(ManagedArtifactType).find(type => type === inputValue) ??
null
);
}
@@ -262,6 +262,45 @@ export function createUploadToAscBuildFunction(): BuildFunction {
}
);
}
if (isRedundantBinaryUploadError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE',
`Increment Build Number: Build number ${bundleVersion} for app version ${bundleShortVersion} has already been used. ` +
'App Store Connect requires unique build numbers within each app version (version train). ' +
'Increment it by setting ios.buildNumber in app.json, or set "autoIncrement": true in eas.json (recommended). Then rebuild and resubmit.' +
`\n\nApp Store Connect errors:\n${itemizeMessages(errors)}`,
{
docsUrl: 'https://docs.expo.dev/build-reference/app-versions/',
cause: state,
}
);
}
if (isMissingBackgroundTaskIdentifiersError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_MISSING_BG_TASK_IDENTIFIERS',
'Build upload was rejected by App Store Connect because Info.plist is missing `BGTaskSchedulerPermittedIdentifiers` while `UIBackgroundModes` includes `processing`. ' +
'In an Expo project, add `BGTaskSchedulerPermittedIdentifiers` to `ios.infoPlist` in your app config and include every background task identifier the app schedules. ' +
'If you do not use Continuous Native Generation (CNG), add the same key to the native target Info.plist. Then rebuild and submit again.' +
`\n\nApp Store Connect errors:\n${itemizeMessages(errors)}`,
{
docsUrl: 'https://docs.expo.dev/versions/latest/config/app/#infoplist',
cause: state,
}
);
}
if (isMissingAppIconsError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_MISSING_APP_ICONS',
'Build upload was rejected by App Store Connect because required app icons are missing or misconfigured in one of the iOS bundles. ' +
'In an Expo project, check your icon config (`expo.icon`, `ios.icon`) and any custom native targets or plugins that create extra bundles, such as watch extensions. ' +
'Make sure the affected target has the required icon assets and Info.plist icon keys (for example `CFBundleIconName` / `CFBundleIconFiles`), then rebuild and submit again.' +
`\n\nApp Store Connect errors:\n${itemizeMessages(errors)}`,
{
docsUrl: 'https://docs.expo.dev/develop/user-interface/splash-screen-and-app-icon/',
cause: state,
}
);
}
if (isInvalidBundleIdentifierError(errors)) {
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
const ipaBundleIdentifier = ipaInfoResult.ok
@@ -344,6 +383,21 @@ export function isSdkVersionIssueError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90725');
}
export function isRedundantBinaryUploadError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90189');
}
export function isMissingBackgroundTaskIdentifiersError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90771');
}
export function isMissingAppIconsError(messages: { code: string }[]): boolean {
return (
messages.length > 0 &&
messages.every(message => ['90022', '90023', '90391', '90713'].includes(message.code))
);
}
export function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
return (
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
@@ -1,9 +1,6 @@
import { BuildFunction } from '@expo/steps';
import fs from 'fs-extra';
import path from 'path';
import { NpmrcTemplate } from '../../templates/npmrc';
import { findPackagerRootDir } from '../../utils/packageManager';
import { setUpNpmrcAsync } from '../../utils/npmrc';
export function createSetUpNpmrcBuildFunction(): BuildFunction {
return new BuildFunction({
@@ -12,28 +9,12 @@ export function createSetUpNpmrcBuildFunction(): BuildFunction {
name: 'Use NPM_TOKEN',
__metricsId: 'eas/use_npm_token',
fn: async (stepCtx, { env }) => {
const { logger } = stepCtx;
if (env.NPM_TOKEN) {
logger.info('We detected that you set the NPM_TOKEN environment variable');
const projectNpmrcPath = path.join(stepCtx.global.projectTargetDirectory, '.npmrc');
if (await fs.pathExists(projectNpmrcPath)) {
logger.info('.npmrc already exists in your project directory, skipping generation');
} else {
logger.info('Creating .npmrc in your project directory with the following contents:');
logger.info(NpmrcTemplate);
await fs.writeFile(projectNpmrcPath, NpmrcTemplate);
}
} else {
const projectNpmrcPath = path.join(findPackagerRootDir(stepCtx.workingDirectory), '.npmrc');
if (await fs.pathExists(projectNpmrcPath)) {
logger.info(
`.npmrc found at ${path.relative(
stepCtx.global.projectTargetDirectory,
projectNpmrcPath
)}`
);
}
}
await setUpNpmrcAsync({
projectTargetDirectory: stepCtx.global.projectTargetDirectory,
workingDirectory: stepCtx.workingDirectory,
env,
logger: stepCtx.logger,
});
},
});
}
@@ -36,7 +36,7 @@ const SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS =
const SERVE_SIM_FINISH_TIMEOUT_MS =
SERVE_SIM_CONTROL_REQUEST_TIMEOUT_MS + SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS;
const SERVE_SIM_FORCE_STOP_TIMEOUT_MS = 5_000;
const SERVE_SIM_RECORDING_STARTED = 'serve-sim:recording-started';
const SERVE_SIM_RECORDING_STARTED_LINE = /(?:^|\n)serve-sim:recording-started\r?\n/;
// The serve-sim package the session host started; every recorder runs `record-video` from it.
let serveSimPackageSpec: string | null = null;
@@ -57,11 +57,19 @@ type ActiveIosSimulatorRecording = IosSimulatorRecording & {
hasExited: () => boolean;
};
type AdoptedIosSimulatorRecording = {
udid: string;
serverToken: string;
collectAsync(): Promise<void>;
};
type IosSimulatorRecordingSession = {
env: Env;
logger: bunyan;
recordingsRootDirectory: string;
activeRecordings: Map<IosSimulatorUuid, ActiveIosSimulatorRecording>;
adoptedRecordings: Map<string, AdoptedIosSimulatorRecording>;
runtimeDisplayNames: Map<string, string>;
completedRecordings: IosSimulatorRecording[];
recordingFailureCounts: Map<IosSimulatorUuid, number>;
/** Failed attempts for the whole session; unlike recordingFailureCounts, never reset. */
@@ -81,6 +89,52 @@ export namespace IosSimulatorRecordingUtils {
serveSimPackageSpec ??= packageSpec;
}
/**
* Reserves this job's recording output before warm serve-sim adoption, which starts the
* recording. The poller skips that device, so it never starts a second recorder.
*/
export async function prepareAdoptedRecordingAsync({
udid,
deviceName,
serverToken,
}: {
udid: string;
deviceName: string;
serverToken: string;
}): Promise<{ output: string; recordingId: string } | undefined> {
const session = activeIosSimulatorRecordingSession;
if (!session) {
return undefined;
}
udid = udid.toUpperCase();
const recordingId = randomUUID();
const output = path.join(session.recordingsRootDirectory, recordingId);
const startedAt = new Date();
await mkdir(output, { recursive: true });
session.adoptedRecordings.set(recordingId, {
udid,
serverToken,
async collectAsync() {
// Keep real footage eligible for the manifest wait if serve-sim shutdown was unconfirmed.
// The uploader preserves the manifest's partial or failed status.
await access(path.join(output, 'session.json')).catch(() =>
access(path.join(output, 'recording.mp4'))
);
session.completedRecordings.push({
id: recordingId,
udid: udid as IosSimulatorUuid,
deviceName,
runtimeDisplayName: session.runtimeDisplayNames.get(udid) ?? 'Unknown iOS runtime',
outputDirectory: output,
startedAt,
getOutput: () => '',
hasStarted: () => true,
});
},
});
return { output, recordingId };
}
export async function startAsync({ env, logger }: { env: Env; logger: bunyan }): Promise<void> {
if (activeIosSimulatorRecordingSession) {
logger.info('iOS Simulator screen recording polling is already running.');
@@ -95,6 +149,8 @@ export namespace IosSimulatorRecordingUtils {
logger,
recordingsRootDirectory,
activeRecordings: new Map(),
adoptedRecordings: new Map(),
runtimeDisplayNames: new Map(),
completedRecordings: [],
recordingFailureCounts: new Map(),
failedAttemptCounts: new Map(),
@@ -190,6 +246,17 @@ export namespace IosSimulatorRecordingUtils {
})
);
await Promise.all(
[...session.adoptedRecordings.values()].map(recording =>
recording.collectAsync().catch(err => {
logger.warn(
{ err },
'Could not collect the warm Simulator video recording after shutdown.'
);
})
)
);
const completedRecordings = [...session.completedRecordings].sort(
(a, b) => a.startedAt.getTime() - b.startedAt.getTime()
);
@@ -243,6 +310,9 @@ async function pollIosSimulatorRecordingsAsync(
break;
}
listDevicesErrorCount = 0;
for (const device of bootedDevices) {
session.runtimeDisplayNames.set(device.udid.toUpperCase(), device.runtimeDisplayName);
}
const readyServers = new Map(
(await readServeSimServersAsync(SERVE_SIM_STATE_DIR)).flatMap(server =>
server.token ? [[server.udid, server.token] as const] : []
@@ -262,7 +332,14 @@ async function pollIosSimulatorRecordingsAsync(
for (const device of bootedDevices) {
const token = readyServers.get(device.udid);
const packageSpec = serveSimPackageSpec;
if (!token || !packageSpec) {
if (
!token ||
!packageSpec ||
[...session.adoptedRecordings.values()].some(
recording =>
recording.udid === device.udid.toUpperCase() && recording.serverToken === token
)
) {
continue;
}
if (session.serveBearerTokens.get(device.udid) !== token) {
@@ -507,14 +584,19 @@ function captureProcessOutput(recordingProcess: ChildProcess): {
} {
let output = '';
let started = false;
const appendChunk = (chunk: Buffer | string): void => {
output = `${output}${chunk.toString()}`;
if (output.includes(SERVE_SIM_RECORDING_STARTED)) {
started = true;
}
output = output.slice(-16_384);
const captureStreamOutput = (stream: ChildProcess['stdout']): void => {
let streamOutput = '';
stream?.on('data', (chunk: Buffer | string) => {
const text = chunk.toString();
output = `${output}${text}`.slice(-16_384);
streamOutput = `${streamOutput}${text}`;
if (SERVE_SIM_RECORDING_STARTED_LINE.test(streamOutput)) {
started = true;
}
streamOutput = streamOutput.slice(-16_384);
});
};
recordingProcess.stdout?.on('data', appendChunk);
recordingProcess.stderr?.on('data', appendChunk);
captureStreamOutput(recordingProcess.stdout);
captureStreamOutput(recordingProcess.stderr);
return { getOutput: () => output, hasStarted: () => started };
}
@@ -3,18 +3,20 @@ import { type bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import { type ChildProcess } from 'node:child_process';
import { EventEmitter } from 'node:events';
import { rm, writeFile } from 'node:fs/promises';
import { readFile, rm, writeFile } from 'node:fs/promises';
import { PassThrough } from 'node:stream';
import { setTimeout } from 'node:timers/promises';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils';
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
jest.mock('@expo/turtle-spawn');
jest.mock('../serveSimMetricsRecorder');
jest.mock('../../../sentry');
jest.mock('../../../utils/turtleFetch');
const logger = {
info: jest.fn(),
@@ -144,7 +146,10 @@ test('starts serve-sim record-video and returns only a finalized recording', asy
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
await setTimeout(20);
expect(child.kill).not.toHaveBeenCalled();
stderr.write('serve-sim:recording-started\n');
child.stdout?.emit('data', 'Downloading');
stderr.write('serve-sim:recording-');
child.stdout?.emit('data', ' package');
stderr.write('started\r\n');
stderr.write('x'.repeat(17_000));
const recordings = await finishing;
@@ -362,6 +367,32 @@ test('keeps a recorder active after its package-manager wrapper exits', async ()
expect(recordings).toHaveLength(1);
});
test('does not mistake bundled source in an error for a recording start', async () => {
let currentTime = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
const recorder = mockRecorder();
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await waitForRecorderSpawnAsync();
recorder.child.stderr?.emit(
'data',
'const marker = "serve-sim:recording-started";\nError: No running serve-sim session found.\n'
);
recorder.fail(new Error('recorder exited before connecting'));
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
try {
expect(await Promise.race([finishing.then(() => true), setTimeout(250, false)])).toBe(true);
} finally {
currentTime += 311_000;
await finishing;
}
expect(recorder.child.kill).not.toHaveBeenCalled();
});
test('does not signal a recorder that exits before it reports its start', async () => {
let currentTime = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
@@ -537,3 +568,171 @@ test('records every booted device with the first serve-sim package', async () =>
currentTime += 311_000;
await IosSimulatorRecordingUtils.finishAsync({ logger });
});
test('does not request adopted video when job recording is disabled', async () => {
await expect(
IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
udid,
deviceName: 'iPhone 17 Pro',
serverToken: 'job-token',
})
).resolves.toBeUndefined();
expect(turtleFetch).not.toHaveBeenCalled();
});
test.each(['complete', 'failed'])(
'collects an adopted %s recording unchanged after host shutdown without a control request',
async status => {
let releaseDevices!: (devices: never[]) => void;
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockImplementation(
() =>
new Promise(resolve => {
releaseDevices = resolve as never;
})
);
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:3200', token: 'job-token' }]);
await IosSimulatorRecordingUtils.startAsync({ env, logger });
const recording = await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
udid,
deviceName: 'iPhone 17 Pro',
serverToken: 'job-token',
});
if (!recording) {
throw new Error('Expected the adopted recording output.');
}
releaseDevices([{ udid, name: 'iPhone 17 Pro', runtimeDisplayName: 'iOS 26.4' }] as never);
await setTimeout(20);
expect(spawn).not.toHaveBeenCalled();
const manifest = JSON.stringify({
recording: 'recording.mp4',
status,
...(status === 'failed' && { error: 'Recording interrupted; partial footage preserved.' }),
});
await writeFile(`${recording.output}/session.json`, manifest);
jest.mocked(turtleFetch).mockRejectedValue(new Error('serve-sim has stopped'));
expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toEqual([
{
udid,
deviceName: 'iPhone 17 Pro',
runtimeDisplayName: 'iOS 26.4',
directory: recording.output,
},
]);
expect(await readFile(`${recording.output}/session.json`, 'utf8')).toBe(manifest);
expect(turtleFetch).not.toHaveBeenCalled();
}
);
test('waits for an adopted recording manifest when its MP4 already exists', async () => {
jest.mocked(readServeSimServersAsync).mockResolvedValue([]);
await IosSimulatorRecordingUtils.startAsync({ env, logger });
const recording = await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
udid,
deviceName: 'iPhone 17 Pro',
serverToken: 'job-token',
});
if (!recording) {
throw new Error('Expected the adopted recording output.');
}
await writeFile(`${recording.output}/recording.mp4`, 'partial footage');
let finished = false;
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger }).then(recordings => {
finished = true;
return recordings;
});
await setTimeout(20);
expect(finished).toBe(false);
const manifest = JSON.stringify({ recording: 'recording.mp4' });
await writeFile(`${recording.output}/session.json`, manifest);
expect(await finishing).toEqual([
{
udid,
deviceName: 'iPhone 17 Pro',
runtimeDisplayName: 'iOS 26.4',
directory: recording.output,
},
]);
expect(await readFile(`${recording.output}/session.json`, 'utf8')).toBe(manifest);
expect(logger.warn).not.toHaveBeenCalled();
expect(turtleFetch).not.toHaveBeenCalled();
expect(spawn).not.toHaveBeenCalled();
});
test('bounds the manifest wait for an adopted MP4 and reports missing metadata', async () => {
let currentTime = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
jest.mocked(readServeSimServersAsync).mockResolvedValue([]);
await IosSimulatorRecordingUtils.startAsync({ env, logger });
const recording = await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
udid,
deviceName: 'iPhone 17 Pro',
serverToken: 'job-token',
});
if (!recording) {
throw new Error('Expected the adopted recording output.');
}
await writeFile(`${recording.output}/recording.mp4`, 'partial footage');
let finished = false;
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger }).then(recordings => {
finished = true;
return recordings;
});
await setTimeout(50);
expect(finished).toBe(false);
currentTime += 311_000;
expect(await finishing).toEqual([]);
expect(logger.warn).toHaveBeenCalledWith(
{ recorderOutput: '' },
'Screen recording for iPhone 17 Pro ended without a manifest; its footage cannot be uploaded.'
);
expect(Sentry.capture).toHaveBeenCalledWith(
'iOS Simulator screen recording ended without a manifest',
{ level: 'warning', extras: { deviceName: 'iPhone 17 Pro' } }
);
expect(turtleFetch).not.toHaveBeenCalled();
expect(spawn).not.toHaveBeenCalled();
});
test('does not wait for an empty adopted recording reservation', async () => {
jest.mocked(readServeSimServersAsync).mockResolvedValue([]);
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
udid,
deviceName: 'iPhone',
serverToken: 'job-token',
});
expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toEqual([]);
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'Could not collect the warm Simulator video recording after shutdown.'
);
expect(turtleFetch).not.toHaveBeenCalled();
});
test('a reserved adopted recording does not suppress recording a replacement server token', async () => {
let releaseDevices!: (devices: never[]) => void;
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockImplementationOnce(
() =>
new Promise(resolve => {
releaseDevices = resolve as never;
})
);
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:3200', token: 'replacement-token' }]);
mockFailingRecorder();
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
udid,
deviceName: 'iPhone',
serverToken: 'old-token',
});
jest.mocked(turtleFetch).mockRejectedValue(new Error('old server stopped'));
releaseDevices([{ udid, name: 'iPhone', runtimeDisplayName: 'iOS 26.4' }] as never);
await waitForRecorderSpawnAsync();
expect(spawn).toHaveBeenCalledTimes(1);
});
@@ -1,6 +1,7 @@
import { bunyan } from '@expo/logger';
import fetch from 'node-fetch';
import { Readable } from 'node:stream';
import timers from 'node:timers/promises';
import { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
@@ -15,6 +16,7 @@ jest.mock('../deviceRunSessionArtifacts');
jest.mock('../../../sentry');
jest.mock('node-fetch');
const sleepWithSignal = timers.setTimeout;
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
async function readStreamAsync(stream: NodeJS.ReadableStream): Promise<void> {
@@ -215,8 +217,12 @@ describe(uploadAgentDeviceArtifactAsync, () => {
);
});
it('uploads a screenshot display rotation as artifact metadata', async () => {
const data = Buffer.from('png-data');
it('uploads the screenshot size, source and display rotation as artifact metadata', async () => {
const data = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aF9sAAAAASUVORK5CYII=',
'base64'
);
const logger = createLoggerMock();
const ctx = {} as unknown as CustomBuildContext;
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
@@ -230,7 +236,7 @@ describe(uploadAgentDeviceArtifactAsync, () => {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'daemon-token',
logger: createLoggerMock(),
logger,
artifact: {
id: 'artifact-id',
artifactType: 'screenshot',
@@ -243,12 +249,48 @@ describe(uploadAgentDeviceArtifactAsync, () => {
ctx,
expect.objectContaining({
kind: 'screenshot',
metadata: { displayRotation: 'landscape-right' },
metadata: {
source: 'agent-device',
displayRotation: 'landscape-right',
width: 1,
height: 1,
},
})
);
expect(logger.warn).not.toHaveBeenCalled();
});
it('uploads a screenshot without its size and warns when its header cannot be read', async () => {
const data = Buffer.from('not-an-image');
const logger = createLoggerMock();
const ctx = {} as unknown as CustomBuildContext;
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { openStream }) => {
await readStreamAsync(openStream());
});
await uploadAgentDeviceArtifactAsync(ctx, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'daemon-token',
logger,
artifact: { id: 'artifact-id', artifactType: 'screenshot', filename: 'shot.png' },
});
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ kind: 'screenshot', metadata: { source: 'agent-device' } })
);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ file: expect.stringMatching(/shot\.png$/) }),
expect.stringContaining('without its width and height')
);
});
it('uploads no metadata when the artifact has no display rotation', async () => {
it('uploads no metadata for a non-screenshot artifact without a display rotation', async () => {
const data = Buffer.from('artifact-data');
const ctx = {} as unknown as CustomBuildContext;
@@ -264,7 +306,7 @@ describe(uploadAgentDeviceArtifactAsync, () => {
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'daemon-token',
logger: createLoggerMock(),
artifact: { id: 'artifact-id', artifactType: 'screenshot', filename: 'shot.png' },
artifact: { id: 'artifact-id', artifactType: 'screen-recording', filename: 'session.mp4' },
});
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls[0][1]).not.toHaveProperty(
@@ -276,6 +318,14 @@ describe(uploadAgentDeviceArtifactAsync, () => {
describe(pollAgentDeviceArtifactsForUploadAsync, () => {
beforeEach(() => {
jest.useFakeTimers();
// Keep fake-clock tests deterministic; cancellation tests use the native abortable timer.
jest
.spyOn(timers, 'setTimeout')
.mockImplementation((delay, value, options) =>
options?.signal
? sleepWithSignal(delay, value, options)
: new Promise(resolve => setTimeout(resolve, delay, value))
);
jest.mocked(fetch).mockReset();
jest.mocked(Sentry.capture).mockReset();
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockReset();
@@ -284,6 +334,257 @@ describe(pollAgentDeviceArtifactsForUploadAsync, () => {
afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
jest.restoreAllMocks();
});
it('cancels a warm poller during its wait without another inventory request', async () => {
jest.useRealTimers();
const controller = new AbortController();
jest.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ artifacts: [] })));
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'token',
logger: createLoggerMock(),
signal: controller.signal,
});
await new Promise(resolve => setImmediate(resolve));
controller.abort();
await polling;
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ signal: controller.signal })
);
});
it('cancels an in-flight warm artifact transfer and drains the poller', async () => {
jest.useRealTimers();
const controller = new AbortController();
let downloading!: () => void;
const started = new Promise<void>(resolve => {
downloading = resolve;
});
jest
.mocked(fetch)
.mockResolvedValueOnce(
new Response(JSON.stringify({ artifacts: [{ id: 'new-artifact', filename: 'new.png' }] }))
)
.mockImplementationOnce(async (_url, options) => {
const signal = options?.signal as AbortSignal;
expect(signal).toBe(controller.signal);
downloading();
return await new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
});
});
const logger = createLoggerMock();
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'token',
logger,
signal: controller.signal,
});
await started;
controller.abort(new Error('session ended'));
await polling;
expect(fetch).toHaveBeenCalledTimes(2);
expect(logger.warn).not.toHaveBeenCalled();
expect(uploadDeviceRunSessionArtifactAsync).not.toHaveBeenCalled();
});
it('finishes an active cloud upload and collects final artifacts after normal stop', async () => {
jest.useRealTimers();
const stopPolling = new AbortController();
const cancellation = new AbortController();
let releaseUpload!: () => void;
let uploadStarted!: () => void;
const pendingUpload = new Promise<void>(resolve => {
releaseUpload = resolve;
});
const started = new Promise<void>(resolve => {
uploadStarted = resolve;
});
let inventories = 0;
const first = { id: 'first', filename: 'first.png' };
const last = { id: 'last', filename: 'last.png' };
jest
.mocked(fetch)
.mockImplementation(async url =>
String(url).endsWith('/artifacts')
? new Response(
JSON.stringify({ artifacts: ++inventories === 1 ? [first] : [first, last] })
)
: new Response(Readable.from(['png']))
);
let transferSignal: AbortSignal | undefined;
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_ctx, options) => {
await readStreamAsync(options.openStream());
if (options.artifactId === 'first') {
transferSignal = options.signal;
uploadStarted();
await pendingUpload;
}
});
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'token',
logger: createLoggerMock(),
signal: cancellation.signal,
stopPollingSignal: stopPolling.signal,
});
let finished = false;
void polling.then(() => {
finished = true;
});
try {
await started;
stopPolling.abort();
await new Promise(resolve => setImmediate(resolve));
expect(transferSignal?.aborted).toBe(false);
expect(finished).toBe(false);
releaseUpload();
await polling;
expect(inventories).toBe(2);
expect(
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mock.calls.map(([, options]) => options.artifactId)
).toEqual(['first', 'last']);
} finally {
releaseUpload();
cancellation.abort();
await polling;
}
});
it('checks for an artifact created between the last poll and normal stop', async () => {
jest.useRealTimers();
const stopPolling = new AbortController();
jest
.mocked(fetch)
.mockResolvedValueOnce(new Response(JSON.stringify({ artifacts: [] })))
.mockResolvedValueOnce(
new Response(JSON.stringify({ artifacts: [{ id: 'last', filename: 'last.png' }] }))
)
.mockResolvedValueOnce(new Response(Readable.from(['png'])));
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_ctx, options) => {
await readStreamAsync(options.openStream());
});
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'token',
logger: createLoggerMock(),
stopPollingSignal: stopPolling.signal,
});
await new Promise(resolve => setImmediate(resolve));
stopPolling.abort();
await polling;
expect(fetch).toHaveBeenCalledTimes(3);
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ artifactId: 'last' })
);
});
it('reports a failed final inventory after normal stop', async () => {
jest.useRealTimers();
const stopPolling = new AbortController();
const logger = createLoggerMock();
const error = new Error('daemon is not ready');
jest
.mocked(fetch)
.mockResolvedValueOnce(new Response(JSON.stringify({ artifacts: [] })))
.mockRejectedValueOnce(error);
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'token',
logger,
stopPollingSignal: stopPolling.signal,
});
await new Promise(resolve => setImmediate(resolve));
stopPolling.abort();
await polling;
expect(fetch).toHaveBeenCalledTimes(2);
expect(logger.warn).toHaveBeenCalledWith(
{ err: error, failedArtifactListCount: 1 },
'Could not list agent-device remote session artifacts.'
);
expect(Sentry.capture).toHaveBeenCalledTimes(1);
});
it('cancels a graceful collector immediately when the job is cancelled', async () => {
jest.useRealTimers();
const stopPolling = new AbortController();
const cancellation = new AbortController();
let entered!: () => void;
const started = new Promise<void>(resolve => {
entered = resolve;
});
jest.mocked(fetch).mockImplementation(async (_url, options) => {
const signal = options?.signal as AbortSignal;
entered();
return await new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
});
});
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'token',
logger: createLoggerMock(),
signal: cancellation.signal,
stopPollingSignal: stopPolling.signal,
});
await started;
cancellation.abort();
await polling;
expect(stopPolling.signal.aborted).toBe(false);
expect(fetch).toHaveBeenCalledTimes(1);
});
it('cancels a stuck transfer after the normal-stop drain budget expires', async () => {
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
const stopPolling = new AbortController();
const logger = createLoggerMock();
let entered!: () => void;
const started = new Promise<void>(resolve => {
entered = resolve;
});
jest
.mocked(fetch)
.mockResolvedValueOnce(
new Response(JSON.stringify({ artifacts: [{ id: 'large', filename: 'large.mp4' }] }))
)
.mockResolvedValueOnce(new Response(Readable.from(['video'])));
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_ctx, options) => {
await readStreamAsync(options.openStream());
entered();
return await new Promise((_resolve, reject) => {
options.signal!.addEventListener('abort', () => reject(options.signal!.reason), {
once: true,
});
});
});
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'token',
logger,
stopPollingSignal: stopPolling.signal,
});
await started;
stopPolling.abort();
await jest.advanceTimersByTimeAsync(30_000);
await polling;
expect(fetch).toHaveBeenCalledTimes(2);
expect(logger.warn).toHaveBeenCalledWith(
'Timed out draining agent-device artifacts; some session artifacts may not have been uploaded.'
);
});
it('reports artifact listing errors on every fifth consecutive failure', async () => {
Loaded 100 of 564 files, more files were not shown because too many files have changed in this diff. Show more