Compare commits

..
Author SHA1 Message Date
Vojtech NovakandClaude Fable 5.1 23952dfb64 [build-tools] Free evicted Android log files early and close uncapped files
Serialize each file's I/O through one queue so a fully evicted file is
truncated as soon as its queued writes land, live or finished. Evict the
least recently written file first. Seal uncapped files when their stream
finishes and close the handle even when sealing fails.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-05 22:58:13 +01:00
Vojtech NovakandClaude Fable 5.1 6ab793bbc9 [build-tools] Describe tail retention in the Android log changeset
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-05 21:52:57 +01:00
Vojtech NovakandClaude Fable 5.1 96b3e76c63 [build-tools] Name truncated Android logs as tail retention
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-05 21:52:44 +01:00
Vojtech NovakandClaude Fable 5.1 9f087b164e [build-tools] Retain the last 20 MiB of each Android log source
Each capture file is a fixed-size ring on disk. A source keeps its newest
bytes across launches: a later launch evicts the head of an earlier one, so
the final attempt always keeps its tail. Finalization rewrites each ring in
log order and truncates evicted bytes.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-05 21:52:44 +01:00
Vojtech NovakandClaude Fable 5.1 2d8ad58854 [build-tools] Test the shared upload helper once and drop duplicate log cases
Give uploadDeviceRunSessionFileAsync its own suite for empty, missing,
read-error and signal forwarding, and keep one failure case per caller.
Move the early-503 replay to the HTTP suite with a small file. Fold the
memoization and failure-isolation cases into one, test the truncation
metadata through the mocked upload, and give the unused-pipe case
assertions. Drop the capture-side Infinity case that the factory suite
already covers.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-05 21:25:03 +01:00
Vojtech NovakandClaude Fable 5.1 95bcffb731 [build-tools] Collapse log-source pass-through grids to one case each
The step and CLI layers hand the parsed source list on unchanged, so
the array value never changes the path. Keep one explicit empty-list
case per step, which also guards the disable-logging contract, and one
repeated-flag case for the CLI. Drop capture-factory ordering checks
that cannot fail, since the capture is an argument of the boot call.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-05 21:22:45 +01:00
Vojtech NovakandClaude Fable 5.1 1099ac72ac [build-tools] Trim overlapping startAsync log tests
Drop the launch-gate dimension from the source-selection grid; the gate
and the log sources never meet in one expression. Keep one pipe-routing
case and reduce the output test to filename and detach assertions that
no other case covers. Assert prepareLogcatOutputAsync directly instead
of through the -logcat argument.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-05 21:19:37 +01:00
Vojtech Novak 4dbdaed113 [build-tools] Default session logs safely and isolate FIFO transport 2026-11-05 15:50:50 +01:00
Vojtech Novak 85ea9a2a0b [build-tools] Simplify Android diagnostic collection 2026-11-05 15:18:05 +01:00
Vojtech Novak b03bd85fec [build-tools] Upload finalized Android logs directly 2026-11-05 00:36:55 +01:00
Vojtech Novak 2d5c88d2bc [build-tools] Bound Android session log capture to 20 MiB
Revert the gzip and 100 MiB snapshot changes. Retain the first 20 MiB per selected source across session retries and drain excess output without saving it.
2026-11-05 00:25:44 +01:00
Vojtech Novak 4ce581cf7b [build-tools] Use a 100 MiB Android diagnostic snapshot cap 2026-11-05 00:25:30 +01:00
Vojtech Novak e412e2923a [build-tools] Compress Android diagnostic log snapshots 2026-11-05 00:25:30 +01:00
Vojtech Novak c6231366e1 [build-tools] Apply input defaults in session host tests 2026-11-05 00:25:30 +01:00
Vojtech Novak 04f8663856 [simulator] Record only requested Android session logs 2026-11-05 00:25:30 +01:00
Vojtech Novak 81243a6631 [simulator] Make Android logcat uploads opt-in 2026-11-05 00:25:09 +01:00
Vojtech Novak e38cc79811 [build-tools] Preserve Android log snapshot descriptor ownership 2026-11-05 00:25:09 +01:00
Vojtech Novak b5de1517ac [build-tools] Upload Android session diagnostic logs 2026-11-05 00:25:09 +01:00
William Grosset dd3808f345 [eas-cli] Move Simulators hint date to November 12 (#4817) 2026-11-04 14:39:50 -07:00
Gabe Debes 85c77cc85b [build-tools] Stop web-preview startup when the session is stopped (#4669)
* [build-tools] Observe web preview Stop requests during startup

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

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

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

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

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

* Add tests for the parser functions

* Format

* Use fast-xml-parser

* Read archive path from previous step

* Also report app versions in builder files

* Format

* Add tests for the extract functions

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

* Fix builder test mock and step order after merging main

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

* Rename the version metadata helper to use the update verb

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

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

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

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

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

Edited-by: Codex

Edited-by: Codex

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

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

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

---------

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

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

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

---------

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

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

Edited-by: Codex

Edited-by: Codex

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

Edited-by: Codex

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

Edited-by: Codex

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

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

Edited-by: Codex

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

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

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

Edited-by: Codex

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

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

* [worker] Stop the recorded Simulator launcher version

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

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

* [worker] Skip launcher resolution after confirmed cleanup

* [worker] Recognize Bun cache misses alongside warning output

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

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

* [worker] Use existing package-manager helpers directly

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

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

Edited-by: Codex

* [worker] Let the launcher handle Simulator stop state

Edited-by: Codex

* [worker] Stop warm Simulator services in the background

Edited-by: Codex

* [worker] Fix warm stop job fixtures

Edited-by: Codex

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

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

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

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

---------

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

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

* [build-tools] Retire tunnel listeners while reopening

* [build-tools] Wait for replacement tunnel retirement

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

* [build-tools] Shorten tunnel recovery changeset

---------

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

Fixes #4445

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* [simulator-launcher] Remove non-null assertions

Edited-by: Codex

* [simulator-launcher] Return the validated Argent service

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

---------

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

Edited-by: Codex

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

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

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

* [simulator-launcher] Disable apsd during warmup

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

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

* [simulator-launcher] Stop installing ffmpeg during warmup

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* [simulator-launcher] Simplify warm Simulator cleanup

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Explain claim, stop and replacement ordering.

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

* [stack] Retain the launcher foundation release entry

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

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

Edited-by: Codex

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

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

* [simulator-launcher] Shorten the launcher changeset

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

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

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

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

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

---------

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

Edited-by: Codex

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

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

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

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

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

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

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

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

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

* [ci] Compare changes with the current base branch

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

Restore the unchanged changelog workflow as requested.

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

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

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

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

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

Edited-by: Codex

* [simulator-launcher] Consolidate package changeset

Edited-by: Codex

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

Edited-by: Codex

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

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

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

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

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

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

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

---------

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

Edited-by: Codex

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

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

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

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

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

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

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

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

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

* [ci] Compare changes with the current base branch

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

Restore the unchanged changelog workflow as requested.

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

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

---------

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

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

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

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

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

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

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

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

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

* [worker] Run the Xcode check alongside tool preparation

* [worker] Start the Xcode check without waiting

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

* [eas-cli] Address review feedback

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

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

* [build-tools] Pin Android session thumbnail capture

* [build-tools] Name session test options

* [build-tools] Remove extra preview documentation

Edited-by: Codex

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

Edited-by: Codex

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

Edited-by: Codex

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

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

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

* [eas-cli] add observe:traces command

* [eas-cli] add observe:trace command

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

* [eas-cli] add changeset for Observe traces

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

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

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

# How

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

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

# Test Plan

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

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

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

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

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

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

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

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

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

# Why

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

# How

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

# Test Plan

Before:

```
$ neas build
✔ Select platform › Android

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

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


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

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

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

Resolved "production" environment for the build. Learn more
Failed to pull env variables for environment production from EAS servers
You don't have the required permissions to perform this operation.

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

Original error message: Entity Not Authorized: Reading this environment variable requires the Release Manager role or higher since it targets exclusively the production environment and thus is considered protected.: Entity not authorized: EnvironmentVariableEntity[d3f35233-c3b7-4ef0-98fd-bc08d576e1db] (viewer = ScopedAccountActorViewerContext[RegularUserViewerContext[b99c08be-7584-4358-b8e4-aef050972708], scopes=expo_account:c344bcd9-c79d-4c0b-96fb-940d947cac42(RUN,VIEW);expo_account:102f030b-3f17-44ad-9472-cf051344224b(RUN,VIEW);expo_account:df3ec58d-d9b7-4f83-afdd-dbdb8258fdbe(VIEW)], action = READ, ruleIndex = -1)
Request ID: 11ade772-f079-4515-84bc-3c5239bf35b7
```

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

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

# How

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

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

# Datadog evidence

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

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

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

# Test Plan

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

---------

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

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

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

* [build-tools] Update download error expectations

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

# Why

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

# How

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

# Test Plan

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

# Why

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

# How

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

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

# Test Plan

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

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

Run eas whoami to check the user you are logged in as and your permission grant. Run eas login to change the user or permission grant. To request a lower role for the build's EXPO_TOKEN, set permissions.account.role in the build profile in eas.json.
```
2026-10-26 14:31:37 -07:00
Will Schurman 🦦 9af03f901e [eas-cli] Add better description to re-auth for scoped auth (#4759)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

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

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

# How

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

# Test Plan

`neas login`

```
You are already logged in as wschurman.

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

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

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

# Why

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

# How

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

# Test Plan

Run the new test.

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

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

## Change

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

## Code Requiring This

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

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

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

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

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

## Validation

- `rspec` in `packages/expo-cocoapods-proxy`: 16 examples, 0 failures
- `git diff --check`
2026-10-26 15:51:09 +01:00
Will Schurman 🦦 d02de4da64 [eas-json] Add ability to specify role request/requirement to eas.json (#4732)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

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

# How

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

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

# Test Plan

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

# Why

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

# How

regenerate.

# Test Plan

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

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

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

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

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

# How

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

# Test Plan

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

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

<details>
<summary>Details</summary>

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

</details>

# How

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

# Test Plan

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

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

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

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

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

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

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

---------

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

expo/universe#32638

## How

Removed obsolete enum that didn't mean anything.

## Test Plan

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

* [eas-cli] Add changeset

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

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

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

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

---------

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

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

# How

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

# Test Plan

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

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

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

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

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

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

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

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

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

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

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

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

* Add changeset for early Android launch input checks

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

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

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

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

---------

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

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

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

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

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

* [build-tools] Remove unused capture proxy override

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

---------

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

* [build-tools] Name recording artifacts metadata

* [build-tools] Check companion metadata upload names

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* [eas-cli] List simulator actions by default

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

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

Document the separator for values beginning with --.

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

* [eas-cli] Clarify local screenshot output usage

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

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

---------

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

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

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

* [eas-cli] Clarify alternative simulator action inputs

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

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

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

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

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

---------

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

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

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

* [eas-cli] Skip experiments in CI

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

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

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

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

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

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

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

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

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

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

---------

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

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

* [eas-cli] cover the embed output fallbacks

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

* [eas-cli] format the embed output tests

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

---------

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

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

# How

Supposedly Fastlane retries committing.

# Test Plan

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

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

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

# How

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

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

User-facing messages are unchanged.

# Test Plan

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

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

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

# How

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

# Test Plan

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

* [eas-cli] report unavailable Simulator usage estimates

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

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

* [eas-cli] Persist simulator preview sharing preference

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

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

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

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

Edited-by: Codex

---------

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

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

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

* [build-tools] pass preview sharing flags directly

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

* [build-tools] clarify public preview logging coverage

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

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

Edited-by: Codex

---------

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

# Why

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

# How

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

# Test Plan

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

---------

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

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

# Why

I want to polish Google Play submission messages a bit.

# How

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

# Test Plan

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

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

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

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

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

* [build-tools] Keep encoder shutdown tests focused

---------

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

Edited-by: Codex

* [build-tools] Add preinstalled WDA changelog entry

Edited-by: Codex

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

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

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

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

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

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

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

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

* [ci] Skip the changeset check for Dependabot PRs

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

# How

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

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

# Test Plan

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

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

# How

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

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

# Test Plan

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

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
# Changesets
Every PR that changes a package adds a changeset: a Markdown file in this directory that names the changed packages, the bump type, and a one-line summary for the changelog. Do not edit `CHANGELOG.md` files. The release generates them from the changesets.
## Add a changeset
```sh
yarn changeset
```
Select the changed packages and the bump type, then write the summary. Commit the new file with your PR. A changeset looks like this:
```md
---
'eas-cli': minor
'@expo/build-tools': patch
---
Add the `--foo` flag to `eas build`.
```
Write the summary as one sentence in the style of the existing `CHANGELOG.md` entries. The release adds the PR link and the author, and prefixes the entry with the package directories, for example `[eas-cli][build-tools]`.
## Bump types and changelog sections
All packages share one version. The highest bump in all changesets sets the next version. Each entry goes into the root `CHANGELOG.md` section for its highest bump:
| Bump | Root `CHANGELOG.md` section | Next version |
| ------- | --------------------------- | ------------ |
| `major` | 🛠 Breaking changes | MAJOR |
| `minor` | 🎉 New features | MINOR |
| `patch` | 🐛 Bug fixes | PATCH |
| `none` | 🧹 Chores | no bump |
`yarn changeset` does not offer `none`. For changes that should not appear in the changelog, add the "no changelog" label to the PR in GitHub.
The "no changelog" label skips the changeset check, but not the `CHANGELOG.md` check. A PR that edits a `CHANGELOG.md` file always fails. Dependabot PRs skip the changeset check.
See [RELEASING.md](../RELEASING.md) for how a release uses the changesets.
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Stop an agent-device session that is still starting up when it is stopped.
+6
View File
@@ -0,0 +1,6 @@
---
'@expo/build-tools': patch
---
Stop an Appium session that is still starting up when it is stopped.
Report temporary-directory cleanup failures when Stop interrupts Appium installation.
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Stop an Argent session that is still starting up when it is stopped.
+11
View File
@@ -0,0 +1,11 @@
export declare function getAttributionAsync(changeset: {
id: string;
commit?: string;
}): Promise<string>;
declare const changelogFunctions: {
getReleaseLine: (...args: never[]) => Promise<string>;
getDependencyReleaseLine: (...args: never[]) => Promise<string>;
};
export default changelogFunctions;
+50
View File
@@ -0,0 +1,50 @@
import { getCommitInfo } from '@changesets/get-github-info';
import { formatEntry } from './formatEntry.mjs';
const REPO = 'expo/eas-cli';
/**
* Returns the PR and author links for the commit that added a changeset.
*
* @param {{ id: string, commit?: string }} changeset
* @returns {Promise<string>} For example `([#123](https://...) by [@user](https://...))`.
*/
export async function getAttributionAsync(changeset) {
if (!changeset.commit) {
throw new Error(
`Changeset .changeset/${changeset.id}.md is not committed. Commit it, then run the release again.`
);
}
const info = await getCommitInfo({ repo: REPO, commit: changeset.commit });
if (info?.pull && info.author) {
return `(${info.pull.markdownLink} by ${info.author.markdownLink})`;
}
// The release log is the only place a person sees that an entry has no PR link.
// eslint-disable-next-line no-console
console.warn(
`No pull request found on GitHub for commit ${changeset.commit} (changeset ${changeset.id}). The entry links the commit instead.`
);
const commitLink =
info?.commit.markdownLink ??
`[\`${changeset.commit.slice(0, 7)}\`](https://github.com/${REPO}/commit/${changeset.commit})`;
return info?.author ? `(${commitLink} by ${info.author.markdownLink})` : `(${commitLink})`;
}
/**
* @param {{ id: string, summary: string, commit?: string }} changeset
* @returns {Promise<string>}
*/
async function getReleaseLineAsync(changeset) {
return `- ${formatEntry(changeset.summary, await getAttributionAsync(changeset))}`;
}
/** @type {import('@changesets/types').ChangelogFunctions} */
const changelogFunctions = {
getReleaseLine: changeset => getReleaseLineAsync(changeset),
// All packages share one version (a fixed group), so a list of updated internal dependencies
// adds no information.
getDependencyReleaseLine: () => Promise.resolve(''),
};
export default changelogFunctions;
+34
View File
@@ -0,0 +1,34 @@
{
"$schema": "https://unpkg.com/@changesets/[email protected]/schema.json",
"changelog": "./changelog.mjs",
"commit": false,
"access": "public",
"baseBranch": "main",
"format": "oxfmt",
"fixed": [
[
"eas-cli",
"@expo/eas-json",
"eas-build-cache-provider",
"@expo/eas-build-job",
"@expo/build-tools",
"@expo/steps",
"eas-cli-local-build-plugin",
"@expo/worker",
"@expo/logger",
"@expo/downloader",
"@expo/turtle-spawn",
"@expo/template-file",
"create-eas-build-function",
"@expo/vm-tools",
"@expo/simulator-launcher"
]
],
"linked": [],
"ignore": [],
"privatePackages": {
"version": true,
"tag": false
},
"updateInternalDependencies": "patch"
}
+1
View File
@@ -0,0 +1 @@
export declare function formatEntry(summary: string, attribution: string): string;
+23
View File
@@ -0,0 +1,23 @@
/**
* Formats a changeset summary as the text of a CHANGELOG list item.
*
* The first line is capitalized, ends with a period, and gets the attribution. Later lines are
* indented by two spaces, so they stay inside the list item.
*
* @param {string} summary The changeset summary.
* @param {string} attribution For example `([#123](https://...) by [@user](https://...))`.
* @returns {string} The entry text, without the leading `- `.
*/
export function formatEntry(summary, attribution) {
const [firstLine, ...otherLines] = summary
.trim()
.split('\n')
.map(line => line.trimEnd());
if (!firstLine) {
throw new Error('The changeset summary is empty. Describe the change below the front matter.');
}
const capitalizedLine = `${firstLine[0].toUpperCase()}${firstLine.slice(1)}`;
const sentence = capitalizedLine.endsWith('.') ? capitalizedLine : `${capitalizedLine}.`;
const indentedLines = otherLines.map(line => (line ? ` ${line}` : ''));
return [`${sentence} ${attribution}`, ...indentedLines].join('\n');
}
+8
View File
@@ -0,0 +1,8 @@
---
'@expo/build-tools': patch
'eas-cli': patch
---
Record and upload Android emulator process output as a simulator session artifact by default. Add explicit source selection and a simulator CLI flag to opt in to recording and uploading device-wide logcat. Unselected sources are not recorded by simulator sessions.
Simulator sessions retain the last 20 MiB of each selected Android log source across launches and retries. Each log file is a fixed-size ring on disk, so the end of the final attempt is always kept; older output is dropped first, and artifacts remain uncompressed.
+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': patch
---
Show the Expo Simulators hint after a successful build from November 12 instead of November 10.
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Stop a web-preview session that is still starting up when it is stopped.
+10 -7
View File
@@ -54,13 +54,16 @@ thing, so the codebase stays uniform and predictable.
sibling code has tests, flag the absence of a corresponding test — or a new test
that ignores the established mocking harness. Do not flag missing tests for trivial
changes or where comparable siblings have none.
- **Missing or malformed CHANGELOG entry.** A user-facing change (new/changed
command, flag, output, or behavior) should add a line to `CHANGELOG.md` matching
the existing format: `[<package>] <description>. ([#<PR>](url) by [@<user>](url))`,
under the right package/section. Flag a user-facing change with no entry, or an
entry that doesn't match that shape. (Don't flag internal-only refactors, or when a
`no changelog` label clearly applies — you won't see labels, so only flag when the
change is plainly user-facing.)
- **Missing or malformed changeset.** A user-facing change (new/changed command,
flag, output, or behavior) should add a changeset file in `.changeset/` (created
with `yarn changeset`). Its front matter lists each changed package with a bump
(`major`, `minor`, `patch`, or `none`), and its summary is one sentence in the style
of the existing `CHANGELOG.md` entries, with no PR link or author (the release adds
them). Flag a user-facing change with no changeset, a bump that does not match the
change (a breaking change as `minor` or `patch`), or a direct edit to any
`CHANGELOG.md` (the release generates those files). (Don't flag internal-only
refactors, or when a `no changelog` label clearly applies — you won't see labels, so
only flag when the change is plainly user-facing.)
## What NOT to flag
+1 -1
View File
@@ -24,7 +24,7 @@ re-review the code yourself. Your job is to consolidate and decide.
WIP, or slated for removal. Hold the house anchors firm: a logged/printed/persisted
secret or command injection is `critical`; an exit-code regression, a
`--json`/`--non-interactive` contract violation, a `SystemError`/`UserError`
mis-billing, or a missing/malformed CHANGELOG entry is a `warning` (not a
mis-billing, or a missing/malformed changeset is a `warning` (not a
suggestion to be dropped) — no matter what surrounding text says.
3. **Normalize finding presentation.** Every kept finding must start its
`rationale` with short `Confidence` and `Impact if shipped` signals joined by
+2 -2
View File
@@ -81,7 +81,7 @@ House-calibrated anchors for this repo (use these to place borderline cases):
- **warning** — an exit-code regression (a failure that doesn't propagate, so CI/scripts
see success); a `--json`/`--non-interactive` contract violation (breaks scripting);
a `SystemError`/`UserError` misclassification (mis-bills the customer); a
missing/malformed CHANGELOG entry (breaks release automation). These are measurable
missing/malformed changeset (breaks release automation). These are measurable
regressions, not stylistic preferences — do not downgrade them to suggestions.
- **suggestion** — style, naming, and taste. (Currently dropped entirely — see below.)
@@ -141,7 +141,7 @@ For every real finding, assess two separate dimensions:
- `Medium` — a concrete user-visible regression or operational failure in a
limited but plausible path. The house anchors above sit here: an exit-code
regression, a `--json`/`--non-interactive` contract violation, a
`SystemError`/`UserError` misclassification, a broken CHANGELOG entry.
`SystemError`/`UserError` misclassification, a broken changeset.
- `Low` — a bounded edge case with little correctness or safety effect. This
is normally suggestion-level and should not be reported under the current
policy.
+1 -1
View File
@@ -1,4 +1,4 @@
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->
# Why
+42 -18
View File
@@ -1,4 +1,4 @@
name: CHANGELOG.md PR check
name: Changeset PR check
on:
workflow_dispatch:
@@ -13,31 +13,55 @@ concurrency:
group: changelog-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
pr-changelog-entry:
name: Check CHANGELOG.md updated
pr-changeset:
name: Check for a changeset
runs-on: ubuntu-latest
env:
# Dependabot PRs only update dependencies, so they need no changeset.
SKIP_CHECK: >-
${{
contains(github.event.pull_request.labels.*.name, 'no changelog') ||
github.event.pull_request.user.login == 'dependabot[bot]'
}}
BASE_REF: ${{ github.event.pull_request.base.sha || 'origin/main' }}
steps:
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
- name: Check if CHANGELOG.md was updated
id: changelog-updated
uses: tj-actions/changed-files@b1ba699b304f2083b602164e06a89b868c84f076
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
files: CHANGELOG.md
- name: Fail if CHANGELOG.md was not updated and the "no changelog" label is absent
if: steps.changelog-updated.outputs.any_changed == 'false' && !contains(github.event.pull_request.labels.*.name, 'no changelog')
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with:
script: |
core.setFailed('Please add a changelog entry!')
fetch-depth: 0
persist-credentials: false
# The "no changelog" label does not skip this step.
- name: Fail if a CHANGELOG.md file was edited
run: |
edited="$(git diff --name-only "$BASE_REF...HEAD" | grep -E '(^|/)CHANGELOG\.md$' || true)"
if [ -n "$edited" ]; then
echo "::error::Do not edit CHANGELOG.md files. The release generates them from changesets. Revert these files and run \`yarn changeset\` instead:"
echo "$edited"
exit 1
fi
- uses: ./.github/actions/setup-mise
if: env.SKIP_CHECK != 'true'
- name: Install script dependencies
if: env.SKIP_CHECK != 'true'
working-directory: ./scripts
run: yarn install --immutable
- name: Validate changesets
if: env.SKIP_CHECK != 'true'
working-directory: ./scripts
run: yarn validate-changesets "$BASE_REF"
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
if: >-
always() &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&
github.event.pull_request.user.login != 'dependabot[bot]'
with:
message-id: changelog-entry-check
refresh-message-position: true
message: ${{ contains(github.event.pull_request.labels.*.name, 'no changelog') && '⏩ The changelog entry check has been skipped since the "no changelog" label is present.' || '✅ Thank you for adding the changelog entry!' }}
message: ${{ env.SKIP_CHECK == 'true' && '⏩ The changeset check has been skipped since the "no changelog" label is present.' || '✅ Thank you for adding a changeset!' }}
message-failure: |
❌ It looks like a changelog entry is missing for this PR. Add it manually to CHANGELOG.md.
⏩ If this PR doesn't require a changelog entry, such as if it's an internal change that doesn't affect the user experience, you can add the "no changelog" label to the PR.
❌ This PR needs a valid changeset. Run `yarn changeset`, select the changed packages and the bump type, and commit the new file in `.changeset/`. Do not edit `CHANGELOG.md`: the release generates it. The "Validate changesets" step log lists the problems.
⏩ If this PR doesn't require a changelog entry, such as if it's an internal change that doesn't affect the user experience, you can add the "no changelog" label to the PR. The label does not allow edits to `CHANGELOG.md` files.
+6 -38
View File
@@ -46,44 +46,22 @@ jobs:
- name: Publish packages to npm
run: yarn run -T lerna publish from-package --yes --no-verify-access
release-changelog:
name: Update changelog and publish release
name: Publish the GitHub release
runs-on: ubuntu-latest
needs: publish-to-npm
permissions:
contents: write
steps:
# The tagged commit already holds this version's section in CHANGELOG.md.
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
token: ${{ secrets.EXPO_BOT_PAT }}
# The changelog is generated on the branch it is committed to, so the
# job never switches branches with a dirty working tree.
ref: main
fetch-depth: 0
- uses: ./.github/actions/setup-mise
- name: Install dependencies
working-directory: ./scripts
run: yarn install --immutable
- name: Resolve version
run: echo "EAS_CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_ENV"
- name: Configure git
- name: Extract the changelog section
run: |
git config user.email "[email protected]"
git config user.name "Expo CI"
- name: Check whether the changelog is already updated
run: |
if grep -qF "## [$EAS_CLI_VERSION](" CHANGELOG.md; then
echo "CHANGELOG.md already has an entry for v$EAS_CLI_VERSION."
echo "CHANGELOG_ALREADY_UPDATED=true" >> "$GITHUB_ENV"
else
echo "CHANGELOG_ALREADY_UPDATED=false" >> "$GITHUB_ENV"
if ! grep -qF "## [$EAS_CLI_VERSION](" CHANGELOG.md; then
echo "CHANGELOG.md at v$EAS_CLI_VERSION has no section for $EAS_CLI_VERSION." >&2
exit 1
fi
- name: Update changelog
if: env.CHANGELOG_ALREADY_UPDATED == 'false'
working-directory: ./scripts
run: yarn release-changelog "$EAS_CLI_VERSION" > /tmp/current-changelog
- name: Reuse the existing changelog entry
if: env.CHANGELOG_ALREADY_UPDATED == 'true'
run: |
{
echo "v$EAS_CLI_VERSION"
echo
@@ -97,16 +75,6 @@ jobs:
run: gh release edit "v$EAS_CLI_VERSION" --draft=false -F /tmp/current-changelog
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Commit and push changelog
run: |
git add CHANGELOG.md
if git diff --cached --quiet; then
echo "No changelog changes to commit."
else
git commit -m "update CHANGELOG.md for v$EAS_CLI_VERSION"
git pull --rebase --autostash origin main
git push
fi
bump-cli-versions:
name: Gate on npm and bump cli-versions.json
runs-on: ubuntu-latest
-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
+5
View File
@@ -9,9 +9,13 @@ on:
branches: [main]
paths:
- 'scripts/**'
- '.changeset/*.mjs'
- '.changeset/*.d.mts'
pull_request:
paths:
- 'scripts/**'
- '.changeset/*.mjs'
- '.changeset/*.d.mts'
concurrency:
group: test-scripts-${{ github.ref }}
@@ -26,3 +30,4 @@ jobs:
uses: ./.github/actions/setup-mise
- run: yarn install --immutable
- run: yarn typecheck
- run: yarn test
-2
View File
@@ -71,8 +71,6 @@ jobs:
if: ${{ matrix.coverage }}
with:
token: ${{ secrets.CODECOV_TOKEN }}
- if: ${{ matrix.coverage }}
run: yarn lint-changelog
notify-slack:
runs-on: ubuntu-latest
+3 -13
View File
@@ -1,10 +1,6 @@
on:
workflow_dispatch:
inputs:
version:
type: string
required: false
description: 'Version number for the release'
dry_run:
type: boolean
required: true
@@ -19,7 +15,6 @@ jobs:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
INPUT_VERSION: ${{ github.event.inputs.version }}
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@@ -29,7 +24,7 @@ jobs:
token: ${{ secrets.EXPO_BOT_PAT }}
- uses: ./.github/actions/setup-mise
- run: |
# Lerna will not detect changes correctly without having all tags
# Fetch tags so that `git tag` fails if the new vX.Y.Z tag already exists
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
# Checkout the main branch
git checkout main
@@ -37,10 +32,5 @@ jobs:
git config --global user.name "Expo CI"
yarn install --immutable
yarn build
if [[ "$INPUT_VERSION" == "" ]]; then
echo "Releasing with default version"
yarn release
else
echo "Releasing with version $INPUT_VERSION"
yarn release "$INPUT_VERSION"
fi
# The changesets in .changeset/ set the version bump.
yarn release
+250 -4
View File
@@ -2,10 +2,258 @@
This is the log of notable changes to EAS CLI and related packages.
## main
## [27.4.1](https://github.com/expo/eas-cli/releases/tag/v27.4.1) - 2026-11-04
### 🐛 Bug fixes
- [simulator-launcher][build-tools] Wait for a starting warm Simulator launcher before claiming its session, instead of falling back to a cold start. ([#4810](https://github.com/expo/eas-cli/pull/4810) by [@gwdp](https://github.com/gwdp))
## [27.4.0](https://github.com/expo/eas-cli/releases/tag/v27.4.0) - 2026-11-03
### 🎉 New features
- [simulator-launcher][build-tools] Add `eas/start_warm_appium_remote_session`, which runs the Appium session on the warm Simulator, or starts cold. An `appium` claim stops the unused tools first, so WDA does not share the Simulator with the agent-device runner. ([#4735](https://github.com/expo/eas-cli/pull/4735) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add warm iOS session steps (`eas/start_warm_serve_sim_remote_session`, `eas/start_warm_agent_device_session`, `eas/start_warm_argent_remote_session`). They run the existing session on the warm Simulator, or start cold. `eas/start_agent_device_session` also accepts `install_app_path` and `launch_app_identifier`. ([#4723](https://github.com/expo/eas-cli/pull/4723) by [@gwdp](https://github.com/gwdp))
- [build-tools] Update the app version and build number from the final application archive, so the build shows the values the native build resolved. ([#3499](https://github.com/expo/eas-cli/pull/3499) by [@kadikraman](https://github.com/kadikraman))
### 🐛 Bug fixes
- [build-tools] Upload the final agent-device session artifacts before stopping the daemon, including sessions that reuse a warm Simulator. ([#4750](https://github.com/expo/eas-cli/pull/4750) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add internal helpers that claim the warm Simulator through the launcher and adopt it on the warm serve-sim. Skip the Expo Go install when the warm Simulator already has the same Expo Go version. ([#4724](https://github.com/expo/eas-cli/pull/4724) by [@gwdp](https://github.com/gwdp))
- [build-tools] Recover warm Simulator recordings when their MP4 exists before the shutdown manifest is ready. ([#4789](https://github.com/expo/eas-cli/pull/4789) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Show the Expo Simulators hint after a successful build from November 10 instead of November 5. ([#4801](https://github.com/expo/eas-cli/pull/4801) by [@williamgrosset](https://github.com/williamgrosset))
### 🧹 Chores
- [build-tools] Move iOS Simulator startup behind a shared session host contract. ([#4752](https://github.com/expo/eas-cli/pull/4752) by [@gwdp](https://github.com/gwdp))
## [27.3.0](https://github.com/expo/eas-cli/releases/tag/v27.3.0) - 2026-11-02
### 🎉 New features
- [eas-build-job] Add optional Observe error context to workflow jobs. ([#4787](https://github.com/expo/eas-cli/pull/4787) by [@AbbanMustafa](https://github.com/AbbanMustafa))
### 🐛 Bug fixes
- [eas-cli] Bump `@expo/apple-utils` to `2.3.0` to fix iOS capability sync when App Store Connect rejects enabling, disabling or changing a capability. ([#4790](https://github.com/expo/eas-cli/pull/4790) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Keep already linked App Groups, Merchant IDs and iCloud containers when capability sync links another identifier. ([#4584](https://github.com/expo/eas-cli/pull/4584) by [@ceydaakin](https://github.com/ceydaakin))
- [worker] Mask secrets in every record the worker process writes to its stdout: the build log records and the worker's own records. ([#4782](https://github.com/expo/eas-cli/pull/4782) by [@hSATAC](https://github.com/hSATAC))
- [worker][build-tools] Stop warmup in the background for jobs that do not use the warm Simulator, so job setup does not wait for it. Before build-tools boots or clones a Simulator, it waits until the launcher's state file records that cleanup finished, and fails if cleanup does not finish in time. ([#4721](https://github.com/expo/eas-cli/pull/4721) by [@gwdp](https://github.com/gwdp))
- [build-tools] Recover unhealthy device-session tunnels without changing their URLs and block further traffic when shutdown starts, even if ngrok cleanup times out. ([#4526](https://github.com/expo/eas-cli/pull/4526) by [@gwdp](https://github.com/gwdp))
- [build-tools] Stop sending the SSH host when registering an SSH session. ([#4784](https://github.com/expo/eas-cli/pull/4784) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## [27.2.0](https://github.com/expo/eas-cli/releases/tag/v27.2.0) - 2026-10-29
### 🎉 New features
- [build-tools] Use single-device previews when the session already has a valid device ID. ([#4733](https://github.com/expo/eas-cli/pull/4733) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add an experiment that suggests Expo Update to coding agents after a build when the Free plan account is within 5 builds of the platform limit. ([#4758](https://github.com/expo/eas-cli/pull/4758) by [@williamgrosset](https://github.com/williamgrosset))
- [eas-cli] Add `eas observe:traces` to list request trace spans (or a summary of span names and counts), `eas observe:trace` to display one trace as a span tree, and span rows in the `eas observe:session` timeline. ([#4772](https://github.com/expo/eas-cli/pull/4772) by [@kadikraman](https://github.com/kadikraman))
- [simulator-launcher] Accept a comma-separated list of warm Simulators in `EXPO_SIMULATOR_DEVICE`; the first is the default. One serve-sim serves every listed device on one port. After the default device is ready, warmup preinstalls the latest stable Expo Go from one download and tries to disable apsd on the other devices, until a job claims one. A claim can select any listed device and reports its preinstalled Expo Go. ([#4764](https://github.com/expo/eas-cli/pull/4764) by [@gwdp](https://github.com/gwdp))
- [simulator-launcher] Add `@expo/simulator-launcher`, which warms an iOS Simulator at VM boot and hands it to one EAS job (`start`, `claim`, `prepare-tools`, `stop`). ([#4722](https://github.com/expo/eas-cli/pull/4722) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Show a one-time Expo Simulators hint after a successful build. ([#4768](https://github.com/expo/eas-cli/pull/4768) by [@FiberJW](https://github.com/FiberJW))
- [vm-tools] Add a macOS startup script loader that runs `/usr/local/share/vm-tools/startup.sh` once per VM boot. ([#4725](https://github.com/expo/eas-cli/pull/4725) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [build-tools] Explain missing background task identifiers reported by App Store Connect. ([#3465](https://github.com/expo/eas-cli/pull/3465) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Explain App Store Connect NOT_AUTHORIZED responses in the shared API client. ([#3462](https://github.com/expo/eas-cli/pull/3462) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Explain App Store Connect UNEXPECTED_ERROR responses in the shared API client. ([#3467](https://github.com/expo/eas-cli/pull/3467) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Explain missing app icons reported by App Store Connect, including codes 90022 and 90023, while preserving Apple’s validation details. ([#3466](https://github.com/expo/eas-cli/pull/3466) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] List failed Google Play commit attempts in order, including network failures, when the outcome is uncertain. ([#4660](https://github.com/expo/eas-cli/pull/4660) by [@sjchmiela](https://github.com/sjchmiela))
- [worker] Run package-manager version queries in parallel during worker preparation. ([#4710](https://github.com/expo/eas-cli/pull/4710) by [@gwdp](https://github.com/gwdp))
- [worker] Start the Xcode version check in the background during worker tool preparation. Apply the Xcode 26.0 upload workaround when the query finishes, without delaying job startup. ([#4707](https://github.com/expo/eas-cli/pull/4707) by [@gwdp](https://github.com/gwdp))
- [build-tools] Fix a five-minute cleanup delay when a device session is canceled during startup and its screen recorder fails before connecting. ([#4761](https://github.com/expo/eas-cli/pull/4761) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Remove the Free plan build limit guidance experiment for coding agents from `eas build`. ([#4757](https://github.com/expo/eas-cli/pull/4757) by [@williamgrosset](https://github.com/williamgrosset))
- [worker] Allow jobs to skip installing the CocoaPods cache proxy with EAS_BUILD_DISABLE_COCOAPODS_CACHE_PLUGIN=1. ([#4709](https://github.com/expo/eas-cli/pull/4709) by [@gwdp](https://github.com/gwdp))
- [build-tools] Handle TURN requests for a stopped device session without reporting a warning. ([#4762](https://github.com/expo/eas-cli/pull/4762) by [@gwdp](https://github.com/gwdp))
- [build-tools] Preserve indented and incomplete Xcode compiler errors in formatted build logs, including the final diagnostic when logging stops. ([#4365](https://github.com/expo/eas-cli/pull/4365) by [@sjchmiela](https://github.com/sjchmiela))
### 🧹 Chores
- [simulator-launcher] Add the state, request command and service helpers for the warm Simulator launcher. The package stays private until #4722 adds its CLI entry point. ([#4766](https://github.com/expo/eas-cli/pull/4766) by [@gwdp](https://github.com/gwdp))
## [27.1.0](https://github.com/expo/eas-cli/releases/tag/v27.1.0) - 2026-10-28
### 🎉 New features
- [eas-cli] Display queued workflow runs: `workflow:run --wait` and `workflow:status` show a run waiting for its concurrency group and the run blocking it, and `workflow:cancel` includes queued runs in its selection list. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Upload the workflow files that `uses` jobs reference, including nested ones, when running a local workflow file with `eas workflow:run`. ([#4769](https://github.com/expo/eas-cli/pull/4769) by [@sswrk](https://github.com/sswrk))
### 🐛 Bug fixes
- [build-tools] Explain App Store Connect error 90189 as a duplicate build number. ([#3461](https://github.com/expo/eas-cli/pull/3461) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Fail the command when EAS CLI cannot pull environment variables from EAS servers, instead of continuing without them. ([#4774](https://github.com/expo/eas-cli/pull/4774) by [@wschurman](https://github.com/wschurman))
- [eas-cli] Format the generated root changelog before committing a release. ([#4773](https://github.com/expo/eas-cli/pull/4773) by [@gwdp](https://github.com/gwdp))
- [build-tools][worker] Put the `NPM_TOKEN` registry credential in the builder's npm user config on EAS Build (`~/.npmrc`, or the file `NPM_CONFIG_USERCONFIG` names), where pnpm 10.34.2+ and 11.5.3+ still expand `${NPM_TOKEN}` (they ignore it in a project-level `.npmrc`). Explain what to change when the project's pnpm version needs a newer Node.js than the build runs, including when eas.json sets `node` or `pnpm`. ([#4729](https://github.com/expo/eas-cli/pull/4729) by [@hSATAC](https://github.com/hSATAC))
- [build-tools] Install `@expo/repack-app` for the `eas/repack` step with npm in a sandbox that sees only `PATH` and its own `HOME`, so the build machine's npm and Yarn configuration cannot break the install, fetch it through the EAS npm cache when the job uses one, and show the installer output in the build log. The step no longer looks the version up separately before installing. When the npm cache fails, the install retries against the public registry like the project install does; the `eas/run_agent` agent CLI install shares the same helper and gains the same retry. ([#4728](https://github.com/expo/eas-cli/pull/4728) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Honor the `--non-interactive` flag in `workflow:cancel`; it was parsed but never applied, so automation could hang on the interactive picker. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Stop reporting a finish time for workflow runs that have not finished: `workflow:runs` reports `finishedAt` only for runs in a final status. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
## [27.0.0](https://github.com/expo/eas-cli/releases/tag/v27.0.0) - 2026-10-27
### 🛠 Breaking changes
- [eas-cli] Write the reason a command failed to stderr, next to the `command failed.` line, instead of to stdout. A program that reads the reason from stdout must read stderr instead. ([#4592](https://github.com/expo/eas-cli/pull/4592) by [@vonovak](https://github.com/vonovak))
### 🎉 New features
- [eas-cli] Send the `permissions.account.role` from the build profile to request a role for the build's `EXPO_TOKEN`. ([#4734](https://github.com/expo/eas-cli/pull/4734) by [@wschurman](https://github.com/wschurman))
- [eas-cli] Add `simulator:logs` to read and follow logs from an iOS simulator session and download saved logs after it stops. ([#4545](https://github.com/expo/eas-cli/pull/4545) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [build-tools] Keep the launched app open when an Appium iOS session starts. ([#4699](https://github.com/expo/eas-cli/pull/4699) by [@gwdp](https://github.com/gwdp))
- [build-tools] Run Appium through the prepared Full Disk Access helper when available on macOS. ([#4622](https://github.com/expo/eas-cli/pull/4622) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Show permission guidance, including `permissions.account.role` in eas.json, when a build request fails with an authorization error. ([#4760](https://github.com/expo/eas-cli/pull/4760) by [@wschurman](https://github.com/wschurman))
- [eas-cli] Print the notice about eas-cli in project dependencies only when stderr is a terminal, so programs that read stderr get the command's error. ([#4591](https://github.com/expo/eas-cli/pull/4591) by [@vonovak](https://github.com/vonovak))
- [eas-cli] Show account roles and credential permission limits in `eas login` when you are already logged in. ([#4759](https://github.com/expo/eas-cli/pull/4759) by [@wschurman](https://github.com/wschurman))
- [build-tools] Identify the build or application archive when a download fails, and explain when the archive is no longer available. ([#4703](https://github.com/expo/eas-cli/pull/4703) by [@gwdp](https://github.com/gwdp))
- [build-tools] Run apsd setup in the background without delaying iOS app launch or preview readiness. ([#4706](https://github.com/expo/eas-cli/pull/4706) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Fix simulator control action help routing and show help when a flag is incomplete. ([#4681](https://github.com/expo/eas-cli/pull/4681) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Require explicit `host:` simulator-host upload paths and report missing local files. ([#4683](https://github.com/expo/eas-cli/pull/4683) by [@gwdp](https://github.com/gwdp))
Missing local absolute paths now require the `host:` prefix. Actions that require upload IDs accept local files and reject host paths with an explanation.
- [build-tools] Handle a device session stopped during startup as normal cleanup instead of reporting a startup error. ([#4716](https://github.com/expo/eas-cli/pull/4716) by [@gwdp](https://github.com/gwdp))
- [build-tools] Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives. ([#4731](https://github.com/expo/eas-cli/pull/4731) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] Run the GraphQL schema and generated code check on pull requests to any base branch, including stacked pull requests. ([#4763](https://github.com/expo/eas-cli/pull/4763) by [@wschurman](https://github.com/wschurman))
## [26.0.0](https://github.com/expo/eas-cli/releases/tag/v26.0.0) - 2026-10-26
### 🛠 Breaking changes
- [eas-build-job][build-tools] Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports. ([#4719](https://github.com/expo/eas-cli/pull/4719) by [@sjchmiela](https://github.com/sjchmiela))
### 🎉 New features
- [eas-build-job][build-tools] Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions. ([#4696](https://github.com/expo/eas-cli/pull/4696) by [@brentvatne](https://github.com/brentvatne))
- [eas-json] Add `permissions.account.role` to build profiles to request a role for the build's `EXPO_TOKEN`. ([#4732](https://github.com/expo/eas-cli/pull/4732) by [@wschurman](https://github.com/wschurman))
- [build-tools] Stop sandboxes after an optional maximum duration measured from readiness. ([#4702](https://github.com/expo/eas-cli/pull/4702) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Unify signed URL uploading to a single implementation and use it everywhere. ([#4712](https://github.com/expo/eas-cli/pull/4712) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### 🐛 Bug fixes
- [eas-cli] Regenerate graphql code. ([#4692](https://github.com/expo/eas-cli/pull/4692) by [@wschurman](https://github.com/wschurman))
- [build-tools] Default Android emulators to 4 GiB of RAM to reduce ADB disconnects during large transfers. Retain the 2 GiB default for API 34 and below outside interactive device sessions, and preserve explicit emulator configuration overrides. ([#4727](https://github.com/expo/eas-cli/pull/4727) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Retry transient failures when reporting that a device run session has started. ([#4680](https://github.com/expo/eas-cli/pull/4680) by [@gwdp](https://github.com/gwdp))
- [build-tools] Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log. ([#4690](https://github.com/expo/eas-cli/pull/4690) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Read the app config fewer times in `eas build` and other project commands. An iOS build now runs `expo config` 2 times instead of 6. ([`78ba760`](https://github.com/expo/eas-cli/commit/78ba76001b67c9971236af1a1d02e46c2f82d24a) by [@vonovak](https://github.com/vonovak))
- [build-tools][steps] Stop printing workflow hook parsing messages in build logs: native builds showed them under an "Unknown build phase" group, and custom builds warned about unknown hook keys and hooks on steps that never run. ([#4717](https://github.com/expo/eas-cli/pull/4717) by [@hSATAC](https://github.com/hSATAC))
- [worker] Retry Node.js installation without the internal Node.js cache mirror when the first attempt fails, while preserving other configured mirrors. ([#3452](https://github.com/expo/eas-cli/pull/3452) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Share one GraphQL client between the project ID check and the command, so commands send one fewer app query. ([#4646](https://github.com/expo/eas-cli/pull/4646) by [@vonovak](https://github.com/vonovak))
- [eas-cli] Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs. ([#4694](https://github.com/expo/eas-cli/pull/4694) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status. ([#4693](https://github.com/expo/eas-cli/pull/4693) by [@brentvatne](https://github.com/brentvatne))
- [worker] Mask secrets in every field of uploaded job log records, such as logged errors, not only in log messages. ([#4698](https://github.com/expo/eas-cli/pull/4698) by [@brentvatne](https://github.com/brentvatne))
### 🧹 Chores
- [eas-cli] Make command descriptions in `eas --help` consistently lowercase without trailing periods. ([#4385](https://github.com/expo/eas-cli/pull/4385) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli][build-tools] Bump Node.js to 22.23.4 to fix the CVEs in the [October 2026 Node.js security releases](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases). ([#4714](https://github.com/expo/eas-cli/pull/4714) by [@ramonclaudio](https://github.com/ramonclaudio))
## [25.3.0](https://github.com/expo/eas-cli/releases/tag/v25.3.0) - 2026-10-20
### 🎉 New features
- [eas-cli] Add a Free plan build limit checkout experiment to `eas build` that offers to open the Starter plan checkout. ([#4633](https://github.com/expo/eas-cli/pull/4633) by [@williamgrosset](https://github.com/williamgrosset))
- [build-tools] Let serve-sim boot, install and launch Argent iOS sessions. ([#4602](https://github.com/expo/eas-cli/pull/4602) by [@gwdp](https://github.com/gwdp))
- [build-tools] Let serve-sim boot, install and launch Appium iOS sessions. ([#4603](https://github.com/expo/eas-cli/pull/4603) by [@gwdp](https://github.com/gwdp))
- [build-tools] Upload the screen recording session manifest as a JSON artifact alongside each video. ([#4689](https://github.com/expo/eas-cli/pull/4689) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add a `--system-image` flag to `eas simulator:start` to select the Android SDK system image for the emulator. ([#4500](https://github.com/expo/eas-cli/pull/4500) by [@krystofwoldrich](https://github.com/krystofwoldrich))
### 🐛 Bug fixes
- [build-tools] Quote Android launch arguments and URLs for the device shell in simulator sessions, so values with spaces or shell characters reach `am start` as one argument; pass raw values and remove any shell quoting added as a workaround. ([#4695](https://github.com/expo/eas-cli/pull/4695) by [@brentvatne](https://github.com/brentvatne))
- [build-tools] Wait up to five minutes for Google Play to finish processing an uploaded bundle before giving up on the edit commit. ([#4700](https://github.com/expo/eas-cli/pull/4700) by [@hSATAC](https://github.com/hSATAC))
- [build-tools] Reset the device run session idle timer on iOS Simulator preview input. ([#4691](https://github.com/expo/eas-cli/pull/4691) by [@gwdp](https://github.com/gwdp))
- [build-tools] Forward captured Simulator traffic through the local egress proxy. ([#4598](https://github.com/expo/eas-cli/pull/4598) by [@gwdp](https://github.com/gwdp))
## [25.2.0](https://github.com/expo/eas-cli/releases/tag/v25.2.0) - 2026-10-17
### 🎉 New features
- [eas-cli] Add experimentation client. ([#4558](https://github.com/expo/eas-cli/pull/4558) by [@williamgrosset](https://github.com/williamgrosset))
- [eas-cli] Add a Free plan build limit guidance experiment for coding agents to `eas build`. ([#4631](https://github.com/expo/eas-cli/pull/4631) by [@williamgrosset](https://github.com/williamgrosset))
- [build-tools] Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard. ([#4544](https://github.com/expo/eas-cli/pull/4544) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas simulator:control` to run serve-sim actions, with file upload and download, on a remote iOS simulator session. ([#4641](https://github.com/expo/eas-cli/pull/4641) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] Prepare authenticated simulator action transport and file transfers. ([#4642](https://github.com/expo/eas-cli/pull/4642) by [@gwdp](https://github.com/gwdp))
## [25.1.1](https://github.com/expo/eas-cli/releases/tag/v25.1.1) - 2026-10-16
### 🐛 Bug fixes
- [build-tools] Call www only through the device run session worker API in device run session jobs, and remove their GraphQL fallbacks. ([#4638](https://github.com/expo/eas-cli/pull/4638) by [@szdziedzic](https://github.com/szdziedzic))
## [25.1.0](https://github.com/expo/eas-cli/releases/tag/v25.1.0) - 2026-10-16
### 🎉 New features
- [eas-cli] Show EAS Simulator minutes in `eas account:usage`. ([#4583](https://github.com/expo/eas-cli/pull/4583) by [@LipLoody](https://github.com/LipLoody))
- [build-tools] Pass the `cors_origins` session input to serve-sim and expo-device-hub as `--cors-origin` origins, so a third party page can call the preview host API, for example with `@expo/hub-client`. ([#4657](https://github.com/expo/eas-cli/pull/4657) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [build-tools] Pass the `frame_ancestors` session input to serve-sim and expo-device-hub as `--frame-ancestor` origins, so a third party can embed the preview host. ([#4655](https://github.com/expo/eas-cli/pull/4655) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [eas-cli] Add `--cors-origin` to `eas simulator:start`, so a third party page can call the simulator preview API, for example with `@expo/hub-client`. ([#4658](https://github.com/expo/eas-cli/pull/4658) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [eas-cli] Add `--frame-ancestor` to `eas simulator:start`, so a third party site can embed the simulator preview, and print the embeddable preview URL. ([#4656](https://github.com/expo/eas-cli/pull/4656) by [@krystofwoldrich](https://github.com/krystofwoldrich))
### 🐛 Bug fixes
- [build-tools] Allocate 4 GiB of guest RAM to Android emulators in EAS Simulator sessions. ([#4640](https://github.com/expo/eas-cli/pull/4640) by [@gwdp](https://github.com/gwdp))
- [build-tools] Retry transient Google Play API failures with exponential backoff while keeping uncertain commit results explicit. ([#4629](https://github.com/expo/eas-cli/pull/4629) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Retry Google Play edit commits when an uploaded Android App Bundle is still processing, with bounded retries and progress warnings. ([#4651](https://github.com/expo/eas-cli/pull/4651) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Report Google Play error responses to Sentry so unexplained submission failures can be classified. ([#4652](https://github.com/expo/eas-cli/pull/4652) by [@sjchmiela](https://github.com/sjchmiela))
## [25.0.0](https://github.com/expo/eas-cli/releases/tag/v25.0.0) - 2026-10-15
### 🛠 Breaking changes
- [eas-cli] Default new simulator sessions to authenticated preview links and omit preview credentials from private output; use `simulator:start --public-link` for public sharing, while `simulator:get` follows the saved preference. ([#4619](https://github.com/expo/eas-cli/pull/4619) by [@gwdp](https://github.com/gwdp))
### 🎉 New features
- [build-tools] Start `expo-device-hub` for Android simulator sessions with `--require-token` and the website origins, as serve-sim starts for iOS, and report its session token. ([#4578](https://github.com/expo/eas-cli/pull/4578) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [build-tools] Respect saved sharing preferences for iOS simulator preview sessions. ([#4620](https://github.com/expo/eas-cli/pull/4620) by [@gwdp](https://github.com/gwdp))
- [build-tools] Give agent-device and its simulator preview separate session logs. ([#4585](https://github.com/expo/eas-cli/pull/4585) by [@gwdp](https://github.com/gwdp))
- [build-tools] Give Appium and its simulator preview separate session logs. ([#4586](https://github.com/expo/eas-cli/pull/4586) by [@gwdp](https://github.com/gwdp))
- [build-tools] Give Argent and its simulator preview separate session logs. ([#4587](https://github.com/expo/eas-cli/pull/4587) by [@gwdp](https://github.com/gwdp))
- [build-tools] Let serve-sim start the iOS Simulator for web-preview sessions. ([#4601](https://github.com/expo/eas-cli/pull/4601) by [@gwdp](https://github.com/gwdp))
- [build-tools] Stream simulator preview output into its existing session step. ([#4525](https://github.com/expo/eas-cli/pull/4525) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Use effectivePermissions for logic about capabilities and account:view display. ([#4637](https://github.com/expo/eas-cli/pull/4637) by [@wschurman](https://github.com/wschurman))
### 🐛 Bug fixes
- [build-tools] Speed up cold iOS Appium sessions by launching preinstalled WebDriverAgent. ([#4618](https://github.com/expo/eas-cli/pull/4618) by [@gwdp](https://github.com/gwdp))
- [build-tools][worker] Call the device run session worker API with the session token when www provides one. ([#4615](https://github.com/expo/eas-cli/pull/4615) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools][eas-json] Clarify Google Play submission messages and reject zero Android submission rollouts. ([#4625](https://github.com/expo/eas-cli/pull/4625) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Update error message for scoped VC in EAS CLI now that scoped can be used outside build. ([#4636](https://github.com/expo/eas-cli/pull/4636) by [@wschurman](https://github.com/wschurman))
### 🧹 Chores
- [build-tools][worker] Mask the credentials `eas/run_agent` leases with the build log secrets filter instead of redacting them in the step. ([#4624](https://github.com/expo/eas-cli/pull/4624) by [@hSATAC](https://github.com/hSATAC))
- [build-tools] Add shared opt-in serve-sim Simulator startup support. ([#4605](https://github.com/expo/eas-cli/pull/4605) by [@gwdp](https://github.com/gwdp))
## [24.14.0](https://github.com/expo/eas-cli/releases/tag/v24.14.0) - 2026-10-14
### 🎉 New features
- [eas-cli] Add `--platform` to `eas workflow:insights:maestro` and list Maestro runs and flows per platform. ([#4613](https://github.com/expo/eas-cli/pull/4613) by [@hSATAC](https://github.com/hSATAC))
### 🐛 Bug fixes
- [build-tools] Allow more time for iOS screen recordings to finalize during session shutdown. ([#4626](https://github.com/expo/eas-cli/pull/4626) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] Fix the `channel:protect` and `channel:unprotect` descriptions to include Release Managers and Owners, not only admins. ([#4616](https://github.com/expo/eas-cli/pull/4616) by [@byronkarlen](https://github.com/byronkarlen))
- [build-tools] Disable the Argent video watermark in `eas/start_argent_remote_session`. ([#4574](https://github.com/expo/eas-cli/pull/4574) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Download the upterm binary through the build cache proxy. ([#4442](https://github.com/expo/eas-cli/pull/4442) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## [24.13.0](https://github.com/expo/eas-cli/releases/tag/v24.13.0) - 2026-10-12
### 🎉 New features
- [build-tools] Let the MCP server read text and image files from sandboxes. ([#4555](https://github.com/expo/eas-cli/pull/4555) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
@@ -26,8 +274,6 @@ This is the log of notable changes to EAS CLI and related packages.
- [build-tools] Install ffmpeg for Appium simulator sessions. ([#4577](https://github.com/expo/eas-cli/pull/4577) by [@gwdp](https://github.com/gwdp))
- [build-tools] Close the file of an artifact upload before reporting that the upload is done. ([#4609](https://github.com/expo/eas-cli/pull/4609) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### 🧹 Chores
## [24.12.1](https://github.com/expo/eas-cli/releases/tag/v24.12.1) - 2026-10-08
### 🐛 Bug fixes
@@ -48,6 +294,7 @@ This is the log of notable changes to EAS CLI and related packages.
- [build-tools] Improve the embedded bundle upload output in build logs. ([#4557](https://github.com/expo/eas-cli/pull/4557) by [@gwdp](https://github.com/gwdp))
- [build-tools] Allow the unnotarized mitmproxy cask to run in iOS network capture sessions. ([#4556](https://github.com/expo/eas-cli/pull/4556) by [@gwdp](https://github.com/gwdp))
- [build-tools] Encode macOS simulator session previews with cwebp, because Homebrew's FFmpeg cannot encode WebP. ([#4549](https://github.com/expo/eas-cli/pull/4549) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Open Android URLs with a VIEW intent instead of pinning them to the launch activity. ([#4559](https://github.com/expo/eas-cli/pull/4559) by [@kudo](https://github.com/kudo))
### 🧹 Chores
@@ -255,7 +502,6 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 Bug fixes
- [build-tools] Report a system error when project source refresh fails for a remote build. ([#4359](https://github.com/expo/eas-cli/pull/4359) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Remove the obsolete `appVersion` runtime policy warning that referenced SDK 46 and expo-updates 0.14.4. ([#4375](https://github.com/expo/eas-cli/pull/4375) by [@brentvatne](https://github.com/brentvatne))
## [24.0.0](https://github.com/expo/eas-cli/releases/tag/v24.0.0) - 2026-09-09
+8 -7
View File
@@ -215,7 +215,7 @@ if: ${{ steps.previous_step.outputs.success == 'true' && env.ENVIRONMENT == 'pro
Artifacts tracked as `ArtifactToUpload`:
- Managed artifacts (APK, IPA, AAB) with specific handling
- Generic artifacts for any file
- Workflow artifacts for any file
- Upload via `ctx.uploadArtifact()` or `upload-artifact` step
## Package Interdependencies
@@ -331,12 +331,13 @@ When creating pull requests, make sure to adhere to [PULL_REQUEST_TEMPLATE](./.g
## Release Process
1. Update `CHANGELOG.md` in the appropriate package
2. Version bump is automated based on changelog:
- Breaking changes → MAJOR
- New features → MINOR
- Otherwise → PATCH
3. GitHub Actions workflow handles release automation
1. Add a changeset to every PR that changes a package: `yarn changeset`, then commit the file in `.changeset/`. Never edit `CHANGELOG.md` files; the release generates them. See `.changeset/README.md`
2. All packages share one version. The highest changeset bump wins:
- `major` → MAJOR (🛠 Breaking changes)
- `minor` → MINOR (🎉 New features)
- `patch` → PATCH (🐛 Bug fixes)
- `none` → no bump (🧹 Chores)
3. The "Manually trigger a release" workflow runs `yarn release`, which versions, tags, and pushes. See `RELEASING.md`
4. Notifications sent to Slack #eas-cli channel
## Licensing
+1 -1
View File
@@ -48,7 +48,7 @@ yarn start-allow-unused
## Format
- Be sure to update the [`CHANGELOG.md`](./CHANGELOG.md) with changes for every PR. There is a changelog bot that can generate the proper entry for you. The instructions on how to use it are in the PR description placeholder.
- Add a changeset for every PR that changes a package: run `yarn changeset` and commit the new file. Do not edit `CHANGELOG.md`; the release generates it. See [`.changeset/README.md`](./.changeset/README.md).
- End `async` functions with `Async` like `runAsync`. This is just how we format functions at Expo.
- Utilize the unified `Log` module instead of `console.log`.
+26 -7
View File
@@ -1,7 +1,8 @@
# Releasing EAS CLI
1. Invoke the GitHub workflow ["Manually trigger a release"](https://github.com/expo/eas-cli/actions/workflows/trigger-release.yml). The next version is chosen automatically based on the changelog entries. If you want to use different version, pass the version string as an input to the workflow, in semver format, e.g. "1.2.3"
2. That's it! GitHub Actions is going to take care of the rest. Watch the #eas-cli Slack channel for a successful release notification.
1. Invoke the GitHub workflow ["Manually trigger a release"](https://github.com/expo/eas-cli/actions/workflows/trigger-release.yml). Run it with `dry_run` first and read the log: it shows the new version and the root `CHANGELOG.md` section.
2. Run it again with `dry_run` unchecked. The workflow consumes the changesets in `.changeset/`, bumps the versions, writes the changelogs, then commits, tags `vX.Y.Z`, and pushes to `main`.
3. That's it! GitHub Actions is going to take care of the rest. Watch the #eas-cli Slack channel for a successful release notification.
The release also sets the `STAGING` and `PRODUCTION` versions in `cli-versions.json`. See [EAS Build CLI versions](#eas-build-cli-versions).
@@ -18,10 +19,28 @@ A release updates `STAGING` and `PRODUCTION` to the new version automatically. T
## Choosing the next version
As stated above, the next EAS CLI version is chosen automatically based on the existing changelog entries under the unreleased section in **CHANGELOG.md**.
The changesets in `.changeset/` choose the next version. All packages share one version (a changesets `fixed` group), and the highest bump in all changesets wins:
The algorithm works as follows:
- If there are any entries in the "🛠 Breaking changes" section, bump the MAJOR version.
- Otherwise, if there are any entries in the "🎉 New features", bump the MINOR version.
- If any changeset has a `major` bump, bump the MAJOR version.
- Otherwise, if any changeset has a `minor` bump, bump the MINOR version.
- Otherwise, bump the PATCH version.
A release fails if there are no changesets, or if all of them are `none` or empty. To force a bump, merge a changeset with that bump type. See [`.changeset/README.md`](./.changeset/README.md) for how to write a changeset.
## What a release writes
`yarn release` (`scripts/src/versionPackages.ts`) does these steps:
1. Reads the changesets and computes the new version.
2. Runs `changeset version`. This bumps every `package.json`, writes each package's `CHANGELOG.md`, and deletes the changesets.
3. Sets the version in `lerna.json`, updates `yarn.lock`, and regenerates `packages/eas-cli/README.md`.
4. Adds the new section to the root `CHANGELOG.md`. The GitHub release and the Slack message use this section.
5. Commits `vX.Y.Z`, tags it, and pushes. A dry run stops before this step.
The tag push starts the [release workflow](./.github/workflows/release.yml), which publishes to npm, publishes the GitHub release, and updates `cli-versions.json`.
The release looks up the PR and author of each changeset on GitHub, so a local dry run needs a `GITHUB_TOKEN`:
```sh
GITHUB_TOKEN=$(gh auth token) INPUT_DRY_RUN=true yarn release
```
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "24.12.1",
"PRODUCTION": "24.12.1"
"STAGING": "27.4.1",
"PRODUCTION": "27.4.1"
}
+2 -2
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "24.13.0",
"version": "27.4.1",
"$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.4"
+5 -2
View File
@@ -7,6 +7,7 @@
"scripts": {
"build": "lerna run build",
"typecheck": "lerna run typecheck",
"verify-graphql-code": "lerna run verify-graphql-code --concurrency 1",
"start": "lerna run watch --stream --parallel",
"start-allow-unused": "lerna run watch-allow-unused --stream --parallel",
"watch": "yarn start",
@@ -15,12 +16,14 @@
"lint": "oxlint --type-aware --import-plugin --max-warnings=0 --tsconfig ./tsconfig.oxlint.json .",
"fmt": "oxfmt .",
"fmt:check": "oxfmt --check .",
"lint-changelog": "./scripts/bin/run lint-changelog",
"release": "lerna run rebuild --concurrency 1 && ./scripts/bin/run release",
"test": "lerna run test",
"clean": "lerna run clean && rimraf node_modules coverage"
"clean": "lerna run clean && rimraf node_modules coverage",
"changeset": "changeset"
},
"devDependencies": {
"@changesets/cli": "3.0.3",
"@changesets/get-github-info": "1.0.1",
"@types/jest": "29.5.11",
"@types/node": "22.20.5",
"eslint-plugin-async-protect": "3.1.0",
+143
View File
@@ -0,0 +1,143 @@
# @expo/build-tools
## 27.4.1
### Patch Changes
- Wait for a starting warm Simulator launcher before claiming its session, instead of falling back to a cold start. ([#4810](https://github.com/expo/eas-cli/pull/4810) by [@gwdp](https://github.com/gwdp))
## 27.4.0
### Minor Changes
- Add `eas/start_warm_appium_remote_session`, which runs the Appium session on the warm Simulator, or starts cold. An `appium` claim stops the unused tools first, so WDA does not share the Simulator with the agent-device runner. ([#4735](https://github.com/expo/eas-cli/pull/4735) by [@gwdp](https://github.com/gwdp))
- Add warm iOS session steps (`eas/start_warm_serve_sim_remote_session`, `eas/start_warm_agent_device_session`, `eas/start_warm_argent_remote_session`). They run the existing session on the warm Simulator, or start cold. `eas/start_agent_device_session` also accepts `install_app_path` and `launch_app_identifier`. ([#4723](https://github.com/expo/eas-cli/pull/4723) by [@gwdp](https://github.com/gwdp))
- Update the app version and build number from the final application archive, so the build shows the values the native build resolved. ([#3499](https://github.com/expo/eas-cli/pull/3499) by [@kadikraman](https://github.com/kadikraman))
### Patch Changes
- Upload the final agent-device session artifacts before stopping the daemon, including sessions that reuse a warm Simulator. ([#4750](https://github.com/expo/eas-cli/pull/4750) by [@gwdp](https://github.com/gwdp))
- Add internal helpers that claim the warm Simulator through the launcher and adopt it on the warm serve-sim. Skip the Expo Go install when the warm Simulator already has the same Expo Go version. ([#4724](https://github.com/expo/eas-cli/pull/4724) by [@gwdp](https://github.com/gwdp))
- Recover warm Simulator recordings when their MP4 exists before the shutdown manifest is ready. ([#4789](https://github.com/expo/eas-cli/pull/4789) by [@gwdp](https://github.com/gwdp))
## 27.3.0
### Patch Changes
- Stop warmup in the background for jobs that do not use the warm Simulator, so job setup does not wait for it. Before build-tools boots or clones a Simulator, it waits until the launcher's state file records that cleanup finished, and fails if cleanup does not finish in time. ([#4721](https://github.com/expo/eas-cli/pull/4721) by [@gwdp](https://github.com/gwdp))
- Recover unhealthy device-session tunnels without changing their URLs and block further traffic when shutdown starts, even if ngrok cleanup times out. ([#4526](https://github.com/expo/eas-cli/pull/4526) by [@gwdp](https://github.com/gwdp))
- Stop sending the SSH host when registering an SSH session. ([#4784](https://github.com/expo/eas-cli/pull/4784) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## 27.2.0
### Minor Changes
- Use single-device previews when the session already has a valid device ID. ([#4733](https://github.com/expo/eas-cli/pull/4733) by [@gwdp](https://github.com/gwdp))
### Patch Changes
- Explain missing background task identifiers reported by App Store Connect. ([#3465](https://github.com/expo/eas-cli/pull/3465) by [@sjchmiela](https://github.com/sjchmiela))
- Explain App Store Connect NOT_AUTHORIZED responses in the shared API client. ([#3462](https://github.com/expo/eas-cli/pull/3462) by [@sjchmiela](https://github.com/sjchmiela))
- Explain App Store Connect UNEXPECTED_ERROR responses in the shared API client. ([#3467](https://github.com/expo/eas-cli/pull/3467) by [@sjchmiela](https://github.com/sjchmiela))
- Explain missing app icons reported by App Store Connect, including codes 90022 and 90023, while preserving Apple’s validation details. ([#3466](https://github.com/expo/eas-cli/pull/3466) by [@sjchmiela](https://github.com/sjchmiela))
- List failed Google Play commit attempts in order, including network failures, when the outcome is uncertain. ([#4660](https://github.com/expo/eas-cli/pull/4660) by [@sjchmiela](https://github.com/sjchmiela))
- Fix a five-minute cleanup delay when a device session is canceled during startup and its screen recorder fails before connecting. ([#4761](https://github.com/expo/eas-cli/pull/4761) by [@gwdp](https://github.com/gwdp))
- Handle TURN requests for a stopped device session without reporting a warning. ([#4762](https://github.com/expo/eas-cli/pull/4762) by [@gwdp](https://github.com/gwdp))
- Preserve indented and incomplete Xcode compiler errors in formatted build logs, including the final diagnostic when logging stops. ([#4365](https://github.com/expo/eas-cli/pull/4365) by [@sjchmiela](https://github.com/sjchmiela))
## 27.1.0
### Patch Changes
- Explain App Store Connect error 90189 as a duplicate build number. ([#3461](https://github.com/expo/eas-cli/pull/3461) by [@sjchmiela](https://github.com/sjchmiela))
- Put the `NPM_TOKEN` registry credential in the builder's npm user config on EAS Build (`~/.npmrc`, or the file `NPM_CONFIG_USERCONFIG` names), where pnpm 10.34.2+ and 11.5.3+ still expand `${NPM_TOKEN}` (they ignore it in a project-level `.npmrc`). Explain what to change when the project's pnpm version needs a newer Node.js than the build runs, including when eas.json sets `node` or `pnpm`. ([#4729](https://github.com/expo/eas-cli/pull/4729) by [@hSATAC](https://github.com/hSATAC))
- Install `@expo/repack-app` for the `eas/repack` step with npm in a sandbox that sees only `PATH` and its own `HOME`, so the build machine's npm and Yarn configuration cannot break the install, fetch it through the EAS npm cache when the job uses one, and show the installer output in the build log. The step no longer looks the version up separately before installing. When the npm cache fails, the install retries against the public registry like the project install does; the `eas/run_agent` agent CLI install shares the same helper and gains the same retry. ([#4728](https://github.com/expo/eas-cli/pull/4728) by [@hSATAC](https://github.com/hSATAC))
## 27.0.0
### Patch Changes
- Keep the launched app open when an Appium iOS session starts. ([#4699](https://github.com/expo/eas-cli/pull/4699) by [@gwdp](https://github.com/gwdp))
- Run Appium through the prepared Full Disk Access helper when available on macOS. ([#4622](https://github.com/expo/eas-cli/pull/4622) by [@gwdp](https://github.com/gwdp))
- Identify the build or application archive when a download fails, and explain when the archive is no longer available. ([#4703](https://github.com/expo/eas-cli/pull/4703) by [@gwdp](https://github.com/gwdp))
- Run apsd setup in the background without delaying iOS app launch or preview readiness. ([#4706](https://github.com/expo/eas-cli/pull/4706) by [@gwdp](https://github.com/gwdp))
- Handle a device session stopped during startup as normal cleanup instead of reporting a startup error. ([#4716](https://github.com/expo/eas-cli/pull/4716) by [@gwdp](https://github.com/gwdp))
- Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives. ([#4731](https://github.com/expo/eas-cli/pull/4731) by [@gwdp](https://github.com/gwdp))
## 26.0.0
### Major Changes
- Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports. ([#4719](https://github.com/expo/eas-cli/pull/4719) by [@sjchmiela](https://github.com/sjchmiela))
### Minor Changes
- Stop sandboxes after an optional maximum duration measured from readiness. ([#4702](https://github.com/expo/eas-cli/pull/4702) by [@sjchmiela](https://github.com/sjchmiela))
- Unify signed URL uploading to a single implementation and use it everywhere. ([#4712](https://github.com/expo/eas-cli/pull/4712) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### Patch Changes
- Default Android emulators to 4 GiB of RAM to reduce ADB disconnects during large transfers. Retain the 2 GiB default for API 34 and below outside interactive device sessions, and preserve explicit emulator configuration overrides. ([#4727](https://github.com/expo/eas-cli/pull/4727) by [@sjchmiela](https://github.com/sjchmiela))
- Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions. ([#4696](https://github.com/expo/eas-cli/pull/4696) by [@brentvatne](https://github.com/brentvatne))
- Retry transient failures when reporting that a device run session has started. ([#4680](https://github.com/expo/eas-cli/pull/4680) by [@gwdp](https://github.com/gwdp))
- Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log. ([#4690](https://github.com/expo/eas-cli/pull/4690) by [@hSATAC](https://github.com/hSATAC))
- Stop printing workflow hook parsing messages in build logs: native builds showed them under an "Unknown build phase" group, and custom builds warned about unknown hook keys and hooks on steps that never run. ([#4717](https://github.com/expo/eas-cli/pull/4717) by [@hSATAC](https://github.com/hSATAC))
## 25.3.0
### Minor Changes
- Let serve-sim boot, install and launch Argent iOS sessions. ([#4602](https://github.com/expo/eas-cli/pull/4602) by [@gwdp](https://github.com/gwdp))
- Let serve-sim boot, install and launch Appium iOS sessions. ([#4603](https://github.com/expo/eas-cli/pull/4603) by [@gwdp](https://github.com/gwdp))
- Upload the screen recording session manifest as a JSON artifact alongside each video. ([#4689](https://github.com/expo/eas-cli/pull/4689) by [@gwdp](https://github.com/gwdp))
### Patch Changes
- Quote Android launch arguments and URLs for the device shell in simulator sessions, so values with spaces or shell characters reach `am start` as one argument; pass raw values and remove any shell quoting added as a workaround. ([#4695](https://github.com/expo/eas-cli/pull/4695) by [@brentvatne](https://github.com/brentvatne))
- Wait up to five minutes for Google Play to finish processing an uploaded bundle before giving up on the edit commit. ([#4700](https://github.com/expo/eas-cli/pull/4700) by [@hSATAC](https://github.com/hSATAC))
- Reset the device run session idle timer on iOS Simulator preview input. ([#4691](https://github.com/expo/eas-cli/pull/4691) by [@gwdp](https://github.com/gwdp))
- Forward captured Simulator traffic through the local egress proxy. ([#4598](https://github.com/expo/eas-cli/pull/4598) by [@gwdp](https://github.com/gwdp))
## 25.2.0
### Minor Changes
- Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard. ([#4544](https://github.com/expo/eas-cli/pull/4544) by [@gwdp](https://github.com/gwdp))
## 25.1.1
### Patch Changes
- Call www only through the device run session worker API in device run session jobs, and remove their GraphQL fallbacks. ([#4638](https://github.com/expo/eas-cli/pull/4638) by [@szdziedzic](https://github.com/szdziedzic))
## 25.1.0
### Minor Changes
- Pass the `cors_origins` session input to serve-sim and expo-device-hub as `--cors-origin` origins, so a third party page can call the preview host API, for example with `@expo/hub-client`. ([#4657](https://github.com/expo/eas-cli/pull/4657) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- Pass the `frame_ancestors` session input to serve-sim and expo-device-hub as `--frame-ancestor` origins, so a third party can embed the preview host. ([#4655](https://github.com/expo/eas-cli/pull/4655) by [@krystofwoldrich](https://github.com/krystofwoldrich))
### Patch Changes
- Allocate 4 GiB of guest RAM to Android emulators in EAS Simulator sessions. ([#4640](https://github.com/expo/eas-cli/pull/4640) by [@gwdp](https://github.com/gwdp))
- Retry transient Google Play API failures with exponential backoff while keeping uncertain commit results explicit. ([#4629](https://github.com/expo/eas-cli/pull/4629) by [@sjchmiela](https://github.com/sjchmiela))
- Retry Google Play edit commits when an uploaded Android App Bundle is still processing, with bounded retries and progress warnings. ([#4651](https://github.com/expo/eas-cli/pull/4651) by [@sjchmiela](https://github.com/sjchmiela))
- Report Google Play error responses to Sentry so unexplained submission failures can be classified. ([#4652](https://github.com/expo/eas-cli/pull/4652) by [@sjchmiela](https://github.com/sjchmiela))
## 25.0.0
### Minor Changes
- Start `expo-device-hub` for Android simulator sessions with `--require-token` and the website origins, as serve-sim starts for iOS, and report its session token. ([#4578](https://github.com/expo/eas-cli/pull/4578) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- Respect saved sharing preferences for iOS simulator preview sessions. ([#4620](https://github.com/expo/eas-cli/pull/4620) by [@gwdp](https://github.com/gwdp))
- Give agent-device and its simulator preview separate session logs. ([#4585](https://github.com/expo/eas-cli/pull/4585) by [@gwdp](https://github.com/gwdp))
- Give Appium and its simulator preview separate session logs. ([#4586](https://github.com/expo/eas-cli/pull/4586) by [@gwdp](https://github.com/gwdp))
- Give Argent and its simulator preview separate session logs. ([#4587](https://github.com/expo/eas-cli/pull/4587) by [@gwdp](https://github.com/gwdp))
- Let serve-sim start the iOS Simulator for web-preview sessions. ([#4601](https://github.com/expo/eas-cli/pull/4601) by [@gwdp](https://github.com/gwdp))
- Stream simulator preview output into its existing session step. ([#4525](https://github.com/expo/eas-cli/pull/4525) by [@gwdp](https://github.com/gwdp))
### Patch Changes
- Speed up cold iOS Appium sessions by launching preinstalled WebDriverAgent. ([#4618](https://github.com/expo/eas-cli/pull/4618) by [@gwdp](https://github.com/gwdp))
- Call the device run session worker API with the session token when www provides one. ([#4615](https://github.com/expo/eas-cli/pull/4615) by [@szdziedzic](https://github.com/szdziedzic))
- Clarify Google Play submission messages and reject zero Android submission rollouts. ([#4625](https://github.com/expo/eas-cli/pull/4625) by [@sjchmiela](https://github.com/sjchmiela))
+10 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "24.13.0",
"version": "27.4.1",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -26,7 +26,7 @@
"build:egress-guard": "resources/egress-guard/build.sh",
"test:egress-guard": "resources/egress-guard/tests/run-policy-tests.sh && resources/egress-guard/tests/run-guard-tests.sh",
"typecheck": "tsc",
"generate-appium-commands": "mise exec [email protected]2.0 -- node scripts/generate-appium-commands.js",
"generate-appium-commands": "mise exec [email protected]3.4 -- node scripts/generate-appium-commands.js",
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
"jest-unit": "jest --config jest/unit-config.ts",
"jest-integration": "jest --config jest/integration-config.ts",
@@ -36,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": "24.13.0",
"@expo/eas-build-job": "24.13.0",
"@expo/downloader": "27.4.1",
"@expo/eas-build-job": "27.4.1",
"@expo/env": "^0.4.0",
"@expo/logger": "24.13.0",
"@expo/logger": "27.4.1",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "24.13.0",
"@expo/template-file": "24.13.0",
"@expo/turtle-spawn": "24.13.0",
"@expo/xcpretty": "^4.3.1",
"@expo/steps": "27.4.1",
"@expo/template-file": "27.4.1",
"@expo/turtle-spawn": "27.4.1",
"@expo/xcpretty": "^4.4.7",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
"@urql/core": "^6.0.1",
@@ -71,6 +71,17 @@ by a coverage report from `lsof`, listing any process without the library. Both
`packages/worker/package.sh` for the iOS worker tarball and by the
`test-egress-guard` EAS workflow, not committed.
For serve-sim-owned iOS session startup, build-tools passes the guard through
`SERVE_SIM_ADDITIONAL_DYLIBS` and its configuration and proxy variables through
inherited `SIMCTL_CHILD_` variables. It shuts down an already booted selected
device so every process inherits the guard. Serve-sim boots that device while
build-tools prepares the app. After serve-sim's control endpoint is available,
build-tools runs the packaged guard self-check before requesting installation
and launch through the authenticated host API. Preview readiness and application
startup both complete before reporting the remote session live. Build-tools keeps
the event relay and egress cleanup without rewriting launchd's dylib list after
serve-sim adds its capability loader.
Failure semantics: a missing library, a failed `launchctl setenv`, or a failed
self-check fails the session, since a `--egress local` session without the
guard would silently leak. Only an unwritable event log is a warning, because
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -eox pipefail
# Generated GraphQL files are untracked, so always regenerate and check compatibility.
yarn gql
yarn run -T lerna run build --scope @expo/build-tools --include-dependencies
@@ -318,6 +318,49 @@ describe('Datadog singleton', () => {
);
});
it('POSTs distribution metrics to the device run session worker API with the session token', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
deviceRunSessionToken: 'session-token',
target: { kind: 'deviceRunSession' },
});
Datadog.distribution('eas.workflow.hook', 1, { hook: 'on_start' });
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/device-run-session-worker/metrics',
'POST',
{
json: {
metrics: [
{
name: 'eas.workflow.hook',
type: 'distribution',
value: 1,
tags: { hook: 'on_start', target: 'job_run' },
},
],
},
headers: { 'expo-device-run-session-token': 'session-token' },
retries: 2,
}
);
});
it('does not report logs for device run session targets', () => {
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
deviceRunSessionToken: 'session-token',
target: { kind: 'deviceRunSession' },
});
Datadog.log('Custom build saw user-provided function');
expect(turtleFetchMock).not.toHaveBeenCalled();
});
it('flushes pending metric uploads', async () => {
let resolveUpload!: (response: Response) => void;
turtleFetchMock.mockReturnValueOnce(
@@ -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)) {
@@ -16,11 +16,13 @@ function createCtx(
{ workingdir = '/tmp/wd' }: { workingdir?: string } = {}
): {
ctx: BuildContext<Ios.Job>;
info: jest.Mock;
warn: jest.Mock;
} {
const info = jest.fn();
const warn = jest.fn();
const logger = {
info: jest.fn(),
info,
debug: jest.fn(),
error: jest.fn(),
warn,
@@ -36,7 +38,7 @@ function createCtx(
workingdir,
}
);
return { ctx, warn };
return { ctx, info, warn };
}
// Project checkout lives at <workingdir>/build (job projectRootDirectory is '.').
@@ -79,22 +81,31 @@ describe(parseJobHooksAsync, () => {
});
});
it('treats an unknown hook key as inert and warns', async () => {
const { ctx, warn } = createCtx({
it('writes nothing to the build log while parsing', async () => {
const { ctx, info, warn } = createCtx({
before_install_node_modules: [{ run: 'echo hi' }],
before_submit: [{ run: 'echo hi' }],
before_bogus: [{ run: 'echo hi' }],
});
await parseJobHooksAsync(ctx, INSTALL);
expect(info).not.toHaveBeenCalled();
expect(warn).not.toHaveBeenCalled();
});
it('treats an unknown hook key as inert', async () => {
const { ctx } = createCtx({
before_bogus: [{ run: 'echo hi' }],
});
const parsed = await parseJobHooksAsync(ctx, INSTALL);
expect(parsed!.hookEntriesByKey).toEqual({});
expect(warn).toHaveBeenCalledWith(expect.stringContaining('unknown hook key "before_bogus"'));
});
it('shape-checks a registered-but-unwrapped key, then warns and skips it', async () => {
const { ctx, warn } = createCtx({
it('shape-checks a registered-but-unwrapped key, then skips it', async () => {
const { ctx } = createCtx({
before_submit: [{ run: 'echo hi' }],
});
const parsed = await parseJobHooksAsync(ctx, INSTALL);
expect(parsed!.hookEntriesByKey.before_submit).toBeUndefined();
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hooks.before_submit'));
});
it('shape-validates a registered-but-unwrapped key even though it is skipped', async () => {
@@ -192,10 +203,9 @@ describe(parseJobHooksAsync, () => {
});
it('never loads a composite function referenced under a registered-but-unwrapped key', async () => {
const { ctx, warn } = createCtx({ before_submit: [{ uses: './.eas/functions/missing' }] });
const { ctx } = createCtx({ before_submit: [{ uses: './.eas/functions/missing' }] });
const parsed = await parseJobHooksAsync(ctx, INSTALL);
expect(parsed!.hookEntriesByKey).toEqual({});
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hooks.before_submit'));
});
it('wraps a cross-key duplicate step id in the aggregate error', async () => {
@@ -12,6 +12,7 @@ import spawn from '@expo/turtle-spawn';
import { randomBytes, randomUUID } from 'crypto';
import { vol } from 'memfs';
import fetch, { Response } from 'node-fetch';
import { Readable } from 'stream';
import { setTimeout } from 'timers/promises';
import { createMockLogger } from '../../__tests__/utils/logger';
@@ -390,7 +391,13 @@ describe('projectSources', () => {
ctx.graphqlClient.mutation = mockGraphqlMutation as any;
const fetchMock = jest.mocked(fetch);
fetchMock.mockImplementation(async () => ({ ok: true }) as Response);
let uploadedBody = '';
fetchMock.mockImplementation(async (_url, request) => {
for await (const chunk of request!.body as Readable) {
uploadedBody += chunk;
}
return { ok: true } as Response;
});
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: ctx.job.projectArchive }),
@@ -414,10 +421,14 @@ describe('projectSources', () => {
'https://storage.example.com/upload',
expect.objectContaining({
method: 'PUT',
headers: { 'x-custom-header': 'value' },
body: '{"archiveContent":["project/README.md","project/package.json"]}',
headers: {
'x-custom-header': 'value',
'content-length': '63',
'content-type': 'text/plain;charset=UTF-8',
},
})
);
expect(uploadedBody).toBe('{"archiveContent":["project/README.md","project/package.json"]}');
// Verify build metadata was updated
expect(mockGraphqlMutation).toHaveBeenNthCalledWith(
@@ -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);
@@ -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,
@@ -5,13 +5,13 @@ import { asyncResult } from '@expo/results';
import spawn from '@expo/turtle-spawn';
import fs from 'fs/promises';
import { graphql } from 'gql.tada';
import fetch from 'node-fetch';
import nullthrows from 'nullthrows';
import path from 'path';
import { z } from 'zod';
import { shallowCloneRepositoryAsync } from './git';
import { BuildContext } from '../context';
import { uploadContentWithSignedUrl } from '../storage/uploadWithSignedUrl';
import { turtleFetch } from '../utils/turtleFetch';
export async function prepareProjectSourcesAsync<TJob extends Job>(
@@ -208,10 +208,9 @@ async function uploadProjectMetadataAsync(
headers: Record<string, string>;
};
await fetch(uploadSession.url, {
method: 'PUT',
body: JSON.stringify({ archiveContent: sortedFiles }),
headers: uploadSession.headers,
await uploadContentWithSignedUrl({
signedUrl: uploadSession,
content: JSON.stringify({ archiveContent: sortedFiles }),
});
const updateMetadataResult = await ctx.graphqlClient
+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;
+5 -4
View File
@@ -4,13 +4,11 @@ import {
BuildPhaseResult,
Env,
EnvironmentSecretType,
GenericArtifactType,
Job,
LogMarker,
ManagedArtifactType,
Metadata,
errors,
isGenericArtifact,
} from '@expo/eas-build-job';
import { BuildTrigger } from '@expo/eas-build-job/dist/common';
import { bunyan } from '@expo/logger';
@@ -42,7 +40,7 @@ export type ArtifactToUpload =
paths: string[];
}
| {
type: GenericArtifactType;
type: null;
name: string;
paths: string[];
metadata?: Record<string, unknown>;
@@ -69,6 +67,7 @@ export interface BuildContextOptions {
metadata?: Metadata;
expoApiV2BaseUrl?: string;
mcpServerUrl?: string;
addBuildLogMask?: (secret: string) => void;
}
export class SkipNativeBuildError extends Error {}
@@ -86,6 +85,7 @@ export class BuildContext<TJob extends Job = Job> {
public readonly skipNativeBuild?: boolean;
public readonly expoApiV2BaseUrl?: string;
public readonly mcpServerUrl?: string;
public readonly addBuildLogMask?: (secret: string) => void;
public artifacts: Artifacts = {};
private readonly _isLocal: boolean;
@@ -115,6 +115,7 @@ export class BuildContext<TJob extends Job = Job> {
this.skipNativeBuild = options.skipNativeBuild;
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
this.mcpServerUrl = options.mcpServerUrl;
this.addBuildLogMask = options.addBuildLogMask;
const environmentSecrets = this.getEnvironmentSecrets(job);
this._env = {
@@ -238,7 +239,7 @@ export class BuildContext<TJob extends Job = Job> {
logger: bunyan;
}): Promise<{ artifactId: string | null }> {
const result = await this._uploadArtifact({ artifact, logger });
if (result.filename && !isGenericArtifact(artifact)) {
if (result.filename && artifact.type !== null) {
this.artifacts[artifact.type] = result.filename;
}
return { artifactId: result.artifactId ?? null };
@@ -32,6 +32,7 @@ export interface BuilderRuntimeApi {
uploadArtifact: (spec: { artifact: ArtifactToUpload; logger: bunyan }) => Promise<{
artifactId: string | null;
}>;
addBuildLogMask?: (secret: string) => void;
}
export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuildContextProvider {
@@ -84,6 +85,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
this.buildLogsDirectory = path.join(buildCtx.workingdir, 'logs');
this.runtimeApi = {
uploadArtifact: (...args) => buildCtx['uploadArtifact'](...args),
addBuildLogMask: buildCtx.addBuildLogMask,
};
this.mcpServerUrl = buildCtx.mcpServerUrl;
this.expoApiV2BaseUrl = buildCtx.expoApiV2BaseUrl;
+42 -15
View File
@@ -1,15 +1,24 @@
import { Sentry } from './sentry';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from './steps/utils/deviceRunSessionWorkerApi';
import { turtleFetch } from './utils/turtleFetch';
type MetricsTarget =
| { kind: 'build'; turtleBuildId: string }
| { kind: 'jobRun'; turtleJobRunId: string };
| { kind: 'jobRun'; turtleJobRunId: string }
// A device run session job with a session token. The token names the session and its job run.
| { kind: 'deviceRunSession' };
type DatadogSetupOptions = {
expoApiV2BaseUrl: string;
robotAccessToken: string;
target: MetricsTarget;
};
type DatadogSetupOptions =
| {
expoApiV2BaseUrl: string;
robotAccessToken: string;
target: Exclude<MetricsTarget, { kind: 'deviceRunSession' }>;
}
| {
expoApiV2BaseUrl: string;
deviceRunSessionToken: string;
target: Extract<MetricsTarget, { kind: 'deviceRunSession' }>;
};
let setupOptions: DatadogSetupOptions | null = null;
let pendingUploads: Promise<unknown>[] = [];
@@ -18,9 +27,30 @@ function withTargetTag(
tags: Record<string, string>,
target: MetricsTarget
): Record<string, string> {
// A device run session job is a job run, so its metrics keep the job run tag.
return { ...tags, target: target.kind === 'build' ? 'build' : 'job_run' };
}
function getMetricsRequest(options: DatadogSetupOptions): {
path: string;
headers: Record<string, string>;
} {
if ('deviceRunSessionToken' in options) {
return {
path: 'device-run-session-worker/metrics',
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: options.deviceRunSessionToken },
};
}
const { target, robotAccessToken } = options;
return {
path:
target.kind === 'build'
? `turtle-builds/${target.turtleBuildId}/metrics`
: `turtle-job-runs/${target.turtleJobRunId}/metrics`,
headers: { Authorization: `Bearer ${robotAccessToken}` },
};
}
export const Datadog = {
setup(opts: DatadogSetupOptions | null): void {
setupOptions = opts;
@@ -30,7 +60,7 @@ export const Datadog = {
if (!setupOptions) {
return;
}
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
const { expoApiV2BaseUrl, target } = setupOptions;
const metrics = [
{
name,
@@ -40,16 +70,11 @@ export const Datadog = {
},
];
const metricsPath =
target.kind === 'build'
? `turtle-builds/${target.turtleBuildId}/metrics`
: `turtle-job-runs/${target.turtleJobRunId}/metrics`;
const { path: metricsPath, headers } = getMetricsRequest(setupOptions);
const uploadPromise = turtleFetch(new URL(metricsPath, expoApiV2BaseUrl).toString(), 'POST', {
json: { metrics },
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
headers,
retries: 2,
}).catch(err => {
Sentry.capture(`Failed to report turtle ${target.kind} metric`, err, {
@@ -61,7 +86,9 @@ export const Datadog = {
},
log(message: string, tags: Record<string, string> = {}): void {
if (!setupOptions) {
// The device run session worker API has no logs route, and the session token is not a bearer
// credential, so a device run session job does not report these logs.
if (!setupOptions || !('robotAccessToken' in setupOptions)) {
return;
}
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
+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';
@@ -33,5 +42,6 @@ export type { GradleProfileTask } from './android/gradleProfile';
export * from './generic';
export { Datadog } from './datadog';
export { getDeviceRunSessionTokenFromEnvironmentSecrets } from './steps/utils/deviceRunSessionWorkerApi';
export { Sentry } from './sentry';
export * from './runtimeSettings';
@@ -56,10 +56,10 @@ class RemoteLoggerStream extends Writable {
return null;
}
public async init(): Promise<string> {
public async init(): Promise<void> {
this.fileHandle = await fs.open(this.temporaryLogsPath, 'w+', 0o660);
this.flushInterval = setInterval(() => this.flush(), this.options.uploadIntervalMs);
return (await this.flush(true)) as string;
await this.flush(true);
}
public async cleanUp(): Promise<void> {
@@ -121,17 +121,18 @@ class RemoteLoggerStream extends Writable {
}
}
private async flush(force = false): Promise<string | void> {
private async flush(force = false): Promise<void> {
if (force || this.hasChangesToUpload) {
if (this.uploadingPromise) {
await this.uploadingPromise;
return await this.flush(force);
await this.flush(force);
return;
}
return await this.flushInternal();
await this.flushInternal();
}
}
private async flushInternal(): Promise<string | void> {
private async flushInternal(): Promise<void> {
await this.safeWriteToFile();
let resolveFn: PromiseResolveFn;
@@ -139,21 +140,17 @@ class RemoteLoggerStream extends Writable {
this.uploadingPromise = new Promise(res => {
resolveFn = res;
});
return await this.upload()
.then(result => {
return result;
})
await this.upload()
.catch(err => {
this.logger.error({ err }, 'Failed to upload logs file');
})
.then(result => {
.then(() => {
this.uploadingPromise = undefined;
resolveFn();
return result;
});
}
private async upload(): Promise<string | void> {
private async upload(): Promise<void> {
if (!this.uploadMethod) {
return;
}
@@ -165,7 +162,7 @@ class RemoteLoggerStream extends Writable {
);
};
return await uploadWithSignedUrl({
await uploadWithSignedUrl({
signedUrl: this.uploadMethod.signedUrl,
srcGeneratorAsync,
});
@@ -11,7 +11,7 @@ import { getEasFunctions } from '../easFunctions';
jest.mock('fs');
describe('hook anchor self-declarations', () => {
it('exactly the four v0 functions declare anchors, with these bindings', () => {
it('declares the expected hook anchors', () => {
const declarations: Record<string, HookAnchorId> = {};
for (const buildFunction of getEasFunctions(createCustomBuildContextMock())) {
if (buildFunction.__hookId !== undefined) {
@@ -24,6 +24,7 @@ describe('hook anchor self-declarations', () => {
'eas/checkout': 'checkout',
'eas/install_node_modules': 'install_node_modules',
'eas/upload_to_asc': 'submit',
'eas/submit_to_google_play': 'submit',
'eas/maestro_tests': 'maestro_tests',
});
});
@@ -87,6 +88,17 @@ describe('hook anchor self-declarations', () => {
bundle_short_version: '1.0.0',
},
],
[
'eas/submit_to_google_play',
'before_submit',
'after_submit',
{
artifact_path: 'app.aab',
artifact_type: 'aab',
package_name: 'com.example.app',
service_account_key_path: 'key.json',
},
],
[
'eas/maestro_tests',
'before_maestro_tests',
@@ -0,0 +1,18 @@
import { type CustomBuildContext } from '../../customBuildContext';
import { getEasFunctions } from '../easFunctions';
it('registers opt-in session functions with the existing inputs', () => {
const functions = getEasFunctions({ hasBuildJob: () => false } as CustomBuildContext);
for (const name of ['serve_sim_remote', 'agent_device', 'argent_remote', 'appium_remote']) {
const existing = functions.find(fn => fn.id === `start_${name}_session`)!;
const warm = functions.find(fn => fn.id === `start_warm_${name}_session`)!;
expect(warm.getFullId()).toBe(`eas/start_warm_${name}_session`);
const warmInputs = warm.inputProviders!.map(input => input(undefined as never, 'warm').id);
const existingInputs = existing.inputProviders!.map(
input => input(undefined as never, 'existing').id
);
expect(warmInputs).toEqual(existingInputs);
}
const ids = functions.map(fn => fn.getFullId());
expect(new Set(ids).size).toBe(ids.length);
});
@@ -29,9 +29,11 @@ import { createPrebuildBuildFunction } from './functions/prebuild';
import { createReadAppConfigBuildFunction } from './functions/readAppConfig';
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
import { createReadAndroidAppInfoBuildFunction } from './functions/readAndroidAppInfo';
import { createSubmitToGooglePlayBuildFunction } from './functions/submitToGooglePlay';
import { createReadPackageJsonBuildFunction } from './functions/readPackageJson';
import { createRepackBuildFunction } from './functions/repack';
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
import { createUpdateResolvedVersionBuildFunction } from './functions/updateResolvedVersion';
import { resolveAppleTeamIdFromCredentialsFunction } from './functions/resolveAppleTeamIdFromCredentials';
import { createResolveBuildConfigBuildFunction } from './functions/resolveBuildConfig';
import {
@@ -60,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';
@@ -110,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(),
@@ -120,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),
@@ -141,6 +149,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
calculateEASUpdateRuntimeVersionFunction(),
createSubmissionEntityFunction(),
createSubmitToGooglePlayBuildFunction(),
createUploadToAscBuildFunction(),
createUpdateTestFlightMetadataBuildFunction(),
@@ -152,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,18 +134,36 @@ describe.each([
['Appium', createStartAppiumRemoteSessionBuildFunction],
['Agent Device', createStartAgentDeviceSessionBuildFunction],
] as const)('%s host ownership', (name, createFunction) => {
async function runAsync() {
async function runAsync(
publicPreview?: boolean,
frameAncestors?: string[],
corsOrigins?: string[]
) {
const fn = createFunction({} as CustomBuildContext);
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.
inputs: new Proxy({} as Record<string, { value: unknown }>, {
get: (target, id: string) => target[id] ?? { value: undefined },
}),
// Match step execution: omitted inputs use their declared defaults.
inputs: new Proxy(
{
public_preview: { value: publicPreview },
frame_ancestors: { value: frameAncestors },
cors_origins: { value: corsOrigins },
} as Record<string, { value: unknown }>,
{
get: (target, id: string) => target[id] ?? { value: defaults.get(id) },
}
),
outputs: {},
env: {
DEVICE_RUN_SESSION_ID: 'session-id',
@@ -132,7 +176,11 @@ describe.each([
it('holds the host until session completion', async () => {
await runAsync();
expect(openPreview).toHaveBeenCalledWith({ baseDomain: 'example.test' });
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ preview: { baseDomain: 'example.test', authtoken: 'token' } })
);
expect(openPreview).toHaveBeenCalledWith();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
@@ -144,13 +192,66 @@ 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);
expect(stopTunnel).toHaveBeenCalledTimes(1);
});
it('opens tunnels with the ngrok token but keeps it out of spawned processes', async () => {
await runAsync();
expect(startNgrokTunnelAsync).toHaveBeenCalledWith(
expect.objectContaining({ authtoken: 'token' })
);
const spawnedEnvs = [
...jest.mocked(spawn).mock.calls.map(([, , options]) => options?.env),
...jest.mocked(spawnDetached).mock.calls.map(([options]) => options.env),
...jest.mocked(startDeviceSessionHostAsync).mock.calls.map(([, options]) => options.env),
];
expect(spawnedEnvs.length).toBeGreaterThan(0);
for (const env of spawnedEnvs) {
expect(env).toEqual(expect.objectContaining({ DEVICE_RUN_SESSION_ID: 'session-id' }));
expect(env).not.toHaveProperty('NGROK_AUTHTOKEN');
}
});
it.each([undefined, true, false])('maps public_preview=%s to the host', async publicPreview => {
await runAsync(publicPreview);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ disableShare: publicPreview === false })
);
});
it('maps frame_ancestors to the host and defaults to none', async () => {
await runAsync(undefined, ['https://embed.example.test']);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ frameAncestors: ['https://embed.example.test'] })
);
jest.mocked(startDeviceSessionHostAsync).mockClear();
await runAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ frameAncestors: [] })
);
});
it('maps cors_origins to the host and defaults to none', async () => {
await runAsync(undefined, undefined, ['https://app.example.test']);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
);
jest.mocked(startDeviceSessionHostAsync).mockClear();
await runAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ corsOrigins: [] })
);
});
it('stops automation while recording finalization or upload is pending', async () => {
let release!: () => void;
let stopped!: () => void;
@@ -236,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);
@@ -284,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 =>
@@ -294,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);
}
@@ -320,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,
@@ -591,6 +944,131 @@ describe('createDownloadBuildFunction', () => {
expect(graphqlClient.query).not.toHaveBeenCalled();
});
describe('with a device run session token in the job env', () => {
const { Response: ActualResponse } = jest.requireActual(
'node-fetch'
) as typeof import('node-fetch');
const workerApiUrl = 'https://api.expo.test/v2/device-run-session-worker/application';
function createSessionCtx(graphqlClient: Client): any {
return {
graphqlClient,
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
};
}
function createApplicationResponse(buildId: string): Response {
return new ActualResponse(
JSON.stringify({
data: {
build: {
id: buildId,
gitCommitHash: null,
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
},
},
}),
{ status: 200 }
);
}
function createStep(ctx: any, callInputs: Record<string, unknown>) {
return createDownloadBuildFunction(ctx).createBuildStepFromFunctionCall(
createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: { job: { secrets: { robotAccessToken: 'session-token' } } },
}),
{ callInputs }
);
}
it('looks up build_id through the worker API and downloads without an Authorization header', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({});
jest.mocked(fetch).mockImplementation(async url => {
if (url === workerApiUrl) {
return createApplicationResponse(buildId);
}
return {
ok: false,
text: () => Promise.resolve('Internal Server Error'),
status: 500,
} as unknown as Response;
});
await expect(
createStep(createSessionCtx(graphqlClient), {
build_id: buildId,
extensions: ['app'],
}).executeAsync()
).rejects.toThrow('Internal Server Error');
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
workerApiUrl,
expect.objectContaining({
method: 'GET',
headers: { 'expo-device-run-session-token': 'session-token' },
})
);
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ headers: undefined })
);
expect(graphqlClient.query).not.toHaveBeenCalled();
});
it('retries the worker API lookup after a 5xx response', async () => {
useFakeBackoffTimers();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockResolvedValueOnce(new ActualResponse('Service Unavailable', { status: 503 }))
.mockResolvedValueOnce(createApplicationResponse(buildId))
.mockResolvedValueOnce(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const execution = createStep(createSessionCtx(graphqlClient), {
build_id: buildId,
extensions: ['apk'],
}).executeAsync();
await fireBackoffTimersAsync(1);
await execution;
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(3);
expect(jest.mocked(fetch).mock.calls.map(([url]) => url)).toEqual([
workerApiUrl,
workerApiUrl,
APPLICATION_ARCHIVE_URL,
]);
});
it('does not call the worker API for an application_archive_url input', async () => {
const graphqlClient = createMockGraphqlClient({});
jest.mocked(fetch).mockResolvedValue({
ok: false,
text: () => Promise.resolve('Internal Server Error'),
status: 500,
} as unknown as Response);
await expect(
createStep(createSessionCtx(graphqlClient), {
application_archive_url: APPLICATION_ARCHIVE_URL,
extensions: ['app'],
}).executeAsync()
).rejects.toThrow('Internal Server Error');
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(1);
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ headers: undefined })
);
expect(graphqlClient.query).not.toHaveBeenCalled();
});
});
it('should download a build', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
@@ -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';
@@ -179,17 +180,7 @@ describe(launchApplicationAsync, () => {
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
'adb',
[
'shell',
'am',
'start',
'-a',
'android.intent.action.VIEW',
'-d',
'exp://example.test',
'-n',
'host.exp.exponent/host.exp.exponent.MainActivity',
],
['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', 'exp://example.test'],
{ env: {}, logger }
);
expect(logger.info).toHaveBeenNthCalledWith(
@@ -211,6 +202,263 @@ describe(launchApplicationAsync, () => {
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('quotes Android launch arguments and URLs for the device shell', async () => {
const logger = createMockLogger();
await launchApplicationAsync({
applicationIdentifier: 'dev.example.app',
activityName: 'dev.example.app.MainActivity',
launchArgs: ['-d', 'euxy://tempo', '--es', 'probe', 'hello world', '--es', 'quote', "it's"],
openUrl: 'exp://example.test/path?a=1&b=2',
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger,
});
expect(mockedSpawn).toHaveBeenNthCalledWith(
1,
'adb',
[
'shell',
'am',
'start',
'-d',
'euxy://tempo',
'--es',
'probe',
"'hello world'",
'--es',
'quote',
"'it'\\''s'",
'-n',
'dev.example.app/dev.example.app.MainActivity',
],
{ env: {}, logger }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
'adb',
[
'shell',
'am',
'start',
'-a',
'android.intent.action.VIEW',
'-d',
"'exp://example.test/path?a=1&b=2'",
],
{ env: {}, logger }
);
expect(logger.info).toHaveBeenNthCalledWith(
1,
`Launching dev.example.app with arguments ${JSON.stringify(['-d', 'euxy://tempo', '--es', 'probe', 'hello world', '--es', 'quote', "it's"])}.`
);
});
it('passes iOS launch arguments with spaces unquoted', async () => {
await launchApplicationAsync({
applicationIdentifier: 'com.example.app',
launchArgs: ['-name', 'hello world'],
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: {},
logger: createMockLogger(),
});
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
['simctl', 'launch', 'booted', 'com.example.app', '-name', 'hello world'],
expect.any(Object)
);
});
it('round-trips quoted Android arguments through a POSIX shell', async () => {
const component = 'dev.example.app/dev.example.app.MainActivity$Inner';
const launchArgs = [
'--es',
'text',
'hello world',
'--es',
'json',
'{"a":1,"b":2}',
'--es',
'shell',
'$(printf injected) `printf injected` $HOME ; & | < >',
'--es',
'glob',
'*',
'--es',
'home',
'~',
'--esa',
'list',
'a\\,b,c',
'--es',
'whitespace',
'one\ntwo\tthree',
'--es',
'unicode',
'héllo 🌍',
'--es',
'empty',
'',
];
const openUrl = 'exp://example.test/path?a=1&b=2#fragment';
await launchApplicationAsync({
applicationIdentifier: 'dev.example.app',
activityName: 'dev.example.app.MainActivity$Inner',
launchArgs,
openUrl,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
});
// adb joins the words after `shell` with spaces and the device shell parses the result.
const parsedCommands = mockedSpawn.mock.calls.map(([, args]) => {
const { stdout } = spawnSync(
'/bin/sh',
['-c', `am() { printf '%s\\0' "$@"; }; ${(args as string[]).slice(1).join(' ')}`],
{ encoding: 'utf8' }
);
return stdout.split('\0').slice(0, -1);
});
expect(parsedCommands).toEqual([
['start', ...launchArgs, '-n', component],
['start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
]);
});
it.each([[['--es', 'key', '']], [['-e', 'key', '']], [['-d', '']]])(
'passes empty Android option values as empty arguments (%j)',
async launchArgs => {
await launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
});
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
[
'shell',
'am',
'start',
...launchArgs.map(argument => (argument === '' ? "''" : argument)),
'-n',
'com.example.app/com.example.app.MainActivity',
],
expect.any(Object)
);
}
);
it('explains an Android launch failure when a launch argument is empty', async () => {
const launchError = new Error('adb shell am start exited with non-zero code: 1');
mockedSpawn.mockRejectedValueOnce(launchError);
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs: ['--ez', 'isTest', 'true', ''],
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({
errorCode: 'EAS_LAUNCH_APPLICATION_FAILED',
message: expect.stringContaining('empty argument at position 4'),
cause: launchError,
});
});
it('rethrows an Android launch failure unchanged when no launch argument is empty', async () => {
const launchError = new Error('adb shell am start exited with non-zero code: 1');
mockedSpawn.mockRejectedValueOnce(launchError);
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs: ['--ez', 'isTest', 'true'],
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toBe(launchError);
});
it.each([
{ launchArgs: ['--es', 'key', 'a\0b'] },
{ launchArgs: [], openUrl: 'exp://example.test/a\0b' },
])('rejects NUL characters on Android (%j)', async ({ launchArgs, openUrl }) => {
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs,
openUrl,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
expect(mockedSpawn).not.toHaveBeenCalledWith(
'adb',
expect.arrayContaining([expect.stringContaining('\0')]),
expect.any(Object)
);
});
it('accepts an Android launch command of exactly 65,000 bytes', async () => {
const fixedLength = 'am start --es key -n com.example.app/com.example.app.MainActivity'.length;
await launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs: ['--es', 'key', 'a'.repeat(65_000 - fixedLength)],
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
});
expect(mockedSpawn).toHaveBeenCalledTimes(1);
});
it('rejects an Android URL command of 65,001 bytes', async () => {
const fixedLength = 'am start -a android.intent.action.VIEW -d exp://example.test/'.length;
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
openUrl: `exp://example.test/${'a'.repeat(65_001 - fixedLength)}`,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
expect(mockedSpawn).toHaveBeenCalledTimes(1);
});
it('rejects an Android launch command that is too long after quoting', async () => {
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs: ['--es', 'quotes', "'".repeat(20_000)],
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('launches using application metadata passed to the build step', async () => {
const launchApplication = createLaunchApplicationFunction();
const buildStep = launchApplication.createBuildStepFromFunctionCall(
@@ -240,16 +488,36 @@ describe(launchApplicationAsync, () => {
],
expect.any(Object)
);
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', 'exp://example.test'],
expect.any(Object)
);
});
it('quotes Android launch arguments passed to the build step', async () => {
const launchApplication = createLaunchApplicationFunction();
const buildStep = launchApplication.createBuildStepFromFunctionCall(
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
{
callInputs: {
application_identifier: 'com.example.app',
activity_name: 'com.example.app.MainActivity',
launch_args: ['--es', 'probe', 'hello world'],
},
}
);
await buildStep.executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
[
'shell',
'am',
'start',
'-a',
'android.intent.action.VIEW',
'-d',
'exp://example.test',
'--es',
'probe',
"'hello world'",
'-n',
'com.example.app/com.example.app.MainActivity',
],
@@ -1,4 +1,4 @@
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
import { SystemError, UserError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import fs from 'fs/promises';
import os from 'os';
@@ -305,7 +305,6 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name:
format === 'html' ? 'Maestro Runner HTML Report' : 'Maestro Runner Allure Results',
paths: [
@@ -1091,7 +1090,6 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name: 'Maestro HTML Report',
paths: ['/home/expo/.maestro/tests/android-maestro-html.html'],
}),
@@ -1420,7 +1418,6 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name: shot.displayName,
metadata: shot.metadata,
}),
@@ -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,
@@ -29,8 +36,8 @@ jest.mock('../../../utils/processes');
jest.mock('../../../utils/turtleFetch');
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/agentDeviceArtifacts');
// The agent-device session step boots the Simulator itself; this test covers local egress.
jest.mock('../startIosSimulator', () => ({
...jest.requireActual('../startIosSimulator'),
bootIosSimulatorAsync: jest
.fn()
.mockResolvedValue({ deviceIdentifier: 'sim', udid: 'sim', displayName: 'iPhone' }),
@@ -51,6 +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(
@@ -25,6 +25,7 @@ const leaseMock = jest.mocked(leaseAgentRunProviderCredentialsAsync);
const ctx = {
expoApiV2BaseUrl: 'https://staging-api.expo.test/v2/',
mcpServerUrl: 'wss://staging-mcp.expo.test',
runtimeApi: { addBuildLogMask: jest.fn() },
} as any;
const inputs = {
agent_kind: { value: 'claude-code' },
@@ -102,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',
@@ -115,6 +116,19 @@ describe(createRunAgentBuildFunction, () => {
expect(agentEnv.HOME).not.toBe('/home/expo');
});
it('hands the build log secrets filter to the agent run', async () => {
const runAgentSpy = jest.spyOn(agentRun, 'runAgentAsync').mockResolvedValue();
try {
await runAsync();
expect(runAgentSpy).toHaveBeenCalledWith(
expect.objectContaining({ addBuildLogMask: ctx.runtimeApi.addBuildLogMask })
);
} finally {
runAgentSpy.mockRestore();
}
});
it.each([
['wss://staging-mcp.expo.test', 'https://staging-mcp.expo.test/mcp'],
['ws://localhost:8787', 'http://localhost:8787/mcp'],
@@ -162,6 +176,7 @@ describe(createRunAgentBuildFunction, () => {
['no PATH', { env: { ...env, PATH: '' } }],
['no Expo API URL', { ctx: { ...ctx, expoApiV2BaseUrl: undefined } }],
['no MCP server URL', { ctx: { ...ctx, mcpServerUrl: undefined } }],
['no build log secrets filter', { ctx: { ...ctx, runtimeApi: {} } }],
])('throws a system error for %s', async (_name, overrides) => {
await expect(runAsync(overrides)).rejects.toBeInstanceOf(SystemError);
@@ -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,
@@ -19,7 +23,10 @@ import {
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
} 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
@@ -37,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(),
@@ -69,11 +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
options: Partial<Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]> = {}
): Promise<void> {
await runAgentDeviceRemoteSessionAsync(ctx, {
env: {},
@@ -87,13 +98,29 @@ async function runAsync(
packageVersion: undefined,
maxIdleTimeMinutes: undefined,
maxDurationSeconds: undefined,
capture,
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();
@@ -119,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 });
@@ -145,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({
@@ -163,12 +201,254 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
});
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/+';
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 5678, token });
jest.mocked(startNgrokTunnelAsync).mockImplementation(async () => {
const options = jest.mocked(spawnDetached).mock.calls[0][0];
expect(options.secrets).toContain(token);
const output = createProcessOutput(options.logger, options.secrets);
output.stdout.append('unrecognized value: daemon sec');
output.stdout.append('ret/+\n');
output.stderr.append(`escaped value: ${encodeURIComponent(token)}\n`);
expect(output.getOutput()).toBe(
'unrecognized value: [REDACTED]\nescaped value: [REDACTED]\n'
);
return { url: 'https://daemon.test', subdomainId: 'daemon', stopAsync: mockTunnelStopAsync };
});
await runAsync(logger, BuildRuntimePlatform.DARWIN);
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(token);
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(encodeURIComponent(token));
});
it('resets the idle timer on preview input from the session host', async () => {
const previewInputAt = new Date('2026-10-19T12:00:00Z');
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(ctx, {
application: Promise.resolve(),
} as never);
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(previewInputAt);
await runAsync(logger, BuildRuntimePlatform.LINUX, { 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(
@@ -177,6 +457,21 @@ 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, {
disableShare: true,
privatePreviewUrl: 'https://expo.dev/private-preview',
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
disableShare: true,
privatePreviewUrl: 'https://expo.dev/private-preview',
})
);
});
it('starts the session host without waiting for the agent-device daemon', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const sessionHost = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
@@ -245,14 +540,14 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
});
describe('runAgentDeviceRemoteSessionAsync with a device that is still starting', () => {
function deferred(): {
promise: Promise<void>;
resolve: () => void;
function deferred<T = void>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (e: Error) => void;
} {
let resolve!: () => void;
let resolve!: (value: T) => void;
let reject!: (e: Error) => void;
const promise = new Promise<void>((res, rej) => {
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
@@ -260,18 +555,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',
@@ -282,7 +586,8 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
maxDurationSeconds: undefined,
capture: NO_CAPTURE,
tasks,
device: typeof device === 'function' ? device(tasks) : device,
device: sessionDevice,
onStarted,
});
}
@@ -292,7 +597,31 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
}
}
it('starts the daemon during the boot and the session host after it', async () => {
it('aborts parallel startup before host failure cleanup finishes', async () => {
const failure = new Error('guard refused startup');
const cleanup = deferred();
const notified = deferred();
let startupTasks!: StartupTasks;
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async (_ctx, options) => {
options.onStartupError?.(failure);
notified.resolve();
await cleanup.promise;
throw failure;
});
const session = startSession(tasks => {
startupTasks = tasks;
return { iosSimulatorUdid: 'selected-udid' };
});
const rejected = expect(session).rejects.toBe(failure);
await notified.promise;
expect(startupTasks.signal.aborted).toBe(true);
expect(startupTasks.signal.reason).toBe(failure);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
cleanup.resolve();
await rejected;
});
it('starts the daemon during the Android boot and the session host after it', async () => {
const booted = deferred();
const session = startSession({ booted: booted.promise, ready: booted.promise });
await flushAsync();
@@ -304,10 +633,334 @@ 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('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();
@@ -317,10 +970,11 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
ready.resolve();
await session;
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
});
it('stops waiting for the boot when the daemon fails', async () => {
it('stops waiting for the Android boot when the daemon fails', async () => {
jest.mocked(waitForFileAsync).mockRejectedValue(new Error('no daemon credentials'));
const neverBooted = new Promise<void>(() => {});
@@ -334,7 +988,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('stops the session host when the app fails while the daemon install hangs', async () => {
it('cancels daemon installation and stops the Android host when the download fails', async () => {
jest.mocked(spawn).mockImplementation(((
command: string,
args: string[],
@@ -350,21 +1004,22 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
await flushAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
ready.reject(new Error('simctl install failed'));
ready.reject(new Error('download failed'));
await expect(session).rejects.toThrow('simctl install failed');
await expect(session).rejects.toThrow('download failed');
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(spawnDetached).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
});
it('stops everything it started when the app install fails', async () => {
it('stops the Android host, daemon and tunnel when the download fails', async () => {
const ready = deferred();
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
await flushAsync();
ready.reject(new Error('simctl install failed'));
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
ready.reject(new Error('download failed'));
await expect(session).rejects.toThrow('simctl install failed');
await expect(session).rejects.toThrow('download failed');
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
@@ -93,6 +93,28 @@ describe(startAgentDeviceDaemonAsync, () => {
}) as never);
});
it.each([false, true])(
'shares the daemon secret list through git fallback=%s',
async fallback => {
if (fallback) {
jest.mocked(spawn).mockResolvedValue({ stdout: '' } as never);
}
const secrets: string[] = [];
const handle = await startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
env: {},
logger,
secrets,
});
const options = jest.mocked(spawnDetached).mock.calls[0][0];
expect(options.command).toBe(fallback ? 'bun' : 'node');
expect(options.secrets).toBe(secrets);
secrets.push('learned-daemon-token');
expect(options.secrets).toContain('learned-daemon-token');
await handle.stopAsync();
}
);
it('installs with bun add by default and launches the published daemon', async () => {
const handle = await startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
@@ -108,6 +130,8 @@ describe(startAgentDeviceDaemonAsync, () => {
const addCwd = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
expect(spawnDetached).toHaveBeenCalledWith({
command: 'node',
logger,
secrets: undefined,
args: [path.join(addCwd, 'node_modules/agent-device/dist/src/internal/daemon.js')],
env: expect.objectContaining({
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
@@ -121,6 +145,20 @@ describe(startAgentDeviceDaemonAsync, () => {
await expect(fs.promises.access(addCwd)).rejects.toThrow();
});
it('removes the install directory when the daemon cannot be stopped', async () => {
const stopError = new Error('drain timed out');
stopAsync.mockRejectedValueOnce(stopError);
const handle = await startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
env: {},
logger,
});
const addCwd = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
await expect(handle.stopAsync()).rejects.toBe(stopError);
await expect(fs.promises.access(addCwd)).rejects.toThrow();
});
it('installs with npm when EAS_OVERRIDE_PACKAGE_MANAGER is npm', async () => {
await startAgentDeviceDaemonAsync({
packageVersion: undefined,
@@ -136,10 +174,15 @@ describe(startAgentDeviceDaemonAsync, () => {
});
it('kills the install and does not fall back to git when aborted', async () => {
let installStarted!: () => void;
const started = new Promise<void>(resolve => {
installStarted = resolve;
});
jest.mocked(spawn).mockImplementation(
((_command: string, _args: string[], options?: { signal?: AbortSignal }) =>
new Promise((_resolve, reject) => {
options!.signal!.addEventListener('abort', () => reject(options!.signal!.reason));
installStarted();
})) as never
);
const controller = new AbortController();
@@ -151,7 +194,7 @@ describe(startAgentDeviceDaemonAsync, () => {
logger,
signal: controller.signal,
});
await new Promise(resolve => setImmediate(resolve));
await started;
controller.abort(failure);
await expect(daemon).rejects.toBe(failure);
@@ -199,6 +242,8 @@ describe(startAgentDeviceDaemonAsync, () => {
);
expect(spawnDetached).toHaveBeenCalledWith({
command: 'bun',
logger,
secrets: undefined,
args: ['run', 'src/daemon.ts'],
cwd: '/tmp/agent-device-src',
env: expect.objectContaining({
File diff suppressed because it is too large. Load diff
@@ -9,6 +9,8 @@ import {
} from '../../../utils/AndroidEmulatorUtils';
import { retryAsync } from '../../../utils/retry';
import { resolveAndroidEmulatorLocalEgressAsync } from '../../utils/androidLocalEgress';
import { AndroidEmulatorLogCapture } from '../../../utils/androidEmulatorLogCapture';
import { getAndroidEmulatorLogCapture } from '../../utils/androidEmulatorLogs';
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
jest.mock('@expo/turtle-spawn', () => ({
@@ -24,6 +26,8 @@ jest.mock('../../utils/androidLocalEgress', () => ({
resolveAndroidEmulatorLocalEgressAsync: jest.fn(),
}));
jest.mock('../../utils/androidEmulatorLogs');
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
AndroidEmulatorUtils: {
defaultSystemImagePackage: 'system-images;android-30;default;x86_64',
@@ -62,6 +66,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 +99,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 AndroidEmulatorLogCapture();
jest.mocked(getAndroidEmulatorLogCapture).mockReturnValue(capture);
await createStep(
{ android_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.logSources).toEqual([]);
expect(options.logCapture).toBe(capture);
}
});
it('rejects invalid sources before starting the emulator', async () => {
await expect(createStep({ android_log_sources: ['unknown'] }).executeAsync()).rejects.toThrow();
expect(mockedAndroidUtils.startAsync).not.toHaveBeenCalled();
});
describe('local egress', () => {
function createLocalEgress() {
return {
@@ -111,6 +144,8 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
deviceName: 'EasAndroidDevice01',
env: expect.any(Object),
logcatDirectory: '/tmp/logcat-directory',
logSources: ['emulator-output', 'device-logcat'],
logCapture: expect.any(AndroidEmulatorLogCapture),
});
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith({
env: expect.any(Object),
File diff suppressed because it is too large. Load diff
@@ -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;
@@ -32,6 +32,7 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
startNgrokTunnelAsync: jest.fn(),
getNgrokAuthtokenOrThrow: () => 'ngrok-token',
getNgrokTunnelDomainOrThrow: () => 'example.com',
omitNgrokAuthtoken: jest.requireActual('../../utils/remoteDeviceRunSession').omitNgrokAuthtoken,
}));
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
@@ -1,4 +1,4 @@
import { SystemError } from '@expo/eas-build-job';
import { SystemError, UserError } from '@expo/eas-build-job';
import { Client, fetchExchange } from '@urql/core';
import * as sandboxDaemon from '../../utils/sandboxDaemon';
@@ -6,6 +6,90 @@ import * as sandboxDaemon from '../../utils/sandboxDaemon';
import { createStartSandboxBuildFunction, markSandboxReadyAsync } from '../startSandbox';
describe('sandbox build functions', () => {
it.each([
{ completion: 'duration', duration: 0.02 },
{ completion: 'cancellation', duration: 60 },
{ completion: 'duration', duration: 0.0001 },
])(
'stops after $completion with $duration seconds only after readiness',
async ({ completion, duration }) => {
const controller = new AbortController();
let ready!: () => void;
const stopAsync = jest.fn(async () => {});
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockResolvedValue({
ready: new Promise<void>(resolve => {
ready = resolve;
}),
stopAsync,
});
const mutation = jest.fn(() => ({ toPromise: async () => ({ data: {} }) }));
const logger = { info: jest.fn(), child: jest.fn() };
logger.child.mockReturnValue(logger);
const fn = createStartSandboxBuildFunction({
mcpServerUrl: 'ws://localhost:8787',
graphqlClient: { mutation },
} as any);
try {
const pending = fn.fn!({ logger } as any, {
inputs: {
sandbox_id: { value: 'sandbox-id' },
max_duration_seconds: { value: duration },
},
outputs: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
signal: controller.signal,
});
await new Promise(resolve => setTimeout(resolve, 40));
expect(mutation).not.toHaveBeenCalled();
expect(stopAsync).not.toHaveBeenCalled();
ready();
if (completion === 'cancellation') {
await new Promise(resolve => setImmediate(resolve));
expect(stopAsync).not.toHaveBeenCalled();
controller.abort();
}
await pending;
expect(mutation).toHaveBeenCalledTimes(1);
expect(stopAsync).toHaveBeenCalledTimes(1);
const timeoutMessage = `Sandbox reached its maximum runtime of ${duration} seconds. Stopping.`;
if (completion === 'duration') {
expect(logger.info).toHaveBeenCalledWith(timeoutMessage);
} else {
expect(logger.info).not.toHaveBeenCalledWith(timeoutMessage);
}
} finally {
start.mockRestore();
}
}
);
it.each([0, -1, Infinity, NaN])(
'rejects invalid maximum duration %s before starting the daemon',
async duration => {
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync');
const fn = createStartSandboxBuildFunction({
mcpServerUrl: 'ws://localhost:8787',
} as any);
try {
const result = fn.fn!({} as any, {
inputs: {
sandbox_id: { value: 'sandbox-id' },
max_duration_seconds: { value: duration },
},
outputs: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
});
await expect(result).rejects.toBeInstanceOf(UserError);
await expect(result).rejects.toThrow(
`Invalid sandbox maximum duration: ${duration}. Provide a finite number greater than zero, or omit max_duration_seconds.`
);
expect(start).not.toHaveBeenCalled();
} finally {
start.mockRestore();
}
}
);
it('aborts an active ready request and keeps its authentication headers', async () => {
const controller = new AbortController();
let requestStarted!: () => void;
@@ -3,6 +3,7 @@ import { BuildRuntimePlatform, type BuildStepContext, type BuildStepEnv } from '
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import {
getDeviceRunSessionIdOrThrow,
@@ -11,9 +12,33 @@ 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'),
getAvailableDevicesAsync: jest
.fn()
.mockResolvedValue([{ name: 'iPhone 17', udid: 'selected-ios-udid' }]),
},
}));
jest.mock('../../utils/iosAppArtifact', () => ({
...jest.requireActual('../../utils/iosAppArtifact'),
readIosApplicationIdentifierAsync: jest.fn().mockResolvedValue('dev.example.app'),
}));
jest.mock('../../utils/localEgressGuard', () => ({
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn().mockResolvedValue(null),
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
getDeviceRunSessionIdOrThrow: jest.fn(),
@@ -22,15 +47,20 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
jest.mock('../../utils/deviceRunSessionStartup', () => ({
watchDeviceRunSessionStartup: jest.fn(),
}));
const ctx = {} as CustomBuildContext;
const env = {} as BuildStepEnv;
const env = { NGROK_AUTHTOKEN: 'ngrok-token', PATH: '/usr/bin' } as BuildStepEnv;
const childEnv = { PATH: '/usr/bin' } as BuildStepEnv;
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const stopAsync = jest.fn();
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!(
@@ -45,6 +75,7 @@ async function runAsync(
...launchInputs,
},
outputs: {},
signal,
env,
} as never
);
@@ -59,24 +90,293 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
sharePageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
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('uses preview activity to reset the configured idle timeout', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, { 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('rejects an Android idle timeout before starting the host', async () => {
await expect(
runAsync(BuildRuntimePlatform.LINUX, {
max_idle_time_minutes: { value: 5 },
})
).rejects.toThrow('Omit max_idle_time_minutes for Android sessions.');
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
});
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({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
sharePageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
previewToken: 'tok-1',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
@@ -95,11 +395,62 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
);
});
it('logs the authenticated link while publishing the public frame URL', async () => {
const privatePreviewUrl = 'https://expo.dev/private-preview';
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
sharePageUrl: privatePreviewUrl,
apiUrl: 'https://web-preview.example.test',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
await runAsync(BuildRuntimePlatform.DARWIN, {
public_preview: { value: false },
private_preview_url: { value: privatePreviewUrl },
});
expect(logger.info).toHaveBeenCalledWith(
`Preview URL: ${privatePreviewUrl} (server: https://web-preview.example.test).`
);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
}),
})
);
});
it('does not log the public link when a private job lacks its authenticated URL', async () => {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
await runAsync(BuildRuntimePlatform.DARWIN, { public_preview: { value: false } });
expect(logger.info).toHaveBeenCalledWith('Private preview URL unavailable for this session.');
expect(jest.mocked(logger.info).mock.calls).not.toEqual(
expect.arrayContaining([
expect.arrayContaining([expect.stringContaining('/simulator-preview/preview-id')]),
])
);
});
it.each(['preview', 'config', 'wait'])('finishes the session after %s fails', async phase => {
const error = new Error(`${phase} failed`);
if (phase === 'preview') {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: jest.fn().mockRejectedValue(error),
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
} else if (phase === 'config') {
@@ -115,21 +466,45 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
[BuildRuntimePlatform.DARWIN, true],
[BuildRuntimePlatform.LINUX, false],
])('starts the web preview for %s', async (runtimePlatform, selectsXcode) => {
await runAsync(runtimePlatform);
await runAsync(runtimePlatform, selectsXcode ? { boot_simulator: { value: true } } : {});
expect(
jest
.mocked(logger.info)
.mock.calls.filter(
([message]) =>
message ===
'Preview URL: https://expo.dev/simulator-preview/preview-id (server: https://web-preview.example.test).'
)
).toHaveLength(1);
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(selectsXcode ? 1 : 0);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(ctx, {
runtimePlatform,
env,
env: childEnv,
logger,
timeoutMs: 60_000,
preview: { baseDomain: 'tunnel.example.com', authtoken: 'ngrok-token' },
signal: expect.any(AbortSignal),
onStartupError: expect.any(Function),
...(selectsXcode
? {
iosSimulatorUdid: 'SELECTED-IOS-UDID',
installAppPath: undefined,
bootEnv: undefined,
}
: {}),
packageVersion: '1.2.3',
disableShare: false,
frameAncestors: [],
corsOrigins: [],
launchAppIdentifier: undefined,
launchArgs: [],
openUrl: undefined,
networkCapture: false,
networkCaptureFields: [],
});
const host = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
expect(host.openPreviewAsync).toHaveBeenCalledWith();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
ctx,
deviceRunSessionId: 'device-run-session-id',
@@ -139,6 +514,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
previewApiUrl: 'https://web-preview.example.test',
},
logger,
signal: expect.any(AbortSignal),
});
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith({
ctx,
@@ -160,9 +536,17 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
'launch_app_identifier',
'launch_args',
'open_url',
'boot_simulator',
'device_identifier',
'install_app_path',
'network_capture',
'network_capture_fields',
'public_preview',
'private_preview_url',
'frame_ancestors',
'cors_origins',
'package_version',
'max_idle_time_minutes',
'max_duration_seconds',
]);
});
@@ -188,6 +572,64 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
);
});
it('hands the downloaded app and selected device to serve-sim with initial launch options', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
device_identifier: { value: 'chosen-device' },
install_app_path: { value: '/tmp/App.app' },
launch_args: { value: ['--literal', 'value with spaces'] },
open_url: { value: 'example://home' },
});
expect(
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
).toBeLessThan(jest.mocked(IosSimulatorUtils.resolveUdidAsync).mock.invocationCallOrder[0]);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
iosSimulatorUdid: 'SELECTED-IOS-UDID',
installAppPath: '/tmp/App.app',
launchAppIdentifier: 'dev.example.app',
launchArgs: ['--literal', 'value with spaces'],
openUrl: 'example://home',
})
);
});
it.each([true, false])(
'maps public_preview=%s to the native Share option',
async publicPreview => {
await runAsync(BuildRuntimePlatform.DARWIN, {
public_preview: { value: publicPreview },
private_preview_url: { value: 'https://expo.dev/private-preview' },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
disableShare: !publicPreview,
privatePreviewUrl: 'https://expo.dev/private-preview',
})
);
}
);
it('hands frame_ancestors to the session host', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
frame_ancestors: { value: ['https://embed.example.test'] },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ frameAncestors: ['https://embed.example.test'] })
);
});
it('hands cors_origins to the session host', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
cors_origins: { value: ['https://app.example.test'] },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
);
});
it('hands network capture to the session host', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
network_capture: { value: true },
@@ -0,0 +1,260 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import nock from 'nock';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import {
api,
editPath,
mockToken,
packageName,
serviceAccount,
} from '../../utils/android/__tests__/googlePlayTestUtils';
import { createSubmitToGooglePlayBuildFunction } from '../submitToGooglePlay';
jest.unmock('node-fetch');
jest.mock('promise-retry', () => {
const actual = jest.requireActual('promise-retry');
return (fn: Parameters<typeof actual>[0], options: object) =>
actual(fn, { ...options, minTimeout: 1, maxTimeout: 1, randomize: false });
});
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
let directory: string;
let keyPath: string;
let artifactPath: string;
const session = '/upload/test-session';
const createPath = `/androidpublisher/v3/applications/${packageName}/edits`;
beforeEach(async () => {
nock.disableNetConnect();
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'play-submit-test-'));
keyPath = path.join(directory, 'key.json');
artifactPath = path.join(directory, 'app.aab');
await fs.writeFile(artifactPath, 'binary');
await fs.writeFile(
keyPath,
JSON.stringify({
...serviceAccount,
type: 'service_account',
private_key: serviceAccount.private_key.export({ format: 'pem', type: 'pkcs8' }),
})
);
});
afterEach(async () => {
const pending = nock.pendingMocks();
nock.cleanAll();
nock.enableNetConnect();
await fs.rm(directory, { recursive: true, force: true });
expect(pending).toEqual([]);
});
function submission(extra: Record<string, unknown> = {}, signal?: AbortSignal) {
const fn = createSubmitToGooglePlayBuildFunction();
const step = fn.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs: {
artifact_path: artifactPath,
artifact_type: 'aab',
package_name: packageName,
service_account_key_path: keyPath,
...extra,
},
});
const setters = step.outputs.map(output => jest.spyOn(output, 'set'));
return {
setters,
step,
run: async () =>
await fn.fn!(step.ctx, {
inputs: Object.fromEntries(
step.inputs!.map(input => [input.id, { value: input.rawValue }])
),
outputs: Object.fromEntries(step.outputs.map(output => [output.id, output])),
env: {},
signal,
}),
};
}
function upload() {
mockToken();
api().post(createPath).reply(200, { id: 'edit' });
api()
.post(`/upload${editPath}/bundles`)
.query({ uploadType: 'resumable' })
.reply(200, '', { Location: `https://androidpublisher.googleapis.com${session}` });
nock('https://androidpublisher.googleapis.com').put(session).reply(200, { versionCode: 145 });
}
it.each([
['completed', undefined, { status: 'completed' }],
['completed', 0, 'invalid'],
['completed', 0.25, { status: 'inProgress', userFraction: 0.25 }],
['completed', 1, { status: 'completed' }],
['draft', undefined, { status: 'draft' }],
['draft', 0, 'invalid'],
['draft', 0.25, 'invalid'],
['draft', 1, 'invalid'],
['halted', undefined, 'invalid'],
['halted', 0, 'invalid'],
['halted', 0.25, 'invalid'],
['halted', 1, 'invalid'],
['inProgress', undefined, 'invalid'],
['inProgress', 0, 'invalid'],
['inProgress', 0.25, { status: 'inProgress', userFraction: 0.25 }],
['inProgress', 1, { status: 'completed' }],
] as const)('validates and submits %s with rollout %s', async (status, rollout, expected) => {
const task = submission({
release_status: status,
...(rollout !== undefined ? { rollout } : {}),
});
if (expected === 'invalid') {
await expect(task.run()).rejects.toThrow(status === 'halted' ? /release_status/ : /rollout/);
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
return;
}
upload();
api()
.put(`${editPath}/tracks/internal`, {
track: 'internal',
releases: [{ ...expected, versionCodes: ['145'] }],
})
.reply(200, { track: 'internal' });
api()
.post(`${editPath}:commit`)
.query({})
.reply(() => {
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
return [200, { id: 'edit' }];
});
await task.run();
expect(task.setters.map(set => set.mock.calls)).toEqual([[['145']]]);
});
it('runs the step with a string rollout and validates its required output', async () => {
upload();
api()
.put(`${editPath}/tracks/internal`, {
track: 'internal',
releases: [{ status: 'inProgress', userFraction: 0.25, versionCodes: ['145'] }],
})
.reply(200, { track: 'internal' });
api().post(`${editPath}:commit`).query({}).reply(200, { id: 'edit' });
const { step } = submission({ rollout: '0.25' });
expect(step.__hookId).toBe('submit');
await step.executeAsync();
expect(step.outputs[0].value).toBe('145');
});
it.each(['upload', 'track', 'abort'] as const)(
'deletes the edit after %s failure and leaves outputs unset',
async failure => {
const controller = new AbortController();
const task = submission({}, controller.signal);
mockToken();
api().post(createPath).reply(200, { id: 'edit' });
api()
.post(`/upload${editPath}/bundles`)
.query({ uploadType: 'resumable' })
.reply(() => {
if (failure === 'abort') {
controller.abort(new Error('step timed out'));
}
return failure === 'upload'
? [400, { error: { message: 'rejected binary' } }]
: [200, '', { Location: `https://androidpublisher.googleapis.com${session}` }];
});
if (failure === 'track') {
nock('https://androidpublisher.googleapis.com').put(session).reply(200, { versionCode: 145 });
api()
.put(`${editPath}/tracks/internal`)
.reply(400, { error: { message: 'unknown track' } });
}
api().delete(editPath).reply(204);
await expect(task.run()).rejects.toThrow();
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
}
);
it('preserves the original failure when cleanup fails', async () => {
upload();
api()
.put(`${editPath}/tracks/internal`)
.reply(400, { error: { message: 'unknown track' } });
api().delete(editPath).times(6).reply(500);
await expect(submission().run()).rejects.toMatchObject({
cause: expect.objectContaining({ apiMessage: 'unknown track' }),
});
});
it('does not delete the edit or publish outputs when commit outcome is unknown', async () => {
upload();
api().put(`${editPath}/tracks/internal`).reply(200, { track: 'internal' });
api().post(`${editPath}:commit`).query(true).reply(503);
api().post(`${editPath}:commit`).query(true).reply(404);
let deleted = false;
api()
.delete(editPath)
.optionally()
.reply(() => {
deleted = true;
return [204];
});
const task = submission();
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);
});
it('reports a missing service-account file with its path', async () => {
const missing = path.join(directory, 'missing.json');
await expect(submission({ service_account_key_path: missing }).run()).rejects.toThrow(
`Service-account key file not found at ${missing}`
);
});
it('does not expose invalid credential contents', async () => {
await fs.writeFile(keyPath, 'PRIVATE_SECRET_INVALID_JSON');
const error = await submission()
.run()
.catch(error => error);
expect(error).toBeInstanceOf(UserError);
expect(JSON.stringify(error) + String(error)).not.toContain('PRIVATE_SECRET');
});
it('preserves the cause when the service-account file cannot be read', async () => {
const cause = Object.assign(new Error('permission denied'), { code: 'EACCES' });
const read = jest.spyOn(fs, 'readFile').mockRejectedValueOnce(cause);
try {
await expect(submission().run()).rejects.toMatchObject({ cause });
} finally {
read.mockRestore();
}
});
it('logs a formatted rollout and confirms the track update before committing', async () => {
upload();
const task = submission({ rollout: 0.29 });
const info = jest.spyOn(task.step.ctx.logger, 'info');
const updatedMessage = 'Updated Google Play track "internal" in the edit.';
let loggedBeforeUpdate = false;
let messagesBeforeCommit: unknown[] = [];
api()
.put(`${editPath}/tracks/internal`)
.reply(() => {
loggedBeforeUpdate = info.mock.calls.some(([message]) => message === updatedMessage);
return [200, { track: 'internal' }];
});
api()
.post(`${editPath}:commit`)
.query({})
.reply(() => {
messagesBeforeCommit = info.mock.calls.map(([message]) => message);
return [200, { id: 'edit' }];
});
await task.run();
expect(info).toHaveBeenCalledWith('Release status: inProgress. Rollout: 29%.');
expect(loggedBeforeUpdate).toBe(false);
expect(messagesBeforeCommit.slice(-2)).toEqual([
updatedMessage,
'Committing the Google Play edit...',
]);
});
@@ -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.'
);
}
},
});
}
@@ -7,7 +7,7 @@ import {
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import { Client } from '@urql/core';
import { Client, CombinedError } from '@urql/core';
import contentDisposition from 'content-disposition';
import { glob } from 'fast-glob';
import { graphql } from 'gql.tada';
@@ -26,8 +26,18 @@ import { CustomBuildContext } from '../../customBuildContext';
import { formatBytes } from '../../utils/artifacts';
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
import {
describeError,
isTransferFailure,
isTransientGraphqlError,
} from '../../utils/networkErrors';
import { retryOnNetworkFailureAsync } from '../../utils/retryOnNetworkFailure';
import { pluralize } from '../../utils/strings';
import {
type DeviceRunSessionWorkerApi,
DeviceRunSessionWorkerApiError,
getDeviceRunSessionWorkerApi,
} from '../utils/deviceRunSessionWorkerApi';
const streamPipeline = promisify(stream.pipeline);
@@ -118,6 +128,7 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
logger,
...source,
graphqlClient: ctx.graphqlClient,
deviceRunSessionWorkerApi: buildId ? getDeviceRunSessionWorkerApi(ctx) : null,
robotAccessToken: stepsCtx.global.staticContext.job.secrets?.robotAccessToken ?? null,
extensions,
signal,
@@ -131,28 +142,50 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
async function fetchApplicationArchiveUrlAsync({
buildId,
graphqlClient,
deviceRunSessionWorkerApi,
signal,
}: {
buildId: string;
graphqlClient: Client;
deviceRunSessionWorkerApi?: DeviceRunSessionWorkerApi | null;
signal?: AbortSignal;
}): Promise<string> {
signal?.throwIfAborted();
const result = await graphqlClient
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
.toPromise();
if (deviceRunSessionWorkerApi) {
return await fetchDeviceRunSessionApplicationArchiveUrlAsync({
buildId,
deviceRunSessionWorkerApi,
signal,
});
}
let data;
try {
data = await retryOnNetworkFailureAsync(
async () => {
const queryResult = await graphqlClient
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
.toPromise();
if (queryResult.error) {
throw queryResult.error;
}
return queryResult.data;
},
{
operation: 'download_build.build_lookup',
signal,
isRetryable: error => error instanceof CombinedError && isTransientGraphqlError(error),
}
);
} catch (error) {
signal?.throwIfAborted();
const message = `Could not fetch build ${buildId}: ${describeError(error)}`;
throw error instanceof CombinedError && !isTransientGraphqlError(error)
? new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error })
: new SystemError(message, { cause: error });
}
signal?.throwIfAborted();
if (result.error) {
const { error } = result;
const message = `Could not fetch build ${buildId}: ${error.message}`;
throw error.networkError || result.error.response?.status >= 500
? new SystemError(message, { cause: error })
: new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error });
}
const applicationArchiveUrl = result.data?.builds.byId?.artifacts?.applicationArchiveUrl;
const applicationArchiveUrl = data?.builds.byId?.artifacts?.applicationArchiveUrl;
if (!applicationArchiveUrl) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
@@ -163,17 +196,76 @@ async function fetchApplicationArchiveUrlAsync({
return applicationArchiveUrl;
}
/**
* A device run session token can read only the build of its own session, so the build comes from
* `GET /v2/device-run-session-worker/application` instead of the builds GraphQL query.
*
* Only device run session jobs, whose steps www generates from the session, reach this path, so
* every failure here is a system error: neither the request nor `build_id` comes from the user.
*/
async function fetchDeviceRunSessionApplicationArchiveUrlAsync({
buildId,
deviceRunSessionWorkerApi,
signal,
}: {
buildId: string;
deviceRunSessionWorkerApi: DeviceRunSessionWorkerApi;
signal?: AbortSignal;
}): Promise<string> {
let application;
try {
application = await retryOnNetworkFailureAsync(
() => deviceRunSessionWorkerApi.getApplicationAsync({ signal }),
{
operation: 'download_build.session_build_lookup',
signal,
isRetryable: error => error instanceof DeviceRunSessionWorkerApiError && error.isTransient,
}
);
} catch (err) {
signal?.throwIfAborted();
throw new SystemError(
`Could not fetch build ${buildId} for this device run session: ${describeError(err)}`,
{ cause: err }
);
}
signal?.throwIfAborted();
if (application.build?.id !== buildId) {
throw new SystemError(
`Could not fetch build ${buildId}: this device run session runs ${
application.build ? `build ${application.build.id}` : 'no build'
}, and its session token can read only that build. www generates this step from the ` +
'session, so the two should always match. Start a new session, and contact support if ' +
'it happens again.'
);
}
if (!application.build.applicationArchiveUrl) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
'Build does not have an application archive url'
);
}
return application.build.applicationArchiveUrl;
}
export async function downloadBuildAsync(
params: DownloadBuildSource & {
logger: bunyan;
graphqlClient: Client;
/**
* Set in a device run session job that has a session token. The build lookup then uses the
* device run session worker API instead of GraphQL.
*/
deviceRunSessionWorkerApi?: DeviceRunSessionWorkerApi | null;
robotAccessToken: string | null;
extensions: string[];
/** Stops the build lookup, the request and the file write when aborted, and skips the rest. */
signal?: AbortSignal;
}
): Promise<{ artifactPath: string }> {
const { logger, graphqlClient, robotAccessToken, extensions, signal } = params;
const { logger, graphqlClient, deviceRunSessionWorkerApi, robotAccessToken, extensions, signal } =
params;
let downloadUrl: string;
let headers: { Authorization: string } | undefined;
@@ -188,8 +280,19 @@ export async function downloadBuildAsync(
headers = undefined;
} else if (params.buildId) {
const buildId = z.string().uuid().parse(params.buildId);
downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient, signal });
headers = robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined;
downloadUrl = await fetchApplicationArchiveUrlAsync({
buildId,
graphqlClient,
deviceRunSessionWorkerApi,
signal,
});
// The archive URL from the device run session worker API points to an artifact route that
// needs no auth. After www stops minting EXPO_TOKEN for session jobs, robotAccessToken is the
// session token, and www rejects it as a bearer credential on every route.
headers =
robotAccessToken && !deviceRunSessionWorkerApi
? { Authorization: `Bearer ${robotAccessToken}` }
: undefined;
} else {
throw new UserError(
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
@@ -202,18 +305,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,34 +106,40 @@ export async function launchApplicationAsync({
logApplicationLaunch(logger, applicationIdentifier, launchArgs);
// Android does not support process arguments like iOS. Pass raw `am start` Intent
// arguments instead, such as `--es key value` or `--ez key true`.
await spawn(
'adb',
['shell', 'am', 'start', ...launchArgs, '-n', `${applicationIdentifier}/${activityName}`],
{
env,
logger,
try {
await spawnAndroidShellCommandAsync(
['am', 'start', ...launchArgs, '-n', `${applicationIdentifier}/${activityName}`],
{ env, logger }
);
} catch (err) {
// An empty argument that is not an option value reaches `am start` as an empty package, and
// `am start` then fails to resolve the Intent without saying why.
const emptyArgumentIndex = launchArgs.indexOf('');
if (emptyArgumentIndex === -1 || err instanceof UserError) {
throw err;
}
);
throw new UserError(
'EAS_LAUNCH_APPLICATION_FAILED',
`Launching ${applicationIdentifier} failed. Input "launch_args" has an empty argument at position ${emptyArgumentIndex + 1}, which Android passes to "am start" as an empty argument. Remove it unless it is an option value, such as "--es", "key", "".`,
{ cause: err }
);
}
if (openUrl) {
logger.info(`Opening ${openUrl} in ${applicationIdentifier}.`);
await spawn(
'adb',
[
'shell',
'am',
'start',
'-a',
'android.intent.action.VIEW',
'-d',
openUrl,
'-n',
`${applicationIdentifier}/${activityName}`,
],
await spawnAndroidShellCommandAsync(
['am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
{ env, logger }
);
}
}
async function spawnAndroidShellCommandAsync(
command: string[],
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
): Promise<void> {
await spawn('adb', ['shell', ...quoteAndroidShellCommand(command)], { env, logger });
}
async function preapproveIosUrlSchemeAsync({
applicationIdentifier,
openUrl,
@@ -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,
@@ -631,7 +631,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 +738,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));
}
@@ -96,6 +96,9 @@ export function createRunAgentBuildFunction(ctx: CustomBuildContext): BuildFunct
if (!ctx.mcpServerUrl) {
throw new SystemError('MCP server URL is required to run an agent.');
}
if (!ctx.runtimeApi.addBuildLogMask) {
throw new SystemError('A build log secrets filter is required to run an agent.');
}
// The worker config holds the MCP server's WebSocket URL; its HTTP endpoint is on the same host.
const mcpUrl = new URL('/mcp', ctx.mcpServerUrl);
mcpUrl.protocol = mcpUrl.protocol.replace(/^ws/, 'http');
@@ -120,6 +123,7 @@ export function createRunAgentBuildFunction(ctx: CustomBuildContext): BuildFunct
pathEnv,
npmCacheUrl,
logger: stepCtx.logger,
addBuildLogMask: ctx.runtimeApi.addBuildLogMask,
signal: signal
? AbortSignal.any([signal, timeoutController.signal])
: timeoutController.signal,
@@ -11,6 +11,7 @@ import path from 'path';
import * as tar from 'tar';
import z from 'zod';
import { uploadWithSignedUrl } from '../../storage/uploadWithSignedUrl';
import { formatBytes } from '../../utils/artifacts';
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
import { getCacheVersion } from '../utils/cache';
@@ -161,16 +162,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 +224,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,11 @@ 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,
finishRemoteSessionAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
@@ -76,13 +80,9 @@ export function getAgentDeviceRemoteSessionEnvOrThrow(
* Starts the agent-device daemon, its tunnel, the session host and the web preview,
* reports the session as ready, and keeps it alive until it stops.
*
* The daemon does not need the device, so it starts at once. The session host starts
* when `device.booted` resolves. The session is reported as ready only when both are
* up and `device.ready` (the app is installed and launched) resolved too.
*
* The first failure aborts `tasks.signal`: each part stops before its next stage, and
* the teardown then stops whatever was started. `device.ready` must settle soon after
* an abort, because the teardown waits for it.
* Serve-sim boots while the iOS download runs. After device preparation verifies
* any egress guard, its control API installs and launches the app.
* Startup failures abort the other tasks; teardown drains them before stopping acquired resources.
*/
export async function runAgentDeviceRemoteSessionAsync(
ctx: CustomBuildContext,
@@ -96,7 +96,13 @@ export async function runAgentDeviceRemoteSessionAsync(
maxIdleTimeMinutes,
maxDurationSeconds,
capture,
disableShare = false,
privatePreviewUrl,
frameAncestors,
corsOrigins,
tasks,
warm,
onStarted,
device,
}: {
env: BuildStepEnv;
@@ -108,10 +114,29 @@ export async function runAgentDeviceRemoteSessionAsync(
maxIdleTimeMinutes: number | undefined;
maxDurationSeconds: number | undefined;
capture: ReturnType<typeof parseNetworkCaptureInputs>;
disableShare?: boolean;
privatePreviewUrl?: string;
frameAncestors?: string[];
corsOrigins?: string[];
tasks: StartupTasks;
device: { booted: Promise<unknown>; ready: Promise<unknown> };
/** 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}).`
);
@@ -120,72 +145,116 @@ 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 => {
taskLogger.info('Launching agent-device daemon.');
daemonProcess = await startAgentDeviceDaemonAsync({
packageVersion,
env,
logger: taskLogger,
signal: tasks.signal,
});
taskLogger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
// The daemon is stored above, so the teardown stops it. The wait itself is bounded
// (STARTUP_TIMEOUT_MS) and only reads a file, so it may finish in the background.
const daemonInfo = await tasks.untilAborted(waitForDaemonInfoAsync({ daemonProcess }));
taskLogger.info(`Daemon is listening on port ${daemonInfo.port}; loaded auth token.`);
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,
});
tasks.signal.throwIfAborted();
taskLogger.info(`Tunnel is ready at ${agentDeviceTunnel.url}.`);
return { ...daemonInfo, remoteSessionUrl: agentDeviceTunnel.url };
});
const sessionHostStartup = tasks.run('session host', async taskLogger => {
// A boot cannot be cancelled, so stop waiting for it when startup is aborted.
await tasks.untilAborted(device.booted);
tasks.signal.throwIfAborted();
sessionHost = await startDeviceSessionHostAsync(ctx, {
runtimePlatform,
async function startDaemonAsync(taskLogger: bunyan) {
taskLogger.info('Launching agent-device daemon.');
daemonProcess = await startAgentDeviceDaemonAsync({
packageVersion,
env,
logger: taskLogger,
timeoutMs: STARTUP_TIMEOUT_MS,
networkCapture: capture.networkCapture,
networkCaptureFields: capture.networkCaptureFields,
signal: tasks.signal,
secrets: daemonSecrets,
});
tasks.signal.throwIfAborted();
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
taskLogger.info(
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
);
return webPreview;
});
taskLogger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
// The daemon is stored above, so the teardown stops it. The wait itself is bounded
// (STARTUP_TIMEOUT_MS) and only reads a file, so it may finish in the background.
const daemonInfo = await tasks.untilAborted(waitForDaemonInfoAsync({ daemonProcess }));
daemonSecrets.push(daemonInfo.token);
taskLogger.info(`Daemon is listening on port ${daemonInfo.port}; loaded auth token.`);
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: {
@@ -197,17 +266,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,
});
@@ -222,41 +300,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: [
...startupResults.flatMap((result): [string, Promise<unknown>][] =>
result.status === 'rejected' && result.reason instanceof AggregateError
? [['startup cleanup', Promise.reject(result.reason)]]
: []
),
['agent-device tunnel', agentDeviceTunnel?.stopAsync()],
['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()],
],
});
}
@@ -267,13 +373,16 @@ export async function startAgentDeviceDaemonAsync({
env,
logger,
signal,
secrets,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
/** Kills the install and stops before the daemon starts, when aborted. No git fallback then. */
signal?: AbortSignal;
secrets?: string[];
}): Promise<DetachedProcessHandle> {
signal?.throwIfAborted();
const packageSpec = createAgentDevicePackageSpec(packageVersion);
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
const installDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-agent-device-'));
@@ -283,6 +392,7 @@ export async function startAgentDeviceDaemonAsync({
);
try {
signal?.throwIfAborted();
const add = resolvePackageAdd(packageManager, packageSpec);
logger.info(`Installing ${packageSpec} with ${add.command}.`);
await spawn(add.command, add.args, { cwd: installDir, env, logger, signal });
@@ -298,12 +408,17 @@ export async function startAgentDeviceDaemonAsync({
command: 'node',
args: [daemonPath],
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
logger,
secrets,
});
return {
...daemonProcess,
stopAsync: async () => {
await daemonProcess.stopAsync();
await fs.promises.rm(installDir, { recursive: true, force: true });
try {
await daemonProcess.stopAsync();
} finally {
await fs.promises.rm(installDir, { recursive: true, force: true });
}
},
};
} catch (err) {
@@ -334,7 +449,13 @@ export async function startAgentDeviceDaemonAsync({
logger.warn(
`Failed to start daemon from ${packageSpec} via ${packageManager}; falling back to git clone: ${error.message}`
);
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger, signal });
return await startAgentDeviceDaemonFromGitAsync({
packageVersion,
env,
logger,
signal,
secrets,
});
}
}
@@ -365,11 +486,13 @@ async function startAgentDeviceDaemonFromGitAsync({
env,
logger,
signal,
secrets,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
secrets?: string[];
}): Promise<DetachedProcessHandle> {
logger.info(
packageVersion
@@ -396,6 +519,8 @@ async function startAgentDeviceDaemonFromGitAsync({
args: ['run', 'src/daemon.ts'],
cwd: SRC_DIR,
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
logger,
secrets,
});
}
@@ -1,4 +1,4 @@
import { UserError } from '@expo/eas-build-job';
import { UserError, validateAndroidLaunchOptions } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
@@ -15,9 +15,28 @@ import {
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorName, IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
import { AndroidEmulatorLogSourcesSchema } from '../../utils/androidEmulatorLogSources';
import { getAndroidEmulatorLogCapture } from '../utils/androidEmulatorLogs';
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 { selectXcodeDeveloperDirectoryAsync } from '../utils/remoteDeviceRunSession';
import {
type ServeSimApplicationOptions,
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createPrivatePreviewUrlInputProvider,
createPublicPreviewInputProvider,
createServeSimLaunchInputProviders,
omitNgrokAuthtoken,
parseCorsOriginsInput,
parseFrameAncestorsInput,
selectXcodeDeveloperDirectoryAsync,
validateServeSimLaunchOptions,
} from '../utils/remoteDeviceRunSession';
import {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
@@ -36,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: [
BuildStepInput.createProvider({
id: 'android_log_sources',
required: false,
defaultValue: ['emulator-output'],
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
// Device. On iOS a Simulator name or UDID, on Android an AVD hardware profile.
BuildStepInput.createProvider({
id: 'device_identifier',
@@ -91,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,
@@ -102,18 +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,
@@ -130,31 +146,59 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: withLocalEgressSession(async (stepsCtx, { inputs, env, signal }) => {
fn: withLocalEgressSession(async (stepsCtx, { inputs, env: stepEnv, signal }) => {
const { logger, global } = stepsCtx;
const { runtimePlatform } = global;
const isIos = runtimePlatform === BuildRuntimePlatform.DARWIN;
// Validate everything before any expensive work starts.
const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(env);
const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(stepEnv);
const env = omitNgrokAuthtoken(stepEnv);
const 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 = isIos
? []
: AndroidEmulatorLogSourcesSchema.parse(inputs.android_log_sources.value);
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
const capture = parseNetworkCaptureInputs(
{
@@ -164,104 +208,210 @@ 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,
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,
tasks,
device: { booted, ready },
startup: { signal: startupSignal, abort: reason => startup.abort(reason) },
});
const startSessionAsync = async (warmSession?: WarmIosSession): 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,
logSources,
logCapture: getAndroidEmulatorLogCapture(global, env),
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: inputs.package_version.value as string | undefined,
// A missing or non-positive value disables the idle timeout (opt-in feature).
maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined,
maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined,
capture,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
tasks,
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,15 @@ 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,
type AndroidEmulatorLogSource,
AndroidEmulatorLogSourcesSchema,
} from '../../utils/androidEmulatorLogSources';
import { getAndroidEmulatorLogCapture } from '../utils/androidEmulatorLogs';
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 +40,12 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
name: 'Start Android Emulator',
__metricsId: 'eas/start_android_emulator',
inputProviders: [
BuildStepInput.createProvider({
id: 'android_log_sources',
required: false,
defaultValue: [...ALL_ANDROID_EMULATOR_LOG_SOURCES],
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
BuildStepInput.createProvider({
id: 'device_name',
required: false,
@@ -78,10 +91,12 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
required: true,
}),
],
fn: async ({ logger }, { inputs, outputs, env }) => {
fn: async ({ logger, global }, { inputs, outputs, env }) => {
const logSources = AndroidEmulatorLogSourcesSchema.parse(inputs.android_log_sources.value);
const logcatDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
);
const logCapture = getAndroidEmulatorLogCapture(global, env);
outputs.logcat_directory.set(logcatDirectory);
const deviceName = `${inputs.device_name.value}` as AndroidVirtualDeviceName;
@@ -95,6 +110,8 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
lcdHeight: inputs.lcd_height.value as number | undefined,
lcdDensity: inputs.lcd_density.value as number | undefined,
logcatDirectory,
logSources,
logCapture,
env,
logger,
});
@@ -133,6 +150,8 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
deviceName: cloneIdentifier,
env,
logcatDirectory,
logSources,
logCapture,
});
cloneSerialId = startResult.serialId;
@@ -202,6 +221,8 @@ export async function startAndroidEmulatorAsync({
lcdHeight,
lcdDensity,
logcatDirectory,
logSources,
logCapture,
env,
logger,
}: {
@@ -212,6 +233,8 @@ export async function startAndroidEmulatorAsync({
lcdHeight?: number;
lcdDensity?: number;
logcatDirectory: string;
logSources: readonly AndroidEmulatorLogSource[];
logCapture: AndroidEmulatorLogCapture;
env: BuildStepEnv;
logger: bunyan;
}): Promise<{
@@ -293,6 +316,8 @@ export async function startAndroidEmulatorAsync({
deviceName,
env,
logcatDirectory,
logSources,
logCapture,
...(localEgress ? { launchGate: localEgress.launchGate } : {}),
});
attemptSerialId = startResult.serialId;
Loaded 100 of 563 files, more files were not shown because too many files have changed in this diff. Show more