Compare commits

...
44 Commits
Author SHA1 Message Date
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
196 changed files with 19176 additions and 2555 deletions

No files matched your search

+3 -1
View File
@@ -19,7 +19,9 @@
"@expo/downloader",
"@expo/turtle-spawn",
"@expo/template-file",
"create-eas-build-function"
"create-eas-build-function",
"@expo/vm-tools",
"@expo/simulator-launcher"
]
],
"linked": [],
+71
View File
@@ -2,6 +2,77 @@
This is the log of notable changes to EAS CLI and related packages.
## [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
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "26.0.0",
"PRODUCTION": "26.0.0"
"STAGING": "27.4.0",
"PRODUCTION": "27.4.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "27.1.0",
"version": "27.4.1",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+45
View File
@@ -1,5 +1,50 @@
# @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
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "27.1.0",
"version": "27.4.1",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -42,18 +42,18 @@
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "27.1.0",
"@expo/eas-build-job": "27.1.0",
"@expo/downloader": "27.4.1",
"@expo/eas-build-job": "27.4.1",
"@expo/env": "^0.4.0",
"@expo/logger": "27.1.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": "27.1.0",
"@expo/template-file": "27.1.0",
"@expo/turtle-spawn": "27.1.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",
@@ -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)) {
@@ -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);
+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;
@@ -0,0 +1,18 @@
import { type CustomBuildContext } from '../../customBuildContext';
import { getEasFunctions } from '../easFunctions';
it('registers opt-in session functions with the existing inputs', () => {
const functions = getEasFunctions({ hasBuildJob: () => false } as CustomBuildContext);
for (const name of ['serve_sim_remote', 'agent_device', 'argent_remote', 'appium_remote']) {
const existing = functions.find(fn => fn.id === `start_${name}_session`)!;
const warm = functions.find(fn => fn.id === `start_warm_${name}_session`)!;
expect(warm.getFullId()).toBe(`eas/start_warm_${name}_session`);
const warmInputs = warm.inputProviders!.map(input => input(undefined as never, 'warm').id);
const existingInputs = existing.inputProviders!.map(
input => input(undefined as never, 'existing').id
);
expect(warmInputs).toEqual(existingInputs);
}
const ids = functions.map(fn => fn.getFullId());
expect(new Set(ids).size).toBe(ids.length);
});
@@ -33,6 +33,7 @@ import { createSubmitToGooglePlayBuildFunction } from './functions/submitToGoogl
import { createReadPackageJsonBuildFunction } from './functions/readPackageJson';
import { createRepackBuildFunction } from './functions/repack';
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
import { createUpdateResolvedVersionBuildFunction } from './functions/updateResolvedVersion';
import { resolveAppleTeamIdFromCredentialsFunction } from './functions/resolveAppleTeamIdFromCredentials';
import { createResolveBuildConfigBuildFunction } from './functions/resolveBuildConfig';
import {
@@ -111,8 +112,11 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
runFastlaneFunction(),
parseXcactivitylogFunction(),
createStartAgentDeviceSessionBuildFunction(ctx),
createStartAgentDeviceSessionBuildFunction(ctx, { warm: true }),
createStartArgentRemoteSessionBuildFunction(ctx),
createStartArgentRemoteSessionBuildFunction(ctx, { warm: true }),
createStartAppiumRemoteSessionBuildFunction(ctx),
createStartAppiumRemoteSessionBuildFunction(ctx, { warm: true }),
createStartAndroidEmulatorBuildFunction(),
createStartCuttlefishDeviceBuildFunction(),
createStartIosSimulatorBuildFunction(),
@@ -121,6 +125,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createFinishIosSimulatorRecordingsBuildFunction(),
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
createStartWebPreviewRemoteSessionBuildFunction(ctx),
createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true }),
createStartServeSimMetricsBuildFunction(),
createStartServeSimLogsBuildFunction(),
createCollectServeSimLogsBuildFunction(ctx),
@@ -154,6 +159,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,
@@ -6,6 +6,7 @@ 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';
@@ -54,6 +55,7 @@ const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnTh
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockResolvedValue();
// 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.
@@ -145,7 +147,11 @@ describe.each([
it('holds the host until session completion', async () => {
await runAsync();
expect(openPreview).toHaveBeenCalledWith({ baseDomain: 'example.test', authtoken: 'token' });
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ preview: { baseDomain: 'example.test', authtoken: 'token' } })
);
expect(openPreview).toHaveBeenCalledWith();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
@@ -350,6 +356,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 =>
@@ -360,25 +370,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);
}
@@ -386,16 +397,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);
});
});
@@ -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'];
@@ -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,
@@ -124,6 +125,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
}
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockResolvedValue();
lifetime = registerLocalEgressResources(stopEgress);
stopEgress.mockResolvedValue(undefined);
stopPreview.mockResolvedValue(undefined);
@@ -1,12 +1,15 @@
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 {
getDeviceRunSessionIdOrThrow,
@@ -21,6 +24,8 @@ import {
} from '../../utils/remoteDeviceRunSession';
import { createProcessOutput } from '../../utils/processOutput';
import { type StartupTasks, createStartupTasks } from '../../utils/startupTasks';
import { createWarmDeviceSessionServer } from '../../utils/warmDeviceSessionHost';
import { type WarmIosSession } from '../../utils/warmIosSession';
import { runAgentDeviceRemoteSessionAsync } from '../startAgentDeviceRemoteSession';
// The daemon entry path and the state directory are resolved from the home directory when
@@ -38,10 +43,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(),
@@ -74,10 +83,7 @@ const NO_CAPTURE: CaptureInputs = { networkCapture: false, networkCaptureFields:
async function runAsync(
logger: { info: jest.Mock; warn: jest.Mock; child: jest.Mock },
runtimePlatform: BuildRuntimePlatform,
capture: CaptureInputs = NO_CAPTURE,
disableShare = false,
privatePreviewUrl?: string,
maxIdleTimeMinutes?: number
options: Partial<Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]> = {}
): Promise<void> {
await runAgentDeviceRemoteSessionAsync(ctx, {
env: {},
@@ -89,16 +95,15 @@ async function runAsync(
ngrokAuthtoken: 'ngrok-token',
},
packageVersion: undefined,
maxIdleTimeMinutes,
maxIdleTimeMinutes: undefined,
maxDurationSeconds: undefined,
capture,
disableShare,
privatePreviewUrl,
tasks: createStartupTasks(logger as never),
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,
});
}
@@ -195,6 +200,213 @@ 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/+';
@@ -224,7 +436,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
} as never);
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(previewInputAt);
await runAsync(logger, BuildRuntimePlatform.LINUX, NO_CAPTURE, false, undefined, 5);
await runAsync(logger, BuildRuntimePlatform.LINUX, { maxIdleTimeMinutes: 5 });
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
@@ -235,8 +447,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
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(
@@ -247,13 +458,10 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
it('hands a private preview choice to the session host', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
await runAsync(
logger,
BuildRuntimePlatform.DARWIN,
NO_CAPTURE,
true,
'https://expo.dev/private-preview'
);
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
disableShare: true,
privatePreviewUrl: 'https://expo.dev/private-preview',
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
@@ -615,10 +823,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
ctx,
{} as never
);
const preview = await host.openPreviewAsync({
baseDomain: 'tunnel.example.com',
authtoken: 'ngrok-token',
});
const preview = await host.openPreviewAsync();
jest.mocked(host.openPreviewAsync).mockImplementation(async () => {
await previewReady.promise;
return preview;
@@ -4,7 +4,6 @@ import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { DeviceRunSessionWorkerApi } from '../../utils/deviceRunSessionWorkerApi';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { stopLocalEgressResourcesAsync } from '../../utils/localEgress';
import {
@@ -20,6 +19,7 @@ import {
getAgentDeviceRemoteSessionEnvOrThrow,
runAgentDeviceRemoteSessionAsync,
} from '../startAgentDeviceRemoteSession';
import { runOnWarmIosSimulatorAsync } from '../../utils/warmDeviceSessionHost';
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
import { startAndroidEmulatorAsync } from '../startAndroidEmulator';
import { bootIosSimulatorAsync } from '../startIosSimulator';
@@ -42,6 +42,7 @@ jest.mock('../startIosSimulator', () => ({
bootIosSimulatorAsync: jest.fn(),
}));
jest.mock('../startAndroidEmulator', () => ({ startAndroidEmulatorAsync: jest.fn() }));
jest.mock('../../utils/warmDeviceSessionHost');
jest.mock('../downloadBuild', () => ({ downloadBuildAsync: jest.fn() }));
jest.mock('../installBuild', () => ({ installBuildAsync: jest.fn() }));
jest.mock('../../utils/iosAppArtifact', () => ({
@@ -102,9 +103,10 @@ function runStep(
runtimePlatform: BuildRuntimePlatform,
inputValues: Record<string, unknown> = {},
signal?: AbortSignal,
stepCtx: CustomBuildContext = ctx
stepCtx: CustomBuildContext = ctx,
warm = false
): Promise<void> {
const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx);
const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx, { warm });
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const inputs = Object.fromEntries(
[
@@ -113,8 +115,10 @@ function runStep(
'lcd_width',
'lcd_height',
'lcd_density',
'install_app_path',
'build_id',
'application_archive_url',
'launch_app_identifier',
'launch_args',
'open_url',
'network_capture',
@@ -172,7 +176,6 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
emulatorPromise: Promise.resolve(),
shouldAdjustAnimationScale: true,
});
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/App.app' });
jest
.mocked(installBuildAsync)
.mockResolvedValue({ applicationIdentifier: 'dev.example.app', activityName: '.Main' });
@@ -188,7 +191,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
};
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(bootEnv);
await runStep(BuildRuntimePlatform.DARWIN, {
build_id: 'build-id',
install_app_path: '/tmp/App.app',
launch_args: ['-flag'],
open_url: 'exp://example.test',
});
@@ -202,14 +205,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
jest.mocked(runAgentDeviceRemoteSessionAsync).mock.invocationCallOrder[0]
);
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({
buildId: 'build-id',
extensions: ['app'],
graphqlClient,
robotAccessToken: 'robot-token',
})
);
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(1);
const device = sessionDevice();
expect(device).toEqual({
@@ -253,63 +249,21 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
);
});
it('downloads build_id through the device run session worker API when the job has a session token', async () => {
const sessionCtx = {
graphqlClient,
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
} as unknown as CustomBuildContext;
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' }, undefined, sessionCtx);
expect(downloadBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({
buildId: 'build-id',
deviceRunSessionWorkerApi: expect.any(DeviceRunSessionWorkerApi),
})
);
});
it('does not use the worker API without a session token or for an archive URL', async () => {
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' });
expect(downloadBuildAsync).toHaveBeenLastCalledWith(
expect.objectContaining({ buildId: 'build-id', deviceRunSessionWorkerApi: null })
);
const sessionCtx = {
graphqlClient,
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
} as unknown as CustomBuildContext;
await runStep(
BuildRuntimePlatform.LINUX,
{ application_archive_url: 'https://example.test/app.tar.gz' },
undefined,
sessionCtx
);
expect(downloadBuildAsync).toHaveBeenLastCalledWith(
expect.objectContaining({
applicationArchiveUrl: 'https://example.test/app.tar.gz',
deviceRunSessionWorkerApi: null,
})
);
});
it('fails before download or host startup when guarded device shutdown fails', async () => {
it('fails before app installation or host startup when guarded device shutdown fails', async () => {
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
});
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Booted' } as never);
jest.mocked(spawn).mockRejectedValue(new Error('shutdown failed'));
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'shutdown failed'
);
expect(downloadBuildAsync).not.toHaveBeenCalled();
await expect(
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app' })
).rejects.toThrow('shutdown failed');
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
});
it('provides Simulator preparation and downloaded startup options to the remote session', async () => {
it('provides Simulator preparation and local app startup options to the remote session', async () => {
const hostReady = deferred();
jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({
SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib',
@@ -324,7 +278,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
});
const step = runStep(BuildRuntimePlatform.DARWIN, {
device_identifier: 'iPhone 17',
build_id: 'build-id',
install_app_path: '/tmp/App.app',
launch_args: ['-flag'],
open_url: 'exp://example.test',
});
@@ -334,7 +288,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
env: {},
});
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).toHaveBeenCalledTimes(1);
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
artifactPath: '/tmp/App.app',
env: {},
@@ -352,15 +306,15 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
});
});
it('fails a guarded session before download or host startup if its boot files are missing', async () => {
it('fails a guarded session before app installation or host startup if its boot files are missing', async () => {
jest
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
.mockRejectedValue(new Error('missing bin/egress-guard-check'));
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'missing bin/egress-guard-check'
);
await expect(
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app' })
).rejects.toThrow('missing bin/egress-guard-check');
expect(startLocalEgressGuardRelayAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
expect(spawn).not.toHaveBeenCalled();
@@ -375,35 +329,18 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
jest
.mocked(runAgentDeviceRemoteSessionAsync)
.mockRejectedValueOnce(new Error('guard check failed'));
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'guard check failed'
);
await expect(
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app' })
).rejects.toThrow('guard check failed');
expect(startLocalEgressGuardRelayAsync).toHaveBeenCalledTimes(1);
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1);
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
});
it('waits for the download before handing the app to the host', async () => {
const download = deferred<{ artifactPath: string }>();
jest.mocked(downloadBuildAsync).mockReturnValue(download.promise);
const step = runStep(BuildRuntimePlatform.DARWIN, {
application_archive_url: 'https://example.test/app.tar.gz',
});
await flushAsync();
expect(installBuildAsync).not.toHaveBeenCalled();
download.resolve({ artifactPath: '/tmp/App.app' });
await step;
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
expect(installBuildAsync).not.toHaveBeenCalled();
});
it('passes the selected Simulator to the host even when there is no app', async () => {
await runStep(BuildRuntimePlatform.DARWIN);
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
expect(sessionDevice()).toEqual({
@@ -420,7 +357,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
lcd_width: 720,
lcd_height: 1600,
lcd_density: 300,
build_id: 'build-id',
install_app_path: '/tmp/App.app',
});
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
@@ -433,131 +370,33 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
lcdDensity: 300,
})
);
expect(downloadBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({ extensions: ['apk'] })
);
expect(launchApplicationAsync).toHaveBeenCalledWith(
expect.objectContaining({ applicationIdentifier: 'dev.example.app', activityName: '.Main' })
);
});
it('fails the session when the download fails, without an unhandled rejection', async () => {
jest.mocked(downloadBuildAsync).mockRejectedValue(new Error('download failed'));
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'download failed'
);
expect(installBuildAsync).not.toHaveBeenCalled();
});
/** A download that runs until its abort signal fires, like a stalled one. */
function mockStalledDownload(): { aborted: () => boolean; settled: () => boolean } {
let aborted = false;
let settled = false;
jest.mocked(downloadBuildAsync).mockImplementation(
({ signal }) =>
new Promise((_resolve, reject) => {
signal!.addEventListener('abort', () => {
aborted = true;
// Settles a bit later, like a request that winds down.
setImmediate(() => {
settled = true;
reject(new Error('The user aborted a request.'));
});
});
})
);
return { aborted: () => aborted, settled: () => settled };
}
/** Runs the session like the real one: fails with the daemon, then waits for every part. */
function mockSessionWithFailingDaemon(beforeFailure?: () => Promise<void>): void {
jest
.mocked(runAgentDeviceRemoteSessionAsync)
.mockImplementation(async (_ctx, { tasks, device }) => {
const ready = runDevicePreparationAsync(device);
await beforeFailure?.();
const daemon = tasks.run('agent-device daemon', async () => {
throw new Error('daemon failed');
});
try {
await Promise.all([daemon, ready]);
} finally {
await Promise.allSettled([daemon, ready]);
}
});
}
it('stops the download and waits for it when Android boot fails', async () => {
const download = mockStalledDownload();
jest.mocked(startAndroidEmulatorAsync).mockRejectedValue(new Error('boot failed'));
await expect(runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' })).rejects.toThrow(
'boot failed'
);
expect(download.aborted()).toBe(true);
// The step returned only after the download stopped.
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
});
it('stops a stalled download and does not install when the daemon fails', async () => {
const download = mockStalledDownload();
mockSessionWithFailingDaemon();
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'daemon failed'
);
expect(download.aborted()).toBe(true);
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
});
it('drains the download when host startup fails before app preparation', async () => {
const download = mockStalledDownload();
jest.mocked(runAgentDeviceRemoteSessionAsync).mockRejectedValue(new Error('host failed'));
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'host failed'
);
expect(download.aborted()).toBe(true);
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
});
it('does not start work for an already cancelled step', async () => {
const controller = new AbortController();
controller.abort(new Error('cancelled'));
await expect(
runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal)
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app' }, controller.signal)
).rejects.toThrow('cancelled');
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('cancels and drains a pending download through the external signal', async () => {
const controller = new AbortController();
const download = mockStalledDownload();
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal);
const failure = expect(step).rejects.toThrow('cancelled');
await flushAsync();
controller.abort(new Error('cancelled'));
await failure;
expect(download.aborted()).toBe(true);
expect(download.settled()).toBe(true);
expect(installBuildAsync).not.toHaveBeenCalled();
});
it('does not start device or download work after cancellation during Xcode selection', async () => {
it('does not start device or app work after cancellation during Xcode selection', async () => {
const controller = new AbortController();
const selected = deferred();
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockReturnValue(selected.promise);
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal);
const step = runStep(
BuildRuntimePlatform.DARWIN,
{ install_app_path: '/tmp/App.app' },
controller.signal
);
const failure = expect(step).rejects.toThrow('cancelled');
await flushAsync();
controller.abort(new Error('cancelled'));
@@ -566,42 +405,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
await failure;
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('drains app preparation when the daemon fails', async () => {
const metadata = deferred<string>();
const metadataStarted = deferred();
jest.mocked(readIosApplicationIdentifierAsync).mockImplementation(() => {
metadataStarted.resolve();
return metadata.promise;
});
mockSessionWithFailingDaemon(async () => {
await metadataStarted.promise;
setImmediate(() => metadata.resolve('dev.example.app'));
});
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
'daemon failed'
);
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
expect(installBuildAsync).not.toHaveBeenCalled();
expect(launchApplicationAsync).not.toHaveBeenCalled();
expect(deviceLaunch).toBeUndefined();
});
it('rejects conflicting application inputs before it boots anything', async () => {
await expect(
runStep(BuildRuntimePlatform.DARWIN, {
build_id: 'build-id',
application_archive_url: 'https://example.test/app.tar.gz',
})
).rejects.toThrow('Pass only one of build_id or application_archive_url.');
await expect(
runStep(BuildRuntimePlatform.DARWIN, { open_url: 'exp://example.test' })
).rejects.toThrow('launch_args and open_url need an application');
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
@@ -617,8 +421,10 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
'lcd_width',
'lcd_height',
'lcd_density',
'install_app_path',
'build_id',
'application_archive_url',
'launch_app_identifier',
'launch_args',
'open_url',
'network_capture',
@@ -678,12 +484,15 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
['NUL URL', { open_url: 'example:\u0000screen' }],
['URL length', { open_url: `example://${'a'.repeat(8192)}` }],
])('rejects iOS launch %s before startup work', async (_name, inputs) => {
const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id', ...inputs });
const step = runStep(BuildRuntimePlatform.DARWIN, {
install_app_path: '/tmp/App.app',
...inputs,
});
await expect(step).rejects.toBeInstanceOf(UserError);
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled();
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
@@ -692,16 +501,22 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
['command length', { launch_args: ['--es', 'quotes', "'".repeat(20_000)] }],
['NUL URL', { open_url: 'example:\u0000screen' }],
])('rejects Android launch %s before startup work', async (_name, inputs) => {
const step = runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', ...inputs });
const step = runStep(BuildRuntimePlatform.LINUX, {
install_app_path: '/tmp/App.app',
...inputs,
});
await expect(step).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
expect(downloadBuildAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('keeps Android launch inputs on its existing direct path', async () => {
const launchArgs = ['a'.repeat(9000)];
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', launch_args: launchArgs });
await runStep(BuildRuntimePlatform.LINUX, {
install_app_path: '/tmp/App.app',
launch_args: launchArgs,
});
expect(launchApplicationAsync).toHaveBeenCalledWith(
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, launchArgs })
);
@@ -728,4 +543,170 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('requires an application for launch options before starting the device', async () => {
await expect(
runStep(BuildRuntimePlatform.DARWIN, { open_url: 'fixture://route' })
).rejects.toThrow('pass install_app_path or launch_app_identifier');
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('launches an installed iOS application without reading an app artifact', async () => {
await runStep(BuildRuntimePlatform.DARWIN, {
launch_app_identifier: 'dev.installed.app',
launch_args: ['--test'],
open_url: 'fixture://route',
});
expect(readIosApplicationIdentifierAsync).not.toHaveBeenCalled();
expect(deviceLaunch).toEqual({
installAppPath: undefined,
launchAppIdentifier: 'dev.installed.app',
launchArgs: ['--test'],
openUrl: 'fixture://route',
});
});
it('keeps explicit application identifier launch iOS-only', async () => {
await expect(
runStep(BuildRuntimePlatform.LINUX, { launch_app_identifier: 'dev.app' })
).rejects.toThrow('Pass install_app_path for Android');
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
});
it('still downloads a build_id app and launches it on iOS', async () => {
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/Downloaded.app' });
await runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id', launch_args: ['-flag'] });
expect(downloadBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({ buildId: 'build-id', extensions: ['app'] })
);
expect(deviceLaunch).toEqual({
installAppPath: '/tmp/Downloaded.app',
launchAppIdentifier: 'dev.example.app',
launchArgs: ['-flag'],
openUrl: undefined,
});
});
it('still downloads an application_archive_url app and installs it on Android', async () => {
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/Downloaded.apk' });
await runStep(BuildRuntimePlatform.LINUX, {
application_archive_url: 'https://example.test/app.tar.gz',
});
expect(downloadBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({
applicationArchiveUrl: 'https://example.test/app.tar.gz',
extensions: ['apk'],
})
);
expect(installBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({ artifactPath: '/tmp/Downloaded.apk' })
);
expect(launchApplicationAsync).toHaveBeenCalledTimes(1);
});
it('runs the warm step on the warm Simulator for a local app only', async () => {
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation((_claim, run) => run());
const runWarm = (inputs: Record<string, unknown>) =>
runStep(BuildRuntimePlatform.DARWIN, inputs, undefined, ctx, true);
await runWarm({ install_app_path: '/tmp/App.app', device_identifier: 'iPhone 17' });
expect(runOnWarmIosSimulatorAsync).toHaveBeenCalledWith(
expect.objectContaining({ engine: 'agent-device', requestedDevice: 'iPhone 17' }),
expect.any(Function)
);
jest.mocked(runOnWarmIosSimulatorAsync).mockClear();
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/Downloaded.app' });
await runWarm({ build_id: 'build-id' });
expect(runOnWarmIosSimulatorAsync).not.toHaveBeenCalled();
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(2);
});
it('accepts only one application source', async () => {
await expect(
runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app', build_id: 'id' })
).rejects.toThrow('Pass only one of install_app_path, build_id or application_archive_url.');
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
it('waits for Android boot before installing the supplied local APK', async () => {
const boot = deferred();
jest.mocked(startAndroidEmulatorAsync).mockReturnValue(boot.promise as never);
const step = runStep(BuildRuntimePlatform.LINUX, { install_app_path: '/tmp/App.apk' });
await flushAsync();
expect(installBuildAsync).not.toHaveBeenCalled();
boot.resolve();
await step;
expect(installBuildAsync).toHaveBeenCalledWith(
expect.objectContaining({ artifactPath: '/tmp/App.apk' })
);
expect(launchApplicationAsync).toHaveBeenCalledTimes(1);
});
it('drains an in-flight Android install on cancellation and never launches it', async () => {
const controller = new AbortController();
const installation = deferred<{ applicationIdentifier: string; activityName: string }>();
jest.mocked(installBuildAsync).mockReturnValue(installation.promise);
const step = runStep(
BuildRuntimePlatform.LINUX,
{ install_app_path: '/tmp/App.apk' },
controller.signal
);
let settled = false;
void step.then(
() => (settled = true),
() => (settled = true)
);
const failure = expect(step).rejects.toThrow('cancelled');
await flushAsync();
expect(installBuildAsync).toHaveBeenCalledTimes(1);
controller.abort(new Error('cancelled'));
await flushAsync();
expect(settled).toBe(false);
installation.resolve({ applicationIdentifier: 'dev.app', activityName: '.Main' });
await failure;
expect(launchApplicationAsync).not.toHaveBeenCalled();
});
it('drains an in-flight Android install when session startup fails', async () => {
const entered = deferred();
const installation = deferred<{ applicationIdentifier: string; activityName: string }>();
jest.mocked(installBuildAsync).mockImplementation(() => {
entered.resolve();
return installation.promise;
});
jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async () => {
await entered.promise;
throw new Error('session startup failed');
});
const step = runStep(BuildRuntimePlatform.LINUX, { install_app_path: '/tmp/App.apk' });
let settled = false;
void step.then(
() => (settled = true),
() => (settled = true)
);
const failure = expect(step).rejects.toThrow('session startup failed');
await entered.promise;
await flushAsync();
expect(settled).toBe(false);
installation.resolve({ applicationIdentifier: 'dev.app', activityName: '.Main' });
await failure;
expect(launchApplicationAsync).not.toHaveBeenCalled();
});
it('drains iOS metadata reading on cancellation without starting the session', async () => {
const controller = new AbortController();
const metadata = deferred<string>();
jest.mocked(readIosApplicationIdentifierAsync).mockReturnValue(metadata.promise);
const step = runStep(
BuildRuntimePlatform.DARWIN,
{ install_app_path: '/tmp/App.app' },
controller.signal
);
const failure = expect(step).rejects.toThrow('cancelled');
await flushAsync();
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1);
controller.abort(new Error('cancelled'));
metadata.resolve('dev.app');
await failure;
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
});
});
@@ -13,6 +13,11 @@ import { turtleFetch } from '../../../utils/turtleFetch';
import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import { preparePreinstalledWdaAsync } from '../../utils/preinstalledWda';
import {
WarmSessionAdoptionError,
runOnWarmIosSimulatorAsync,
} from '../../utils/warmDeviceSessionHost';
import * as warmIosSession from '../../utils/warmIosSession';
import {
ensureFfmpegInstalledOnceAsync,
selectXcodeDeveloperDirectoryAsync,
@@ -58,6 +63,14 @@ jest.mock('../../../utils/turtleFetch', () => ({
}));
jest.mock('../../utils/appiumEvents', () => ({ startAppiumEventCollectionAsync: jest.fn() }));
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/warmDeviceSessionHost', () => ({
...jest.createMockFromModule<typeof import('../../utils/warmDeviceSessionHost')>(
'../../utils/warmDeviceSessionHost'
),
WarmSessionAdoptionError: jest.requireActual<typeof import('../../utils/warmDeviceSessionHost')>(
'../../utils/warmDeviceSessionHost'
).WarmSessionAdoptionError,
}));
jest.mock('../../utils/preinstalledWda', () => ({ preparePreinstalledWdaAsync: jest.fn() }));
jest.mock('../../utils/iosAppArtifact', () => ({
...jest.requireActual('../../utils/iosAppArtifact'),
@@ -275,6 +288,36 @@ describe('createStartAppiumRemoteSessionBuildFunction', () => {
});
});
it('claims the warm Simulator for Appium without its package version', async () => {
jest.mocked(runOnWarmIosSimulatorAsync).mockResolvedValue(undefined);
const buildFunction = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext, {
warm: true,
});
expect(buildFunction.id).toBe('start_warm_appium_remote_session');
await buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: '3.5.0' },
max_idle_time_minutes: { value: undefined },
device_identifier: { value: 'iPhone 17' },
},
outputs: {},
env: {
DEVICE_RUN_SESSION_ID: 'session',
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'tunnel.example',
NGROK_AUTHTOKEN: 'ngrok-token',
},
} as never
);
const [claim] = jest.mocked(runOnWarmIosSimulatorAsync).mock.calls[0];
expect(claim).toMatchObject({ engine: 'appium', requestedDevice: 'iPhone 17' });
expect(claim).not.toHaveProperty('packageVersion');
});
it('declares the launch inputs so serve-sim can launch the app', () => {
const ctx = {} as unknown as CustomBuildContext;
const buildFunction = createStartAppiumRemoteSessionBuildFunction(ctx);
@@ -298,6 +341,7 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
const stopError = new Error('Process output drain timed out after 5000ms.');
const stopAppium = jest.fn();
const stopEventCollection = jest.fn();
const finishHost = jest.fn();
beforeEach(() => {
jest.mocked(ensureFfmpegInstalledOnceAsync).mockResolvedValue(undefined);
@@ -331,6 +375,7 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
subdomainId: 'appium-abc',
stopAsync: jest.fn(),
});
finishHost.mockResolvedValue(undefined);
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
@@ -338,7 +383,7 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
closeAsync: jest.fn(),
}),
getLastPreviewActivityAt: jest.fn(),
finishAsync: jest.fn(),
finishAsync: finishHost,
});
});
@@ -350,9 +395,10 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
logger: { info: jest.Mock; warn: jest.Mock },
maxIdleTimeMinutes?: number,
runtimePlatform = BuildRuntimePlatform.LINUX,
startupInputs: Record<string, { value: unknown }> = {}
startupInputs: Record<string, { value: unknown }> = {},
warm = false
): Promise<void> {
await createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
await createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext, { warm }).fn!(
{
logger,
global: { runtimePlatform },
@@ -382,6 +428,210 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
);
}
it('passes the existing Android selection to the preview host', async () => {
stopAppium.mockResolvedValue(undefined);
await runSessionAsync(logger);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ previewDeviceId: 'emulator-5554' })
);
});
it('passes the selected iOS preview target without enabling Simulator startup', async () => {
stopAppium.mockResolvedValue(undefined);
const udid = 'AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA';
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ udid }, { udid: 'BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB' }] as Awaited<
ReturnType<typeof IosSimulatorUtils.getAvailableDevicesAsync>
>);
await runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN);
const options = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
expect(options).toHaveProperty('previewDeviceId', udid);
expect(options.iosSimulatorUdid).toBeUndefined();
});
function mockWarmSession(): jest.Mock {
const stopWarm = jest.fn().mockResolvedValue(undefined);
const warm: warmIosSession.WarmIosSession = {
device: { udid: 'CHOSEN-IOS-UDID', name: 'iPhone 17' },
server: { pid: 100, port: 3200, token: 'warm-token' },
agentDeviceStateDir: '/warm/agent-device',
argentEventLogPath: '/warm/argent-events',
prepareToolsAsync: jest.fn(),
process: {
pid: 100,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: stopWarm,
},
};
jest.spyOn(warmIosSession, 'tryClaimWarmIosSessionAsync').mockResolvedValue(warm);
jest
.mocked(runOnWarmIosSimulatorAsync)
.mockImplementation(
jest.requireActual<typeof import('../../utils/warmDeviceSessionHost')>(
'../../utils/warmDeviceSessionHost'
).runOnWarmIosSimulatorAsync
);
return stopWarm;
}
it.each(['event collection', 'Appium process'])(
'waits for warm %s teardown before finishing the session host',
async stage => {
mockWarmSession();
stopAppium.mockResolvedValue(undefined);
let finishTeardown = () => {};
const teardownGate = new Promise<void>(resolve => {
finishTeardown = resolve;
});
let startedTeardown = () => {};
const teardownStarted = new Promise<void>(resolve => {
startedTeardown = resolve;
});
const stop = async () => {
startedTeardown();
await teardownGate;
};
(stage === 'event collection' ? stopEventCollection : stopAppium).mockImplementationOnce(
stop
);
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {}, true);
try {
await teardownStarted;
await Promise.resolve();
expect(finishHost).not.toHaveBeenCalled();
if (stage === 'event collection') {
expect(stopAppium).not.toHaveBeenCalled();
}
} finally {
finishTeardown();
await running;
}
expect(finishHost).toHaveBeenCalledTimes(1);
expect(stopAppium.mock.invocationCallOrder[0]).toBeLessThan(
finishHost.mock.invocationCallOrder[0]
);
const home = jest.mocked(spawnDetached).mock.calls[0][0].env.APPIUM_HOME;
expect(fs.existsSync(home ?? '')).toBe(false);
}
);
it.each(['event collection', 'Appium process'])(
'finishes the warm host after %s teardown fails',
async stage => {
const stopWarm = mockWarmSession();
stopAppium.mockResolvedValue(undefined);
const error = new Error(`${stage} stop failed`);
(stage === 'event collection' ? stopEventCollection : stopAppium).mockRejectedValueOnce(
error
);
await expect(
runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {}, true)
).rejects.toBe(error);
expect(stopAppium).toHaveBeenCalledTimes(1);
expect(finishHost).toHaveBeenCalledTimes(1);
expect(stopWarm).toHaveBeenCalledTimes(1);
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
}
);
it.each([false, true])(
'finishes a cold host while Appium is still stopping (warm step: %s)',
async warm => {
if (warm) {
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementationOnce((_claim, run) => run());
}
let finishAppium = () => {};
const appiumGate = new Promise<void>(resolve => {
finishAppium = resolve;
});
let startedAppiumStop = () => {};
const appiumStopping = new Promise<void>(resolve => {
startedAppiumStop = resolve;
});
stopAppium.mockImplementationOnce(async () => {
startedAppiumStop();
await appiumGate;
});
let startedHostFinish = () => {};
const hostFinishing = new Promise<void>(resolve => {
startedHostFinish = resolve;
});
finishHost.mockImplementationOnce(async () => startedHostFinish());
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {}, warm);
try {
await appiumStopping;
await Promise.resolve();
expect(finishHost).toHaveBeenCalledTimes(1);
await hostFinishing;
} finally {
finishAppium();
await running;
}
}
);
it.each(['stopped', 'process stop failed', 'home cleanup failed'])(
'retries a failed warm adoption only after Appium stops (%s)',
async cleanup => {
const stopFails = cleanup === 'process stop failed';
const adoptionError = new WarmSessionAdoptionError();
const stopWarm = mockWarmSession();
let failHost: (error: Error) => void = () => {};
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
new Promise((_resolve, reject) => {
failHost = reject;
})
);
jest.mocked(spawnDetached).mockImplementationOnce(() => {
failHost(adoptionError);
return {
pid: 4242,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: stopAppium,
};
});
if (!stopFails) {
stopAppium.mockResolvedValue(undefined);
}
if (cleanup === 'home cleanup failed') {
jest.spyOn(fs.promises, 'rm').mockRejectedValueOnce(new Error('home cleanup failed'));
}
const running = runSessionAsync(
{ info: jest.fn(), warn: jest.fn() },
undefined,
BuildRuntimePlatform.DARWIN,
{ boot_simulator: { value: true }, device_identifier: { value: 'iPhone 17' } },
true
);
if (stopFails) {
await expect(running).rejects.toMatchObject({ errors: [adoptionError, stopError] });
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
expect(stopWarm).toHaveBeenCalledTimes(1);
} else {
await expect(running).resolves.toBeUndefined();
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(2);
expect(stopWarm).toHaveBeenCalledTimes(1);
}
expect(stopAppium).toHaveBeenCalledTimes(stopFails ? 1 : 2);
if (cleanup === 'home cleanup failed') {
const home = jest.mocked(spawnDetached).mock.calls[0][0].env.APPIUM_HOME;
if (home) {
await fs.promises.rm(home, { recursive: true, force: true });
}
}
}
);
it.each([false, true])(
'preserves preview settings with Simulator startup %s',
async bootSimulator => {
@@ -24,6 +24,11 @@ import {
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import {
createWarmDeviceSessionServer,
runOnWarmIosSimulatorAsync,
} from '../../utils/warmDeviceSessionHost';
import { type WarmIosSession } from '../../utils/warmIosSession';
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact';
@@ -50,6 +55,7 @@ jest.mock('../../utils/argentEvents', () => ({
startArgentEventCollectionAsync: jest.fn(),
}));
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/warmDeviceSessionHost');
jest.mock('../../utils/iosAppArtifact', () => ({
...jest.requireActual('../../utils/iosAppArtifact'),
readIosApplicationIdentifierAsync: jest.fn().mockResolvedValue('dev.example.app'),
@@ -605,6 +611,72 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
expect(JSON.stringify(logger.info.mock.calls)).not.toContain('secret');
});
it('uses the warm Argent server and drains its artifacts before stopping the host', async () => {
const ctx = {} as unknown as CustomBuildContext;
const warmServer = { port: 3200 } as never;
const warm = {
device: { udid: 'WARM-UDID', name: 'iPhone 17' },
argentEventLogPath: '/warm/argent-events',
tools: { pid: 30, port: 3201, token: 'warm-token' },
} as WarmIosSession;
jest.mocked(createWarmDeviceSessionServer).mockReturnValue(warmServer);
jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation((_claim, run) => run(warm));
let releaseArtifacts = () => {};
const artifactsDrained = new Promise<void>(resolve => {
releaseArtifacts = resolve;
});
let pollingStarted = () => {};
const polling = new Promise<void>(resolve => {
pollingStarted = resolve;
});
jest.mocked(pollArgentArtifactsForUploadAsync).mockImplementationOnce(async () => {
pollingStarted();
await artifactsDrained;
});
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx, { warm: true });
expect(buildFunction.id).toBe('start_warm_argent_remote_session');
if (!buildFunction.fn) {
throw new Error('Expected the Argent session function.');
}
const running = buildFunction.fn(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
max_duration_seconds: { value: undefined },
},
outputs: {},
env: {},
} as never
);
try {
await polling;
await new Promise<void>(resolve => setImmediate(resolve));
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalled();
expect(mockPreviewStopAsync).not.toHaveBeenCalled();
} finally {
releaseArtifacts();
await running;
}
expect(spawn).not.toHaveBeenCalled();
expect(spawnDetached).not.toHaveBeenCalled();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(ctx, expect.anything(), warmServer);
expect(startNgrokTunnelAsync).toHaveBeenCalledWith(expect.objectContaining({ port: 3201 }));
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({ toolsAuthToken: 'warm-token' }),
})
);
expect(startArgentEventCollectionAsync).toHaveBeenCalledWith(
expect.objectContaining({ eventLogPath: '/warm/argent-events' })
);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
const ctx = {} as unknown as CustomBuildContext;
const logger = { info: jest.fn(), warn: jest.fn() };
@@ -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;
@@ -12,9 +12,15 @@ import {
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import {
createWarmDeviceSessionServer,
runOnWarmIosSimulatorAsync,
} from '../../utils/warmDeviceSessionHost';
import { type WarmIosSession } from '../../utils/warmIosSession';
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/warmDeviceSessionHost');
jest.mock('../../../utils/IosSimulatorUtils', () => ({
IosSimulatorUtils: {
resolveUdidAsync: jest.fn().mockResolvedValue('selected-ios-udid'),
@@ -89,6 +95,28 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
stopAsync.mockResolvedValue(undefined);
});
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];
@@ -246,6 +274,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
env: childEnv,
logger,
timeoutMs: 60_000,
preview: { baseDomain: 'tunnel.example.com', authtoken: 'ngrok-token' },
signal: undefined,
...(selectsXcode
? {
@@ -265,10 +294,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
networkCaptureFields: [],
});
const host = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
expect(host.openPreviewAsync).toHaveBeenCalledWith({
baseDomain: 'tunnel.example.com',
authtoken: 'ngrok-token',
});
expect(host.openPreviewAsync).toHaveBeenCalledWith();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
ctx,
deviceRunSessionId: 'device-run-session-id',
@@ -198,7 +198,10 @@ it('does not delete the edit or publish outputs when commit outcome is unknown',
return [204];
});
const task = submission();
await expect(task.run()).rejects.toBeInstanceOf(SystemError);
const error = await task.run().catch(error => error);
expect(error).toBeInstanceOf(SystemError);
expect((error as Error).message).toContain('HTTP 404');
expect((error as Error).message).toContain('Could not confirm whether Google Play saved');
expect(deleted).toBe(false);
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
});
@@ -0,0 +1,407 @@
import { BuildStep, spawnAsync } from '@expo/steps';
import fs from 'node:fs';
import path from 'node:path';
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
jest.mock('@expo/steps', () => ({
...jest.requireActual('@expo/steps'),
spawnAsync: jest.fn(),
}));
const mockedSpawnAsync = jest.mocked(spawnAsync);
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createTestAndroidJob } from '../../../__tests__/utils/job';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { BuildContext } from '../../../context';
import { CustomBuildContext } from '../../../customBuildContext';
import { parseInfoPlistBuffer } from '../readIpaInfo';
import {
createUpdateResolvedVersionBuildFunction,
extractAndroidVersionAsync,
extractIosVersionAsync,
parseAaptOutput,
parseManifestXml,
} from '../updateResolvedVersion';
describe(parseAaptOutput, () => {
it('extracts versionName and versionCode from aapt2 output', () => {
const output = `package: name='com.example.app' versionCode='42' versionName='2.5.0' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34' compileSdkVersionCodename='14'
sdkVersion:'21'
targetSdkVersion:'34'
uses-permission: name='android.permission.INTERNET'`;
expect(parseAaptOutput(output)).toEqual({
appVersion: '2.5.0',
appBuildVersion: '42',
});
});
it('handles output with no version info', () => {
expect(parseAaptOutput('some unrelated output')).toEqual({
appVersion: undefined,
appBuildVersion: undefined,
});
});
it('handles versionName with prerelease suffix', () => {
const output = `package: name='com.example' versionCode='1' versionName='1.0.0-beta.1'`;
expect(parseAaptOutput(output)).toEqual({
appVersion: '1.0.0-beta.1',
appBuildVersion: '1',
});
});
it('keeps an unescaped apostrophe in versionName', () => {
const output = `package: name='com.example' versionCode='1' versionName='1.0-O'Brien' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34'
sdkVersion:'21'`;
expect(parseAaptOutput(output)).toEqual({
appVersion: "1.0-O'Brien",
appBuildVersion: '1',
});
});
it('keeps a versionName that contains a quote and an equals sign', () => {
const output = `package: name='com.example' versionCode='1' versionName='1.0' build=beta'
sdkVersion:'21'`;
expect(parseAaptOutput(output)).toEqual({
appVersion: "1.0' build=beta",
appBuildVersion: '1',
});
});
it('stops versionName at the split field of a split APK', () => {
const output = `package: name='com.example' versionCode='42' versionName='2.5.0' split='feature' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34'
sdkVersion:'21'`;
expect(parseAaptOutput(output)).toEqual({
appVersion: '2.5.0',
appBuildVersion: '42',
});
});
it('treats an empty versionName as missing', () => {
const output = `package: name='com.example' versionCode='42' versionName='' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34'
sdkVersion:'21'`;
expect(parseAaptOutput(output)).toEqual({
appVersion: undefined,
appBuildVersion: '42',
});
});
});
describe(parseManifestXml, () => {
it('extracts versionName and versionCode from bundletool manifest XML', () => {
const xml = `<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:versionCode="10"
android:versionName="3.1.0"
package="com.example.app">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
</manifest>`;
expect(parseManifestXml(xml)).toEqual({
appVersion: '3.1.0',
appBuildVersion: '10',
});
});
it('handles XML with no version attributes', () => {
const xml = `<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.app">
</manifest>`;
expect(parseManifestXml(xml)).toEqual({
appVersion: undefined,
appBuildVersion: undefined,
});
});
});
describe('simulator .app Info.plist parsing', () => {
const FIXTURES_DIR = path.join(__dirname, 'fixtures');
const SIMULATOR_APP_DIR = path.join(FIXTURES_DIR, 'TestSimulator.app');
const INFO_PLIST_PATH = path.join(SIMULATOR_APP_DIR, 'Info.plist');
beforeAll(async () => {
// Create a minimal XML Info.plist fixture
await fs.promises.mkdir(SIMULATOR_APP_DIR, { recursive: true });
await fs.promises.writeFile(
INFO_PLIST_PATH,
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>com.example.test</string>
<key>CFBundleShortVersionString</key>
<string>4.2.0</string>
<key>CFBundleVersion</key>
<string>99</string>
</dict>
</plist>`
);
});
afterAll(async () => {
await fs.promises.rm(SIMULATOR_APP_DIR, { recursive: true, force: true });
});
it('reads version from a .app Info.plist', async () => {
const buffer = await fs.promises.readFile(INFO_PLIST_PATH);
const infoPlist = parseInfoPlistBuffer(buffer);
expect(infoPlist.CFBundleShortVersionString).toBe('4.2.0');
expect(infoPlist.CFBundleVersion).toBe('99');
});
});
describe(extractIosVersionAsync, () => {
const FIXTURES_DIR = path.join(__dirname, 'fixtures');
const SIMULATOR_APP_DIR = path.join(FIXTURES_DIR, 'ExtractVersion.app');
const INFO_PLIST_PATH = path.join(SIMULATOR_APP_DIR, 'Info.plist');
const IPA_FIXTURE_PATH = path.join(FIXTURES_DIR, 'SmallestAppExample.ipa');
beforeAll(async () => {
await fs.promises.mkdir(SIMULATOR_APP_DIR, { recursive: true });
await fs.promises.writeFile(
INFO_PLIST_PATH,
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleShortVersionString</key>
<string>2.0.1</string>
<key>CFBundleVersion</key>
<string>55</string>
</dict>
</plist>`
);
});
afterAll(async () => {
await fs.promises.rm(SIMULATOR_APP_DIR, { recursive: true, force: true });
});
it('extracts version from a .app directory', async () => {
const result = await extractIosVersionAsync(SIMULATOR_APP_DIR);
expect(result).toEqual({
appVersion: '2.0.1',
appBuildVersion: '55',
});
});
it('extracts version from an .ipa file', async () => {
const result = await extractIosVersionAsync(IPA_FIXTURE_PATH);
expect(result).toEqual({
appVersion: '1.0',
appBuildVersion: '1',
});
});
});
describe(extractAndroidVersionAsync, () => {
afterEach(() => {
mockedSpawnAsync.mockReset();
});
it('extracts version from an .apk via aapt2', async () => {
mockedSpawnAsync.mockResolvedValue({
stdout: `package: name='com.example.app' versionCode='42' versionName='2.5.0'`,
stderr: '',
} as any);
const result = await extractAndroidVersionAsync('/path/to/app.apk');
expect(mockedSpawnAsync).toHaveBeenCalledWith(
'aapt2',
['dump', 'badging', '/path/to/app.apk'],
{
stdio: 'pipe',
}
);
expect(result).toEqual({
appVersion: '2.5.0',
appBuildVersion: '42',
});
});
it('extracts version from an .aab via bundletool', async () => {
mockedSpawnAsync.mockResolvedValue({
stdout: `<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:versionCode="10"
android:versionName="3.1.0"
package="com.example.app">
</manifest>`,
stderr: '',
} as any);
const result = await extractAndroidVersionAsync('/path/to/app.aab');
expect(mockedSpawnAsync).toHaveBeenCalledWith(
'bundletool',
['dump', 'manifest', '--bundle', '/path/to/app.aab'],
{ stdio: 'pipe' }
);
expect(result).toEqual({
appVersion: '3.1.0',
appBuildVersion: '10',
});
});
it('returns empty object for unknown extension', async () => {
const result = await extractAndroidVersionAsync('/path/to/archive.zip');
expect(mockedSpawnAsync).not.toHaveBeenCalled();
expect(result).toEqual({});
});
});
describe(createUpdateResolvedVersionBuildFunction, () => {
afterEach(() => {
mockedSpawnAsync.mockReset();
});
function createStep({
env,
archivePath,
workingDirectory,
mutationResult = { data: {}, error: undefined },
}: {
env: Record<string, string>;
archivePath?: string;
workingDirectory?: string;
mutationResult?: { data?: unknown; error?: Error };
}): { step: BuildStep; mutation: jest.Mock } {
const mutation = jest.fn(() => ({ toPromise: async () => mutationResult }));
const ctx = new BuildContext(createTestAndroidJob({}), {
env: { __API_SERVER_URL: 'http://api.expo.test', ...env },
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
uploadArtifact: jest.fn(),
workingdir: '',
});
ctx.graphqlClient.mutation = mutation as any;
const step = createUpdateResolvedVersionBuildFunction(
new CustomBuildContext(ctx)
).createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs: archivePath ? { application_archive_path: archivePath } : {},
workingDirectory,
});
return { step, mutation };
}
it('resolves a relative archive path against the step working directory', async () => {
mockedSpawnAsync.mockResolvedValue({
stdout: `package: name='com.example.app' versionCode='42' versionName='2.5.0'`,
stderr: '',
} as any);
const { step, mutation } = createStep({
env: { EAS_BUILD_ID: 'build-id' },
archivePath: 'android/app/build/outputs/apk/release/app-release.apk',
workingDirectory: './my-app',
});
await step.executeAsync();
const expectedPath = path.join(
step.ctx.workingDirectory,
'android/app/build/outputs/apk/release/app-release.apk'
);
expect(path.isAbsolute(expectedPath)).toBe(true);
expect(mockedSpawnAsync).toHaveBeenCalledWith('aapt2', ['dump', 'badging', expectedPath], {
stdio: 'pipe',
});
expect(mutation).toHaveBeenCalledTimes(1);
});
it('updates the resolved versions for the build', async () => {
mockedSpawnAsync.mockResolvedValue({
stdout: `package: name='com.example.app' versionCode='42' versionName='2.5.0'`,
stderr: '',
} as any);
const { step, mutation } = createStep({
env: { EAS_BUILD_ID: 'build-id' },
archivePath: '/path/to/app.apk',
});
await step.executeAsync();
expect(mutation).toHaveBeenCalledTimes(1);
expect(mutation.mock.calls[0][1]).toEqual({
buildId: 'build-id',
appVersion: '2.5.0',
appBuildVersion: '42',
});
});
it('omits a missing value instead of sending null', async () => {
mockedSpawnAsync.mockResolvedValue({
stdout: `package: name='com.example.app' versionCode='42'`,
stderr: '',
} as any);
const { step, mutation } = createStep({
env: { EAS_BUILD_ID: 'build-id' },
archivePath: '/path/to/app.apk',
});
await step.executeAsync();
const variables = mutation.mock.calls[0][1];
expect(variables.appBuildVersion).toBe('42');
expect(variables.appVersion).toBeUndefined();
expect(variables.appVersion).not.toBeNull();
});
it('does nothing without a build ID', async () => {
const { step, mutation } = createStep({ env: {}, archivePath: '/path/to/app.apk' });
await step.executeAsync();
expect(mockedSpawnAsync).not.toHaveBeenCalled();
expect(mutation).not.toHaveBeenCalled();
});
it('does nothing without an archive path', async () => {
const { step, mutation } = createStep({ env: { EAS_BUILD_ID: 'build-id' } });
await step.executeAsync();
expect(mockedSpawnAsync).not.toHaveBeenCalled();
expect(mutation).not.toHaveBeenCalled();
});
it('does not fail the step when extraction or the mutation fails', async () => {
mockedSpawnAsync.mockRejectedValueOnce(new Error('aapt2: command not found'));
const failingExtraction = createStep({
env: { EAS_BUILD_ID: 'build-id' },
archivePath: '/path/to/app.apk',
});
await expect(failingExtraction.step.executeAsync()).resolves.not.toThrow();
expect(failingExtraction.mutation).not.toHaveBeenCalled();
mockedSpawnAsync.mockResolvedValue({
stdout: `package: name='com.example.app' versionCode='42' versionName='2.5.0'`,
stderr: '',
} as any);
const failingMutation = createStep({
env: { EAS_BUILD_ID: 'build-id' },
archivePath: '/path/to/app.apk',
mutationResult: { error: new Error('GraphQL error') },
});
await expect(failingMutation.step.executeAsync()).resolves.not.toThrow();
expect(failingMutation.mutation).toHaveBeenCalledTimes(1);
});
});
@@ -8,6 +8,8 @@ import {
createUploadToAscBuildFunction,
isClosedVersionTrainError,
isInvalidBundleIdentifierError,
isMissingAppIconsError,
isMissingBackgroundTaskIdentifiersError,
isMissingPurposeStringError,
isRedundantBinaryUploadError,
isSdkVersionIssueError,
@@ -95,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(
@@ -191,3 +228,117 @@ describe(createUploadToAscBuildFunction, () => {
}
});
});
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();
}
});
});
@@ -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({
@@ -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);
@@ -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,
@@ -98,6 +100,7 @@ export async function runAgentDeviceRemoteSessionAsync(
frameAncestors,
corsOrigins,
tasks,
warm,
device,
}: {
env: BuildStepEnv;
@@ -114,6 +117,8 @@ export async function runAgentDeviceRemoteSessionAsync(
frameAncestors?: string[];
corsOrigins?: string[];
tasks: StartupTasks;
/** A claimed warm Simulator: adopt it and use its agent-device daemon. */
warm?: WarmIosSession;
device:
| {
booted: Promise<unknown>;
@@ -137,6 +142,9 @@ 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;
@@ -144,7 +152,70 @@ export async function runAgentDeviceRemoteSessionAsync(
// Each task stores what it started, so the teardown below can stop it even when
// another task failed first.
const sessionHostStartup = tasks.run('session host', async taskLogger => {
if (externallyBootedDevice) {
await tasks.untilAborted(externallyBootedDevice.booted);
}
tasks.signal.throwIfAborted();
sessionHost = await startDeviceSessionHostAsync(
ctx,
{
separateLogPhase: true,
runtimePlatform,
env,
logger: taskLogger,
timeoutMs: STARTUP_TIMEOUT_MS,
preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken },
disableShare,
privatePreviewUrl,
frameAncestors,
corsOrigins,
signal: tasks.signal,
onStartupError: tasks.abort,
networkCapture: capture.networkCapture,
networkCaptureFields: capture.networkCaptureFields,
iosSimulatorUdid: hostBootedDevice?.iosSimulatorUdid,
bootEnv: hostBootedDevice?.bootEnv,
application: hostBootedDevice?.application,
},
...(warm ? [createWarmDeviceSessionServer(ctx, taskLogger, warm)] : [])
);
tasks.signal.throwIfAborted();
const webPreview = await sessionHost.openPreviewAsync();
tasks.signal.throwIfAborted();
taskLogger.info(
webPreview.sharePageUrl
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
return webPreview;
});
const agentDeviceStartup = tasks.run('agent-device daemon', async taskLogger => {
let daemonInfo: { port: number; token: string };
if (warm) {
// Adoption prepares the launcher's daemon for the claimed device.
await tasks.untilAborted(sessionHostStartup);
if (!warm.tools) {
throw new Error('The warm agent-device daemon was not prepared.');
}
daemonInfo = warm.tools;
} else {
daemonInfo = await startDaemonAsync(taskLogger);
}
tasks.signal.throwIfAborted();
agentDeviceTunnel = await startNgrokTunnelAsync({
port: daemonInfo.port,
subdomainPrefix: 'agent-device',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
healthCheck: { path: '/health' },
logger: taskLogger,
});
tasks.signal.throwIfAborted();
taskLogger.info(`Tunnel is ready at ${agentDeviceTunnel.url}.`);
return { ...daemonInfo, remoteSessionUrl: agentDeviceTunnel.url };
});
async function startDaemonAsync(taskLogger: bunyan) {
taskLogger.info('Launching agent-device daemon.');
daemonProcess = await startAgentDeviceDaemonAsync({
packageVersion,
@@ -160,55 +231,8 @@ export async function runAgentDeviceRemoteSessionAsync(
const daemonInfo = await tasks.untilAborted(waitForDaemonInfoAsync({ daemonProcess }));
daemonSecrets.push(daemonInfo.token);
taskLogger.info(`Daemon is listening on port ${daemonInfo.port}; loaded auth token.`);
tasks.signal.throwIfAborted();
agentDeviceTunnel = await startNgrokTunnelAsync({
port: daemonInfo.port,
subdomainPrefix: 'agent-device',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
logger: taskLogger,
});
tasks.signal.throwIfAborted();
taskLogger.info(`Tunnel is ready at ${agentDeviceTunnel.url}.`);
return { ...daemonInfo, remoteSessionUrl: agentDeviceTunnel.url };
});
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,
disableShare,
privatePreviewUrl,
frameAncestors,
corsOrigins,
signal: tasks.signal,
onStartupError: tasks.abort,
networkCapture: capture.networkCapture,
networkCaptureFields: capture.networkCaptureFields,
iosSimulatorUdid: hostBootedDevice?.iosSimulatorUdid,
bootEnv: hostBootedDevice?.bootEnv,
application: hostBootedDevice?.application,
});
tasks.signal.throwIfAborted();
const webPreview = await sessionHost.openPreviewAsync({
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
});
tasks.signal.throwIfAborted();
taskLogger.info(
webPreview.sharePageUrl
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
return webPreview;
});
return daemonInfo;
}
const deviceReady = externallyBootedDevice?.ready;
try {
@@ -243,17 +267,21 @@ export async function runAgentDeviceRemoteSessionAsync(
return;
}
tasks.signal.throwIfAborted();
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
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,
});
@@ -281,29 +309,41 @@ export async function runAgentDeviceRemoteSessionAsync(
// everything that was started.
await Promise.allSettled([agentDeviceStartup, sessionHostStartup, deviceReady]);
const startedDaemon = daemonProcess;
const toolTeardown = (async () => {
try {
if (eventCollection) {
await stopAgentDeviceEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
}
} finally {
try {
// The final inventory reads from the daemon, so it finishes before the daemon stops.
stopArtifactPolling.abort();
await artifactUploads;
} finally {
await startedDaemon?.stopAsync();
}
}
})();
await finishRemoteSessionAsync({
logger,
sessionFailed,
teardown: [
['agent-device tunnel', agentDeviceTunnel?.stopAsync()],
['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()],
],
});
}
@@ -16,26 +16,29 @@ import {
AndroidVirtualDeviceName,
} from '../../utils/AndroidEmulatorUtils';
import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi';
import { readIosApplicationIdentifierAsync } from '../utils/iosAppArtifact';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
import { runOnWarmIosSimulatorAsync } from '../utils/warmDeviceSessionHost';
import { type WarmIosSession } from '../utils/warmIosSession';
import { withLocalEgressSession } from '../utils/localEgressSession';
import {
type ServeSimApplicationOptions,
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createPrivatePreviewUrlInputProvider,
createPublicPreviewInputProvider,
createServeSimLaunchInputProviders,
omitNgrokAuthtoken,
parseCorsOriginsInput,
parseFrameAncestorsInput,
selectXcodeDeveloperDirectoryAsync,
validateServeSimLaunchOptions,
} from '../utils/remoteDeviceRunSession';
import {
createNetworkCaptureInputProviders,
parseNetworkCaptureInputs,
} from '../utils/networkCaptureFields';
import { createStartupTasks } from '../utils/startupTasks';
import {
type ServeSimApplicationOptions,
validateServeSimLaunchOptions,
} from '../utils/remoteDeviceRunSession';
import { downloadBuildAsync } from './downloadBuild';
import { installBuildAsync } from './installBuild';
@@ -49,22 +52,23 @@ import {
runAgentDeviceRemoteSessionAsync,
} from './startAgentDeviceRemoteSession';
import { startAndroidEmulatorAsync } from './startAndroidEmulator';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
import { readIosApplicationIdentifierAsync } from '../utils/iosAppArtifact';
const ANDROID_DEVICE_NAME = 'EasAndroidDevice01' as AndroidVirtualDeviceName;
/**
* Starts a device session, downloading the app alongside daemon startup.
* 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 downloaded app. Build-tools verifies any egress guard before install and launch.
* 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: [
// Device. On iOS a Simulator name or UDID, on Android an AVD hardware profile.
BuildStepInput.createProvider({
@@ -93,7 +97,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,
@@ -104,16 +114,7 @@ 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(),
@@ -144,22 +145,41 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
// Validate everything before any expensive work starts.
const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(stepEnv);
const env = omitNgrokAuthtoken(stepEnv);
const installAppPath = inputs.install_app_path.value as string | undefined;
const buildId = inputs.build_id.value as string | undefined;
const applicationArchiveUrl = inputs.application_archive_url.value as string | undefined;
if (buildId && applicationArchiveUrl) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
'Pass only one of build_id or application_archive_url.'
);
}
const hasApplication = Boolean(buildId || applicationArchiveUrl);
const launchAppIdentifier = inputs.launch_app_identifier.value as string | undefined;
const launchArgs = parseLaunchArgsInput(inputs.launch_args.value);
const openUrl =
inputs.open_url.value === undefined ? undefined : parseOpenUrlInput(inputs.open_url.value);
if (!hasApplication && (launchArgs.length > 0 || openUrl)) {
const download = buildId || applicationArchiveUrl;
if ([installAppPath, buildId, applicationArchiveUrl].filter(Boolean).length > 1) {
throw new UserError(
'EAS_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_args and open_url need an application: pass build_id or application_archive_url.'
'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) {
@@ -176,87 +196,111 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
{ runtimePlatform }
);
const tasks = createStartupTasks(logger, signal);
let downloaded: Promise<string> | undefined;
let applicationReady: Promise<ServeSimApplicationOptions | void> | undefined;
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
const tasks = createStartupTasks(logger, signal);
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 { iosStartup } = isIos
? await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: true,
deviceIdentifier,
env,
logger,
signal: tasks.signal,
})
: {};
const iosSimulatorUdid = iosStartup?.iosSimulatorUdid;
if (iosSimulatorUdid) {
logger.info(`Selected iOS Simulator: ${iosSimulatorUdid}.`);
}
const booted = iosSimulatorUdid
? undefined
: tasks.run('Android Emulator boot', async taskLogger => {
const logcatDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
);
tasks.signal.throwIfAborted();
await startAndroidEmulatorAsync({
deviceName: ANDROID_DEVICE_NAME,
systemImagePackage: `${inputs.system_image_package.value}`,
deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined,
lcdWidth: inputs.lcd_width.value as number | undefined,
lcdHeight: inputs.lcd_height.value as number | undefined,
lcdDensity: inputs.lcd_density.value as number | undefined,
logcatDirectory,
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: taskLogger,
});
});
downloaded = hasApplication
? tasks.run('build download', async taskLogger => {
const { artifactPath } = await downloadBuildAsync({
logger: taskLogger,
...(buildId ? { buildId } : { applicationArchiveUrl: applicationArchiveUrl! }),
graphqlClient: ctx.graphqlClient,
deviceRunSessionWorkerApi: buildId ? getDeviceRunSessionWorkerApi(ctx) : null,
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
extensions: isIos ? ['app'] : ['apk'],
logger,
signal: tasks.signal,
});
return artifactPath;
})
: undefined;
})
: {};
const iosSimulatorUdid = iosStartup?.iosSimulatorUdid;
if (iosSimulatorUdid) {
logger.info(`Selected iOS Simulator: ${iosSimulatorUdid}.`);
}
const download = downloaded;
applicationReady = download
? tasks.run(isIos ? 'app preparation' : 'app install and launch', async taskLogger => {
if (booted) {
await tasks.untilAborted(booted);
}
const artifactPath = await download;
tasks.signal.throwIfAborted();
if (isIos) {
const applicationIdentifier = await readIosApplicationIdentifierAsync({
artifactPath,
env,
});
const 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();
return {
installAppPath: artifactPath,
launchAppIdentifier: applicationIdentifier,
launchArgs,
openUrl,
};
await startAndroidEmulatorAsync({
deviceName: ANDROID_DEVICE_NAME,
systemImagePackage: `${inputs.system_image_package.value}`,
deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined,
lcdWidth: inputs.lcd_width.value as number | undefined,
lcdHeight: inputs.lcd_height.value as number | undefined,
lcdDensity: inputs.lcd_density.value as number | undefined,
logcatDirectory,
env,
logger: taskLogger,
});
});
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,
@@ -273,39 +317,57 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
env,
logger: taskLogger,
});
})
: undefined;
});
}
await runAgentDeviceRemoteSessionAsync(ctx, {
env,
logger,
signal,
runtimePlatform,
sessionEnv,
packageVersion: inputs.package_version.value as string | undefined,
// A missing or non-positive value disables the idle timeout (opt-in feature).
maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined,
maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined,
capture,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
tasks,
device: iosSimulatorUdid
? {
...iosStartup,
iosSimulatorUdid,
application: applicationReady,
}
: {
booted: booted!,
ready: applicationReady ?? tasks.untilAborted(booted!),
},
});
} finally {
tasks.abort(new Error('Agent-device session ended.'));
await Promise.allSettled([downloaded, applicationReady]);
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,
device: iosSimulatorUdid
? {
iosSimulatorUdid,
bootEnv: iosStartup?.bootEnv,
application: applicationReady,
}
: {
booted,
ready: applicationReady ?? tasks.untilAborted(booted),
},
});
} finally {
tasks.abort(new Error('Agent-device session ended.'));
await Promise.allSettled([downloaded, applicationReady]);
}
};
// The warm path installs only a local app; build_id and application_archive_url start cold.
if (warm && isIos && !download) {
await runOnWarmIosSimulatorAsync(
{
engine: 'agent-device',
requestedDevice: deviceIdentifier,
packageVersion: inputs.package_version.value as string | undefined,
env,
logger,
signal,
},
startSessionAsync
);
} else {
await startSessionAsync();
}
}),
});
@@ -22,6 +22,12 @@ import {
withLocalEgressSession,
} from '../utils/localEgressSession';
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import {
WarmSessionAdoptionError,
createWarmDeviceSessionServer,
runOnWarmIosSimulatorAsync,
} from '../utils/warmDeviceSessionHost';
import { type WarmIosSession } from '../utils/warmIosSession';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
import {
createNetworkCaptureInputProviders,
@@ -73,13 +79,14 @@ const AppiumInstalledDriversSchema = z.record(
);
export function createStartAppiumRemoteSessionBuildFunction(
ctx: CustomBuildContext
ctx: CustomBuildContext,
{ warm = false }: { warm?: boolean } = {}
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_appium_remote_session',
id: warm ? 'start_warm_appium_remote_session' : 'start_appium_remote_session',
name: 'Start Appium remote session',
__metricsId: 'eas/start_appium_remote_session',
__metricsId: warm ? 'eas/start_warm_appium_remote_session' : 'eas/start_appium_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
...createIosSessionStartupInputProviders(),
@@ -110,249 +117,284 @@ export function createStartAppiumRemoteSessionBuildFunction(
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
installAppPath: inputs.install_app_path?.value as string | undefined,
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
env,
logger,
signal,
});
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
{ runtimePlatform }
);
const versionSpec = resolveAppium3VersionSpec(packageVersion);
logger.info(
`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
const device: AppiumDevice = iosStartup
? {
platformName: 'iOS',
automationName: 'XCUITest',
driverName: 'xcuitest',
udid: iosStartup.iosSimulatorUdid,
}
: await resolveAppiumDeviceAsync({ runtimePlatform, env });
const startupAbortController = new AbortController();
const startupSignal = signal
? AbortSignal.any([signal, startupAbortController.signal])
: startupAbortController.signal;
const setupSignal = iosStartup ? startupSignal : undefined;
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let appiumProcess: ReturnType<typeof spawnDetached> | undefined;
let appiumHome: string | undefined;
let eventCollection: Awaited<ReturnType<typeof startAppiumEventCollectionAsync>> | undefined;
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let sessionFailed = false;
const startHost = () =>
startDeviceSessionHostAsync(ctx, {
...iosStartup,
separateLogPhase: true,
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
deviceIdentifier: warmSession?.device.udid ?? deviceIdentifier,
installAppPath: inputs.install_app_path?.value as string | undefined,
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
env,
logger,
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
signal: startupSignal,
onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined,
...launch,
networkCapture,
networkCaptureFields,
}).catch(error => {
if (iosStartup) {
startupAbortController.abort(error);
}
throw error;
signal,
});
try {
if (iosStartup) {
hostStartup = asyncResult(startHost());
}
// Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the
// simulator stream with it, and UiAutomator2 merges long recordings with it. The
// macOS session image does not ship ffmpeg, so install it concurrently with Appium.
// Both owned installs settle before proceeding; a shared FFmpeg install keeps its owner.
const [appiumInstall] = await Promise.all([
asyncResult(
installAppiumAsync({
versionSpec,
driverName: device.driverName,
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
{ runtimePlatform }
);
const versionSpec = resolveAppium3VersionSpec(packageVersion);
logger.info(
`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
const device: AppiumDevice = iosStartup
? {
platformName: 'iOS',
automationName: 'XCUITest',
driverName: 'xcuitest',
udid: iosStartup.iosSimulatorUdid,
}
: await resolveAppiumDeviceAsync({ runtimePlatform, env });
const startupAbortController = new AbortController();
const startupSignal = signal
? AbortSignal.any([signal, startupAbortController.signal])
: startupAbortController.signal;
const setupSignal = iosStartup ? startupSignal : undefined;
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let appiumProcess: ReturnType<typeof spawnDetached> | undefined;
let appiumHome: string | undefined;
let eventCollection:
| Awaited<ReturnType<typeof startAppiumEventCollectionAsync>>
| undefined;
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let sessionFailed = false;
let sessionError: unknown;
const startHost = () =>
startDeviceSessionHostAsync(
ctx,
{
...iosStartup,
previewDeviceId: device.udid,
separateLogPhase: true,
runtimePlatform,
env,
logger,
signal: setupSignal,
onError: iosStartup ? error => startupAbortController.abort(error) : undefined,
}).catch(error => {
if (iosStartup) {
startupAbortController.abort(error);
}
throw error;
})
),
asyncResult(
ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger, signal: setupSignal })
),
]);
const installation = appiumInstall.enforceValue();
appiumHome = installation.appiumHome;
setupSignal?.throwIfAborted();
const { appiumBinPath, appiumEnv } = installation;
const wdaCapabilities = await preparePreinstalledWdaAsync({
runtimeIdentifier: iosStartup
? (
await IosSimulatorUtils.getDeviceAsync({
udid: iosStartup.iosSimulatorUdid as IosSimulatorUuid,
env,
})
)?.runtime
: device.runtimeIdentifier,
appiumHome,
appiumBinPath,
appiumEnv,
logger,
signal: setupSignal,
});
setupSignal?.throwIfAborted();
const useFdaHelper =
runtimePlatform === BuildRuntimePlatform.DARWIN && fs.existsSync(FDA_HELPER);
appiumProcess = spawnDetached({
command: useFdaHelper ? FDA_HELPER : appiumBinPath,
args: [
...(useFdaHelper ? [appiumBinPath] : []),
'--address',
APPIUM_HOST,
'--port',
String(APPIUM_PORT),
'--base-path',
'/',
'--log-level',
'error',
// Appium 3 gates session listing (GET /appium/sessions) behind the
// session_discovery insecure feature. We rely on it to poll for
// Appium Event Timings, so enable it for all drivers.
'--allow-insecure',
'*:session_discovery',
'--default-capabilities',
JSON.stringify({
'appium:eventTimings': true,
// Without it, XCUITest restarted the booted Simulator at session start, which
// closed the app serve-sim had launched. EAS VMs never show the Simulator window.
...(device.platformName === 'iOS' ? { 'appium:isHeadless': true } : {}),
...wdaCapabilities,
}),
],
env: appiumEnv,
logger,
});
await waitForAppiumReadyAsync({ appiumProcess, logger, signal: setupSignal });
if (hostStartup) {
sessionHost = (await hostStartup).enforceValue();
}
setupSignal?.throwIfAborted();
eventCollection = await startAppiumEventCollectionAsync({
ctx,
deviceRunSessionId,
appiumUrl: `http://${APPIUM_HOST}:${APPIUM_PORT}/`,
logger,
});
appiumTunnel = await startNgrokTunnelAsync({
port: APPIUM_PORT,
subdomainPrefix: 'appium',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
logger,
});
// expo-device-hub has no serial-selection flag. Device run session workflows must expose
// a single booted Android emulator so the Hub and Appium resolve the same device.
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
const webPreview = await sessionHost.openPreviewAsync({
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
});
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
deviceRunSessionId,
remoteConfig: {
appiumUrl: appiumTunnel.url,
capabilities: {
platformName: device.platformName,
'appium:automationName': device.automationName,
'appium:udid': device.udid,
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken },
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
signal: startupSignal,
onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined,
...launch,
networkCapture,
networkCaptureFields,
},
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
...(warmSession ? [createWarmDeviceSessionServer(ctx, logger, warmSession)] : [])
).catch(error => {
if (iosStartup) {
startupAbortController.abort(error);
}
throw error;
});
try {
if (iosStartup) {
hostStartup = asyncResult(startHost());
}
// Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the
// simulator stream with it, and UiAutomator2 merges long recordings with it. The
// macOS session image does not ship ffmpeg, so install it concurrently with Appium.
// Both owned installs settle before proceeding; a shared FFmpeg install keeps its owner.
const [appiumInstall] = await Promise.all([
asyncResult(
installAppiumAsync({
versionSpec,
driverName: device.driverName,
env,
logger,
signal: setupSignal,
onError: iosStartup ? error => startupAbortController.abort(error) : undefined,
}).catch(error => {
if (iosStartup) {
startupAbortController.abort(error);
}
throw error;
})
),
asyncResult(
ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger, signal: setupSignal })
),
]);
const installation = appiumInstall.enforceValue();
appiumHome = installation.appiumHome;
setupSignal?.throwIfAborted();
const { appiumBinPath, appiumEnv } = installation;
const wdaCapabilities = await preparePreinstalledWdaAsync({
runtimeIdentifier: iosStartup
? (
await IosSimulatorUtils.getDeviceAsync({
udid: iosStartup.iosSimulatorUdid as IosSimulatorUuid,
env,
})
)?.runtime
: device.runtimeIdentifier,
appiumHome,
appiumBinPath,
appiumEnv,
logger,
signal: setupSignal,
});
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
}
: undefined,
});
} catch (error) {
sessionFailed = true;
throw setupSignal?.aborted ? setupSignal.reason : error;
} finally {
startupAbortController.abort(new Error('Appium session ended.'));
await finishRemoteSessionAsync({
logger,
sessionFailed,
teardown: [
['Appium tunnel', appiumTunnel?.stopAsync()],
[
'Appium server',
(async () => {
try {
await eventCollection?.stopAsync();
} finally {
try {
await appiumProcess?.stopAsync();
} finally {
if (appiumHome) {
await fs.promises.rm(appiumHome, { recursive: true, force: true });
}
}
}
})(),
setupSignal?.throwIfAborted();
const useFdaHelper =
runtimePlatform === BuildRuntimePlatform.DARWIN && fs.existsSync(FDA_HELPER);
appiumProcess = spawnDetached({
command: useFdaHelper ? FDA_HELPER : appiumBinPath,
args: [
...(useFdaHelper ? [appiumBinPath] : []),
'--address',
APPIUM_HOST,
'--port',
String(APPIUM_PORT),
'--base-path',
'/',
'--log-level',
'error',
// Appium 3 gates session listing (GET /appium/sessions) behind the
// session_discovery insecure feature. We rely on it to poll for
// Appium Event Timings, so enable it for all drivers.
'--allow-insecure',
'*:session_discovery',
'--default-capabilities',
JSON.stringify({
'appium:eventTimings': true,
// Without it, XCUITest restarted the booted Simulator at session start, which
// closed the app serve-sim had launched. EAS VMs never show the Simulator window.
...(device.platformName === 'iOS' ? { 'appium:isHeadless': true } : {}),
...wdaCapabilities,
}),
],
['session host', hostStartup?.then(result => result.value?.finishAsync())],
],
});
env: appiumEnv,
logger,
});
await waitForAppiumReadyAsync({ appiumProcess, logger, signal: setupSignal });
if (hostStartup) {
sessionHost = (await hostStartup).enforceValue();
}
setupSignal?.throwIfAborted();
eventCollection = await startAppiumEventCollectionAsync({
ctx,
deviceRunSessionId,
appiumUrl: `http://${APPIUM_HOST}:${APPIUM_PORT}/`,
logger,
});
appiumTunnel = await startNgrokTunnelAsync({
port: APPIUM_PORT,
subdomainPrefix: 'appium',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
healthCheck: { path: '/status' },
logger,
});
// The preview host uses the same device selected for Appium.
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
const webPreview = await sessionHost.openPreviewAsync();
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
deviceRunSessionId,
remoteConfig: {
appiumUrl: appiumTunnel.url,
capabilities: {
platformName: device.platformName,
'appium:automationName': device.automationName,
'appium:udid': device.udid,
},
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
}
: undefined,
});
} catch (error) {
sessionFailed = true;
sessionError = setupSignal?.aborted ? setupSignal.reason : error;
throw sessionError;
} finally {
startupAbortController.abort(new Error('Appium session ended.'));
const appiumStopErrors: unknown[] = [];
const appiumTeardown = (async () => {
try {
await eventCollection?.stopAsync();
} finally {
try {
await appiumProcess?.stopAsync();
} catch (error) {
appiumStopErrors.push(error);
throw error;
} finally {
if (appiumHome) {
await fs.promises.rm(appiumHome, { recursive: true, force: true });
}
}
}
})();
await finishRemoteSessionAsync({
logger,
sessionFailed,
teardown: [
['Appium tunnel', appiumTunnel?.stopAsync()],
['Appium server', appiumTeardown],
[
'session host',
hostStartup?.then(async result => {
if (warmSession) {
await Promise.allSettled([appiumTeardown]);
}
await result.value?.finishAsync();
}),
],
],
});
if (sessionError instanceof WarmSessionAdoptionError && appiumStopErrors.length) {
throw new AggregateError(
[sessionError, ...appiumStopErrors],
'Could not stop Appium after warm adoption failed; a cold session cannot start safely.'
);
}
}
};
if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) {
await runOnWarmIosSimulatorAsync(
// package_version is the Appium version, which the job installs either way.
{ engine: 'appium', requestedDevice: deviceIdentifier, env, logger, signal },
startSessionAsync
);
} else {
await startSessionAsync();
}
}),
});
@@ -12,7 +12,6 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import semver from 'semver';
import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
@@ -21,6 +20,12 @@ import {
withLocalEgressSession,
} from '../utils/localEgressSession';
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import {
createWarmDeviceSessionServer,
runOnWarmIosSimulatorAsync,
} from '../utils/warmDeviceSessionHost';
import { type WarmIosSession } from '../utils/warmIosSession';
import { validateControllerVersion } from '../utils/controllerVersion';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
import {
createNetworkCaptureInputProviders,
@@ -84,13 +89,14 @@ const ArgentToolServerStateSchema = z.object({
type ArgentToolServerState = z.infer<typeof ArgentToolServerStateSchema>;
export function createStartArgentRemoteSessionBuildFunction(
ctx: CustomBuildContext
ctx: CustomBuildContext,
{ warm = false }: { warm?: boolean } = {}
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_argent_remote_session',
id: warm ? 'start_warm_argent_remote_session' : 'start_argent_remote_session',
name: 'Start argent remote session',
__metricsId: 'eas/start_argent_remote_session',
__metricsId: warm ? 'eas/start_warm_argent_remote_session' : 'eas/start_argent_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
...createIosSessionStartupInputProviders(),
@@ -129,313 +135,362 @@ export function createStartArgentRemoteSessionBuildFunction(
// A missing or non-positive value disables the idle timeout (opt-in feature).
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
validateControllerVersion({
packageName: ARGENT_PACKAGE_NAME,
minimumVersion: MIN_ARGENT_REMOTE_SESSION_VERSION,
packageVersion,
logger,
});
const versionSpec = packageVersion ?? 'latest';
const { runtimePlatform } = global;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
installAppPath: inputs.install_app_path?.value as string | undefined,
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
env,
logger,
signal,
});
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
{ runtimePlatform }
);
logger.info(
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
// Start the potentially slow installation while Argent is being prepared.
// On Linux expo-device-hub calls this again and awaits the same in-flight
// setup before launching. On macOS this remains non-blocking, so only a
// recording started in the first moments may miss ffmpeg.
// Never rejects, so `void` is safe.
void ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
const argentExec = (args: string[]): { command: string; args: string[] } =>
resolvePackageExec(packageManager, args);
const startupAbortController = new AbortController();
const startupSignal = signal
? AbortSignal.any([signal, startupAbortController.signal])
: startupAbortController.signal;
const setupSignal = iosStartup ? startupSignal : undefined;
const artifactPollAbortController = new AbortController();
let artifactPollingPromise: Promise<void> | undefined;
let eventCollection: Awaited<ReturnType<typeof startArgentEventCollectionAsync>> | undefined;
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let argentServer: ReturnType<typeof spawnDetached> | undefined;
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let sessionFailed = false;
const startHost = () =>
startDeviceSessionHostAsync(ctx, {
...iosStartup,
separateLogPhase: true,
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
deviceIdentifier: warmSession?.device.udid ?? deviceIdentifier,
installAppPath: inputs.install_app_path?.value as string | undefined,
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
env,
logger,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
timeoutMs: STARTUP_TIMEOUT_MS,
signal: startupSignal,
onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined,
...launch,
networkCapture,
networkCaptureFields,
}).catch(error => {
if (iosStartup) {
startupAbortController.abort(error);
}
throw error;
});
try {
if (iosStartup) {
hostStartup = asyncResult(startHost());
}
logger.info('Enabling the Argent artifacts list endpoint flag.');
const enableArtifacts = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
]);
await runArgentSetupCommandAsync(
enableArtifacts.command,
enableArtifacts.args,
{ env, logger },
setupSignal
);
logger.info('Enabling the Argent tool-server event log flag.');
const enableEventLog = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_EVENT_LOG_FLAG,
]);
await runArgentSetupCommandAsync(
enableEventLog.command,
enableEventLog.args,
{ env, logger },
setupSignal
);
logger.info('Disabling the Argent video watermark flag.');
const disableVideoWatermark = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'disable',
ARGENT_VIDEO_WATERMARK_FLAG,
]);
// The watermark is cosmetic, so a failure here must not fail the session.
try {
await runArgentSetupCommandAsync(
disableVideoWatermark.command,
disableVideoWatermark.args,
{ env, logger },
setupSignal
);
} catch (err) {
setupSignal?.throwIfAborted();
logger.warn(
{ err },
'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.'
);
}
setupSignal?.throwIfAborted();
const startServer = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'server',
'start',
'--port',
'0',
'--idle-timeout',
'0',
'--force',
]);
logger.info(
`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.`
);
// Keep Argent itself in foreground mode under the detached process. This preserves
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
const secrets: string[] = [];
argentServer = spawnDetached({
command: startServer.command,
args: startServer.args,
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH, ARGENT_EMULATOR_NO_WINDOW: '1' },
logger,
secrets,
});
if (argentServer.pid === undefined) {
throw new SystemError(
'Failed to start Argent: could not determine the PID of the launched process.'
);
}
logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`);
let toolServerPort: number;
let toolServerToken: string | undefined;
try {
const toolServerState = await waitForArgentToolServerStateAsync({
stateDir: ARGENT_STATE_DIR,
ancestorPid: argentServer.pid,
timeoutMs: STARTUP_TIMEOUT_MS,
getExitError: argentServer.getExitError,
signal: setupSignal,
});
toolServerPort = toolServerState.port;
toolServerToken = toolServerState.token;
if (toolServerToken) {
secrets.push(toolServerToken);
}
} catch (err) {
const output = argentServer.getOutput();
throw new SystemError(
`${
err instanceof Error ? err.message : `Timed out waiting for argent tool-server state.`
}${output ? `\nArgent tool-server output:\n${output}` : ''}`
);
}
if (hostStartup) {
sessionHost = (await hostStartup).enforceValue();
}
setupSignal?.throwIfAborted();
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
const artifactPollSignal = signal
? AbortSignal.any([signal, artifactPollAbortController.signal])
: artifactPollAbortController.signal;
artifactPollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
toolsUrl: `http://127.0.0.1:${toolServerPort}`,
toolsAuthToken: toolServerToken,
logger,
signal: artifactPollSignal,
});
eventCollection = await startArgentEventCollectionAsync({
ctx,
deviceRunSessionId,
eventLogPath: ARGENT_EVENT_LOG_PATH,
logger,
});
toolsTunnel = await startNgrokTunnelAsync({
port: toolServerPort,
subdomainPrefix: 'argent',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
rewriteHostHeader: true,
logger,
});
const publicToolsUrl = toolsTunnel.url;
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
const webPreview = await sessionHost.openPreviewAsync({
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
});
logger.info(
webPreview.sharePageUrl
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
deviceRunSessionId,
remoteConfig: {
toolsUrl: publicToolsUrl,
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
});
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
{ runtimePlatform }
);
logger.info(
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
maxDurationSeconds,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
}
: undefined,
});
} catch (error) {
sessionFailed = true;
throw setupSignal?.aborted ? setupSignal.reason : error;
} finally {
startupAbortController.abort(new Error('Argent session ended.'));
await finishRemoteSessionAsync({
logger,
sessionFailed,
teardown: [
['Argent tunnel', toolsTunnel?.stopAsync()],
[
'Argent tool-server',
(async () => {
try {
if (eventCollection) {
await stopArgentEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
// Start the potentially slow installation while Argent is being prepared.
// On Linux expo-device-hub calls this again and awaits the same in-flight
// setup before launching. On macOS this remains non-blocking, so only a
// recording started in the first moments may miss ffmpeg.
// Never rejects, so `void` is safe.
void ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
const argentExec = (args: string[]): { command: string; args: string[] } =>
resolvePackageExec(packageManager, args);
const startupAbortController = new AbortController();
const startupSignal = signal
? AbortSignal.any([signal, startupAbortController.signal])
: startupAbortController.signal;
const setupSignal = iosStartup ? startupSignal : undefined;
const artifactPollAbortController = new AbortController();
let artifactPollingPromise: Promise<void> | undefined;
let eventCollection:
| Awaited<ReturnType<typeof startArgentEventCollectionAsync>>
| undefined;
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let argentServer: ReturnType<typeof spawnDetached> | undefined;
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let sessionFailed = false;
const startHost = () =>
startDeviceSessionHostAsync(
ctx,
{
...iosStartup,
separateLogPhase: true,
runtimePlatform,
env,
logger,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
timeoutMs: STARTUP_TIMEOUT_MS,
preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken },
signal: startupSignal,
onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined,
...launch,
networkCapture,
networkCaptureFields,
},
...(warmSession ? [createWarmDeviceSessionServer(ctx, logger, warmSession)] : [])
).catch(error => {
if (iosStartup) {
startupAbortController.abort(error);
}
throw error;
});
try {
if (iosStartup) {
hostStartup = asyncResult(startHost());
}
let toolServerPort: number;
let toolServerToken: string | undefined;
if (warmSession) {
// Adoption prepares the launcher's Argent server for the claimed device.
sessionHost = (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
const tools = warmSession.tools;
if (!tools) {
throw new Error('The warm Argent server was not prepared.');
}
toolServerPort = tools.port;
toolServerToken = tools.token;
} else {
logger.info('Enabling the Argent artifacts list endpoint flag.');
const enableArtifacts = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
]);
await runArgentSetupCommandAsync(
enableArtifacts.command,
enableArtifacts.args,
{ env, logger },
setupSignal
);
logger.info('Enabling the Argent tool-server event log flag.');
const enableEventLog = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_EVENT_LOG_FLAG,
]);
await runArgentSetupCommandAsync(
enableEventLog.command,
enableEventLog.args,
{ env, logger },
setupSignal
);
logger.info('Disabling the Argent video watermark flag.');
const disableVideoWatermark = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'disable',
ARGENT_VIDEO_WATERMARK_FLAG,
]);
// The watermark is cosmetic, so a failure here must not fail the session.
try {
await runArgentSetupCommandAsync(
disableVideoWatermark.command,
disableVideoWatermark.args,
{ env, logger },
setupSignal
);
} catch (err) {
setupSignal?.throwIfAborted();
logger.warn(
{ err },
'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.'
);
}
setupSignal?.throwIfAborted();
const startServer = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'server',
'start',
'--port',
'0',
'--idle-timeout',
'0',
'--force',
]);
logger.info(
`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.`
);
// Keep Argent itself in foreground mode under the detached process. This preserves
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
const secrets: string[] = [];
argentServer = spawnDetached({
command: startServer.command,
args: startServer.args,
env: {
...env,
ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH,
ARGENT_EMULATOR_NO_WINDOW: '1',
},
logger,
secrets,
});
if (argentServer.pid === undefined) {
throw new SystemError(
'Failed to start Argent: could not determine the PID of the launched process.'
);
}
logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`);
try {
const toolServerState = await waitForArgentToolServerStateAsync({
stateDir: ARGENT_STATE_DIR,
ancestorPid: argentServer.pid,
timeoutMs: STARTUP_TIMEOUT_MS,
getExitError: argentServer.getExitError,
signal: setupSignal,
});
toolServerPort = toolServerState.port;
toolServerToken = toolServerState.token;
if (toolServerToken) {
secrets.push(toolServerToken);
}
} catch (err) {
const output = argentServer.getOutput();
throw new SystemError(
`${
err instanceof Error
? err.message
: `Timed out waiting for argent tool-server state.`
}${output ? `\nArgent tool-server output:\n${output}` : ''}`
);
}
if (hostStartup) {
sessionHost = (await hostStartup).enforceValue();
}
setupSignal?.throwIfAborted();
}
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
const artifactPollSignal = signal
? AbortSignal.any([signal, artifactPollAbortController.signal])
: artifactPollAbortController.signal;
artifactPollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
toolsUrl: `http://127.0.0.1:${toolServerPort}`,
toolsAuthToken: toolServerToken,
logger,
signal: artifactPollSignal,
});
eventCollection = await startArgentEventCollectionAsync({
ctx,
deviceRunSessionId,
eventLogPath: warmSession?.argentEventLogPath ?? ARGENT_EVENT_LOG_PATH,
logger,
});
toolsTunnel = await startNgrokTunnelAsync({
port: toolServerPort,
subdomainPrefix: 'argent',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
rewriteHostHeader: true,
healthCheck: { path: '/tools', statuses: [200, 401] },
logger,
});
const publicToolsUrl = toolsTunnel.url;
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
const webPreview = await sessionHost.openPreviewAsync();
logger.info(
webPreview.sharePageUrl
? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
deviceRunSessionId,
remoteConfig: {
toolsUrl: publicToolsUrl,
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
maxDurationSeconds,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
}
artifactPollAbortController.abort();
try {
await artifactPollingPromise;
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture(
'Could not finish Argent remote session artifact polling',
error
);
logger.warn(
{ err: error },
'Could not finish Argent remote session artifact polling.'
);
: undefined,
});
} catch (error) {
sessionFailed = true;
throw setupSignal?.aborted ? setupSignal.reason : error;
} finally {
startupAbortController.abort(new Error('Argent session ended.'));
const toolTeardown = (async () => {
try {
if (eventCollection) {
await stopArgentEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
}
artifactPollAbortController.abort();
try {
await artifactPollingPromise;
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not finish Argent remote session artifact polling', error);
logger.warn(
{ err: error },
'Could not finish Argent remote session artifact polling.'
);
}
} finally {
await argentServer?.stopAsync();
}
})();
await finishRemoteSessionAsync({
logger,
sessionFailed,
teardown: [
['Argent tunnel', toolsTunnel?.stopAsync()],
['Argent tool-server', toolTeardown],
[
'session host',
hostStartup?.then(async result => {
if (warmSession) {
// The warm host owns Argent too; leave it alive until the final downloads finish.
await Promise.allSettled([toolTeardown]);
}
} finally {
await argentServer?.stopAsync();
}
})(),
await result.value?.finishAsync();
}),
],
],
['session host', hostStartup?.then(result => result.value?.finishAsync())],
],
});
});
}
};
if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) {
await runOnWarmIosSimulatorAsync(
{
engine: 'argent',
requestedDevice: deviceIdentifier,
packageVersion,
env,
logger,
signal,
},
startSessionAsync
);
} else {
await startSessionAsync();
}
}),
});
@@ -463,36 +518,6 @@ export async function stopArgentEventCollectionSafelyAsync({
}
}
export function warnIfArgentPackageVersionCannotBeVerified({
packageVersion,
logger,
}: {
packageVersion: string | undefined;
logger: bunyan;
}): void {
if (!packageVersion || packageVersion === 'latest') {
return;
}
const validVersion = semver.valid(packageVersion);
if (!validVersion) {
logger.warn(
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer, ` +
`but package_version "${packageVersion}" is not an exact semver version that EAS can verify. ` +
`Continuing and letting the package manager resolve it.`
);
return;
}
if (semver.lt(validVersion, MIN_ARGENT_REMOTE_SESSION_VERSION)) {
throw new SystemError(
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer. ` +
`The requested package_version "${packageVersion}" is too old for the EAS remote-session API. ` +
`Use "latest" or pass an exact version >= ${MIN_ARGENT_REMOTE_SESSION_VERSION}.`
);
}
}
async function runArgentSetupCommandAsync(
command: string,
args: string[],
@@ -8,6 +8,11 @@ import {
import { CustomBuildContext } from '../../customBuildContext';
import { startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import {
createWarmDeviceSessionServer,
runOnWarmIosSimulatorAsync,
} from '../utils/warmDeviceSessionHost';
import { type WarmIosSession } from '../utils/warmIosSession';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
import {
createNetworkCaptureInputProviders,
@@ -38,13 +43,16 @@ import {
const STARTUP_TIMEOUT_MS = 60_000;
export function createStartWebPreviewRemoteSessionBuildFunction(
ctx: CustomBuildContext
ctx: CustomBuildContext,
{ warm = false }: { warm?: boolean } = {}
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_serve_sim_remote_session',
id: warm ? 'start_warm_serve_sim_remote_session' : 'start_serve_sim_remote_session',
name: 'Start web preview remote session',
__metricsId: 'eas/start_serve_sim_remote_session',
__metricsId: warm
? 'eas/start_warm_serve_sim_remote_session'
: 'eas/start_serve_sim_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
...createIosSessionStartupInputProviders(),
@@ -91,100 +99,123 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
installAppPath: inputs.install_app_path?.value as string | undefined,
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
env,
logger,
signal,
});
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
{ runtimePlatform }
);
logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`);
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
const sessionHost = await startDeviceSessionHostAsync(ctx, {
...iosStartup,
runtimePlatform,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
signal,
packageVersion,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
networkCapture,
networkCaptureFields,
});
try {
const webPreview = await sessionHost.openPreviewAsync({
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
});
logger.info(
webPreview.sharePageUrl
? `Preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
const deviceIdentifier = inputs.device_identifier?.value as string | undefined;
const startSessionAsync = async (warmSession?: WarmIosSession): Promise<void> => {
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
deviceIdentifier: warmSession?.device.udid ?? deviceIdentifier,
installAppPath: inputs.install_app_path?.value as string | undefined,
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
env,
signal,
ctx,
deviceRunSessionId,
// WEB_PREVIEW_ONLY sessions read webPreviewUrl and webPreviewToken. Legacy SERVE_SIM
// sessions read previewUrl and previewToken. The server drops the keys that the
// session type does not use.
remoteConfig: {
webPreviewUrl: webPreview.previewPageUrl,
previewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken
? { webPreviewToken: webPreview.previewToken, previewToken: webPreview.previewToken }
: {}),
},
logger,
signal,
});
if (sessionStart === 'stopped') {
return;
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
{ runtimePlatform }
);
logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`);
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
await waitForDeviceRunSessionStoppedAsync({
const sessionHost = await startDeviceSessionHostAsync(
ctx,
deviceRunSessionId,
logger,
maxDurationSeconds,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: () => undefined,
getLastPreviewActivityAt: () => sessionHost.getLastPreviewActivityAt(),
}
: undefined,
});
} finally {
await sessionHost.finishAsync();
{
...iosStartup,
runtimePlatform,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken },
signal,
packageVersion,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
networkCapture,
networkCaptureFields,
},
...(warmSession ? [createWarmDeviceSessionServer(ctx, logger, warmSession)] : [])
);
try {
const webPreview = await sessionHost.openPreviewAsync();
logger.info(
webPreview.sharePageUrl
? `Preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
deviceRunSessionId,
// WEB_PREVIEW_ONLY sessions read webPreviewUrl and webPreviewToken. Legacy SERVE_SIM
// sessions read previewUrl and previewToken. The server drops the keys that the
// session type does not use.
remoteConfig: {
webPreviewUrl: webPreview.previewPageUrl,
previewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken
? {
webPreviewToken: webPreview.previewToken,
previewToken: webPreview.previewToken,
}
: {}),
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
maxDurationSeconds,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: () => undefined,
getLastPreviewActivityAt: () => sessionHost.getLastPreviewActivityAt(),
}
: undefined,
});
} finally {
await sessionHost.finishAsync();
}
};
if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) {
await runOnWarmIosSimulatorAsync(
{
engine: 'serve-sim',
requestedDevice: deviceIdentifier,
packageVersion,
env,
logger,
signal,
},
startSessionAsync
);
} else {
await startSessionAsync();
}
}),
});
@@ -0,0 +1,224 @@
import { BuildJob, Platform } from '@expo/eas-build-job';
import {
BuildFunction,
BuildStepInput,
BuildStepInputValueTypeName,
spawnAsync,
} from '@expo/steps';
import { XMLParser } from 'fast-xml-parser';
import fs from 'fs-extra';
import { Client } from '@urql/core';
import { graphql } from 'gql.tada';
import path from 'node:path';
import { parseInfoPlistBuffer, readIpaInfoAsync } from './readIpaInfo';
import { CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
export function createUpdateResolvedVersionBuildFunction(
ctx: CustomBuildContext<BuildJob>
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'update_resolved_version',
name: 'Update resolved version',
__metricsId: 'eas/update_resolved_version',
inputProviders: [
BuildStepInput.createProvider({
id: 'application_archive_path',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async (stepCtx, { inputs }) => {
try {
const archivePathInput = inputs.application_archive_path.value as string | undefined;
if (!archivePathInput) {
stepCtx.logger.info('No application archive path provided, skipping.');
return;
}
const archivePath = path.resolve(stepCtx.workingDirectory, archivePathInput);
const buildId = ctx.env.EAS_BUILD_ID;
if (!buildId) {
stepCtx.logger.info('EAS_BUILD_ID not set, skipping.');
return;
}
const { appVersion, appBuildVersion } =
ctx.job.platform === Platform.IOS
? await extractIosVersionAsync(archivePath)
: await extractAndroidVersionAsync(archivePath);
if (!appVersion && !appBuildVersion) {
stepCtx.logger.info('No resolved version found, skipping.');
return;
}
stepCtx.logger.info(
`Resolved version: ${appVersion ?? 'N/A'} (${appBuildVersion ?? 'N/A'})`
);
await updateResolvedVersionAsync(ctx.graphqlClient, buildId, {
appVersion,
appBuildVersion,
});
stepCtx.logger.info('Updated resolved version on EAS.');
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Failed to update resolved version', error);
stepCtx.logger.warn({ err: error }, 'Failed to update resolved version (non-fatal)');
}
},
});
}
export async function extractIosVersionAsync(
archivePath: string
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
const ext = path.extname(archivePath).toLowerCase();
if (ext === '.app') {
return await extractSimulatorAppVersionAsync(archivePath);
}
const ipaInfo = await readIpaInfoAsync(archivePath);
return {
appVersion: ipaInfo.bundleShortVersion,
appBuildVersion: ipaInfo.bundleVersion,
};
}
async function extractSimulatorAppVersionAsync(
appPath: string
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
const infoPlistPath = path.join(appPath, 'Info.plist');
if (!(await fs.pathExists(infoPlistPath))) {
return {};
}
const infoPlistBuffer = await fs.readFile(infoPlistPath);
const infoPlist = parseInfoPlistBuffer(infoPlistBuffer);
return {
appVersion:
typeof infoPlist.CFBundleShortVersionString === 'string'
? infoPlist.CFBundleShortVersionString
: undefined,
appBuildVersion:
typeof infoPlist.CFBundleVersion === 'string' ? infoPlist.CFBundleVersion : undefined,
};
}
export async function extractAndroidVersionAsync(
archivePath: string
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
const ext = path.extname(archivePath).toLowerCase();
if (ext === '.apk') {
return await extractVersionFromApkAsync(archivePath);
} else if (ext === '.aab') {
return await extractVersionFromAabAsync(archivePath);
}
return {};
}
async function extractVersionFromApkAsync(
apkPath: string
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
const result = await spawnAsync('aapt2', ['dump', 'badging', apkPath], {
stdio: 'pipe',
});
return parseAaptOutput(result.stdout);
}
async function extractVersionFromAabAsync(
aabPath: string
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
const result = await spawnAsync('bundletool', ['dump', 'manifest', '--bundle', aabPath], {
stdio: 'pipe',
});
return parseManifestXml(result.stdout);
}
export function parseAaptOutput(output: string): { appVersion?: string; appBuildVersion?: string } {
// aapt2 does not escape apostrophes inside versionName, so stop at the next known
// badging field (or the end of the line) rather than at the first closing quote.
const versionNameMatch = output.match(
/versionName='(.*?)'(?= split=| platformBuildVersionName=| platformBuildVersionCode=| compileSdkVersion=|\s*$)/m
);
const versionCodeMatch = output.match(/versionCode='([^']+)'/);
return {
appVersion: versionNameMatch?.[1] || undefined,
appBuildVersion: versionCodeMatch?.[1],
};
}
export function parseManifestXml(xml: string): { appVersion?: string; appBuildVersion?: string } {
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_' });
const parsed = parser.parse(xml);
const manifest = parsed?.manifest;
if (!manifest) {
return {};
}
const versionName = manifest['@_android:versionName'];
const versionCode = manifest['@_android:versionCode'];
return {
appVersion: versionName != null ? String(versionName) : undefined,
appBuildVersion: versionCode != null ? String(versionCode) : undefined,
};
}
export async function updateResolvedVersionAsync(
graphqlClient: Client,
buildId: string,
{
appVersion,
appBuildVersion,
}: {
appVersion: string | undefined;
appBuildVersion: string | undefined;
}
): Promise<void> {
const result = await graphqlClient
.mutation(
graphql(`
mutation UpdateResolvedVersionMutation(
$buildId: ID!
$appVersion: String
$appBuildVersion: String
) {
build {
updateBuildMetadata(
buildId: $buildId
metadata: {
appVersion: $appVersion
appBuildVersion: $appBuildVersion
}
) {
id
}
}
}
`),
{
buildId,
appVersion,
appBuildVersion,
}
)
.toPromise();
if (result.error) {
throw result.error;
}
}
@@ -275,6 +275,32 @@ export function createUploadToAscBuildFunction(): BuildFunction {
}
);
}
if (isMissingBackgroundTaskIdentifiersError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_MISSING_BG_TASK_IDENTIFIERS',
'Build upload was rejected by App Store Connect because Info.plist is missing `BGTaskSchedulerPermittedIdentifiers` while `UIBackgroundModes` includes `processing`. ' +
'In an Expo project, add `BGTaskSchedulerPermittedIdentifiers` to `ios.infoPlist` in your app config and include every background task identifier the app schedules. ' +
'If you do not use Continuous Native Generation (CNG), add the same key to the native target Info.plist. Then rebuild and submit again.' +
`\n\nApp Store Connect errors:\n${itemizeMessages(errors)}`,
{
docsUrl: 'https://docs.expo.dev/versions/latest/config/app/#infoplist',
cause: state,
}
);
}
if (isMissingAppIconsError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_MISSING_APP_ICONS',
'Build upload was rejected by App Store Connect because required app icons are missing or misconfigured in one of the iOS bundles. ' +
'In an Expo project, check your icon config (`expo.icon`, `ios.icon`) and any custom native targets or plugins that create extra bundles, such as watch extensions. ' +
'Make sure the affected target has the required icon assets and Info.plist icon keys (for example `CFBundleIconName` / `CFBundleIconFiles`), then rebuild and submit again.' +
`\n\nApp Store Connect errors:\n${itemizeMessages(errors)}`,
{
docsUrl: 'https://docs.expo.dev/develop/user-interface/splash-screen-and-app-icon/',
cause: state,
}
);
}
if (isInvalidBundleIdentifierError(errors)) {
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
const ipaBundleIdentifier = ipaInfoResult.ok
@@ -361,6 +387,17 @@ export function isRedundantBinaryUploadError(messages: { code: string }[]): bool
return messages.length > 0 && messages.every(message => message.code === '90189');
}
export function isMissingBackgroundTaskIdentifiersError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90771');
}
export function isMissingAppIconsError(messages: { code: string }[]): boolean {
return (
messages.length > 0 &&
messages.every(message => ['90022', '90023', '90391', '90713'].includes(message.code))
);
}
export function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
return (
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
@@ -36,7 +36,7 @@ const SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS =
const SERVE_SIM_FINISH_TIMEOUT_MS =
SERVE_SIM_CONTROL_REQUEST_TIMEOUT_MS + SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS;
const SERVE_SIM_FORCE_STOP_TIMEOUT_MS = 5_000;
const SERVE_SIM_RECORDING_STARTED = 'serve-sim:recording-started';
const SERVE_SIM_RECORDING_STARTED_LINE = /(?:^|\n)serve-sim:recording-started\r?\n/;
// The serve-sim package the session host started; every recorder runs `record-video` from it.
let serveSimPackageSpec: string | null = null;
@@ -57,11 +57,19 @@ type ActiveIosSimulatorRecording = IosSimulatorRecording & {
hasExited: () => boolean;
};
type AdoptedIosSimulatorRecording = {
udid: string;
serverToken: string;
collectAsync(): Promise<void>;
};
type IosSimulatorRecordingSession = {
env: Env;
logger: bunyan;
recordingsRootDirectory: string;
activeRecordings: Map<IosSimulatorUuid, ActiveIosSimulatorRecording>;
adoptedRecordings: Map<string, AdoptedIosSimulatorRecording>;
runtimeDisplayNames: Map<string, string>;
completedRecordings: IosSimulatorRecording[];
recordingFailureCounts: Map<IosSimulatorUuid, number>;
/** Failed attempts for the whole session; unlike recordingFailureCounts, never reset. */
@@ -81,6 +89,52 @@ export namespace IosSimulatorRecordingUtils {
serveSimPackageSpec ??= packageSpec;
}
/**
* Reserves this job's recording output before warm serve-sim adoption, which starts the
* recording. The poller skips that device, so it never starts a second recorder.
*/
export async function prepareAdoptedRecordingAsync({
udid,
deviceName,
serverToken,
}: {
udid: string;
deviceName: string;
serverToken: string;
}): Promise<{ output: string; recordingId: string } | undefined> {
const session = activeIosSimulatorRecordingSession;
if (!session) {
return undefined;
}
udid = udid.toUpperCase();
const recordingId = randomUUID();
const output = path.join(session.recordingsRootDirectory, recordingId);
const startedAt = new Date();
await mkdir(output, { recursive: true });
session.adoptedRecordings.set(recordingId, {
udid,
serverToken,
async collectAsync() {
// Keep real footage eligible for the manifest wait if serve-sim shutdown was unconfirmed.
// The uploader preserves the manifest's partial or failed status.
await access(path.join(output, 'session.json')).catch(() =>
access(path.join(output, 'recording.mp4'))
);
session.completedRecordings.push({
id: recordingId,
udid: udid as IosSimulatorUuid,
deviceName,
runtimeDisplayName: session.runtimeDisplayNames.get(udid) ?? 'Unknown iOS runtime',
outputDirectory: output,
startedAt,
getOutput: () => '',
hasStarted: () => true,
});
},
});
return { output, recordingId };
}
export async function startAsync({ env, logger }: { env: Env; logger: bunyan }): Promise<void> {
if (activeIosSimulatorRecordingSession) {
logger.info('iOS Simulator screen recording polling is already running.');
@@ -95,6 +149,8 @@ export namespace IosSimulatorRecordingUtils {
logger,
recordingsRootDirectory,
activeRecordings: new Map(),
adoptedRecordings: new Map(),
runtimeDisplayNames: new Map(),
completedRecordings: [],
recordingFailureCounts: new Map(),
failedAttemptCounts: new Map(),
@@ -190,6 +246,17 @@ export namespace IosSimulatorRecordingUtils {
})
);
await Promise.all(
[...session.adoptedRecordings.values()].map(recording =>
recording.collectAsync().catch(err => {
logger.warn(
{ err },
'Could not collect the warm Simulator video recording after shutdown.'
);
})
)
);
const completedRecordings = [...session.completedRecordings].sort(
(a, b) => a.startedAt.getTime() - b.startedAt.getTime()
);
@@ -243,6 +310,9 @@ async function pollIosSimulatorRecordingsAsync(
break;
}
listDevicesErrorCount = 0;
for (const device of bootedDevices) {
session.runtimeDisplayNames.set(device.udid.toUpperCase(), device.runtimeDisplayName);
}
const readyServers = new Map(
(await readServeSimServersAsync(SERVE_SIM_STATE_DIR)).flatMap(server =>
server.token ? [[server.udid, server.token] as const] : []
@@ -262,7 +332,14 @@ async function pollIosSimulatorRecordingsAsync(
for (const device of bootedDevices) {
const token = readyServers.get(device.udid);
const packageSpec = serveSimPackageSpec;
if (!token || !packageSpec) {
if (
!token ||
!packageSpec ||
[...session.adoptedRecordings.values()].some(
recording =>
recording.udid === device.udid.toUpperCase() && recording.serverToken === token
)
) {
continue;
}
if (session.serveBearerTokens.get(device.udid) !== token) {
@@ -507,14 +584,19 @@ function captureProcessOutput(recordingProcess: ChildProcess): {
} {
let output = '';
let started = false;
const appendChunk = (chunk: Buffer | string): void => {
output = `${output}${chunk.toString()}`;
if (output.includes(SERVE_SIM_RECORDING_STARTED)) {
started = true;
}
output = output.slice(-16_384);
const captureStreamOutput = (stream: ChildProcess['stdout']): void => {
let streamOutput = '';
stream?.on('data', (chunk: Buffer | string) => {
const text = chunk.toString();
output = `${output}${text}`.slice(-16_384);
streamOutput = `${streamOutput}${text}`;
if (SERVE_SIM_RECORDING_STARTED_LINE.test(streamOutput)) {
started = true;
}
streamOutput = streamOutput.slice(-16_384);
});
};
recordingProcess.stdout?.on('data', appendChunk);
recordingProcess.stderr?.on('data', appendChunk);
captureStreamOutput(recordingProcess.stdout);
captureStreamOutput(recordingProcess.stderr);
return { getOutput: () => output, hasStarted: () => started };
}
@@ -3,18 +3,20 @@ import { type bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import { type ChildProcess } from 'node:child_process';
import { EventEmitter } from 'node:events';
import { rm, writeFile } from 'node:fs/promises';
import { readFile, rm, writeFile } from 'node:fs/promises';
import { PassThrough } from 'node:stream';
import { setTimeout } from 'node:timers/promises';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils';
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
jest.mock('@expo/turtle-spawn');
jest.mock('../serveSimMetricsRecorder');
jest.mock('../../../sentry');
jest.mock('../../../utils/turtleFetch');
const logger = {
info: jest.fn(),
@@ -144,7 +146,10 @@ test('starts serve-sim record-video and returns only a finalized recording', asy
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
await setTimeout(20);
expect(child.kill).not.toHaveBeenCalled();
stderr.write('serve-sim:recording-started\n');
child.stdout?.emit('data', 'Downloading');
stderr.write('serve-sim:recording-');
child.stdout?.emit('data', ' package');
stderr.write('started\r\n');
stderr.write('x'.repeat(17_000));
const recordings = await finishing;
@@ -362,6 +367,32 @@ test('keeps a recorder active after its package-manager wrapper exits', async ()
expect(recordings).toHaveLength(1);
});
test('does not mistake bundled source in an error for a recording start', async () => {
let currentTime = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
const recorder = mockRecorder();
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await waitForRecorderSpawnAsync();
recorder.child.stderr?.emit(
'data',
'const marker = "serve-sim:recording-started";\nError: No running serve-sim session found.\n'
);
recorder.fail(new Error('recorder exited before connecting'));
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
try {
expect(await Promise.race([finishing.then(() => true), setTimeout(250, false)])).toBe(true);
} finally {
currentTime += 311_000;
await finishing;
}
expect(recorder.child.kill).not.toHaveBeenCalled();
});
test('does not signal a recorder that exits before it reports its start', async () => {
let currentTime = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
@@ -537,3 +568,171 @@ test('records every booted device with the first serve-sim package', async () =>
currentTime += 311_000;
await IosSimulatorRecordingUtils.finishAsync({ logger });
});
test('does not request adopted video when job recording is disabled', async () => {
await expect(
IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
udid,
deviceName: 'iPhone 17 Pro',
serverToken: 'job-token',
})
).resolves.toBeUndefined();
expect(turtleFetch).not.toHaveBeenCalled();
});
test.each(['complete', 'failed'])(
'collects an adopted %s recording unchanged after host shutdown without a control request',
async status => {
let releaseDevices!: (devices: never[]) => void;
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockImplementation(
() =>
new Promise(resolve => {
releaseDevices = resolve as never;
})
);
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:3200', token: 'job-token' }]);
await IosSimulatorRecordingUtils.startAsync({ env, logger });
const recording = await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
udid,
deviceName: 'iPhone 17 Pro',
serverToken: 'job-token',
});
if (!recording) {
throw new Error('Expected the adopted recording output.');
}
releaseDevices([{ udid, name: 'iPhone 17 Pro', runtimeDisplayName: 'iOS 26.4' }] as never);
await setTimeout(20);
expect(spawn).not.toHaveBeenCalled();
const manifest = JSON.stringify({
recording: 'recording.mp4',
status,
...(status === 'failed' && { error: 'Recording interrupted; partial footage preserved.' }),
});
await writeFile(`${recording.output}/session.json`, manifest);
jest.mocked(turtleFetch).mockRejectedValue(new Error('serve-sim has stopped'));
expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toEqual([
{
udid,
deviceName: 'iPhone 17 Pro',
runtimeDisplayName: 'iOS 26.4',
directory: recording.output,
},
]);
expect(await readFile(`${recording.output}/session.json`, 'utf8')).toBe(manifest);
expect(turtleFetch).not.toHaveBeenCalled();
}
);
test('waits for an adopted recording manifest when its MP4 already exists', async () => {
jest.mocked(readServeSimServersAsync).mockResolvedValue([]);
await IosSimulatorRecordingUtils.startAsync({ env, logger });
const recording = await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
udid,
deviceName: 'iPhone 17 Pro',
serverToken: 'job-token',
});
if (!recording) {
throw new Error('Expected the adopted recording output.');
}
await writeFile(`${recording.output}/recording.mp4`, 'partial footage');
let finished = false;
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger }).then(recordings => {
finished = true;
return recordings;
});
await setTimeout(20);
expect(finished).toBe(false);
const manifest = JSON.stringify({ recording: 'recording.mp4' });
await writeFile(`${recording.output}/session.json`, manifest);
expect(await finishing).toEqual([
{
udid,
deviceName: 'iPhone 17 Pro',
runtimeDisplayName: 'iOS 26.4',
directory: recording.output,
},
]);
expect(await readFile(`${recording.output}/session.json`, 'utf8')).toBe(manifest);
expect(logger.warn).not.toHaveBeenCalled();
expect(turtleFetch).not.toHaveBeenCalled();
expect(spawn).not.toHaveBeenCalled();
});
test('bounds the manifest wait for an adopted MP4 and reports missing metadata', async () => {
let currentTime = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
jest.mocked(readServeSimServersAsync).mockResolvedValue([]);
await IosSimulatorRecordingUtils.startAsync({ env, logger });
const recording = await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
udid,
deviceName: 'iPhone 17 Pro',
serverToken: 'job-token',
});
if (!recording) {
throw new Error('Expected the adopted recording output.');
}
await writeFile(`${recording.output}/recording.mp4`, 'partial footage');
let finished = false;
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger }).then(recordings => {
finished = true;
return recordings;
});
await setTimeout(50);
expect(finished).toBe(false);
currentTime += 311_000;
expect(await finishing).toEqual([]);
expect(logger.warn).toHaveBeenCalledWith(
{ recorderOutput: '' },
'Screen recording for iPhone 17 Pro ended without a manifest; its footage cannot be uploaded.'
);
expect(Sentry.capture).toHaveBeenCalledWith(
'iOS Simulator screen recording ended without a manifest',
{ level: 'warning', extras: { deviceName: 'iPhone 17 Pro' } }
);
expect(turtleFetch).not.toHaveBeenCalled();
expect(spawn).not.toHaveBeenCalled();
});
test('does not wait for an empty adopted recording reservation', async () => {
jest.mocked(readServeSimServersAsync).mockResolvedValue([]);
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
udid,
deviceName: 'iPhone',
serverToken: 'job-token',
});
expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toEqual([]);
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'Could not collect the warm Simulator video recording after shutdown.'
);
expect(turtleFetch).not.toHaveBeenCalled();
});
test('a reserved adopted recording does not suppress recording a replacement server token', async () => {
let releaseDevices!: (devices: never[]) => void;
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockImplementationOnce(
() =>
new Promise(resolve => {
releaseDevices = resolve as never;
})
);
jest
.mocked(readServeSimServersAsync)
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:3200', token: 'replacement-token' }]);
mockFailingRecorder();
await IosSimulatorRecordingUtils.startAsync({ env, logger });
await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
udid,
deviceName: 'iPhone',
serverToken: 'old-token',
});
jest.mocked(turtleFetch).mockRejectedValue(new Error('old server stopped'));
releaseDevices([{ udid, name: 'iPhone', runtimeDisplayName: 'iOS 26.4' }] as never);
await waitForRecorderSpawnAsync();
expect(spawn).toHaveBeenCalledTimes(1);
});
@@ -1,6 +1,7 @@
import { bunyan } from '@expo/logger';
import fetch from 'node-fetch';
import { Readable } from 'node:stream';
import timers from 'node:timers/promises';
import { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
@@ -15,6 +16,7 @@ jest.mock('../deviceRunSessionArtifacts');
jest.mock('../../../sentry');
jest.mock('node-fetch');
const sleepWithSignal = timers.setTimeout;
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
async function readStreamAsync(stream: NodeJS.ReadableStream): Promise<void> {
@@ -276,6 +278,14 @@ describe(uploadAgentDeviceArtifactAsync, () => {
describe(pollAgentDeviceArtifactsForUploadAsync, () => {
beforeEach(() => {
jest.useFakeTimers();
// Keep fake-clock tests deterministic; cancellation tests use the native abortable timer.
jest
.spyOn(timers, 'setTimeout')
.mockImplementation((delay, value, options) =>
options?.signal
? sleepWithSignal(delay, value, options)
: new Promise(resolve => setTimeout(resolve, delay, value))
);
jest.mocked(fetch).mockReset();
jest.mocked(Sentry.capture).mockReset();
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockReset();
@@ -284,6 +294,257 @@ describe(pollAgentDeviceArtifactsForUploadAsync, () => {
afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
jest.restoreAllMocks();
});
it('cancels a warm poller during its wait without another inventory request', async () => {
jest.useRealTimers();
const controller = new AbortController();
jest.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ artifacts: [] })));
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'token',
logger: createLoggerMock(),
signal: controller.signal,
});
await new Promise(resolve => setImmediate(resolve));
controller.abort();
await polling;
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ signal: controller.signal })
);
});
it('cancels an in-flight warm artifact transfer and drains the poller', async () => {
jest.useRealTimers();
const controller = new AbortController();
let downloading!: () => void;
const started = new Promise<void>(resolve => {
downloading = resolve;
});
jest
.mocked(fetch)
.mockResolvedValueOnce(
new Response(JSON.stringify({ artifacts: [{ id: 'new-artifact', filename: 'new.png' }] }))
)
.mockImplementationOnce(async (_url, options) => {
const signal = options?.signal as AbortSignal;
expect(signal).toBe(controller.signal);
downloading();
return await new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
});
});
const logger = createLoggerMock();
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'token',
logger,
signal: controller.signal,
});
await started;
controller.abort(new Error('session ended'));
await polling;
expect(fetch).toHaveBeenCalledTimes(2);
expect(logger.warn).not.toHaveBeenCalled();
expect(uploadDeviceRunSessionArtifactAsync).not.toHaveBeenCalled();
});
it('finishes an active cloud upload and collects final artifacts after normal stop', async () => {
jest.useRealTimers();
const stopPolling = new AbortController();
const cancellation = new AbortController();
let releaseUpload!: () => void;
let uploadStarted!: () => void;
const pendingUpload = new Promise<void>(resolve => {
releaseUpload = resolve;
});
const started = new Promise<void>(resolve => {
uploadStarted = resolve;
});
let inventories = 0;
const first = { id: 'first', filename: 'first.png' };
const last = { id: 'last', filename: 'last.png' };
jest
.mocked(fetch)
.mockImplementation(async url =>
String(url).endsWith('/artifacts')
? new Response(
JSON.stringify({ artifacts: ++inventories === 1 ? [first] : [first, last] })
)
: new Response(Readable.from(['png']))
);
let transferSignal: AbortSignal | undefined;
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_ctx, options) => {
await readStreamAsync(options.openStream());
if (options.artifactId === 'first') {
transferSignal = options.signal;
uploadStarted();
await pendingUpload;
}
});
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'token',
logger: createLoggerMock(),
signal: cancellation.signal,
stopPollingSignal: stopPolling.signal,
});
let finished = false;
void polling.then(() => {
finished = true;
});
try {
await started;
stopPolling.abort();
await new Promise(resolve => setImmediate(resolve));
expect(transferSignal?.aborted).toBe(false);
expect(finished).toBe(false);
releaseUpload();
await polling;
expect(inventories).toBe(2);
expect(
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mock.calls.map(([, options]) => options.artifactId)
).toEqual(['first', 'last']);
} finally {
releaseUpload();
cancellation.abort();
await polling;
}
});
it('checks for an artifact created between the last poll and normal stop', async () => {
jest.useRealTimers();
const stopPolling = new AbortController();
jest
.mocked(fetch)
.mockResolvedValueOnce(new Response(JSON.stringify({ artifacts: [] })))
.mockResolvedValueOnce(
new Response(JSON.stringify({ artifacts: [{ id: 'last', filename: 'last.png' }] }))
)
.mockResolvedValueOnce(new Response(Readable.from(['png'])));
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_ctx, options) => {
await readStreamAsync(options.openStream());
});
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'token',
logger: createLoggerMock(),
stopPollingSignal: stopPolling.signal,
});
await new Promise(resolve => setImmediate(resolve));
stopPolling.abort();
await polling;
expect(fetch).toHaveBeenCalledTimes(3);
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ artifactId: 'last' })
);
});
it('reports a failed final inventory after normal stop', async () => {
jest.useRealTimers();
const stopPolling = new AbortController();
const logger = createLoggerMock();
const error = new Error('daemon is not ready');
jest
.mocked(fetch)
.mockResolvedValueOnce(new Response(JSON.stringify({ artifacts: [] })))
.mockRejectedValueOnce(error);
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'token',
logger,
stopPollingSignal: stopPolling.signal,
});
await new Promise(resolve => setImmediate(resolve));
stopPolling.abort();
await polling;
expect(fetch).toHaveBeenCalledTimes(2);
expect(logger.warn).toHaveBeenCalledWith(
{ err: error, failedArtifactListCount: 1 },
'Could not list agent-device remote session artifacts.'
);
expect(Sentry.capture).toHaveBeenCalledTimes(1);
});
it('cancels a graceful collector immediately when the job is cancelled', async () => {
jest.useRealTimers();
const stopPolling = new AbortController();
const cancellation = new AbortController();
let entered!: () => void;
const started = new Promise<void>(resolve => {
entered = resolve;
});
jest.mocked(fetch).mockImplementation(async (_url, options) => {
const signal = options?.signal as AbortSignal;
entered();
return await new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
});
});
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'token',
logger: createLoggerMock(),
signal: cancellation.signal,
stopPollingSignal: stopPolling.signal,
});
await started;
cancellation.abort();
await polling;
expect(stopPolling.signal.aborted).toBe(false);
expect(fetch).toHaveBeenCalledTimes(1);
});
it('cancels a stuck transfer after the normal-stop drain budget expires', async () => {
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
const stopPolling = new AbortController();
const logger = createLoggerMock();
let entered!: () => void;
const started = new Promise<void>(resolve => {
entered = resolve;
});
jest
.mocked(fetch)
.mockResolvedValueOnce(
new Response(JSON.stringify({ artifacts: [{ id: 'large', filename: 'large.mp4' }] }))
)
.mockResolvedValueOnce(new Response(Readable.from(['video'])));
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_ctx, options) => {
await readStreamAsync(options.openStream());
entered();
return await new Promise((_resolve, reject) => {
options.signal!.addEventListener('abort', () => reject(options.signal!.reason), {
once: true,
});
});
});
const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, {
deviceRunSessionId: 'drs-id',
daemonUrl: 'http://127.0.0.1:1234',
daemonToken: 'token',
logger,
stopPollingSignal: stopPolling.signal,
});
await started;
stopPolling.abort();
await jest.advanceTimersByTimeAsync(30_000);
await polling;
expect(fetch).toHaveBeenCalledTimes(2);
expect(logger.warn).toHaveBeenCalledWith(
'Timed out draining agent-device artifacts; some session artifacts may not have been uploaded.'
);
});
it('reports artifact listing errors on every fifth consecutive failure', async () => {
@@ -0,0 +1,57 @@
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs/promises';
import { createColdIosSessionAsync } from '../coldIosSession';
import { spawnDetached } from '../remoteDeviceRunSession';
jest.mock('@expo/turtle-spawn');
jest.mock('../remoteDeviceRunSession', () => ({
fetchWebPreviewTurnArgsAsync: jest.fn(async () => []),
findAvailablePortAsync: jest.fn(async () => 3200),
spawnDetached: jest.fn(() => ({ stopAsync: jest.fn() })),
}));
const STATE_FILE = '/tmp/expo-simulator-startup.json';
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
const logger = { info: jest.fn(), warn: jest.fn() } as any;
async function startServerAsync(signal?: AbortSignal) {
const server = await createColdIosSessionAsync({} as any, { env: {}, logger });
return await server.startAsync({
command: 'serve-sim',
args: [],
env: {},
logger,
secrets: [],
stopGracePeriodMs: 1000,
signal,
});
}
beforeEach(async () => {
jest.mocked(spawn).mockResolvedValue({ stdout: 'current-boot\n' } as never);
Object.defineProperty(process, 'platform', { value: 'darwin' });
await fs.mkdir('/tmp', { recursive: true });
await fs.writeFile(STATE_FILE, JSON.stringify({ status: 'stopping' }));
});
afterEach(() => {
Object.defineProperty(process, 'platform', platform);
});
it('starts serve-sim only after warm Simulator cleanup finished', async () => {
const starting = startServerAsync();
await new Promise(resolve => setTimeout(resolve, 600));
expect(spawnDetached).not.toHaveBeenCalled();
await fs.writeFile(STATE_FILE, JSON.stringify({ status: 'disabled', bootUuid: 'current-boot' }));
await starting;
expect(spawnDetached).toHaveBeenCalledTimes(1);
});
it('does not start serve-sim when the job is cancelled while cleanup runs', async () => {
const controller = new AbortController();
const starting = startServerAsync(controller.signal);
// Cleanup is still running; the cancellation alone must end the wait.
controller.abort(new Error('cancelled'));
await expect(starting).rejects.toThrow('cancelled');
expect(spawnDetached).not.toHaveBeenCalled();
});
@@ -0,0 +1,62 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { validateControllerVersion } from '../controllerVersion';
describe(validateControllerVersion, () => {
const minimumVersion = '0.16.0';
const controller = { packageName: 'example-controller', minimumVersion };
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(() =>
validateControllerVersion({ ...controller, packageVersion: undefined, logger })
).not.toThrow();
expect(() =>
validateControllerVersion({ ...controller, packageVersion: 'latest', logger })
).not.toThrow();
expect(warn).not.toHaveBeenCalled();
});
it('allows exact supported semver versions', () => {
expect(() =>
validateControllerVersion({ ...controller, packageVersion: minimumVersion, logger })
).not.toThrow();
expect(() =>
validateControllerVersion({ ...controller, packageVersion: supportedVersion, logger })
).not.toThrow();
expect(() =>
validateControllerVersion({ ...controller, packageVersion: `v${supportedVersion}`, logger })
).not.toThrow();
expect(warn).not.toHaveBeenCalled();
});
it('rejects exact semver versions that are too old', () => {
expect(() =>
validateControllerVersion({ ...controller, packageVersion: oldVersion, logger })
).toThrow(SystemError);
expect(() =>
validateControllerVersion({ ...controller, packageVersion: oldVersion, logger })
).toThrow(`Use "latest" or pass an exact version >= ${minimumVersion}`);
});
it('warns for package tags or ranges that cannot be verified', () => {
expect(() =>
validateControllerVersion({ ...controller, packageVersion: 'next', logger })
).not.toThrow();
expect(() =>
validateControllerVersion({ ...controller, packageVersion: `^${minimumVersion}`, logger })
).not.toThrow();
expect(warn).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Continuing and letting the package manager resolve it.')
);
});
});
@@ -7,6 +7,7 @@ import { setImmediate } from 'node:timers/promises';
import { type CustomBuildContext } from '../../../customBuildContext';
import RemoteLoggerStream from '../../../logging/RemoteLoggerStream';
import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
import { downloadBuildAsync } from '../../functions/downloadBuild';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
@@ -266,6 +267,48 @@ describe('with a device run session token', () => {
expect(jest.mocked(turtleFetch)).toHaveBeenCalledTimes(1);
});
it('handles a stopped-session TURN response without a warning or retry', async () => {
const ctx = createCtx({ withToken: true });
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(
jsonResponse(
{ errors: [{ code: 'DEVICE_RUN_SESSION_ALREADY_STOPPED', message: 'Stopped.' }] },
400
)
);
await expect(fetchWebPreviewTurnArgsAsync(ctx, { logger })).resolves.toEqual([]);
expect(logger.info).toHaveBeenCalledWith(
'Device run session was stopped before TURN credentials were fetched.'
);
expect(logger.warn).not.toHaveBeenCalled();
expect(Sentry.capture).not.toHaveBeenCalled();
expect(turtleFetch).toHaveBeenCalledTimes(1);
});
it.each([undefined, 'VALIDATION_ERROR'])(
'still warns on a TURN failure with code %s',
async code => {
const ctx = createCtx({ withToken: true });
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(
jsonResponse(
{ errors: [{ code, message: 'Device run session has already stopped.' }] },
400
)
);
await expect(fetchWebPreviewTurnArgsAsync(ctx, { logger })).resolves.toEqual([]);
expect(logger.warn).toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalled();
expect(turtleFetch).toHaveBeenCalledTimes(1);
}
);
it('creates artifact upload sessions through the worker API', async () => {
const ctx = createCtx({ withToken: true });
jest.mocked(turtleFetch).mockResolvedValueOnce(
File diff suppressed because it is too large. Load diff
@@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { readIosApplicationIdentifierAsync } from '../iosAppArtifact';
import { isPreinstalledExpoGoAsync, readIosApplicationIdentifierAsync } from '../iosAppArtifact';
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.unmock('fs');
@@ -61,3 +61,59 @@ describe(readIosApplicationIdentifierAsync, () => {
).rejects.toMatchObject({ errorCode: 'EAS_INSTALL_BUILD_MISSING_IDENTIFIER' });
});
});
describe(isPreinstalledExpoGoAsync, () => {
const preinstalled = {
bundleIdentifier: 'host.exp.Exponent',
shortVersion: '58.0.7',
version: '58.0.7',
};
function plist(values: Record<string, string>) {
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => ({
stdout: `${values[args[1]]}\n`,
})) as never);
}
const app = (shortVersion: string, bundleIdentifier = 'host.exp.Exponent') => ({
CFBundleIdentifier: bundleIdentifier,
CFBundleShortVersionString: shortVersion,
CFBundleVersion: shortVersion,
});
beforeEach(() => {
jest.mocked(spawn).mockReset();
});
it('matches the preinstalled Expo Go without asking the Simulator', async () => {
plist(app('58.0.7'));
await expect(
isPreinstalledExpoGoAsync({ appPath: '/job/Expo Go.app', preinstalled, env: {} })
).resolves.toBe(true);
expect(jest.mocked(spawn).mock.calls.every(([command]) => command === 'plutil')).toBe(true);
});
it('does not match a different Expo Go version', async () => {
plist(app('58.0.6'));
await expect(
isPreinstalledExpoGoAsync({ appPath: '/job/Expo Go.app', preinstalled, env: {} })
).resolves.toBe(false);
});
it('never matches a custom build, even with the same version', async () => {
plist(app('58.0.7', 'com.example.app'));
await expect(
isPreinstalledExpoGoAsync({
appPath: '/job/Example.app',
preinstalled: { ...preinstalled, bundleIdentifier: 'com.example.app' },
env: {},
})
).resolves.toBe(false);
expect(spawn).not.toHaveBeenCalled();
});
it('does not match an app it cannot read', async () => {
jest.mocked(spawn).mockRejectedValue(new Error('plutil failed'));
await expect(
isPreinstalledExpoGoAsync({ appPath: '/job/Expo Go.app', preinstalled, env: {} })
).resolves.toBe(false);
});
});
@@ -0,0 +1,873 @@
import { type bunyan } from '@expo/logger';
import * as ngrok from '@ngrok/ngrok';
import { once } from 'node:events';
import { createServer } from 'node:http';
import WebSocket, { WebSocketServer } from 'ws';
import { type AddressInfo, createConnection, createServer as createTcpServer } from 'node:net';
import { turtleFetch } from '../../../utils/turtleFetch';
import { startNgrokTunnelAsync } from '../ngrokTunnel';
jest.mock('@ngrok/ngrok');
jest.mock('node:timers', () => ({
setTimeout: (...args: Parameters<typeof setTimeout>) => setTimeout(...args),
clearTimeout: (...args: Parameters<typeof clearTimeout>) => clearTimeout(...args),
}));
jest.mock('../../../utils/turtleFetch');
const { Response } = jest.requireActual<typeof import('node-fetch')>('node-fetch');
const url = 'https://web-preview-fixed.example.test';
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const options = {
port: 4321,
subdomainPrefix: 'web-preview',
subdomainId: 'fixed',
baseDomain: 'example.test',
authtoken: 'token',
rewriteHostHeader: true,
healthCheck: { path: '/healthz' },
logger,
};
function listener(publicUrl = url) {
return { url: () => publicUrl, close: jest.fn().mockResolvedValue(undefined) };
}
let initial: ReturnType<typeof listener>;
let replacement: ReturnType<typeof listener>;
beforeEach(() => {
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
jest.clearAllMocks();
initial = listener();
replacement = listener();
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValueOnce(initial as never)
.mockResolvedValue(replacement as never);
jest
.mocked(turtleFetch)
.mockReset()
.mockResolvedValue(new Response(undefined, { status: 200 }));
});
afterEach(() => {
jest.useRealTimers();
});
function failPublicProbes() {
jest.mocked(turtleFetch).mockImplementation(
async target =>
new Response(undefined, {
status: target.startsWith('https:') ? 503 : 200,
})
);
}
it('keeps tunnels without a health check unsupervised and closes them on stop', async () => {
const tunnel = await startNgrokTunnelAsync({ ...options, healthCheck: undefined });
await jest.advanceTimersByTimeAsync(90_000);
expect(turtleFetch).not.toHaveBeenCalled();
expect(ngrok.forward).toHaveBeenCalledTimes(1);
await tunnel.stopAsync();
expect(initial.close).toHaveBeenCalledTimes(1);
});
it('probes the public service and keeps a healthy listener open', async () => {
const tunnel = await startNgrokTunnelAsync(options);
try {
await jest.advanceTimersByTimeAsync(45_000);
expect(turtleFetch).toHaveBeenCalledTimes(3);
expect(turtleFetch).toHaveBeenCalledWith(
`${url}/healthz`,
'GET',
expect.objectContaining({
retries: 0,
redirect: 'manual',
headers: expect.objectContaining({ 'ngrok-skip-browser-warning': 'true' }),
})
);
expect(ngrok.forward).toHaveBeenCalledTimes(1);
} finally {
await tunnel.stopAsync();
}
expect(initial.close).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(60_000);
expect(turtleFetch).toHaveBeenCalledTimes(3);
});
it('reopens after three failed public probes with a healthy local service, preserving the endpoint', async () => {
failPublicProbes();
const tunnel = await startNgrokTunnelAsync(options);
try {
await jest.advanceTimersByTimeAsync(30_000);
expect(initial.close).not.toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(15_000);
expect(initial.close).toHaveBeenCalledTimes(1);
expect(ngrok.forward).toHaveBeenCalledTimes(2);
expect(jest.mocked(ngrok.forward).mock.calls[1][0]).toEqual({
addr: expect.any(Number),
authtoken: 'token',
domain: 'web-preview-fixed.example.test',
request_header_add: ['Host:localhost:4321'],
force_new_session: true,
});
expect(tunnel.url).toBe(url);
jest.mocked(turtleFetch).mockResolvedValue(new Response(undefined, { status: 200 }));
await jest.advanceTimersByTimeAsync(15_000);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('healthy again'));
} finally {
await tunnel.stopAsync();
}
expect(replacement.close).toHaveBeenCalledTimes(1);
});
it('does not reopen when the local service is unhealthy', async () => {
jest.mocked(turtleFetch).mockResolvedValue(new Response(undefined, { status: 503 }));
const tunnel = await startNgrokTunnelAsync(options);
try {
await jest.advanceTimersByTimeAsync(90_000);
expect(ngrok.forward).toHaveBeenCalledTimes(1);
expect(initial.close).not.toHaveBeenCalled();
} finally {
await tunnel.stopAsync();
}
});
it('resets consecutive failures after a successful public probe', async () => {
failPublicProbes();
const tunnel = await startNgrokTunnelAsync(options);
try {
await jest.advanceTimersByTimeAsync(30_000);
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response(undefined, { status: 200 }));
await jest.advanceTimersByTimeAsync(15_000);
await jest.advanceTimersByTimeAsync(30_000);
expect(ngrok.forward).toHaveBeenCalledTimes(1);
} finally {
await tunnel.stopAsync();
}
});
it('retries a failed reopen with backoff', async () => {
failPublicProbes();
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValueOnce(initial as never)
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValue(replacement as never);
const tunnel = await startNgrokTunnelAsync(options);
try {
await jest.advanceTimersByTimeAsync(45_000);
expect(ngrok.forward).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(1_999);
expect(ngrok.forward).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(1);
expect(ngrok.forward).toHaveBeenCalledTimes(3);
} finally {
await tunnel.stopAsync();
}
});
it('closes a listener that finishes opening after shutdown, with idempotent cleanup', async () => {
failPublicProbes();
let resolveOpening!: (value: ngrok.Listener) => void;
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValueOnce(initial as never)
.mockReturnValueOnce(
new Promise(resolve => {
resolveOpening = resolve;
})
);
const tunnel = await startNgrokTunnelAsync(options);
await jest.advanceTimersByTimeAsync(45_000);
const stopping = tunnel.stopAsync();
expect(tunnel.stopAsync()).toBe(stopping);
const stopped = jest.fn();
void stopping.then(stopped);
await jest.advanceTimersByTimeAsync(0);
expect(stopped).not.toHaveBeenCalled();
resolveOpening(replacement as never);
await stopping;
expect(replacement.close).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(120_000);
expect(ngrok.forward).toHaveBeenCalledTimes(2);
});
it('waits for a replacement acquired during shutdown to finish closing', async () => {
failPublicProbes();
let resolveOpening!: (value: ngrok.Listener) => void;
let resolveClose!: () => void;
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValueOnce(initial as never)
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
replacement.close.mockReturnValue(new Promise<void>(resolve => (resolveClose = resolve)));
const tunnel = await startNgrokTunnelAsync(options);
await jest.advanceTimersByTimeAsync(45_000);
let stopped = false;
const stopping = tunnel.stopAsync().then(() => {
stopped = true;
});
await jest.advanceTimersByTimeAsync(0);
resolveOpening(replacement as never);
await jest.advanceTimersByTimeAsync(0);
const stoppedBeforeClose = stopped;
resolveClose();
await jest.advanceTimersByTimeAsync(250);
await stopping;
expect(stoppedBeforeClose).toBe(false);
expect(replacement.close).toHaveBeenCalledTimes(1);
});
it.each([0, 4, 5, 10])('retires a reopen cancelled after %s promise turns', async turns => {
failPublicProbes();
let resolveOpening!: (value: ngrok.Listener) => void;
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValueOnce(initial as never)
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
const tunnel = await startNgrokTunnelAsync(options);
await jest.advanceTimersByTimeAsync(45_000);
resolveOpening(replacement as never);
for (let turn = 0; turn < turns; turn++) {
await Promise.resolve();
}
await tunnel.stopAsync();
await jest.advanceTimersByTimeAsync(0);
expect(replacement.close).toHaveBeenCalledTimes(1);
});
it('retries a rejected close when a reopen finishes after shutdown', async () => {
failPublicProbes();
let resolveOpening!: (value: ngrok.Listener) => void;
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValueOnce(initial as never)
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
const tunnel = await startNgrokTunnelAsync(options);
await jest.advanceTimersByTimeAsync(45_000);
const stopping = tunnel.stopAsync();
replacement.close.mockRejectedValueOnce(new Error('temporary unlisten failure'));
resolveOpening(replacement as never);
await jest.advanceTimersByTimeAsync(250);
await stopping;
expect(replacement.close).toHaveBeenCalledTimes(2);
const warnings = jest.mocked(logger.warn).mock.calls.length;
await jest.advanceTimersByTimeAsync(120_000);
expect(logger.warn).toHaveBeenCalledTimes(warnings);
});
it('fails stop if an in-flight reopen still has not settled by the stop deadline', async () => {
failPublicProbes();
let resolveOpening!: (value: ngrok.Listener) => void;
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValueOnce(initial as never)
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
const tunnel = await startNgrokTunnelAsync(options);
await jest.advanceTimersByTimeAsync(45_000);
const rejected = expect(tunnel.stopAsync()).rejects.toThrow(
'Ngrok tunnel web-preview-fixed.example.test stop timed out after 4000ms'
);
await jest.advanceTimersByTimeAsync(4_000);
await rejected;
expect(ngrok.forward).toHaveBeenCalledTimes(2);
resolveOpening(replacement as never);
await jest.advanceTimersByTimeAsync(0);
expect(replacement.close).toHaveBeenCalledTimes(1);
});
it('retries an old listener while a reopen outlasts shutdown', async () => {
failPublicProbes();
initial.close.mockRejectedValueOnce(new Error('agent disconnected'));
let resolveOpening!: (value: ngrok.Listener) => void;
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValueOnce(initial as never)
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
const tunnel = await startNgrokTunnelAsync(options);
await jest.advanceTimersByTimeAsync(45_000);
expect(initial.close).toHaveBeenCalledTimes(1);
const rejected = expect(tunnel.stopAsync()).rejects.toThrow(
'Ngrok tunnel web-preview-fixed.example.test stop timed out after 4000ms'
);
await jest.advanceTimersByTimeAsync(4_000);
await rejected;
const oldCloseCalls = initial.close.mock.calls.length;
resolveOpening(replacement as never);
await jest.advanceTimersByTimeAsync(0);
expect(oldCloseCalls).toBe(2);
expect(replacement.close).toHaveBeenCalledTimes(1);
expect(ngrok.forward).toHaveBeenCalledTimes(2);
});
it('fails stop if a pending reopen arrives but cannot be retired', async () => {
failPublicProbes();
let resolveOpening!: (value: ngrok.Listener) => void;
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValueOnce(initial as never)
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
replacement.close.mockRejectedValue(new Error('unlisten failed'));
const tunnel = await startNgrokTunnelAsync(options);
await jest.advanceTimersByTimeAsync(45_000);
const rejected = expect(tunnel.stopAsync()).rejects.toThrow(
'Ngrok tunnel web-preview-fixed.example.test stop timed out'
);
resolveOpening(replacement as never);
await jest.advanceTimersByTimeAsync(4_000);
await rejected;
expect(replacement.close.mock.calls.length).toBeGreaterThan(1);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Could not retire late ngrok tunnel web-preview-fixed.example.test.'
);
const warnings = jest.mocked(logger.warn).mock.calls.length;
await jest.advanceTimersByTimeAsync(120_000);
expect(logger.warn).toHaveBeenCalledTimes(warnings);
expect(ngrok.forward).toHaveBeenCalledTimes(2);
});
it('rejects a changed public URL and closes the replacement', async () => {
failPublicProbes();
const changed = listener('https://different.example.test');
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValueOnce(initial as never)
.mockResolvedValue(changed as never);
const tunnel = await startNgrokTunnelAsync(options);
try {
await jest.advanceTimersByTimeAsync(45_000);
expect(changed.close).toHaveBeenCalledTimes(1);
expect(tunnel.url).toBe(url);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
expect.stringContaining('Could not reopen')
);
} finally {
await tunnel.stopAsync();
}
});
it('treats ngrok error responses as failures even if their status is accepted', async () => {
jest.mocked(turtleFetch).mockImplementation(
async target =>
new Response(undefined, {
status: 200,
headers: target.startsWith('https:') ? { 'ngrok-error-code': 'ERR_NGROK_3200' } : {},
})
);
const tunnel = await startNgrokTunnelAsync(options);
try {
await jest.advanceTimersByTimeAsync(45_000);
expect(ngrok.forward).toHaveBeenCalledTimes(2);
} finally {
await tunnel.stopAsync();
}
});
it('reopens on a fresh agent session even if closing the old listener fails', async () => {
failPublicProbes();
initial.close.mockRejectedValueOnce(new Error('agent disconnected'));
const tunnel = await startNgrokTunnelAsync(options);
try {
await jest.advanceTimersByTimeAsync(45_000);
expect(ngrok.forward).toHaveBeenCalledTimes(2);
expect(ngrok.forward).toHaveBeenLastCalledWith(
expect.objectContaining({ force_new_session: true })
);
expect(ngrok.disconnect).not.toHaveBeenCalled();
} finally {
await tunnel.stopAsync();
}
expect(initial.close).toHaveBeenCalledTimes(2);
expect(replacement.close).toHaveBeenCalledTimes(1);
});
it('retries a rejected close before reopening an endpoint that remains bound', async () => {
failPublicProbes();
let domainOccupied = true;
initial.close
.mockRejectedValueOnce(new Error('agent disconnected'))
.mockImplementation(async () => {
domainOccupied = false;
});
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValueOnce(initial as never)
.mockImplementation(async () => {
if (domainOccupied) {
throw new Error('endpoint is still bound');
}
return replacement as never;
});
const tunnel = await startNgrokTunnelAsync(options);
try {
await jest.advanceTimersByTimeAsync(45_000);
expect(initial.close).toHaveBeenCalledTimes(1);
expect(ngrok.forward).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(2_000);
expect(initial.close).toHaveBeenCalledTimes(2);
expect(ngrok.forward).toHaveBeenCalledTimes(3);
expect(tunnel.url).toBe(url);
} finally {
await tunnel.stopAsync();
}
expect(replacement.close).toHaveBeenCalledTimes(1);
});
it('retires an old listener even after its replacement is healthy', async () => {
failPublicProbes();
initial.close.mockRejectedValueOnce(new Error('agent disconnected'));
const tunnel = await startNgrokTunnelAsync(options);
try {
await jest.advanceTimersByTimeAsync(45_000);
expect(initial.close).toHaveBeenCalledTimes(1);
jest.mocked(turtleFetch).mockResolvedValue(new Response(undefined, { status: 200 }));
await jest.advanceTimersByTimeAsync(15_000);
expect(initial.close).toHaveBeenCalledTimes(2);
expect(ngrok.forward).toHaveBeenCalledTimes(2);
} finally {
await tunnel.stopAsync();
}
});
it('retries an old listener without repeating its close warning on every probe', async () => {
failPublicProbes();
initial.close.mockRejectedValue(new Error('old session disconnected'));
const tunnel = await startNgrokTunnelAsync(options);
await jest.advanceTimersByTimeAsync(45_000);
const warnings = jest.mocked(logger.warn).mock.calls.length;
expect(warnings).toBeGreaterThan(0);
jest.mocked(turtleFetch).mockResolvedValue(new Response(undefined, { status: 200 }));
await jest.advanceTimersByTimeAsync(120_000);
expect(initial.close.mock.calls.length).toBeGreaterThan(1);
expect(ngrok.forward).toHaveBeenCalledTimes(2);
expect(logger.warn).toHaveBeenCalledTimes(warnings);
const rejected = expect(tunnel.stopAsync()).rejects.toThrow(
'Ngrok tunnel web-preview-fixed.example.test stop timed out'
);
await jest.advanceTimersByTimeAsync(4_000);
await rejected;
expect(replacement.close).toHaveBeenCalledTimes(1);
});
it('retries a rejected close during shutdown', async () => {
const failure = new Error('agent disconnected');
initial.close.mockRejectedValueOnce(failure);
const tunnel = await startNgrokTunnelAsync(options);
const stopping = tunnel.stopAsync();
await jest.advanceTimersByTimeAsync(250);
await stopping;
expect(initial.close).toHaveBeenCalledTimes(2);
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: failure },
'Could not stop ngrok tunnel web-preview-fixed.example.test.'
);
});
it('logs the first close rejection once while bounded shutdown retries it', async () => {
const failure = new Error('agent disconnected');
initial.close.mockRejectedValue(failure);
const tunnel = await startNgrokTunnelAsync(options);
const rejected = expect(tunnel.stopAsync()).rejects.toThrow('stop timed out');
await jest.advanceTimersByTimeAsync(4_000);
await rejected;
expect(initial.close.mock.calls.length).toBeGreaterThan(1);
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: failure },
'Could not stop ngrok tunnel web-preview-fixed.example.test.'
);
});
it('bounds shutdown during recovery without duplicating a pending native close', async () => {
failPublicProbes();
let finishClose!: () => void;
initial.close.mockReturnValueOnce(new Promise<void>(resolve => (finishClose = resolve)));
const tunnel = await startNgrokTunnelAsync(options);
await jest.advanceTimersByTimeAsync(45_000);
const stopping = tunnel.stopAsync();
expect(tunnel.stopAsync()).toBe(stopping);
const rejected = expect(stopping).rejects.toThrow(
'Ngrok tunnel web-preview-fixed.example.test stop timed out after 4000ms'
);
await jest.advanceTimersByTimeAsync(4_000);
await rejected;
expect(initial.close).toHaveBeenCalledTimes(1);
expect(ngrok.forward).toHaveBeenCalledTimes(1);
const warnings = jest.mocked(logger.warn).mock.calls.length;
finishClose();
await jest.advanceTimersByTimeAsync(120_000);
expect(logger.warn).toHaveBeenCalledTimes(warnings);
expect(initial.close).toHaveBeenCalledTimes(1);
});
it('bounds a stalled listener close before reopening', async () => {
failPublicProbes();
initial.close.mockImplementationOnce(async () => await new Promise(() => {}));
const tunnel = await startNgrokTunnelAsync(options);
try {
await jest.advanceTimersByTimeAsync(49_999);
expect(ngrok.forward).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1);
expect(ngrok.forward).toHaveBeenCalledTimes(2);
} finally {
const stopping = tunnel.stopAsync();
const rejected = expect(stopping).rejects.toThrow(
'Ngrok tunnel web-preview-fixed.example.test stop timed out'
);
await jest.advanceTimersByTimeAsync(4_000);
await rejected;
}
});
it('does not queue more native opens while a timed-out open is still pending', async () => {
failPublicProbes();
let resolveOpening!: (value: ngrok.Listener) => void;
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValueOnce(initial as never)
.mockReturnValueOnce(
new Promise(resolve => {
resolveOpening = resolve;
})
)
.mockResolvedValue(replacement as never);
const tunnel = await startNgrokTunnelAsync(options);
const late = listener();
try {
await jest.advanceTimersByTimeAsync(180_000);
expect(ngrok.forward).toHaveBeenCalledTimes(2);
resolveOpening(late as never);
await jest.advanceTimersByTimeAsync(30_000);
expect(late.close).toHaveBeenCalledTimes(1);
expect(ngrok.forward).toHaveBeenCalledTimes(3);
} finally {
await tunnel.stopAsync();
}
expect(replacement.close).toHaveBeenCalledTimes(1);
});
it('bounds initial tunnel creation and retires a late listener', async () => {
let resolveOpening!: (value: ngrok.Listener) => void;
jest
.mocked(ngrok.forward)
.mockReset()
.mockReturnValueOnce(
new Promise(resolve => {
resolveOpening = resolve;
})
);
const opening = startNgrokTunnelAsync(options);
const rejected = expect(opening).rejects.toThrow('Ngrok tunnel open timed out');
await jest.advanceTimersByTimeAsync(15_000);
await rejected;
resolveOpening(initial as never);
await jest.advanceTimersByTimeAsync(0);
expect(initial.close).toHaveBeenCalledTimes(1);
});
it('retries a rejected close after initial tunnel creation timed out', async () => {
let resolveOpening!: (value: ngrok.Listener) => void;
jest
.mocked(ngrok.forward)
.mockReset()
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
const opening = startNgrokTunnelAsync(options);
const rejected = expect(opening).rejects.toThrow('Ngrok tunnel open timed out');
await jest.advanceTimersByTimeAsync(15_000);
await rejected;
initial.close.mockRejectedValueOnce(new Error('temporary unlisten failure'));
resolveOpening(initial as never);
await jest.advanceTimersByTimeAsync(250);
expect(initial.close).toHaveBeenCalledTimes(2);
});
it('retires only the late listener without blaming it for an older stuck listener', async () => {
failPublicProbes();
initial.close.mockRejectedValue(new Error('old session disconnected'));
let resolveOpening!: (value: ngrok.Listener) => void;
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValueOnce(initial as never)
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)))
.mockResolvedValue(replacement as never);
const tunnel = await startNgrokTunnelAsync(options);
try {
await jest.advanceTimersByTimeAsync(60_000);
const late = listener();
resolveOpening(late as never);
jest.mocked(turtleFetch).mockResolvedValue(new Response(undefined, { status: 200 }));
await jest.advanceTimersByTimeAsync(4_000);
expect(late.close).toHaveBeenCalledTimes(1);
expect(
jest
.mocked(logger.warn)
.mock.calls.some(([, message]) => message?.includes('Could not retire late'))
).toBe(false);
} finally {
initial.close.mockResolvedValue(undefined);
await tunnel.stopAsync();
}
});
it('recovers using actual HTTP health probes with a local ngrok adapter', async () => {
const actualFetch = jest.requireActual<typeof import('node-fetch')>('node-fetch').default;
const realSetTimeout = jest.requireActual<typeof import('node:timers')>('node:timers').setTimeout;
let broken = true;
const local = createServer((_req, res) => {
res.writeHead(200);
res.end('healthy');
});
const publicServer = createServer((_req, res) => {
res.writeHead(broken ? 503 : 200);
res.end('probe');
});
await Promise.all(
[local, publicServer].map(
server => new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
)
);
const publicUrl = `http://127.0.0.1:${(publicServer.address() as AddressInfo).port}`;
let completedRequests = 0;
jest.mocked(turtleFetch).mockImplementation(async (target, method, requestOptions) => {
const response = await actualFetch(target, { ...requestOptions, method });
completedRequests++;
return response;
});
const first = listener(publicUrl);
const next = listener(publicUrl);
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValueOnce(first as never)
.mockImplementation(async () => {
broken = false;
return next as never;
});
const tunnel = await startNgrokTunnelAsync({
...options,
port: (local.address() as AddressInfo).port,
});
try {
for (let count = 0; count < 3; count++) {
await jest.advanceTimersByTimeAsync(15_000);
for (let turns = 0; turns < 50 && completedRequests < (count + 1) * 2; turns++) {
await new Promise(resolve => realSetTimeout(resolve, 10));
}
expect(completedRequests).toBe((count + 1) * 2);
}
expect(ngrok.forward).toHaveBeenCalledTimes(2);
expect(first.close).toHaveBeenCalledTimes(1);
expect((await actualFetch(`${tunnel.url}/healthz`)).status).toBe(200);
} finally {
await tunnel.stopAsync();
await Promise.all(
[local, publicServer].map(
server =>
new Promise<void>((resolve, reject) =>
server.close(err => (err ? reject(err) : resolve()))
)
)
);
}
expect(next.close).toHaveBeenCalledTimes(1);
});
it('blocks HTTP and existing WebSockets on Stop even when ngrok cannot close', async () => {
jest.useRealTimers();
const actualFetch = jest.requireActual<typeof import('node-fetch')>('node-fetch').default;
let requests = 0;
let messages = 0;
const backend = createServer((_request, response) => {
requests++;
response.end('owned backend');
});
const websockets = new WebSocketServer({ server: backend });
websockets.on('connection', socket => {
socket.on('message', data => {
messages++;
socket.send(data);
});
});
backend.listen(0, '127.0.0.1');
await once(backend, 'listening');
const backendPort = (backend.address() as AddressInfo).port;
const tunnel = await startNgrokTunnelAsync({
...options,
port: backendPort,
healthCheck: undefined,
});
const targetPort = (jest.mocked(ngrok.forward).mock.calls[0][0] as ngrok.Config).addr;
const targetUrl = `http://127.0.0.1:${targetPort}`;
const socket = new WebSocket(`ws://127.0.0.1:${targetPort}`);
const opened = once(socket, 'open');
try {
expect(await (await actualFetch(targetUrl)).text()).toBe('owned backend');
await opened;
const echoed = once(socket, 'message');
socket.send('before-stop');
expect((await echoed)[0].toString()).toBe('before-stop');
const closed = once(socket, 'close');
initial.close.mockRejectedValue(new Error('agent disconnected'));
const stopping = tunnel.stopAsync();
const rejected = expect(stopping).rejects.toThrow('stop timed out after 4000ms');
await expect(actualFetch(targetUrl, { timeout: 1_000 })).rejects.toThrow();
await closed;
expect(requests).toBe(1);
expect(messages).toBe(1);
expect(await (await actualFetch(`http://127.0.0.1:${backendPort}`)).text()).toBe(
'owned backend'
);
await rejected;
await expect(actualFetch(targetUrl, { timeout: 1_000 })).rejects.toThrow();
expect(requests).toBe(2);
expect(messages).toBe(1);
const competing = createTcpServer();
const conflict = once(competing, 'error');
competing.listen(targetPort as number, '127.0.0.1');
expect((await conflict)[0]).toMatchObject({ code: 'EADDRINUSE' });
} finally {
socket.terminate();
for (const client of websockets.clients) {
client.terminate();
}
websockets.close();
backend.closeAllConnections();
await new Promise<void>(resolve => backend.close(() => resolve()));
await tunnel.stopAsync().catch(() => {});
}
});
it('releases its local target when initial tunnel creation fails', async () => {
jest.useRealTimers();
jest.mocked(ngrok.forward).mockReset().mockRejectedValue(new Error('forward failed'));
await expect(startNgrokTunnelAsync(options)).rejects.toThrow('forward failed');
const targetPort = (jest.mocked(ngrok.forward).mock.calls[0][0] as ngrok.Config).addr;
const server = createServer();
server.listen(targetPort as number, '127.0.0.1');
await once(server, 'listening');
await new Promise<void>(resolve => server.close(() => resolve()));
});
it('preserves a full response for a half-closed slow reader', async () => {
jest.useRealTimers();
const payload = Buffer.alloc(4 * 1024 * 1024, 37);
const backend = createTcpServer({ allowHalfOpen: true }, socket => {
socket.resume();
socket.on('end', () => socket.end(payload));
});
backend.listen(0, '127.0.0.1');
await once(backend, 'listening');
const tunnel = await startNgrokTunnelAsync({
...options,
port: (backend.address() as AddressInfo).port,
healthCheck: undefined,
});
const targetPort = (jest.mocked(ngrok.forward).mock.calls[0][0] as ngrok.Config).addr as number;
const socket = createConnection({ host: '127.0.0.1', port: targetPort, allowHalfOpen: true });
try {
const chunks: Buffer[] = [];
socket.on('data', chunk => chunks.push(chunk));
const ended = once(socket, 'end');
socket.pause();
socket.end('request');
await new Promise(resolve => setTimeout(resolve, 100));
socket.resume();
await ended;
expect(Buffer.concat(chunks).equals(payload)).toBe(true);
} finally {
socket.destroy();
await tunnel.stopAsync();
await new Promise<void>(resolve => backend.close(() => resolve()));
}
});
it.each(['resolve', 'reject'])(
'releases the target after timed-out startup later %s',
async outcome => {
let resolveOpening!: (value: ngrok.Listener) => void;
let rejectOpening!: (error: Error) => void;
jest
.mocked(ngrok.forward)
.mockReset()
.mockReturnValue(
new Promise((resolve, reject) => {
resolveOpening = resolve;
rejectOpening = reject;
})
);
const opening = startNgrokTunnelAsync(options);
const rejected = expect(opening).rejects.toThrow('open timed out');
await new Promise<void>(resolve => setImmediate(resolve));
await jest.advanceTimersByTimeAsync(15_000);
await rejected;
const targetPort = (jest.mocked(ngrok.forward).mock.calls[0][0] as ngrok.Config).addr as number;
if (outcome === 'resolve') {
resolveOpening(replacement as never);
} else {
rejectOpening(new Error('forward failed'));
}
await jest.advanceTimersByTimeAsync(0);
const server = createTcpServer();
server.listen(targetPort, '127.0.0.1');
await once(server, 'listening');
await new Promise<void>(resolve => server.close(() => resolve()));
}
);
it('drains a full upload when the backend ends its response early', async () => {
jest.useRealTimers();
const payload = Buffer.alloc(4 * 1024 * 1024, 51);
let received!: Buffer;
let receivedRequest!: () => void;
const completed = new Promise<void>(resolve => {
receivedRequest = resolve;
});
const backend = createTcpServer({ allowHalfOpen: true }, socket => {
const chunks: Buffer[] = [];
socket.on('data', chunk => chunks.push(chunk));
socket.on('end', () => {
received = Buffer.concat(chunks);
receivedRequest();
});
socket.pause();
socket.end('early response');
setTimeout(() => socket.resume(), 100);
});
backend.listen(0, '127.0.0.1');
await once(backend, 'listening');
const tunnel = await startNgrokTunnelAsync({
...options,
port: (backend.address() as AddressInfo).port,
healthCheck: undefined,
});
const targetPort = (jest.mocked(ngrok.forward).mock.calls[0][0] as ngrok.Config).addr as number;
const socket = createConnection({ host: '127.0.0.1', port: targetPort, allowHalfOpen: true });
try {
socket.resume();
socket.end(payload);
await completed;
expect(received.equals(payload)).toBe(true);
} finally {
socket.destroy();
await tunnel.stopAsync();
await new Promise<void>(resolve => backend.close(() => resolve()));
}
});
@@ -530,6 +530,22 @@ describe(describeServeSimLaunch, () => {
});
});
it('only pins hosts when a valid device ID is supplied', () => {
const udid = 'AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA';
expect(createServeSimArgs({ port: 4321, iosSimulatorUdid: udid })).toEqual(
expect.arrayContaining(['--single-device', udid])
);
for (const iosSimulatorUdid of [undefined, '', 'booted', 'iPhone 17']) {
expect(createServeSimArgs({ port: 4321, iosSimulatorUdid })).not.toContain('--single-device');
}
expect(createExpoDeviceHubArgs({ port: 4321, device: 'emulator-5554' })).toEqual(
expect.arrayContaining(['--single-device', 'emulator-5554'])
);
for (const device of [undefined, '', 'all', 'booted', 'invalid serial']) {
expect(createExpoDeviceHubArgs({ port: 4321, device })).not.toContain('--single-device');
}
});
describe(createServeSimArgs, () => {
it('uses the latest Expo package and applies the EAS streaming policy', () => {
expect(
@@ -916,7 +932,7 @@ describe(waitForWebPreviewReadyAsync, () => {
describe(startNgrokTunnelAsync, () => {
it.each(['rejected', 'stalled'] as const)(
'retains a %s close failure on repeated stops',
'bounds %s listener retirement and reuses the stop promise',
async mode => {
jest.useFakeTimers();
try {
@@ -938,14 +954,12 @@ describe(startNgrokTunnelAsync, () => {
logger: createLoggerMock(),
});
const stopping = tunnel.stopAsync();
const assertion = expect(stopping).rejects.toThrow(
mode === 'rejected' ? 'close failed' : 'Ngrok tunnel stop timed out after 4000ms.'
);
const assertion = expect(stopping).rejects.toThrow(/stop timed out after 4000ms/);
expect(tunnel.stopAsync()).toBe(stopping);
await jest.advanceTimersByTimeAsync(4_000);
await assertion;
await expect(tunnel.stopAsync()).rejects.toThrow();
expect(close).toHaveBeenCalledTimes(1);
expect(close).toHaveBeenCalled();
} finally {
jest.useRealTimers();
}
@@ -969,7 +983,7 @@ describe(startNgrokTunnelAsync, () => {
expect(ngrok.forward).toHaveBeenCalledWith(
expect.objectContaining({
addr: 4321,
addr: expect.any(Number),
authtoken: 'token',
domain: expect.stringMatching(/^web-preview-[a-f0-9]{32}\.eas-simulator\.ngrok\.dev$/),
})
@@ -1073,7 +1087,13 @@ describe(startDeviceSessionHostAsync, () => {
beforeEach(() => {
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockReset();
jest.mocked(spawn).mockReset();
jest.mocked(ngrok.forward).mockReset();
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValue({
url: () => 'https://preview.example.test',
close: jest.fn().mockResolvedValue(undefined),
} as never);
jest.mocked(turtleFetch).mockReset();
jest
.mocked(readServeSimServersAsync)
@@ -1136,6 +1156,7 @@ describe(startDeviceSessionHostAsync, () => {
it('gives serve-sim its recording grace period on shutdown', async () => {
const spawnDetachedSpy = jest.spyOn(remoteDeviceRunSession, 'spawnDetached');
const host = await startDeviceSessionHostAsync(createCtxMock(), {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger: createLoggerMock(),
@@ -1153,6 +1174,7 @@ describe(startDeviceSessionHostAsync, () => {
'passes private sharing flags directly to serve-sim version %s',
async packageVersion => {
const host = await startDeviceSessionHostAsync(createCtxMock(), {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger: createLoggerMock(),
@@ -1181,6 +1203,7 @@ describe(startDeviceSessionHostAsync, () => {
const privatePreviewUrl =
'https://expo.dev/accounts/owner/projects/app/simulator-sessions/session-id/preview';
const host = await startDeviceSessionHostAsync(createCtxMock(), {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger: createLoggerMock(),
@@ -1188,7 +1211,7 @@ describe(startDeviceSessionHostAsync, () => {
disableShare: true,
privatePreviewUrl,
});
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
const preview = await host.openPreviewAsync();
const serveSimArgs = jest
.mocked(spawn)
@@ -1208,13 +1231,14 @@ describe(startDeviceSessionHostAsync, () => {
close: jest.fn().mockResolvedValue(undefined),
} as never);
const host = await startDeviceSessionHostAsync(createCtxMock(), {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
disableShare: true,
});
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
const preview = await host.openPreviewAsync();
const serveSimArgs = jest
.mocked(spawn)
@@ -1241,13 +1265,14 @@ describe(startDeviceSessionHostAsync, () => {
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>);
const host = await startDeviceSessionHostAsync(createCtxMock(), {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.LINUX,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
packageVersion,
});
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
const preview = await host.openPreviewAsync();
const spawnCalls = jest.mocked(spawn).mock.calls;
expect(spawnCalls[0]).toEqual([
@@ -1286,7 +1311,10 @@ describe(startDeviceSessionHostAsync, () => {
recordingDirectory,
}),
]);
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(ngrok.forward).toHaveBeenCalledWith(
expect.objectContaining({ addr: expect.any(Number) })
);
expect(ngrok.forward).not.toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(preview.apiUrl).toBe('https://android-preview.example.test');
await host.finishAsync();
@@ -1301,12 +1329,13 @@ describe(startDeviceSessionHostAsync, () => {
} as never);
const host = await startDeviceSessionHostAsync(createCtxMock(), {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
});
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
const preview = await host.openPreviewAsync();
expect(preview.previewToken).toBe('tok-1');
expect(preview.apiUrl).toBe('https://preview.example.test');
@@ -1336,12 +1365,13 @@ describe(startDeviceSessionHostAsync, () => {
} as never);
const host = await startDeviceSessionHostAsync(createCtxMock(), {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: { ...env, EXPO_STAGING: '1' },
logger: createLoggerMock(),
timeoutMs: 10_000,
});
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
const preview = await host.openPreviewAsync();
expect(preview.previewPageUrl).toMatch(
/^https:\/\/staging\.expo\.dev\/simulator-preview\/[a-f0-9]{32}$/
@@ -1361,6 +1391,7 @@ describe(startDeviceSessionHostAsync, () => {
await expect(
startDeviceSessionHostAsync(createCtxMock(), {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger: createLoggerMock(),
@@ -1378,12 +1409,13 @@ describe(startDeviceSessionHostAsync, () => {
} as never);
const host = await startDeviceSessionHostAsync(createCtxMock(), {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.LINUX,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
});
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
const preview = await host.openPreviewAsync();
expect(preview.previewToken).toBe('hub-token');
const [, args] = jest.mocked(spawn).mock.calls.find(([command]) => command === 'npx')!;
@@ -1400,13 +1432,14 @@ describe(startDeviceSessionHostAsync, () => {
} as never);
const host = await startDeviceSessionHostAsync(createCtxMock(), {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
packageVersion,
});
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
const preview = await host.openPreviewAsync();
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
@@ -1422,7 +1455,10 @@ describe(startDeviceSessionHostAsync, () => {
packageVersion,
}),
]);
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(ngrok.forward).toHaveBeenCalledWith(
expect.objectContaining({ addr: expect.any(Number) })
);
expect(ngrok.forward).not.toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(preview.apiUrl).toBe('https://ios-preview.example.test');
await host.finishAsync();
@@ -1439,13 +1475,14 @@ describe(startDeviceSessionHostAsync, () => {
const bunEnv = { ...env, EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' };
const host = await startDeviceSessionHostAsync(createCtxMock(), {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: bunEnv,
logger: createLoggerMock(),
timeoutMs: 10_000,
packageVersion: '4.5.6',
});
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
const preview = await host.openPreviewAsync();
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
@@ -1474,12 +1511,13 @@ describe(startDeviceSessionHostAsync, () => {
} as never);
const host = await startDeviceSessionHostAsync(createCtxMock(), {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: { ...env, EAS_FALLBACK_PACKAGE_MANAGER: 'bun' },
logger: createLoggerMock(),
timeoutMs: 10_000,
});
const preview = await host.openPreviewAsync({ baseDomain, authtoken });
const preview = await host.openPreviewAsync();
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
@@ -1505,6 +1543,7 @@ describe(startDeviceSessionHostAsync, () => {
} as never);
const host = await startDeviceSessionHostAsync(createCtxMock(), {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger: createLoggerMock(),
@@ -1531,6 +1570,7 @@ describe(startDeviceSessionHostAsync, () => {
it('refuses to launch an application on Linux, where expo-device-hub cannot', async () => {
await expect(
startDeviceSessionHostAsync(createCtxMock(), {
preview: { baseDomain, authtoken },
runtimePlatform: BuildRuntimePlatform.LINUX,
env,
logger: createLoggerMock(),
@@ -0,0 +1,225 @@
import { type bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import { type CustomBuildContext } from '../../../customBuildContext';
import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils';
import { fetchWebPreviewTurnArgsAsync } from '../remoteDeviceRunSession';
import {
WarmSessionAdoptionError,
createWarmDeviceSessionServer,
runOnWarmIosSimulatorAsync,
} from '../warmDeviceSessionHost';
import { type WarmIosSession, tryClaimWarmIosSessionAsync } from '../warmIosSession';
jest.mock('@expo/turtle-spawn');
jest.mock('../IosSimulatorRecordingUtils');
jest.mock('../warmIosSession');
jest.mock('../remoteDeviceRunSession', () => ({
...jest.requireActual('../remoteDeviceRunSession'),
fetchWebPreviewTurnArgsAsync: jest.fn(),
}));
const ctx = {} as CustomBuildContext;
const logger = { warn: jest.fn() } as unknown as bunyan;
let warm: WarmIosSession;
function launch(signal?: AbortSignal) {
return {
command: 'bunx',
args: ['serve-sim', '--port', '3200'],
env: {},
logger,
signal,
secrets: [],
stopGracePeriodMs: 1_000,
};
}
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(fetchWebPreviewTurnArgsAsync).mockResolvedValue(['--turn', 'creds']);
jest.mocked(spawn).mockResolvedValue({ stdout: '' } as never);
jest
.mocked(IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync)
.mockResolvedValue({ output: '/recordings/id', recordingId: 'id' });
warm = {
device: { udid: 'UDID', name: 'iPhone 17' },
server: { pid: 10, port: 3200, token: 'serve-token' },
agentDeviceStateDir: '/agent',
argentEventLogPath: '/argent/events',
preinstalledExpoGo: {
bundleIdentifier: 'host.exp.Exponent',
shortVersion: '2.0',
version: '1',
},
prepareToolsAsync: jest.fn().mockResolvedValue(undefined),
process: {
pid: 10,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: jest.fn().mockResolvedValue(undefined),
},
};
});
it('adopts the warm device on the warm port with its recording, then prepares tools', async () => {
const server = createWarmDeviceSessionServer(ctx, logger, warm);
expect(server).toMatchObject({
port: 3200,
requiresPreviewRetirement: true,
preinstalledApp: warm.preinstalledExpoGo,
});
const handle = await server.startAsync(launch());
expect(spawn).toHaveBeenCalledWith(
'bunx',
[
'serve-sim',
'--port',
'3200',
'--single-device',
'UDID',
'--adopt',
'--recording-output',
'/recordings/id',
'--recording-id',
'id',
],
expect.objectContaining({ stdio: 'pipe' })
);
expect(warm.prepareToolsAsync).toHaveBeenCalled();
expect(handle.pid).toBe(10);
await handle.stopAsync();
expect(warm.process.stopAsync).toHaveBeenCalledTimes(1);
});
it.each([
['adoption', () => jest.mocked(spawn).mockRejectedValue(new Error('adopt --turn creds failed'))],
[
'tool preparation',
() => jest.mocked(warm.prepareToolsAsync).mockRejectedValue(new Error('no tool')),
],
])('reports a %s failure as an adoption error without its details', async (_name, setup) => {
setup();
const error = await createWarmDeviceSessionServer(ctx, logger, warm)
.startAsync(launch())
.catch(error => error);
expect(error).toBeInstanceOf(WarmSessionAdoptionError);
expect(String(error)).not.toContain('creds');
});
it('reports a cancellation as a cancellation', async () => {
const controller = new AbortController();
jest.mocked(spawn).mockImplementation((() => {
controller.abort(new Error('cancelled'));
return Promise.reject(new Error('killed'));
}) as never);
await expect(
createWarmDeviceSessionServer(ctx, logger, warm).startAsync(launch(controller.signal))
).rejects.toThrow('cancelled');
});
describe(runOnWarmIosSimulatorAsync, () => {
const claim = { engine: 'serve-sim' as const, env: {}, logger };
it('runs cold when there is no warm Simulator to claim', async () => {
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(undefined);
const run = jest.fn().mockResolvedValue('cold');
await expect(runOnWarmIosSimulatorAsync(claim, run)).resolves.toBe('cold');
expect(run).toHaveBeenCalledWith(undefined);
});
it('runs on the claimed warm Simulator', async () => {
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm);
const run = jest.fn().mockResolvedValue('warm');
await expect(runOnWarmIosSimulatorAsync(claim, run)).resolves.toBe('warm');
expect(run).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledWith(warm);
});
it('stops warmup and runs cold after an adoption failure', async () => {
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm);
const run = jest
.fn()
.mockRejectedValueOnce(new WarmSessionAdoptionError())
.mockResolvedValueOnce('cold');
await expect(runOnWarmIosSimulatorAsync(claim, run)).resolves.toBe('cold');
expect(warm.process.stopAsync).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenLastCalledWith(undefined);
});
it('does not run cold after a failure during the session', async () => {
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm);
const error = new Error('session failed');
const run = jest.fn().mockRejectedValue(error);
await expect(runOnWarmIosSimulatorAsync(claim, run)).rejects.toBe(error);
expect(warm.process.stopAsync).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledTimes(1);
});
it.each([new Error('session failed'), new WarmSessionAdoptionError()])(
'preserves both errors and does not run cold when cleanup fails (%s)',
async error => {
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm);
const cleanupError = new Error('cleanup failed');
jest.mocked(warm.process.stopAsync).mockRejectedValue(cleanupError);
const run = jest.fn().mockRejectedValue(error);
await expect(runOnWarmIosSimulatorAsync(claim, run)).rejects.toMatchObject({
errors: [error, cleanupError],
});
expect(run).toHaveBeenCalledTimes(1);
}
);
it.each([false, true])(
'waits for cleanup before settling (adoption failure: %s)',
async adoption => {
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm);
const error = adoption ? new WarmSessionAdoptionError() : new Error('session failed');
let releaseCleanup = () => {};
let cleanupStarted = () => {};
const started = new Promise<void>(resolve => {
cleanupStarted = resolve;
});
jest.mocked(warm.process.stopAsync).mockImplementation(() => {
cleanupStarted();
return new Promise<void>(resolve => {
releaseCleanup = resolve;
});
});
const run = jest.fn().mockRejectedValueOnce(error).mockResolvedValue('cold');
const running = runOnWarmIosSimulatorAsync(claim, run);
let settled = false;
const result = running.then(
value => {
settled = true;
return value;
},
error => {
settled = true;
return error;
}
);
await started;
await Promise.resolve();
expect(settled).toBe(false);
expect(run).toHaveBeenCalledTimes(1);
releaseCleanup();
expect(await result).toBe(adoption ? 'cold' : error);
expect(run).toHaveBeenCalledTimes(adoption ? 2 : 1);
}
);
it('does not retry cold when cancellation occurs during cleanup', async () => {
jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm);
const controller = new AbortController();
const cancellation = new Error('cancelled');
jest.mocked(warm.process.stopAsync).mockImplementation(async () => {
controller.abort(cancellation);
});
const run = jest.fn().mockRejectedValue(new WarmSessionAdoptionError());
await expect(
runOnWarmIosSimulatorAsync({ ...claim, signal: controller.signal }, run)
).rejects.toBe(cancellation);
expect(run).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,151 @@
import { type bunyan } from '@expo/logger';
import fs from 'node:fs/promises';
import path from 'node:path';
import { runSimulatorLauncherAsync, stopWarmSimulatorAsync } from '../../../utils/warmSimulator';
import { readLocalEgressHandoffAsync } from '../localEgress';
import { tryClaimWarmIosSessionAsync } from '../warmIosSession';
jest.mock('../../../utils/warmSimulator');
jest.mock('../localEgress');
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const env = { EAS_USE_WARM_SIMULATOR: '1' };
const claim = {
status: 'claimed',
device: { udid: 'UDID', name: 'iPhone 17' },
server: { pid: 10, port: 3200, token: 'serve-token', url: 'http://127.0.0.1:3200' },
agentDeviceStateDir: '/agent',
argentEventLogPath: '/argent/events',
preinstalledExpoGo: { bundleIdentifier: 'host.exp.Exponent', shortVersion: '2.0', version: '1' },
};
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(stopWarmSimulatorAsync).mockResolvedValue();
jest
.mocked(runSimulatorLauncherAsync)
.mockImplementation(async ([command]) => (command === 'claim' ? JSON.stringify(claim) : '{}'));
});
afterEach(() => jest.restoreAllMocks());
it('does not claim when the job does not use the warm Simulator', async () => {
await expect(
tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env: {}, logger })
).resolves.toBeUndefined();
expect(runSimulatorLauncherAsync).not.toHaveBeenCalled();
expect(stopWarmSimulatorAsync).not.toHaveBeenCalled();
});
it('logs the wait before claiming, without logging the private launcher record', async () => {
jest.mocked(runSimulatorLauncherAsync).mockImplementationOnce(async () => {
expect(logger.info).toHaveBeenCalledWith('Waiting for the warm Simulator launcher.');
return JSON.stringify(claim);
});
const session = await tryClaimWarmIosSessionAsync({
engine: 'serve-sim',
requestedDevice: 'iPhone 17',
env,
logger,
});
expect(runSimulatorLauncherAsync).toHaveBeenCalledWith(['claim', 'serve-sim', 'iPhone 17'], {
env,
signal: undefined,
});
expect(jest.mocked(logger.info).mock.calls).toEqual([
['Waiting for the warm Simulator launcher.'],
['Claimed the warm Simulator iPhone 17 (UDID).'],
]);
expect(session).toMatchObject({
device: claim.device,
server: { pid: 10, port: 3200, token: 'serve-token' },
preinstalledExpoGo: claim.preinstalledExpoGo,
});
await session!.prepareToolsAsync();
expect(runSimulatorLauncherAsync).toHaveBeenLastCalledWith(['prepare-tools'], {
env,
signal: undefined,
});
expect(session!.tools).toBeUndefined();
});
it('takes the preinstalled Expo Go that prepare-tools reports', async () => {
const expoGo = { bundleIdentifier: 'host.exp.Exponent', shortVersion: '3.0', version: '2' };
jest
.mocked(runSimulatorLauncherAsync)
.mockImplementation(async ([command]) =>
command === 'claim'
? JSON.stringify({ ...claim, preinstalledExpoGo: undefined })
: JSON.stringify({ preinstalledExpoGo: expoGo })
);
const session = await tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger });
expect(session!.preinstalledExpoGo).toBeUndefined();
await session!.prepareToolsAsync();
expect(session!.preinstalledExpoGo).toEqual(expoGo);
});
it('reads the agent-device server after tool preparation', async () => {
jest
.spyOn(fs, 'readFile')
.mockResolvedValue(JSON.stringify({ pid: 20, httpPort: 4310, token: 'agent-token' }));
const session = await tryClaimWarmIosSessionAsync({ engine: 'agent-device', env, logger });
await session!.prepareToolsAsync();
expect(fs.readFile).toHaveBeenCalledWith(path.join('/agent', 'daemon.json'), 'utf8');
expect(session!.tools).toEqual({ pid: 20, port: 4310, token: 'agent-token' });
});
it('uses the Argent identity validated by the launcher without scanning state files again', async () => {
const tools = { pid: 30, port: 3201, token: 'argent-token' };
const readDirectory = jest.spyOn(fs, 'readdir');
const readFile = jest.spyOn(fs, 'readFile');
jest
.mocked(runSimulatorLauncherAsync)
.mockImplementation(async ([command]) =>
JSON.stringify(command === 'claim' ? claim : { tools })
);
const session = await tryClaimWarmIosSessionAsync({ engine: 'argent', env, logger });
await session?.prepareToolsAsync();
expect(session?.tools).toEqual(tools);
expect(readDirectory).not.toHaveBeenCalled();
expect(readFile).not.toHaveBeenCalled();
});
it('rejects Argent preparation when the launcher returns no validated identity', async () => {
const session = await tryClaimWarmIosSessionAsync({ engine: 'argent', env, logger });
await expect(session?.prepareToolsAsync()).rejects.toThrow();
});
it.each([
[
'a failed claim',
{},
() => jest.mocked(runSimulatorLauncherAsync).mockRejectedValue(new Error('no session')),
],
['a pinned package version', { packageVersion: '1.2.3' }, () => {}],
['a job Simulator setting', { env: { ...env, SIMCTL_CHILD_FOO: '1' } }, () => {}],
[
'a local egress session',
{},
() => jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue({ port: 1 } as never),
],
])('stops warmup and returns nothing for %s', async (_name, options, setup) => {
setup();
await expect(
tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger, ...options })
).resolves.toBeUndefined();
expect(stopWarmSimulatorAsync).toHaveBeenCalledTimes(1);
});
it('rejects when warmup cleanup cannot be confirmed', async () => {
jest.mocked(runSimulatorLauncherAsync).mockRejectedValue(new Error('no session'));
jest.mocked(stopWarmSimulatorAsync).mockRejectedValue(new Error('cleanup failed'));
await expect(tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger })).rejects.toThrow(
'cleanup failed'
);
});
it('stops the claimed session once', async () => {
const session = await tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger });
await Promise.all([session!.process.stopAsync(), session!.process.stopAsync()]);
expect(stopWarmSimulatorAsync).toHaveBeenCalledTimes(1);
});
@@ -6,15 +6,16 @@ import fetch from 'node-fetch';
import os from 'node:os';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { setTimeout as sleepAsync } from 'node:timers/promises';
import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { formatBytes } from '../../utils/artifacts';
import { sleepAsync } from '../../utils/retry';
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
const AGENT_DEVICE_ARTIFACT_UPLOAD_POLL_INTERVAL_MS = 5_000;
const AGENT_DEVICE_ARTIFACT_CLEANUP_TIMEOUT_MS = 30_000;
const AgentDeviceArtifactSchema = z.object({
id: z.string(),
@@ -45,69 +46,128 @@ export async function pollAgentDeviceArtifactsForUploadAsync(
daemonUrl,
daemonToken,
logger,
signal,
stopPollingSignal,
}: {
deviceRunSessionId: string;
daemonUrl: string;
daemonToken: string;
logger: bunyan;
signal?: AbortSignal;
stopPollingSignal?: AbortSignal;
}
): Promise<void> {
logger.info('Started polling agent-device daemon for artifacts.');
const uploadedArtifactIds = new Set<string>();
let listArtifactsErrorCount = 0;
for (;;) {
try {
const artifacts = await listAgentDeviceArtifactsAsync({ daemonUrl, daemonToken });
listArtifactsErrorCount = 0;
for (const artifact of artifacts) {
if (uploadedArtifactIds.has(artifact.id)) {
continue;
}
try {
await uploadAgentDeviceArtifactAsync(ctx, {
deviceRunSessionId,
daemonUrl,
daemonToken,
artifact,
logger,
});
uploadedArtifactIds.add(artifact.id);
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not upload agent-device remote session artifact', error);
logger.warn({ err: error }, 'Could not upload agent-device remote session artifact.');
}
}
} catch (err) {
if (err instanceof AgentDeviceArtifactsUnsupportedError) {
logger.warn(
'agent-device daemon does not expose artifact inventory; remote session artifact uploads are disabled.'
);
// Ending normal polling must not cancel a transfer that the session already produced.
const cleanup = new AbortController();
const transferSignal = stopPollingSignal
? AbortSignal.any([cleanup.signal, ...(signal ? [signal] : [])])
: signal;
const pollingSignal = stopPollingSignal
? AbortSignal.any([stopPollingSignal, ...(signal ? [signal] : [])])
: signal;
let cleanupTimer: NodeJS.Timeout | undefined;
const startCleanupDeadline = (): void => {
cleanupTimer ??= setTimeout(() => cleanup.abort(), AGENT_DEVICE_ARTIFACT_CLEANUP_TIMEOUT_MS);
};
stopPollingSignal?.addEventListener('abort', startCleanupDeadline, { once: true });
if (stopPollingSignal?.aborted) {
startCleanupDeadline();
}
try {
for (;;) {
const finalInventory = stopPollingSignal?.aborted;
if (transferSignal?.aborted) {
return;
}
const error = err instanceof Error ? err : new Error(String(err));
listArtifactsErrorCount += 1;
if (listArtifactsErrorCount % 5 === 0) {
Sentry.capture('Could not list agent-device remote session artifacts', error);
logger.warn(
{ err: error, failedArtifactListCount: listArtifactsErrorCount },
'Could not list agent-device remote session artifacts.'
);
try {
const artifacts = await listAgentDeviceArtifactsAsync({
daemonUrl,
daemonToken,
signal: transferSignal,
});
listArtifactsErrorCount = 0;
for (const artifact of artifacts) {
if (uploadedArtifactIds.has(artifact.id)) {
continue;
}
try {
await uploadAgentDeviceArtifactAsync(ctx, {
deviceRunSessionId,
daemonUrl,
daemonToken,
artifact,
logger,
...(transferSignal ? { signal: transferSignal } : {}),
});
uploadedArtifactIds.add(artifact.id);
} catch (err) {
if (transferSignal?.aborted) {
return;
}
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not upload agent-device remote session artifact', error);
logger.warn({ err: error }, 'Could not upload agent-device remote session artifact.');
}
}
} catch (err) {
if (transferSignal?.aborted) {
return;
}
if (err instanceof AgentDeviceArtifactsUnsupportedError) {
logger.warn(
'agent-device daemon does not expose artifact inventory; remote session artifact uploads are disabled.'
);
return;
}
const error = err instanceof Error ? err : new Error(String(err));
listArtifactsErrorCount += 1;
// The final inventory has no later retry, so always report its failure.
if (finalInventory || listArtifactsErrorCount % 5 === 0) {
Sentry.capture('Could not list agent-device remote session artifacts', error);
logger.warn(
{ err: error, failedArtifactListCount: listArtifactsErrorCount },
'Could not list agent-device remote session artifacts.'
);
}
}
if (finalInventory) {
return;
}
await sleepAsync(AGENT_DEVICE_ARTIFACT_UPLOAD_POLL_INTERVAL_MS, undefined, {
signal: pollingSignal,
}).catch(error => {
if (!pollingSignal?.aborted) {
throw error;
}
});
}
} finally {
clearTimeout(cleanupTimer);
stopPollingSignal?.removeEventListener('abort', startCleanupDeadline);
if (cleanup.signal.aborted) {
logger.warn(
'Timed out draining agent-device artifacts; some session artifacts may not have been uploaded.'
);
}
await sleepAsync(AGENT_DEVICE_ARTIFACT_UPLOAD_POLL_INTERVAL_MS);
}
}
export async function listAgentDeviceArtifactsAsync({
daemonUrl,
daemonToken,
signal,
}: {
daemonUrl: string;
daemonToken: string;
signal?: AbortSignal;
}): Promise<AgentDeviceArtifact[]> {
const response = await fetch(new URL('/artifacts', daemonUrl).toString(), {
...(signal ? { signal } : {}),
headers: { Authorization: `Bearer ${daemonToken}` },
});
if (response.status === 404) {
@@ -133,14 +193,17 @@ export async function uploadAgentDeviceArtifactAsync(
daemonToken,
artifact,
logger,
signal,
}: {
deviceRunSessionId: string;
daemonUrl: string;
daemonToken: string;
artifact: AgentDeviceArtifact;
logger: bunyan;
signal?: AbortSignal;
}
): Promise<void> {
signal?.throwIfAborted();
logger.info(`Downloading artifact ${artifact.filename}.`);
const temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), 'agent-device-artifact-'));
try {
@@ -150,6 +213,7 @@ export async function uploadAgentDeviceArtifactAsync(
daemonUrl,
daemonToken,
destinationPath: temporaryArtifactPath,
signal,
});
const { size } = await stat(temporaryArtifactPath);
logger.info(`Uploading artifact ${artifact.filename} (${formatBytes(size)}).`);
@@ -164,6 +228,7 @@ export async function uploadAgentDeviceArtifactAsync(
: {}),
size,
openStream: () => createReadStream(temporaryArtifactPath),
...(signal ? { signal } : {}),
});
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
@@ -175,16 +240,19 @@ async function downloadAgentDeviceArtifactToFileAsync({
daemonUrl,
daemonToken,
destinationPath,
signal,
}: {
artifact: AgentDeviceArtifact;
daemonUrl: string;
daemonToken: string;
destinationPath: string;
signal?: AbortSignal;
}): Promise<void> {
const response = await fetch(
new URL(`/artifacts/${encodeURIComponent(artifact.id)}`, daemonUrl).toString(),
{
headers: { Authorization: `Bearer ${daemonToken}` },
...(signal ? { signal } : {}),
}
);
if (!response.ok) {
@@ -197,5 +265,9 @@ async function downloadAgentDeviceArtifactToFileAsync({
`Agent-device artifact ${artifact.id} response did not include a readable body.`
);
}
await pipeline(response.body, createWriteStream(destinationPath));
if (signal) {
await pipeline(response.body, createWriteStream(destinationPath), { signal });
} else {
await pipeline(response.body, createWriteStream(destinationPath));
}
}
@@ -144,6 +144,7 @@ export namespace GooglePlayUtils {
signal?: AbortSignal;
}
): Promise<void> {
const attempts: unknown[] = [];
const sendCommitAsync = async ({
changesNotSentForReview,
}: {
@@ -161,6 +162,7 @@ export namespace GooglePlayUtils {
throw new SystemError('Google did not confirm the committed edit.');
}
} catch (error) {
attempts.push(...(error instanceof AggregateError ? error.errors : [error]));
if (error instanceof GooglePlayAuthRequestError) {
throw error;
}
@@ -168,8 +170,14 @@ export namespace GooglePlayUtils {
!(error instanceof GooglePlayApiError) ||
GooglePlayApiError.isRetryableStatus(error.status)
) {
const attemptMessages = attempts
.map(
(attempt, index) =>
`${index + 1}. ${attempt instanceof Error ? attempt.message : String(attempt)}`
)
.join('\n');
throw new GooglePlayCommitOutcomeUnknownError(
`Could not confirm whether Google Play saved the release for ${packageName}. Check its release status at https://play.google.com/console/ before submitting again.`,
`Could not confirm whether Google Play saved the release for ${packageName}. Check its release status at https://play.google.com/console/ before submitting again.\n\nCommit attempts:\n${attemptMessages}`,
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
);
}
@@ -84,6 +84,41 @@ it.each([408, 429, 503])('retries a transient HTTP %s commit on the same edit',
api().post(`${editPath}:commit`).query({}).reply(200, { id: 'edit' });
await expect(commit()).resolves.toBeUndefined();
});
it('shows failed commit attempts in order across processing, HTTP, and network retries', async () => {
mockToken();
api()
.post(`${editPath}:commit`)
.query({})
.reply(400, { error: { message: pendingUploadMessage } });
api().post(`${editPath}:commit`).query({}).reply(503);
api()
.post(`${editPath}:commit`)
.query({})
.replyWithError(Object.assign(new Error('Lost commit response'), { code: 'ECONNRESET' }));
const message = 'You must let us know whether your app includes any health features.';
api().post(`${editPath}:commit`).query({}).reply(403, { error: { message } });
const error = await commit().catch(error => error);
const mapped = GooglePlayUtils.mapGooglePlayError(error, packageName);
expect(mapped).toBeInstanceOf(GooglePlayCommitOutcomeUnknownError);
expect((mapped as Error).message).toContain(`Google Play request failed (HTTP 403): ${message}`);
expect((mapped as Error).message).toContain('Could not confirm whether Google Play saved');
expect((mapped as Error).message).toContain('https://play.google.com/console/');
expect(mapped).toMatchObject({
metadata: { googlePlayErrorResponse: JSON.stringify({ error: { message } }) },
cause: expect.any(AggregateError),
});
const attempts = (mapped as Error).message.split('Commit attempts:\n')[1]?.split('\n');
expect(attempts).toEqual([
`1. Google Play request failed (HTTP 400): ${pendingUploadMessage}`,
'2. Google Play request failed (HTTP 503).',
'3. Google Play request failed before a response was received.',
`4. Google Play request failed (HTTP 403): ${message}`,
]);
expect(((mapped as Error).cause as AggregateError).errors).toHaveLength(3);
expect(((mapped as Error).cause as AggregateError).errors[1]).toMatchObject({
cause: { code: 'ECONNRESET' },
});
});
it('preserves cancellation before commit is sent', async () => {
const controller = new AbortController();
const reason = new Error('cancelled before commit');
@@ -250,7 +285,9 @@ it('keeps the last Google Play error response when the commit outcome is unknown
.query({})
.times(6)
.reply(503, { error: { message: 'Backend error' } });
await expect(commit()).rejects.toMatchObject({
const error = await commit().catch(error => error);
expect(error.message).toContain('Google Play request failed (HTTP 503): Backend error');
expect(error).toMatchObject({
metadata: {
googlePlayRequestPath: `${editPath}:commit`,
googlePlayErrorResponse: JSON.stringify({ error: { message: 'Backend error' } }),
@@ -0,0 +1,50 @@
import type { bunyan } from '@expo/logger';
import type { BuildStepEnv } from '@expo/steps';
import type { CustomBuildContext } from '../../customBuildContext';
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
import { waitForWarmSimulatorCleanupAsync } from '../../utils/warmSimulatorCleanup';
import type { DeviceSessionServer } from './deviceSessionHost';
import {
fetchWebPreviewTurnArgsAsync,
findAvailablePortAsync,
spawnDetached,
} from './remoteDeviceRunSession';
/** Creates the cold iOS server handle; start and setup run when the host calls them. */
export async function createColdIosSessionAsync(
ctx: CustomBuildContext,
{
env,
logger,
iosSimulatorUdid,
}: { env: BuildStepEnv; logger: bunyan; iosSimulatorUdid?: string }
): Promise<DeviceSessionServer> {
const port = await findAvailablePortAsync();
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { logger });
return {
port,
turnArgs: Promise.resolve(turnArgs),
requiresPreviewRetirement: false,
async startAsync({ command, args, env, logger, signal, secrets, stopGracePeriodMs }) {
// serve-sim boots the device, so warm Simulator cleanup must finish first.
await waitForWarmSimulatorCleanupAsync(env, { signal });
signal?.throwIfAborted();
return spawnDetached({ command, args, env, logger, secrets, stopGracePeriodMs });
},
async setupSimulatorAsync({ signal, logger }) {
try {
await IosSimulatorUtils.disableApsdAsync({
udid: iosSimulatorUdid as IosSimulatorUuid,
env,
});
} catch (err) {
signal.throwIfAborted();
logger.warn({ err }, 'Failed to disable apsd in the Simulator.');
}
signal.throwIfAborted();
},
// A cold launch either returns its handle or starts no process.
async stopAsync() {},
};
}
@@ -0,0 +1,34 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import semver from 'semver';
export function validateControllerVersion({
packageName,
minimumVersion,
packageVersion,
logger,
}: {
packageName: string;
minimumVersion: string;
packageVersion: string | undefined;
logger: bunyan;
}): void {
if (!packageVersion || packageVersion === 'latest') {
return;
}
const requirement = `Remote sessions require ${packageName}@${minimumVersion} or newer.`;
const version = semver.valid(packageVersion);
if (!version) {
logger.warn(
`${requirement} package_version "${packageVersion}" is not an exact semver version that EAS can verify. ` +
'Continuing and letting the package manager resolve it.'
);
return;
}
if (semver.lt(version, minimumVersion)) {
throw new SystemError(
`${requirement} The requested package_version "${packageVersion}" is too old for the EAS remote-session API. ` +
`Use "latest" or pass an exact version >= ${minimumVersion}.`
);
}
}
@@ -49,12 +49,20 @@ import {
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
import { startLogPhase } from '../../utils/logPhase';
import { runServeSimActionAsync, stageServeSimAppAsync } from './serveSimActions';
import {
type IosAppIdentity,
isPreinstalledExpoGoAsync,
readIosApplicationIdentifierAsync,
} from './iosAppArtifact';
import { type ServeSimActivityPolling, startServeSimActivityPolling } from './serveSimActivity';
import { verifyLocalEgressGuardAsync } from './localEgressGuard';
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
import { type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
import { createColdIosSessionAsync } from './coldIosSession';
import { createStartupTasks } from './startupTasks';
const WEB_PREVIEW_HOST = '127.0.0.1';
const IOS_SIMULATOR_UDID = /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
const ANDROID_DEVICE_SERIAL = /^(?!all$|booted$)[A-Za-z0-9][A-Za-z0-9._:-]*$/;
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
const SERVE_SIM_MAX_DIMENSION = '1600';
const SERVE_SIM_MJPEG_QUALITY = '0.55';
@@ -157,6 +165,9 @@ export function createServeSimArgs({
return [
createServeSimPackageSpec(packageVersion),
...(iosSimulatorUdid ? [iosSimulatorUdid] : []),
...(iosSimulatorUdid && IOS_SIMULATOR_UDID.test(iosSimulatorUdid)
? ['--single-device', iosSimulatorUdid]
: []),
'--port',
String(port),
'--host',
@@ -198,6 +209,7 @@ export function createServeSimArgs({
export function createExpoDeviceHubArgs({
port,
device,
turnArgs = [],
websiteArgs = [],
frameAncestorArgs: frameArgs = [],
@@ -206,6 +218,7 @@ export function createExpoDeviceHubArgs({
recordingDirectory,
}: {
port: number;
device?: string;
turnArgs?: string[];
websiteArgs?: string[];
frameAncestorArgs?: string[];
@@ -215,6 +228,7 @@ export function createExpoDeviceHubArgs({
}): string[] {
return [
createExpoDeviceHubPackageSpec(packageVersion),
...(device && ANDROID_DEVICE_SERIAL.test(device) ? ['--single-device', device] : []),
'--port',
String(port),
'--host',
@@ -354,12 +368,13 @@ export type DeviceWebPreview = {
};
export type DeviceSessionHost = {
openPreviewAsync(options: { baseDomain: string; authtoken: string }): Promise<DeviceWebPreview>;
/** The preview tunnel opened during startup, or a new one after closeAsync. */
openPreviewAsync(): Promise<DeviceWebPreview>;
/** Last observed preview input. Always `undefined` on Android, which keeps no event log. */
getLastPreviewActivityAt(): Date | undefined;
/**
* Terminal and idempotent. Never rejects: a failed finalization, host stop or upload is logged
* and reported, so callers need no error handling around it.
* Terminal and idempotent. Finalization, host-stop and upload failures are logged and reported.
* Warm preview-tunnel cleanup can reject when retirement cannot be confirmed.
*/
finishAsync(): Promise<void>;
};
@@ -371,11 +386,13 @@ type AndroidSessionRecording = {
env: BuildStepEnv;
};
type DeviceSessionHostOptions = {
export type DeviceSessionHostOptions = {
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
/** The host opens this preview tunnel while the server starts. */
preview: { baseDomain: string; authtoken: string };
separateLogPhase?: boolean;
startupTimeoutMs?: number;
signal?: AbortSignal;
@@ -387,6 +404,8 @@ type DeviceSessionHostOptions = {
/** Origins whose pages may call the preview host's API, in addition to the website origins. */
corsOrigins?: string[];
iosSimulatorUdid?: string;
/** Selected preview target; does not enable Simulator boot or setup. */
previewDeviceId?: string;
installAppPath?: string;
bootEnv?: Record<string, string>;
application?: Promise<ServeSimApplicationOptions | void>;
@@ -395,12 +414,69 @@ type DeviceSessionHostOptions = {
networkCaptureFields?: string[];
} & ServeSimLaunchOptions;
export type DeviceSessionServerLaunch = {
command: string;
args: string[];
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
/** Redacted from server output. The host adds the preview token after launch. */
secrets: string[];
stopGracePeriodMs: number;
};
/**
* The server process behind a session host. A cold server is launched for this session; a warm
* server was prepared before the job and is adopted. The host runs the same readiness, app
* actions and finalization for both.
*/
export type DeviceSessionServer = {
port: number;
turnArgs: Promise<string[]>;
/** Cold iOS setup runs after control readiness, alongside app installation and before launch. */
setupSimulatorAsync?(options: { signal: AbortSignal; logger: bunyan }): Promise<void>;
/** An app the server's launcher installed and never launched. */
preinstalledApp?: IosAppIdentity;
/** Another session can start on this machine, so an unconfirmed tunnel close fails cleanup. */
requiresPreviewRetirement: boolean;
startAsync(launch: DeviceSessionServerLaunch): Promise<DetachedProcessHandle>;
/** Stops the server after startAsync failed. Finalization stops the returned handle. */
stopAsync(): Promise<void>;
};
/** Fetches TURN before the host starts its collectors. */
async function createAndroidDeviceSessionServerAsync(
ctx: CustomBuildContext,
{ logger }: { logger: bunyan }
): Promise<DeviceSessionServer> {
const port = await findAvailablePortAsync();
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { logger });
return {
port,
turnArgs: Promise.resolve(turnArgs),
requiresPreviewRetirement: false,
async startAsync({ command, args, env, logger, secrets, stopGracePeriodMs }) {
return spawnDetached({ command, args, env, logger, secrets, stopGracePeriodMs });
},
// A cold launch either returns its handle or starts no process.
async stopAsync() {},
};
}
/** Without a server, the host launches a cold one. */
export async function startDeviceSessionHostAsync(
ctx: CustomBuildContext,
options: DeviceSessionHostOptions
options: DeviceSessionHostOptions,
server?: DeviceSessionServer
): Promise<DeviceSessionHost> {
if (!options.separateLogPhase) {
return await startDeviceSessionHostInternalAsync(ctx, options, () => {}, options.logger);
return await startDeviceSessionHostInternalAsync(
ctx,
options,
() => {},
options.logger,
server
);
}
const phase = startLogPhase(options.logger, 'Simulator preview');
let ready = false;
@@ -410,7 +486,8 @@ export async function startDeviceSessionHostAsync(
{ ...options, logger: phase.logger },
successful =>
phase.end(ready && successful ? BuildPhaseResult.SUCCESS : BuildPhaseResult.FAIL),
options.logger
options.logger,
server
);
ready = true;
return host;
@@ -431,6 +508,7 @@ async function startDeviceSessionHostInternalAsync(
signal,
packageVersion,
iosSimulatorUdid,
previewDeviceId,
installAppPath,
application,
onStartupError,
@@ -444,9 +522,11 @@ async function startDeviceSessionHostInternalAsync(
networkCapture = false,
networkCaptureFields = [],
bootEnv = {},
preview,
}: DeviceSessionHostOptions,
onFinished: (successful: boolean) => void,
artifactLogger: bunyan
artifactLogger: bunyan,
providedServer?: DeviceSessionServer
): Promise<DeviceSessionHost> {
signal?.throwIfAborted();
const isAndroid = runtimePlatform === BuildRuntimePlatform.LINUX;
@@ -479,6 +559,10 @@ async function startDeviceSessionHostInternalAsync(
if (isAndroid) {
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
}
const deviceIdPattern = isAndroid ? ANDROID_DEVICE_SERIAL : IOS_SIMULATOR_UDID;
const validPreviewDevice =
previewDeviceId && deviceIdPattern.test(previewDeviceId) ? previewDeviceId : undefined;
const previewDevice = isAndroid ? validPreviewDevice : (iosSimulatorUdid ?? validPreviewDevice);
const recording: AndroidSessionRecording | null = isAndroid
? {
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
@@ -494,94 +578,28 @@ async function startDeviceSessionHostInternalAsync(
const subdomainId = randomBytes(16).toString('hex');
const previewPageUrl = simulatorPreviewPageUrl(env, subdomainId);
const sharePageUrl = disableShare ? privatePreviewUrl : previewPageUrl;
const port = await findAvailablePortAsync();
const server =
providedServer ??
(isAndroid
? await createAndroidDeviceSessionServerAsync(ctx, { logger })
: await createColdIosSessionAsync(ctx, { env, logger, iosSimulatorUdid }));
const port = server.port;
const serverName = isAndroid ? 'expo-device-hub' : 'serve-sim';
const packageSpec = isAndroid
? createExpoDeviceHubPackageSpec(packageVersion)
: createServeSimPackageSpec(packageVersion);
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { logger });
const localEgress = !isAndroid ? await readLocalEgressHandoffAsync() : null;
const previewExec = resolvePackageExec(
resolveConfiguredPackageManager(env, PackageManager.NPM),
isAndroid
? createExpoDeviceHubArgs({
port,
turnArgs,
packageVersion,
recordingDirectory: recording?.directory,
websiteArgs: websiteOriginArgs(env),
frameAncestorArgs: frameAncestorArgs(frameAncestors),
corsOriginArgs: corsOriginArgs(corsOrigins),
})
: createServeSimArgs({
port,
turnArgs,
packageVersion,
iosSimulatorUdid,
websiteArgs: websiteOriginArgs(env),
frameAncestorArgs: frameAncestorArgs(frameAncestors),
corsOriginArgs: corsOriginArgs(corsOrigins),
// serve-sim adds its bearer token to --share-url; private links are for worker logs only.
shareUrl: disableShare ? undefined : previewPageUrl,
disableShare,
installAppPath: iosSimulatorUdid ? undefined : installAppPath,
launchAppIdentifier: iosSimulatorUdid ? undefined : launchAppIdentifier,
launchArgs: iosSimulatorUdid ? [] : launchArgs,
openUrl: iosSimulatorUdid ? undefined : openUrl,
networkCapture,
networkCaptureFields,
networkCaptureProxy: localEgress
? `http://${LOCAL_EGRESS_PROXY_HOST}:${localEgress.port}`
: undefined,
})
);
logger.info(
`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port} via ${previewExec.command}.`
);
// Redact these credentials before forwarding process output to build logs.
const secrets = [
...turnArgs.filter((_, index) => turnArgs[index - 1] === '--turn-credential'),
...(recording ? [recording.controlToken] : []),
];
const turnArgsResult = asyncResult(server.turnArgs);
const screenshots = await startDeviceRunSessionScreenshotsAsync(ctx, {
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
logger: artifactLogger,
});
let previewServer: DetachedProcessHandle;
try {
signal?.throwIfAborted();
previewServer = spawnDetached({
command: previewExec.command,
args: previewExec.args,
env: {
...env,
...bootEnv,
...(isAndroid ? {} : { TMPDIR: serveSimTmpdir, SERVE_SIM_STATE_DIR: serveSimStateDir }),
EXPO_DEVICE_HUB_SCREENSHOT_DIRECTORY: screenshots.directory,
...(recording ? { EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN: recording.controlToken } : {}),
// The Hub would resolve its state directory from the step's env, which may set its own
// TMPDIR or state directory, so it writes its token where the host reads it below.
...(isAndroid ? { EXPO_DEVICE_HUB_STATE_DIR } : {}),
},
// Only an Android host records through expo-device-hub; serve-sim records on iOS.
stopGracePeriodMs: recording
? RECORDING_STOP_GRACE_PERIOD_MS
: SERVE_SIM_STOP_GRACE_PERIOD_MS,
logger,
secrets,
});
} catch (error) {
// Nothing was spawned, so nothing can still write into the directory.
await screenshots.finishAsync(true);
throw error;
}
let stagedAppDirectory: string | undefined;
let simulatorSetup: ReturnType<typeof asyncResult<void>> | undefined;
const readinessController = new AbortController();
const readinessSignal = AbortSignal.any([startup.signal, readinessController.signal]);
let previewToken: string | undefined;
let previewTask: Promise<DeviceWebPreview> | null = null;
const previewRetirementErrors: unknown[] = [];
let finishTask: Promise<void> | null = null;
let hostReady = false;
let sessionPreview: ReturnType<typeof startDeviceRunSessionPreview> | null = null;
@@ -590,85 +608,239 @@ async function startDeviceSessionHostInternalAsync(
let previewFailed = false;
let previewActivity: ServeSimActivityPolling | null = null;
const host: DeviceSessionHost = {
openPreviewAsync({ baseDomain, authtoken }) {
if (finishTask) {
return Promise.reject(
new SystemError('Cannot open a preview after session host finalization.')
);
}
if (previewTask) {
return previewTask;
}
const opening = (async (): Promise<DeviceWebPreview> => {
const tunnel = await startNgrokTunnelAsync({
port,
subdomainPrefix: 'web-preview',
subdomainId,
baseDomain,
authtoken,
logger,
});
if (finishTask) {
await withDeviceRunSessionTimeoutAsync(
{ name: 'Late preview tunnel close', timeoutMs: 5_000 },
async () => await tunnel.stopAsync()
).catch(err => logger.warn({ err }, 'Could not close a late preview tunnel.'));
throw new SystemError('Session host finalized while the preview was opening.');
}
let closeTask: Promise<void> | null = null;
previewFailed = false;
return {
previewPageUrl,
sharePageUrl,
apiUrl: tunnel.url,
previewToken,
closeAsync() {
return (closeTask ??= (async () => {
try {
await tunnel.stopAsync();
} finally {
if (previewTask === opening) {
previewTask = null;
}
}
})());
},
};
})();
previewTask = opening;
// A failed tunnel may be retried without restarting capture.
void opening.catch(() => {
previewFailed = true;
if (previewTask === opening) {
previewTask = null;
}
function openPreviewAsync(): Promise<DeviceWebPreview> {
if (finishTask) {
return Promise.reject(
new SystemError('Cannot open a preview after session host finalization.')
);
}
if (previewTask) {
return previewTask;
}
const opening = (async (): Promise<DeviceWebPreview> => {
const tunnel = await startNgrokTunnelAsync({
port,
subdomainPrefix: 'web-preview',
subdomainId,
baseDomain: preview.baseDomain,
authtoken: preview.authtoken,
healthCheck: { path: isAndroid ? '/readyz' : '/healthz' },
logger,
});
return opening;
},
if (finishTask) {
await withDeviceRunSessionTimeoutAsync(
{ name: 'Late preview tunnel close', timeoutMs: 5_000 },
async () => await tunnel.stopAsync()
).catch(err => {
if (server.requiresPreviewRetirement) {
previewRetirementErrors.push(err);
}
logger.warn({ err }, 'Could not close a late preview tunnel.');
});
throw new SystemError('Session host finalized while the preview was opening.');
}
let closeTask: Promise<void> | null = null;
previewFailed = false;
return {
previewPageUrl,
sharePageUrl,
apiUrl: tunnel.url,
get previewToken() {
return previewToken;
},
closeAsync() {
return (closeTask ??= (async () => {
try {
await tunnel.stopAsync();
} catch (error) {
if (server.requiresPreviewRetirement) {
previewRetirementErrors.push(error);
}
throw error;
} finally {
if (previewTask === opening) {
previewTask = null;
}
}
})());
},
};
})();
previewTask = opening;
// A failed tunnel may be retried without restarting capture.
void opening.catch(() => {
previewFailed = true;
if (previewTask === opening) {
previewTask = null;
}
});
return opening;
}
const earlyPreview = asyncResult(
withDeviceRunSessionTimeoutAsync(
{ name: 'Preview tunnel opening', timeoutMs, signal: startup.signal },
async () => await openPreviewAsync()
).catch(error => {
if (!finishTask) {
startup.abort(error);
}
throw error;
})
);
function retirePreviewAsync(): Promise<boolean> {
return withDeviceRunSessionTimeoutAsync(
{ name: 'Preview tunnel retirement', timeoutMs: 5_000 },
async () => {
const preview = await previewTask?.catch(() => null);
await preview?.closeAsync();
}
).then(
() => true,
err => {
if (server.requiresPreviewRetirement && !previewRetirementErrors.includes(err)) {
previewRetirementErrors.push(err);
}
logger.warn(
{ err },
`Could not close the ${serverName} preview tunnel within its deadline.`
);
return false;
}
);
}
const secrets: string[] = [];
let previewServer: DetachedProcessHandle;
try {
const turnArgs = (await turnArgsResult).enforceValue();
const localEgress = !isAndroid ? await readLocalEgressHandoffAsync() : null;
const previewExec = resolvePackageExec(
resolveConfiguredPackageManager(env, PackageManager.NPM),
isAndroid
? createExpoDeviceHubArgs({
port,
device: previewDevice,
turnArgs,
packageVersion,
recordingDirectory: recording?.directory,
websiteArgs: websiteOriginArgs(env),
frameAncestorArgs: frameAncestorArgs(frameAncestors),
corsOriginArgs: corsOriginArgs(corsOrigins),
})
: createServeSimArgs({
port,
turnArgs,
packageVersion,
iosSimulatorUdid: previewDevice,
websiteArgs: websiteOriginArgs(env),
frameAncestorArgs: frameAncestorArgs(frameAncestors),
corsOriginArgs: corsOriginArgs(corsOrigins),
// serve-sim adds its bearer token to --share-url; private links are for worker logs only.
shareUrl: disableShare ? undefined : previewPageUrl,
disableShare,
installAppPath: iosSimulatorUdid ? undefined : installAppPath,
launchAppIdentifier: iosSimulatorUdid ? undefined : launchAppIdentifier,
launchArgs: iosSimulatorUdid ? [] : launchArgs,
openUrl: iosSimulatorUdid ? undefined : openUrl,
networkCapture,
networkCaptureFields,
networkCaptureProxy: localEgress
? `http://${LOCAL_EGRESS_PROXY_HOST}:${localEgress.port}`
: undefined,
})
);
logger.info(
`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port} via ${previewExec.command}.`
);
// Redact these credentials before forwarding process output to build logs.
secrets.push(
...turnArgs.filter((_, index) => turnArgs[index - 1] === '--turn-credential'),
...(recording ? [recording.controlToken] : [])
);
startup.signal.throwIfAborted();
const launchOptions = {
command: previewExec.command,
args: previewExec.args,
env: {
...env,
...bootEnv,
...(isAndroid ? {} : { TMPDIR: serveSimTmpdir, SERVE_SIM_STATE_DIR: serveSimStateDir }),
EXPO_DEVICE_HUB_SCREENSHOT_DIRECTORY: screenshots.directory,
...(recording ? { EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN: recording.controlToken } : {}),
// The Hub must write its token in the directory read by this host.
...(isAndroid ? { EXPO_DEVICE_HUB_STATE_DIR } : {}),
},
logger,
};
previewServer = await server.startAsync({
...launchOptions,
signal: startup.signal,
secrets,
// Only an Android host records through expo-device-hub; serve-sim records on iOS.
stopGracePeriodMs: recording
? RECORDING_STOP_GRACE_PERIOD_MS
: SERVE_SIM_STOP_GRACE_PERIOD_MS,
});
} catch (error) {
startup.abort(error);
// Mark the opening tunnel as retired; its late-open path closes it.
finishTask = Promise.resolve();
await Promise.allSettled([simulatorSetup, retirePreviewAsync()]);
let hostStopped = true;
try {
await server.stopAsync();
} catch (stopError) {
hostStopped = false;
logger.warn({ err: stopError }, 'Could not stop the session server after startup failed.');
}
// A warm server may already be writing here even if its adoption command failed.
await screenshots.finishAsync(hostStopped);
if (previewRetirementErrors.length) {
throw new AggregateError(
[error, ...previewRetirementErrors],
'The warm Simulator preview tunnel could not be closed. Another session cannot start safely.'
);
}
throw error;
}
const host: DeviceSessionHost = {
openPreviewAsync,
getLastPreviewActivityAt() {
return previewActivity?.getLastActivityObservedAt();
},
finishAsync() {
readinessController.abort(new Error('Simulator preview ended.'));
return (finishTask ??= finishDeviceSessionHostAsync(ctx, {
previewTask,
stopSessionPreviewAsync: async () => {
previewSetupController.abort();
await previewSetupTask;
await sessionPreview?.stopAsync();
await previewActivity?.stopAsync();
},
previewServer,
screenshots,
serverName,
port,
// A host that never answered /readyz has nothing to finalize or upload.
recording: hostReady ? recording : null,
logger,
artifactLogger,
onStopped: successful => onFinished(successful && !previewFailed),
})
return (finishTask ??= (async () => {
const previewRetirement = retirePreviewAsync();
if (server.requiresPreviewRetirement) {
// Confirm the tunnel is closed before stop, so the next session cannot reach this one.
await previewRetirement;
}
await finishDeviceSessionHostAsync(ctx, {
previewRetirement,
stopSessionPreviewAsync: async () => {
previewSetupController.abort();
await previewSetupTask;
await sessionPreview?.stopAsync();
await previewActivity?.stopAsync();
},
previewServer,
screenshots,
serverName,
port,
// A host that never answered /readyz has nothing to finalize or upload.
recording: hostReady ? recording : null,
logger,
artifactLogger,
onStopped: successful => onFinished(successful && !previewFailed),
});
if (previewRetirementErrors.length) {
throw new AggregateError(
previewRetirementErrors,
'The warm Simulator preview tunnel could not be closed. Another session cannot start safely.'
);
}
})()
.finally(async () => {
await simulatorSetup;
if (stagedAppDirectory) {
@@ -678,6 +850,12 @@ async function startDeviceSessionHostInternalAsync(
.catch(err => {
artifactLogger.warn({ err }, 'Could not finish the simulator preview.');
onFinished(false);
if (previewRetirementErrors.length) {
throw new AggregateError(
[err, ...previewRetirementErrors],
'The warm Simulator preview tunnel could not be closed. Another session cannot start safely.'
);
}
}));
},
};
@@ -696,7 +874,7 @@ async function startDeviceSessionHostInternalAsync(
})
);
if (iosSimulatorUdid) {
previewToken = await waitForServeSimControlAsync({
const token = await waitForServeSimControlAsync({
device: iosSimulatorUdid,
port,
previewServer,
@@ -704,7 +882,8 @@ async function startDeviceSessionHostInternalAsync(
timeoutMs: startupTimeoutMs ?? timeoutMs,
signal: readinessSignal,
});
secrets.push(previewToken);
previewToken = token;
secrets.push(token);
if (Object.keys(bootEnv).length > 0) {
await startup.untilAborted(
verifyLocalEgressGuardAsync({ udid: iosSimulatorUdid as IosSimulatorUuid, env, logger })
@@ -712,22 +891,16 @@ async function startDeviceSessionHostInternalAsync(
}
signal?.throwIfAborted();
// APSD cleanup runs alongside app startup and readiness; teardown joins the task.
simulatorSetup = asyncResult(
startup.untilAborted(
startup.run('Simulator setup', async taskLogger => {
try {
await IosSimulatorUtils.disableApsdAsync({
udid: iosSimulatorUdid as IosSimulatorUuid,
env,
});
} catch (err) {
startup.signal.throwIfAborted();
taskLogger.warn({ err }, 'Failed to disable apsd in the Simulator.');
}
startup.signal.throwIfAborted();
})
)
);
const setupSimulatorAsync = server.setupSimulatorAsync;
if (setupSimulatorAsync) {
simulatorSetup = asyncResult(
startup.untilAborted(
startup.run('Simulator setup', taskLogger =>
setupSimulatorAsync({ signal: startup.signal, logger: taskLogger })
)
)
);
}
const app = (application ? await startup.untilAborted(application) : undefined) ?? {
installAppPath,
launchAppIdentifier,
@@ -738,18 +911,39 @@ async function startDeviceSessionHostInternalAsync(
const action = (name: string, params: Record<string, unknown>) =>
runServeSimActionAsync({
port,
token: previewToken!,
token,
action: name,
params,
timeoutMs: startupTimeoutMs ?? timeoutMs,
signal: startup.signal,
});
if (app.installAppPath) {
// The bundle that has not run yet: just installed, or preinstalled and never launched.
let notYetLaunched =
!app.installAppPath && app.launchAppIdentifier === server.preinstalledApp?.bundleIdentifier
? server.preinstalledApp?.bundleIdentifier
: undefined;
if (
app.installAppPath &&
server.preinstalledApp &&
(await isPreinstalledExpoGoAsync({
appPath: app.installAppPath,
preinstalled: server.preinstalledApp,
env,
}))
) {
logger.info('This Expo Go version is already installed on the Simulator.');
notYetLaunched = server.preinstalledApp.bundleIdentifier;
} else if (app.installAppPath) {
const staged = await stageServeSimAppAsync(app.installAppPath, serveSimTmpdir);
stagedAppDirectory = staged.directory;
signal?.throwIfAborted();
await action('app.install', { udid: iosSimulatorUdid, path: staged.path });
notYetLaunched = await readIosApplicationIdentifierAsync({
artifactPath: app.installAppPath,
env,
}).catch(() => undefined);
}
startup.signal.throwIfAborted();
if (app.launchAppIdentifier) {
signal?.throwIfAborted();
await action('app.launch', {
@@ -757,14 +951,16 @@ async function startDeviceSessionHostInternalAsync(
bundleId: app.launchAppIdentifier,
launchArgs: app.launchArgs ?? [],
openUrl: app.openUrl,
// An app that has not run yet needs no terminate check; any other app keeps it.
...(notYetLaunched === app.launchAppIdentifier ? { restart: false } : {}),
});
}
}
logger.info(`Waiting for ${serverName} to become ready.`);
const device = (await readiness).enforceValue();
if (iosSimulatorUdid && device.toLowerCase() !== iosSimulatorUdid.toLowerCase()) {
if (!isAndroid && previewDevice && device.toLowerCase() !== previewDevice.toLowerCase()) {
throw new SystemError(
`serve-sim became ready on ${device}, but this session requested ${iosSimulatorUdid}.`
`serve-sim became ready on ${device}, but this session requested ${previewDevice}.`
);
}
hostReady = true;
@@ -807,7 +1003,7 @@ async function startDeviceSessionHostInternalAsync(
captureAsync: signal =>
captureDeviceRunSessionPreviewAsync({
runtimePlatform,
device,
device: previewDevice ?? device,
env,
signal,
}),
@@ -818,11 +1014,19 @@ async function startDeviceSessionHostInternalAsync(
logger.warn({ err }, 'Could not start refreshing the session preview.');
}
});
(await earlyPreview).enforceValue();
signal?.throwIfAborted();
return host;
} catch (error) {
onStartupError?.(error);
await host.finishAsync();
try {
await host.finishAsync();
} catch (cleanupError) {
throw new AggregateError(
[error, cleanupError],
'Device session startup failed, and cleanup also failed.'
);
}
throw error;
}
}
@@ -871,7 +1075,7 @@ async function waitForServeSimControlAsync({
async function finishDeviceSessionHostAsync(
ctx: CustomBuildContext,
{
previewTask,
previewRetirement,
stopSessionPreviewAsync,
previewServer,
screenshots,
@@ -882,7 +1086,7 @@ async function finishDeviceSessionHostAsync(
artifactLogger,
onStopped,
}: {
previewTask: Promise<DeviceWebPreview> | null;
previewRetirement: Promise<boolean>;
stopSessionPreviewAsync: () => Promise<void>;
previewServer: DetachedProcessHandle;
screenshots: { finishAsync(hostStopped: boolean): Promise<void> };
@@ -897,21 +1101,6 @@ async function finishDeviceSessionHostAsync(
// Stop capturing before the host stops, so the last thumbnail shows the session, not shutdown.
await stopSessionPreviewAsync();
const hostExited = previewServer.getExitError() !== undefined;
// Native ngrok operations have no scoped cancellation. Retire a late listener too.
const retirePreview = withDeviceRunSessionTimeoutAsync(
{ name: 'Preview tunnel retirement', timeoutMs: 5_000 },
async () => {
// A preview that failed to open, or was closed by its opener after finish began, has no tunnel left.
const preview = await previewTask?.catch(() => null);
await preview?.closeAsync();
}
).then(
() => true,
err => {
logger.warn({ err }, `Could not close the ${serverName} preview tunnel within its deadline.`);
return false;
}
);
let finalization: AndroidRecordingFinalization | null = null;
if (recording) {
// stopAsync signals the whole process group, including capture's encoder.
@@ -932,7 +1121,7 @@ async function finishDeviceSessionHostAsync(
} catch (err) {
logger.warn({ err }, `Could not stop the ${serverName} session host.`);
}
const previewRetired = await retirePreview;
const previewRetired = await previewRetirement;
onStopped(hostStopped && !hostExited && previewRetired);
await screenshots.finishAsync(hostStopped);
// A Hub that never recorded has logged its reason and left nothing to upload.
@@ -1,4 +1,4 @@
import { UserError } from '@expo/eas-build-job';
import { SystemError, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import * as jose from 'jose';
@@ -617,6 +617,24 @@ export class AscApiClient {
);
if (parsedAscErrorResponse.ok) {
const errors = parsedAscErrorResponse.value.errors.map(ascError => {
if (response.status === 401 && ascError.code === 'NOT_AUTHORIZED') {
return new UserError(
'EAS_ASC_INVALID_AUTH',
'App Store Connect rejected the API authentication credentials. ' +
'Check that the API key is active and belongs to the correct App Store Connect account. ' +
'If the key is stored in EAS credentials, replace or reconfigure it. ' +
'For a local key, check `key_id` and `key`, and `issuer_id` for a team key. Then try again.',
{ cause: ascError, docsUrl: 'https://docs.expo.dev/submit/ios/' }
);
}
if (response.status === 500 && ascError.code === 'UNEXPECTED_ERROR') {
return new SystemError(
'App Store Connect returned an unexpected server error. ' +
'Check Apple’s service status at https://developer.apple.com/system-status/ and verify whether the requested operation completed in App Store Connect before trying again. ' +
'If the error continues, try again later or contact Apple Developer Support.',
{ cause: ascError, trackingCode: 'EAS_ASC_SERVER_ERROR' }
);
}
if (ascError.code === 'FORBIDDEN.REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED') {
return new UserError(
'EAS_ASC_REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED',
@@ -1,4 +1,4 @@
import { UserError } from '@expo/eas-build-job';
import { SystemError, UserError } from '@expo/eas-build-job';
import * as jose from 'jose';
import nock from 'nock';
@@ -85,6 +85,57 @@ describe(AscApiClient, () => {
}
);
it.each([false, true])(
'explains NOT_AUTHORIZED without losing other errors (mixed: %s)',
async mixed => {
const known = { status: '401', code: 'NOT_AUTHORIZED' };
const other = { status: '401', code: 'OTHER_ERROR' };
const scope = nock('https://api.appstoreconnect.apple.com')
.get('/v1/apps/app')
.query(true)
.reply(401, { errors: mixed ? [known, other] : [known] });
const error = await client
.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: 'app' })
.catch(error => error);
const mapped = mixed ? error.errors[0] : error;
expect(mapped).toBeInstanceOf(UserError);
expect(mapped).toMatchObject({ errorCode: 'EAS_ASC_INVALID_AUTH', cause: known });
if (mixed) {
expect(error).toBeInstanceOf(AggregateError);
expect(error.errors[1]).toBeInstanceOf(AscApiRequestError);
expect(error.errors[1].code).toBe('OTHER_ERROR');
}
expect(scope.isDone()).toBe(true);
}
);
it.each([false, true])(
'explains UNEXPECTED_ERROR without losing other errors (mixed: %s)',
async mixed => {
const known = { status: '500', code: 'UNEXPECTED_ERROR' };
const other = { status: '500', code: 'OTHER_ERROR' };
const scope = nock('https://api.appstoreconnect.apple.com')
.get('/v1/apps/app')
.query(true)
.reply(500, { errors: mixed ? [known, other] : [known] });
const error = await client
.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: 'app' })
.catch(error => error);
const mapped = mixed ? error.errors[0] : error;
expect(mapped).toBeInstanceOf(SystemError);
expect(mapped).toMatchObject({
trackingCode: 'EAS_ASC_SERVER_ERROR',
cause: known,
});
if (mixed) {
expect(error).toBeInstanceOf(AggregateError);
expect(error.errors[1]).toBeInstanceOf(AscApiRequestError);
expect(error.errors[1].code).toBe('OTHER_ERROR');
}
expect(scope.isDone()).toBe(true);
}
);
it('fetches app info', async () => {
const appId = '1491144534';
const responseFixture = require('./fixtures/apps/get-apps-200.json');
@@ -391,7 +391,9 @@ describe('commitBuildUploadFileAsync', () => {
.reply(status, { errors: [{ code }] });
await expect(
AscApiUtils.commitBuildUploadFileAsync({ client, fileId: 'file', logger })
).rejects.toMatchObject({ status, code });
).rejects.toMatchObject(
code === 'NOT_AUTHORIZED' ? { errorCode: 'EAS_ASC_INVALID_AUTH' } : { status, code }
);
});
it('does not commit when the initial state read fails', async () => {
@@ -39,3 +39,54 @@ export async function readIosApplicationIdentifierAsync({
}
return applicationIdentifier;
}
const EXPO_GO_BUNDLE_IDENTIFIER = 'host.exp.Exponent';
export type IosAppIdentity = { bundleIdentifier: string; shortVersion: string; version: string };
/**
* Expo Go releases are identified by their version, so a preinstalled copy with the same version is
* the same app. Custom builds can reuse a version, so they are always installed.
*/
export async function isPreinstalledExpoGoAsync({
appPath,
preinstalled,
env,
}: {
appPath: string;
preinstalled: IosAppIdentity;
env: BuildStepEnv;
}): Promise<boolean> {
if (preinstalled.bundleIdentifier !== EXPO_GO_BUNDLE_IDENTIFIER) {
return false;
}
try {
const requested = await readIosAppIdentityAsync(appPath, env);
return (
requested.bundleIdentifier === preinstalled.bundleIdentifier &&
requested.shortVersion === preinstalled.shortVersion &&
requested.version === preinstalled.version
);
} catch {
// An app that cannot be read is installed as usual.
return false;
}
}
async function readIosAppIdentityAsync(
appPath: string,
env: BuildStepEnv
): Promise<IosAppIdentity> {
const infoPlistPath = path.join(appPath, 'Info.plist');
const [bundleIdentifier, shortVersion, version] = await Promise.all(
['CFBundleIdentifier', 'CFBundleShortVersionString', 'CFBundleVersion'].map(async key =>
(
await spawn('plutil', ['-extract', key, 'raw', '-o', '-', infoPlistPath], {
stdio: 'pipe',
env,
})
).stdout.trim()
)
);
return { bundleIdentifier, shortVersion, version };
}
@@ -0,0 +1,368 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import * as ngrok from '@ngrok/ngrok';
import { randomBytes } from 'node:crypto';
import { once } from 'node:events';
import { type AddressInfo, type Socket, createConnection, createServer } from 'node:net';
import { clearTimeout, setTimeout } from 'node:timers';
import { turtleFetch } from '../../utils/turtleFetch';
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
const PROBE_INTERVAL_MS = 15_000;
const FAILURE_THRESHOLD = 3;
const STOP_TIMEOUT_MS = 4_000;
export type NgrokTunnelHandle = {
url: string;
subdomainId: string;
stopAsync: () => Promise<void>;
};
export async function startNgrokTunnelAsync({
port,
subdomainPrefix,
subdomainId = randomBytes(16).toString('hex'),
baseDomain,
authtoken,
rewriteHostHeader,
healthCheck,
logger,
}: {
port: number;
subdomainPrefix: string;
subdomainId?: string;
baseDomain: string;
authtoken: string;
rewriteHostHeader?: boolean;
healthCheck?: { path: string; statuses?: number[] };
logger: bunyan;
}): Promise<NgrokTunnelHandle> {
const domain = `${subdomainPrefix}-${subdomainId}.${baseDomain}`;
const controller = new AbortController();
const { signal } = controller;
const sockets = new Set<Socket>();
const target = createServer({ allowHalfOpen: true }, client => {
if (signal.aborted) {
client.destroy();
return;
}
const upstream = createConnection({ host: '127.0.0.1', port, allowHalfOpen: true });
for (const socket of [client, upstream]) {
sockets.add(socket);
socket.on('error', () => {
client.destroy();
upstream.destroy();
});
socket.on('close', () => sockets.delete(socket));
}
client.pipe(upstream).pipe(client);
});
target.listen(0, '127.0.0.1');
await once(target, 'listening');
target.unref();
signal.addEventListener(
'abort',
() => {
for (const socket of sockets) {
socket.destroy();
}
},
{ once: true }
);
const config: ngrok.Config = {
addr: (target.address() as AddressInfo).port,
authtoken,
domain,
...(rewriteHostHeader ? { request_header_add: [`Host:localhost:${port}`] } : {}),
};
const ownedListeners = new Set<ngrok.Listener>();
const retiredListeners = new Set<ngrok.Listener>();
const closingListeners = new Map<ngrok.Listener, Promise<void>>();
let pendingOpen: Promise<ngrok.Listener> | undefined;
let currentListener: ngrok.Listener | undefined;
function releaseTarget(): void {
if (signal.aborted && !pendingOpen && ownedListeners.size === 0 && target.listening) {
target.close();
}
}
async function closeAsync(
listener: ngrok.Listener,
closeSignal: AbortSignal,
logFailure = true
): Promise<void> {
retiredListeners.add(listener);
let closing = closingListeners.get(listener);
if (!closing) {
closing = Promise.resolve().then(async () => await listener.close());
closingListeners.set(listener, closing);
void closing.then(
() => {
closingListeners.delete(listener);
retiredListeners.delete(listener);
ownedListeners.delete(listener);
releaseTarget();
},
() => closingListeners.delete(listener)
);
}
try {
await withDeviceRunSessionTimeoutAsync(
{ name: 'Ngrok tunnel close', timeoutMs: 5_000, signal: closeSignal },
async () => await closing
);
} catch (err) {
if (logFailure && !closeSignal.aborted) {
logger.warn({ err }, `Could not stop ngrok tunnel ${domain}.`);
}
}
}
async function retireListenersAsync(retirementSignal: AbortSignal): Promise<void> {
let logFailure = true;
while (retiredListeners.size > 0) {
retirementSignal.throwIfAborted();
await Promise.all(
[...retiredListeners].map(retired => closeAsync(retired, retirementSignal, logFailure))
);
logFailure = false;
if (retiredListeners.size > 0) {
await waitAsync(250, retirementSignal);
}
}
}
async function retireLateListenerAsync(listener: ngrok.Listener): Promise<void> {
retiredListeners.add(listener);
try {
await withDeviceRunSessionTimeoutAsync(
{ name: 'Late ngrok tunnel retirement', timeoutMs: STOP_TIMEOUT_MS },
async retirementSignal => {
while (retiredListeners.has(listener)) {
retirementSignal.throwIfAborted();
await closeAsync(listener, retirementSignal, false);
if (retiredListeners.has(listener)) {
await waitAsync(250, retirementSignal);
}
}
}
);
} catch (err) {
logger.warn({ err }, `Could not retire late ngrok tunnel ${domain}.`);
}
}
async function openAsync(forceNewSession = false): Promise<ngrok.Listener> {
if (pendingOpen) {
throw new SystemError(`A previous ngrok tunnel open for ${domain} is still pending.`);
}
let acquired: ngrok.Listener | undefined;
let retirement: Promise<void> | undefined;
const retireAcquired = () => {
if (acquired) {
retirement ??= retireLateListenerAsync(acquired);
}
};
try {
return await withDeviceRunSessionTimeoutAsync(
{ name: 'Ngrok tunnel open', timeoutMs: 15_000, signal },
async openSignal => {
openSignal.throwIfAborted();
const opening = ngrok
.forward({
...config,
...(forceNewSession ? { force_new_session: true } : {}),
})
.then(listener => {
acquired = listener;
ownedListeners.add(listener);
return listener;
})
.finally(() => {
pendingOpen = undefined;
});
pendingOpen = opening;
void opening
.then(() => {
if (openSignal.aborted) {
retireAcquired();
}
})
.catch(() => releaseTarget());
const listener = await opening;
openSignal.throwIfAborted();
return listener;
}
);
} catch (err) {
retireAcquired();
throw err;
}
}
logger.info(`Starting ngrok tunnel ${domain} -> http://localhost:${port}.`);
let publicUrl: string | null;
try {
currentListener = await openAsync();
publicUrl = currentListener.url();
if (!publicUrl) {
await closeAsync(currentListener, signal);
currentListener = undefined;
throw new SystemError(`ngrok tunnel for ${domain} did not return a public URL.`);
}
} catch (error) {
controller.abort();
releaseTarget();
throw error;
}
const url = publicUrl;
async function probeAsync(
baseUrl: string,
{ path, statuses = [200] }: NonNullable<typeof healthCheck>
): Promise<boolean> {
try {
return await withDeviceRunSessionTimeoutAsync(
{ name: 'Ngrok tunnel health probe', timeoutMs: 5_000, signal },
async probeSignal => {
const requestController = new AbortController();
try {
const response = await turtleFetch(new URL(path, baseUrl).href, 'GET', {
retries: 0,
redirect: 'manual',
shouldThrowOnNotOk: false,
headers: { 'ngrok-skip-browser-warning': 'true', 'Cache-Control': 'no-cache' },
signal: AbortSignal.any([probeSignal, requestController.signal]),
});
return !response.headers.has('ngrok-error-code') && statuses.includes(response.status);
} finally {
requestController.abort();
}
}
);
} catch {
return false;
}
}
async function superviseAsync(check: NonNullable<typeof healthCheck>): Promise<void> {
let failures = 0;
let attempts = 0;
let localUnhealthy = false;
while (
await waitAsync(
currentListener || localUnhealthy
? PROBE_INTERVAL_MS
: Math.min(1_000 * 2 ** Math.min(attempts, 5), 30_000),
signal
)
) {
await Promise.all([...retiredListeners].map(retired => closeAsync(retired, signal, false)));
if (signal.aborted) {
return;
}
if (currentListener && (await probeAsync(url, check))) {
if (failures > 0 || attempts > 0 || localUnhealthy) {
logger.info(`Ngrok tunnel ${domain} is healthy again.`);
}
failures = 0;
attempts = 0;
localUnhealthy = false;
continue;
}
if (signal.aborted) {
return;
}
if (!(await probeAsync(`http://127.0.0.1:${port}`, check))) {
if (!localUnhealthy && !signal.aborted) {
logger.warn(`Local service on port ${port} is unhealthy; waiting for it to recover.`);
}
localUnhealthy = true;
failures = 0;
continue;
}
localUnhealthy = false;
if (currentListener) {
failures++;
if (failures < FAILURE_THRESHOLD) {
continue;
}
logger.warn(`Ngrok tunnel ${domain} failed ${failures} health probes; reopening it.`);
await closeAsync(currentListener, signal);
currentListener = undefined;
}
if (signal.aborted) {
return;
}
attempts++;
try {
const reopened = await openAsync(/* forceNewSession */ true);
if (reopened.url() !== url) {
await closeAsync(reopened, signal);
throw new SystemError(
`Reopened ngrok tunnel for ${domain} returned a different public URL.`
);
}
currentListener = reopened;
failures = 0;
} catch (err) {
if (!signal.aborted && (attempts === 1 || attempts % 5 === 0)) {
logger.warn(
{ err, attempt: attempts },
`Could not reopen ngrok tunnel ${domain}; will retry.`
);
}
}
}
}
const supervision = healthCheck
? superviseAsync(healthCheck).catch(err => {
if (!signal.aborted) {
logger.warn({ err }, `Ngrok tunnel supervision stopped for ${domain}.`);
}
})
: Promise.resolve();
let stopTask: Promise<void> | undefined;
return {
url,
subdomainId,
stopAsync: () =>
(stopTask ??= (async () => {
controller.abort();
await withDeviceRunSessionTimeoutAsync(
{ name: `Ngrok tunnel ${domain} stop`, timeoutMs: STOP_TIMEOUT_MS },
async stopSignal => {
await supervision;
if (currentListener) {
retiredListeners.add(currentListener);
currentListener = undefined;
}
await Promise.all([pendingOpen?.catch(() => {}), retireListenersAsync(stopSignal)]);
await retireListenersAsync(stopSignal);
}
);
releaseTarget();
})()),
};
}
function waitAsync(ms: number, signal: AbortSignal): Promise<boolean> {
if (signal.aborted) {
return Promise.resolve(false);
}
return new Promise(resolve => {
const onAbort = () => {
clearTimeout(timer);
resolve(false);
};
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort);
resolve(true);
}, ms);
signal.addEventListener('abort', onAbort, { once: true });
});
}
@@ -9,9 +9,7 @@ import {
spawnAsync,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import * as ngrok from '@ngrok/ngrok';
import { z } from 'zod';
import { randomBytes } from 'node:crypto';
import fs from 'node:fs';
import { createServer } from 'node:net';
import path from 'node:path';
@@ -524,6 +522,14 @@ export async function fetchWebPreviewTurnArgsAsync(
}
return args;
} catch (err) {
if (
err instanceof DeviceRunSessionWorkerApiError &&
err.code === 'DEVICE_RUN_SESSION_ALREADY_STOPPED'
) {
logger.info('Device run session was stopped before TURN credentials were fetched.');
return [];
}
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not fetch Cloudflare TURN ICE servers', error, { level: 'warning' });
logger.warn(
@@ -601,7 +607,7 @@ export function isProcessRunning(pid: number): boolean {
}
}
async function stopDetachedProcessAsync(
export async function stopDetachedProcessAsync(
pid: number | undefined,
gracePeriodMs = 5_000
): Promise<void> {
@@ -767,7 +773,7 @@ export function createIosSessionStartupInputProviders(): ReturnType<
}
export type ServeSimApplicationOptions = ServeSimLaunchOptions & {
installAppPath: string;
installAppPath?: string;
launchAppIdentifier: string;
};
@@ -959,63 +965,7 @@ export async function findAvailablePortAsync(): Promise<number> {
return address.port;
}
export type NgrokTunnelHandle = {
url: string;
subdomainId: string;
stopAsync: () => Promise<void>;
};
export async function startNgrokTunnelAsync({
port,
subdomainPrefix,
subdomainId: subdomainIdArg,
baseDomain,
authtoken,
rewriteHostHeader,
logger,
}: {
port: number;
subdomainPrefix: string;
subdomainId?: string;
baseDomain: string;
authtoken: string;
rewriteHostHeader?: boolean;
logger: bunyan;
}): Promise<NgrokTunnelHandle> {
const subdomainId = subdomainIdArg ?? randomBytes(16).toString('hex');
const domain = `${subdomainPrefix}-${subdomainId}.${baseDomain}`;
logger.info(`Starting ngrok tunnel ${domain} -> http://localhost:${port}.`);
// Run the ngrok agent in-process via the SDK; it keeps the session alive until
// the process exits, and the step blocks forever to hold it open.
const listener = await ngrok.forward({
addr: port,
authtoken,
domain,
...(rewriteHostHeader ? { request_header_add: [`Host:localhost:${port}`] } : {}),
});
const url = listener.url();
if (!url) {
await listener.close();
throw new SystemError(`ngrok tunnel for ${domain} did not return a public URL.`);
}
let stopTask: Promise<void> | undefined;
return {
url,
subdomainId,
stopAsync: () =>
(stopTask ??= (async () => {
try {
await withDeviceRunSessionTimeoutAsync(
{ name: 'Ngrok tunnel stop', timeoutMs: 4_000 },
async () => await listener.close()
);
} catch (error) {
logger.warn({ err: error }, `Could not stop ngrok tunnel ${domain}.`);
throw error;
}
})()),
};
}
export { type NgrokTunnelHandle, startNgrokTunnelAsync } from './ngrokTunnel';
export async function waitForFileAsync<T>({
filePath,
@@ -0,0 +1,95 @@
import type { bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import { type CustomBuildContext } from '../../customBuildContext';
import { IosSimulatorRecordingUtils } from './IosSimulatorRecordingUtils';
import { type DeviceSessionServer } from './deviceSessionHost';
import { fetchWebPreviewTurnArgsAsync } from './remoteDeviceRunSession';
import { type WarmIosSession, tryClaimWarmIosSessionAsync } from './warmIosSession';
/** Adoption failed before app actions, so the caller can stop warmup and start cold. */
export class WarmSessionAdoptionError extends Error {
constructor() {
// The adoption command line includes TURN credentials, so the cause stays out of the message.
super('Could not adopt the warm Simulator for this job.');
this.name = 'WarmSessionAdoptionError';
}
}
/** A session server that adopts the warm serve-sim for the job's device instead of launching one. */
export function createWarmDeviceSessionServer(
ctx: CustomBuildContext,
logger: bunyan,
warm: WarmIosSession
): DeviceSessionServer {
const { udid, name } = warm.device;
async function stopAsync(): Promise<void> {
await warm.process.stopAsync();
}
return {
port: warm.server.port,
turnArgs: fetchWebPreviewTurnArgsAsync(ctx, { logger }),
// Set by prepare-tools, which the host runs before it installs the app.
get preinstalledApp() {
return warm.preinstalledExpoGo;
},
requiresPreviewRetirement: true,
stopAsync,
async startAsync({ command, args, env, signal }) {
try {
// serve-sim starts this job's recording when it adopts the device.
const recording = await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({
udid,
deviceName: name,
serverToken: warm.server.token,
});
const recordingArgs = recording
? ['--recording-output', recording.output, '--recording-id', recording.recordingId]
: [];
const timeout = AbortSignal.timeout(360_000);
await spawn(command, [...args, '--single-device', udid, '--adopt', ...recordingArgs], {
env,
stdio: 'pipe',
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
});
await warm.prepareToolsAsync(signal);
} catch {
signal?.throwIfAborted();
throw new WarmSessionAdoptionError();
}
return { ...warm.process, stopAsync };
},
};
}
/**
* Runs an iOS session on the warm Simulator when the job can claim it, else cold. After a
* callback failure, it stops warmup. Only an adoption failure can retry cold.
*/
export async function runOnWarmIosSimulatorAsync<T>(
claim: Parameters<typeof tryClaimWarmIosSessionAsync>[0],
run: (warm?: WarmIosSession) => Promise<T>
): Promise<T> {
const warm = await tryClaimWarmIosSessionAsync(claim);
if (!warm) {
return await run(undefined);
}
try {
return await run(warm);
} catch (error) {
try {
await warm.process.stopAsync();
} catch (cleanupError) {
throw new AggregateError(
[error, cleanupError],
'Could not stop the warm Simulator after failure.'
);
}
if (!(error instanceof WarmSessionAdoptionError)) {
throw error;
}
claim.logger.warn('Could not adopt the warm Simulator; starting a cold Simulator.');
claim.signal?.throwIfAborted();
return await run(undefined);
}
}
@@ -0,0 +1,119 @@
import { type bunyan } from '@expo/logger';
import { type BuildStepEnv } from '@expo/steps';
import fs from 'node:fs/promises';
import path from 'node:path';
import { z } from 'zod';
import { runSimulatorLauncherAsync, stopWarmSimulatorAsync } from '../../utils/warmSimulator';
import { readLocalEgressHandoffAsync } from './localEgress';
import { type DetachedProcessHandle, isProcessRunning } from './remoteDeviceRunSession';
const ServiceSchema = z.object({ pid: z.number(), port: z.number(), token: z.string() });
const ClaimSchema = z.object({
device: z.object({ udid: z.string(), name: z.string() }),
server: ServiceSchema,
agentDeviceStateDir: z.string(),
argentEventLogPath: z.string(),
preinstalledExpoGo: z
.object({ bundleIdentifier: z.string(), shortVersion: z.string(), version: z.string() })
.optional(),
});
type Service = z.infer<typeof ServiceSchema>;
/** The warm Simulator that the launcher handed to this job. */
export type WarmIosSession = z.infer<typeof ClaimSchema> & {
/** The selected tool's server; set by prepareToolsAsync. */
tools?: Service;
/** Runs after adoption: the launcher prepares the selected tool for the adopted device. */
prepareToolsAsync(signal?: AbortSignal): Promise<void>;
process: DetachedProcessHandle;
};
/**
* Claims the warm Simulator. The launcher checks the session; build-tools only uses it. On any
* failure, warmup stops and the caller uses a cold start; a failed stop rejects.
*/
export async function tryClaimWarmIosSessionAsync({
engine,
requestedDevice,
packageVersion,
env,
logger,
signal,
}: {
engine: 'serve-sim' | 'agent-device' | 'argent' | 'appium';
requestedDevice?: string;
packageVersion?: string;
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
}): Promise<WarmIosSession | undefined> {
if (env.EAS_USE_WARM_SIMULATOR !== '1') {
return undefined;
}
try {
// Warm tools run the latest release with the VM's Simulator settings.
if (packageVersion && packageVersion !== 'latest') {
throw new Error('A pinned package_version needs a cold start.');
}
const setting = Object.keys(env).find(
name => name === 'SERVE_SIM_ADDITIONAL_DYLIBS' || name.startsWith('SIMCTL_CHILD_')
);
if (setting) {
throw new Error(`${setting} needs a cold start.`);
}
if (await readLocalEgressHandoffAsync()) {
throw new Error('A local egress session needs a cold start.');
}
logger.info('Waiting for the warm Simulator launcher.');
const claimArgs = ['claim', engine, ...(requestedDevice ? [requestedDevice] : [])];
const claim = ClaimSchema.parse(
JSON.parse(await runSimulatorLauncherAsync(claimArgs, { env, signal }))
);
logger.info(`Claimed the warm Simulator ${claim.device.name} (${claim.device.udid}).`);
let stopping: Promise<void> | undefined;
const session: WarmIosSession = {
...claim,
async prepareToolsAsync(prepareSignal = signal) {
const prepared = ClaimSchema.pick({ preinstalledExpoGo: true })
.extend({ tools: ServiceSchema.optional() })
.parse(
JSON.parse(
await runSimulatorLauncherAsync(['prepare-tools'], { env, signal: prepareSignal })
)
);
// The launcher reports the Expo Go it preinstalled once its install has finished.
session.preinstalledExpoGo = prepared.preinstalledExpoGo ?? session.preinstalledExpoGo;
if (engine === 'agent-device') {
const daemon = z
.object({ pid: z.number(), httpPort: z.number(), token: z.string() })
.parse(await readJsonAsync(path.join(claim.agentDeviceStateDir, 'daemon.json')));
session.tools = { pid: daemon.pid, port: daemon.httpPort, token: daemon.token };
} else if (engine === 'argent') {
session.tools = ServiceSchema.parse(prepared.tools);
}
},
process: {
pid: claim.server.pid,
getOutput: () => '',
getExitError: () =>
isProcessRunning(claim.server.pid) &&
(!session.tools || isProcessRunning(session.tools.pid))
? undefined
: new Error('A service of the warm Simulator session has exited.'),
stopAsync: () => (stopping ??= stopWarmSimulatorAsync(env)),
},
};
return session;
} catch (error) {
logger.warn({ err: error }, 'Could not use the warm Simulator; stopping it for a cold start.');
await stopWarmSimulatorAsync(env);
signal?.throwIfAborted();
return undefined;
}
}
async function readJsonAsync(file: string): Promise<unknown> {
return JSON.parse(await fs.readFile(file, 'utf8'));
}
@@ -7,6 +7,7 @@ import { setTimeout } from 'node:timers/promises';
import { z } from 'zod';
import { retryAsync } from './retry';
import { waitForWarmSimulatorCleanupAsync } from './warmSimulatorCleanup';
export type IosSimulatorUuid = string & z.BRAND<'IosSimulatorUuid'>;
export type IosSimulatorName = string & z.BRAND<'IosSimulatorName'>;
@@ -119,6 +120,7 @@ export namespace IosSimulatorUtils {
destinationDeviceName: IosSimulatorName;
env: NodeJS.ProcessEnv;
}): Promise<void> {
await waitForWarmSimulatorCleanupAsync(env);
await spawn('xcrun', ['simctl', 'clone', sourceDeviceIdentifier, destinationDeviceName], {
env,
});
@@ -131,6 +133,7 @@ export namespace IosSimulatorUtils {
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
env: NodeJS.ProcessEnv;
}): Promise<void> {
await waitForWarmSimulatorCleanupAsync(env);
try {
const devices = await getAvailableDevicesAsync({ env, filter: 'available' });
const device = devices.find(
@@ -231,6 +234,7 @@ export namespace IosSimulatorUtils {
env: NodeJS.ProcessEnv;
launchdEnvironment?: Record<string, string>;
}): Promise<void> {
await waitForWarmSimulatorCleanupAsync(env);
const bootEnv = { ...env };
for (const [name, value] of Object.entries(launchdEnvironment)) {
bootEnv[`SIMCTL_CHILD_${name}`] = value;
@@ -253,6 +257,7 @@ export namespace IosSimulatorUtils {
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
env: NodeJS.ProcessEnv;
}): Promise<{ udid: IosSimulatorUuid }> {
await waitForWarmSimulatorCleanupAsync(env);
const bootstatusResult = await spawn(
'xcrun',
['simctl', 'bootstatus', deviceIdentifier, '-b'],
@@ -5,6 +5,7 @@ import path from 'node:path';
import { IosSimulatorUtils } from '../IosSimulatorUtils';
import { retryAsync } from '../retry';
import { waitForWarmSimulatorCleanupAsync } from '../warmSimulatorCleanup';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
@@ -14,6 +15,7 @@ jest.mock('@expo/turtle-spawn', () => ({
jest.mock('../retry', () => ({
retryAsync: jest.fn(async fn => await fn(0)),
}));
jest.mock('../warmSimulatorCleanup');
const mockedSpawn = jest.mocked(spawn);
@@ -21,6 +23,7 @@ describe('IosSimulatorUtils', () => {
beforeEach(() => {
mockedSpawn.mockReset();
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
jest.mocked(waitForWarmSimulatorCleanupAsync).mockReset().mockResolvedValue(undefined);
});
describe(IosSimulatorUtils.getAvailableRuntimesAsync, () => {
@@ -151,6 +154,30 @@ describe('IosSimulatorUtils', () => {
}) as any);
}
it('waits for warm cleanup before checking device state and writing accessibility prefs', async () => {
let finishCleanup: (() => void) | undefined;
jest.mocked(waitForWarmSimulatorCleanupAsync).mockImplementationOnce(
async () =>
await new Promise<void>(resolve => {
finishCleanup = resolve;
})
);
mockAvailableDevices([device()]);
const setup = IosSimulatorUtils.enableAccessibilitySettingsAsync({
deviceIdentifier: device().udid as any,
env: {},
});
expect(mockedSpawn).not.toHaveBeenCalled();
expect(finishCleanup).toBeDefined();
finishCleanup?.();
await setup;
expect(mockedSpawn).toHaveBeenCalledWith('plutil', expect.anything(), {
env: {},
});
});
it('writes accessibility prefs for an exact shutdown simulator match', async () => {
mockAvailableDevices([
device({
@@ -76,11 +76,7 @@ describe(getSshRelayServerUrl, () => {
});
describe(startSshSessionAsync, () => {
const config1: SshConnectionConfig = {
type: 'upterm-v1',
host: 'relay.expo.dev',
secret: 'secret-1',
};
const config1: SshConnectionConfig = { type: 'upterm-v1', secret: 'secret-1' };
const config2: SshConnectionConfig = { ...config1, secret: 'secret-2' };
const target = { type: 'JOB_RUN', id: 'jr-1' } as const;
@@ -133,7 +129,7 @@ describe(startSshSessionAsync, () => {
expect(mutation).toHaveBeenCalledWith(expect.anything(), {
target: { type: 'JOB_RUN', id: 'jr-1' },
connectionConfig: { ...config1, reconnecting: false, type: 'UPTERM_V1' },
connectionConfig: { type: 'UPTERM_V1', secret: 'secret-1', reconnecting: false },
sessionSettings: { idleTimeoutSeconds: 900 },
});
expect(idleTimeoutSeconds).toBe(900);
@@ -155,7 +151,7 @@ describe(startSshSessionAsync, () => {
expect(mutation).toHaveBeenCalledWith(expect.anything(), {
target: { type: 'BUILD', id: 'b-1' },
connectionConfig: { ...config1, reconnecting: false, type: 'UPTERM_V1' },
connectionConfig: { type: 'UPTERM_V1', secret: 'secret-1', reconnecting: false },
sessionSettings: { idleTimeoutSeconds: 900 },
});
});
@@ -242,7 +238,7 @@ describe(startSshSessionAsync, () => {
);
expect(mutation).toHaveBeenLastCalledWith(expect.anything(), {
target: { type: 'JOB_RUN', id: 'jr-1' },
connectionConfig: { ...config2, reconnecting: false, type: 'UPTERM_V1' },
connectionConfig: { type: 'UPTERM_V1', secret: 'secret-2', reconnecting: false },
sessionSettings: { idleTimeoutSeconds: 300 },
});
});
@@ -298,7 +294,7 @@ describe(startSshSessionAsync, () => {
expect(host.redialAsync).toHaveBeenCalledTimes(1);
expect(mutation).toHaveBeenLastCalledWith(expect.anything(), {
target: { type: 'JOB_RUN', id: 'jr-1' },
connectionConfig: { ...config2, reconnecting: false, type: 'UPTERM_V1' },
connectionConfig: { type: 'UPTERM_V1', secret: 'secret-2', reconnecting: false },
sessionSettings: { idleTimeoutSeconds: 300 },
});
});
@@ -7,7 +7,6 @@ import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { sleepAsync } from '../retry';
import {
connectionConfigFromUptermSession,
redactConnectionSecrets,
redactSpawnErrorForLog,
resolveUptermGcsObjectName,
@@ -42,49 +41,6 @@ describe(resolveUptermGcsObjectName, () => {
});
});
describe(connectionConfigFromUptermSession, () => {
it('parses sessionId and host from session current fields', () => {
expect(
connectionConfigFromUptermSession({
sessionId: 'TOKENabc123',
host: 'ssh://relay.expo.dev:22',
})
).toEqual({
type: 'upterm-v1',
host: 'relay.expo.dev',
secret: 'TOKENabc123',
});
});
it('accepts a bare hostname', () => {
expect(connectionConfigFromUptermSession({ sessionId: 'tok', host: 'relay.expo.dev' })).toEqual(
{
type: 'upterm-v1',
host: 'relay.expo.dev',
secret: 'tok',
}
);
});
it('keeps a non-default port so the CLI can pass it to ssh -p', () => {
expect(
connectionConfigFromUptermSession({
sessionId: 'tok',
host: 'ssh://relay.expo.dev:2222',
})
).toEqual({
type: 'upterm-v1',
host: 'relay.expo.dev:2222',
secret: 'tok',
});
});
it('returns null when the host is not a parseable url', () => {
expect(connectionConfigFromUptermSession({ sessionId: 'tok', host: 'ssh://' })).toBeNull();
expect(connectionConfigFromUptermSession({ sessionId: 'tok', host: 'ssh://[' })).toBeNull();
});
});
describe(redactConnectionSecrets, () => {
it('strips the userinfo secret from a connection string', () => {
expect(
@@ -257,11 +213,7 @@ describe(startUptermHostAsync, () => {
relayServerUrl: 'wss://relay.expo.dev',
});
expect(host.connectionConfig).toEqual({
type: 'upterm-v1',
host: 'relay.expo.dev',
secret: 'TOKENx',
});
expect(host.connectionConfig).toEqual({ type: 'upterm-v1', secret: 'TOKENx' });
expect(host.isAlive()).toBe(true);
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(mockedSpawn).toHaveBeenCalledWith('upterm', ['version'], {
@@ -369,6 +321,21 @@ describe(startUptermHostAsync, () => {
);
});
it('keeps polling until upterm reports a session id', async () => {
sessionStdout = JSON.stringify({ host: 'ssh://relay.expo.dev:22', clientCount: 0 });
mockedSleep.mockImplementationOnce(async () => {
sessionStdout = JSON.stringify({
sessionId: 'TOKENy',
host: 'ssh://relay.expo.dev:22',
clientCount: 0,
});
});
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
expect(host.connectionConfig).toEqual({ type: 'upterm-v1', secret: 'TOKENy' });
});
it('tears down and rethrows when the host never registers with the relay', async () => {
jest.useFakeTimers();
jest.setSystemTime(0);
@@ -420,11 +387,7 @@ describe(startUptermHostAsync, () => {
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
const config = await host.redialAsync();
expect(config).toEqual({
type: 'upterm-v1',
host: 'relay.expo.dev',
secret: 'TOKENx',
});
expect(config).toEqual({ type: 'upterm-v1', secret: 'TOKENx' });
expect(host.connectionConfig).toEqual(config);
});
@@ -462,11 +425,7 @@ describe(startUptermHostAsync, () => {
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
hostProcess.catch = () => new Promise<void>(() => {});
await expect(host.redialAsync()).resolves.toEqual({
type: 'upterm-v1',
host: 'relay.expo.dev',
secret: 'TOKENx',
});
await expect(host.redialAsync()).resolves.toEqual({ type: 'upterm-v1', secret: 'TOKENx' });
expect(mockedSleep).toHaveBeenCalledWith(5_000);
});
@@ -0,0 +1,61 @@
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs/promises';
import { runSimulatorLauncherAsync, stopWarmSimulatorAsync } from '../warmSimulator';
jest.mock('@expo/turtle-spawn');
const originalPlatform = process.platform;
beforeEach(() => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
jest.resetAllMocks();
jest.mocked(spawn).mockResolvedValue({ stdout: '{"ok":true}' } as never);
});
afterEach(() => {
jest.restoreAllMocks();
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('runs the launcher version that start recorded and returns its output', async () => {
jest.spyOn(fs, 'readFile').mockResolvedValue('{"launcherVersion":"27.1.0"}');
await expect(runSimulatorLauncherAsync(['claim', 'serve-sim'], { env: {} })).resolves.toBe(
'{"ok":true}'
);
expect(spawn).toHaveBeenCalledWith(
'bun',
['x', '@expo/[email protected]', 'claim', 'serve-sim'],
expect.objectContaining({ stdio: 'pipe' })
);
expect(jest.mocked(spawn).mock.calls[0][2]).not.toHaveProperty('logger');
});
it.each([
[{}, 'bun', ['x']],
[{ EAS_OVERRIDE_PACKAGE_MANAGER: 'npm' }, 'npx', ['--yes']],
])(
'stops with the latest launcher and the configured package manager for %j',
async (env, command, prefix) => {
jest
.spyOn(fs, 'readFile')
.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }));
await stopWarmSimulatorAsync(env);
expect(spawn).toHaveBeenCalledWith(
command,
[...prefix, '@expo/simulator-launcher@latest', 'stop'],
expect.objectContaining({ env })
);
}
);
it('rejects when the launcher cannot confirm cleanup', async () => {
jest.spyOn(fs, 'readFile').mockResolvedValue('{}');
jest.mocked(spawn).mockRejectedValue(new Error('cleanup failed'));
await expect(stopWarmSimulatorAsync({})).rejects.toThrow('cleanup failed');
});
it('does nothing on other platforms', async () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
await stopWarmSimulatorAsync({});
expect(spawn).not.toHaveBeenCalled();
});
@@ -0,0 +1,125 @@
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs/promises';
import { waitForWarmSimulatorCleanupAsync } from '../warmSimulatorCleanup';
const STATE_FILE = '/tmp/expo-simulator-startup.json';
const STARTUP_SCRIPT = '/usr/local/share/vm-tools/startup.sh';
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
jest.mock('@expo/turtle-spawn');
async function writeStateAsync(state: object): Promise<void> {
await fs.mkdir('/tmp', { recursive: true });
await fs.writeFile(STATE_FILE, JSON.stringify({ bootUuid: 'current-boot', ...state }));
}
beforeEach(() => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
jest.mocked(spawn).mockResolvedValue({ stdout: 'current-boot\n' } as never);
});
afterEach(() => {
Object.defineProperty(process, 'platform', platform);
});
it('continues at once on a VM without warmup', async () => {
await expect(waitForWarmSimulatorCleanupAsync({})).resolves.toBeUndefined();
expect(spawn).not.toHaveBeenCalled();
});
it('ignores a previous boot cleanup record and reads the current boot only once', async () => {
await writeStateAsync({ status: 'disabled', bootUuid: 'old-boot' });
let finished = false;
const waiting = waitForWarmSimulatorCleanupAsync({}, { pollMs: 5 }).then(() => {
finished = true;
});
await new Promise(resolve => setTimeout(resolve, 30));
expect(finished).toBe(false);
await writeStateAsync({ status: 'disabled' });
await waiting;
expect(spawn).toHaveBeenCalledTimes(1);
});
it('does not accept a cleanup record without a boot ID', async () => {
await writeStateAsync({ status: 'disabled', bootUuid: undefined });
await expect(waitForWarmSimulatorCleanupAsync({}, { timeoutMs: 20, pollMs: 5 })).rejects.toThrow(
'Warm Simulator cleanup did not finish'
);
});
it.each(['failed', 'empty'])('fails closed when the current boot ID is %s', async mode => {
await writeStateAsync({ status: 'disabled' });
if (mode === 'failed') {
jest.mocked(spawn).mockRejectedValueOnce(new Error('sysctl failed'));
} else {
jest.mocked(spawn).mockResolvedValueOnce({ stdout: '' } as never);
}
await expect(waitForWarmSimulatorCleanupAsync({})).rejects.toThrow();
});
it('waits for cleanup when warmup is installed but has not written state yet', async () => {
await fs.mkdir('/usr/local/share/vm-tools', { recursive: true });
await fs.writeFile(STARTUP_SCRIPT, '');
let finished = false;
const waiting = waitForWarmSimulatorCleanupAsync({}, { pollMs: 5 }).then(() => {
finished = true;
});
await new Promise(resolve => setTimeout(resolve, 30));
expect(finished).toBe(false);
await writeStateAsync({ status: 'starting' });
await new Promise(resolve => setTimeout(resolve, 30));
expect(finished).toBe(false);
await writeStateAsync({ status: 'disabled' });
await waiting;
});
it('times out when installed warmup never writes state', async () => {
await fs.mkdir('/usr/local/share/vm-tools', { recursive: true });
await fs.writeFile(STARTUP_SCRIPT, '');
await expect(waitForWarmSimulatorCleanupAsync({}, { timeoutMs: 20, pollMs: 5 })).rejects.toThrow(
'Warm Simulator cleanup did not finish; the Simulator cannot be used.'
);
});
it('waits until the launcher records that cleanup finished', async () => {
await writeStateAsync({ status: 'ready' });
let finished = false;
const waiting = waitForWarmSimulatorCleanupAsync({}, { pollMs: 5 }).then(() => {
finished = true;
});
await new Promise(resolve => setTimeout(resolve, 30));
expect(finished).toBe(false);
await writeStateAsync({ status: 'stopping' });
await new Promise(resolve => setTimeout(resolve, 30));
expect(finished).toBe(false);
await writeStateAsync({ status: 'disabled' });
await waiting;
expect(finished).toBe(true);
});
it('fails with the recorded error when cleanup does not finish in time', async () => {
await writeStateAsync({ status: 'stopping', error: 'serve-sim did not exit' });
await expect(waitForWarmSimulatorCleanupAsync({}, { timeoutMs: 20, pollMs: 5 })).rejects.toThrow(
'Warm Simulator cleanup did not finish (serve-sim did not exit); the Simulator cannot be used.'
);
});
it('stops waiting when the job is cancelled', async () => {
await writeStateAsync({ status: 'stopping' });
const controller = new AbortController();
const waiting = waitForWarmSimulatorCleanupAsync({}, { pollMs: 5, signal: controller.signal });
controller.abort(new Error('cancelled'));
await expect(waiting).rejects.toThrow('cancelled');
});
it('does not wait for a job that uses the warm Simulator', async () => {
await writeStateAsync({ status: 'claimed' });
await expect(
waitForWarmSimulatorCleanupAsync({ EAS_USE_WARM_SIMULATOR: '1' }, { timeoutMs: 0 })
).resolves.toBeUndefined();
});
it('does nothing outside macOS', async () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
await writeStateAsync({ status: 'ready' });
await expect(waitForWarmSimulatorCleanupAsync({}, { timeoutMs: 0 })).resolves.toBeUndefined();
});
+3 -1
View File
@@ -121,7 +121,7 @@ export async function uploadApplicationArchive(
patternOrPath: string;
rootDir: string;
}
): Promise<void> {
): Promise<string[]> {
const applicationArchives = await findArtifacts({ rootDir, patternOrPath, logger });
const artifactsSizes = await getArtifactsSizes(applicationArchives);
logger.info(`Application archives:`);
@@ -137,6 +137,8 @@ export async function uploadApplicationArchive(
},
logger,
});
return applicationArchives;
}
async function getArtifactsSizes(artifacts: string[]): Promise<Record<string, number | undefined>> {
+1 -26
View File
@@ -37,11 +37,9 @@ const CONNECTION_STARTUP_TIMEOUT_MS = 60_000;
const PROCESS_EXIT_TIMEOUT_MS = 5_000;
const CLIENT_COUNT_READ_ATTEMPTS = 4;
const CLIENT_COUNT_READ_RETRY_MS = 500;
const DEFAULT_SSH_PORT = '22';
export type SshConnectionConfig = {
type: 'upterm-v1';
host: string;
secret: string;
};
@@ -55,30 +53,10 @@ export type UptermHost = {
const UptermSessionJsonZ = z.object({
sessionId: z.string().min(1),
host: z.string().min(1),
clientCount: z.number().optional(),
});
type UptermSessionJson = z.infer<typeof UptermSessionJsonZ>;
export function connectionConfigFromUptermSession(
parsed: Pick<UptermSessionJson, 'sessionId' | 'host'>
): SshConnectionConfig | null {
let host = parsed.host;
if (host.includes('://')) {
let url: URL;
try {
url = new URL(host);
} catch {
return null;
}
host = url.port && url.port !== DEFAULT_SSH_PORT ? `${url.hostname}:${url.port}` : url.hostname;
}
if (!host) {
return null;
}
return { type: 'upterm-v1', host, secret: parsed.sessionId };
}
/**
* Strip upterm session secrets out of text before it reaches a log or an error message. upterm
* prints the session id as the userinfo of the connect line it advertises
@@ -196,10 +174,7 @@ async function waitForConnectionConfigAsync(
if (adminSocketPath) {
const session = await readCurrentSessionJsonAsync(uptermPath, adminSocketPath);
if (session) {
const connectionConfig = connectionConfigFromUptermSession(session);
if (connectionConfig) {
return connectionConfig;
}
return { type: 'upterm-v1', secret: session.sessionId };
}
}
if (Date.now() >= deadline) {
@@ -0,0 +1,39 @@
import { type BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs/promises';
import {
PackageManager,
resolveConfiguredPackageManager,
resolvePackageExec,
} from './packageManager';
/**
* Runs a Simulator launcher command with the version that `start` recorded, which warmup cached.
* The output can contain credentials, so it is returned and never logged.
*/
export async function runSimulatorLauncherAsync(
launcherArgs: string[],
{ env, signal }: { env: BuildStepEnv; signal?: AbortSignal }
): Promise<string> {
const version = await fs
.readFile('/tmp/expo-simulator-startup.json', 'utf8')
.then(text => JSON.parse(text).launcherVersion)
.catch(() => undefined);
const { command, args } = resolvePackageExec(
resolveConfiguredPackageManager(env, PackageManager.BUN),
[
`@expo/simulator-launcher@${typeof version === 'string' ? version : 'latest'}`,
...launcherArgs,
]
);
const { stdout } = await spawn(command, args, { env, stdio: 'pipe', signal, timeout: 425_000 });
return stdout;
}
/** Stops warmup; resolves only after the launcher confirms cleanup. */
export async function stopWarmSimulatorAsync(env: BuildStepEnv): Promise<void> {
if (process.platform === 'darwin') {
await runSimulatorLauncherAsync(['stop'], { env });
}
}
@@ -0,0 +1,61 @@
import { SystemError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { existsSync } from 'node:fs';
import fs from 'node:fs/promises';
import { setTimeout as sleepAsync } from 'node:timers/promises';
const LAUNCHER_STATE_FILE = '/tmp/expo-simulator-startup.json';
const STARTUP_SCRIPT = '/usr/local/share/vm-tools/startup.sh';
/**
* The worker stops warmup in the background for a job that does not use the warm Simulator. Wait
* here, before the job changes a Simulator, until the launcher records that its cleanup finished.
*/
export async function waitForWarmSimulatorCleanupAsync(
env: NodeJS.ProcessEnv,
{
timeoutMs = 150_000,
pollMs = 250,
signal,
}: { timeoutMs?: number; pollMs?: number; signal?: AbortSignal } = {}
): Promise<void> {
if (process.platform !== 'darwin' || env.EAS_USE_WARM_SIMULATOR === '1') {
return;
}
const deadline = Date.now() + timeoutMs;
let bootUuid: string | undefined;
for (;;) {
signal?.throwIfAborted();
let state: { status?: unknown; error?: unknown; bootUuid?: unknown };
try {
state = JSON.parse(await fs.readFile(LAUNCHER_STATE_FILE, 'utf8'));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT' && !existsSync(STARTUP_SCRIPT)) {
return;
}
// Warmup may still be loading; only disabled confirms cleanup on an enabled VM.
state = {};
}
if (state.status === 'disabled' && typeof state.bootUuid === 'string') {
bootUuid ??= (
await spawn('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid'], {
signal,
timeout: Math.max(1, deadline - Date.now()),
})
).stdout.trim();
if (!bootUuid) {
throw new SystemError('Could not read the current VM boot ID.');
}
if (state.bootUuid === bootUuid) {
return;
}
}
if (Date.now() >= deadline) {
throw new SystemError(
`Warm Simulator cleanup did not finish${typeof state.error === 'string' ? ` (${state.error})` : ''}; the Simulator cannot be used.`
);
}
// The signal is checked at each poll, so a cancelled job stops within one interval.
await sleepAsync(pollMs);
}
}
@@ -1,5 +1,21 @@
# create-eas-build-function
## 27.4.1
No changes in this release.
## 27.4.0
No changes in this release.
## 27.3.0
No changes in this release.
## 27.2.0
No changes in this release.
## 27.1.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "27.1.0",
"version": "27.4.1",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "27.1.0"
"@expo/steps": "27.4.1"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+16
View File
@@ -1,5 +1,21 @@
# @expo/downloader
## 27.4.1
No changes in this release.
## 27.4.0
No changes in this release.
## 27.3.0
No changes in this release.
## 27.2.0
No changes in this release.
## 27.1.0
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "27.1.0",
"version": "27.4.1",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -1,5 +1,21 @@
# eas-build-cache-provider
## 27.4.1
No changes in this release.
## 27.4.0
No changes in this release.
## 27.3.0
No changes in this release.
## 27.2.0
No changes in this release.
## 27.1.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "eas-build-cache-provider",
"version": "27.1.0",
"version": "27.4.1",
"description": "A build cache provider plugin for the Expo CLI",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
+18
View File
@@ -1,5 +1,23 @@
# @expo/eas-build-job
## 27.4.1
No changes in this release.
## 27.4.0
No changes in this release.
## 27.3.0
### Minor Changes
- Add optional Observe error context to workflow jobs. ([#4787](https://github.com/expo/eas-cli/pull/4787) by [@AbbanMustafa](https://github.com/AbbanMustafa))
## 27.2.0
No changes in this release.
## 27.1.0
No changes in this release.
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "27.1.0",
"version": "27.4.1",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -25,9 +25,9 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "27.1.0",
"@expo/logger": "27.4.1",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "27.1.0",
"@expo/turtle-spawn": "27.4.1",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
@@ -0,0 +1,95 @@
import { ArchiveSourceType, BuildTrigger, StaticWorkflowInterpolationContextZ } from '../common';
import { Generic } from '../generic';
function createContext() {
return {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'Investigate error',
filename: 'observe.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: { id: '123e4567-e89b-42d3-a456-426614174000', slug: 'example' },
account: { id: 'account-id', name: 'example' },
observe: {
event: {
id: '123e4567-e89b-42d3-a456-426614174001',
type: 'new_error' as const,
occurredAt: '2026-10-29T12:00:00.000Z',
appId: '123e4567-e89b-42d3-a456-426614174000',
fingerprint: { value: 'error-fingerprint', version: 'v1' },
severity: 'fatal' as const,
environment: 'production',
platform: 'ios' as const,
deviceOs: 'iOS',
exception: { type: 'Error', message: 'Example error' },
},
},
};
}
describe('Observe workflow context', () => {
it('preserves the event when validating a generic job', () => {
const context = createContext();
const job = {
projectArchive: { type: ArchiveSourceType.GIT, gitCommitHash: 'abc123', gitRef: null },
steps: [{ run: 'echo "${{ observe.event.exception.message }}"', shell: 'sh' }],
secrets: { robotAccessToken: 'token', environmentSecrets: [] },
expoDevUrl: context.workflow.url,
builderEnvironment: { image: 'ubuntu', env: {} },
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
appId: context.app.id,
initiatingUserId: '123e4567-e89b-42d3-a456-426614174002',
workflowInterpolationContext: context,
};
const parsed = Generic.JobZ.parse(job);
expect(parsed.workflowInterpolationContext).toEqual(context);
});
it('accepts nullable event metadata and an omitted device OS', () => {
const context = createContext();
const nullableContext = {
...context,
observe: {
event: {
...context.observe.event,
fingerprint: { value: 'error-fingerprint', version: null },
severity: 'error',
environment: null,
platform: null,
deviceOs: undefined,
exception: { type: null, message: null },
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(nullableContext)).toEqual(nullableContext);
});
it('preserves additional event fields from a newer producer', () => {
const context = createContext();
const extendedContext = {
...context,
observe: {
...context.observe,
event: {
...context.observe.event,
additionalMetadata: 'future value',
exception: { ...context.observe.event.exception, stacktrace: 'future stack trace' },
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(extendedContext)).toEqual(extendedContext);
});
it('rejects an Observe context without an event', () => {
expect(
StaticWorkflowInterpolationContextZ.safeParse({ ...createContext(), observe: {} }).success
).toBe(false);
});
});
+19
View File
@@ -344,6 +344,24 @@ const SentryContextZ = z.looseObject({
resolution_type: z.string().optional(),
});
const ObserveContextZ = z.looseObject({
event: z.looseObject({
id: z.uuid(),
type: z.literal('new_error'),
occurredAt: z.iso.datetime(),
appId: z.uuid(),
fingerprint: z.looseObject({
value: z.string().min(1),
version: z.string().min(1).nullable(),
}),
severity: z.enum(['fatal', 'error']),
environment: z.string().nullable(),
platform: z.enum(['ios', 'android']).nullable(),
deviceOs: z.string().optional(),
exception: z.looseObject({ type: z.string().nullable(), message: z.string().nullable() }),
}),
});
export const StaticWorkflowInterpolationContextZ = z.object({
after: z.record(
z.string(),
@@ -381,6 +399,7 @@ export const StaticWorkflowInterpolationContextZ = z.object({
app_store_connect: AppStoreConnectContextZ.optional(),
// We need to .optional() to support jobs that are not triggered by a Sentry event.
sentry: SentryContextZ.optional(),
observe: ObserveContextZ.optional(),
});
export type StaticWorkflowInterpolationContext = z.infer<
+29
View File
@@ -1,5 +1,34 @@
# eas-cli
## 27.4.1
No changes in this release.
## 27.4.0
### Patch Changes
- 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))
## 27.3.0
### Patch Changes
- 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))
- 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))
## 27.2.0
### Minor Changes
- 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))
- 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))
- 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))
### Patch Changes
- 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))
## 27.1.0
### Minor Changes
+221 -146
View File
File diff suppressed because it is too large. Load diff
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "27.1.0",
"version": "27.4.1",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -44,16 +44,16 @@
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
},
"dependencies": {
"@expo/apple-utils": "2.2.1",
"@expo/apple-utils": "2.3.0",
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "27.1.0",
"@expo/eas-json": "27.1.0",
"@expo/eas-build-job": "27.4.1",
"@expo/eas-json": "27.4.1",
"@expo/env": "^1.0.0",
"@expo/experimentation": "0.0.5",
"@expo/json-file": "8.3.3",
"@expo/logger": "27.1.0",
"@expo/logger": "27.4.1",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -65,7 +65,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "27.1.0",
"@expo/steps": "27.4.1",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -70,6 +70,8 @@ export enum BuildEvent {
BUILD_REQUEST_SUCCESS = 'build cli build request success',
BUILD_REQUEST_FAIL = 'build cli build request fail',
FREE_TIER_LIMIT_CHECKOUT_PROMPT = 'build cli free tier limit checkout prompt',
FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP = 'build cli free tier near limit agent updates tip',
SIMULATORS_LAUNCH_HINT_SHOWN = 'build cli simulators launch hint shown',
BUILD_STATUS_COMMAND = 'build cli build status',
@@ -1,280 +0,0 @@
import type { ExperimentationDefinition } from '@expo/experimentation';
import { Platform } from '@expo/eas-build-job';
import { getAgentTelemetryContext } from '../../analytics/agent';
import { ExperimentOverrides } from '../../experimentation/ExperimentOverrides';
import {
EMPTY_EXPERIMENTATION_DEFINITION,
ExperimentationClient,
createLoggerConfig,
} from '../../experimentation/ExperimentationClient';
import { LazyExperimentationClient } from '../../experimentation/LazyExperimentationClient';
import { createMockAnalytics, findUnitsByValue } from '../../experimentation/__tests__/fixtures';
import {
FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE,
getFreeTierLimitAgentGuidanceExperiment,
maybeAppendFreeTierLimitAgentGuidanceAsync,
resolveFreeTierLimitAgentGuidanceVariantAsync,
} from '../freeTierLimitGuidance';
jest.mock('../../analytics/agent');
const EXPERIMENT_NAME = 'cli-build-free-tier-limit-agent-guidance';
/** Every account is in the experiment: 50% control, 50% treatment. */
const ACCOUNT_DEFINITION: ExperimentationDefinition = {
experiments: [
{
experimentName: EXPERIMENT_NAME,
paramDefinitions: {
variant: { type: 'uniform_choice', choices: ['control', 'treatment'] },
},
exposureLoggerType: 'rudderstack',
},
],
namespaces: [
{
name: FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE,
numSegments: 1000,
segmentExperimentSetupDefinitions: [
{
method: 'ADD',
name: `${EXPERIMENT_NAME} full`,
experimentName: EXPERIMENT_NAME,
numSegments: 1000,
},
],
},
],
};
function createClient(): {
client: ExperimentationClient;
analytics: ReturnType<typeof createMockAnalytics>;
} {
const analytics = createMockAnalytics();
const client = new ExperimentationClient(
{
user: EMPTY_EXPERIMENTATION_DEFINITION,
account: ACCOUNT_DEFINITION,
device: EMPTY_EXPERIMENTATION_DEFINITION,
},
{ user: null, device: null },
ExperimentOverrides.empty(),
createLoggerConfig(analytics)
);
return { client, analytics };
}
/** Probes a throwaway client, so the exposures it logs do not affect the client under test. */
function findAccountIdsByVariant(): Map<string, string> {
const { client } = createClient();
return findUnitsByValue(
accountId =>
client.getAccountNamespace(FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE, {
accountId,
}),
'variant',
'control',
['control', 'treatment']
);
}
describe(getFreeTierLimitAgentGuidanceExperiment, () => {
it('returns the handle when the command has an experimentation client', () => {
const experimentation = new LazyExperimentationClient(async () => createClient().client);
expect(
getFreeTierLimitAgentGuidanceExperiment({ experimentation, accountId: 'account-id' })
).toEqual({ experimentation, accountId: 'account-id' });
});
it('returns undefined when the command has no experimentation client', () => {
expect(getFreeTierLimitAgentGuidanceExperiment({ accountId: 'account-id' })).toBeUndefined();
});
});
describe(resolveFreeTierLimitAgentGuidanceVariantAsync, () => {
const accountIdsByVariant = findAccountIdsByVariant();
const treatmentAccountId = accountIdsByVariant.get('treatment')!;
const controlAccountId = accountIdsByVariant.get('control')!;
beforeEach(() => {
jest.resetAllMocks();
jest
.mocked(getAgentTelemetryContext)
.mockReturnValue({ id: 'claude-code', sessionId: 'session-1' });
});
it('does not load the client when no coding agent runs the CLI', async () => {
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
const loadAsync = jest.fn(async () => createClient().client);
expect(
await resolveFreeTierLimitAgentGuidanceVariantAsync({
experimentation: new LazyExperimentationClient(loadAsync),
accountId: treatmentAccountId,
})
).toBe('control');
expect(loadAsync).not.toHaveBeenCalled();
});
it('loads the client once across repeated reads', async () => {
const { client, analytics } = createClient();
const loadAsync = jest.fn(async () => client);
const experiment = {
experimentation: new LazyExperimentationClient(loadAsync),
accountId: treatmentAccountId,
};
await resolveFreeTierLimitAgentGuidanceVariantAsync(experiment);
await resolveFreeTierLimitAgentGuidanceVariantAsync(experiment);
expect(loadAsync).toHaveBeenCalledTimes(1);
expect(analytics.logEvent).toHaveBeenCalledTimes(1);
});
it('returns control and logs no exposure when no coding agent runs the CLI', async () => {
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
const { client, analytics } = createClient();
expect(
await resolveFreeTierLimitAgentGuidanceVariantAsync({
experimentation: LazyExperimentationClient.fromClient(client),
accountId: treatmentAccountId,
})
).toBe('control');
expect(analytics.logEvent).not.toHaveBeenCalled();
});
it('returns the account variant and logs the exposure once when a coding agent runs the CLI', async () => {
const { client, analytics } = createClient();
expect(
await resolveFreeTierLimitAgentGuidanceVariantAsync({
experimentation: LazyExperimentationClient.fromClient(client),
accountId: treatmentAccountId,
})
).toBe('treatment');
expect(
await resolveFreeTierLimitAgentGuidanceVariantAsync({
experimentation: LazyExperimentationClient.fromClient(client),
accountId: treatmentAccountId,
})
).toBe('treatment');
expect(analytics.logEvent).toHaveBeenCalledTimes(1);
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
experimentName: EXPERIMENT_NAME,
variationName: 'treatment',
unit: treatmentAccountId,
});
});
it('logs the exposure for control accounts too', async () => {
const { client, analytics } = createClient();
expect(
await resolveFreeTierLimitAgentGuidanceVariantAsync({
experimentation: LazyExperimentationClient.fromClient(client),
accountId: controlAccountId,
})
).toBe('control');
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
experimentName: EXPERIMENT_NAME,
variationName: 'control',
unit: controlAccountId,
});
});
it('returns control and logs no exposure when the namespace is missing from the config', async () => {
const analytics = createMockAnalytics();
const client = new ExperimentationClient(
{
user: EMPTY_EXPERIMENTATION_DEFINITION,
account: EMPTY_EXPERIMENTATION_DEFINITION,
device: EMPTY_EXPERIMENTATION_DEFINITION,
},
{ user: null, device: null },
ExperimentOverrides.empty(),
createLoggerConfig(analytics)
);
expect(
await resolveFreeTierLimitAgentGuidanceVariantAsync({
experimentation: LazyExperimentationClient.fromClient(client),
accountId: treatmentAccountId,
})
).toBe('control');
expect(analytics.logEvent).not.toHaveBeenCalled();
});
});
describe(maybeAppendFreeTierLimitAgentGuidanceAsync, () => {
const treatmentAccountId = findAccountIdsByVariant().get('treatment')!;
beforeEach(() => {
jest.resetAllMocks();
jest
.mocked(getAgentTelemetryContext)
.mockReturnValue({ id: 'claude-code', sessionId: 'session-1' });
});
it('returns the message unchanged and logs no exposure when the command has no experimentation client', async () => {
expect(await maybeAppendFreeTierLimitAgentGuidanceAsync('Error 1', Platform.ANDROID, undefined)).toBe(
'Error 1'
);
});
it('logs the exposure and appends the guidance in the treatment arm', async () => {
const { client, analytics } = createClient();
expect(
await maybeAppendFreeTierLimitAgentGuidanceAsync('Error 1', Platform.IOS, {
experimentation: LazyExperimentationClient.fromClient(client),
accountId: treatmentAccountId,
})
).toBe(
[
'Error 1',
'Fix: Human required: iOS builds for this account are blocked until the Free plan resets. Upgrading costs money, so an account owner or admin decides.',
].join('\n')
);
expect(analytics.logEvent).toHaveBeenCalledTimes(1);
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
experimentName: EXPERIMENT_NAME,
variationName: 'treatment',
unit: treatmentAccountId,
});
});
it('returns the message unchanged in the control arm', async () => {
const client = ExperimentationClient.createDisabled(
new ExperimentOverrides(
new Map([
[FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE, new Map([['variant', 'control']])],
])
)
);
expect(
await maybeAppendFreeTierLimitAgentGuidanceAsync('Error 1', Platform.ANDROID, {
experimentation: LazyExperimentationClient.fromClient(client),
accountId: treatmentAccountId,
})
).toBe('Error 1');
});
it('returns the message unchanged and logs no exposure when no coding agent runs the CLI', async () => {
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
const { client, analytics } = createClient();
expect(
await maybeAppendFreeTierLimitAgentGuidanceAsync('Error 1', Platform.ANDROID, {
experimentation: LazyExperimentationClient.fromClient(client),
accountId: treatmentAccountId,
})
).toBe('Error 1');
expect(analytics.logEvent).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,880 @@
import type { ExperimentationDefinition } from '@expo/experimentation';
import { ExpoConfig } from '@expo/config';
import { getAgentTelemetryContext } from '../../analytics/agent';
import { createGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { ExperimentOverrides } from '../../experimentation/ExperimentOverrides';
import {
EMPTY_EXPERIMENTATION_DEFINITION,
ExperimentationClient,
createLoggerConfig,
} from '../../experimentation/ExperimentationClient';
import { LazyExperimentationClient } from '../../experimentation/LazyExperimentationClient';
import { createMockAnalytics, findUnitsByValue } from '../../experimentation/__tests__/fixtures';
import {
AppPlatform,
BuildFragment,
BuildStatus,
EasServiceMetric,
UsageMetricType,
} from '../../graphql/generated';
import { UserPreferenceMutation } from '../../graphql/mutations/UserPreferenceMutation';
import { AccountQuery } from '../../graphql/queries/AccountQuery';
import { UserPreferenceQuery } from '../../graphql/queries/UserPreferenceQuery';
import Log from '../../log';
import { isExpoUpdatesInstalled, isUsingEASUpdate } from '../../project/projectUtils';
import { LoggedInAuthenticationInfo } from '../../user/SessionManager';
import { Actor } from '../../user/User';
import {
FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP_EXPERIMENT_NAMESPACE,
FreeTierNearLimitAgentUpdatesTipExperiment,
FreeTierNearLimitAgentUpdatesTipPlatformProject,
formatFreeTierNearLimitAgentUpdatesTip,
getFreeTierBuildUsageAsync,
getFreeTierNearLimitAgentUpdatesTipExperiment,
getFreeTierNearLimitAgentUpdatesTipPreferenceKey,
isEligibleForFreeTierNearLimitAgentUpdatesTip,
isPlatformEligibleForFreeTierNearLimitAgentUpdatesTip,
isWithinFreeTierNearLimitBand,
maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync,
resolveFreeTierNearLimitAgentUpdatesTipVariantAsync,
} from '../freeTierNearLimitAgentUpdatesTip';
jest.mock('../../analytics/agent');
jest.mock('../../commandUtils/context/contextUtils/createGraphqlClient');
jest.mock('../../graphql/queries/AccountQuery');
jest.mock('../../graphql/queries/UserPreferenceQuery');
jest.mock('../../graphql/mutations/UserPreferenceMutation');
jest.mock('../../project/projectUtils', () => ({
...jest.requireActual('../../project/projectUtils'),
isExpoUpdatesInstalled: jest.fn(),
isUsingEASUpdate: jest.fn(),
}));
const EXPERIMENT_NAME = 'cli-build-free-tier-near-limit-agent-updates-tip';
const ANALYTICS_EVENT_PROPERTIES = { tracking_id: 'tracking-id' };
const EXP = { name: 'app', slug: 'app' } as ExpoConfig;
const IOS_EXP = { name: 'app', slug: 'app', ios: { bundleIdentifier: 'com.app' } } as ExpoConfig;
const USER = { __typename: 'User', id: 'user-id' } as Actor;
const SSO_USER = { __typename: 'SSOUser', id: 'sso-user-id' } as Actor;
const ROBOT = { __typename: 'Robot', id: 'robot-id' } as Actor;
const PARTNER = { __typename: 'PartnerActor', id: 'partner-id' } as Actor;
const AUTHENTICATION_INFO: LoggedInAuthenticationInfo = {
accessToken: 'access-token',
sessionSecret: null,
};
const GRAPHQL_CLIENT = {} as FreeTierNearLimitAgentUpdatesTipExperiment['graphqlClient'];
const ANDROID_KEY = 'free-tier-near-limit-agent-updates-tip-shown/account-id/android';
const IOS_KEY = 'free-tier-near-limit-agent-updates-tip-shown/account-id/ios';
const ANDROID_TIP_11_OF_15 = [
"Tip: this account has used 11 of 15 Android builds on the Free plan this period. This project isn't set up for Expo Update, so every change needs a new build.",
'With Expo Update, JavaScript, styling and image changes reach installed builds without a new build. Setup takes effect from the next build, so it saves the most builds if done now.',
'Next: eas update:configure --non-interactive, then build once more.',
'Tell the user: this adds expo-updates and changes app.json and eas.json.',
'Docs: https://docs.expo.dev/eas-update/getting-started/',
].join('\n');
function accountDefinition(enrolledSegments: number): ExperimentationDefinition {
return {
experiments: [
{
experimentName: EXPERIMENT_NAME,
paramDefinitions: {
variant: { type: 'uniform_choice', choices: ['control', 'treatment'] },
},
exposureLoggerType: 'rudderstack',
},
],
namespaces: [
{
name: FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP_EXPERIMENT_NAMESPACE,
numSegments: 1000,
segmentExperimentSetupDefinitions: [
{
method: 'ADD',
name: `${EXPERIMENT_NAME} rollout`,
experimentName: EXPERIMENT_NAME,
numSegments: enrolledSegments,
},
],
},
],
};
}
/** Every account is in the experiment: 50% control, 50% treatment. */
const ACCOUNT_DEFINITION = accountDefinition(1000);
/** Like the production soft launch: 20% of accounts are in the experiment. */
const PARTIAL_ROLLOUT_DEFINITION = accountDefinition(200);
function createClient(definition: ExperimentationDefinition = ACCOUNT_DEFINITION): {
client: ExperimentationClient;
analytics: ReturnType<typeof createMockAnalytics>;
} {
const analytics = createMockAnalytics();
const client = new ExperimentationClient(
{
user: EMPTY_EXPERIMENTATION_DEFINITION,
account: definition,
device: EMPTY_EXPERIMENTATION_DEFINITION,
},
{ user: null, device: null },
ExperimentOverrides.empty(),
createLoggerConfig(analytics)
);
return { client, analytics };
}
/**
* Probes a throwaway client, so the exposures it logs do not affect the client under test. The
* probe uses a null default, so accounts outside the rollout can be told apart from control.
*/
function findAccountIdsByVariant(
definition: ExperimentationDefinition,
variants: (string | null)[]
): Map<string | null, string> {
const { client } = createClient(definition);
return findUnitsByValue(
accountId =>
client.getAccountNamespace(FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP_EXPERIMENT_NAMESPACE, {
accountId,
}),
'variant',
null,
variants
);
}
function createOverriddenClient(variant: string): ExperimentationClient {
return ExperimentationClient.createDisabled(
new ExperimentOverrides(
new Map([
[FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP_EXPERIMENT_NAMESPACE, new Map([['variant', variant]])],
])
)
);
}
function project(
overrides: Partial<FreeTierNearLimitAgentUpdatesTipPlatformProject> = {}
): FreeTierNearLimitAgentUpdatesTipPlatformProject {
return {
developmentClient: false,
projectDir: '/app',
projectId: 'project-id',
exp: EXP,
manifestHostOverride: null,
...overrides,
};
}
function createExperiment(
accountId: string,
{
client = createClient().client,
runFromCI = false,
json = false,
isUser = true,
analytics = createMockAnalytics(),
projectByPlatform = { [AppPlatform.Android]: project(), [AppPlatform.Ios]: project() },
}: {
client?: ExperimentationClient;
runFromCI?: boolean;
json?: boolean;
isUser?: boolean;
analytics?: ReturnType<typeof createMockAnalytics>;
projectByPlatform?: FreeTierNearLimitAgentUpdatesTipExperiment['projectByPlatform'];
} = {}
): FreeTierNearLimitAgentUpdatesTipExperiment {
return {
experimentation: LazyExperimentationClient.fromClient(client),
graphqlClient: GRAPHQL_CLIENT,
analytics,
analyticsEventProperties: ANALYTICS_EVENT_PROPERTIES,
accountId,
isUser,
runFromCI,
json,
projectByPlatform,
};
}
function build(platform: AppPlatform, status: BuildStatus = BuildStatus.Finished): BuildFragment {
return { id: `${platform}-build`, platform, status } as BuildFragment;
}
type PlatformUsage = { value: number; limit: number };
function usageResponse({
subscriptionName = 'Free' as string | null,
android = { value: 11, limit: 15 } as PlatformUsage,
ios = { value: 3, limit: 15 } as PlatformUsage,
billingPeriodId = 'period-1',
platformBreakdown = true,
} = {}): Awaited<ReturnType<typeof AccountQuery.getFreeTierBuildUsageAsync>> {
return {
id: 'account-id',
subscription: subscriptionName === null ? null : { id: 'subscription-id', name: subscriptionName },
billingPeriod: {
id: billingPeriodId,
start: '2026-10-01T00:00:00.000Z',
end: '2026-11-01T00:00:00.000Z',
},
usageMetrics: {
EAS_BUILD: {
id: 'eas-build',
planMetrics: [
{
id: 'builds',
serviceMetric: EasServiceMetric.Builds,
metricType: UsageMetricType.Build,
value: android.value + ios.value,
limit: android.limit + ios.limit,
platformBreakdown: platformBreakdown ? { android, ios } : null,
},
],
},
},
};
}
const accountIdsByVariant = findAccountIdsByVariant(ACCOUNT_DEFINITION, ['control', 'treatment']);
const treatmentAccountId = accountIdsByVariant.get('treatment')!;
const controlAccountId = accountIdsByVariant.get('control')!;
/** An account outside the partial rollout. */
const notEnrolledAccountId = findAccountIdsByVariant(PARTIAL_ROLLOUT_DEFINITION, [null]).get(null)!;
/** In-memory stand-in for the server-side user preference store, keyed by preference key. */
let shownTips: Record<string, unknown>;
let logSpy: jest.SpyInstance;
beforeEach(() => {
jest.resetAllMocks();
shownTips = {};
jest
.mocked(UserPreferenceQuery.getValueAsync)
.mockImplementation(async (_graphqlClient, key) => shownTips[key] ?? null);
jest
.mocked(UserPreferenceMutation.setAsync)
.mockImplementation(async (_graphqlClient, key, value) => {
shownTips[key] = value;
});
jest.mocked(getAgentTelemetryContext).mockReturnValue({ id: 'claude-code', sessionId: 's-1' });
jest.mocked(createGraphqlClient).mockReturnValue(GRAPHQL_CLIENT);
jest.mocked(isExpoUpdatesInstalled).mockReturnValue(false);
jest.mocked(isUsingEASUpdate).mockReturnValue(false);
jest.mocked(AccountQuery.getFreeTierBuildUsageAsync).mockResolvedValue(usageResponse());
logSpy = jest.spyOn(Log, 'log').mockImplementation(() => {});
});
describe(getFreeTierNearLimitAgentUpdatesTipExperiment, () => {
const ctx = {
authenticationInfo: AUTHENTICATION_INFO,
analytics: createMockAnalytics(),
analyticsEventProperties: ANALYTICS_EVENT_PROPERTIES,
accountId: 'account-id',
user: USER,
developmentClient: false,
projectDir: '/app',
projectId: 'project-id',
exp: EXP,
easJsonCliConfig: { updateManifestHostOverride: 'https://u.example.com' },
runFromCI: false,
};
it('returns the handle when the command has an experimentation client', () => {
const experimentation = new LazyExperimentationClient(async () => createClient().client);
expect(
getFreeTierNearLimitAgentUpdatesTipExperiment(
{ [AppPlatform.Android]: { ...ctx, experimentation } },
{ json: true }
)
).toEqual({
experimentation,
graphqlClient: GRAPHQL_CLIENT,
analytics: ctx.analytics,
analyticsEventProperties: ANALYTICS_EVENT_PROPERTIES,
accountId: 'account-id',
isUser: true,
runFromCI: false,
json: true,
projectByPlatform: {
[AppPlatform.Android]: {
developmentClient: false,
projectDir: '/app',
projectId: 'project-id',
exp: EXP,
manifestHostOverride: 'https://u.example.com',
},
},
});
});
it('keeps the project state of each platform', () => {
const experimentation = new LazyExperimentationClient(async () => createClient().client);
expect(
getFreeTierNearLimitAgentUpdatesTipExperiment(
{
[AppPlatform.Android]: { ...ctx, experimentation },
[AppPlatform.Ios]: {
...ctx,
experimentation,
exp: IOS_EXP,
developmentClient: true,
easJsonCliConfig: undefined,
},
},
{ json: false }
)?.projectByPlatform
).toEqual({
[AppPlatform.Android]: project({ manifestHostOverride: 'https://u.example.com' }),
[AppPlatform.Ios]: project({ exp: IOS_EXP, developmentClient: true }),
});
});
it('creates one GraphQL client with a request timeout', () => {
const experimentation = new LazyExperimentationClient(async () => createClient().client);
getFreeTierNearLimitAgentUpdatesTipExperiment(
{
[AppPlatform.Android]: { ...ctx, experimentation },
[AppPlatform.Ios]: { ...ctx, experimentation },
},
{ json: false }
);
expect(createGraphqlClient).toHaveBeenCalledTimes(1);
expect(createGraphqlClient).toHaveBeenCalledWith(AUTHENTICATION_INFO, {
requestTimeoutMs: 3000,
});
});
it.each([
['a regular user', USER, true],
['an SSO user', SSO_USER, true],
['a robot', ROBOT, false],
['a partner actor', PARTNER, false],
])('marks %s with isUser %p', (_label, user, isUser) => {
const experimentation = new LazyExperimentationClient(async () => createClient().client);
expect(
getFreeTierNearLimitAgentUpdatesTipExperiment(
{ [AppPlatform.Android]: { ...ctx, user, experimentation } },
{ json: false }
)?.isUser
).toBe(isUser);
});
it('returns undefined when the command has no experimentation client', () => {
expect(
getFreeTierNearLimitAgentUpdatesTipExperiment({ [AppPlatform.Android]: ctx }, { json: false })
).toBeUndefined();
});
it('returns undefined when the command has no authentication info', () => {
const experimentation = new LazyExperimentationClient(async () => createClient().client);
expect(
getFreeTierNearLimitAgentUpdatesTipExperiment(
{ [AppPlatform.Android]: { ...ctx, authenticationInfo: undefined, experimentation } },
{ json: false }
)
).toBeUndefined();
expect(createGraphqlClient).not.toHaveBeenCalled();
});
it('returns undefined without build contexts', () => {
expect(getFreeTierNearLimitAgentUpdatesTipExperiment({}, { json: false })).toBeUndefined();
});
});
describe(isEligibleForFreeTierNearLimitAgentUpdatesTip, () => {
it('accepts agent runs', () => {
expect(isEligibleForFreeTierNearLimitAgentUpdatesTip(createExperiment('account-id'))).toBe(true);
});
it('rejects runs without a coding agent', () => {
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
expect(isEligibleForFreeTierNearLimitAgentUpdatesTip(createExperiment('account-id'))).toBe(false);
});
it('rejects runs from CI', () => {
expect(
isEligibleForFreeTierNearLimitAgentUpdatesTip(createExperiment('account-id', { runFromCI: true }))
).toBe(false);
});
it('rejects robot and partner actors, which cannot store user preferences', () => {
expect(
isEligibleForFreeTierNearLimitAgentUpdatesTip(createExperiment('account-id', { isUser: false }))
).toBe(false);
});
it('rejects --json runs', () => {
expect(
isEligibleForFreeTierNearLimitAgentUpdatesTip(createExperiment('account-id', { json: true }))
).toBe(false);
});
});
describe(isPlatformEligibleForFreeTierNearLimitAgentUpdatesTip, () => {
it('accepts a project without Expo Update', () => {
expect(isPlatformEligibleForFreeTierNearLimitAgentUpdatesTip(project())).toBe(true);
});
it('accepts a project with expo-updates installed but no Expo Update URL', () => {
jest.mocked(isExpoUpdatesInstalled).mockReturnValue(true);
expect(isPlatformEligibleForFreeTierNearLimitAgentUpdatesTip(project())).toBe(true);
});
it('rejects a project that is already set up for Expo Update', () => {
jest.mocked(isExpoUpdatesInstalled).mockReturnValue(true);
jest.mocked(isUsingEASUpdate).mockReturnValue(true);
expect(isPlatformEligibleForFreeTierNearLimitAgentUpdatesTip(project())).toBe(false);
expect(isUsingEASUpdate).toHaveBeenCalledWith(EXP, 'project-id', null);
});
it('rejects development builds, which load JavaScript from the dev server', () => {
expect(
isPlatformEligibleForFreeTierNearLimitAgentUpdatesTip(project({ developmentClient: true }))
).toBe(false);
});
});
describe(isWithinFreeTierNearLimitBand, () => {
it.each([
[9, false],
[10, true],
[11, true],
[14, true],
[15, false],
[16, false],
])('used %s of 15 → %s', (used, expected) => {
expect(isWithinFreeTierNearLimitBand({ used, limit: 15 })).toBe(expected);
});
it('is false without a limit', () => {
expect(isWithinFreeTierNearLimitBand({ used: 0, limit: 0 })).toBe(false);
});
});
describe(getFreeTierBuildUsageAsync, () => {
it('returns the per-platform usage for a Free account', async () => {
expect(await getFreeTierBuildUsageAsync(createExperiment('account-id'))).toEqual({
billingPeriodId: 'period-1',
byPlatform: {
[AppPlatform.Android]: { used: 11, limit: 15 },
[AppPlatform.Ios]: { used: 3, limit: 15 },
},
});
expect(AccountQuery.getFreeTierBuildUsageAsync).toHaveBeenCalledWith(
expect.anything(),
'account-id',
expect.any(Date)
);
});
it('treats an account without a subscription as Free', async () => {
jest
.mocked(AccountQuery.getFreeTierBuildUsageAsync)
.mockResolvedValue(usageResponse({ subscriptionName: null }));
expect(await getFreeTierBuildUsageAsync(createExperiment('account-id'))).not.toBeNull();
});
it('returns null for a paid plan', async () => {
jest
.mocked(AccountQuery.getFreeTierBuildUsageAsync)
.mockResolvedValue(usageResponse({ subscriptionName: 'Starter' }));
expect(await getFreeTierBuildUsageAsync(createExperiment('account-id'))).toBeNull();
});
it('returns null without a platform breakdown', async () => {
jest
.mocked(AccountQuery.getFreeTierBuildUsageAsync)
.mockResolvedValue(usageResponse({ platformBreakdown: false }));
expect(await getFreeTierBuildUsageAsync(createExperiment('account-id'))).toBeNull();
});
});
describe(resolveFreeTierNearLimitAgentUpdatesTipVariantAsync, () => {
it('returns the account variant and logs the exposure once', async () => {
const { client, analytics } = createClient();
const experiment = createExperiment(treatmentAccountId, { client });
expect(await resolveFreeTierNearLimitAgentUpdatesTipVariantAsync(experiment)).toBe('treatment');
expect(await resolveFreeTierNearLimitAgentUpdatesTipVariantAsync(experiment)).toBe('treatment');
expect(analytics.logEvent).toHaveBeenCalledTimes(1);
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
experimentName: EXPERIMENT_NAME,
variationName: 'treatment',
unit: treatmentAccountId,
});
});
it('logs the exposure for control accounts too', async () => {
const { client, analytics } = createClient();
expect(
await resolveFreeTierNearLimitAgentUpdatesTipVariantAsync(
createExperiment(controlAccountId, { client })
)
).toBe('control');
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
experimentName: EXPERIMENT_NAME,
variationName: 'control',
unit: controlAccountId,
});
});
it('returns control and logs no exposure for an account outside the rollout', async () => {
const { client, analytics } = createClient(PARTIAL_ROLLOUT_DEFINITION);
expect(
await resolveFreeTierNearLimitAgentUpdatesTipVariantAsync(
createExperiment(notEnrolledAccountId, { client })
)
).toBe('control');
expect(analytics.logEvent).not.toHaveBeenCalled();
});
it('returns control and logs no exposure when the namespace is missing from the config', async () => {
const { client, analytics } = createClient(EMPTY_EXPERIMENTATION_DEFINITION);
expect(
await resolveFreeTierNearLimitAgentUpdatesTipVariantAsync(
createExperiment(treatmentAccountId, { client })
)
).toBe('control');
expect(analytics.logEvent).not.toHaveBeenCalled();
});
});
describe(getFreeTierNearLimitAgentUpdatesTipPreferenceKey, () => {
it('scopes the key to the account and the lowercase platform', () => {
expect(getFreeTierNearLimitAgentUpdatesTipPreferenceKey('account-id', AppPlatform.Android)).toBe(
ANDROID_KEY
);
expect(getFreeTierNearLimitAgentUpdatesTipPreferenceKey('account-id', AppPlatform.Ios)).toBe(
IOS_KEY
);
});
});
describe(formatFreeTierNearLimitAgentUpdatesTip, () => {
it('formats the Android tip', () => {
expect(
formatFreeTierNearLimitAgentUpdatesTip({ platform: AppPlatform.Android, used: 11, limit: 15 })
).toBe(ANDROID_TIP_11_OF_15);
});
it('formats the iOS tip', () => {
expect(
formatFreeTierNearLimitAgentUpdatesTip({ platform: AppPlatform.Ios, used: 14, limit: 15 })
).toContain('Tip: this account has used 14 of 15 iOS builds on the Free plan this period.');
});
});
describe(maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync, () => {
function treatmentExperiment(
options: Parameters<typeof createExperiment>[1] = {}
): FreeTierNearLimitAgentUpdatesTipExperiment {
return createExperiment('account-id', {
client: createOverriddenClient('treatment'),
...options,
});
}
it('does nothing when the command has no experiment handle', async () => {
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync([build(AppPlatform.Android)], undefined);
expect(AccountQuery.getFreeTierBuildUsageAsync).not.toHaveBeenCalled();
expect(logSpy).not.toHaveBeenCalled();
});
it('requests no usage for ineligible runs', async () => {
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
[build(AppPlatform.Android)],
treatmentExperiment()
);
expect(AccountQuery.getFreeTierBuildUsageAsync).not.toHaveBeenCalled();
expect(logSpy).not.toHaveBeenCalled();
});
it('requests no usage when no build finished', async () => {
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
[build(AppPlatform.Android, BuildStatus.Errored), null],
treatmentExperiment()
);
expect(AccountQuery.getFreeTierBuildUsageAsync).not.toHaveBeenCalled();
expect(logSpy).not.toHaveBeenCalled();
});
it('requests no usage when no finished platform is eligible', async () => {
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
[build(AppPlatform.Android)],
treatmentExperiment({
projectByPlatform: { [AppPlatform.Android]: project({ developmentClient: true }) },
})
);
expect(AccountQuery.getFreeTierBuildUsageAsync).not.toHaveBeenCalled();
expect(logSpy).not.toHaveBeenCalled();
});
it('checks each platform against its own build context', async () => {
// Only the iOS build evaluated a config with the Expo Update URL.
jest.mocked(isExpoUpdatesInstalled).mockReturnValue(true);
jest.mocked(isUsingEASUpdate).mockImplementation(exp => exp === IOS_EXP);
jest
.mocked(AccountQuery.getFreeTierBuildUsageAsync)
.mockResolvedValue(usageResponse({ ios: { value: 14, limit: 15 } }));
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
[build(AppPlatform.Android), build(AppPlatform.Ios)],
treatmentExperiment({
projectByPlatform: {
[AppPlatform.Android]: project(),
[AppPlatform.Ios]: project({ exp: IOS_EXP }),
},
})
);
expect(logSpy).toHaveBeenCalledTimes(1);
expect(logSpy).toHaveBeenCalledWith(ANDROID_TIP_11_OF_15);
expect(shownTips).toEqual({ [ANDROID_KEY]: { billingPeriodId: 'period-1' } });
});
it('prints the tip in the treatment arm and remembers it for the billing period', async () => {
const experiment = treatmentExperiment();
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync([build(AppPlatform.Android)], experiment);
expect(logSpy).toHaveBeenCalledWith(ANDROID_TIP_11_OF_15);
expect(shownTips).toEqual({ [ANDROID_KEY]: { billingPeriodId: 'period-1' } });
expect(UserPreferenceMutation.setAsync).toHaveBeenCalledWith(GRAPHQL_CLIENT, ANDROID_KEY, {
billingPeriodId: 'period-1',
});
expect(experiment.analytics.logEvent).toHaveBeenCalledWith(
'build cli free tier near limit agent updates tip',
{
...ANALYTICS_EVENT_PROPERTIES,
platform: AppPlatform.Android,
builds_used: 11,
builds_limit: 15,
}
);
});
it('prints nothing and writes no marker in the control arm, but records the exposure', async () => {
const { client, analytics } = createClient();
const experiment = createExperiment(controlAccountId, { client, analytics });
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync([build(AppPlatform.Android)], experiment);
expect(logSpy).not.toHaveBeenCalled();
expect(shownTips).toEqual({});
expect(UserPreferenceMutation.setAsync).not.toHaveBeenCalled();
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
experimentName: EXPERIMENT_NAME,
variationName: 'control',
unit: controlAccountId,
});
expect(analytics.logEvent).not.toHaveBeenCalledWith(
'build cli free tier near limit agent updates tip',
expect.anything()
);
});
it('does nothing for an account outside the experiment', async () => {
const { client, analytics } = createClient(PARTIAL_ROLLOUT_DEFINITION);
const experiment = createExperiment(notEnrolledAccountId, { client, analytics });
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync([build(AppPlatform.Android)], experiment);
expect(logSpy).not.toHaveBeenCalled();
expect(analytics.logEvent).not.toHaveBeenCalled();
expect(shownTips).toEqual({});
expect(UserPreferenceMutation.setAsync).not.toHaveBeenCalled();
});
it('logs the exposure only after the usage checks pass', async () => {
const { client, analytics } = createClient();
jest
.mocked(AccountQuery.getFreeTierBuildUsageAsync)
.mockResolvedValue(usageResponse({ android: { value: 9, limit: 15 } }));
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
[build(AppPlatform.Android)],
createExperiment(treatmentAccountId, { client, analytics })
);
expect(analytics.logEvent).not.toHaveBeenCalled();
expect(logSpy).not.toHaveBeenCalled();
expect(UserPreferenceQuery.getValueAsync).not.toHaveBeenCalled();
expect(UserPreferenceMutation.setAsync).not.toHaveBeenCalled();
});
it('does nothing for paid plans', async () => {
jest
.mocked(AccountQuery.getFreeTierBuildUsageAsync)
.mockResolvedValue(usageResponse({ subscriptionName: 'Production' }));
const experiment = treatmentExperiment();
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync([build(AppPlatform.Android)], experiment);
expect(logSpy).not.toHaveBeenCalled();
expect(experiment.analytics.logEvent).not.toHaveBeenCalled();
});
it('does not repeat the tip within the same billing period for this user', async () => {
shownTips = { [ANDROID_KEY]: { billingPeriodId: 'period-1' } };
const experiment = treatmentExperiment();
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync([build(AppPlatform.Android)], experiment);
expect(UserPreferenceQuery.getValueAsync).toHaveBeenCalledWith(GRAPHQL_CLIENT, ANDROID_KEY);
expect(logSpy).not.toHaveBeenCalled();
expect(experiment.analytics.logEvent).not.toHaveBeenCalled();
expect(UserPreferenceMutation.setAsync).not.toHaveBeenCalled();
});
it('shows the tip again in a new billing period', async () => {
const otherAccountIosKey = 'free-tier-near-limit-agent-updates-tip-shown/other-account/ios';
shownTips = {
[ANDROID_KEY]: { billingPeriodId: 'period-0' },
[otherAccountIosKey]: { billingPeriodId: 'period-1' },
};
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
[build(AppPlatform.Android)],
treatmentExperiment()
);
expect(logSpy).toHaveBeenCalledWith(ANDROID_TIP_11_OF_15);
expect(shownTips).toEqual({
[ANDROID_KEY]: { billingPeriodId: 'period-1' },
[otherAccountIosKey]: { billingPeriodId: 'period-1' },
});
});
it('treats a malformed stored preference as not shown', async () => {
shownTips = { [ANDROID_KEY]: 'period-1' };
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
[build(AppPlatform.Android)],
treatmentExperiment()
);
expect(logSpy).toHaveBeenCalledWith(ANDROID_TIP_11_OF_15);
expect(shownTips).toEqual({ [ANDROID_KEY]: { billingPeriodId: 'period-1' } });
});
it('checks each finished platform with one usage request', async () => {
jest
.mocked(AccountQuery.getFreeTierBuildUsageAsync)
.mockResolvedValue(usageResponse({ ios: { value: 14, limit: 15 } }));
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
[build(AppPlatform.Android), build(AppPlatform.Ios)],
treatmentExperiment()
);
expect(AccountQuery.getFreeTierBuildUsageAsync).toHaveBeenCalledTimes(1);
expect(logSpy).toHaveBeenCalledTimes(2);
expect(logSpy).toHaveBeenCalledWith(ANDROID_TIP_11_OF_15);
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining('used 14 of 15 iOS builds on the Free plan')
);
expect(shownTips).toEqual({
[ANDROID_KEY]: { billingPeriodId: 'period-1' },
[IOS_KEY]: { billingPeriodId: 'period-1' },
});
});
it('skips platforms outside the band', async () => {
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
[build(AppPlatform.Android), build(AppPlatform.Ios)],
treatmentExperiment()
);
expect(logSpy).toHaveBeenCalledTimes(1);
expect(logSpy).toHaveBeenCalledWith(ANDROID_TIP_11_OF_15);
expect(shownTips).toEqual({ [ANDROID_KEY]: { billingPeriodId: 'period-1' } });
});
it('still shows the tip and logs the events when the marker write fails', async () => {
jest.mocked(UserPreferenceMutation.setAsync).mockRejectedValue(new Error('forbidden'));
const { client, analytics } = createClient();
const experiment = createExperiment(treatmentAccountId, { client, analytics });
await expect(
maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync([build(AppPlatform.Android)], experiment)
).resolves.toBeUndefined();
expect(logSpy).toHaveBeenCalledWith(ANDROID_TIP_11_OF_15);
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
experimentName: EXPERIMENT_NAME,
variationName: 'treatment',
unit: treatmentAccountId,
});
expect(analytics.logEvent).toHaveBeenCalledWith(
'build cli free tier near limit agent updates tip',
expect.objectContaining({ platform: AppPlatform.Android })
);
expect(shownTips).toEqual({});
});
it('still checks the next platform when a marker write fails', async () => {
jest
.mocked(UserPreferenceMutation.setAsync)
.mockRejectedValueOnce(new Error('forbidden'))
.mockImplementation(async (_graphqlClient, key, value) => {
shownTips[key] = value;
});
jest
.mocked(AccountQuery.getFreeTierBuildUsageAsync)
.mockResolvedValue(usageResponse({ ios: { value: 14, limit: 15 } }));
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
[build(AppPlatform.Android), build(AppPlatform.Ios)],
treatmentExperiment()
);
expect(logSpy).toHaveBeenCalledTimes(2);
expect(logSpy).toHaveBeenCalledWith(ANDROID_TIP_11_OF_15);
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining('used 14 of 15 iOS builds on the Free plan')
);
expect(shownTips).toEqual({ [IOS_KEY]: { billingPeriodId: 'period-1' } });
});
it('fails open when the usage request fails', async () => {
jest
.mocked(AccountQuery.getFreeTierBuildUsageAsync)
.mockRejectedValue(new Error('network down'));
const experiment = treatmentExperiment();
await expect(
maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync([build(AppPlatform.Android)], experiment)
).resolves.toBeUndefined();
expect(logSpy).not.toHaveBeenCalled();
expect(experiment.analytics.logEvent).not.toHaveBeenCalled();
});
});
@@ -5,12 +5,8 @@ import { GraphQLError } from 'graphql/error';
import { v4 as uuidv4 } from 'uuid';
import { getErrorAsync } from '../../__tests__/commands/utils';
import { getAgentTelemetryContext } from '../../analytics/agent';
import { EasCommandError } from '../../commandUtils/errors';
import Build from '../../commands/build';
import { ExperimentOverrides } from '../../experimentation/ExperimentOverrides';
import { ExperimentationClient } from '../../experimentation/ExperimentationClient';
import { LazyExperimentationClient } from '../../experimentation/LazyExperimentationClient';
import Log, { link } from '../../log';
import { handleBuildRequestErrorAsync } from '../build';
import {
@@ -30,12 +26,7 @@ import {
FreeTierLimitCheckoutExperiment,
maybeOfferFreeTierLimitCheckoutAsync,
} from '../freeTierLimitCheckout';
import {
FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE,
FreeTierLimitAgentGuidanceExperiment,
} from '../freeTierLimitGuidance';
jest.mock('../../analytics/agent');
jest.mock('../freeTierLimitCheckout', () => ({
...jest.requireActual('../freeTierLimitCheckout'),
maybeOfferFreeTierLimitCheckoutAsync: jest.fn(),
@@ -45,26 +36,9 @@ const CHECKOUT_EXPERIMENT = { accountId: 'account-id' } as FreeTierLimitCheckout
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
jest.mocked(maybeOfferFreeTierLimitCheckoutAsync).mockResolvedValue(false);
});
/** An experiment handle whose client is forced to the given variant through an override. */
function createExperiment(variant: string): FreeTierLimitAgentGuidanceExperiment {
return {
accountId: 'account-id',
experimentation: LazyExperimentationClient.fromClient(
ExperimentationClient.createDisabled(
new ExperimentOverrides(
new Map([
[FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE, new Map([['variant', variant]])],
])
)
)
),
};
}
const EXPECTED_STRINGIFIED_GRAPHQL_ERROR_JSON = `Build request error details:
{
"message": "[GraphQL] Error 1\\n[GraphQL] Error 2\\n[GraphQL] Error 3",
@@ -357,115 +331,6 @@ describe(Build.name, () => {
);
});
});
describe('for free tier limit errors in the agent guidance experiment', () => {
beforeEach(() => {
jest
.mocked(getAgentTelemetryContext)
.mockReturnValue({ id: 'claude-code', sessionId: 'session-1' });
});
it('appends the human required block for Android in the treatment arm', async () => {
const platform = Platform.ANDROID;
const accountName = 'test-account';
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
const error = new CombinedError({ graphQLErrors: [graphQLError] });
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierLimitExceededError>(
async () => {
await handleBuildRequestErrorAsync(error, platform, accountName, createExperiment('treatment'));
}
);
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildFreeTierLimitExceededError,
[
'Error 1',
'Run eas billing:subscribe starter --account test-account to upgrade to the Starter plan.',
'Fix: Human required: Android builds for this account are blocked until the Free plan resets. Upgrading costs money, so an account owner or admin decides.',
].join('\n')
);
});
it('appends the human required block for iOS in the treatment arm', async () => {
const platform = Platform.IOS;
const accountName = 'test-account';
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED');
const error = new CombinedError({ graphQLErrors: [graphQLError] });
const handleBuildRequestErrorThrownError =
await getErrorAsync<EasBuildFreeTierIosLimitExceededError>(async () => {
await handleBuildRequestErrorAsync(error, platform, accountName, createExperiment('treatment'));
});
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildFreeTierIosLimitExceededError,
[
'Error 1',
'Run eas billing:subscribe starter --account test-account to upgrade to the Starter plan.',
'Fix: Human required: iOS builds for this account are blocked until the Free plan resets. Upgrading costs money, so an account owner or admin decides.',
].join('\n')
);
});
it('does not append the block in the control arm', async () => {
const platform = Platform.ANDROID;
const accountName = 'test-account';
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
const error = new CombinedError({ graphQLErrors: [graphQLError] });
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierLimitExceededError>(
async () => {
await handleBuildRequestErrorAsync(error, platform, accountName, createExperiment('control'));
}
);
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildFreeTierLimitExceededError,
'Error 1\nRun eas billing:subscribe starter --account test-account to upgrade to the Starter plan.'
);
});
it('does not append the block when no coding agent runs the CLI', async () => {
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
const platform = Platform.ANDROID;
const accountName = 'test-account';
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
const error = new CombinedError({ graphQLErrors: [graphQLError] });
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierLimitExceededError>(
async () => {
await handleBuildRequestErrorAsync(error, platform, accountName, createExperiment('treatment'));
}
);
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildFreeTierLimitExceededError,
'Error 1\nRun eas billing:subscribe starter --account test-account to upgrade to the Starter plan.'
);
});
it('does not append the block to other server-side errors', async () => {
const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED');
const error = new CombinedError({ graphQLErrors: [graphQLError] });
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierDisabledError>(
async () => {
await handleBuildRequestErrorAsync(error, platform, undefined, createExperiment('treatment'));
}
);
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildFreeTierDisabledError,
'Error 1'
);
});
});
describe('for free tier limit errors in the checkout experiment', () => {
it('offers the checkout with the limit message and marks the message as printed', async () => {
jest.mocked(maybeOfferFreeTierLimitCheckoutAsync).mockResolvedValue(true);
@@ -482,7 +347,6 @@ describe(Build.name, () => {
error,
platform,
accountName,
undefined,
CHECKOUT_EXPERIMENT
);
}
@@ -515,7 +379,6 @@ describe(Build.name, () => {
error,
platform,
accountName,
undefined,
CHECKOUT_EXPERIMENT
);
});
@@ -544,7 +407,6 @@ describe(Build.name, () => {
error,
platform,
accountName,
undefined,
CHECKOUT_EXPERIMENT
);
}
@@ -559,35 +421,6 @@ describe(Build.name, () => {
);
});
it('passes the agent guidance along when both experiments are present', async () => {
jest
.mocked(getAgentTelemetryContext)
.mockReturnValue({ id: 'claude-code', sessionId: 'session-1' });
const platform = Platform.ANDROID;
const accountName = 'test-account';
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
const error = new CombinedError({ graphQLErrors: [graphQLError] });
await getErrorAsync(async () => {
await handleBuildRequestErrorAsync(
error,
platform,
accountName,
createExperiment('treatment'),
CHECKOUT_EXPERIMENT
);
});
expect(maybeOfferFreeTierLimitCheckoutAsync).toHaveBeenCalledWith(
[
'Error 1',
'Run eas billing:subscribe starter --account test-account to upgrade to the Starter plan.',
'Fix: Human required: Android builds for this account are blocked until the Free plan resets. Upgrading costs money, so an account owner or admin decides.',
].join('\n'),
CHECKOUT_EXPERIMENT
);
});
it('does not offer the checkout for other server-side errors', async () => {
const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED');
@@ -599,7 +432,6 @@ describe(Build.name, () => {
error,
platform,
undefined,
undefined,
CHECKOUT_EXPERIMENT
);
}
+1 -12
View File
@@ -34,11 +34,6 @@ import {
getFreeTierLimitCheckoutExperiment,
maybeOfferFreeTierLimitCheckoutAsync,
} from './freeTierLimitCheckout';
import {
FreeTierLimitAgentGuidanceExperiment,
getFreeTierLimitAgentGuidanceExperiment,
maybeAppendFreeTierLimitAgentGuidanceAsync,
} from './freeTierLimitGuidance';
import { transformMetadata } from './graphql';
import { LocalBuildMode, runLocalBuildAsync } from './local';
import {
@@ -236,7 +231,6 @@ export async function prepareBuildRequestForPlatformAsync<
error,
job.platform,
ctx.accountName,
getFreeTierLimitAgentGuidanceExperiment(ctx),
getFreeTierLimitCheckoutExperiment(ctx)
);
}
@@ -263,7 +257,6 @@ export async function handleBuildRequestErrorAsync(
error: any,
platform: Platform,
accountName?: string,
freeTierLimitAgentGuidanceExperiment?: FreeTierLimitAgentGuidanceExperiment,
freeTierLimitCheckoutExperiment?: FreeTierLimitCheckoutExperiment
): Promise<never> {
logBuildRequestErrorDebugInfo(error);
@@ -283,11 +276,7 @@ export async function handleBuildRequestErrorAsync(
if (!isFreeTierLimitError) {
throw new ErrorClass(message);
}
const limitMessage = await maybeAppendFreeTierLimitAgentGuidanceAsync(
`${message}\nRun ${formatStarterSubscribeCommand(accountName)} to upgrade to the Starter plan.`,
platform,
freeTierLimitAgentGuidanceExperiment
);
const limitMessage = `${message}\nRun ${formatStarterSubscribeCommand(accountName)} to upgrade to the Starter plan.`;
const messageAlreadyPrinted = await maybeOfferFreeTierLimitCheckoutAsync(
limitMessage,
freeTierLimitCheckoutExperiment
@@ -1,60 +0,0 @@
import { Platform } from '@expo/eas-build-job';
import { getAgentTelemetryContext } from '../analytics/agent';
import { LazyExperimentationClient } from '../experimentation/LazyExperimentationClient';
import { requestedPlatformDisplayNames } from '../platform';
export const FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE =
'cli-build-free-tier-limit-agent-guidance';
export type FreeTierLimitAgentGuidanceVariant = 'control' | 'treatment';
export type FreeTierLimitAgentGuidanceExperiment = {
experimentation: LazyExperimentationClient;
accountId: string;
};
export function getFreeTierLimitAgentGuidanceExperiment(ctx: {
experimentation?: LazyExperimentationClient;
accountId: string;
}): FreeTierLimitAgentGuidanceExperiment | undefined {
return ctx.experimentation
? { experimentation: ctx.experimentation, accountId: ctx.accountId }
: undefined;
}
/**
* Resolves the account's variant. Only runs driven by a coding agent are eligible. Other runs get
* control before the client loads, so they make no config request and log no exposure.
*/
export async function resolveFreeTierLimitAgentGuidanceVariantAsync({
experimentation,
accountId,
}: FreeTierLimitAgentGuidanceExperiment): Promise<FreeTierLimitAgentGuidanceVariant> {
if (!getAgentTelemetryContext()) {
return 'control';
}
const client = await experimentation.getClientAsync();
return client
.getAccountNamespace(FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE, { accountId })
.getParam<FreeTierLimitAgentGuidanceVariant>('variant', 'control');
}
export function formatFreeTierLimitAgentGuidance(platform: Platform): string {
const platformName = requestedPlatformDisplayNames[platform];
return `Fix: Human required: ${platformName} builds for this account are blocked until the Free plan resets. Upgrading costs money, so an account owner or admin decides.`;
}
export async function maybeAppendFreeTierLimitAgentGuidanceAsync(
message: string,
platform: Platform,
experiment: FreeTierLimitAgentGuidanceExperiment | undefined
): Promise<string> {
if (
!experiment ||
(await resolveFreeTierLimitAgentGuidanceVariantAsync(experiment)) !== 'treatment'
) {
return message;
}
return `${message}\n${formatFreeTierLimitAgentGuidance(platform)}`;
}
@@ -0,0 +1,344 @@
import { ExpoConfig } from '@expo/config';
import { getAgentTelemetryContext } from '../analytics/agent';
import { Analytics, AnalyticsEventProperties, BuildEvent } from '../analytics/AnalyticsManager';
import {
ExpoGraphqlClient,
createGraphqlClient,
} from '../commandUtils/context/contextUtils/createGraphqlClient';
import { LazyExperimentationClient } from '../experimentation/LazyExperimentationClient';
import {
AppPlatform,
BuildFragment,
BuildStatus,
EasServiceMetric,
UsageMetricType,
} from '../graphql/generated';
import { UserPreferenceMutation } from '../graphql/mutations/UserPreferenceMutation';
import { AccountQuery } from '../graphql/queries/AccountQuery';
import { UserPreferenceQuery } from '../graphql/queries/UserPreferenceQuery';
import Log from '../log';
import { appPlatformDisplayNames } from '../platform';
import { isExpoUpdatesInstalled, isUsingEASUpdate } from '../project/projectUtils';
import { LoggedInAuthenticationInfo } from '../user/SessionManager';
import { Actor } from '../user/User';
export const FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP_EXPERIMENT_NAMESPACE =
'cli-build-free-tier-near-limit-agent-updates-tip';
/** The tip shows when this many builds or fewer are left on the platform. */
export const FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP_BUILDS_LEFT = 5;
export const EAS_UPDATE_GETTING_STARTED_URL = 'https://docs.expo.dev/eas-update/getting-started/';
/** Server-side user preference key prefix. The full key is `<prefix>/<accountId>/<platform>`. */
const USER_PREFERENCE_KEY_PREFIX = 'free-tier-near-limit-agent-updates-tip-shown';
/** The tip's requests run after the build, so a stalled request must not delay the exit. */
const REQUEST_TIMEOUT_MS = 3000;
export type FreeTierNearLimitAgentUpdatesTipVariant = 'control' | 'treatment';
/** The project state of one platform's build. Each build profile evaluates its own config. */
export type FreeTierNearLimitAgentUpdatesTipPlatformProject = {
developmentClient: boolean;
projectDir: string;
projectId: string;
exp: ExpoConfig;
manifestHostOverride: string | null;
};
export type FreeTierNearLimitAgentUpdatesTipExperiment = {
experimentation: LazyExperimentationClient;
graphqlClient: ExpoGraphqlClient;
analytics: Analytics;
analyticsEventProperties: AnalyticsEventProperties;
accountId: string;
/** Only regular and SSO users can store user preferences. */
isUser: boolean;
runFromCI: boolean;
json: boolean;
projectByPlatform: Partial<Record<AppPlatform, FreeTierNearLimitAgentUpdatesTipPlatformProject>>;
};
export type FreeTierNearLimitAgentUpdatesTipBuildContext = {
experimentation?: LazyExperimentationClient;
authenticationInfo?: LoggedInAuthenticationInfo;
analytics: Analytics;
analyticsEventProperties: AnalyticsEventProperties;
accountId: string;
user: Actor;
developmentClient: boolean;
projectDir: string;
projectId: string;
exp: ExpoConfig;
easJsonCliConfig: { updateManifestHostOverride?: string } | undefined;
runFromCI: boolean;
};
export type FreeTierPlatformBuildUsage = {
used: number;
limit: number;
};
export type FreeTierBuildUsage = {
billingPeriodId: string;
byPlatform: Record<AppPlatform, FreeTierPlatformBuildUsage>;
};
/** All build contexts belong to one command run, so the account and the actor are shared. */
export function getFreeTierNearLimitAgentUpdatesTipExperiment(
buildCtxByPlatform: Partial<Record<AppPlatform, FreeTierNearLimitAgentUpdatesTipBuildContext>>,
{ json }: { json: boolean }
): FreeTierNearLimitAgentUpdatesTipExperiment | undefined {
const ctx = Object.values(buildCtxByPlatform)[0];
if (!ctx?.experimentation || !ctx.authenticationInfo) {
return undefined;
}
const projectByPlatform: FreeTierNearLimitAgentUpdatesTipExperiment['projectByPlatform'] = {};
for (const [platform, platformCtx] of Object.entries(buildCtxByPlatform)) {
projectByPlatform[platform as AppPlatform] = {
developmentClient: platformCtx.developmentClient,
projectDir: platformCtx.projectDir,
projectId: platformCtx.projectId,
exp: platformCtx.exp,
manifestHostOverride: platformCtx.easJsonCliConfig?.updateManifestHostOverride ?? null,
};
}
return {
experimentation: ctx.experimentation,
graphqlClient: createGraphqlClient(ctx.authenticationInfo, {
requestTimeoutMs: REQUEST_TIMEOUT_MS,
}),
analytics: ctx.analytics,
analyticsEventProperties: ctx.analyticsEventProperties,
accountId: ctx.accountId,
isUser: ctx.user.__typename === 'User' || ctx.user.__typename === 'SSOUser',
runFromCI: ctx.runFromCI,
json,
projectByPlatform,
};
}
export function isProjectConfiguredForEASUpdate({
projectDir,
exp,
projectId,
manifestHostOverride,
}: Pick<
FreeTierNearLimitAgentUpdatesTipPlatformProject,
'projectDir' | 'exp' | 'projectId' | 'manifestHostOverride'
>): boolean {
return (
isExpoUpdatesInstalled(projectDir) && isUsingEASUpdate(exp, projectId, manifestHostOverride)
);
}
/**
* Only runs driven by a coding agent logged in as a user, outside CI and without --json are
* eligible. Everything else gets no tip and no usage request.
*/
export function isEligibleForFreeTierNearLimitAgentUpdatesTip(
experiment: FreeTierNearLimitAgentUpdatesTipExperiment
): boolean {
return !(
experiment.runFromCI ||
experiment.json ||
!experiment.isUser ||
!getAgentTelemetryContext()
);
}
/**
* A platform is eligible when its project is not set up for Expo Update. Development builds load
* JavaScript from the dev server, so the tip does not apply to them.
*/
export function isPlatformEligibleForFreeTierNearLimitAgentUpdatesTip(
project: FreeTierNearLimitAgentUpdatesTipPlatformProject
): boolean {
return !project.developmentClient && !isProjectConfiguredForEASUpdate(project);
}
/** True when the platform still has builds left, but no more than the tip's threshold. */
export function isWithinFreeTierNearLimitBand({ used, limit }: FreeTierPlatformBuildUsage): boolean {
return limit > 0 && used < limit && limit - used <= FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP_BUILDS_LEFT;
}
/** Returns the Free plan build usage per platform, or null when the account is not on the Free plan. */
export async function getFreeTierBuildUsageAsync({
graphqlClient,
accountId,
}: Pick<FreeTierNearLimitAgentUpdatesTipExperiment, 'graphqlClient' | 'accountId'>): Promise<FreeTierBuildUsage | null> {
const account = await AccountQuery.getFreeTierBuildUsageAsync(graphqlClient, accountId, new Date());
if ((account.subscription?.name ?? 'Free') !== 'Free') {
return null;
}
const buildMetric = account.usageMetrics.EAS_BUILD.planMetrics.find(
metric =>
metric.serviceMetric === EasServiceMetric.Builds &&
metric.metricType === UsageMetricType.Build
);
const breakdown = buildMetric?.platformBreakdown;
if (!breakdown) {
return null;
}
return {
billingPeriodId: account.billingPeriod.id,
byPlatform: {
[AppPlatform.Android]: { used: breakdown.android.value, limit: breakdown.android.limit },
[AppPlatform.Ios]: { used: breakdown.ios.value, limit: breakdown.ios.limit },
},
};
}
/**
* The first call logs the exposure, so call it only when the tip would show. Accounts outside the
* rollout get the control default and no exposure.
*/
export async function resolveFreeTierNearLimitAgentUpdatesTipVariantAsync({
experimentation,
accountId,
}: Pick<FreeTierNearLimitAgentUpdatesTipExperiment, 'experimentation' | 'accountId'>): Promise<FreeTierNearLimitAgentUpdatesTipVariant> {
const client = await experimentation.getClientAsync();
return client
.getAccountNamespace(FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP_EXPERIMENT_NAMESPACE, { accountId })
.getParam<FreeTierNearLimitAgentUpdatesTipVariant>('variant', 'control');
}
export function formatFreeTierNearLimitAgentUpdatesTip({
platform,
used,
limit,
}: FreeTierPlatformBuildUsage & { platform: AppPlatform }): string {
const platformName = appPlatformDisplayNames[platform];
return [
`Tip: this account has used ${used} of ${limit} ${platformName} builds on the Free plan this period. This project isn't set up for Expo Update, so every change needs a new build.`,
'With Expo Update, JavaScript, styling and image changes reach installed builds without a new build. Setup takes effect from the next build, so it saves the most builds if done now.',
'Next: eas update:configure --non-interactive, then build once more.',
'Tell the user: this adds expo-updates and changes app.json and eas.json.',
`Docs: ${EAS_UPDATE_GETTING_STARTED_URL}`,
].join('\n');
}
export function getFreeTierNearLimitAgentUpdatesTipPreferenceKey(
accountId: string,
platform: AppPlatform
): string {
return `${USER_PREFERENCE_KEY_PREFIX}/${accountId}/${platform.toLowerCase()}`;
}
type ShownTipPreference = { billingPeriodId: string };
function isShownTipPreference(value: unknown): value is ShownTipPreference {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as ShownTipPreference).billingPeriodId === 'string'
);
}
/**
* The tip shows at most once per user, account, platform and billing period. The marker is a
* user preference on the server, so it follows the user across machines and fresh agent
* sandboxes.
*/
export async function wasFreeTierNearLimitAgentUpdatesTipShownAsync(
graphqlClient: ExpoGraphqlClient,
accountId: string,
platform: AppPlatform,
billingPeriodId: string
): Promise<boolean> {
const value = await UserPreferenceQuery.getValueAsync(
graphqlClient,
getFreeTierNearLimitAgentUpdatesTipPreferenceKey(accountId, platform)
);
return isShownTipPreference(value) && value.billingPeriodId === billingPeriodId;
}
export async function markFreeTierNearLimitAgentUpdatesTipShownAsync(
graphqlClient: ExpoGraphqlClient,
accountId: string,
platform: AppPlatform,
billingPeriodId: string
): Promise<void> {
const value: ShownTipPreference = { billingPeriodId };
await UserPreferenceMutation.setAsync(
graphqlClient,
getFreeTierNearLimitAgentUpdatesTipPreferenceKey(accountId, platform),
value
);
}
/**
* After finished builds, suggests Expo Update to coding agents whose Free plan account is close to
* the platform build limit. The exposure logs only when every eligibility check passed, so both
* arms see the same population. The treatment arm prints the tip, logs the tip event, then
* writes the marker, so a failed write means at most a repeated tip. Fails open on any error.
*/
export async function maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
builds: (BuildFragment | null)[],
experiment: FreeTierNearLimitAgentUpdatesTipExperiment | undefined
): Promise<void> {
if (!experiment || !isEligibleForFreeTierNearLimitAgentUpdatesTip(experiment)) {
return;
}
const eligiblePlatforms = new Set(
builds
.filter((build): build is BuildFragment => build?.status === BuildStatus.Finished)
.map(build => build.platform)
.filter(platform => {
const project = experiment.projectByPlatform[platform];
return project && isPlatformEligibleForFreeTierNearLimitAgentUpdatesTip(project);
})
);
if (eligiblePlatforms.size === 0) {
return;
}
try {
const usage = await getFreeTierBuildUsageAsync(experiment);
if (!usage) {
return;
}
for (const platform of eligiblePlatforms) {
const platformUsage = usage.byPlatform[platform];
if (
!isWithinFreeTierNearLimitBand(platformUsage) ||
(await wasFreeTierNearLimitAgentUpdatesTipShownAsync(
experiment.graphqlClient,
experiment.accountId,
platform,
usage.billingPeriodId
))
) {
continue;
}
const variant = await resolveFreeTierNearLimitAgentUpdatesTipVariantAsync(experiment);
if (variant !== 'treatment') {
continue;
}
Log.newLine();
Log.log(formatFreeTierNearLimitAgentUpdatesTip({ platform, ...platformUsage }));
experiment.analytics.logEvent(BuildEvent.FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP, {
...experiment.analyticsEventProperties,
platform,
builds_used: platformUsage.used,
builds_limit: platformUsage.limit,
});
try {
await markFreeTierNearLimitAgentUpdatesTipShownAsync(
experiment.graphqlClient,
experiment.accountId,
platform,
usage.billingPeriodId
);
} catch (error: any) {
// The tip was shown. A missing marker means at most a repeated tip, so keep going.
Log.debug('Failed to mark the Expo Update tip as shown:', error?.message ?? error);
}
}
} catch (error: any) {
Log.debug('Failed to run the Expo Update tip check:', error?.message ?? error);
}
}
@@ -24,6 +24,10 @@ import { prepareIosBuildAsync } from './ios/build';
import { LocalBuildMode, LocalBuildOptions } from './local';
import { ensureLockfileExistsAsync } from './validateLockfile';
import { ensureExpoDevClientInstalledForDevClientBuildsAsync } from './utils/devClient';
import {
getFreeTierNearLimitAgentUpdatesTipExperiment,
maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync,
} from './freeTierNearLimitAgentUpdatesTip';
import { printBuildResults, printLogsUrls } from './utils/printBuildInfo';
import { ensureRepoIsCleanAsync } from './utils/repository';
import { Analytics } from '../analytics/AnalyticsManager';
@@ -139,6 +143,7 @@ export async function runBuildAndSubmitAsync({
}): Promise<{
buildIds: string[];
buildProfiles?: ProfileData<'build'>[];
builds?: MaybeBuildFragment[];
}> {
await vcsClient.ensureRepoExistsAsync();
await ensureRepoIsCleanAsync(vcsClient, flags.nonInteractive);
@@ -339,6 +344,10 @@ export async function runBuildAndSubmitAsync({
if (!flags.json) {
printBuildResults(builds);
}
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
builds,
getFreeTierNearLimitAgentUpdatesTipExperiment(buildCtxByPlatform, { json: flags.json })
);
const haveAllBuildsFailedOrCanceled = builds.every(
build =>
@@ -372,6 +381,7 @@ export async function runBuildAndSubmitAsync({
return {
buildIds: startedBuilds.map(({ build }) => build.id),
buildProfiles,
builds,
};
}
@@ -0,0 +1,52 @@
import { boolish } from 'getenv';
import { Analytics, BuildEvent } from '../analytics/AnalyticsManager';
import { createGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { UserPreferenceMutation } from '../graphql/mutations/UserPreferenceMutation';
import { UserPreferenceQuery } from '../graphql/queries/UserPreferenceQuery';
import Log, { link } from '../log';
import { LoggedInAuthenticationInfo } from '../user/SessionManager';
import { Actor } from '../user/User';
const SIMULATORS_LAUNCH_AT = new Date('2026-11-10T18:00:00Z');
const SIMULATORS_LAUNCH_HINT_PREFERENCE_KEY = 'simulators-launch-cli-hint-seen';
const REQUEST_TIMEOUT_MS = 3000;
const SIMULATORS_QUICKSTART_URL =
'https://docs.expo.dev/preview/simulators/get-started/?utm_source=eas-cli&utm_medium=cli&utm_campaign=simulators-launch';
export async function maybeShowSimulatorsLaunchHintAsync(
authenticationInfo: LoggedInAuthenticationInfo,
analytics: Analytics,
{ actor, nonInteractive }: { actor: Actor; nonInteractive: boolean }
): Promise<void> {
if (
nonInteractive ||
boolish('CI', false) ||
Date.now() < SIMULATORS_LAUNCH_AT.getTime() ||
(actor.__typename !== 'User' && actor.__typename !== 'SSOUser')
) {
return;
}
try {
const graphqlClient = createGraphqlClient(authenticationInfo, {
requestTimeoutMs: REQUEST_TIMEOUT_MS,
});
const value = await UserPreferenceQuery.getValueAsync(
graphqlClient,
SIMULATORS_LAUNCH_HINT_PREFERENCE_KEY
);
if (value && typeof value === 'object' && 'isDismissed' in value && value.isDismissed) {
return;
}
Log.newLine();
Log.log(
`✨ New: run your build in Expo Simulators, cloud Android and iOS simulators for you and your agents. ${link(SIMULATORS_QUICKSTART_URL)}`
);
analytics.logEvent(BuildEvent.SIMULATORS_LAUNCH_HINT_SHOWN, {});
await UserPreferenceMutation.setAsync(graphqlClient, SIMULATORS_LAUNCH_HINT_PREFERENCE_KEY, {
isDismissed: true,
});
} catch (error) {
Log.debug('Failed to show the Expo Simulators hint', error);
}
}
+10 -2
View File
@@ -9,12 +9,13 @@ import path from 'path';
import { LocalBuildMode } from '../../build/local';
import { BuildFlags, runBuildAndSubmitAsync } from '../../build/runBuildAndSubmit';
import { maybeShowSimulatorsLaunchHintAsync } from '../../build/simulatorsLaunchHint';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { StatuspageServiceName } from '../../graphql/generated';
import { BuildStatus, StatuspageServiceName } from '../../graphql/generated';
import Log, { link } from '../../log';
import { RequestedPlatform, selectRequestedPlatformAsync } from '../../platform';
import { selectAsync } from '../../prompts';
@@ -176,7 +177,7 @@ export default class Build extends EasCommand {
const flagsWithPlatform = await this.ensurePlatformSelectedAsync(flags);
const { buildProfiles } = await runBuildAndSubmitAsync({
const { buildProfiles, builds } = await runBuildAndSubmitAsync({
graphqlClient,
analytics,
vcsClient,
@@ -192,6 +193,13 @@ export default class Build extends EasCommand {
buildProfiles,
nonInteractive: flags.nonInteractive,
});
if (builds?.some(build => build?.status === BuildStatus.Finished)) {
await maybeShowSimulatorsLaunchHintAsync(authenticationInfo, analytics, {
actor,
nonInteractive: flags.nonInteractive,
});
}
}
private sanitizeFlags(
@@ -112,6 +112,7 @@ describe(ObserveSession, () => {
metadata: null,
hasMoreMetricEvents: false,
hasMoreLogEvents: false,
hasMoreSpans: false,
});
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([]);
mockFetchSessionLogCandidatesAsync.mockResolvedValue([]);
@@ -0,0 +1,113 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { ObserveQuery } from '../../../graphql/queries/ObserveQuery';
import { buildObserveTraceJson, buildObserveTraceTable } from '../../../observe/formatSpans';
import { EAS_OBSERVE_PLAN_UPGRADE_REQUIRED_ERROR_CODE } from '../../../observe/planGating';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ObserveTrace from '../trace';
jest.mock('../../../observe/formatSpans', () => ({
buildObserveTraceTable: jest.fn().mockReturnValue('trace-table'),
buildObserveTraceJson: jest.fn().mockReturnValue({ id: 'trace-json' }),
}));
jest.mock('../../../graphql/queries/ObserveQuery', () => ({
ObserveQuery: {
traceByIdAsync: jest.fn(),
},
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockTraceByIdAsync = jest.mocked(ObserveQuery.traceByIdAsync);
const mockBuildObserveTraceTable = jest.mocked(buildObserveTraceTable);
const mockBuildObserveTraceJson = jest.mocked(buildObserveTraceJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
const TRACE_ID = '0123456789abcdef0123456789abcdef';
const trace = { id: TRACE_ID, isTruncated: false, spans: [] } as any;
describe(ObserveTrace, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectId = 'test-project-id';
beforeEach(() => {
jest.clearAllMocks();
mockTraceByIdAsync.mockResolvedValue(trace);
});
function createCommand(argv: string[]): ObserveTrace {
const command = new ObserveTrace(argv, mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId,
loggedIn: { graphqlClient },
});
return command;
}
it('requires a trace ID argument', async () => {
await expect(createCommand([]).runAsync()).rejects.toThrow();
expect(mockTraceByIdAsync).not.toHaveBeenCalled();
});
it('rejects a malformed trace ID before querying', async () => {
await expect(createCommand(['not-a-trace-id']).runAsync()).rejects.toThrow(
/not a valid trace ID/
);
expect(mockTraceByIdAsync).not.toHaveBeenCalled();
});
it('accepts an uppercase trace ID', async () => {
await createCommand([TRACE_ID.toUpperCase()]).runAsync();
expect(mockTraceByIdAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
id: TRACE_ID.toUpperCase(),
});
});
it('renders the trace table by default', async () => {
await createCommand([TRACE_ID]).runAsync();
expect(mockTraceByIdAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
id: TRACE_ID,
});
expect(mockBuildObserveTraceTable).toHaveBeenCalledWith(trace);
expect(mockBuildObserveTraceJson).not.toHaveBeenCalled();
});
it('emits JSON with --json', async () => {
await createCommand([TRACE_ID, '--json', '--non-interactive']).runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockBuildObserveTraceJson).toHaveBeenCalledWith(trace);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({ id: 'trace-json' });
expect(mockBuildObserveTraceTable).not.toHaveBeenCalled();
});
it('fails with a not-found message when the trace is unknown', async () => {
mockTraceByIdAsync.mockResolvedValue(null);
await expect(createCommand([TRACE_ID]).runAsync()).rejects.toThrow(/No trace found with ID/);
});
it('surfaces the plan-gate message', async () => {
const serverMessage =
'Access to this feature is not included in your current plan. ' +
'Upgrade your plan: https://expo.dev/accounts/acme/settings/billing';
mockTraceByIdAsync.mockRejectedValueOnce(
new CombinedError({
graphQLErrors: [
new GraphQLError(serverMessage, null, null, null, null, null, {
errorCode: EAS_OBSERVE_PLAN_UPGRADE_REQUIRED_ERROR_CODE,
}),
],
})
);
await expect(createCommand([TRACE_ID]).runAsync()).rejects.toThrow(serverMessage);
});
});
@@ -0,0 +1,260 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import {
AppObservePlatform,
AppObserveSpanKind,
AppObserveSpanStatusCode,
} from '../../../graphql/generated';
import { ObserveQuery } from '../../../graphql/queries/ObserveQuery';
import { fetchObserveSpansAsync } from '../../../observe/fetchSpans';
import {
buildObserveSpanNamesJson,
buildObserveSpanNamesTable,
buildObserveSpansJson,
buildObserveSpansTable,
} from '../../../observe/formatSpans';
import { EAS_OBSERVE_PLAN_UPGRADE_REQUIRED_ERROR_CODE } from '../../../observe/planGating';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ObserveTraces from '../traces';
jest.mock('../../../observe/fetchSpans');
jest.mock('../../../observe/formatSpans', () => ({
buildObserveSpansTable: jest.fn().mockReturnValue('spans-table'),
buildObserveSpansJson: jest.fn().mockReturnValue({ spans: [] }),
buildObserveSpanNamesTable: jest.fn().mockReturnValue('names-table'),
buildObserveSpanNamesJson: jest.fn().mockReturnValue({ names: [], isTruncated: false }),
}));
jest.mock('../../../graphql/queries/ObserveQuery', () => ({
ObserveQuery: {
spanNamesAsync: jest.fn(),
},
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockFetchObserveSpansAsync = jest.mocked(fetchObserveSpansAsync);
const mockSpanNamesAsync = jest.mocked(ObserveQuery.spanNamesAsync);
const mockBuildObserveSpansTable = jest.mocked(buildObserveSpansTable);
const mockBuildObserveSpansJson = jest.mocked(buildObserveSpansJson);
const mockBuildObserveSpanNamesTable = jest.mocked(buildObserveSpanNamesTable);
const mockBuildObserveSpanNamesJson = jest.mocked(buildObserveSpanNamesJson);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
describe(ObserveTraces, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectId = 'test-project-id';
beforeEach(() => {
jest.clearAllMocks();
mockFetchObserveSpansAsync.mockResolvedValue({
spans: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
});
mockSpanNamesAsync.mockResolvedValue({ names: [], isTruncated: false });
});
function createCommand(argv: string[]): ObserveTraces {
const command = new ObserveTraces(argv, mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
projectId,
loggedIn: { graphqlClient },
});
return command;
}
function planGateError(): CombinedError {
const serverMessage =
'Access to this feature is not included in your current plan. ' +
'Upgrade your plan: https://expo.dev/accounts/acme/settings/billing';
return new CombinedError({
graphQLErrors: [
new GraphQLError(serverMessage, null, null, null, null, null, {
errorCode: EAS_OBSERVE_PLAN_UPGRADE_REQUIRED_ERROR_CODE,
}),
],
});
}
it('shows the span names summary when no span filter is given', async () => {
const command = createCommand(['--days', '7', '--platform', 'ios', '--environment', 'prod']);
await command.runAsync();
expect(mockSpanNamesAsync).toHaveBeenCalledTimes(1);
const variables = mockSpanNamesAsync.mock.calls[0][1];
expect(variables.appId).toBe(projectId);
expect(variables.input.platforms).toEqual([AppObservePlatform.Ios]);
expect(variables.input.environment).toBe('prod');
expect(variables.input.startTime).toBeDefined();
expect(variables.input.endTime).toBeDefined();
expect(mockFetchObserveSpansAsync).not.toHaveBeenCalled();
expect(mockBuildObserveSpanNamesTable).toHaveBeenCalled();
expect(mockBuildObserveSpansTable).not.toHaveBeenCalled();
});
it('passes release filters to the summary', async () => {
const command = createCommand([
'--app-version',
'1.2.0',
'--build-number',
'42',
'--update-id',
'update-1',
]);
await command.runAsync();
const { input } = mockSpanNamesAsync.mock.calls[0][1];
expect(input.appVersion).toBe('1.2.0');
expect(input.appBuildNumber).toBe('42');
expect(input.appUpdateId).toBe('update-1');
});
it('emits the summary as JSON with --json', async () => {
const command = createCommand(['--json', '--non-interactive']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockBuildObserveSpanNamesJson).toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
expect(mockBuildObserveSpanNamesTable).not.toHaveBeenCalled();
});
it('lists spans with --all-spans', async () => {
const command = createCommand(['--all-spans']);
await command.runAsync();
expect(mockSpanNamesAsync).not.toHaveBeenCalled();
expect(mockFetchObserveSpansAsync).toHaveBeenCalledTimes(1);
expect(mockBuildObserveSpansTable).toHaveBeenCalled();
});
it('lists spans when paging with --after', async () => {
const command = createCommand(['--after', 'cursor-1']);
await command.runAsync();
expect(mockSpanNamesAsync).not.toHaveBeenCalled();
expect(mockFetchObserveSpansAsync.mock.calls[0][2].after).toBe('cursor-1');
});
it.each([
['--name', 'GET'],
['--kind', 'client'],
['--status', 'error'],
['--host', 'api.example.com'],
['--http-status', '500'],
['--min-duration', '100'],
['--session-id', 'session-1'],
])('lists spans when %s is given', async (flag, value) => {
const command = createCommand([flag, value]);
await command.runAsync();
expect(mockSpanNamesAsync).not.toHaveBeenCalled();
expect(mockFetchObserveSpansAsync).toHaveBeenCalledTimes(1);
});
it('lists spans with --root-only', async () => {
const command = createCommand(['--root-only']);
await command.runAsync();
expect(mockSpanNamesAsync).not.toHaveBeenCalled();
expect(mockFetchObserveSpansAsync.mock.calls[0][2].rootOnly).toBe(true);
});
it('maps every span filter flag to the fetch options', async () => {
const command = createCommand([
'--name',
'GET',
'--name',
'POST',
'--kind',
'client',
'--status',
'error',
'--status',
'unset',
'--host',
'api.example.com',
'--http-status',
'500',
'--http-status',
'502',
'--min-duration',
'250',
'--root-only',
'--session-id',
'session-1',
'--platform',
'android',
'--app-version',
'1.2.0',
'--build-number',
'42',
'--update-id',
'update-1',
'--environment',
'prod',
'--limit',
'50',
]);
await command.runAsync();
const options = mockFetchObserveSpansAsync.mock.calls[0][2];
expect(options).toEqual(
expect.objectContaining({
limit: 50,
names: ['GET', 'POST'],
kinds: [AppObserveSpanKind.Client],
statusCodes: [AppObserveSpanStatusCode.Error, AppObserveSpanStatusCode.Unset],
hosts: ['api.example.com'],
httpStatusCodes: [500, 502],
minDurationMs: 250,
rootOnly: true,
sessionId: 'session-1',
platforms: [AppObservePlatform.Android],
appVersion: '1.2.0',
buildNumber: '42',
updateId: 'update-1',
environment: 'prod',
})
);
});
it('defaults the span list limit to 20 and caps it at 100', async () => {
await createCommand(['--all-spans']).runAsync();
expect(mockFetchObserveSpansAsync.mock.calls[0][2].limit).toBe(20);
await expect(createCommand(['--all-spans', '--limit', '101']).runAsync()).rejects.toThrow();
});
it('rejects an unknown --kind or --status value', async () => {
await expect(createCommand(['--kind', 'bogus']).runAsync()).rejects.toThrow();
await expect(createCommand(['--status', 'bogus']).runAsync()).rejects.toThrow();
});
it('emits the span list as JSON with --json', async () => {
const command = createCommand(['--all-spans', '--json', '--non-interactive']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockBuildObserveSpansJson).toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
expect(mockBuildObserveSpansTable).not.toHaveBeenCalled();
});
it('surfaces the plan-gate message when a session filter is not available on the plan', async () => {
mockFetchObserveSpansAsync.mockRejectedValueOnce(planGateError());
const command = createCommand(['--session-id', 'session-1']);
await expect(command.runAsync()).rejects.toThrow(/not included in your current plan/);
});
it('surfaces the plan-gate message from the summary query', async () => {
mockSpanNamesAsync.mockRejectedValueOnce(planGateError());
const command = createCommand([]);
await expect(command.runAsync()).rejects.toThrow(/not included in your current plan/);
});
});
@@ -45,7 +45,7 @@ const PICKER_CANDIDATE_LIMIT = 25;
export default class ObserveSession extends EasCommand {
static override description =
'display the timeline of metric and log events for a specific session';
'display the timeline of metric, log, and request trace span events for a specific session';
static override args = {
sessionId: Args.string({
@@ -129,7 +129,7 @@ export default class ObserveSession extends EasCommand {
});
}
const { entries, metadata, hasMoreMetricEvents, hasMoreLogEvents } =
const { entries, metadata, hasMoreMetricEvents, hasMoreLogEvents, hasMoreSpans } =
await withObservePlanGateHandlingAsync(() =>
fetchObserveSessionEventsAsync(graphqlClient, projectId, {
sessionId,
@@ -144,7 +144,8 @@ export default class ObserveSession extends EasCommand {
sessionId,
metadata,
hasMoreMetricEvents,
hasMoreLogEvents
hasMoreLogEvents,
hasMoreSpans
)
);
} else {
@@ -154,6 +155,7 @@ export default class ObserveSession extends EasCommand {
metadata,
hasMoreMetricEvents,
hasMoreLogEvents,
hasMoreSpans,
})
);
}
@@ -0,0 +1,80 @@
import { Args } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasCommandError } from '../../commandUtils/errors';
import {
EasNonInteractiveAndJsonFlags,
EasProjectIdFlag,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
import Log from '../../log';
import { buildObserveTraceJson, buildObserveTraceTable } from '../../observe/formatSpans';
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
// A trace ID is the 32-character hex string of the OTLP JSON encoding.
const TRACE_ID_REGEX = /^[0-9a-f]{32}$/i;
export default class ObserveTrace extends EasCommand {
static override description =
'display every span of a single request trace by its trace ID, oldest first, with child spans indented under their parent. Trace IDs are shown by `eas observe:traces`. In `--json` output a span with no `parentSpanId` is a root span, and a span with no `http` object is not an HTTP request';
static override args = {
traceId: Args.string({
description: 'ID of the trace to display',
required: true,
}),
};
static override flags = {
...EasProjectIdFlag,
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags, args } = await this.parse(ObserveTrace);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const traceId = args.traceId;
if (!TRACE_ID_REGEX.test(traceId)) {
throw new EasCommandError(
`"${traceId}" is not a valid trace ID. A trace ID is 32 hexadecimal characters; copy it from the Trace ID column of \`eas observe:traces\`.`
);
}
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveTrace, {
nonInteractive,
projectIdOverride: flags['project-id'],
});
if (json) {
enableJsonOutput();
}
const trace = await withObservePlanGateHandlingAsync(() =>
ObserveQuery.traceByIdAsync(graphqlClient, { appId: projectId, id: traceId })
);
if (!trace) {
throw new EasCommandError(
`No trace found with ID "${traceId}". Trace IDs come from \`eas observe:traces\`, and traces age out of retention.`
);
}
if (json) {
printJsonOnlyOutput(buildObserveTraceJson(trace));
} else {
Log.addNewLineIfNone();
Log.log(buildObserveTraceTable(trace));
}
}
}
Loaded 100 of 196 files, more files were not shown because too many files have changed in this diff. Show more