Compare commits

...
Author SHA1 Message Date
Krystof WoldrichandClaude Opus 5.5 f3aee2d0ca [build-tools] Record both iPhone Duo panels upright in record-sim
record-sim recorded only the largest framebuffer, which on iPhone Duo is the
inner panel, so recordings of a folded device were black. Record all
integrated panels side by side and rotate each one upright by the
nativeRotation in the device profile.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-29 13:50:26 +02:00
Szymon DziedzicandClaude Opus 5.5 4fb6de564c [build-tools] Disable agent-device internal idle timeouts in remote sessions (#4485)
* [build-tools] Disable agent-device internal idle timeouts in remote sessions

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

* [build-tools] Add changelog entry

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

---------

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

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

# Why

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

# How

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

# Test Plan

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

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

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

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

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

* [ci] Update the ffmpeg install comment

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

---------

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

* [build-tools] Stop automation alongside recording cleanup

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

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

* [build-tools] Extract session host shutdown operations

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

* [eas-cli] Add changelog entry

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 00:21:04 +02:00
Expo CI 5198a2cc9c bump STAGING and PRODUCTION to 24.8.0 in cli-versions.json 2026-09-24 21:22:09 +00:00
Expo CI ca5f0d2306 update CHANGELOG.md for v24.8.0 2026-09-24 21:17:13 +00:00
Expo CI 9eefd5a5c4 v24.8.0 2026-09-24 21:13:58 +00:00
Tomek Czajecki a797caaa8a [eas-cli] Add --build-fingerprint to eas simulator (#4472)
* [eas-cli] Add --build-fingerprint to eas simulator

* [eas-cli] Add changelog entry
2026-09-25 00:05:35 +03:00
Gabe Debes 11323be04b [build-tools] boot the Android emulator headless from argent (#4465)
* [build-tools] start argent with ARGENT_EMULATOR_NO_WINDOW

* [build-tools] add the PR number to the changelog
2026-09-24 12:44:25 -07:00
David Mokos b1f886ef8d allows interpolated URIs in workflow validation (#4329) 2026-09-24 04:57:53 -07:00
David MokosandDavid Mokos 14e75df9e5 [eas-cli] Stop creating CLAUDE.md in new apps (#4446)
* [eas-cli] Stop creating CLAUDE.md in new apps

* [eas-cli] Link changelog entry to PR

---------

Co-authored-by: David Mokos <[email protected]>
2026-09-24 12:19:18 +02:00
Szymon Świerk dd88335654 [eas-cli] Gate the individual ASC API key prompt (#4464) 2026-09-24 01:01:57 +02:00
Gabe Debes 5183a6581a [build-tools] pass the website origin to serve-sim (#4437)
* [build-tools] pass the website origin to serve-sim

* [build-tools] allow deploy previews to reach staging sessions

* [build-tools] allow the website dev ports on staging sessions

* [build-tools] tidy the website origin helper and its tests

* [build-tools] name only https website dev origins
2026-09-23 13:43:06 -07:00
Gabe Debes fb7c812e50 [build-tools] raise the simulator preview stream to 1600px and 10 Mbps (#4439) 2026-09-23 13:01:19 -07:00
Gabe Debes e690ac0060 [build-tools] hand the iOS application launch to serve-sim (#4324) 2026-09-23 09:38:36 -07:00
Szymon Świerk c2190ca74f [eas-cli] Add individual key type prompt for submission keys (#4250) 2026-09-23 16:34:34 +02:00
Szymon Świerk 157b7cb79f [eas-cli] Accept individual ASC API keys for submissions, guard provisioning (#4249)
* [eas-cli] Allow resolving an ASC API key without an issuer ID

* [eas-cli] Accept individual ASC API keys during authentication

* [eas-cli] Guard credential provisioning operations against individual ASC API keys

* [eas-cli] Allow individual ASC API keys for metadata and TestFlight setup

* [eas-cli] Accept individual ASC API keys in EAS Submit

* [eas-cli] Reject individual ASC API keys in the ASC integration flow

* [eas-cli] Update CHANGELOG for individual ASC API key support
2026-09-23 16:21:16 +02:00
Tomasz Mazur 0d2b1c8d88 [eas-cli] add support for build id in workflow:ssh (#4452) 2026-09-23 10:48:18 +02:00
Szymon Dziedzic 788cb57ff1 [eas-cli] Exclude simulator queue time from startup timeout (#4411)
* [eas-cli] Exclude simulator queue time from startup timeout

* [eas-cli] Add PR attribution to simulator changelog

* [eas-cli] Clarify simulator queue wait message
2026-09-22 23:31:13 +02:00
Krystof WoldrichandClaude Opus 5 1634a50b6d [eas-cli] Make sim a hidden alias of simulator in the help print (#4457)
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-09-22 20:04:39 +02:00
Szymon DziedzicandClaude Fable 5.1 805b403ae3 [eas-cli] Make eas simulator commands visible (#4456)
* [eas-cli] Make eas simulator commands visible

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

* [eas-cli] Add PR number to changelog entry

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

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-22 19:28:46 +02:00
Byron Karlen 1672bcdadd [eas-cli] Recognize new Release Manager role (#4300) 2026-09-21 11:32:53 -07:00
Juwan WheatleyandClaude Fable 5.1 30b1eb2dda [eas-cli] add integrations:supabase:advisors command (#4342)
* [eas-cli] add integrations:supabase:advisors command

* [eas-cli] prompt to re-authorize Supabase from integrations:supabase:advisors

* [eas-cli] link each Supabase advisor finding to its dashboard entry

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

* [eas-cli] verify Supabase advisors against deployed API and preserve reauthorization state

* [eas-cli] make Supabase advisor titles clickable

* [eas-cli] remove advisor remediation links from terminal output

* [eas-cli] move Supabase advisor links below issue descriptions

* [eas-cli] print Supabase dashboard URL above advisor findings

* [eas-cli] tighten spacing within Supabase advisor findings

* [eas-cli] address Supabase advisors review feedback

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-21 13:20:28 -04:00
Stanisław Chmiela 99ce876d50 [build-tools] Preserve safe certificate import diagnostics (#4447)
* [build-tools] Preserve safe certificate import diagnostics

* [build-tools] Keep certificate import diagnostics in Keychain

* [build-tools] Simplify import error handling and log diagnostics as errors

* [build-tools] Narrow import failures to Error with optional process fields

* [build-tools] Name import diagnostics and simplify their tests

* [build-tools] Avoid duplicate certificate import diagnostics
2026-09-21 17:40:34 +02:00
Stanisław Chmiela 602502e502 [build-tools] Unify iOS keychain implementations (#4450)
# Why

We had two copies of the Keychain class (from the time period of migrating "legacy build process" to "step-based build process" and copying stuff "not to break anything").

# How

Unified the implementations. The difference between classes was whether the class received `BuildContext` as constructor param or not.

The unified interface requires `logger` being passed in to every command (since we're using the same `Keychain` instance in multiple build phases).

# Test Plan

CI should pass.
2026-09-21 17:14:23 +02:00
Gabe Debes 30a3924f43 [build-tools] pass --share-url so serve-sim copies the expo.dev preview page (#4434)
* [build-tools] pass --share-url so serve-sim copies the expo.dev preview page

* [build-tools] changelog for serve-sim --share-url

* [build-tools] reuse previewPageUrl for serve-sim --share-url
2026-09-17 15:48:10 -07:00
Douglas Lowder d2e6b4c45e [docs] Update RELEASING.md (#4417) 2026-09-17 12:28:30 -07:00
Douglas LowderandClaude Opus 5 419efa485f [ci] Fix and harden release workflow (#4425)
- Rework the workflow so that the changelog is read correctly, and the changelog commit is skipped if the new entry is already present
- Split the npm check into its own release job and raise the timeout
- Serialize release runs with a concurrency group, effectively forcing them to queue

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 12:20:37 -07:00
Will Schurman 🦦 3bb4240cb4 Upgrade typescript to 6.x (#4400)
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Follow-up to #4399 to upgrade typescript to latest version. 

# How

Tell claude to upgrade to 6.x.

# Test Plan

CI, tests
2026-09-17 11:51:16 -07:00
Will Schurman 🦦 1677efc00c Upgrade typescript to highest 5.x (#4399)
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

This is a bit behind in typescript. 6.x is where most of our repos live.

First step is to get to highest 5.x.

# How

Tell claude to upgrade to highest 5.x.

It required a new version of `https-proxy-agent` to be compatible with new node types.

# Test Plan

`yarn start`, tests.
2026-09-17 10:17:45 -07:00
Douglas Lowder c7b3105f9f [eas-cli] Skip managed-workflow checks for custom builds (#4424) 2026-09-16 19:08:46 -07:00
Expo CI 6fceead233 update CHANGELOG.md for v24.7.0 2026-09-16 22:16:08 +00:00
Expo CI 4a109648a6 v24.7.0 2026-09-16 22:13:30 +00:00
Gabe Debes ea139e207c [eas-cli] point preview links at the expo.dev page and expose the preview api url (#4352) 2026-09-16 15:07:49 -07:00
Gabe Debes 7b3c8cf245 [build-tools] report the expo.dev preview page url and the preview api url (#4353) 2026-09-16 14:31:37 -07:00
Gabe Debes efbe0bbdff [build-tools] Launch simulator packages with the resolved package manager (#4343)
* [build-tools] Launch simulator packages with the resolved package manager

Serve-sim, device-hub, and Argent were hardcoded to npx or bunx. Resolve the manager the same way other steps do so EAS_FALLBACK_PACKAGE_MANAGER can switch them off the npm audit path.

* [build-tools] Override the simulator package manager instead of using the lockfile fallback

EAS_FALLBACK_PACKAGE_MANAGER only applies when no lockfile is found, so a worker yarn.lock would keep npx. EAS_OVERRIDE_PACKAGE_MANAGER wins when set. Preview still defaults to npx and Argent to bunx.

* [build-tools] Honor fallback as well as override for simulator package exec

Override still wins when set. If it is unset, EAS_FALLBACK_PACKAGE_MANAGER selects the runner so serve-sim can use bunx without a lockfile-based resolve.

* [build-tools] Add changelog for simulator package manager env

* [build-tools] Format package manager changes

* [build-tools] Honor the simulator package manager for agent-device and Appium

Install these session packages through the same env override/fallback as serve-sim, instead of hardcoding bun add --global and npm install.

* [build-tools] Sort Appium session test imports for lint

* [build-tools] Keep simulator process comments concrete

* [build-tools] Cover simulator package install and agent-device fallback
2026-09-15 16:19:51 -07:00
Gabe Debes 8f645d8870 [build-tools] Default serve-sim iOS previews to H.264 (#4416)
* fix(build-tools): default serve-sim iOS previews to H.264

* changelog
2026-09-15 13:34:00 -07:00
Stanisław Chmiela 97007d17cc [build-tools] Use maestro-runner --parallel execution for shards (#4407)
`maestro-runner` supports sharding!

Runner parallel execution: https://github.com/devicelab-dev/maestro-runner/blob/main/pkg/executor/parallel.go
2026-09-15 19:09:52 +02:00
Expo CI 0dbaa34a8b bump STAGING and PRODUCTION to 24.6.0 in cli-versions.json 2026-09-15 16:21:45 +00:00
Expo CI 2fc537f9a7 update CHANGELOG.md for v24.6.0 2026-09-15 16:17:00 +00:00
Expo CI 71cf0aea74 v24.6.0 2026-09-15 16:14:36 +00:00
Will Schurman 🦦 f6a3028356 [eas-cli] Remove broad primaryAccount querying to support scoped token access (#4380) 2026-09-15 08:38:14 -07:00
Will Schurman 🦦 3182070f4c [eas-cli] Regenerate graphql and fix types (#4379) 2026-09-15 08:14:01 -07:00
Tomasz Mazur 097b58c7a9 [eas-cli] Display build logs when --wait flag is set (#4303)
* [eas-cli] Display build logs when --wait flag is set

* [eas-cli] use custom fetch

* [eas-cli] add timeouts to builds logs watch
2026-09-15 16:57:22 +02:00
Expo CI 9584d05708 bump STAGING and PRODUCTION to 24.5.0 in cli-versions.json 2026-09-15 09:48:19 +00:00
Expo CI b9ac59b336 update CHANGELOG.md for v24.5.0 2026-09-15 09:44:37 +00:00
Expo CI 43068db22a v24.5.0 2026-09-15 09:41:35 +00:00
Stanisław Chmiela 2b7f1954fc [build-tools] Execute sandbox commands over MCP connection (#4347)
## Why

Clients are telling sandboxes through MCP server what to do. Let's handle the requests!

## How

The endeavour starts from a (relatively, after all) complex task of starting commands and reading/writing to them.

Since commands may live longer than a tool call (request followed by a response), we need some kind of manager of running commands — Shell Session Manager. (Session is the running command.)

This is where most of the complexity lays, the rest is mostly a wrapper.

## Test plan

CI should pass. Tested manually.
2026-09-15 11:34:05 +02:00
Expo CI 3b64599aea bump STAGING and PRODUCTION to 24.4.2 in cli-versions.json 2026-09-15 05:07:36 +00:00
Expo CI 96fe9a239e update CHANGELOG.md for v24.4.2 2026-09-15 05:05:39 +00:00
209 changed files with 12923 additions and 2833 deletions

No files matched your search

+74 -9
View File
@@ -5,6 +5,14 @@ on:
name: Release workflow
# Releases push commits to main, so they must never overlap. The group is a
# constant rather than per-ref, so two different tags serialize. Queued runs
# are never cancelled, because a half-finished release leaves npm, the git
# history, and cli-versions.json out of sync.
concurrency:
group: release
cancel-in-progress: false
jobs:
release:
name: Create a release
@@ -47,6 +55,10 @@ jobs:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
token: ${{ secrets.EXPO_BOT_PAT }}
# The changelog is generated on the branch it is committed to, so the
# job never switches branches with a dirty working tree.
ref: main
fetch-depth: 0
- uses: ./.github/actions/setup-mise
- name: Install dependencies
working-directory: ./scripts
@@ -57,23 +69,67 @@ jobs:
run: |
git config user.email "[email protected]"
git config user.name "Expo CI"
- name: Check whether the changelog is already updated
run: |
if grep -qF "## [$EAS_CLI_VERSION](" CHANGELOG.md; then
echo "CHANGELOG.md already has an entry for v$EAS_CLI_VERSION."
echo "CHANGELOG_ALREADY_UPDATED=true" >> "$GITHUB_ENV"
else
echo "CHANGELOG_ALREADY_UPDATED=false" >> "$GITHUB_ENV"
fi
- name: Update changelog
if: env.CHANGELOG_ALREADY_UPDATED == 'false'
working-directory: ./scripts
run: yarn release-changelog "$EAS_CLI_VERSION" > /tmp/current-changelog
- name: Reuse the existing changelog entry
if: env.CHANGELOG_ALREADY_UPDATED == 'true'
run: |
{
echo "v$EAS_CLI_VERSION"
echo
awk -v heading="## [$EAS_CLI_VERSION](" '
index($0, heading) == 1 { found = 1; next }
found && /^## / { exit }
found { print }
' CHANGELOG.md | sed '/./,$!d'
} > /tmp/current-changelog
- name: Publish release
run: gh release edit "v$EAS_CLI_VERSION" --draft=false -F /tmp/current-changelog
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Commit and push changelog
run: |
git fetch
git checkout main
git add CHANGELOG.md
git commit --allow-empty -m "update CHANGELOG.md for v$EAS_CLI_VERSION"
git push
if git diff --cached --quiet; then
echo "No changelog changes to commit."
else
git commit -m "update CHANGELOG.md for v$EAS_CLI_VERSION"
git pull --rebase --autostash origin main
git push
fi
bump-cli-versions:
name: Gate on npm and bump cli-versions.json
runs-on: ubuntu-latest
needs: release-changelog
permissions:
contents: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
token: ${{ secrets.EXPO_BOT_PAT }}
ref: main
fetch-depth: 0
- uses: ./.github/actions/setup-mise
- name: Resolve version
run: echo "EAS_CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_ENV"
- name: Configure git
run: |
git config user.email "[email protected]"
git config user.name "Expo CI"
- name: Verify eas-cli is available on npm
run: |
availability_deadline_epoch=$(( $(date +%s) + 600 ))
availability_timeout_seconds=1800
availability_deadline_epoch=$(( $(date +%s) + availability_timeout_seconds ))
while true; do
published="$(npm view "eas-cli@$EAS_CLI_VERSION" version --prefer-online 2>/dev/null || true)"
@@ -95,7 +151,7 @@ jobs:
sleep "$retry_delay"
done
echo "eas-cli@$EAS_CLI_VERSION was not available on npm after 10 minutes." >&2
echo "eas-cli@$EAS_CLI_VERSION was not available on npm after ${availability_timeout_seconds}s." >&2
exit 1
- name: Bump STAGING and PRODUCTION
run: |
@@ -109,11 +165,20 @@ jobs:
- name: Commit and push version update
run: |
git add cli-versions.json
git commit --allow-empty -m "bump STAGING and PRODUCTION to $EAS_CLI_VERSION in cli-versions.json"
git push
if git diff --cached --quiet; then
echo "cli-versions.json already points at $EAS_CLI_VERSION."
else
git commit -m "bump STAGING and PRODUCTION to $EAS_CLI_VERSION in cli-versions.json"
git pull --rebase --autostash origin main
git push
fi
- name: Prepare changelog for Slack
run: |
tail -n +3 /tmp/current-changelog > /tmp/changelog.md
awk -v heading="## [$EAS_CLI_VERSION](" '
index($0, heading) == 1 { found = 1; next }
found && /^## / { exit }
found { print }
' CHANGELOG.md | sed '/./,$!d' > /tmp/changelog.md
npx slackmd /tmp/changelog.md
{
echo 'SLACK_CHANGELOG<<EOF'
+3
View File
@@ -26,6 +26,9 @@ jobs:
uses: ./.github/actions/setup-mise
env:
MISE_NODE_VERSION: ${{ matrix.node }}
- name: Install ffmpeg
# build-tools recording-recovery tests cut and decode real MP4 files with ffmpeg and ffprobe.
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
- name: Install dependencies
run: yarn install --immutable
env:
+75 -2
View File
@@ -8,12 +8,85 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-cli] Allow workflow submissions to skip loading app config when project and application identifiers are supplied. ([#4471](https://github.com/expo/eas-cli/pull/4471) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Record Android device sessions and upload the screen recording, including the partial recording a killed Device Hub leaves behind. ([#4418](https://github.com/expo/eas-cli/pull/4418) by [@vonovak](https://github.com/vonovak))
### 🐛 Bug fixes
- [build-tools] Disable agent-device's internal idle timeouts in remote sessions, so only the session's own idle and duration limits end it. ([#4485](https://github.com/expo/eas-cli/pull/4485) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Record both iPhone Duo panels side by side and upright, so iOS Simulator screen recordings are no longer black while the device is folded.
### 🧹 Chores
## [24.8.0](https://github.com/expo/eas-cli/releases/tag/v24.8.0) - 2026-09-24
### 🎉 New features
- [eas-cli] Add `--build-fingerprint` to `eas simulator` to install the newest build with a given fingerprint. ([#4472](https://github.com/expo/eas-cli/pull/4472) by [@tchayen](https://github.com/tchayen))
- [eas-cli] Accept build IDs in `workflow:ssh <id>`. ([#4452](https://github.com/expo/eas-cli/pull/4452) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [eas-cli] Make `eas simulator:*` commands visible in help output. ([#4456](https://github.com/expo/eas-cli/pull/4456) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Pass `--share-url` when launching serve-sim so Share copies the expo.dev preview page instead of the tunnel URL. ([#4434](https://github.com/expo/eas-cli/pull/4434) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas integrations:supabase:advisors` to list unresolved Supabase Security and Performance Advisor findings for the linked Supabase project. ([#4342](https://github.com/expo/eas-cli/pull/4342) by [@fiberjw](https://github.com/fiberjw))
- [eas-cli] Accept individual App Store Connect API keys for submissions, TestFlight setup, and metadata. ([#4249](https://github.com/expo/eas-cli/pull/4249) by [@sswrk](https://github.com/sswrk))
- [build-tools] Add `launch_app_identifier`, `launch_args`, and `open_url` inputs to the serve-sim, agent-device, argent, and Appium remote sessions, so serve-sim launches an installed app before the stream starts. The inputs are iOS only, and Android sessions keep using `eas/launch_application`. ([#4324](https://github.com/expo/eas-cli/pull/4324) by [@gwdp](https://github.com/gwdp))
- [build-tools] Stream iOS Simulator previews at 1600px and 10 Mbps. ([#4439](https://github.com/expo/eas-cli/pull/4439) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [build-tools] Report early Argent server exits during simulator startup instead of waiting for the full readiness timeout. ([#4454](https://github.com/expo/eas-cli/pull/4454) by [@sjkim-expo](https://github.com/sjkim-expo))
- [eas-cli] Stop spinners from printing a new line on every frame after the terminal gets narrower. ([#4470](https://github.com/expo/eas-cli/pull/4470) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Exclude time waiting for simulator concurrency from the session startup timeout and show queue progress. ([#4411](https://github.com/expo/eas-cli/pull/4411) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] List `eas simulator` only once in `eas --help`. The `sim` shorthand is now a hidden alias, so it still runs but no longer duplicates the entry. ([#4457](https://github.com/expo/eas-cli/pull/4457) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [build-tools] Start the Argent tool-server with `ARGENT_EMULATOR_NO_WINDOW=1`, so Argent can boot the Android emulator again after it stops. Without it, the windowed emulator fails on the Linux image because `libpulse.so.0` is missing. ([#4465](https://github.com/expo/eas-cli/pull/4465) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Fix `eas account:view` printing `Role: undefined` for the new Release Manager role. ([#4300](https://github.com/expo/eas-cli/pull/4300) by [@byronkarlen](https://github.com/byronkarlen))
- [eas-cli] Include legacy serve-sim sessions in `eas simulator:list --type web-preview-only`, and list `web-preview-only` only once in the `--type` options. ([#4466](https://github.com/expo/eas-cli/pull/4466) by [@szdziedzic](https://github.com/szdziedzic))
### 🧹 Chores
- [eas-cli] Stop creating `CLAUDE.md` in new apps; use `AGENTS.md` for agent instructions. ([#4446](https://github.com/expo/eas-cli/pull/4446) by [@davidmokos](https://github.com/davidmokos))
- Upgrade `typescript` to 6.0.3, `ts-jest` to 29.4.12, `@types/node` to 20.19.43, `gql.tada` to 1.11.3, and `https-proxy-agent` to 7.0.6. ([#XXXX](https://github.com/expo/eas-cli/pull/XXXX) by [@wschurman](https://github.com/wschurman))
- [build-tools] Use the website origin for simulator preview CORS and framing, and allow deploy previews and local website dev servers on staging. ([#4437](https://github.com/expo/eas-cli/pull/4437) by [@gwdp](https://github.com/gwdp))
## [24.7.0](https://github.com/expo/eas-cli/releases/tag/v24.7.0) - 2026-09-16
### 🎉 New features
- [build-tools] Stream iOS Simulator previews with H.264. ([#4416](https://github.com/expo/eas-cli/pull/4416) by [@gwdp](https://github.com/gwdp))
- [build-tools] Report the expo.dev preview page as a simulator session's preview URL, and the preview server behind it as `previewApiUrl`. ([#4353](https://github.com/expo/eas-cli/pull/4353) by [@gwdp](https://github.com/gwdp))
- [eas-cli] `eas simulator:start` and `eas simulator:get` now report the expo.dev preview page instead of the raw tunnel URL, in both the printed instructions and `--json`. ([#4352](https://github.com/expo/eas-cli/pull/4352) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [eas-cli] Skip the managed-workflow Expo SDK and Metro config checks for builds that use a custom build config, since those builds run their own steps instead of the standard managed pipeline. ([#4424](https://github.com/expo/eas-cli/pull/4424) by [@douglowder](https://github.com/douglowder))
- [build-tools] Honor `EAS_OVERRIDE_PACKAGE_MANAGER` and `EAS_FALLBACK_PACKAGE_MANAGER` when installing and launching simulator packages (serve-sim, device-hub, Argent, agent-device, Appium) so they can use bun instead of stalling on npm audit. ([#4343](https://github.com/expo/eas-cli/pull/4343) by [@gwdp](https://github.com/gwdp))
## [24.6.0](https://github.com/expo/eas-cli/releases/tag/v24.6.0) - 2026-09-15
### 🎉 New features
- [eas-cli] Display logs in `eas build --wait`. ([#4303](https://github.com/expo/eas-cli/pull/4303) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### 🐛 Bug fixes
- [build-tools] Skip project source refresh for jobs with no sources. ([#4389](https://github.com/expo/eas-cli/pull/4389) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Stop fetching the primary account in the login query. Commands that need it now fetch it on demand, so scoped access tokens without primary account access no longer fail at login. ([#4380](https://github.com/expo/eas-cli/pull/4380) by [@wschurman](https://github.com/wschurman))
### 🧹 Chores
- [eas-cli] Regenerate GraphQL types and update Observe tests for the new `firstSeenAt` and `lastSeenAt` event-name fields. ([#4379](https://github.com/expo/eas-cli/pull/4379) by [@wschurman](https://github.com/wschurman))
## [24.5.0](https://github.com/expo/eas-cli/releases/tag/v24.5.0) - 2026-09-15
### 🎉 New features
- [build-tools] Let connected sandboxes run shell commands through the Expo MCP server. ([#4347](https://github.com/expo/eas-cli/pull/4347) by [@sjchmiela](https://github.com/sjchmiela))
## [24.4.2](https://github.com/expo/eas-cli/releases/tag/v24.4.2) - 2026-09-15
### 🐛 Bug fixes
- [eas-cli] Support Xcode 27, which replaces Simulator.app with Device Hub, in `build:dev`, `build:run`, and `run` for iOS simulator builds. ([#4405](https://github.com/expo/eas-cli/pull/4405) by [@brentvatne](https://github.com/brentvatne))
### 🧹 Chores
## [24.4.1](https://github.com/expo/eas-cli/releases/tag/v24.4.1) - 2026-09-15
### 🧹 Chores
+2 -2
View File
@@ -3,7 +3,7 @@
1. Invoke the GitHub workflow ["Manually trigger a release"](https://github.com/expo/eas-cli/actions/workflows/trigger-release.yml). The next version is chosen automatically based on the changelog entries. If you want to use different version, pass the version string as an input to the workflow, in semver format, e.g. "1.2.3"
2. That's it! GitHub Actions is going to take care of the rest. Watch the #eas-cli Slack channel for a successful release notification.
The release also sets the `STAGING` version in `cli-versions.json`. It does not change the `PRODUCTION` version. See [EAS Build CLI versions](#eas-build-cli-versions).
The release also sets the `STAGING` and `PRODUCTION` versions in `cli-versions.json`. See [EAS Build CLI versions](#eas-build-cli-versions).
## EAS Build CLI versions
@@ -14,7 +14,7 @@ The release also sets the `STAGING` version in `cli-versions.json`. It does not
This file replaces the old `latest-eas-build` and `latest-eas-build-staging` npm dist-tags. The build servers read the file from the `main` branch.
A release updates `STAGING` to the new version automatically. To promote a version to production, invoke the GitHub workflow ["Promote eas-cli to production"](https://github.com/expo/eas-cli/actions/workflows/promote-eas-cli-production.yml). Pass the version to promote as an input. If you leave the input empty, the workflow uses the current `STAGING` version. The workflow checks that the version is published on npm before it updates `PRODUCTION`.
A release updates `STAGING` and `PRODUCTION` to the new version automatically. To set a different version to production (to fix a regression, for example), invoke the GitHub workflow ["Promote eas-cli to production"](https://github.com/expo/eas-cli/actions/workflows/promote-eas-cli-production.yml). Pass the version to promote as an input. If you leave the input empty, the workflow uses the current `STAGING` version. The workflow checks that the version is published on npm before it updates `PRODUCTION`.
## Choosing the next version
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "24.4.1",
"PRODUCTION": "24.4.1"
"STAGING": "24.8.0",
"PRODUCTION": "24.8.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "24.4.2",
"version": "24.8.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+3 -3
View File
@@ -22,7 +22,7 @@
},
"devDependencies": {
"@types/jest": "29.5.11",
"@types/node": "20.14.8",
"@types/node": "20.19.43",
"eslint-plugin-async-protect": "3.1.0",
"jest": "29.7.0",
"jest-watch-typeahead": "2.2.2",
@@ -32,8 +32,8 @@
"oxlint": "1.43.0",
"oxlint-tsgolint": "0.11.1",
"rimraf": "3.0.2",
"ts-jest": "29.1.1",
"typescript": "5.5.4"
"ts-jest": "29.4.12",
"typescript": "6.0.3"
},
"packageManager": "[email protected]"
}
+12 -11
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "24.4.0",
"version": "24.8.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -41,17 +41,17 @@
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "24.0.0",
"@expo/eas-build-job": "24.3.0",
"@expo/downloader": "24.8.0",
"@expo/eas-build-job": "24.8.0",
"@expo/env": "^0.4.0",
"@expo/logger": "24.0.0",
"@expo/logger": "24.8.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "24.3.0",
"@expo/template-file": "24.0.0",
"@expo/turtle-spawn": "24.0.0",
"@expo/steps": "24.8.0",
"@expo/template-file": "24.8.0",
"@expo/turtle-spawn": "24.8.0",
"@expo/xcpretty": "^4.3.1",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
@@ -61,12 +61,13 @@
"fast-glob": "^3.3.2",
"fast-xml-parser": "^4.4.1",
"fs-extra": "^11.2.0",
"gql.tada": "^1.8.13",
"gql.tada": "^1.11.3",
"joi": "^17.13.1",
"jose": "5.10.0",
"lodash": "^4.18.1",
"node-fetch": "^2.7.0",
"node-forge": "^1.3.1",
"node-pty": "1.2.0-beta.15",
"node-stream-zip": "1.15.0",
"nullthrows": "^1.1.1",
"plist": "^3.1.0",
@@ -87,7 +88,7 @@
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.4",
"@types/node": "20.14.2",
"@types/node": "20.19.43",
"@types/node-fetch": "^2.6.11",
"@types/node-forge": "^1.3.11",
"@types/plist": "^3.0.5",
@@ -100,10 +101,10 @@
"memfs": "^4.17.1",
"nock": "13.4.0",
"rimraf": "3.0.2",
"ts-jest": "^29.1.4",
"ts-jest": "^29.4.12",
"ts-mockito": "^2.6.1",
"tslib": "^2.6.3",
"typescript": "^5.5.4",
"typescript": "^6.0.3",
"uuid": "^9.0.1"
}
}
@@ -15,6 +15,7 @@ let package = Package(
.target(
name: "RecordSim",
linkerSettings: [
.linkedFramework("Accelerate"),
.linkedFramework("AVFoundation"),
.linkedFramework("CoreGraphics"),
.linkedFramework("CoreMedia"),
@@ -9,6 +9,13 @@ It implements one recording path:
3. Feed one continuous `AVAssetWriter`.
4. Emit Apple HLS/CMAF-style fragmented MP4 segments.
Foldable simulators such as iPhone Duo keep a framebuffer for every integrated
panel. The recorder puts all integrated panels side by side in one video, in
screen ID order, so the video follows the device when it folds. The panel that
is not in use is black. Each panel is rotated upright by the `nativeRotation`
in the device profile, which turns the iPhone Duo inner panel 90 degrees
clockwise. Other simulators record their largest framebuffer.
The output is suitable for uploading while recording. `session.json` contains
the ordered segment metadata needed to build a playlist later.
@@ -8,6 +8,18 @@ final class FramebufferDisplaySource {
let width: Int
let height: Int
let seed: UInt32
/// Clockwise rotation in degrees (0, 90, 180, or 270) that shows the panel upright.
let rotation: Int
var uprightWidth: Int { rotation % 180 == 0 ? width : height }
var uprightHeight: Int { rotation % 180 == 0 ? height : width }
}
/// The panels to record, in a stable order. A nil entry is a panel that has no surface yet.
struct FrameSnapshot {
let panels: [SurfaceSnapshot?]
var seeds: [UInt32?] { panels.map { $0?.seed } }
}
private let deviceUDID: String
@@ -15,6 +27,10 @@ final class FramebufferDisplaySource {
private let onFrame: () -> Void
private let onSurfaceChange: () -> Void
private var descriptors: [NSObject] = []
/// Integrated panels in screen ID order, which puts the iPhone Duo front panel first.
private var integratedPanels: [(descriptor: NSObject, rotation: Int)] = []
/// Mounting rotation of each integrated panel from the device profile, by screen ID.
private var nativeRotations: [UInt32: Int] = [:]
private var callbackUUIDs: [ObjectIdentifier: NSUUID] = [:]
private var retainedBlocks: [AnyObject] = []
private var ioClient: NSObject?
@@ -46,40 +62,54 @@ final class FramebufferDisplaySource {
throw RecorderError.make(3, "Failed to get simulator IO client")
}
ioClient = io
nativeRotations = Self.nativeRotations(device: device)
try wireUpFramebuffer()
}
func stop() {
unregisterCallbacks()
descriptors.removeAll()
integratedPanels.removeAll()
retainedBlocks.removeAll()
ioClient = nil
}
func surfaceSnapshot() -> SurfaceSnapshot? {
let surfaceSelector = NSSelectorFromString("framebufferSurface")
var bestSnapshot: SurfaceSnapshot?
var bestArea = 0
for descriptor in descriptors {
guard let surfaceObject = descriptor.perform(surfaceSelector)?.takeUnretainedValue()
else {
continue
}
let surface = unsafeBitCast(surfaceObject, to: IOSurface.self)
let width = IOSurfaceGetWidth(surface)
let height = IOSurfaceGetHeight(surface)
let area = width * height
if area > bestArea {
bestSnapshot = SurfaceSnapshot(
surface: surface,
width: width,
height: height,
seed: IOSurfaceGetSeed(surface)
)
bestArea = area
/// Foldable simulators such as iPhone Duo keep an IOSurface alive for every integrated panel,
/// and the panel that is not in use stays black. Record all integrated panels so the video
/// follows a fold; other simulators record their largest surface.
func frameSnapshot() -> FrameSnapshot? {
if integratedPanels.count >= 2 {
let panels = integratedPanels.map {
surfaceSnapshot(for: $0.descriptor, rotation: $0.rotation)
}
return panels.contains { $0 != nil } ? FrameSnapshot(panels: panels) : nil
}
return bestSnapshot
let largest = descriptors.compactMap { surfaceSnapshot(for: $0, rotation: 0) }.max {
$0.width * $0.height < $1.width * $1.height
}
return largest.map { FrameSnapshot(panels: [$0]) }
}
private func surfaceSnapshot(for descriptor: NSObject, rotation: Int) -> SurfaceSnapshot? {
guard
let surfaceObject = descriptor.perform(NSSelectorFromString("framebufferSurface"))?
.takeUnretainedValue()
else {
return nil
}
let surface = unsafeBitCast(surfaceObject, to: IOSurface.self)
let width = IOSurfaceGetWidth(surface)
let height = IOSurfaceGetHeight(surface)
guard width > 0, height > 0 else {
return nil
}
return SurfaceSnapshot(
surface: surface,
width: width,
height: height,
seed: IOSurfaceGetSeed(surface),
rotation: rotation
)
}
func rewireFramebuffer() throws {
@@ -94,6 +124,12 @@ final class FramebufferDisplaySource {
let nextDescriptors = try findFramebufferDescriptors(io: io)
unregisterCallbacks()
descriptors = nextDescriptors
integratedPanels = Self.integratedPanels(in: nextDescriptors).map { panel in
// A panel mounted at 270 degrees, such as the iPhone Duo inner panel, turns 90
// degrees clockwise to be upright.
let nativeRotation = nativeRotations[panel.screenID] ?? 0
return (panel.descriptor, (360 - nativeRotation) % 360)
}
retainedBlocks.removeAll()
do {
for descriptor in descriptors {
@@ -102,6 +138,7 @@ final class FramebufferDisplaySource {
} catch {
unregisterCallbacks()
descriptors.removeAll()
integratedPanels.removeAll()
retainedBlocks.removeAll()
throw error
}
@@ -135,6 +172,61 @@ final class FramebufferDisplaySource {
return candidates
}
/// Xcode releases without the screen properties API report no integrated panels, which keeps
/// them on the largest-surface path.
private static func integratedPanels(
in descriptors: [NSObject]
) -> [(screenID: UInt32, descriptor: NSObject)] {
typealias Panel = (screenID: UInt32, descriptor: NSObject)
let panels = descriptors.compactMap { descriptor -> Panel? in
let selector = NSSelectorFromString("screenProperties")
guard descriptor.responds(to: selector),
let properties = descriptor.perform(selector)?.takeUnretainedValue()
else {
return nil
}
let object: AnyObject = properties
guard object.recordSimScreenType?() == integratedScreenType,
let screenID = object.recordSimScreenID?()
else {
return nil
}
return (screenID, descriptor)
}
return panels.sorted { $0.screenID < $1.screenID }
}
/// The device profile that serve-sim also reads. A missing profile means no rotation.
private static func nativeRotations(device: NSObject) -> [UInt32: Int] {
let typeSelector = NSSelectorFromString("deviceType")
let capabilitiesSelector = NSSelectorFromString("capabilities")
guard device.responds(to: typeSelector),
let type = device.perform(typeSelector)?.takeUnretainedValue() as? NSObject,
type.responds(to: capabilitiesSelector),
let profile = type.perform(capabilitiesSelector)?.takeUnretainedValue()
as? [String: Any],
let capabilities = profile["capabilities"] as? [String: Any],
let displays = capabilities["displays"] as? [[String: Any]]
else {
return [:]
}
var rotations: [UInt32: Int] = [:]
for display in displays {
guard display["displayType"] as? String == "integrated",
let id = display["screenID"] as? NSNumber,
let screenID = UInt32(exactly: id.int64Value),
let rotation = display["nativeRotation"] as? NSNumber,
[0, 90, 180, 270].contains(rotation.intValue)
else {
continue
}
rotations[screenID] = rotation.intValue
}
return rotations
}
private static let integratedScreenType: UInt64 = 0
private func registerCallbacks(descriptor: NSObject) throws {
let selector = NSSelectorFromString(
"registerScreenCallbacksWithUUID:callbackQueue:frameCallback:surfacesChangedCallback:propertiesChangedCallback:"
@@ -190,3 +282,10 @@ final class FramebufferDisplaySource {
callbackUUIDs.removeAll()
}
}
/// Getters from the CoreSimDeviceIO SimScreenProperties protocol. Optional dispatch keeps Xcode
/// releases that lack them working.
@objc private protocol SimScreenPropertiesAccess {
@objc(screenID) func recordSimScreenID() -> UInt32
@objc(screenType) func recordSimScreenType() -> UInt64
}
@@ -1,4 +1,5 @@
import AVFoundation
import Accelerate
import CoreVideo
import Foundation
import IOSurface
@@ -27,10 +28,9 @@ public final class SimulatorRecorder {
private var monotonicClock = MonotonicClock()
private var firstAcceptedCaptureTime: CMTime?
private var firstAcceptedWallClock: Date?
private var recordingWidth: Int?
private var recordingHeight: Int?
private var canvasLayout: PanelCanvasLayout?
private var lastPTS: CMTime?
private var lastSeed: UInt32?
private var lastSeeds: [UInt32?]?
private var lastFrameCallbackElapsed: TimeInterval?
private var lastFirstFrameRewireElapsed: TimeInterval = 0
private var lastAppendedPixelBuffer: CVPixelBuffer?
@@ -147,14 +147,14 @@ public final class SimulatorRecorder {
guard let firstAcceptedWallClock else {
throw RecorderError.make(25, "Missing first frame wall-clock timestamp")
}
guard let recordingWidth, let recordingHeight else {
guard let canvasLayout else {
throw RecorderError.make(27, "Missing recording dimensions")
}
return try outputWriter.writeManifest(
configuration: configuration,
firstFrameWallClock: firstAcceptedWallClock,
width: recordingWidth,
height: recordingHeight
width: canvasLayout.width,
height: canvasLayout.height
)
}
}
@@ -239,19 +239,13 @@ public final class SimulatorRecorder {
if reason == .callback {
lastFrameCallbackElapsed = monotonicClock.elapsedSeconds()
}
guard let snapshot = displaySource?.surfaceSnapshot() else {
return
}
let surface = snapshot.surface
let surfaceWidth = snapshot.width
let surfaceHeight = snapshot.height
guard surfaceWidth > 0, surfaceHeight > 0 else {
guard let snapshot = displaySource?.frameSnapshot() else {
return
}
let seed = snapshot.seed
let matchesLastAppendedSeed = writerQueue.sync { lastSeed == seed }
if !force, matchesLastAppendedSeed {
let seeds = snapshot.seeds
let matchesLastAppendedSeeds = writerQueue.sync { lastSeeds == seeds }
if !force, matchesLastAppendedSeeds {
return
}
@@ -262,14 +256,10 @@ public final class SimulatorRecorder {
let capturedAt = monotonicClock.elapsedTime()
let capturedAtWallClock = Date()
do {
let pixelBuffer = try copySurfaceToOwnedPixelBuffer(
surface,
width: surfaceWidth,
height: surfaceHeight
)
let pixelBuffer = try copyFrameToOwnedPixelBuffer(snapshot)
appendOwned(
pixelBuffer: pixelBuffer,
seed: seed,
seeds: seeds,
capturedAt: capturedAt,
capturedAtWallClock: capturedAtWallClock
)
@@ -296,17 +286,14 @@ public final class SimulatorRecorder {
return
}
guard let snapshot = displaySource.surfaceSnapshot(),
snapshot.width > 0,
snapshot.height > 0
else {
guard let snapshot = displaySource.frameSnapshot() else {
rewireFramebuffer()
return
}
let observedSeed = snapshot.seed
let lastAppendedSeed = writerQueue.sync { lastSeed }
guard let lastAppendedSeed, observedSeed != lastAppendedSeed else {
let observedSeeds = snapshot.seeds
let lastAppendedSeeds = writerQueue.sync { lastSeeds }
guard let lastAppendedSeeds, observedSeeds != lastAppendedSeeds else {
return
}
@@ -369,22 +356,20 @@ public final class SimulatorRecorder {
pendingLock.unlock()
}
private func copySurfaceToOwnedPixelBuffer(
_ surface: IOSurface,
width: Int,
height: Int
private func copyFrameToOwnedPixelBuffer(
_ snapshot: FramebufferDisplaySource.FrameSnapshot
) throws -> CVPixelBuffer {
let pool = try writerQueue.sync {
let (pool, layout) = try writerQueue.sync {
if let firstError {
throw firstError
}
if writer == nil {
try startWriter(width: width, height: height)
try startWriter(layout: PanelCanvasLayout(panels: snapshot.panels))
}
guard let pool = adaptor?.pixelBufferPool else {
guard let pool = adaptor?.pixelBufferPool, let canvasLayout else {
throw RecorderError.make(40, "AVAssetWriter pixel buffer pool is unavailable")
}
return pool
return (pool, canvasLayout)
}
var output: CVPixelBuffer?
@@ -397,39 +382,129 @@ public final class SimulatorRecorder {
guard destinationLockStatus == kCVReturnSuccess else {
throw RecorderError.make(50, "Failed to lock pixel buffer: \(destinationLockStatus)")
}
let surfaceLockStatus = IOSurfaceLock(surface, .readOnly, nil)
guard surfaceLockStatus == KERN_SUCCESS else {
CVPixelBufferUnlockBaseAddress(destination, [])
throw RecorderError.make(49, "Failed to lock IOSurface: \(surfaceLockStatus)")
}
defer {
IOSurfaceUnlock(surface, .readOnly, nil)
CVPixelBufferUnlockBaseAddress(destination, [])
}
defer { CVPixelBufferUnlockBaseAddress(destination, []) }
guard let destinationAddress = CVPixelBufferGetBaseAddress(destination) else {
throw RecorderError.make(42, "Pixel buffer has no base address")
}
let sourceAddress = IOSurfaceGetBaseAddress(surface)
let sourceStride = IOSurfaceGetBytesPerRow(surface)
let destinationStride = CVPixelBufferGetBytesPerRow(destination)
let copyWidth = min(width, IOSurfaceGetWidth(surface), CVPixelBufferGetWidth(destination))
let copyHeight = min(
height, IOSurfaceGetHeight(surface), CVPixelBufferGetHeight(destination))
let copyBytes = min(copyWidth * 4, sourceStride, destinationStride)
for row in 0..<copyHeight {
memcpy(
destinationAddress.advanced(by: row * destinationStride),
sourceAddress.advanced(by: row * sourceStride),
copyBytes
let destinationWidth = CVPixelBufferGetWidth(destination)
let destinationHeight = CVPixelBufferGetHeight(destination)
for (index, slot) in layout.slots.enumerated() {
let slotWidth = max(0, min(slot.width, destinationWidth - slot.x))
guard slotWidth > 0 else {
continue
}
let panel = index < snapshot.panels.count ? snapshot.panels[index] : nil
try copyPanel(
panel?.surface,
rotation: panel?.rotation ?? 0,
into: destinationAddress.advanced(by: slot.x * 4),
stride: destinationStride,
width: slotWidth,
height: destinationHeight
)
}
return destination
}
/// Pool buffers are reused, so every pixel of the region that the surface does not cover,
/// including the whole region of a panel without a surface, is cleared to black.
private func copyPanel(
_ surface: IOSurface?,
rotation: Int,
into destinationAddress: UnsafeMutableRawPointer,
stride destinationStride: Int,
width: Int,
height: Int
) throws {
let regionBytes = width * 4
guard let surface else {
for row in 0..<height {
memset(destinationAddress.advanced(by: row * destinationStride), 0, regionBytes)
}
return
}
let surfaceLockStatus = IOSurfaceLock(surface, .readOnly, nil)
guard surfaceLockStatus == KERN_SUCCESS else {
throw RecorderError.make(49, "Failed to lock IOSurface: \(surfaceLockStatus)")
}
defer { IOSurfaceUnlock(surface, .readOnly, nil) }
let sourceAddress = IOSurfaceGetBaseAddress(surface)
let sourceStride = IOSurfaceGetBytesPerRow(surface)
if rotation != 0 {
try rotatePanel(
surface,
rotation: rotation,
into: destinationAddress,
stride: destinationStride,
width: width,
height: height
)
return
}
let copyHeight = min(height, IOSurfaceGetHeight(surface))
let copyBytes = min(min(width, IOSurfaceGetWidth(surface)) * 4, sourceStride)
for row in 0..<height {
let destinationRow = destinationAddress.advanced(by: row * destinationStride)
let copiedBytes = row < copyHeight ? copyBytes : 0
if copiedBytes > 0 {
memcpy(destinationRow, sourceAddress.advanced(by: row * sourceStride), copiedBytes)
}
if copiedBytes < regionBytes {
memset(destinationRow.advanced(by: copiedBytes), 0, regionBytes - copiedBytes)
}
}
}
/// Expects the surface to be locked. vImage centers a surface whose upright size differs from
/// the region and fills the rest with black.
private func rotatePanel(
_ surface: IOSurface,
rotation: Int,
into destinationAddress: UnsafeMutableRawPointer,
stride destinationStride: Int,
width: Int,
height: Int
) throws {
let rotationConstant: Int
switch rotation {
case 90: rotationConstant = kRotate90DegreesClockwise
case 180: rotationConstant = kRotate180DegreesClockwise
case 270: rotationConstant = kRotate270DegreesClockwise
default: throw RecorderError.make(51, "Unsupported panel rotation: \(rotation)")
}
let surfaceWidth = IOSurfaceGetWidth(surface)
let surfaceHeight = IOSurfaceGetHeight(surface)
let rotatedHeight = min(height, rotation == 180 ? surfaceHeight : surfaceWidth)
var source = vImage_Buffer(
data: IOSurfaceGetBaseAddress(surface),
height: vImagePixelCount(surfaceHeight),
width: vImagePixelCount(surfaceWidth),
rowBytes: IOSurfaceGetBytesPerRow(surface)
)
var destination = vImage_Buffer(
data: destinationAddress,
height: vImagePixelCount(rotatedHeight),
width: vImagePixelCount(width),
rowBytes: destinationStride
)
var black: [UInt8] = [0, 0, 0, 0]
let error = vImageRotate90_ARGB8888(
&source, &destination, UInt8(rotationConstant), &black, vImage_Flags(kvImageNoFlags))
guard error == kvImageNoError else {
throw RecorderError.make(52, "Failed to rotate panel: \(error)")
}
for row in rotatedHeight..<height {
memset(destinationAddress.advanced(by: row * destinationStride), 0, width * 4)
}
}
private func appendOwned(
pixelBuffer: CVPixelBuffer,
seed: UInt32,
seeds: [UInt32?],
capturedAt: CMTime,
capturedAtWallClock: Date
) {
@@ -441,7 +516,7 @@ public final class SimulatorRecorder {
do {
try self.appendOnWriterQueue(
pixelBuffer: pixelBuffer,
seed: seed,
seeds: seeds,
capturedAt: capturedAt,
capturedAtWallClock: capturedAtWallClock
)
@@ -454,7 +529,7 @@ public final class SimulatorRecorder {
private func appendOnWriterQueue(
pixelBuffer: CVPixelBuffer,
seed: UInt32,
seeds: [UInt32?],
capturedAt: CMTime,
capturedAtWallClock: Date
) throws {
@@ -480,7 +555,7 @@ public final class SimulatorRecorder {
firstAcceptedWallClock = capturedAtWallClock
}
lastPTS = pts
lastSeed = seed
lastSeeds = seeds
lastAppendedPixelBuffer = pixelBuffer
signalFirstFrameReadyIfNeeded()
}
@@ -552,7 +627,9 @@ public final class SimulatorRecorder {
return CMTimeSubtract(capturedAt, start)
}
private func startWriter(width: Int, height: Int) throws {
private func startWriter(layout: PanelCanvasLayout) throws {
let width = layout.width
let height = layout.height
guard let outputWriter else {
throw RecorderError.make(46, "Missing output writer")
}
@@ -600,8 +677,7 @@ public final class SimulatorRecorder {
self.writer = writer
self.input = input
self.adaptor = adaptor
recordingWidth = width
recordingHeight = height
canvasLayout = layout
}
private func assetWriterSettings(width: Int, height: Int) -> [String: Any] {
@@ -642,3 +718,29 @@ public final class SimulatorRecorder {
}
}
}
/// Upright panels side by side, top aligned, sized from the first frame. A panel without a
/// surface in that frame gets no region.
private struct PanelCanvasLayout {
struct Slot {
let x: Int
let width: Int
}
let slots: [Slot]
let width: Int
let height: Int
init(panels: [FramebufferDisplaySource.SurfaceSnapshot?]) {
var x = 0
var slots: [Slot] = []
for panel in panels {
let width = panel?.uprightWidth ?? 0
slots.append(Slot(x: x, width: width))
x += width
}
self.slots = slots
width = x
height = panels.compactMap { $0?.uprightHeight }.max() ?? 0
}
}
@@ -1,11 +1,9 @@
import { Ios } from '@expo/eas-build-job';
import { createLogger } from '@expo/logger';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { v4 as uuid } from 'uuid';
import { BuildContext } from '../../../context';
import { distributionCertificate } from '../__tests__/fixtures';
import Keychain from '../keychain';
@@ -17,11 +15,9 @@ jest.setTimeout(60 * 1000);
// We need the JS code to modify the file system, so we need to mock it.
jest.unmock('fs');
let ctx: BuildContext<Ios.Job>;
describe('Keychain class', () => {
describe('ensureCertificateImported method', () => {
let keychain: Keychain<Ios.Job>;
let keychain: Keychain;
const certificatePath = path.join(os.tmpdir(), `cert-${uuid()}.p12`);
beforeAll(async () => {
@@ -36,37 +32,34 @@ describe('Keychain class', () => {
});
beforeEach(async () => {
ctx = new BuildContext({ projectRootDirectory: '.' } as Ios.Job, {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: mockLogger,
env: {},
uploadArtifact: jest.fn(),
});
keychain = new Keychain(ctx);
await keychain.create();
keychain = new Keychain();
await keychain.create({ logger: mockLogger });
});
afterEach(async () => {
await keychain.destroy();
await keychain.destroy({ logger: mockLogger });
});
it("should throw an error if the certificate hasn't been imported", async () => {
await expect(
keychain.ensureCertificateImported(
distributionCertificate.teamId,
distributionCertificate.fingerprint
)
keychain.ensureCertificateImported({
teamId: distributionCertificate.teamId,
fingerprint: distributionCertificate.fingerprint,
})
).rejects.toThrowError(/hasn't been imported successfully/);
});
it("shouldn't throw any error if the certificate has been imported successfully", async () => {
await keychain.importCertificate(certificatePath, distributionCertificate.password);
await keychain.importCertificate({
logger: mockLogger,
certPath: certificatePath,
certPassword: distributionCertificate.password,
});
await expect(
keychain.ensureCertificateImported(
distributionCertificate.teamId,
distributionCertificate.fingerprint
)
keychain.ensureCertificateImported({
teamId: distributionCertificate.teamId,
fingerprint: distributionCertificate.fingerprint,
})
).resolves.not.toThrow();
});
});
@@ -0,0 +1,133 @@
import { errors } from '@expo/eas-build-job';
import { createLogger } from '@expo/logger';
import { SpawnResult } from '@expo/turtle-spawn';
import { runFastlane } from '../../fastlane';
import Keychain from '../keychain';
jest.mock('../../fastlane');
const runFastlaneMock = jest.mocked(runFastlane);
const logger = createLogger({ name: 'test' });
const logError = jest.spyOn(logger, 'error').mockImplementation();
let keychain: Keychain;
const options = {
logger,
certPath: '/test.p12',
certPassword: 'certificate-password',
};
const result = (stdout: string, stderr = ''): SpawnResult => ({
stdout,
stderr,
output: [stdout, stderr],
pid: 1,
status: 0,
signal: null,
});
beforeEach(async () => {
keychain = new Keychain();
runFastlaneMock.mockResolvedValueOnce(result(''));
await keychain.create({ logger });
runFastlaneMock.mockClear();
});
it('runs the import with the certificate and keychain credentials, without streaming output', async () => {
runFastlaneMock.mockResolvedValueOnce(result('Result: true'));
await keychain.importCertificate(options);
expect(runFastlaneMock).toHaveBeenCalledWith([
'run',
'import_certificate',
'certificate_path:/test.p12',
'certificate_password:certificate-password',
`keychain_path:${keychain.data.path}`,
`keychain_password:${keychain.data.password}`,
]);
expect(logError).not.toHaveBeenCalled();
});
describe.each(['success', 'failure'])('Fastlane %s', outcome => {
it.each([
{
output: 'SecKeychainItemImport: MAC verification failed during PKCS12 import',
codes: ['PKCS12_MAC_VERIFICATION_FAILED'],
},
{
output: 'SecKeychainItemImport: Unknown format in import',
codes: ['PKCS12_UNKNOWN_FORMAT'],
},
{
output: 'SecKeychainItemImport: Some other error',
codes: ['CERTIFICATE_IMPORT_FAILED'],
},
{
output:
'SecKeychainItemImport: Unknown format in import\nSecKeychainItemSetAccessWithPassword: Access denied',
codes: ['PKCS12_UNKNOWN_FORMAT', 'PRIVATE_KEY_ACCESS_FAILED'],
},
{
output:
'seckeychainitemimport: unknown format in import\nSecKeychainItemImport: Some other error',
codes: ['PKCS12_UNKNOWN_FORMAT', 'CERTIFICATE_IMPORT_FAILED'],
},
])('reports only the applicable codes: $codes', async ({ output, codes }) => {
if (outcome === 'success') {
runFastlaneMock.mockResolvedValueOnce(result(output));
await keychain.importCertificate(options);
} else {
runFastlaneMock.mockRejectedValueOnce(
Object.assign(new Error('import failed'), {
stderr: output,
})
);
const failure = await keychain.importCertificate(options).catch(error => error);
expect(failure.metadata.diagnosticCodes).toEqual(codes);
}
expect(logError.mock.calls.map(([fields]) => fields.diagnosticCode)).toEqual(codes);
});
});
it('never forwards output, command arguments, private key data, or the original error', async () => {
const secret = 'very-secret-password';
const error = Object.assign(new Error(`fastlane certificate_password:${secret}`), {
stdout: `private key data ${secret}`,
stderr: `security: SecKeychainItemSetAccessWithPassword: ${secret}`,
});
runFastlaneMock.mockRejectedValueOnce(error);
const failure = await keychain.importCertificate(options).catch(error => error);
expect(failure).toBeInstanceOf(errors.UserError);
expect(failure.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(failure.metadata).toEqual({ diagnosticCodes: ['PRIVATE_KEY_ACCESS_FAILED'] });
expect(failure.toExternalExpoError().message).toContain(
'Fastlane could not complete certificate import'
);
expect(failure.cause).toBeUndefined();
expect(JSON.stringify([failure, logError.mock.calls])).not.toContain(secret);
expect(logError).toHaveBeenCalledWith(
{ diagnosticCode: 'PRIVATE_KEY_ACCESS_FAILED' },
expect.stringContaining('private key')
);
});
it.each(['ENOENT', 'EACCES'])(
'classifies a %s process launch failure as a system error',
async code => {
runFastlaneMock.mockRejectedValueOnce(Object.assign(new Error('secret command'), { code }));
const error = await keychain.importCertificate(options).catch(error => error);
expect(error).toBeInstanceOf(errors.SystemError);
expect(error.trackingCode).toBe('IOS_CERTIFICATE_IMPORT_PROCESS_START_FAILED');
expect(error.cause).toBeUndefined();
}
);
it.each([
null,
new Error('failure'),
{ stderr: 'SecKeychainItemImport: Unknown format in import' },
Object.assign(new Error('failure'), { stdout: 123, stderr: {} }),
])('handles rejected values without usable process output: %p', async value => {
runFastlaneMock.mockRejectedValueOnce(value);
await expect(keychain.importCertificate(options)).rejects.toThrow(
'Fastlane could not complete certificate import'
);
expect(logError).not.toHaveBeenCalled();
});
@@ -18,7 +18,7 @@ jest.unmock('fs');
describe('ProvisioningProfile class', () => {
describe('verifyCertificate method', () => {
let ctx: BuildContext<Ios.Job>;
let keychain: Keychain<Ios.Job>;
let keychain: Keychain;
beforeAll(async () => {
ctx = new BuildContext({ projectRootDirectory: '.' } as Ios.Job, {
@@ -30,12 +30,12 @@ describe('ProvisioningProfile class', () => {
},
uploadArtifact: jest.fn(),
});
keychain = new Keychain(ctx);
await keychain.create();
keychain = new Keychain();
await keychain.create({ logger: mockLogger });
});
afterAll(async () => {
await keychain.destroy();
await keychain.destroy({ logger: mockLogger });
});
it("shouldn't throw any error if the provisioning profile and distribution certificate match", async () => {
@@ -1,19 +1,53 @@
import { Ios } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { errors } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import spawn, { SpawnResult } from '@expo/turtle-spawn';
import os from 'os';
import path from 'path';
import { v4 as uuid } from 'uuid';
import { BuildContext } from '../../context';
import { runFastlane } from '../fastlane';
export default class Keychain<TJob extends Ios.Job> {
// Do not log raw fastlane output or spawn errors: they can contain passwords,
// command arguments, and private key attributes from set-key-partition-list.
const IMPORT_CERTIFICATE_DIAGNOSTICS = [
{
code: 'PKCS12_MAC_VERIFICATION_FAILED',
pattern: /SecKeychainItemImport: MAC verification failed during PKCS12 import/i,
message:
'macOS could not verify the PKCS#12 MAC. Check the certificate password and PKCS#12 export format; this error does not prove that the password is wrong.',
},
{
code: 'PKCS12_UNKNOWN_FORMAT',
pattern: /SecKeychainItemImport: Unknown format in import/i,
message: 'macOS did not recognize the certificate import format.',
},
{
code: 'CERTIFICATE_IMPORT_FAILED',
pattern:
/SecKeychainItemImport:(?! MAC verification failed during PKCS12 import| Unknown format in import)/i,
message: 'macOS reported a certificate import error (SecKeychainItemImport).',
},
{
code: 'PRIVATE_KEY_ACCESS_FAILED',
pattern: /SecKeychainItemSetAccessWithPassword:/,
message:
'macOS could not set access to the imported private key (SecKeychainItemSetAccessWithPassword).',
},
{
code: 'KEYCHAIN_ITEM_LOOKUP_FAILED',
pattern: /SecItemCopyMatching:/,
message:
'macOS could not find an item while configuring private key access (SecItemCopyMatching).',
},
];
export default class Keychain {
private readonly keychainPath: string;
private readonly keychainPassword: string;
private created = false;
private destroyed = false;
constructor(private readonly ctx: BuildContext<TJob>) {
constructor() {
this.keychainPath = path.join(os.tmpdir(), `eas-build-${uuid()}.keychain`);
this.keychainPassword = uuid();
}
@@ -25,8 +59,8 @@ export default class Keychain<TJob extends Ios.Job> {
};
}
public async create(): Promise<void> {
this.ctx.logger.debug(`Creating keychain - ${this.keychainPath}`);
public async create({ logger }: { logger: bunyan }): Promise<void> {
logger.debug(`Creating keychain - ${this.keychainPath}`);
await runFastlane([
'run',
'create_keychain',
@@ -38,23 +72,77 @@ export default class Keychain<TJob extends Ios.Job> {
this.created = true;
}
public async importCertificate(certPath: string, certPassword: string): Promise<void> {
public async importCertificate({
logger,
certPath,
certPassword,
}: {
logger: bunyan;
certPath: string;
certPassword: string;
}): Promise<void> {
if (!this.created) {
throw new Error('You must create a keychain first.');
}
this.ctx.logger.debug(`Importing certificate ${certPath} into keychain ${this.keychainPath}`);
await runFastlane([
'run',
'import_certificate',
`certificate_path:${certPath}`,
`certificate_password:${certPassword}`,
`keychain_path:${this.keychainPath}`,
`keychain_password:${this.keychainPassword}`,
]);
logger.debug(`Importing certificate ${certPath} into keychain ${this.keychainPath}`);
try {
const result = await runFastlane([
'run',
'import_certificate',
`certificate_path:${certPath}`,
`certificate_password:${certPassword}`,
`keychain_path:${this.keychainPath}`,
`keychain_password:${this.keychainPassword}`,
]);
const output = [result.stdout, result.stderr].join('\n');
for (const diagnostic of IMPORT_CERTIFICATE_DIAGNOSTICS) {
if (diagnostic.pattern.test(output)) {
logger.error({ diagnosticCode: diagnostic.code }, diagnostic.message);
}
}
} catch (error) {
const processError =
error instanceof Error
? (error as Error &
Partial<Pick<SpawnResult, 'stdout' | 'stderr'> & NodeJS.ErrnoException>)
: undefined;
const output = [processError?.stdout, processError?.stderr]
.filter(value => typeof value === 'string')
.join('\n');
const diagnosticCodes: string[] = [];
for (const diagnostic of IMPORT_CERTIFICATE_DIAGNOSTICS) {
if (diagnostic.pattern.test(output)) {
diagnosticCodes.push(diagnostic.code);
logger.error({ diagnosticCode: diagnostic.code }, diagnostic.message);
}
}
// Never attach the original error: its message includes passwords.
if (processError?.code === 'ENOENT' || processError?.code === 'EACCES') {
throw new errors.SystemError('Fastlane could not be started to import the certificate.', {
trackingCode: 'IOS_CERTIFICATE_IMPORT_PROCESS_START_FAILED',
});
}
throw new errors.UserError(
errors.ErrorCode.UNKNOWN_ERROR,
'Fastlane could not complete certificate import. Check the certificate import diagnostics in the Prepare credentials logs.',
{
trackingCode: 'IOS_CERTIFICATE_IMPORT_FAILED',
metadata: { diagnosticCodes },
}
);
}
}
public async ensureCertificateImported(teamId: string, fingerprint: string): Promise<void> {
public async ensureCertificateImported({
teamId,
fingerprint,
}: {
teamId: string;
fingerprint: string;
}): Promise<void> {
const identities = await this.findIdentitiesByTeamId(teamId);
if (!identities.includes(fingerprint)) {
throw new Error(
@@ -63,27 +151,33 @@ export default class Keychain<TJob extends Ios.Job> {
}
}
public async destroy(keychainPath?: string): Promise<void> {
public async destroy({
logger,
keychainPath,
}: {
logger: bunyan;
keychainPath?: string;
}): Promise<void> {
if (!keychainPath && !this.created) {
this.ctx.logger.warn("There is nothing to destroy, a keychain hasn't been created yet.");
logger.warn("There is nothing to destroy, a keychain hasn't been created yet.");
return;
}
if (this.destroyed) {
this.ctx.logger.warn('The keychain has been already destroyed');
logger.warn('The keychain has been already destroyed');
return;
}
const keychainToDeletePath = keychainPath ?? this.keychainPath;
this.ctx.logger.info(`Destroying keychain - ${keychainToDeletePath}`);
logger.info(`Destroying keychain - ${keychainToDeletePath}`);
try {
await runFastlane(['run', 'delete_keychain', `keychain_path:${keychainToDeletePath}`]);
this.destroyed = true;
} catch (err) {
this.ctx.logger.error({ err }, 'Failed to delete the keychain\n');
logger.error({ err }, 'Failed to delete the keychain\n');
throw err;
}
}
public async cleanUpKeychains(): Promise<void> {
public async cleanUpKeychains({ logger }: { logger: bunyan }): Promise<void> {
const { stdout } = await spawn('security', ['list-keychains'], { stdio: 'pipe' });
const keychainList = (/"(.*)"/g.exec(stdout) ?? ([] as string[])).map(i =>
i.slice(1, i.length - 1)
@@ -92,7 +186,7 @@ export default class Keychain<TJob extends Ios.Job> {
/eas-build-[\w-]+\.keychain$/.exec(keychain)
);
for (const turtleKeychainPath of turtleKeychainList) {
await this.destroy(turtleKeychainPath);
await this.destroy({ logger, keychainPath: turtleKeychainPath });
}
}
@@ -26,7 +26,7 @@ export interface Credentials {
export type TargetProvisioningProfiles = Record<string, ProvisioningProfileData>;
export default class IosCredentialsManager<TJob extends Ios.Job> {
private keychain?: Keychain<TJob>;
private keychain?: Keychain;
private readonly provisioningProfiles: ProvisioningProfile<TJob>[] = [];
private cleanedUp = false;
@@ -48,8 +48,8 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
this.ctx.logger.info('Preparing credentials');
this.ctx.logger.info('Creating keychain');
this.keychain = new Keychain(this.ctx);
await this.keychain.create();
this.keychain = new Keychain();
await this.keychain.create({ logger: this.ctx.logger });
const targets = Object.keys(buildCredentials);
const targetProvisioningProfiles: TargetProvisioningProfiles = {};
@@ -82,7 +82,7 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
}
if (this.keychain) {
await this.keychain.destroy();
await this.keychain.destroy({ logger: this.ctx.logger });
}
if (this.provisioningProfiles) {
for (const provisioningProfile of this.provisioningProfiles) {
@@ -120,10 +120,11 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
);
this.ctx.logger.info('Importing distribution certificate into the keychain');
await this.keychain.importCertificate(
distCertPath,
targetCredentials.distributionCertificate.password
);
await this.keychain.importCertificate({
logger: this.ctx.logger,
certPath: distCertPath,
certPassword: targetCredentials.distributionCertificate.password,
});
this.ctx.logger.info('Initializing provisioning profile');
const provisioningProfile = new ProvisioningProfile(
@@ -138,10 +139,10 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
this.ctx.logger.info(
'Validating whether the distribution certificate has been imported successfully'
);
await this.keychain.ensureCertificateImported(
provisioningProfile.data.teamId,
certificateFingerprint
);
await this.keychain.ensureCertificateImported({
teamId: provisioningProfile.data.teamId,
fingerprint: certificateFingerprint,
});
this.ctx.logger.info(
'Verifying whether the distribution certificate and provisioning profile match'
@@ -0,0 +1,238 @@
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import type { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
import { turtleFetch } from '../../../utils/turtleFetch';
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import {
spawnDetached,
startNgrokTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
jest.mock('@expo/turtle-spawn');
jest.mock('../../../sentry');
jest.mock('../../../utils/turtleFetch');
jest.mock('../../../utils/AndroidEmulatorUtils');
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/agentDeviceEvents');
jest.mock('../../utils/appiumEvents');
jest.mock('../../utils/agentDeviceArtifacts');
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
spawnDetached: jest.fn(),
startNgrokTunnelAsync: jest.fn(),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
waitForFileAsync: jest.fn(),
}));
const finishHost = jest.fn();
const closePreview = jest.fn();
const stopTool = jest.fn();
const stopTunnel = jest.fn();
const stopEvents = jest.fn();
const openPreview = jest.fn();
const logger = { info: jest.fn(), warn: jest.fn() };
beforeEach(() => {
jest.clearAllMocks();
// The daemon package is external. No installation or daemon process runs in these tests.
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.mocked(spawn).mockResolvedValue({ stdout: '{}' } as never);
jest
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
.mockResolvedValue([{ serialId: 'emulator-5554', state: 'device' } as never]);
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 4567, token: 'daemon-token' });
for (const stop of [finishHost, closePreview, stopTool, stopTunnel, stopEvents]) {
stop.mockResolvedValue(undefined);
}
openPreview.mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://preview.example.test',
closeAsync: closePreview,
});
jest
.mocked(startDeviceSessionHostAsync)
.mockResolvedValue({ openPreviewAsync: openPreview, finishAsync: finishHost });
jest.mocked(spawnDetached).mockReturnValue({
pid: undefined,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: stopTool,
});
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
url: 'https://tool.example.test',
subdomainId: 'tool-id',
stopAsync: stopTunnel,
});
const events = { stopAsync: stopEvents, getLastEventObservedAt: () => undefined };
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue(events);
jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue(events);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe.each([
['Appium', createStartAppiumRemoteSessionBuildFunction],
['Agent Device', createStartAgentDeviceRemoteSessionBuildFunction],
] as const)('%s host ownership', (name, createFunction) => {
async function runAsync() {
const fn = createFunction({} as CustomBuildContext);
await fn.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {
DEVICE_RUN_SESSION_ID: 'session-id',
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
NGROK_AUTHTOKEN: 'token',
},
} as never
);
}
it('holds the host until session completion', async () => {
await runAsync();
expect(openPreview).toHaveBeenCalledWith({ baseDomain: 'example.test' });
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewApiUrl: 'https://preview.example.test',
}),
})
);
expect(finishHost).toHaveBeenCalledTimes(1);
expect(closePreview).not.toHaveBeenCalled();
expect(finishHost.mock.invocationCallOrder[0]).toBeGreaterThan(
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
);
expect(stopTool).toHaveBeenCalledTimes(1);
expect(stopEvents).toHaveBeenCalledTimes(1);
expect(stopTunnel).toHaveBeenCalledTimes(1);
});
it('stops automation while recording finalization or upload is pending', async () => {
let release!: () => void;
let stopped!: () => void;
const pendingFinish = new Promise<void>(resolve => {
release = resolve;
});
const toolStopped = new Promise<void>(resolve => {
stopped = resolve;
});
finishHost.mockReturnValueOnce(pendingFinish);
stopTool.mockImplementationOnce(async () => {
stopped();
});
let completed = false;
const running = runAsync().then(() => {
completed = true;
});
try {
await toolStopped;
expect(stopTunnel).toHaveBeenCalledTimes(1);
expect(finishHost).toHaveBeenCalledTimes(1);
expect(completed).toBe(false);
} finally {
release();
await running;
}
});
it('finalizes recording and stops automation while the tunnel close is pending', async () => {
let release!: () => void;
let finished!: () => void;
const pendingClose = new Promise<void>(resolve => {
release = resolve;
});
const hostFinished = new Promise<void>(resolve => {
finished = resolve;
});
const toolStopped = new Promise<void>(resolve => {
stopTool.mockImplementationOnce(async () => resolve());
});
stopTunnel.mockReturnValueOnce(pendingClose);
finishHost.mockImplementationOnce(async () => {
finished();
});
const running = runAsync();
try {
// Both settle while the tunnel close is still held open.
await Promise.all([hostFinished, toolStopped]);
expect(stopTunnel).toHaveBeenCalledTimes(1);
} finally {
release();
await running;
}
});
it('still stops the automation process when event collection stop rejects', async () => {
stopEvents.mockRejectedValueOnce(new Error('collector failed'));
// Agent Device wraps its collector stop in a catch-all helper; Appium does not.
const outcome = await runAsync().then(
() => 'resolved',
(err: Error) => err.message
);
expect(outcome).toBe(name === 'Appium' ? 'collector failed' : 'resolved');
expect(stopTool).toHaveBeenCalledTimes(1);
expect(stopTunnel).toHaveBeenCalledTimes(1);
expect(finishHost).toHaveBeenCalledTimes(1);
});
it('still stops automation when recording finalization rejects', async () => {
finishHost.mockRejectedValueOnce(new Error('recording cleanup failed'));
await expect(runAsync()).rejects.toThrow('recording cleanup failed');
expect(stopTool).toHaveBeenCalledTimes(1);
expect(stopTunnel).toHaveBeenCalledTimes(1);
expect(Sentry.capture).not.toHaveBeenCalled();
});
it.each(['preview', 'config', 'wait'])('finishes the host after %s fails', async phase => {
const error = new Error(`${phase} failed`);
// A teardown failure must not replace the error that ended the session.
finishHost.mockRejectedValueOnce(new Error('recording cleanup failed'));
if (phase === 'preview') {
openPreview.mockRejectedValueOnce(error);
} else if (phase === 'config') {
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
} else {
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
}
await expect(runAsync()).rejects.toBe(error);
expect(finishHost).toHaveBeenCalledTimes(1);
expect(stopTool).toHaveBeenCalledTimes(1);
expect(stopTunnel).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.objectContaining({ message: 'recording cleanup failed' }) },
'Could not stop the session host during remote session teardown.'
);
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not stop the session host after the remote session failed',
expect.objectContaining({ message: 'recording cleanup failed' }),
{ level: 'warning' }
);
});
});
@@ -256,7 +256,9 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('rejects sharding with maestro-runner', async () => {
it('passes shards to maestro-runner as parallel devices', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest.spyOn(fs, 'copyFile').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
@@ -264,10 +266,9 @@ describe('createMaestroTestsBuildFunction', () => {
shards: 2,
});
await expect(step.executeAsync()).rejects.toThrow(
'maestro-runner does not support EAS Maestro test sharding'
);
expect(mockedSpawn).not.toHaveBeenCalled();
await step.executeAsync();
expect(mockedSpawn.mock.calls[0][1]).toContain('--parallel=2');
});
it('rejects a non-junit output_format with maestro-runner', async () => {
@@ -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 { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import {
monitorLocalEgressAsync,
readLocalEgressHandoffAsync,
@@ -14,7 +15,6 @@ import {
stopLocalEgressResourcesAsync,
} from '../../utils/localEgress';
import {
startDeviceWebPreviewWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
@@ -27,6 +27,7 @@ jest.mock('@expo/turtle-spawn');
jest.mock('../../../utils/IosSimulatorUtils');
jest.mock('../../../utils/processes');
jest.mock('../../../utils/turtleFetch');
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/agentDeviceArtifacts');
jest.mock('../../utils/argentArtifacts');
jest.mock('../../utils/agentDeviceEvents', () => ({
@@ -45,6 +46,7 @@ jest.mock('../../utils/localEgress', () => ({
monitorLocalEgressAsync: jest.fn(),
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
getDeviceRunSessionIdOrThrow: () => 'session-id',
getNgrokTunnelDomainOrThrow: () => 'example.test',
getNgrokAuthtokenOrThrow: () => 'ngrok-token',
@@ -52,8 +54,11 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
ensureFfmpegInstalledOnceAsync: jest.fn(),
spawnDetached: () => ({ pid: 123, getOutput: () => '', stopAsync: jest.fn() }),
waitForFileAsync: async () => ({ port: 1234, token: 'controller-token' }),
startNgrokTunnelAsync: async () => ({ url: 'https://controller.test', stopAsync: jest.fn() }),
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
startNgrokTunnelAsync: async () => ({
url: 'https://controller.test',
subdomainId: 'controller-id',
stopAsync: jest.fn(),
}),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
@@ -77,6 +82,7 @@ const controllers = [
describe.each(controllers)('%s local egress', (_name, createFunction, controllerField) => {
const stopPreview = jest.fn();
const openPreview = jest.fn();
const stopEgress = jest.fn();
let lifetime: AbortSignal;
async function run(signal?: AbortSignal): Promise<void> {
@@ -114,10 +120,15 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(handoff);
jest.mocked(monitorLocalEgressAsync).mockResolvedValue(undefined);
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewUrl: 'https://preview.test',
openPreview.mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://preview.test',
previewToken: 'preview-secret',
stopAsync: stopPreview,
closeAsync: jest.fn(),
});
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: openPreview,
finishAsync: stopPreview,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
@@ -163,12 +174,14 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
expect(stopEgress).not.toHaveBeenCalled();
});
it.each(['startup', 'report', 'wait', 'teardown'])(
it.each(['startup', 'preview', 'report', 'wait', 'teardown'])(
'releases egress after %s fails',
async phase => {
const error = new Error(`${phase} failed`);
if (phase === 'startup') {
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockRejectedValue(error);
jest.mocked(startDeviceSessionHostAsync).mockRejectedValue(error);
} else if (phase === 'preview') {
openPreview.mockRejectedValue(error);
} else if (phase === 'report') {
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValue(error);
} else if (phase === 'wait') {
@@ -180,7 +193,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
expect(lifetime.aborted).toBe(true);
expect(stopEgress).toHaveBeenCalledTimes(1);
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(
phase === 'startup' || phase === 'report' ? 0 : 1
phase === 'startup' || phase === 'preview' || phase === 'report' ? 0 : 1
);
}
);
@@ -0,0 +1,204 @@
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import {
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startNgrokTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
// The daemon entry path and the state directory are resolved from the home directory when
// the module loads, so point it at a temp home we can populate.
jest.mock('node:os', () => {
const actual = jest.requireActual('node:os');
const actualPath = jest.requireActual('node:path');
return {
...actual,
homedir: () => actualPath.join(actual.tmpdir(), 'eas-agent-device-orchestration-home'),
};
});
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('../../../sentry');
jest.mock('../../utils/agentDeviceArtifacts', () => ({
pollAgentDeviceArtifactsForUploadAsync: jest.fn(),
}));
jest.mock('../../utils/agentDeviceEvents', () => ({
startAgentDeviceEventCollectionAsync: jest.fn(),
}));
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
getDeviceRunSessionIdOrThrow: jest.fn(),
getNgrokAuthtokenOrThrow: jest.fn(),
getNgrokTunnelDomainOrThrow: jest.fn(),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
spawnDetached: jest.fn(),
startNgrokTunnelAsync: jest.fn(),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
waitForFileAsync: jest.fn(),
}));
const TEST_HOME = path.join(os.tmpdir(), 'eas-agent-device-orchestration-home');
const DAEMON_ENTRY_PATH = path.join(
TEST_HOME,
'.bun/install/global/node_modules/agent-device/dist/src/internal/daemon.js'
);
const ctx = {} as unknown as CustomBuildContext;
const mockPreviewStopAsync = jest.fn();
const mockTunnelStopAsync = jest.fn();
const mockDaemonStopAsync = jest.fn();
const mockEventCollectionStopAsync = jest.fn();
async function runAsync(
logger: { info: jest.Mock; warn: jest.Mock },
runtimePlatform: BuildRuntimePlatform,
launchInputs: Record<string, { value: unknown }> = {}
): Promise<void> {
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
await buildFunction.fn!(
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
max_duration_seconds: { value: undefined },
...launchInputs,
},
outputs: {},
env: {},
} as never
);
}
describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () => {
beforeEach(async () => {
jest.clearAllMocks();
jest.mocked(spawn).mockResolvedValue(undefined as never);
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockResolvedValue(undefined);
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue({
stopAsync: mockEventCollectionStopAsync,
getLastEventObservedAt: () => undefined,
});
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('device-run-session-id');
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
jest.mocked(getNgrokAuthtokenOrThrow).mockReturnValue('ngrok-token');
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockResolvedValue(undefined);
jest.mocked(spawnDetached).mockReturnValue({
pid: 4242,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: mockDaemonStopAsync,
});
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 5678, token: 'daemon-token' });
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
url: 'https://agent-device-abc.tunnel.example.com',
subdomainId: 'agent-device-abc',
stopAsync: mockTunnelStopAsync,
});
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.tunnel.example.com',
closeAsync: jest.fn(),
}),
finishAsync: mockPreviewStopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
await fs.promises.mkdir(path.dirname(DAEMON_ENTRY_PATH), { recursive: true });
await fs.promises.writeFile(DAEMON_ENTRY_PATH, '');
});
afterEach(async () => {
await fs.promises.rm(TEST_HOME, { recursive: true, force: true });
});
it('reports the preview URL and tears every resource down', async () => {
const logger = { info: jest.fn(), warn: jest.fn() };
await runAsync(logger, BuildRuntimePlatform.LINUX);
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
agentDeviceRemoteSessionUrl: 'https://agent-device-abc.tunnel.example.com',
agentDeviceRemoteSessionToken: 'daemon-token',
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewApiUrl: 'https://web-preview.tunnel.example.com',
}),
})
);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(mockEventCollectionStopAsync).toHaveBeenCalledTimes(1);
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
});
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
const logger = { info: jest.fn(), warn: jest.fn() };
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
launch_app_identifier: { value: 'host.exp.Exponent' },
launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] },
open_url: { value: 'exp://127.0.0.1:8081' },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
runtimePlatform: BuildRuntimePlatform.DARWIN,
launchAppIdentifier: 'host.exp.Exponent',
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
openUrl: 'exp://127.0.0.1:8081',
})
);
expect(logger.info).toHaveBeenCalledWith(
'serve-sim will launch host.exp.Exponent with arguments ' +
'["-EXDevMenuIsOnboardingFinished","1"], then open exp://127.0.0.1:8081.'
);
});
it('fails before starting the daemon when a launch is asked for on Android', async () => {
const logger = { info: jest.fn(), warn: jest.fn() };
await expect(
runAsync(logger, BuildRuntimePlatform.LINUX, {
launch_app_identifier: { value: 'host.exp.Exponent' },
})
).rejects.toThrow('runs on linux');
expect(spawnDetached).not.toHaveBeenCalled();
});
it('declares the launch inputs', () => {
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
const globalCtx = createGlobalContextMock();
expect(
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
});
});
@@ -1,9 +1,38 @@
import { type bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import path from 'node:path';
import { Sentry } from '../../../sentry';
import { stopAgentDeviceEventCollectionSafelyAsync } from '../startAgentDeviceRemoteSession';
import { spawnDetached } from '../../utils/remoteDeviceRunSession';
import {
startAgentDeviceDaemonAsync,
stopAgentDeviceEventCollectionSafelyAsync,
} from '../startAgentDeviceRemoteSession';
jest.mock('../../../sentry');
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
spawnDetached: jest.fn(),
}));
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
async function writeDaemonEntry(cwd: string): Promise<string> {
const daemonPath = path.join(
cwd,
'node_modules',
'agent-device',
'dist',
'src',
'internal',
'daemon.js'
);
await fs.promises.mkdir(path.dirname(daemonPath), { recursive: true });
await fs.promises.writeFile(daemonPath, '');
return daemonPath;
}
describe(stopAgentDeviceEventCollectionSafelyAsync, () => {
beforeEach(() => {
@@ -12,7 +41,6 @@ describe(stopAgentDeviceEventCollectionSafelyAsync, () => {
it('reports an unexpected stop failure without rejecting', async () => {
const error = new Error('stop failed');
const logger = { warn: jest.fn() } as unknown as bunyan;
await expect(
stopAgentDeviceEventCollectionSafelyAsync({
@@ -37,3 +65,172 @@ describe(stopAgentDeviceEventCollectionSafelyAsync, () => {
);
});
});
describe(startAgentDeviceDaemonAsync, () => {
const stopAsync = jest.fn().mockResolvedValue(undefined);
beforeEach(() => {
jest.clearAllMocks();
stopAsync.mockResolvedValue(undefined);
jest.mocked(spawnDetached).mockReturnValue({
pid: 1234,
getOutput: () => '',
getExitError: () => undefined,
stopAsync,
});
jest.mocked(spawn).mockImplementation((async (
command: string,
args: string[],
options?: { cwd?: string }
) => {
if (command === 'bun' && args[0] === '--version') {
return { stdout: '1.3.11\n' };
}
if (typeof options?.cwd === 'string' && command !== 'git') {
await writeDaemonEntry(options.cwd);
}
return { stdout: '' };
}) as never);
});
it('installs with bun add by default and launches the published daemon', async () => {
const handle = await startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
env: {},
logger,
});
expect(spawn).toHaveBeenCalledWith(
'bun',
['add', '[email protected]'],
expect.objectContaining({ cwd: expect.stringContaining('eas-agent-device-') })
);
const addCwd = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
expect(spawnDetached).toHaveBeenCalledWith({
command: 'node',
args: [path.join(addCwd, 'node_modules/agent-device/dist/src/internal/daemon.js')],
env: expect.objectContaining({
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0',
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS: '0',
AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS: '0',
}),
});
await handle.stopAsync();
await expect(fs.promises.access(addCwd)).rejects.toThrow();
});
it('installs with npm when EAS_OVERRIDE_PACKAGE_MANAGER is npm', async () => {
await startAgentDeviceDaemonAsync({
packageVersion: undefined,
env: { EAS_OVERRIDE_PACKAGE_MANAGER: 'npm' },
logger,
});
expect(spawn).toHaveBeenCalledWith(
'npm',
['install', '--no-audit', 'agent-device@latest'],
expect.objectContaining({ cwd: expect.stringContaining('eas-agent-device-') })
);
});
it('falls back to git clone when the published daemon is missing', async () => {
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => {
if (command === 'bun' && args[0] === '--version') {
return { stdout: '1.3.11\n' };
}
return { stdout: '' };
}) as never);
await startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
env: {},
logger,
});
expect(spawn).toHaveBeenCalledWith(
'git',
[
'clone',
'--depth',
'1',
'--branch',
'v1.2.3',
'https://github.com/callstack/agent-device.git',
'/tmp/agent-device-src',
],
expect.objectContaining({ env: {}, logger })
);
expect(spawn).toHaveBeenCalledWith(
'bun',
['install', '--production'],
expect.objectContaining({ cwd: '/tmp/agent-device-src' })
);
expect(spawnDetached).toHaveBeenCalledWith({
command: 'bun',
args: ['run', 'src/daemon.ts'],
cwd: '/tmp/agent-device-src',
env: expect.objectContaining({
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0',
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS: '0',
AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS: '0',
}),
});
expect(Sentry.capture).toHaveBeenCalledWith(
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
expect.any(Error),
expect.objectContaining({
extras: expect.objectContaining({
packageSpec: '[email protected]',
packageManager: 'bun',
bunVersion: '1.3.11',
}),
})
);
});
it('clones latest and reports unknown bun when diagnostics fail', async () => {
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => {
if (command === 'bun' && args[0] === '--version') {
throw new Error('bun missing');
}
return { stdout: '' };
}) as never);
await startAgentDeviceDaemonAsync({
packageVersion: undefined,
env: { EAS_OVERRIDE_PACKAGE_MANAGER: 'npm' },
logger,
});
expect(spawn).toHaveBeenCalledWith(
'git',
[
'clone',
'--depth',
'1',
'https://github.com/callstack/agent-device.git',
'/tmp/agent-device-src',
],
expect.objectContaining({ logger })
);
expect(spawn).toHaveBeenCalledWith(
'npm',
['install', '--no-audit', '--omit=dev'],
expect.objectContaining({ cwd: '/tmp/agent-device-src' })
);
expect(Sentry.capture).toHaveBeenCalledWith(
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
expect.any(Error),
expect.objectContaining({
extras: expect.objectContaining({
packageVersion: 'latest',
packageManager: 'npm',
bunVersion: 'unknown',
}),
})
);
});
});
@@ -1,10 +1,19 @@
import { BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
import { resolveAppium3VersionSpec, resolveAppiumDeviceAsync } from '../startAppiumRemoteSession';
import {
createStartAppiumRemoteSessionBuildFunction,
installAppiumAsync,
resolveAppium3VersionSpec,
resolveAppiumDeviceAsync,
} from '../startAppiumRemoteSession';
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
AndroidEmulatorUtils: { getAttachedDevicesAsync: jest.fn() },
@@ -13,8 +22,10 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({
IosSimulatorUtils: { getAvailableDevicesAsync: jest.fn() },
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
}));
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
const logger = { info: jest.fn(), warn: jest.fn() } as never;
@@ -84,3 +95,75 @@ describe(resolveAppiumDeviceAsync, () => {
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
});
});
describe(installAppiumAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => {
if (args.includes('--json')) {
return { stdout: JSON.stringify({}) };
}
return { stdout: '' };
}) as never);
});
it('installs Appium with npm by default', async () => {
const result = await installAppiumAsync({
versionSpec: '^3',
driverName: 'xcuitest',
env: { EXISTING: 'value' },
logger,
});
try {
expect(spawn).toHaveBeenNthCalledWith(
1,
'npm',
['install', '--no-audit', 'appium@^3'],
expect.objectContaining({
cwd: result.appiumHome,
env: expect.objectContaining({ APPIUM_HOME: result.appiumHome, EXISTING: 'value' }),
})
);
expect(spawn).toHaveBeenCalledWith(
result.appiumBinPath,
['driver', 'install', 'xcuitest'],
expect.objectContaining({ env: result.appiumEnv, logger })
);
} finally {
await fs.promises.rm(result.appiumHome, { recursive: true, force: true });
}
});
it('installs Appium with bun add when EAS_OVERRIDE_PACKAGE_MANAGER is bun', async () => {
const result = await installAppiumAsync({
versionSpec: '3.5.0',
driverName: 'uiautomator2',
env: { EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' },
logger,
});
try {
expect(spawn).toHaveBeenNthCalledWith(
1,
'bun',
['add', '[email protected]'],
expect.objectContaining({ cwd: result.appiumHome })
);
} finally {
await fs.promises.rm(result.appiumHome, { recursive: true, force: true });
}
});
});
describe('createStartAppiumRemoteSessionBuildFunction', () => {
it('declares the launch inputs so serve-sim can launch the app', () => {
const ctx = {} as unknown as CustomBuildContext;
const buildFunction = createStartAppiumRemoteSessionBuildFunction(ctx);
const globalCtx = createGlobalContextMock();
expect(
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
});
});
@@ -4,7 +4,9 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import { isProcessDescendantOfAsync } from '../../../utils/processes';
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
import { startArgentEventCollectionAsync } from '../../utils/argentEvents';
@@ -15,7 +17,6 @@ import {
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
@@ -41,14 +42,17 @@ jest.mock('../../utils/argentEvents', () => ({
...jest.requireActual('../../utils/argentEvents'),
startArgentEventCollectionAsync: jest.fn(),
}));
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
ensureFfmpegInstalledOnceAsync: jest.fn(),
finishRemoteSessionAsync: jest.requireActual('../../utils/remoteDeviceRunSession')
.finishRemoteSessionAsync,
getDeviceRunSessionIdOrThrow: jest.fn(),
getNgrokAuthtokenOrThrow: jest.fn(),
getNgrokTunnelDomainOrThrow: jest.fn(),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
spawnDetached: jest.fn(),
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
startNgrokTunnelAsync: jest.fn(),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
@@ -84,15 +88,21 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
jest.mocked(spawnDetached).mockReturnValue({
pid: 4242,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: jest.fn(),
});
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
url: 'https://argent-abc.tunnel.example.com',
subdomainId: 'argent-abc',
stopAsync: mockTunnelStopAsync,
});
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewUrl: 'https://web-preview.tunnel.example.com',
stopAsync: mockPreviewStopAsync,
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.tunnel.example.com',
closeAsync: jest.fn(),
}),
finishAsync: mockPreviewStopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
@@ -109,6 +119,75 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
await fs.promises.rm(TEST_HOME, { recursive: true, force: true });
});
it('reports an early exit with output before opening tunnels or publishing readiness', async () => {
jest.mocked(spawnDetached).mockReturnValue({
pid: 4242,
getOutput: () => 'could not bind server port',
getExitError: () => new Error('process exited with code 1'),
stopAsync: jest.fn(),
});
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
await expect(
buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
)
).rejects.toThrow(
'Argent exited before becoming ready: process exited with code 1\nArgent tool-server output:\ncould not bind server port'
);
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
});
it.each(['preview', 'config', 'wait'])(
'finishes the host and tools after %s fails',
async phase => {
const error = new Error(`${phase} failed`);
if (phase === 'preview') {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: jest.fn().mockRejectedValue(error),
finishAsync: mockPreviewStopAsync,
});
} else if (phase === 'config') {
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
} else {
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
}
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
await expect(
buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
)
).rejects.toBe(error);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(mockStopAsync).toHaveBeenCalledTimes(1);
expect(jest.mocked(spawnDetached).mock.results[0].value.stopAsync).toHaveBeenCalledTimes(1);
}
);
it('enables the event log flag, shares one path, and starts/stops the collector', async () => {
const ctx = {} as unknown as CustomBuildContext;
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
@@ -155,6 +234,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
// (3) The tool-server and the collector are pinned to the exact same event log path.
const serverEnv = jest.mocked(spawnDetached).mock.calls[0][0].env;
expect(serverEnv.ARGENT_EVENT_LOG).toBe(EXPECTED_EVENT_LOG_PATH);
expect(serverEnv.ARGENT_EMULATOR_NO_WINDOW).toBe('1');
expect(serverEnv.EXISTING).toBe('value');
expect(jest.mocked(startArgentEventCollectionAsync).mock.calls[0][0]).toMatchObject({
deviceRunSessionId: 'device-run-session-id',
@@ -172,17 +252,138 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
webPreviewUrl: 'https://web-preview.tunnel.example.com',
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewApiUrl: 'https://web-preview.tunnel.example.com',
}),
})
);
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() };
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
await buildFunction.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
max_duration_seconds: { value: undefined },
launch_app_identifier: { value: 'host.exp.Exponent' },
launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] },
open_url: { value: 'exp://127.0.0.1:8081' },
},
outputs: {},
env: { EXISTING: 'value' },
} as never
);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
runtimePlatform: BuildRuntimePlatform.DARWIN,
launchAppIdentifier: 'host.exp.Exponent',
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
openUrl: 'exp://127.0.0.1:8081',
})
);
expect(logger.info).toHaveBeenCalledWith(
'serve-sim will launch host.exp.Exponent with arguments ' +
'["-EXDevMenuIsOnboardingFinished","1"], then open exp://127.0.0.1:8081.'
);
});
it('fails before starting anything when a launch is asked for on Android', async () => {
const ctx = {} as unknown as CustomBuildContext;
const logger = { info: jest.fn(), warn: jest.fn() };
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
await expect(
buildFunction.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
max_duration_seconds: { value: undefined },
launch_app_identifier: { value: 'host.exp.Exponent' },
},
outputs: {},
env: {},
} as never
)
).rejects.toThrow('runs on linux');
expect(spawn).not.toHaveBeenCalled();
});
it('declares the launch inputs', () => {
const ctx = {} as unknown as CustomBuildContext;
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
const globalCtx = createGlobalContextMock();
expect(
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
});
it('stops automation without waiting for recording upload', async () => {
let release!: () => void;
let stopped!: () => void;
const pendingFinish = new Promise<void>(resolve => {
release = resolve;
});
const toolStopped = new Promise<void>(resolve => {
stopped = resolve;
});
mockPreviewStopAsync.mockReturnValueOnce(pendingFinish);
const stopServer = jest.fn(async () => {
stopped();
});
jest.mocked(spawnDetached).mockReturnValueOnce({
pid: 4242,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: stopServer,
});
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
const running = buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
);
try {
await toolStopped;
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(stopServer).toHaveBeenCalledTimes(1);
} finally {
release();
await running;
}
});
});
@@ -74,7 +74,7 @@ describe(warnIfArgentPackageVersionCannotBeVerified, () => {
).not.toThrow();
expect(warn).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Continuing and letting bun x resolve it.')
expect.stringContaining('Continuing and letting the package manager resolve it.')
);
});
});
@@ -94,6 +94,59 @@ describe(waitForArgentToolServerStateAsync, () => {
return JSON.stringify({ port, pid, token });
}
it('reports process exit instead of waiting for the startup timeout', async () => {
const getExitError = jest.fn(() => new Error('server exited with code 1'));
await expect(
waitForArgentToolServerStateAsync({
stateDir,
ancestorPid: process.pid,
timeoutMs: 100,
pollIntervalMs: 1,
getExitError,
})
).rejects.toThrow('server exited with code 1');
expect(getExitError).toHaveBeenCalledTimes(1);
});
it('detects an exit after polling has begun', async () => {
const getExitError = jest
.fn<Error | undefined, []>()
.mockReturnValueOnce(undefined)
.mockReturnValueOnce(undefined)
.mockReturnValue(new Error('server stopped'));
await expect(
waitForArgentToolServerStateAsync({
stateDir,
ancestorPid: process.pid,
timeoutMs: 100,
pollIntervalMs: 1,
getExitError,
})
).rejects.toThrow('server stopped');
expect(getExitError).toHaveBeenCalledTimes(3);
});
it('rejects an exit observed while reading a matching state file', async () => {
await fs.promises.writeFile(
path.join(stateDir, 'tool-server.json'),
stateJson({ port: 4321, pid: process.pid })
);
const getExitError = jest
.fn<Error | undefined, []>()
.mockReturnValueOnce(undefined)
.mockReturnValue(new Error('server exited during startup'));
await expect(
waitForArgentToolServerStateAsync({
stateDir,
ancestorPid: process.ppid,
timeoutMs: 100,
pollIntervalMs: 1,
getExitError,
})
).rejects.toThrow('server exited during startup');
expect(getExitError).toHaveBeenCalledTimes(2);
});
it.each(['tool-server.json', 'tool-server-012345abcdef.json'])(
'reads a matching process from %s',
async fileName => {
@@ -31,8 +31,17 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
}));
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const server = { pid: 12345, getOutput: () => '', stopAsync: jest.fn() };
const tunnel = { url: 'https://egress.example.com', stopAsync: jest.fn() };
const server = {
pid: 12345,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: jest.fn(),
};
const tunnel = {
url: 'https://egress.example.com',
subdomainId: 'egress-id',
stopAsync: jest.fn(),
};
async function start(signal?: AbortSignal): Promise<void> {
await createStartLocalEgressBuildFunction().fn!({ logger } as BuildStepContext, {
@@ -59,19 +59,47 @@ describe('sandbox build functions', () => {
fn.fn!({ logger: {} } as any, {
inputs: { sandbox_id: { value: 'sandbox-id' } },
outputs: {},
env: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
signal: controller.signal,
})
).rejects.toThrow();
).rejects.toMatchObject({ name: 'AbortError' });
expect(mutation).not.toHaveBeenCalled();
expect(stopAsync).toHaveBeenCalledTimes(1);
} finally {
start.mockRestore();
}
});
it('provides one long-running step', () => {
const ctx = { job: {} } as any;
expect(createStartSandboxBuildFunction(ctx).getFullId()).toBe('eas/start_sandbox');
it('excludes the MCP token from the command environment', async () => {
const env = {
PREPARED_BY_EARLIER_STEP: 'value',
EXPO_TOKEN: 'expo-token',
__EAS_SANDBOX_MCP_TOKEN: 'token',
};
const startupError = new Error('stop after checking options');
const start = jest
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
.mockRejectedValue(startupError);
const fn = createStartSandboxBuildFunction({
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
mcpServerUrl: 'ws://localhost:8787',
} as any);
try {
await expect(
fn.fn!({ logger: {} } as any, {
inputs: { sandbox_id: { value: 'sandbox-id' } },
outputs: {},
env,
})
).rejects.toBe(startupError);
expect(start.mock.calls[0][0]).toMatchObject({ credential: 'token' });
expect(start.mock.calls[0][0].env).toEqual({
PREPARED_BY_EARLIER_STEP: 'value',
EXPO_TOKEN: 'expo-token',
});
expect(env.__EAS_SANDBOX_MCP_TOKEN).toBe('token');
} finally {
start.mockRestore();
}
});
it('throws a system error when the sandbox token is missing', async () => {
@@ -100,9 +128,9 @@ describe('sandbox build functions', () => {
fn.fn!({} as any, {
inputs: { sandbox_id: { value: 'sandbox-id' } },
outputs: {},
env: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'sandbox-token' },
})
).rejects.toBeInstanceOf(SystemError);
).rejects.toThrow('MCP server URL is required to start the sandbox daemon.');
});
it('marks the sandbox as ready', async () => {
@@ -1,25 +1,37 @@
import { type bunyan } from '@expo/logger';
import { BuildRuntimePlatform, type BuildStepContext, type BuildStepEnv } from '@expo/steps';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import {
getDeviceRunSessionIdOrThrow,
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
startDeviceWebPreviewWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
jest.mock('../../utils/remoteDeviceRunSession');
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
getDeviceRunSessionIdOrThrow: jest.fn(),
getNgrokTunnelDomainOrThrow: jest.fn(),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
const ctx = {} as CustomBuildContext;
const env = {} as BuildStepEnv;
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const stopAsync = jest.fn();
async function runAsync(runtimePlatform: BuildRuntimePlatform): Promise<void> {
async function runAsync(
runtimePlatform: BuildRuntimePlatform,
launchInputs: Record<string, { value: unknown }> = {}
): Promise<void> {
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx);
await buildFunction.fn!(
{
@@ -30,6 +42,7 @@ async function runAsync(runtimePlatform: BuildRuntimePlatform): Promise<void> {
inputs: {
package_version: { value: '1.2.3' },
max_duration_seconds: { value: 120 },
...launchInputs,
},
outputs: {},
env,
@@ -43,9 +56,13 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('device-run-session-id');
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockResolvedValue(undefined);
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewUrl: 'https://web-preview.example.test',
stopAsync,
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
closeAsync: jest.fn(),
}),
finishAsync: stopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
@@ -53,10 +70,14 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
});
it('reports the session token when serve-sim minted one', async () => {
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewUrl: 'https://web-preview.example.test',
previewToken: 'tok-1',
stopAsync,
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
previewToken: 'tok-1',
closeAsync: jest.fn(),
}),
finishAsync: stopAsync,
});
await runAsync(BuildRuntimePlatform.DARWIN);
@@ -64,13 +85,30 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: {
previewUrl: 'https://web-preview.example.test',
previewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewApiUrl: 'https://web-preview.example.test',
previewToken: 'tok-1',
},
})
);
});
it.each(['preview', 'config', 'wait'])('finishes the session after %s fails', async phase => {
const error = new Error(`${phase} failed`);
if (phase === 'preview') {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: jest.fn().mockRejectedValue(error),
finishAsync: stopAsync,
});
} else if (phase === 'config') {
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
} else {
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
}
await expect(runAsync(BuildRuntimePlatform.LINUX)).rejects.toBe(error);
expect(stopAsync).toHaveBeenCalledTimes(1);
});
it.each([
[BuildRuntimePlatform.DARWIN, true],
[BuildRuntimePlatform.LINUX, false],
@@ -78,18 +116,23 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
await runAsync(runtimePlatform);
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(selectsXcode ? 1 : 0);
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(ctx, {
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(ctx, {
runtimePlatform,
baseDomain: 'tunnel.example.com',
env,
logger,
timeoutMs: 60_000,
packageVersion: '1.2.3',
launchAppIdentifier: undefined,
launchArgs: [],
openUrl: undefined,
});
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
ctx,
deviceRunSessionId: 'device-run-session-id',
remoteConfig: { previewUrl: 'https://web-preview.example.test' },
remoteConfig: {
previewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewApiUrl: 'https://web-preview.example.test',
},
logger,
});
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith({
@@ -101,4 +144,49 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
});
expect(stopAsync).toHaveBeenCalledTimes(1);
});
it('declares the launch inputs', () => {
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx);
const globalCtx = createGlobalContextMock();
expect(
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
).toEqual([
'launch_app_identifier',
'launch_args',
'open_url',
'package_version',
'max_duration_seconds',
]);
});
it('hands the launch inputs to the session host and announces them', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
launch_app_identifier: { value: 'host.exp.Exponent' },
launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] },
open_url: { value: 'exp://127.0.0.1:8081' },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
launchAppIdentifier: 'host.exp.Exponent',
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
openUrl: 'exp://127.0.0.1:8081',
})
);
expect(logger.info).toHaveBeenCalledWith(
'serve-sim will launch host.exp.Exponent with arguments ' +
'["-EXDevMenuIsOnboardingFinished","1"], then open exp://127.0.0.1:8081.'
);
});
it('fails before starting anything when a launch is asked for on Android', async () => {
await expect(
runAsync(BuildRuntimePlatform.LINUX, {
launch_app_identifier: { value: 'host.exp.Exponent' },
})
).rejects.toThrow('runs on linux');
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
});
});
@@ -29,7 +29,20 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
});
});
it('uploads simulator device metadata', async () => {
it.each([
{
platform: BuildRuntimePlatform.DARWIN,
udid: SIMULATOR_UDID,
name: 'iPhone 16',
runtime: 'iOS 18.6',
},
{
platform: BuildRuntimePlatform.LINUX,
udid: 'emulator-5554',
name: 'Pixel',
runtime: 'Android 16',
},
])('uploads device metadata on $platform', async ({ platform, udid, name, runtime }) => {
const recordingDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'ios-recording-test-'));
const recordingPath = path.join(recordingDirectory, 'recording.mp4');
await fs.writeFile(recordingPath, 'recording');
@@ -45,7 +58,7 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
try {
const globalContext = createGlobalContextMock({
runtimePlatform: BuildRuntimePlatform.DARWIN,
runtimePlatform: platform,
});
const buildStep = createUploadDeviceRunSessionScreenRecordingsBuildFunction(
{} as CustomBuildContext
@@ -54,9 +67,9 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
callInputs: {
recordings_json: [
{
udid: SIMULATOR_UDID,
deviceName: 'iPhone 16',
runtimeDisplayName: 'iOS 18.6',
udid,
deviceName: name,
runtimeDisplayName: runtime,
directory: recordingDirectory,
},
],
@@ -68,13 +81,13 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
name: 'iPhone 16 screen recording (01234567-…, started at Jul 10, 2026, 10:00:00.000 UTC)',
name: `${name} screen recording (${udid.slice(0, 8)}-…, started at Jul 10, 2026, 10:00:00.000 UTC)`,
metadata: {
__eas_type: 'screen-recording',
__eas_screen_recording: '1',
udid: SIMULATOR_UDID,
deviceName: 'iPhone 16',
runtimeDisplayName: 'iOS 18.6',
udid,
deviceName: name,
runtimeDisplayName: runtime,
firstFrameAt: '2026-07-10T10:00:00.000Z',
width: 1179,
height: 2556,
@@ -184,7 +184,7 @@ function logApplicationLaunch(
logger.info(`Launching ${applicationIdentifier}${argumentsDescription}.`);
}
function parseNonEmptyStringInput(value: unknown, inputName: string): string {
export function parseNonEmptyStringInput(value: unknown, inputName: string): string {
if (typeof value !== 'string' || value.length === 0) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
@@ -194,7 +194,7 @@ function parseNonEmptyStringInput(value: unknown, inputName: string): string {
return value;
}
function parseLaunchArgsInput(value: unknown): string[] {
export function parseLaunchArgsInput(value: unknown): string[] {
if (value === undefined) {
return [];
}
@@ -207,7 +207,7 @@ function parseLaunchArgsInput(value: unknown): string[] {
return value;
}
function parseOpenUrlInput(value: unknown): string {
export function parseOpenUrlInput(value: unknown): string {
const openUrl = parseNonEmptyStringInput(value, 'open_url');
if (!URL.canParse(openUrl)) {
throw new UserError(
@@ -108,6 +108,9 @@ function buildMaestroArgs({
}
case 'maestro-runner': {
const args = [`--platform=${platform}`, 'test', `--output=${output}`, '--flatten'];
if (shards !== undefined && shards > 1) {
args.push(`--parallel=${shards}`);
}
if (includeTags) {
args.push(`--include-tags=${includeTags}`);
}
@@ -259,12 +262,6 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
undefined,
'android_connection_mode and EAS_MAESTRO_ANDROID_CONNECTION_MODE must be either "adb" or "dadb".'
);
if (backend === 'maestro-runner' && shards !== undefined && shards > 1) {
throw new UserError(
'ERR_MAESTRO_INVALID_INPUT',
'maestro-runner does not support EAS Maestro test sharding. Remove shards or set it to 1.'
);
}
if (backend === 'maestro-runner' && outputFormat !== undefined && outputFormat !== 'junit') {
throw new UserError(
'ERR_MAESTRO_INVALID_INPUT',
@@ -17,17 +17,27 @@ import {
uploadRemoteSessionConfigWithLocalEgressAsync,
withLocalEgressSession,
} from '../utils/localEgressSession';
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import { Sentry } from '../../sentry';
import {
PackageManager,
resolveConfiguredPackageManager,
resolvePackageAdd,
resolvePackageInstall,
} from '../../utils/packageManager';
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
import {
type DetachedProcessHandle,
createServeSimLaunchInputProviders,
describeServeSimLaunch,
finishRemoteSessionAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
@@ -42,6 +52,13 @@ const STARTUP_TIMEOUT_MS = 60_000;
const AGENT_DEVICE_DAEMON_ENV = {
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
AGENT_DEVICE_RETAIN_ARTIFACTS: '1',
// The session lifetime is owned by max_idle_time_minutes / max_duration_seconds
// and the device run session stop, so disable agent-device's own idle timers.
// Without this the daemon exits after 5 minutes with no open session, and the
// tunnel is left without a daemon behind it.
AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0',
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS: '0',
AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS: '0',
};
export function createStartAgentDeviceRemoteSessionBuildFunction(
@@ -53,6 +70,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
name: 'Start agent device remote session',
__metricsId: 'eas/start_agent_device_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -83,6 +101,14 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
const { runtimePlatform } = global;
const launch = parseServeSimLaunchInputs(
{
launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value as string | undefined,
},
{ runtimePlatform }
);
logger.info(
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
);
@@ -110,19 +136,29 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
const agentDeviceRemoteSessionUrl = agentDeviceTunnel.url;
logger.info(`Tunnel is ready at ${agentDeviceRemoteSessionUrl}.`);
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let eventCollection:
| Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>>
| undefined;
let sessionFailed = false;
try {
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
sessionHost = await startDeviceSessionHostAsync(ctx, {
runtimePlatform,
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
});
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
logger.info(
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
);
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
@@ -132,7 +168,8 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
remoteConfig: {
agentDeviceRemoteSessionUrl,
agentDeviceRemoteSessionToken: daemonToken,
webPreviewUrl: webPreview.previewUrl,
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
@@ -165,24 +202,107 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
}
: undefined,
});
} catch (error) {
sessionFailed = true;
throw error;
} finally {
if (webPreview) {
await webPreview.stopAsync();
}
await agentDeviceTunnel.stopAsync();
if (eventCollection) {
await stopAgentDeviceEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
}
await daemonProcess.stopAsync();
await finishRemoteSessionAsync({
logger,
sessionFailed,
teardown: [
['agent-device tunnel', agentDeviceTunnel.stopAsync()],
[
'agent-device daemon',
(async () => {
try {
if (eventCollection) {
await stopAgentDeviceEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
}
} finally {
await daemonProcess.stopAsync();
}
})(),
],
['session host', sessionHost?.finishAsync()],
],
});
}
}),
});
}
export async function startAgentDeviceDaemonAsync({
packageVersion,
env,
logger,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
}): Promise<DetachedProcessHandle> {
const packageSpec = createAgentDevicePackageSpec(packageVersion);
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
const installDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-agent-device-'));
await fs.promises.writeFile(
path.join(installDir, 'package.json'),
`${JSON.stringify({ name: 'eas-agent-device', private: true })}\n`
);
try {
const add = resolvePackageAdd(packageManager, packageSpec);
logger.info(`Installing ${packageSpec} with ${add.command}.`);
await spawn(add.command, add.args, { cwd: installDir, env, logger });
const daemonPath = getInstalledAgentDeviceDaemonPath(installDir);
if (!fs.existsSync(daemonPath)) {
throw new SystemError(`Expected agent-device daemon entry at ${daemonPath}.`);
}
logger.info(`Launching daemon from ${daemonPath} after ${add.command} install.`);
const daemonProcess = spawnDetached({
command: 'node',
args: [daemonPath],
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
});
return {
...daemonProcess,
stopAsync: async () => {
await daemonProcess.stopAsync();
await fs.promises.rm(installDir, { recursive: true, force: true });
},
};
} catch (err) {
await fs.promises.rm(installDir, { recursive: true, force: true });
const error = err instanceof Error ? err : new Error(String(err));
const bunVersion = await getBunVersionForDiagnosticsAsync(env);
Sentry.capture(
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
error,
{
level: 'warning',
tags: {
phase: 'agent-device-daemon-start',
fallback: 'git-clone',
},
extras: {
packageSpec,
packageVersion: packageVersion ?? 'latest',
packageManager,
bunVersion,
},
}
);
logger.warn(
`Failed to start daemon from ${packageSpec} via ${packageManager}; falling back to git clone: ${error.message}`
);
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger });
}
}
export async function stopAgentDeviceEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
@@ -205,61 +325,6 @@ export async function stopAgentDeviceEventCollectionSafelyAsync({
}
}
async function startAgentDeviceDaemonAsync({
packageVersion,
env,
logger,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
}): Promise<DetachedProcessHandle> {
const packageSpec = createAgentDevicePackageSpec(packageVersion);
try {
logger.info(`Installing ${packageSpec} globally with Bun.`);
await spawn('bun', ['add', '--global', packageSpec], {
env,
logger,
});
const daemonPath = getGlobalAgentDeviceDaemonPath(env);
if (!fs.existsSync(daemonPath)) {
throw new SystemError(`Expected agent-device daemon entry at ${daemonPath}.`);
}
logger.info(`Launching daemon from ${daemonPath}.`);
return spawnDetached({
command: 'node',
args: [daemonPath],
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
const bunVersion = await getBunVersionForDiagnosticsAsync(env);
Sentry.capture(
'Failed to start agent-device daemon from global Bun package; falling back to git clone',
error,
{
level: 'warning',
tags: {
phase: 'agent-device-daemon-start',
fallback: 'git-clone',
},
extras: {
packageSpec,
packageVersion: packageVersion ?? 'latest',
bunVersion,
bunInstallConfigured: Boolean(env.BUN_INSTALL?.trim()),
},
}
);
logger.warn(
`Failed to start daemon from global ${packageSpec}; falling back to git clone: ${error.message}`
);
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger });
}
}
async function startAgentDeviceDaemonFromGitAsync({
packageVersion,
env,
@@ -276,14 +341,17 @@ async function startAgentDeviceDaemonFromGitAsync({
);
await cloneAgentDeviceAsync({ packageVersion, env, logger });
logger.info('Installing agent-device dependencies.');
await spawn('bun', ['install', '--production'], {
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
const install = resolvePackageInstall(packageManager, { production: true });
logger.info(`Installing agent-device dependencies with ${install.command}.`);
await spawn(install.command, install.args, {
cwd: SRC_DIR,
env,
logger,
});
logger.info('Launching daemon from cloned agent-device source.');
// Git fallback is TypeScript source. The published path runs node on dist JS.
return spawnDetached({
command: 'bun',
args: ['run', 'src/daemon.ts'],
@@ -346,11 +414,9 @@ function createAgentDevicePackageSpec(packageVersion: string | undefined): strin
return `${AGENT_DEVICE_PACKAGE_NAME}@${versionSpec}`;
}
function getGlobalAgentDeviceDaemonPath(env: BuildStepEnv): string {
function getInstalledAgentDeviceDaemonPath(installDir: string): string {
return path.join(
getBunInstallDirectory(env),
'install',
'global',
installDir,
'node_modules',
AGENT_DEVICE_PACKAGE_NAME,
'dist',
@@ -360,11 +426,6 @@ function getGlobalAgentDeviceDaemonPath(env: BuildStepEnv): string {
);
}
function getBunInstallDirectory(env: BuildStepEnv): string {
const bunInstall = env.BUN_INSTALL?.trim();
return bunInstall ? bunInstall : path.join(os.homedir(), '.bun');
}
function parseDaemonInfo(raw: string): { port: number; token: string } {
const parsed = JSON.parse(raw) as unknown;
if (
@@ -19,18 +19,27 @@ import {
uploadRemoteSessionConfigWithLocalEgressAsync,
withLocalEgressSession,
} from '../utils/localEgressSession';
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
import {
PackageManager,
resolveConfiguredPackageManager,
resolvePackageAdd,
} from '../../utils/packageManager';
import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
import {
createServeSimLaunchInputProviders,
describeServeSimLaunch,
finishRemoteSessionAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
@@ -54,6 +63,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
name: 'Start Appium remote session',
__metricsId: 'eas/start_appium_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -72,6 +82,14 @@ export function createStartAppiumRemoteSessionBuildFunction(
const packageVersion = inputs.package_version.value as string | undefined;
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
const { runtimePlatform } = global;
const launch = parseServeSimLaunchInputs(
{
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
},
{ runtimePlatform }
);
const versionSpec = resolveAppium3VersionSpec(packageVersion);
logger.info(
@@ -121,7 +139,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
logger,
});
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let sessionFailed = false;
try {
appiumTunnel = await startNgrokTunnelAsync({
port: APPIUM_PORT,
@@ -133,13 +152,20 @@ export function createStartAppiumRemoteSessionBuildFunction(
// 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.
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
sessionHost = await startDeviceSessionHostAsync(ctx, {
runtimePlatform,
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
});
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
@@ -153,7 +179,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
'appium:automationName': device.automationName,
'appium:udid': device.udid,
},
webPreviewUrl: webPreview.previewUrl,
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
@@ -172,16 +199,29 @@ export function createStartAppiumRemoteSessionBuildFunction(
}
: undefined,
});
} catch (error) {
sessionFailed = true;
throw error;
} finally {
if (webPreview) {
await webPreview.stopAsync();
}
if (appiumTunnel) {
await appiumTunnel.stopAsync();
}
await eventCollection.stopAsync();
await appiumProcess.stopAsync();
await fs.promises.rm(appiumHome, { recursive: true, force: true });
await finishRemoteSessionAsync({
logger,
sessionFailed,
teardown: [
['Appium tunnel', appiumTunnel?.stopAsync()],
[
'Appium server',
(async () => {
try {
await eventCollection.stopAsync();
} finally {
await appiumProcess.stopAsync();
await fs.promises.rm(appiumHome, { recursive: true, force: true });
}
})(),
],
['session host', sessionHost?.finishAsync()],
],
});
}
}),
});
@@ -198,7 +238,7 @@ export function resolveAppium3VersionSpec(packageVersion: string | undefined): s
return versionSpec;
}
type AppiumDevice = {
export type AppiumDevice = {
platformName: 'iOS' | 'Android';
automationName: 'XCUITest' | 'UiAutomator2';
driverName: 'xcuitest' | 'uiautomator2';
@@ -247,7 +287,7 @@ export async function resolveAppiumDeviceAsync({
}
}
async function installAppiumAsync({
export async function installAppiumAsync({
versionSpec,
driverName,
env,
@@ -265,9 +305,14 @@ async function installAppiumAsync({
);
const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome };
const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium');
const add = resolvePackageAdd(
resolveConfiguredPackageManager(env, PackageManager.NPM),
`appium@${versionSpec}`
);
logger.info(`Installing appium@${versionSpec}.`);
await spawn('npm', ['install', '--prefix', appiumHome, `appium@${versionSpec}`], {
logger.info(`Installing appium@${versionSpec} with ${add.command}.`);
await spawn(add.command, add.args, {
cwd: appiumHome,
env: appiumEnv,
logger,
});
@@ -18,19 +18,28 @@ import {
uploadRemoteSessionConfigWithLocalEgressAsync,
withLocalEgressSession,
} from '../utils/localEgressSession';
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import { Sentry } from '../../sentry';
import {
PackageManager,
resolveConfiguredPackageManager,
resolvePackageExec,
} from '../../utils/packageManager';
import { isProcessDescendantOfAsync } from '../../utils/processes';
import { sleepAsync } from '../../utils/retry';
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents';
import {
createServeSimLaunchInputProviders,
describeServeSimLaunch,
ensureFfmpegInstalledOnceAsync,
finishRemoteSessionAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
@@ -66,6 +75,7 @@ export function createStartArgentRemoteSessionBuildFunction(
name: 'Start argent remote session',
__metricsId: 'eas/start_argent_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -98,6 +108,14 @@ export function createStartArgentRemoteSessionBuildFunction(
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
const versionSpec = packageVersion ?? 'latest';
const { runtimePlatform } = global;
const launch = parseServeSimLaunchInputs(
{
launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value as string | undefined,
},
{ runtimePlatform }
);
logger.info(
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
@@ -113,42 +131,45 @@ export function createStartArgentRemoteSessionBuildFunction(
// Never rejects, so `void` is safe.
void ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
const argentExec = (args: string[]): { command: string; args: string[] } =>
resolvePackageExec(packageManager, args);
logger.info('Enabling the Argent artifacts list endpoint flag.');
await spawn(
'bun',
[
'x',
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
],
{ env, logger }
);
const enableArtifacts = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
]);
await spawn(enableArtifacts.command, enableArtifacts.args, { env, logger });
logger.info('Enabling the Argent tool-server event log flag.');
await spawn(
'bun',
['x', `${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_EVENT_LOG_FLAG],
{ env, logger }
);
const enableEventLog = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_EVENT_LOG_FLAG,
]);
await spawn(enableEventLog.command, enableEventLog.args, { env, logger });
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bun x.`);
// Keep Argent itself in foreground mode under the detached bun process. This preserves
// the bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
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 argentServer = spawnDetached({
command: 'bun',
args: [
'x',
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'server',
'start',
'--port',
'0',
'--idle-timeout',
'0',
'--force',
],
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH },
command: startServer.command,
args: startServer.args,
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH, ARGENT_EMULATOR_NO_WINDOW: '1' },
});
if (argentServer.pid === undefined) {
throw new SystemError(
@@ -164,6 +185,7 @@ export function createStartArgentRemoteSessionBuildFunction(
stateDir: ARGENT_STATE_DIR,
ancestorPid: argentServer.pid,
timeoutMs: STARTUP_TIMEOUT_MS,
getExitError: argentServer.getExitError,
});
toolServerPort = toolServerState.port;
toolServerToken = toolServerState.token;
@@ -196,7 +218,8 @@ export function createStartArgentRemoteSessionBuildFunction(
});
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
let sessionHost: DeviceSessionHost | undefined;
let sessionFailed = false;
try {
toolsTunnel = await startNgrokTunnelAsync({
port: toolServerPort,
@@ -209,14 +232,23 @@ export function createStartArgentRemoteSessionBuildFunction(
const publicToolsUrl = toolsTunnel.url;
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
sessionHost = await startDeviceSessionHostAsync(ctx, {
runtimePlatform,
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
});
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
logger.info(
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
);
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
@@ -226,7 +258,8 @@ export function createStartArgentRemoteSessionBuildFunction(
remoteConfig: {
toolsUrl: publicToolsUrl,
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
webPreviewUrl: webPreview.previewUrl,
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
@@ -246,23 +279,46 @@ export function createStartArgentRemoteSessionBuildFunction(
}
: undefined,
});
} catch (error) {
sessionFailed = true;
throw error;
} finally {
if (webPreview) {
await webPreview.stopAsync();
}
if (toolsTunnel) {
await toolsTunnel.stopAsync();
}
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.');
}
await argentServer.stopAsync();
await finishRemoteSessionAsync({
logger,
sessionFailed,
teardown: [
['Argent tunnel', toolsTunnel?.stopAsync()],
[
'Argent tool-server',
(async () => {
try {
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();
}
})(),
],
['session host', sessionHost?.finishAsync()],
],
});
}
}),
});
@@ -306,7 +362,7 @@ export function warnIfArgentPackageVersionCannotBeVerified({
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 bun x resolve it.`
`Continuing and letting the package manager resolve it.`
);
return;
}
@@ -341,16 +397,26 @@ export async function waitForArgentToolServerStateAsync({
ancestorPid,
timeoutMs,
pollIntervalMs = 1_000,
getExitError,
}: {
stateDir: string;
ancestorPid: number;
timeoutMs: number;
pollIntervalMs?: number;
getExitError?: () => Error | undefined;
}): Promise<ArgentToolServerState> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
const throwIfExited = (): void => {
const error = getExitError?.();
if (error) {
throw new SystemError(`Argent exited before becoming ready: ${error.message}`);
}
};
while (Date.now() < deadline) {
throwIfExited();
let matchingState: ArgentToolServerState | undefined;
try {
const stateFileNames = (await fs.promises.readdir(stateDir)).filter(
name => name.startsWith('tool-server') && name.endsWith('.json')
@@ -361,7 +427,8 @@ export async function waitForArgentToolServerStateAsync({
await fs.promises.readFile(path.join(stateDir, stateFileName), 'utf8')
);
if (await isProcessDescendantOfAsync(state.pid, ancestorPid)) {
return state;
matchingState = state;
break;
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT' && !(err instanceof SystemError)) {
@@ -372,6 +439,11 @@ export async function waitForArgentToolServerStateAsync({
} catch (err) {
lastError = err;
}
// Keep process failures outside the retryable state-file read errors.
throwIfExited();
if (matchingState) {
return matchingState;
}
await sleepAsync(pollIntervalMs);
}
@@ -3,6 +3,7 @@ import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@exp
import { graphql } from 'gql.tada';
import { CustomBuildContext } from '../../customBuildContext';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import { withLogPhaseAsync } from '../../utils/logPhase';
import { startSandboxDaemonAsync } from '../utils/sandboxDaemon';
@@ -32,8 +33,9 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async (stepCtx, { inputs, signal }) => {
const sandboxToken = ctx.env.__EAS_SANDBOX_MCP_TOKEN;
fn: async (stepCtx, { inputs, signal, env }) => {
// The daemon needs this credential, but shell commands do not.
const { __EAS_SANDBOX_MCP_TOKEN: sandboxToken, ...commandEnv } = env;
if (!sandboxToken) {
throw new SystemError('__EAS_SANDBOX_MCP_TOKEN is required to start the sandbox daemon.');
}
@@ -48,6 +50,8 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
reconnectDelayMs: RECONNECT_DELAY_MS,
logger: stepCtx.logger,
signal,
workingDirectory: stepCtx.workingDirectory,
env: commandEnv,
});
try {
await daemon.ready;
@@ -73,19 +77,7 @@ export async function markSandboxReadyAsync(
): Promise<void> {
signal?.throwIfAborted();
const result = await ctx.graphqlClient
.mutation(
MARK_SANDBOX_READY_MUTATION,
{ sandboxId },
signal
? {
fetch: (input, init) =>
fetch(input, {
...init,
signal: init?.signal ? AbortSignal.any([signal, init.signal]) : signal,
}),
}
: undefined
)
.mutation(MARK_SANDBOX_READY_MUTATION, { sandboxId }, graphqlAbortContext(signal))
.toPromise();
signal?.throwIfAborted();
if (result.error) {
@@ -6,15 +6,18 @@ import {
} from '@expo/steps';
import { CustomBuildContext } from '../../customBuildContext';
import { startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import {
uploadRemoteSessionConfigWithLocalEgressAsync,
withLocalEgressSession,
} from '../utils/localEgressSession';
import {
createServeSimLaunchInputProviders,
describeServeSimLaunch,
getDeviceRunSessionIdOrThrow,
getNgrokTunnelDomainOrThrow,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
startDeviceWebPreviewWithTunnelAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
@@ -29,6 +32,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
name: 'Start web preview remote session',
__metricsId: 'eas/start_serve_sim_remote_session',
inputProviders: [
...createServeSimLaunchInputProviders(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -46,31 +50,47 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
const packageVersion = inputs.package_version?.value as string | undefined;
const { runtimePlatform } = global;
const launch = parseServeSimLaunchInputs(
{
launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value as string | undefined,
},
{ runtimePlatform }
);
logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`);
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
const sessionHost = await startDeviceSessionHostAsync(ctx, {
runtimePlatform,
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
packageVersion,
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
});
logger.info(`Preview URL: ${webPreview.previewUrl}`);
try {
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
logger.info(`Preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`);
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
deviceRunSessionId,
remoteConfig: {
previewUrl: webPreview.previewUrl,
previewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { previewToken: webPreview.previewToken } : {}),
},
logger,
@@ -84,7 +104,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
signal,
});
} finally {
await webPreview.stopAsync();
await sessionHost.finishAsync();
}
}),
});
@@ -1,53 +1,17 @@
import { SystemError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import { createReadStream } from 'node:fs';
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import limitFactory from 'promise-limit';
import { z } from 'zod';
import { type CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { formatBytes } from '../../utils/artifacts';
import { uploadDeviceRunSessionArtifactAsync } from '../utils/deviceRunSessionArtifacts';
import {
parseDeviceScreenRecordings,
uploadDeviceRunSessionScreenRecordingsAsync,
} from '../utils/deviceRunSessionScreenRecordings';
import { getDeviceRunSessionIdOrThrow } from '../utils/remoteDeviceRunSession';
const RecordingsSchema = z.array(
z.object({
udid: z.string(),
deviceName: z.string(),
runtimeDisplayName: z.string(),
directory: z.string(),
})
);
const RecordingManifestSchema = z.object({
firstFrameWallClock: z.object({
iso8601: z.string(),
}),
width: z.number().int().positive(),
height: z.number().int().positive(),
recording: z.string(),
});
const recordingStartTimeFormatter = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3,
hourCycle: 'h23',
timeZone: 'UTC',
timeZoneName: 'short',
});
export function createUploadDeviceRunSessionScreenRecordingsBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
@@ -65,69 +29,16 @@ export function createUploadDeviceRunSessionScreenRecordingsBuildFunction(
}),
],
fn: async ({ logger }, { env, inputs }) => {
const result = RecordingsSchema.safeParse(inputs.recordings_json.value ?? []);
if (!result.success) {
throw new SystemError('Invalid iOS Simulator recordings input.', {
cause: result.error,
});
}
const recordings = result.data;
const recordings = parseDeviceScreenRecordings(inputs.recordings_json.value ?? []);
if (recordings.length === 0) {
logger.info('No iOS Simulator recordings found; skipping uploads.');
logger.info('No device screen recordings found; skipping uploads.');
return;
}
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const limit = limitFactory(5);
await Promise.all(
recordings.map(recording =>
limit(async () => {
try {
const metadata = RecordingManifestSchema.parse(
JSON.parse(await readFile(path.join(recording.directory, 'session.json'), 'utf-8'))
);
const startedAt = recordingStartTimeFormatter.format(
new Date(metadata.firstFrameWallClock.iso8601)
);
const shortUdid = `${recording.udid.slice(0, 8)}-…`;
const displayName = `${recording.deviceName} screen recording (${shortUdid}, started at ${startedAt})`;
const recordingPath = path.join(recording.directory, metadata.recording);
const { size } = await stat(recordingPath);
const recordingId = path.basename(recording.directory);
logger.info(
`Uploading screen recording for ${recording.deviceName} (${formatBytes(size)}).`
);
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId,
artifactId: recordingId,
name: displayName,
filename: `${recordingId}.mp4`,
kind: 'screen-recording',
metadata: {
__eas_type: 'screen-recording',
__eas_screen_recording: '1',
udid: recording.udid,
deviceName: recording.deviceName,
runtimeDisplayName: recording.runtimeDisplayName,
firstFrameAt: metadata.firstFrameWallClock.iso8601,
width: metadata.width,
height: metadata.height,
},
size,
stream: createReadStream(recordingPath),
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not upload iOS Simulator screen recording', error);
logger.warn(
{ err: error },
`Could not upload screen recording for ${recording.deviceName}.`
);
}
})
)
);
await uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
logger,
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
recordings,
});
},
});
}
@@ -63,7 +63,8 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
metadata: { firstFrameRecordAt: 'test-time' },
size: reportedSize,
},
})
}),
{ fetch: expect.any(Function) }
);
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
'https://uploads.expo.test/artifact',
@@ -0,0 +1,80 @@
import {
DeviceRunSessionTimeoutError,
withDeviceRunSessionTimeoutAsync,
} from '../deviceRunSessionTimeout';
afterEach(() => jest.useRealTimers());
it('aborts a stalled operation when its deadline expires', async () => {
jest.useFakeTimers();
let signal: AbortSignal | undefined;
const operation = withDeviceRunSessionTimeoutAsync(
{ name: 'test operation', timeoutMs: 100 },
async value => {
signal = value;
return await new Promise<void>(() => {});
}
);
const rejected = expect(operation).rejects.toThrow('test operation timed out after 100ms');
const typed = expect(operation).rejects.toBeInstanceOf(DeviceRunSessionTimeoutError);
await jest.advanceTimersByTimeAsync(100);
await rejected;
await typed;
expect(signal?.aborted).toBe(true);
expect(jest.getTimerCount()).toBe(0);
});
it('does not start an operation with an already aborted parent', async () => {
const operation = jest.fn();
await expect(
withDeviceRunSessionTimeoutAsync(
{
name: 'test',
timeoutMs: 100,
signal: AbortSignal.abort(new Error('parent ended')),
},
operation
)
).rejects.toThrow('parent ended');
expect(operation).not.toHaveBeenCalled();
});
it('clears the deadline when an operation succeeds', async () => {
jest.useFakeTimers();
await expect(
withDeviceRunSessionTimeoutAsync({ name: 'test', timeoutMs: 100 }, async () => 42)
).resolves.toBe(42);
expect(jest.getTimerCount()).toBe(0);
});
it('ignores a deadline reset that arrives after the operation settled', async () => {
jest.useFakeTimers();
let resetDeadline!: () => void;
await expect(
withDeviceRunSessionTimeoutAsync({ name: 'test', timeoutMs: 100 }, async (_signal, reset) => {
resetDeadline = reset;
return 42;
})
).resolves.toBe(42);
resetDeadline();
expect(jest.getTimerCount()).toBe(0);
});
it('keeps an operation alive while it reports progress', async () => {
jest.useFakeTimers();
const operation = withDeviceRunSessionTimeoutAsync(
{ name: 'test operation', timeoutMs: 100 },
async (signal, resetDeadline) => {
await new Promise<void>(resolve => setTimeout(resolve, 80));
resetDeadline();
await new Promise<void>(resolve => setTimeout(resolve, 80));
resetDeadline();
await new Promise<void>(resolve => setTimeout(resolve, 80));
return signal.aborted;
}
);
const settled = expect(operation).resolves.toBe(false);
await jest.advanceTimersByTimeAsync(240);
await settled;
expect(jest.getTimerCount()).toBe(0);
});
@@ -0,0 +1,463 @@
import type { bunyan } from '@expo/logger';
import type { BuildStepEnv } from '@expo/steps';
import { Client, fetchExchange } from '@urql/core';
import { execFileSync } from 'node:child_process';
import { createReadStream } from 'node:fs';
import { copyFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
import type { Socket } from 'node:net';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { Readable } from 'node:stream';
import { setTimeout as delay } from 'node:timers/promises';
import type { CustomBuildContext } from '../../../customBuildContext';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import {
findUnlistedDeviceScreenRecordingsAsync,
uploadDeviceRunSessionScreenRecordingsAsync,
} from '../deviceRunSessionScreenRecordings';
jest.unmock('node-fetch');
jest.unmock('node:fs');
jest.unmock('node:fs/promises');
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(r => {
resolve = r;
});
return { promise, resolve };
}
const metadata = {
deviceRunSessionId: 'drs-id',
artifactId: 'recording-id',
name: 'Recording',
filename: 'recording.mp4',
kind: 'screen-recording',
size: 16,
};
let handle: (request: IncomingMessage, response: ServerResponse) => void;
let server: ReturnType<typeof createServer>;
let ctx: CustomBuildContext;
let url: string;
let directory: string;
let creates: number;
let authorization: string | undefined;
let stallCreation: boolean;
let creationStarted: ReturnType<typeof deferred<IncomingMessage>>;
let creationBodies: string[];
beforeEach(async () => {
creates = 0;
authorization = undefined;
stallCreation = false;
creationBodies = [];
creationStarted = deferred();
directory = await mkdtemp(path.join(tmpdir(), 'recording-upload-http-'));
server = createServer((request, response) => {
if (request.url === '/graphql') {
creates++;
authorization = request.headers.authorization;
creationStarted.resolve(request);
request.setEncoding('utf8');
let body = '';
request.on('data', chunk => (body += chunk));
request.on('end', () => creationBodies.push(body));
request.on('end', () => {
if (stallCreation) {
return;
}
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify({
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: { url: `${url}/artifact`, headers: {} },
},
},
},
})
);
});
} else {
handle(request, response);
}
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Missing test server address');
}
url = `http://127.0.0.1:${address.port}`;
ctx = {
graphqlClient: new Client({
url: `${url}/graphql`,
exchanges: [fetchExchange],
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
}),
} as CustomBuildContext;
});
afterEach(async () => {
server.closeAllConnections();
await new Promise<void>(resolve => server.close(() => resolve()));
await rm(directory, { recursive: true, force: true });
});
it('replays the full file after a transient PUT failure without recreating the upload session', async () => {
const bodies: string[] = [];
handle = (request, response) => {
const chunks: string[] = [];
request.setEncoding('utf8');
request.on('data', chunk => chunks.push(chunk));
request.on('end', () => {
bodies.push(chunks.join(''));
response.statusCode = bodies.length === 1 ? 503 : 200;
response.end();
});
};
const file = path.join(directory, 'recording.mp4');
const payload = '*'.repeat(128 * 1024);
await writeFile(file, payload);
const streams: Readable[] = [];
const open = () => {
const stream = createReadStream(file);
streams.push(stream);
return stream;
};
await uploadDeviceRunSessionArtifactAsync(ctx, {
...metadata,
size: payload.length,
stream: open(),
reopenStream: open,
});
expect(creates).toBe(1);
expect(authorization).toBe('Bearer test-auth');
expect(bodies).toEqual([payload, payload]);
expect(streams).toHaveLength(2);
expect(streams.every(stream => stream.destroyed)).toBe(true);
});
it('keeps a slow PUT alive while its body is still flowing', async () => {
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
let received = 0;
let onChunk = () => {};
handle = (request, response) => {
request.on('data', chunk => {
received += chunk.length;
onChunk();
});
request.on('end', () => {
response.statusCode = 200;
response.end();
});
};
const waitForBytes = (total: number) =>
new Promise<void>(resolve => {
onChunk = () => {
if (received >= total) {
resolve();
}
};
onChunk();
});
const stream = new Readable({ read() {} });
const upload = uploadDeviceRunSessionArtifactAsync(ctx, { ...metadata, size: 3, stream });
const settled = expect(upload).resolves.toBeUndefined();
stream.push('a');
await waitForBytes(1);
await jest.advanceTimersByTimeAsync(60_000);
stream.push('b');
await waitForBytes(2);
await jest.advanceTimersByTimeAsync(60_000);
stream.push('c');
stream.push(null);
await settled;
expect(received).toBe(3);
jest.useRealTimers();
});
it('retries a PUT that stalled past the idle deadline with a fresh stream', async () => {
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
let puts = 0;
const firstBodyRead = deferred<void>();
handle = (request, response) => {
puts++;
request.resume();
request.on('end', () => {
if (puts === 1) {
firstBodyRead.resolve();
return;
}
response.statusCode = 200;
response.end();
});
};
const streams: Readable[] = [];
const open = () => {
const stream = Readable.from('recording');
streams.push(stream);
return stream;
};
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
...metadata,
size: 9,
stream: open(),
reopenStream: open,
});
const settled = expect(upload).resolves.toBeUndefined();
await firstBodyRead.promise;
await jest.advanceTimersByTimeAsync(90_000);
await jest.advanceTimersByTimeAsync(500);
await settled;
expect(puts).toBe(2);
expect(streams).toHaveLength(2);
expect(streams.every(stream => stream.destroyed)).toBe(true);
jest.useRealTimers();
});
it('cancels an active PUT socket and destroys its source without retrying', async () => {
const started = deferred<void>();
const closed = deferred<void>();
let puts = 0;
handle = request => {
puts++;
request.socket.once('close', () => closed.resolve());
request.on('data', () => started.resolve());
};
const controller = new AbortController();
const stream = new Readable({ read() {} });
stream.push(Buffer.alloc(1024));
const reopenStream = jest.fn(() => Readable.from('unexpected retry'));
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
...metadata,
stream,
reopenStream,
signal: controller.signal,
});
const rejected = expect(upload).rejects.toThrow('test cancellation');
await started.promise;
controller.abort(new Error('test cancellation'));
await rejected;
await closed.promise;
expect(stream.destroyed).toBe(true);
expect(reopenStream).not.toHaveBeenCalled();
expect(puts).toBe(1);
});
it.each([200, 400, 503])(
'closes a stalled HTTP %s response before leaving or retrying the PUT',
async status => {
const sockets = new Set<Socket>();
let puts = 0;
let maxOpenSockets = 0;
handle = (request, response) => {
puts++;
sockets.add(request.socket);
maxOpenSockets = Math.max(maxOpenSockets, sockets.size);
request.socket.once('close', () => sockets.delete(request.socket));
request.resume();
request.on('end', () => {
response.writeHead(status, { 'Content-Length': '1000' });
response.flushHeaders();
response.write('partial response');
});
};
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
...metadata,
stream: Readable.from('recording'),
reopenStream: () => Readable.from('recording'),
});
if (status === 200) {
await expect(upload).resolves.toBeUndefined();
} else {
await expect(upload).rejects.toThrow(`HTTP ${status}`);
}
for (let i = 0; i < 100 && sockets.size > 0; i++) {
await delay(10);
}
expect(creates).toBe(1);
expect(puts).toBe(status === 503 ? 3 : 1);
expect(maxOpenSockets).toBe(1);
expect(sockets.size).toBe(0);
}
);
it('cancels urql session creation with auth intact and never starts a PUT', async () => {
stallCreation = true;
const puts = jest.fn();
handle = puts;
const controller = new AbortController();
const stream = Readable.from('recording');
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
...metadata,
stream,
signal: controller.signal,
});
const rejected = expect(upload).rejects.toThrow('test cancellation');
const request = await creationStarted.promise;
const closed = new Promise<void>(resolve => request.socket.once('close', resolve));
controller.abort(new Error('test cancellation'));
await rejected;
await closed;
expect(authorization).toBe('Bearer test-auth');
expect(creates).toBe(1);
expect(puts).not.toHaveBeenCalled();
expect(stream.destroyed).toBe(true);
});
it('says ffprobe is missing instead of calling an unlisted recording undecodable', async () => {
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const session = path.join(directory, 'session');
await mkdir(session);
await writeFile(path.join(session, 'recording.mp4.partial'), new Uint8Array(28));
await writeFile(
path.join(session, 'session.json'),
JSON.stringify({
udid: 'emulator-5554',
deviceName: 'Pixel',
runtimeDisplayName: 'Android 16',
status: 'recording',
recording: 'recording.mp4.partial',
})
);
const recordings = await findUnlistedDeviceScreenRecordingsAsync({
root: directory,
env: { ...process.env, PATH: path.join(directory, 'no-tools') } as BuildStepEnv,
logger,
});
expect(recordings).toEqual([]);
expect(logger.warn).toHaveBeenCalledWith(
'ffprobe is not available; cannot recover the unlisted recording session/recording.mp4.partial.'
);
});
it('recovers unlisted recordings from a killed host and flags the unfinished ones as partial', async () => {
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const env = process.env as BuildStepEnv;
const cut = path.join(directory, 'cut');
const empty = path.join(directory, 'empty');
const failed = path.join(directory, 'failed');
const done = path.join(directory, 'done');
await mkdir(cut);
await mkdir(empty);
await mkdir(failed);
await mkdir(done);
const manifest = {
udid: 'emulator-5554',
deviceName: 'Pixel',
runtimeDisplayName: 'Android 16',
status: 'recording',
recording: 'recording.mp4.partial',
firstFrameWallClock: { iso8601: '2026-07-10T10:00:00.000Z' },
width: 128,
height: 96,
};
execFileSync('ffmpeg', [
'-v',
'error',
'-f',
'lavfi',
'-i',
'color=c=red:s=128x96:r=1',
'-t',
'3',
'-c:v',
'libx264',
'-g',
'1',
'-pix_fmt',
'yuv420p',
'-movflags',
'frag_keyframe+empty_moov',
'-f',
'mp4',
path.join(cut, 'recording.mp4.partial'),
]);
await writeFile(path.join(cut, 'session.json'), JSON.stringify(manifest));
await writeFile(path.join(empty, 'recording.mp4.partial'), new Uint8Array(28));
await writeFile(path.join(empty, 'session.json'), JSON.stringify(manifest));
await copyFile(
path.join(cut, 'recording.mp4.partial'),
path.join(failed, 'recording.mp4.partial')
);
await writeFile(
path.join(failed, 'session.json'),
JSON.stringify({
...manifest,
status: 'failed',
error: 'Recording finalization exceeded 30000 ms.',
})
);
await copyFile(path.join(cut, 'recording.mp4.partial'), path.join(done, 'recording.mp4'));
await writeFile(
path.join(done, 'session.json'),
JSON.stringify({ ...manifest, status: 'complete', recording: 'recording.mp4' })
);
const recordings = await findUnlistedDeviceScreenRecordingsAsync({
root: directory,
env,
logger,
});
expect(recordings).toEqual([
{
udid: 'emulator-5554',
deviceName: 'Pixel',
runtimeDisplayName: 'Android 16',
directory: cut,
},
{
udid: 'emulator-5554',
deviceName: 'Pixel',
runtimeDisplayName: 'Android 16',
directory: done,
},
{
udid: 'emulator-5554',
deviceName: 'Pixel',
runtimeDisplayName: 'Android 16',
directory: failed,
},
]);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('empty/recording.mp4.partial'));
let putBytes = 0;
handle = (request, response) => {
request.on('data', chunk => (putBytes += chunk.length));
request.on('end', () => {
response.statusCode = 200;
response.end();
});
};
await expect(
uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
logger,
deviceRunSessionId: 'drs-id',
recordings,
})
).resolves.toBe(true);
expect(putBytes).toBe(3 * (await stat(path.join(cut, 'recording.mp4.partial'))).size);
const inputs = creationBodies
.map(body => JSON.parse(body).variables.input)
.sort((a, b) => a.filename.localeCompare(b.filename));
expect(inputs.map(input => input.filename)).toEqual(['cut.mp4', 'done.mp4', 'failed.mp4']);
expect(inputs.map(input => input.name.includes(', partial)'))).toEqual([true, false, true]);
expect(inputs[1].metadata).not.toHaveProperty('partial');
expect(inputs[0].metadata).toMatchObject({
partial: true,
partialReason: 'The Device Hub stopped before it could finalize the recording.',
width: 128,
height: 96,
});
expect(inputs[2].metadata).toMatchObject({
partial: true,
partialReason: 'Recording finalization exceeded 30000 ms.',
});
});
@@ -0,0 +1,446 @@
import type { bunyan } from '@expo/logger';
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import * as ngrok from '@ngrok/ngrok';
import { access, mkdir, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
import {
findUnlistedDeviceScreenRecordingsAsync,
uploadDeviceRunSessionScreenRecordingsAsync,
} from '../deviceRunSessionScreenRecordings';
import { startDeviceSessionHostAsync } from '../deviceSessionHost';
import { spawnDetached } from '../remoteDeviceRunSession';
jest.mock('@ngrok/ngrok');
jest.mock('../../../sentry');
jest.mock('../serveSimMetricsRecorder', () => ({
readServeSimServersAsync: jest
.fn()
.mockResolvedValue([{ udid: 'emulator-5554', token: 'preview-token' }]),
}));
jest.mock('../../../utils/turtleFetch');
jest.mock('../remoteDeviceRunSession', () => ({
...jest.requireActual('../remoteDeviceRunSession'),
ensureFfmpegInstalledOnceAsync: jest.fn(),
fetchWebPreviewTurnArgsAsync: jest.fn().mockResolvedValue([]),
spawnDetached: jest.fn(),
}));
jest.mock('../deviceRunSessionScreenRecordings', () => ({
...jest.requireActual('../deviceRunSessionScreenRecordings'),
findUnlistedDeviceScreenRecordingsAsync: jest.fn(),
uploadDeviceRunSessionScreenRecordingsAsync: jest.fn(),
}));
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(r => {
resolve = r;
});
return { promise, resolve };
}
const env = {
DEVICE_RUN_SESSION_ID: 'drs-id',
NGROK_AUTHTOKEN: 'token',
} as BuildStepEnv;
const ctx = {} as CustomBuildContext;
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const stopServer = jest.fn();
const closeTunnel = jest.fn();
const directories: string[] = [];
const baseDomain = 'preview.example.test';
async function startHostAsync() {
return await startDeviceSessionHostAsync(ctx, {
runtimePlatform: BuildRuntimePlatform.LINUX,
env,
logger,
timeoutMs: 10_000,
});
}
beforeEach(() => {
jest.clearAllMocks();
stopServer.mockResolvedValue(undefined);
closeTunnel.mockResolvedValue(undefined);
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockReset().mockResolvedValue(false);
jest.mocked(findUnlistedDeviceScreenRecordingsAsync).mockReset().mockResolvedValue([]);
jest.mocked(spawnDetached).mockImplementation(options => {
const flag = options.args.indexOf('--android-recording-directory');
if (flag >= 0) {
directories.push(options.args[flag + 1]);
}
return {
pid: undefined,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: stopServer,
};
});
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://public.example.test',
close: closeTunnel,
} as never);
jest.mocked(turtleFetch).mockImplementation(
async () =>
({
ok: true,
json: async () => ({ status: 'ready', device: 'emulator-5554' }),
}) as Awaited<ReturnType<typeof turtleFetch>>
);
});
afterEach(async () => {
jest.useRealTimers();
await Promise.all(
directories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))
);
});
it('records by default with no preview and finalizes before process stop, uploading exactly once', async () => {
const host = await startHostAsync();
expect(ngrok.forward).not.toHaveBeenCalled();
const directory = directories[0];
const recordings = [
{
udid: 'emulator-5554',
deviceName: 'Pixel',
runtimeDisplayName: 'Android 16',
directory: path.join(directory, 'session'),
},
];
await writeFile(path.join(directory, 'recordings.json'), JSON.stringify(recordings));
await mkdir(recordings[0].directory);
const started = deferred<void>();
const response = deferred<Awaited<ReturnType<typeof turtleFetch>>>();
jest.mocked(turtleFetch).mockImplementationOnce(async () => {
started.resolve();
return await response.promise;
});
const finishing = host.finishAsync();
expect(host.finishAsync()).toBe(finishing);
await started.promise;
expect(stopServer).not.toHaveBeenCalled();
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
const token =
jest.mocked(spawnDetached).mock.calls[0][0].env.EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN;
expect(token).toMatch(/^[a-f0-9]{64}$/);
expect(turtleFetch).toHaveBeenLastCalledWith(
expect.stringMatching(/\/_eas\/android-recording\/stop$/),
'POST',
{
headers: { Authorization: `Bearer ${token}` },
retries: 0,
shouldThrowOnNotOk: false,
signal: expect.any(AbortSignal),
}
);
response.resolve({ ok: true } as Awaited<ReturnType<typeof turtleFetch>>);
await finishing;
await host.finishAsync();
expect(stopServer).toHaveBeenCalledTimes(1);
expect(uploadDeviceRunSessionScreenRecordingsAsync).toHaveBeenCalledTimes(1);
expect(uploadDeviceRunSessionScreenRecordingsAsync).toHaveBeenCalledWith(ctx, {
logger,
deviceRunSessionId: 'drs-id',
recordings,
});
expect(stopServer.mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mock.invocationCallOrder[0]
);
await expect(host.openPreviewAsync({ baseDomain })).rejects.toThrow(
'after session host finalization'
);
});
it('coalesces preview opens and allows close/reopen without ending capture', async () => {
const host = await startHostAsync();
const opening = host.openPreviewAsync({ baseDomain });
expect(host.openPreviewAsync({ baseDomain })).toBe(opening);
const first = await opening;
const closing = first.closeAsync();
expect(first.closeAsync()).toBe(closing);
await closing;
const secondClose = jest.fn().mockResolvedValue(undefined);
jest.mocked(ngrok.forward).mockResolvedValueOnce({
url: () => 'https://replacement.example.test',
close: secondClose,
} as never);
const second = await host.openPreviewAsync({ baseDomain });
await first.closeAsync();
expect(await host.openPreviewAsync({ baseDomain })).toBe(second);
expect(secondClose).not.toHaveBeenCalled();
expect(spawnDetached).toHaveBeenCalledTimes(1);
expect(stopServer).not.toHaveBeenCalled();
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
expect(jest.mocked(turtleFetch).mock.calls.some(([, method]) => method === 'POST')).toBe(false);
await host.finishAsync();
expect(closeTunnel).toHaveBeenCalledTimes(1);
expect(secondClose).toHaveBeenCalledTimes(1);
expect(stopServer).toHaveBeenCalledTimes(1);
});
it('retries a failed tunnel without restarting the host', async () => {
const host = await startHostAsync();
jest.mocked(ngrok.forward).mockRejectedValueOnce(new Error('tunnel unavailable'));
await expect(host.openPreviewAsync({ baseDomain })).rejects.toThrow('tunnel unavailable');
expect(stopServer).not.toHaveBeenCalled();
await host.openPreviewAsync({ baseDomain });
expect(spawnDetached).toHaveBeenCalledTimes(1);
await host.finishAsync();
expect(closeTunnel).toHaveBeenCalledTimes(1);
});
it('drains an in-flight tunnel when finishing and rejects new opens', async () => {
const host = await startHostAsync();
const tunnel = deferred<Awaited<ReturnType<typeof ngrok.forward>>>();
jest.mocked(ngrok.forward).mockReturnValueOnce(tunnel.promise);
const opening = host.openPreviewAsync({ baseDomain });
const finishing = host.finishAsync();
await expect(host.openPreviewAsync({ baseDomain })).rejects.toThrow(
'after session host finalization'
);
tunnel.resolve({ url: () => 'https://late.example.test', close: closeTunnel } as never);
await expect(opening).rejects.toThrow('finalized while the preview was opening');
await finishing;
expect(closeTunnel).toHaveBeenCalledTimes(1);
expect(stopServer).toHaveBeenCalledTimes(1);
});
it('keeps cleanup and upload best-effort when tunnel close and finalization fail', async () => {
jest.mocked(spawnDetached).mockImplementationOnce(options => {
directories.push(options.args[options.args.indexOf('--android-recording-directory') + 1]);
return {
pid: undefined,
getOutput: () => '[serve-emu] emulator-5554 capture error: scrcpy exited with code 255',
getExitError: () => undefined,
stopAsync: stopServer,
};
});
const host = await startHostAsync();
await writeFile(path.join(directories[0], 'recordings.json'), '[]');
await host.openPreviewAsync({ baseDomain });
closeTunnel.mockRejectedValueOnce(new Error('tunnel close failure'));
jest.mocked(turtleFetch).mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => 'Error: scrcpy exited with code 255',
} as Awaited<ReturnType<typeof turtleFetch>>);
await host.finishAsync();
expect(stopServer).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{
err: expect.objectContaining({
message: expect.stringContaining('HTTP 500: Error: scrcpy exited with code 255'),
}),
},
'Could not finalize Android recording before shutdown.'
);
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not finalize Android recording before shutdown',
expect.any(Error),
{ level: 'warning' }
);
expect(uploadDeviceRunSessionScreenRecordingsAsync).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ hostOutput: '[serve-emu] emulator-5554 capture error: scrcpy exited with code 255' },
'Session host output around the recording failure.'
);
});
it('logs the reason and skips upload and output dump when the Hub never recorded', async () => {
jest.mocked(spawnDetached).mockImplementationOnce(options => {
directories.push(options.args[options.args.indexOf('--android-recording-directory') + 1]);
return {
pid: undefined,
getOutput: () => '[serve-emu] Android recording skipped',
getExitError: () => undefined,
stopAsync: stopServer,
};
});
const host = await startHostAsync();
jest.mocked(turtleFetch).mockResolvedValueOnce({
ok: false,
status: 409,
text: async () => 'Android recording requires exactly one booted emulator; found 0.',
} as Awaited<ReturnType<typeof turtleFetch>>);
await host.finishAsync();
expect(stopServer).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
'Android recording was not captured: Android recording requires exactly one booted emulator; found 0.'
);
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalledWith(
expect.anything(),
'Session host output around the recording failure.'
);
});
it('rolls back failed host startup without replacing the original error', async () => {
stopServer.mockRejectedValueOnce(new Error('stop failure'));
await expect(
startDeviceSessionHostAsync(ctx, {
runtimePlatform: BuildRuntimePlatform.LINUX,
env,
logger,
timeoutMs: 0,
})
).rejects.toThrow('Timed out waiting');
expect(stopServer).toHaveBeenCalledTimes(1);
expect(ngrok.forward).not.toHaveBeenCalled();
expect(jest.mocked(turtleFetch).mock.calls.some(([, method]) => method === 'POST')).toBe(false);
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalledWith(
expect.anything(),
expect.stringMatching(/finalize|upload/)
);
});
async function writeRecordingDescriptorAsync(directory: string) {
const child = path.join(directory, 'session');
await mkdir(child);
await writeFile(
path.join(directory, 'recordings.json'),
JSON.stringify([
{
udid: 'emulator-5554',
deviceName: 'Pixel',
runtimeDisplayName: 'Android',
directory: child,
},
])
);
}
it.each([true, false])(
'removes its recording root only when every upload succeeds (%s)',
async uploaded => {
const host = await startHostAsync();
const directory = directories[0];
await writeRecordingDescriptorAsync(directory);
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockResolvedValueOnce(uploaded);
await host.finishAsync();
if (uploaded) {
await expect(access(directory)).rejects.toMatchObject({ code: 'ENOENT' });
} else {
await expect(access(directory)).resolves.toBeUndefined();
}
}
);
it('finishes despite a stalled tunnel close', async () => {
const host = await startHostAsync();
await host.openPreviewAsync({ baseDomain });
const closing = deferred<void>();
closeTunnel.mockReturnValueOnce(closing.promise);
jest.useFakeTimers();
const finishing = host.finishAsync();
await jest.advanceTimersByTimeAsync(5_000);
await finishing;
expect(stopServer).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('deadline'));
closing.resolve();
});
it('aborts a stalled finalization request before stopping the host', async () => {
const host = await startHostAsync();
const response = deferred<Awaited<ReturnType<typeof turtleFetch>>>();
jest.mocked(turtleFetch).mockReturnValueOnce(response.promise);
jest.useFakeTimers();
const finishing = host.finishAsync();
await jest.advanceTimersByTimeAsync(60_000);
await finishing;
const options = jest.mocked(turtleFetch).mock.calls.at(-1)?.[2];
expect(options?.signal?.aborted).toBe(true);
expect(stopServer).toHaveBeenCalledTimes(1);
response.resolve({ ok: true } as Awaited<ReturnType<typeof turtleFetch>>);
});
it('uploads the partial recording a killed host left behind and then removes the root', async () => {
const host = await startHostAsync();
const directory = directories[0];
const child = path.join(directory, 'session');
await mkdir(child);
await writeFile(path.join(directory, 'recordings.json'), '[]');
const partial = [
{ udid: 'emulator-5554', deviceName: 'Pixel', runtimeDisplayName: 'Android', directory: child },
];
jest.mocked(findUnlistedDeviceScreenRecordingsAsync).mockResolvedValueOnce(partial);
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockResolvedValueOnce(true);
await host.finishAsync();
expect(findUnlistedDeviceScreenRecordingsAsync).toHaveBeenCalledWith({
root: directory,
env,
logger,
});
expect(uploadDeviceRunSessionScreenRecordingsAsync).toHaveBeenCalledWith(ctx, {
logger,
deviceRunSessionId: 'drs-id',
recordings: partial,
});
await expect(access(directory)).rejects.toMatchObject({ code: 'ENOENT' });
});
it('rejects descriptors outside its recording root', async () => {
const host = await startHostAsync();
const directory = directories[0];
await writeFile(
path.join(directory, 'recordings.json'),
JSON.stringify([
{
udid: 'emulator-5554',
deviceName: 'Pixel',
runtimeDisplayName: 'Android',
directory: path.dirname(directory),
},
])
);
await host.finishAsync();
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
await expect(access(directory)).resolves.toBeUndefined();
});
it('retains recordings without uploading if the host cannot be stopped', async () => {
const host = await startHostAsync();
const directory = directories[0];
await writeRecordingDescriptorAsync(directory);
stopServer.mockRejectedValueOnce(new Error('process stop failed'));
await host.finishAsync();
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
await expect(access(directory)).resolves.toBeUndefined();
});
it('closes a listener that arrives after finish has already timed out waiting for it', async () => {
const host = await startHostAsync();
const tunnel = deferred<Awaited<ReturnType<typeof ngrok.forward>>>();
jest.mocked(ngrok.forward).mockReturnValueOnce(tunnel.promise);
const opening = host.openPreviewAsync({ baseDomain });
const rejected = expect(opening).rejects.toThrow('finalized while the preview was opening');
jest.useFakeTimers();
const finishing = host.finishAsync();
await jest.advanceTimersByTimeAsync(5_000);
await finishing;
expect(stopServer).toHaveBeenCalledTimes(1);
tunnel.resolve({ url: () => 'https://late.example.test', close: closeTunnel } as never);
await rejected;
expect(closeTunnel).toHaveBeenCalledTimes(1);
});
it('leaves iOS recording to its existing build steps', async () => {
const host = await startDeviceSessionHostAsync(ctx, {
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger,
timeoutMs: 10_000,
});
const options = jest.mocked(spawnDetached).mock.calls[0][0];
expect(options.args).not.toContain('--android-recording-directory');
expect(options.env.EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN).toBeUndefined();
await host.finishAsync();
expect(jest.mocked(turtleFetch).mock.calls.some(([, method]) => method === 'POST')).toBe(false);
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
expect(stopServer).toHaveBeenCalledTimes(1);
});
@@ -454,6 +454,7 @@ describe(startChiselServerAsync, () => {
jest.mocked(spawnDetached).mockReturnValue({
pid: 12345,
getOutput: () => output,
getExitError: () => undefined,
stopAsync,
});
});
@@ -1,32 +1,46 @@
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildRuntimePlatform, BuildStepEnv } from '@expo/steps';
import { BuildRuntimePlatform, BuildStepEnv, BuildStepInputValueTypeName } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import * as ngrok from '@ngrok/ngrok';
import { EventEmitter } from 'node:events';
import fs from 'node:fs/promises';
import path from 'node:path';
import {
clearTimeout as clearTimeoutCallback,
setTimeout as setTimeoutCallback,
} from 'node:timers';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
import { sleepAsync } from '../../../utils/retry';
import { uploadDeviceRunSessionScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
import {
createExpoDeviceHubArgs,
createServeSimArgs,
createServeSimLaunchInputProviders,
describeServeSimLaunch,
ensureFfmpegInstalledOnceAsync,
fetchWebPreviewTurnArgsAsync,
metricsCorsOriginToServeSimArgs,
startDeviceWebPreviewWithTunnelAsync,
startExpoDeviceHubWithTunnelAsync,
parseServeSimLaunchInputs,
spawnDetached,
startNgrokTunnelAsync,
turnIceServersToWebPreviewArgs,
waitForDeviceRunSessionStoppedAsync,
waitForWebPreviewReadyAsync,
} from '../remoteDeviceRunSession';
import {
createExpoDeviceHubArgs,
createServeSimArgs,
simulatorPreviewPageUrl,
startDeviceSessionHostAsync,
waitForWebPreviewReadyAsync,
websiteOrigin,
websiteOriginServeSimArgs,
} from '../deviceSessionHost';
jest.mock('@ngrok/ngrok');
jest.mock('node:timers');
jest.mock('node:timers/promises');
@@ -34,6 +48,10 @@ jest.mock('../../../utils/turtleFetch');
jest.mock('../../../utils/retry', () => ({ sleepAsync: jest.fn() }));
jest.mock('../../../sentry');
jest.mock('@expo/turtle-spawn');
jest.mock('../deviceRunSessionScreenRecordings', () => ({
...jest.requireActual('../deviceRunSessionScreenRecordings'),
uploadDeviceRunSessionScreenRecordingsAsync: jest.fn(),
}));
// Spyable so a test can stand in for the serve-sim state directory, which a local serve-sim owns.
jest.mock('../serveSimMetricsRecorder', () => {
const actual = jest.requireActual('../serveSimMetricsRecorder');
@@ -49,6 +67,69 @@ function createLoggerMock(): bunyan {
} as unknown as bunyan;
}
describe(spawnDetached, () => {
function mockProcess(promise: Promise<unknown>): void {
jest
.mocked(spawn)
.mockReturnValue(
Object.assign(promise, { child: { pid: 1234, unref: jest.fn(), once: jest.fn() } }) as never
);
}
it('observes exit without waiting for inherited output pipes to close', () => {
const child = Object.assign(new EventEmitter(), { pid: 1234, unref: jest.fn() });
jest.mocked(spawn).mockReturnValue(Object.assign(new Promise(() => {}), { child }) as never);
const handle = spawnDetached({ command: 'server', args: [], env: {} });
child.emit('exit', 1, null);
expect(handle.getExitError()?.message).toContain('code 1');
expect(child.listenerCount('exit')).toBe(0);
});
it('does not report an exit while the process is running', () => {
mockProcess(new Promise(() => {}));
const handle = spawnDetached({ command: 'server', args: [], env: {} });
expect(handle.getExitError()).toBeUndefined();
});
it('reports a clean exit so startup does not keep waiting', async () => {
const completion = Promise.resolve();
mockProcess(completion);
const handle = spawnDetached({ command: 'server', args: [], env: {} });
await completion;
expect(handle.getExitError()?.message).toContain('code 0');
});
it.each([0, 1])('observes a real subprocess exiting with code %s', async exitCode => {
const actualSpawn =
jest.requireActual<typeof import('@expo/turtle-spawn')>('@expo/turtle-spawn').default;
let completion: ReturnType<typeof actualSpawn>;
jest.mocked(spawn).mockImplementationOnce((...args) => {
completion = actualSpawn(...args);
return completion;
});
const handle = spawnDetached({
command: process.execPath,
args: ['-e', `console.error('startup-marker'); process.exit(${exitCode});`],
env: {},
});
await completion!.catch(() => {});
expect(handle.getExitError()).toBeInstanceOf(Error);
expect(handle.getOutput()).toContain('startup-marker');
});
it.each(['spawn ENOENT', 'server exited with code 1', 'server terminated by SIGTERM'])(
'preserves the process failure: %s',
async message => {
const error = new Error(message);
const completion = Promise.reject(error);
mockProcess(completion);
const handle = spawnDetached({ command: 'server', args: [], env: {} });
await completion.catch(() => {});
expect(handle.getExitError()).toBe(error);
}
);
});
function createCtxMock(): CustomBuildContext {
return {
env: {
@@ -122,6 +203,161 @@ function createEnvMock(): BuildStepEnv {
return { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv;
}
describe(createServeSimLaunchInputProviders, () => {
it('declares the launch inputs as optional', () => {
const globalCtx = createGlobalContextMock();
const inputs = createServeSimLaunchInputProviders().map(provider =>
provider(globalCtx, 'Test step')
);
expect(
inputs.map(({ id, required, allowedValueTypeName }) => ({
id,
required,
allowedValueTypeName,
}))
).toEqual([
{
id: 'launch_app_identifier',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
},
{
id: 'launch_args',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
},
{
id: 'open_url',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
},
]);
});
});
describe(parseServeSimLaunchInputs, () => {
const darwin = { runtimePlatform: BuildRuntimePlatform.DARWIN };
it('reads the launch identifier, arguments and URL', () => {
expect(
parseServeSimLaunchInputs(
{
launchAppIdentifier: 'host.exp.Exponent',
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
openUrl: 'exp://127.0.0.1:8081',
},
darwin
)
).toEqual({
launchAppIdentifier: 'host.exp.Exponent',
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
openUrl: 'exp://127.0.0.1:8081',
});
});
it('defaults to no launch when the step declares nothing', () => {
expect(parseServeSimLaunchInputs({}, { runtimePlatform: BuildRuntimePlatform.LINUX })).toEqual({
launchAppIdentifier: undefined,
launchArgs: [],
openUrl: undefined,
});
});
it('rejects launch arguments that are not a list', () => {
expect(() =>
parseServeSimLaunchInputs(
{
launchAppIdentifier: 'host.exp.Exponent',
launchArgs: 'oops',
},
darwin
)
).toThrow('must be an array of strings');
});
it('rejects launch arguments that are not all strings', () => {
expect(() =>
parseServeSimLaunchInputs(
{
launchAppIdentifier: 'host.exp.Exponent',
launchArgs: ['-flag', 1],
},
darwin
)
).toThrow('must be an array of strings');
});
it('rejects launch arguments with no application to launch', () => {
expect(() => parseServeSimLaunchInputs({ launchArgs: ['-flag'] }, darwin)).toThrow(
'Pass "launch_app_identifier"'
);
});
it('rejects a URL with no application to open it in', () => {
expect(() => parseServeSimLaunchInputs({ openUrl: 'exp://127.0.0.1:8081' }, darwin)).toThrow(
'Pass "launch_app_identifier"'
);
});
it('rejects an identifier that resolved to an empty string', () => {
expect(() => parseServeSimLaunchInputs({ launchAppIdentifier: '' }, darwin)).toThrow(
'must be a non-empty string'
);
});
it('rejects a URL that is not a URL', () => {
expect(() =>
parseServeSimLaunchInputs(
{ launchAppIdentifier: 'host.exp.Exponent', openUrl: 'not a url' },
darwin
)
).toThrow('must be a valid URL');
});
it('raises a user error, not a platform error', () => {
expect(() => parseServeSimLaunchInputs({ launchAppIdentifier: '' }, darwin)).toThrow(UserError);
try {
parseServeSimLaunchInputs({ launchAppIdentifier: '' }, darwin);
} catch (error) {
expect((error as UserError).errorCode).toBe('EAS_LAUNCH_APPLICATION_INVALID_INPUT');
}
});
it('rejects a launch on a session that does not run an iOS simulator', () => {
expect(() =>
parseServeSimLaunchInputs(
{ launchAppIdentifier: 'host.exp.Exponent' },
{ runtimePlatform: BuildRuntimePlatform.LINUX }
)
).toThrow('runs on linux');
});
});
describe(describeServeSimLaunch, () => {
it('says nothing when there is no application to launch', () => {
expect(describeServeSimLaunch({ launchArgs: [] })).toBeNull();
});
it('names only the application when there are no arguments or URL', () => {
expect(describeServeSimLaunch({ launchAppIdentifier: 'host.exp.Exponent' })).toBe(
'serve-sim will launch host.exp.Exponent.'
);
});
it('names the application, its arguments and the URL', () => {
expect(
describeServeSimLaunch({
launchAppIdentifier: 'host.exp.Exponent',
launchArgs: ['-flag', '1'],
openUrl: 'exp://127.0.0.1:8081',
})
).toBe(
'serve-sim will launch host.exp.Exponent with arguments ["-flag","1"], then open exp://127.0.0.1:8081.'
);
});
});
describe(createServeSimArgs, () => {
it('uses the latest Expo package and applies the EAS streaming policy', () => {
expect(
@@ -130,7 +366,6 @@ describe(createServeSimArgs, () => {
turnArgs: ['--turn-url', 'turns:turn.example.test:443'],
})
).toEqual([
'--yes',
'@expo/serve-sim@latest',
'--port',
'4321',
@@ -140,13 +375,13 @@ describe(createServeSimArgs, () => {
'--transport',
'webrtc',
'--webrtc-codec',
'vp8',
'h264',
'--max-dimension',
'960',
'1600',
'--mjpeg-quality',
'0.55',
'--video-bitrate',
'6000000',
'10000000',
'--video-fps',
'60',
'--turn-url',
@@ -154,36 +389,79 @@ describe(createServeSimArgs, () => {
]);
});
it('appends metrics CORS args after the TURN args when provided', () => {
it('appends the website args after the TURN args when provided', () => {
const args = createServeSimArgs({
port: 4321,
turnArgs: ['--turn-url', 'turns:turn.example.test:443'],
metricsCorsArgs: ['--metrics-cors-origin', 'https://expo.dev'],
websiteArgs: ['--cors-origin', 'https://expo.dev'],
});
expect(args.slice(-4)).toEqual([
'--turn-url',
'turns:turn.example.test:443',
'--metrics-cors-origin',
'--cors-origin',
'https://expo.dev',
]);
});
it('pins the requested package version', () => {
expect(createServeSimArgs({ port: 4321, packageVersion: '0.1.38' }).slice(0, 2)).toEqual([
'--yes',
'@expo/[email protected]',
]);
expect(createServeSimArgs({ port: 4321, packageVersion: '0.1.38' })[0]).toBe(
'@expo/[email protected]'
);
});
it('pins a dist-tag', () => {
expect(createServeSimArgs({ port: 4321, packageVersion: 'next' }).slice(0, 2)).toEqual([
'--yes',
'@expo/serve-sim@next',
expect(createServeSimArgs({ port: 4321, packageVersion: 'next' })[0]).toBe(
'@expo/serve-sim@next'
);
});
it('appends --share-url after the website args', () => {
const args = createServeSimArgs({
port: 4321,
websiteArgs: ['--cors-origin', 'https://expo.dev', '--frame-ancestor', 'https://expo.dev'],
shareUrl: 'https://expo.dev/simulator-preview/abc',
});
expect(args.slice(-4)).toEqual([
'--frame-ancestor',
'https://expo.dev',
'--share-url',
'https://expo.dev/simulator-preview/abc',
]);
});
it('appends the launch flags after the streaming policy', () => {
const args = createServeSimArgs({
port: 4321,
launchAppIdentifier: 'host.exp.Exponent',
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
openUrl: 'exp://127.0.0.1:8081',
});
expect(args.slice(-8)).toEqual([
'--launch-app-identifier',
'host.exp.Exponent',
'--launch-arg',
'-EXDevMenuIsOnboardingFinished',
'--launch-arg',
'1',
'--open-url',
'exp://127.0.0.1:8081',
]);
});
it('omits the launch flags when there is no application to launch', () => {
const args = createServeSimArgs({ port: 4321 });
expect(args.some(argument => argument.startsWith('--launch'))).toBe(false);
expect(args).not.toContain('--open-url');
});
});
describe(createExpoDeviceHubArgs, () => {
it('opts in to recording only when a directory is provided', () => {
expect(createExpoDeviceHubArgs({ port: 4321 })).not.toContain('--android-recording-directory');
expect(
createExpoDeviceHubArgs({ port: 4321, recordingDirectory: '/tmp/recordings' }).slice(-2)
).toEqual(['--android-recording-directory', '/tmp/recordings']);
});
it('uses the latest Expo package and applies the EAS Android streaming policy', () => {
expect(
createExpoDeviceHubArgs({
@@ -191,7 +469,6 @@ describe(createExpoDeviceHubArgs, () => {
turnArgs: ['--turn-url', 'turns:turn.example.test:443'],
})
).toEqual([
'--yes',
'expo-device-hub@latest',
'--port',
'4321',
@@ -219,37 +496,51 @@ describe(createExpoDeviceHubArgs, () => {
});
it('pins the requested package version', () => {
expect(createExpoDeviceHubArgs({ port: 4321, packageVersion: '0.7.0' }).slice(0, 2)).toEqual([
'--yes',
'[email protected]',
]);
expect(createExpoDeviceHubArgs({ port: 4321, packageVersion: '0.7.0' })[0]).toBe(
'[email protected]'
);
});
});
describe(metricsCorsOriginToServeSimArgs, () => {
it('returns no args when the origin is unset or empty', () => {
expect(metricsCorsOriginToServeSimArgs({} as BuildStepEnv)).toEqual([]);
expect(
metricsCorsOriginToServeSimArgs({ EAS_SIMULATOR_METRICS_CORS_ORIGIN: '' } as BuildStepEnv)
).toEqual([]);
describe(websiteOriginServeSimArgs, () => {
it('names only the production website by default', () => {
expect(websiteOriginServeSimArgs({} as BuildStepEnv)).toEqual([
'--cors-origin',
'https://expo.dev',
'--frame-ancestor',
'https://expo.dev',
]);
});
it('builds one flag per comma-separated origin', () => {
expect(
metricsCorsOriginToServeSimArgs({
EAS_SIMULATOR_METRICS_CORS_ORIGIN: 'https://expo.dev',
} as BuildStepEnv)
).toEqual(['--metrics-cors-origin', 'https://expo.dev']);
expect(
metricsCorsOriginToServeSimArgs({
EAS_SIMULATOR_METRICS_CORS_ORIGIN: 'https://expo.dev, https://staging.expo.dev',
} as BuildStepEnv)
).toEqual([
'--metrics-cors-origin',
'https://expo.dev',
'--metrics-cors-origin',
it('names staging, its deploy previews and each website dev port on staging', () => {
const args = websiteOriginServeSimArgs({ EXPO_STAGING: '1' } as BuildStepEnv);
expect(args.slice(0, 4)).toEqual([
'--cors-origin',
'https://staging.expo.dev',
'--frame-ancestor',
'https://staging.expo.dev',
]);
expect(args).toContain('https://*.expo.dev');
expect(args).toContain('https://expo.test:13001');
expect(args).toContain('https://expo.test:13215');
expect(args).not.toContain('https://expo.test:13216');
expect(args).not.toContain('https://expo.dev');
});
it('names only https origins', () => {
for (const env of [{}, { EXPO_STAGING: '1' }, { EXPO_LOCAL: '1' }]) {
const args = websiteOriginServeSimArgs(env as BuildStepEnv);
expect(args.filter(value => value.startsWith('http://'))).toEqual([]);
}
});
it('names the website dev ports on local, without the deploy-preview wildcard', () => {
for (const env of [{ EXPO_LOCAL: '1' }, { EXPO_LOCAL: '1', EXPO_STAGING: '1' }]) {
const args = websiteOriginServeSimArgs(env as BuildStepEnv);
expect(args).toContain('https://expo.test:13001');
expect(args).not.toContain('https://*.expo.dev');
expect(args.filter(value => value === 'https://expo.test')).toHaveLength(2);
}
});
});
@@ -309,13 +600,18 @@ describe(startNgrokTunnelAsync, () => {
})
);
expect(tunnel.url).toBe('https://web-preview.example.test');
expect(ngrok.forward).toHaveBeenCalledWith(
expect.objectContaining({
domain: `web-preview-${tunnel.subdomainId}.eas-simulator.ngrok.dev`,
})
);
await tunnel.stopAsync();
await tunnel.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
});
});
describe(startDeviceWebPreviewWithTunnelAsync, () => {
describe(startDeviceSessionHostAsync, () => {
const baseDomain = 'eas-simulator.ngrok.dev';
const turnArgs = [
'--turn-url',
@@ -325,14 +621,14 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
'--turn-credential',
'turn-credential',
];
const metricsCorsArgs = ['--metrics-cors-origin', 'https://metrics.expo.test'];
const websiteArgs = ['--cors-origin', 'https://expo.dev', '--frame-ancestor', 'https://expo.dev'];
const env = {
DEVICE_RUN_SESSION_ID: 'drs-id',
EAS_SIMULATOR_METRICS_CORS_ORIGIN: 'https://metrics.expo.test',
NGROK_AUTHTOKEN: 'ngrok-token',
} as unknown as BuildStepEnv;
beforeEach(() => {
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockReset();
jest.mocked(spawn).mockReset();
jest.mocked(ngrok.forward).mockReset();
jest.mocked(turtleFetch).mockReset();
@@ -344,6 +640,7 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
child: {
pid: undefined,
unref: jest.fn(),
once: jest.fn(),
},
});
jest.mocked(spawn).mockReturnValue(spawnPromise as never);
@@ -385,14 +682,14 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>)
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>);
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
const host = await startDeviceSessionHostAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.LINUX,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
packageVersion,
});
const preview = await host.openPreviewAsync({ baseDomain });
const spawnCalls = jest.mocked(spawn).mock.calls;
expect(spawnCalls[0]).toEqual(['ffmpeg', ['-version'], { env }]);
@@ -419,11 +716,17 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
const port = Number(args[args.indexOf('--port') + 1]);
expect(port).toBeGreaterThan(0);
expect(command).toBe('npx');
expect(args).toEqual(createExpoDeviceHubArgs({ port, turnArgs, packageVersion }));
expect(args).toContain('--android-recording-directory');
const recordingDirectory = args[args.indexOf('--android-recording-directory') + 1];
expect(args).toEqual([
'--yes',
...createExpoDeviceHubArgs({ port, turnArgs, packageVersion, recordingDirectory }),
]);
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(preview.previewUrl).toBe('https://android-preview.example.test');
expect(preview.apiUrl).toBe('https://android-preview.example.test');
await preview.stopAsync();
await host.finishAsync();
await fs.rm(recordingDirectory, { recursive: true, force: true });
expect(close).toHaveBeenCalledTimes(1);
});
@@ -433,16 +736,50 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
close: jest.fn().mockResolvedValue(undefined),
} as never);
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
const host = await startDeviceSessionHostAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
});
const preview = await host.openPreviewAsync({ baseDomain });
expect(preview.previewToken).toBe('tok-1');
expect(preview.previewUrl).toBe('https://preview.example.test');
expect(preview.apiUrl).toBe('https://preview.example.test');
});
it.each([
[{}, 'https://expo.dev'],
[{ EXPO_STAGING: '1' }, 'https://staging.expo.dev'],
[{ EXPO_LOCAL: '1' }, 'https://expo.test'],
])('points the preview page at the website for the stage the worker runs in', (stage, origin) => {
expect(websiteOrigin({ ...env, ...stage })).toBe(origin);
expect(simulatorPreviewPageUrl({ ...env, ...stage }, 'abc')).toBe(
`${origin}/simulator-preview/abc`
);
});
it('points the preview URL at the website page for the tunnel', async () => {
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://preview.example.test',
close: jest.fn().mockResolvedValue(undefined),
} as never);
const host = await startDeviceSessionHostAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: { ...env, EXPO_STAGING: '1' },
logger: createLoggerMock(),
timeoutMs: 10_000,
});
const preview = await host.openPreviewAsync({ baseDomain });
expect(preview.previewPageUrl).toMatch(
/^https:\/\/staging\.expo\.dev\/simulator-preview\/[a-f0-9]{32}$/
);
const previewId = preview.previewPageUrl.split('/').at(-1);
expect(ngrok.forward).toHaveBeenCalledWith(
expect.objectContaining({ domain: `web-preview-${previewId}.${baseDomain}` })
);
});
// serve-sim is always launched with --require-token, so a missing token means it is running
@@ -453,9 +790,8 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
.mockResolvedValue([{ udid: 'device-id', url: 'http://127.0.0.1:1' }]);
await expect(
startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
startDeviceSessionHostAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
@@ -471,13 +807,13 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
close: jest.fn().mockResolvedValue(undefined),
} as never);
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
const host = await startDeviceSessionHostAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.LINUX,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
});
const preview = await host.openPreviewAsync({ baseDomain });
expect(preview.previewToken).toBeUndefined();
const [, args] = jest.mocked(spawn).mock.calls[0];
@@ -492,52 +828,145 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
close,
} as never);
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
const host = await startDeviceSessionHostAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
packageVersion,
});
const preview = await host.openPreviewAsync({ baseDomain });
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
expect(port).toBeGreaterThan(0);
expect(command).toBe('npx');
expect(args).toEqual(createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }));
expect(args).toEqual([
'--yes',
...createServeSimArgs({
port,
turnArgs,
websiteArgs,
shareUrl: preview.previewPageUrl,
packageVersion,
}),
]);
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(preview.previewUrl).toBe('https://ios-preview.example.test');
expect(preview.apiUrl).toBe('https://ios-preview.example.test');
await preview.stopAsync();
await host.finishAsync();
expect(close).toHaveBeenCalledTimes(1);
});
it('does not install ffmpeg before starting expo-device-hub outside Linux', async () => {
it('launches serve-sim with bun x when EAS_OVERRIDE_PACKAGE_MANAGER is bun', async () => {
const close = jest.fn().mockResolvedValue(undefined);
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://android-preview.example.test',
url: () => 'https://ios-preview.example.test',
close,
} as never);
const preview = await startExpoDeviceHubWithTunnelAsync(createCtxMock(), {
const bunEnv = { ...env, EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' };
const host = await startDeviceSessionHostAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env,
env: bunEnv,
logger: createLoggerMock(),
timeoutMs: 10_000,
packageVersion: '4.5.6',
});
const preview = await host.openPreviewAsync({ baseDomain });
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
expect(command).toBe('bun');
expect(args).toEqual([
'x',
...createServeSimArgs({
port,
turnArgs,
websiteArgs,
shareUrl: preview.previewPageUrl,
packageVersion: '4.5.6',
}),
]);
await host.finishAsync();
expect(close).toHaveBeenCalledTimes(1);
});
it('launches serve-sim with bun x when EAS_FALLBACK_PACKAGE_MANAGER is bun', async () => {
const close = jest.fn().mockResolvedValue(undefined);
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://ios-preview.example.test',
close,
} as never);
const host = await startDeviceSessionHostAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: { ...env, EAS_FALLBACK_PACKAGE_MANAGER: 'bun' },
logger: createLoggerMock(),
timeoutMs: 10_000,
});
const preview = await host.openPreviewAsync({ baseDomain });
expect(jest.mocked(spawn).mock.calls[0][0]).toBe('npx');
expect(jest.mocked(spawn)).not.toHaveBeenCalledWith(
'ffmpeg',
expect.anything(),
expect.anything()
);
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
expect(command).toBe('bun');
expect(args).toEqual([
'x',
...createServeSimArgs({
port,
turnArgs,
websiteArgs,
shareUrl: preview.previewPageUrl,
}),
]);
await preview.stopAsync();
await host.finishAsync();
expect(close).toHaveBeenCalledTimes(1);
});
it('hands the launch options to serve-sim on Darwin', async () => {
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://ios-preview.example.test',
close: jest.fn().mockResolvedValue(undefined),
} as never);
const host = await startDeviceSessionHostAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
launchAppIdentifier: 'host.exp.Exponent',
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
openUrl: 'exp://127.0.0.1:8081',
});
await host.finishAsync();
const [, args] = jest.mocked(spawn).mock.calls[0];
expect(args.slice(-8)).toEqual([
'--launch-app-identifier',
'host.exp.Exponent',
'--launch-arg',
'-EXDevMenuIsOnboardingFinished',
'--launch-arg',
'1',
'--open-url',
'exp://127.0.0.1:8081',
]);
});
it('refuses to launch an application on Linux, where expo-device-hub cannot', async () => {
await expect(
startDeviceSessionHostAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.LINUX,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
launchAppIdentifier: 'host.exp.Exponent',
})
).rejects.toThrow('Cannot launch host.exp.Exponent');
expect(spawn).not.toHaveBeenCalled();
});
});
describe(turnIceServersToWebPreviewArgs, () => {
@@ -0,0 +1,25 @@
import type { BuildStepEnv } from '@expo/steps';
import { isProcessRunning, spawnDetached } from '../remoteDeviceRunSession';
jest.mock('@ngrok/ngrok');
it('resolves stopAsync only after a process that ignores SIGTERM is gone', async () => {
const handle = spawnDetached({
command: 'sh',
args: ['-c', 'trap "" TERM; echo ready; sleep 30 & wait'],
env: process.env as BuildStepEnv,
stopGracePeriodMs: 300,
});
const pid = handle.pid;
if (pid === undefined) {
throw new Error('spawn failed');
}
while (!handle.getOutput().includes('ready')) {
await new Promise(resolve => setTimeout(resolve, 20));
}
const startedAt = Date.now();
await handle.stopAsync();
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(300);
expect(isProcessRunning(pid)).toBe(false);
});
@@ -1,4 +1,4 @@
import { SystemError } from '@expo/eas-build-job';
import { SandboxDaemonResponseZ, SystemError } from '@expo/eas-build-job';
import Log from '@expo/logger';
import http from 'node:http';
import net, { AddressInfo } from 'node:net';
@@ -7,6 +7,10 @@ import WebSocket, { WebSocketServer } from 'ws';
import { startSandboxDaemonAsync } from '../sandboxDaemon';
jest.unmock('@expo/logger');
jest.unmock('fs');
jest.unmock('fs/promises');
jest.unmock('node:fs');
jest.unmock('node:fs/promises');
describe(startSandboxDaemonAsync.name, () => {
const logger = Log.child({ buildStepId: 'sandbox-test' });
@@ -17,6 +21,8 @@ describe(startSandboxDaemonAsync.name, () => {
serverUrl,
reconnectDelayMs: 10,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
try {
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
@@ -40,6 +46,8 @@ describe(startSandboxDaemonAsync.name, () => {
serverUrl: `ws://127.0.0.1:${(server.address() as AddressInfo).port}`,
reconnectDelayMs: 60_000,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
try {
const socket = await connection;
@@ -72,6 +80,8 @@ describe(startSandboxDaemonAsync.name, () => {
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 10,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
try {
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
@@ -100,6 +110,8 @@ describe(startSandboxDaemonAsync.name, () => {
reconnectDelayMs: 60_000,
logger,
signal: controller.signal,
workingDirectory: process.cwd(),
env: process.env,
});
const connectionError = daemon.ready.catch(error => error);
@@ -126,6 +138,8 @@ describe(startSandboxDaemonAsync.name, () => {
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 60_000,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
await expect(daemon.ready).rejects.toThrow('Sandbox MCP server connection failed');
@@ -157,6 +171,8 @@ describe(startSandboxDaemonAsync.name, () => {
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 10,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
const socket = await connection;
const daemon = await daemonPromise;
@@ -170,6 +186,117 @@ describe(startSandboxDaemonAsync.name, () => {
await new Promise<void>(resolve => httpServer.close(() => resolve()));
});
it('executes command messages', async () => {
const httpServer = http.createServer();
const mcpServer = new WebSocketServer({ noServer: true });
httpServer.on('upgrade', (request, socket, head) => {
mcpServer.handleUpgrade(request, socket, head, client =>
mcpServer.emit('connection', client)
);
});
await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve));
const address = httpServer.address() as AddressInfo;
const connection = new Promise<WebSocket>(resolve => mcpServer.once('connection', resolve));
const daemon = await startSandboxDaemonAsync({
credential: 'secret-token',
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 10,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
const socket = await connection;
await daemon.ready;
const commandResponse = new Promise<string>(resolve =>
socket.once('message', data => resolve(`${data}`))
);
socket.send(
JSON.stringify({
jsonrpc: '2.0',
id: 'command-1',
method: 'execCommand',
params: { cmd: 'printf hello' },
})
);
expect(JSON.parse(await commandResponse)).toMatchObject({
jsonrpc: '2.0',
id: 'command-1',
result: { output: 'hello', exitCode: 0 },
});
await daemon.stopAsync();
mcpServer.close();
await new Promise<void>(resolve => httpServer.close(() => resolve()));
});
it('returns JSON-RPC errors for invalid messages', async () => {
const httpServer = http.createServer();
const mcpServer = new WebSocketServer({ noServer: true });
httpServer.on('upgrade', (request, socket, head) => {
mcpServer.handleUpgrade(request, socket, head, client =>
mcpServer.emit('connection', client)
);
});
await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve));
const address = httpServer.address() as AddressInfo;
const connection = new Promise<WebSocket>(resolve => mcpServer.once('connection', resolve));
const daemon = await startSandboxDaemonAsync({
credential: 'secret-token',
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 10,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
const socket = await connection;
await daemon.ready;
const sendAsync = async (message: string): Promise<unknown> => {
const response = new Promise<string>(resolve =>
socket.once('message', data => resolve(`${data}`))
);
socket.send(message);
return SandboxDaemonResponseZ.parse(JSON.parse(await response));
};
await expect(sendAsync('{')).resolves.toMatchObject({
id: null,
error: { code: -32700, message: 'Parse error' },
});
await expect(sendAsync('{}')).resolves.toMatchObject({
id: null,
error: { code: -32600, message: 'Invalid request' },
});
await expect(
sendAsync(JSON.stringify({ jsonrpc: '2.0', id: '1', method: 'unknown' }))
).resolves.toMatchObject({
id: '1',
error: { code: -32601, message: 'Method not found' },
});
await expect(
sendAsync(JSON.stringify({ jsonrpc: '2.0', id: '2', method: 'execCommand', params: {} }))
).resolves.toMatchObject({
id: '2',
error: { code: -32602, message: 'Invalid params' },
});
await expect(
sendAsync(
JSON.stringify({
jsonrpc: '2.0',
id: '3',
method: 'writeStdin',
params: { sessionId: 999 },
})
)
).resolves.toMatchObject({
id: '3',
error: { code: -32603, message: 'Command session 999 does not exist.' },
});
await daemon.stopAsync();
mcpServer.close();
await new Promise<void>(resolve => httpServer.close(() => resolve()));
});
it('cancels the reconnect delay when stopped', async () => {
const httpServer = http.createServer();
const mcpServer = new WebSocketServer({ noServer: true });
@@ -186,6 +313,8 @@ describe(startSandboxDaemonAsync.name, () => {
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 60_000,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
const socket = await connection;
const daemon = await daemonPromise;
@@ -0,0 +1,286 @@
jest.unmock('fs');
jest.unmock('fs/promises');
jest.unmock('node:fs');
jest.unmock('node:fs/promises');
import { type SandboxDaemonCommandResult, type SandboxDaemonMethod } from '@expo/eas-build-job';
import fs from 'node:fs/promises';
import { spawnSync } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
import { performance } from 'node:perf_hooks';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import {
type SandboxDaemonCommandImplementations,
createSandboxCommandImplementations,
} from '../sandboxCommandImplementations';
describe('sandbox daemon commands', () => {
let commandImplementations: SandboxDaemonCommandImplementations;
let stopAsync: () => Promise<void>;
let workingDirectory: string;
beforeEach(async () => {
workingDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'sandbox-command-executor-'));
const controller = new AbortController();
const commands = createSandboxCommandImplementations({
workingDirectory,
env: process.env,
signal: controller.signal,
});
commandImplementations = commands.commandImplementations;
stopAsync = async () => {
controller.abort();
await commands.stoppedPromise;
};
});
afterEach(async () => {
await stopAsync();
await fs.chmod(workingDirectory, 0o700);
await fs.rm(workingDirectory, { recursive: true, force: true });
});
it('returns output and the exit code for a completed command', async () => {
const result = await commandImplementations.execCommand({ cmd: 'printf hello' });
expect(result).toMatchObject({ output: 'hello', exitCode: 0 });
expect('sessionId' in result).toBe(false);
});
it('rejects a command canceled during directory validation', async () => {
const controller = new AbortController();
const commands = createSandboxCommandImplementations({
workingDirectory,
env: process.env,
signal: controller.signal,
});
const command = commands.commandImplementations.execCommand({ cmd: 'printf should-not-run' });
controller.abort();
await commands.stoppedPromise;
await expect(command).rejects.toMatchObject({ name: 'AbortError' });
await expect(
commands.commandImplementations.writeStdin({ sessionId: 1 })
).rejects.toMatchObject({ name: 'AbortError' });
});
it('returns a session id and accepts stdin for a running command', async () => {
const started = await commandImplementations.execCommand({
cmd: `node -e "process.stdin.once('data', data => process.stdout.write(data.toString().toUpperCase(), () => process.exit(0)))"`,
yieldTimeMs: 10,
});
const sessionId = getSessionId(started);
const completed = await commandImplementations.writeStdin({
sessionId,
chars: 'hello',
yieldTimeMs: 1_000,
});
expect(completed).toMatchObject({ output: 'HELLO', exitCode: 0 });
expect('sessionId' in completed).toBe(false);
});
it('returns only output produced since the previous call', async () => {
const started = await commandImplementations.execCommand({
cmd: `node -e "process.stdin.once('data', () => process.stdout.write('second\\n', () => process.exit(0))); console.log('first')"`,
yieldTimeMs: 50,
});
const sessionId = getSessionId(started);
expect(await readUntilAsync(started, output => output.includes('first\n'))).toMatchObject({
output: 'first\n',
});
const completed = await readUntilAsync(
await commandImplementations.writeStdin({ sessionId, chars: 'continue', yieldTimeMs: 100 }),
(_, result) => !('sessionId' in result)
);
expect(completed).toMatchObject({ output: 'second\n', exitCode: 0 });
});
it('provides a TTY when requested', async () => {
const result = await commandImplementations.execCommand({
cmd: 'test -t 0 && test -t 1 && test -t 2',
tty: true,
});
expect(result).toMatchObject({ exitCode: 0 });
});
it('uses pipes by default', async () => {
const result = await commandImplementations.execCommand({ cmd: 'test -t 0' });
expect(result).toMatchObject({ exitCode: 1 });
});
it('sends Ctrl+C to the foreground PTY process', async () => {
const started = await commandImplementations.execCommand({
cmd: `exec node -e "process.on('SIGINT', () => process.stdout.write('interrupted\\n', () => process.exit(0))); console.log('ready'); setInterval(() => {}, 1000)"`,
tty: true,
yieldTimeMs: 50,
});
await readUntilAsync(started, output => output.includes('ready'));
const completed = await readUntilAsync(
await commandImplementations.writeStdin({
sessionId: getSessionId(started),
chars: '\u0003',
yieldTimeMs: 1_000,
}),
(_, result) => !('sessionId' in result)
);
expect(completed.output).toContain('interrupted');
expect(completed).toMatchObject({ exitCode: 0 });
});
it('writes U+0003 as literal input in pipe mode', async () => {
const started = await commandImplementations.execCommand({
cmd: `node -e "process.stdin.once('data', data => process.stdout.write(data[0] + '\\n', () => process.exit(0)))"`,
yieldTimeMs: 10,
});
const completed = await readUntilAsync(
await commandImplementations.writeStdin({
sessionId: getSessionId(started),
chars: '\u0003',
yieldTimeMs: 1_000,
}),
(_, result) => !('sessionId' in result)
);
expect(completed).toMatchObject({ output: '3\n', exitCode: 0 });
});
it('reports a termination signal without an exit code', async () => {
const result = await commandImplementations.execCommand({ cmd: 'kill -TERM $$' });
expect(result).toMatchObject({ terminationSignal: 'SIGTERM' });
expect('exitCode' in result).toBe(false);
});
it('measures each call separately', async () => {
const now = jest.spyOn(performance, 'now');
try {
now.mockReturnValueOnce(1_000).mockReturnValueOnce(1_250);
const started = await commandImplementations.execCommand({
cmd: 'read value',
yieldTimeMs: 0,
});
expect(started.wallTimeSeconds).toBe(0.25);
now.mockReturnValueOnce(5_000).mockReturnValueOnce(5_100);
const polled = await commandImplementations.writeStdin({
sessionId: getSessionId(started),
yieldTimeMs: 0,
});
expect(polled.wallTimeSeconds).toBe(0.1);
} finally {
now.mockRestore();
}
});
it('uses the requested working directory', async () => {
const childDirectory = path.join(workingDirectory, 'child');
await fs.mkdir(childDirectory);
const result = await commandImplementations.execCommand({ cmd: 'pwd', workdir: 'child' });
expect(result).toMatchObject({ output: `${await fs.realpath(childDirectory)}\n`, exitCode: 0 });
});
it('reports a missing working directory', async () => {
await expect(
commandImplementations.execCommand({ cmd: 'true', workdir: 'missing' })
).rejects.toThrow(
`Working directory does not exist: ${path.join(workingDirectory, 'missing')}`
);
});
it('reports a working directory that is not a directory', async () => {
const file = path.join(workingDirectory, 'file');
await fs.writeFile(file, 'content');
await expect(
commandImplementations.execCommand({ cmd: 'true', workdir: 'file' })
).rejects.toThrow(`Working directory is not a directory: ${file}`);
});
it('reports an inaccessible working directory', async () => {
if (process.platform === 'win32') {
return;
}
const directory = path.join(workingDirectory, 'inaccessible');
await fs.mkdir(directory, 0o000);
await expect(
commandImplementations.execCommand({ cmd: 'true', workdir: 'inaccessible' })
).rejects.toThrow(`Working directory is not accessible: ${directory}`);
await fs.chmod(directory, 0o700);
});
it.each([false, true])('stops command descendants when tty is %s', async tty => {
const started = await commandImplementations.execCommand({
cmd: `node -e "const { spawn } = require('node:child_process'); const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' }); process.stdout.write(String(child.pid) + '\\n'); setInterval(() => {}, 1000)"`,
tty,
yieldTimeMs: 100,
});
const ready = await readUntilAsync(started, output => /\d+\r?\n/.test(output));
const childPid = Number(/\d+/.exec(ready.output)?.[0]);
expect(childPid).toBeGreaterThan(0);
await stopAsync();
await setTimeoutAsync(50);
expect(isProcessRunning(childPid)).toBe(false);
});
async function readUntilAsync(
initial: SandboxDaemonCommandResult<SandboxDaemonMethod>,
isReady: (output: string, result: SandboxDaemonCommandResult<SandboxDaemonMethod>) => boolean
): Promise<SandboxDaemonCommandResult<SandboxDaemonMethod>> {
let result = initial;
let output = result.output;
const deadline = Date.now() + 10_000;
while (!isReady(output, result)) {
if (!('sessionId' in result) || Date.now() >= deadline) {
throw new Error(
`Command did not reach the expected state: ${JSON.stringify({ ...result, output })}`
);
}
result = await commandImplementations.writeStdin({
sessionId: result.sessionId,
yieldTimeMs: 100,
});
output += result.output;
}
return { ...result, output };
}
});
function getSessionId(result: unknown): number {
if (
typeof result !== 'object' ||
result === null ||
!('sessionId' in result) ||
typeof result.sessionId !== 'number'
) {
throw new Error('Expected the command to still be running.');
}
return result.sessionId;
}
function isProcessRunning(pid: number): boolean {
const result = spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], { encoding: 'utf8' });
if (result.error) {
throw result.error;
}
if (result.status === 1 && !result.stdout.trim()) {
return false;
}
expect(result.status).toBe(0);
// A zombie has exited, but its parent has not yet collected its exit status.
return !result.stdout.trim().startsWith('Z');
}
@@ -0,0 +1,105 @@
jest.unmock('node:fs/promises');
import { ShellSessionManager } from '../shellSessionManager';
import * as processes from '../../../utils/processes';
describe('ShellSessionManager', () => {
it.each([false, true])('uses only the supplied environment (tty: %s)', async tty => {
const controller = new AbortController();
const previous = process.env.SANDBOX_WORKER_ONLY;
process.env.SANDBOX_WORKER_ONLY = 'worker-secret';
const manager = new ShellSessionManager({
workingDirectory: process.cwd(),
env: { SHELL: '/bin/sh', SANDBOX_PREPARED: 'prepared-value' },
signal: controller.signal,
});
try {
const sessionId = await manager.startAsync({
cmd: 'printf "%s:%s" "$SANDBOX_PREPARED" "${SANDBOX_WORKER_ONLY-unset}"',
tty,
});
expect(await manager.readAsync(sessionId, 10_000)).toEqual({
output: 'prepared-value:unset',
exitCode: 0,
});
} finally {
controller.abort();
await manager.stoppedPromise;
if (previous === undefined) {
delete process.env.SANDBOX_WORKER_ONLY;
} else {
process.env.SANDBOX_WORKER_ONLY = previous;
}
}
});
it.each([false, true])('does not signal an exited leader (tty: %s)', async tty => {
const controller = new AbortController();
const manager = new ShellSessionManager({
workingDirectory: process.cwd(),
env: process.env,
signal: controller.signal,
});
const kill = jest.spyOn(processes, 'killProcessGroup');
try {
const sessionId = await manager.startAsync({ cmd: 'exit 0', tty });
expect(await manager.readAsync(sessionId, 10_000)).toMatchObject({ exitCode: 0 });
controller.abort();
await manager.stoppedPromise;
expect(kill).not.toHaveBeenCalled();
} finally {
controller.abort();
await manager.stoppedPromise;
kill.mockRestore();
}
});
it('keeps completed sessions without repeating their output', async () => {
const controller = new AbortController();
const manager = new ShellSessionManager({
workingDirectory: process.cwd(),
env: process.env,
signal: controller.signal,
});
try {
const sessionId = await manager.startAsync({ cmd: 'printf hello' });
expect(await manager.readAsync(sessionId, 1_000)).toEqual({ output: 'hello', exitCode: 0 });
expect(await manager.readAsync(sessionId, 1_000)).toEqual({ output: '', exitCode: 0 });
} finally {
controller.abort();
await manager.stoppedPromise;
}
});
it('keeps sessions separate and stops only its own manager', async () => {
const firstController = new AbortController();
const secondController = new AbortController();
const first = new ShellSessionManager({
workingDirectory: process.cwd(),
env: process.env,
signal: firstController.signal,
});
const second = new ShellSessionManager({
workingDirectory: process.cwd(),
env: process.env,
signal: secondController.signal,
});
try {
const firstId = await first.startAsync({ cmd: 'printf first' });
const secondId = await second.startAsync({ cmd: 'read value; printf "%s" "$value"' });
expect(await first.readAsync(firstId, 1_000)).toEqual({ output: 'first', exitCode: 0 });
firstController.abort();
await first.stoppedPromise;
await expect(first.startAsync({ cmd: 'printf stopped' })).rejects.toMatchObject({
name: 'AbortError',
});
expect(await second.readAsync(secondId, 0)).toEqual({ output: '', sessionId: secondId });
second.write(secondId, 'second\n');
expect(await second.readAsync(secondId, 1_000)).toEqual({ output: 'second', exitCode: 0 });
} finally {
firstController.abort();
secondController.abort();
await Promise.all([first.stoppedPromise, second.stoppedPromise]);
}
});
});
@@ -1,8 +1,15 @@
import { SystemError } from '@expo/eas-build-job';
import { graphql } from 'gql.tada';
import fetch, { Headers } from 'node-fetch';
import type { Readable } from 'node:stream';
import { setTimeout as delay } from 'node:timers/promises';
import { CustomBuildContext } from '../../customBuildContext';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import {
DeviceRunSessionTimeoutError,
withDeviceRunSessionTimeoutAsync,
} from './deviceRunSessionTimeout';
const CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
mutation CreateDeviceRunSessionArtifactUploadSession(
@@ -20,82 +27,157 @@ const CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
}
`);
type ArtifactMetadata = {
deviceRunSessionId: string;
artifactId: string;
name: string;
filename: string;
kind: string | undefined;
metadata?: Record<string, unknown>;
size: number;
};
type ArtifactSource = { stream: Readable; reopenStream?: () => Readable };
type ArtifactUploadSession = Awaited<
ReturnType<typeof createDeviceRunSessionArtifactUploadSessionAsync>
>;
export async function uploadDeviceRunSessionArtifactAsync(
ctx: CustomBuildContext,
{
deviceRunSessionId,
artifactId,
name,
filename,
kind,
metadata,
size,
stream,
}: {
deviceRunSessionId: string;
artifactId: string;
name: string;
filename: string;
kind: string | undefined;
metadata?: Record<string, unknown>;
size: number;
stream: NodeJS.ReadableStream;
}
options: ArtifactMetadata & ArtifactSource & { signal?: AbortSignal }
): Promise<void> {
const uploadSession = await createDeviceRunSessionArtifactUploadSessionAsync(ctx, {
deviceRunSessionId,
artifactId,
name,
filename,
kind,
metadata,
size,
});
const response = await fetch(uploadSession.url, {
method: 'PUT',
headers: new Headers(uploadSession.headers as Record<string, string>),
body: stream,
});
if (!response.ok) {
throw new SystemError(
`Failed to upload device run session artifact ${artifactId}: ${response.status} ${response.statusText}`,
{ cause: response }
try {
const uploadSession = await withDeviceRunSessionTimeoutAsync(
{ name: 'Artifact upload session creation', timeoutMs: 15_000, signal: options.signal },
async signal => await createDeviceRunSessionArtifactUploadSessionAsync(ctx, options, signal)
);
options.signal?.throwIfAborted();
await putArtifactWithRetriesAsync({
uploadSession,
source: options,
artifactId: options.artifactId,
signal: options.signal,
});
} finally {
// One-shot callers may have opened their stream before upload-session creation failed.
options.stream.destroy();
}
}
async function putArtifactWithRetriesAsync({
uploadSession,
source,
artifactId,
signal,
}: {
uploadSession: ArtifactUploadSession;
source: ArtifactSource;
artifactId: string;
signal?: AbortSignal;
}): Promise<void> {
const attempts = source.reopenStream ? 3 : 1;
for (let attempt = 0; attempt < attempts; attempt++) {
signal?.throwIfAborted();
const stream = attempt > 0 && source.reopenStream ? source.reopenStream() : source.stream;
try {
// Upload time scales with file size, so the deadline bounds stalls, not the whole request.
await withDeviceRunSessionTimeoutAsync(
{ name: 'Artifact PUT', timeoutMs: 90_000, signal },
async (putSignal, resetDeadline) =>
await putArtifactAsync({
uploadSession,
stream,
artifactId,
signal: putSignal,
onProgress: resetDeadline,
})
);
return;
} catch (error) {
signal?.throwIfAborted();
const retryable =
error instanceof ArtifactPutError
? [408, 429, 500, 502, 503, 504].includes(error.status)
: error instanceof DeviceRunSessionTimeoutError ||
(error instanceof Error && error.name === 'FetchError');
if (!retryable || attempt + 1 === attempts) {
throw error;
}
await delay(500 * (attempt + 1), undefined, { signal });
} finally {
stream.destroy();
}
}
}
async function putArtifactAsync({
uploadSession,
stream,
artifactId,
signal,
onProgress,
}: {
uploadSession: ArtifactUploadSession;
stream: Readable;
artifactId: string;
signal: AbortSignal;
onProgress: () => void;
}): Promise<void> {
const requestController = new AbortController();
try {
const responding = fetch(uploadSession.url, {
method: 'PUT',
headers: new Headers(uploadSession.headers as Record<string, string>),
body: stream,
signal: AbortSignal.any([signal, requestController.signal]),
});
// fetch has piped the body already, so this listener observes flow without starting it.
stream.on('data', onProgress);
const response = await responding;
signal.throwIfAborted();
if (!response.ok) {
throw new ArtifactPutError(artifactId, response);
}
} finally {
// node-fetch destroys the request body on abort, and its response stream is a PassThrough
// whose destruction leaves the socket open, so the request itself is aborted here.
requestController.abort();
}
}
class ArtifactPutError extends SystemError {
readonly status: number;
constructor(artifactId: string, response: { status: number; statusText: string }) {
super(
`Failed to upload device run session artifact ${artifactId}: HTTP ${response.status} ${response.statusText}.`
);
this.status = response.status;
}
}
async function createDeviceRunSessionArtifactUploadSessionAsync(
ctx: CustomBuildContext,
{
deviceRunSessionId,
artifactId,
name,
filename,
kind,
metadata,
size,
}: {
deviceRunSessionId: string;
artifactId: string;
name: string;
filename: string;
kind: string | undefined;
metadata?: Record<string, unknown>;
size: number;
}
{ deviceRunSessionId, artifactId, name, filename, kind, metadata, size }: ArtifactMetadata,
signal: AbortSignal
) {
const result = await ctx.graphqlClient
.mutation(CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION, {
deviceRunSessionId,
input: {
name,
filename,
...(kind !== undefined ? { kind } : {}),
...(metadata !== undefined ? { metadata } : {}),
size,
.mutation(
CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION,
{
deviceRunSessionId,
input: {
name,
filename,
...(kind !== undefined ? { kind } : {}),
...(metadata !== undefined ? { metadata } : {}),
size,
},
},
})
graphqlAbortContext(signal)
)
.toPromise();
signal.throwIfAborted();
if (result.error) {
throw new SystemError(
`Failed to create upload session for device run session artifact ${artifactId}: ${result.error.message}`,
@@ -0,0 +1,203 @@
import { SystemError } from '@expo/eas-build-job';
import type { bunyan } from '@expo/logger';
import type { BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { createReadStream } from 'node:fs';
import { readFile, readdir, stat } from 'node:fs/promises';
import path from 'node:path';
import limitFactory from 'promise-limit';
import { z } from 'zod';
import { type CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { formatBytes } from '../../utils/artifacts';
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
const RecordingsSchema = z.array(
z.object({
udid: z.string(),
deviceName: z.string(),
runtimeDisplayName: z.string(),
directory: z.string(),
})
);
const RecordingManifestSchema = z.object({
firstFrameWallClock: z.object({
iso8601: z.string(),
}),
width: z.number().int().positive(),
height: z.number().int().positive(),
recording: z.string(),
status: z.string().optional(),
error: z.string().optional(),
});
const UnlistedRecordingManifestSchema = z.object({
udid: z.string(),
deviceName: z.string(),
runtimeDisplayName: z.string(),
status: z.enum(['recording', 'failed', 'complete']),
recording: z.string(),
});
const recordingStartTimeFormatter = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3,
hourCycle: 'h23',
timeZone: 'UTC',
timeZoneName: 'short',
});
export function parseDeviceScreenRecordings(input: unknown): z.infer<typeof RecordingsSchema> {
const result = RecordingsSchema.safeParse(input);
if (!result.success) {
throw new SystemError('Invalid device screen recordings input.', {
cause: result.error,
});
}
return result.data;
}
/**
* A Hub that was killed never lists its recording, even one it had already completed at the
* duration limit. session.json is at "recording" or "failed" with a fragmented .partial that plays
* up to its last keyframe, or at "complete" with the finished file. Return the ones ffprobe can read.
*/
export async function findUnlistedDeviceScreenRecordingsAsync({
root,
env,
logger,
}: {
root: string;
env: BuildStepEnv;
logger: bunyan;
}): Promise<z.infer<typeof RecordingsSchema>> {
const found: z.infer<typeof RecordingsSchema> = [];
for (const entry of await readdir(root, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
const directory = path.join(root, entry.name);
const manifest = UnlistedRecordingManifestSchema.safeParse(
await readFile(path.join(directory, 'session.json'), 'utf-8')
.then(text => JSON.parse(text))
.catch(() => null)
);
if (!manifest.success || path.basename(manifest.data.recording) !== manifest.data.recording) {
continue;
}
const file = path.join(directory, manifest.data.recording);
const name = path.join(entry.name, manifest.data.recording);
let duration = 0;
try {
const probe = await spawn(
'ffprobe',
['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', file],
{ env, stdio: 'pipe' }
);
duration = Number(probe.stdout.trim());
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
logger.warn(`ffprobe is not available; cannot recover the unlisted recording ${name}.`);
continue;
}
}
if (!(duration > 0)) {
logger.warn(`Unlisted recording ${name} does not decode; skipping it.`);
continue;
}
const { udid, deviceName, runtimeDisplayName } = manifest.data;
found.push({ udid, deviceName, runtimeDisplayName, directory });
}
return found;
}
export async function uploadDeviceRunSessionScreenRecordingsAsync(
ctx: CustomBuildContext,
{
logger,
deviceRunSessionId,
recordings,
}: {
logger: bunyan;
deviceRunSessionId: string;
recordings: z.infer<typeof RecordingsSchema>;
}
): Promise<boolean> {
if (recordings.length === 0) {
logger.info('No device screen recordings found; skipping uploads.');
return true;
}
const limit = limitFactory(5);
const uploaded = await Promise.all(
recordings.map(recording =>
limit(async () => {
try {
const metadata = RecordingManifestSchema.parse(
JSON.parse(await readFile(path.join(recording.directory, 'session.json'), 'utf-8'))
);
if (path.basename(metadata.recording) !== metadata.recording) {
throw new Error('Recording filename must not contain a directory.');
}
const startedAt = recordingStartTimeFormatter.format(
new Date(metadata.firstFrameWallClock.iso8601)
);
const partial = metadata.status !== undefined && metadata.status !== 'complete';
const shortUdid = `${recording.udid.slice(0, 8)}-…`;
const displayName = `${recording.deviceName} screen recording (${shortUdid}, started at ${startedAt}${partial ? ', partial' : ''})`;
const recordingPath = path.join(recording.directory, metadata.recording);
const { size } = await stat(recordingPath);
const recordingId = path.basename(recording.directory);
logger.info(
`Uploading screen recording for ${recording.deviceName} (${formatBytes(size)}).`
);
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId,
artifactId: recordingId,
name: displayName,
filename: `${recordingId}.mp4`,
kind: 'screen-recording',
metadata: {
__eas_type: 'screen-recording',
__eas_screen_recording: '1',
udid: recording.udid,
deviceName: recording.deviceName,
runtimeDisplayName: recording.runtimeDisplayName,
firstFrameAt: metadata.firstFrameWallClock.iso8601,
width: metadata.width,
height: metadata.height,
...(partial
? {
partial: true,
partialReason:
metadata.error ??
'The Device Hub stopped before it could finalize the recording.',
}
: {}),
},
size,
stream: createReadStream(recordingPath),
reopenStream: () => createReadStream(recordingPath),
});
return true;
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not upload device screen recording', error);
logger.warn(
{ err: error },
`Could not upload screen recording for ${recording.deviceName}.`
);
return false;
}
})
)
);
return uploaded.every(Boolean);
}
@@ -0,0 +1,43 @@
export class DeviceRunSessionTimeoutError extends Error {
constructor(name: string, timeoutMs: number) {
super(`${name} timed out after ${timeoutMs}ms.`);
this.name = 'DeviceRunSessionTimeoutError';
}
}
/**
* Bounds waiting and signals cancellation. Operations must check the signal before later effects.
* Calling resetDeadline restarts the timer, so the bound applies to stalls instead of total time.
*/
export async function withDeviceRunSessionTimeoutAsync<T>(
{ name, timeoutMs, signal: parent }: { name: string; timeoutMs: number; signal?: AbortSignal },
operation: (signal: AbortSignal, resetDeadline: () => void) => Promise<T>
): Promise<T> {
const controller = new AbortController();
const signal = parent ? AbortSignal.any([parent, controller.signal]) : controller.signal;
signal.throwIfAborted();
let rejectAbort: (reason: unknown) => void = () => {};
const aborted = new Promise<never>((_resolve, reject) => {
rejectAbort = reject;
});
const onAbort = () => rejectAbort(signal.reason);
signal.addEventListener('abort', onAbort, { once: true });
const expire = () => controller.abort(new DeviceRunSessionTimeoutError(name, timeoutMs));
let timer = setTimeout(expire, timeoutMs);
let settled = false;
const resetDeadline = () => {
clearTimeout(timer);
if (!settled && !signal.aborted) {
timer = setTimeout(expire, timeoutMs);
}
};
try {
const result = await Promise.race([operation(signal, resetDeadline), aborted]);
signal.throwIfAborted();
return result;
} finally {
settled = true;
clearTimeout(timer);
signal.removeEventListener('abort', onAbort);
}
}
@@ -0,0 +1,590 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import type { bunyan } from '@expo/logger';
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import { randomBytes } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { z } from 'zod';
import type { CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import {
PackageManager,
resolveConfiguredPackageManager,
resolvePackageExec,
} from '../../utils/packageManager';
import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
import {
findUnlistedDeviceScreenRecordingsAsync,
parseDeviceScreenRecordings,
uploadDeviceRunSessionScreenRecordingsAsync,
} from './deviceRunSessionScreenRecordings';
import {
type DetachedProcessHandle,
type ServeSimLaunchOptions,
ensureFfmpegInstalledOnceAsync,
fetchWebPreviewTurnArgsAsync,
findAvailablePortAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
isProcessRunning,
spawnDetached,
startNgrokTunnelAsync,
} from './remoteDeviceRunSession';
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
const WEB_PREVIEW_HOST = '127.0.0.1';
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
const SERVE_SIM_MAX_DIMENSION = '1600';
const SERVE_SIM_MJPEG_QUALITY = '0.55';
const SERVE_SIM_VIDEO_BITRATE = '10000000';
const SERVE_SIM_VIDEO_FPS = '60';
const EXPO_DEVICE_HUB_PACKAGE_NAME = 'expo-device-hub';
const EXPO_DEVICE_HUB_MAX_DIMENSION = '960';
const EXPO_DEVICE_HUB_VIDEO_BITRATE = '6000000';
const EXPO_DEVICE_HUB_VIDEO_FPS = '60';
// On SIGTERM the Hub finalizes the recording itself, with this deadline before it force-exits,
// so a stop request that failed here still gets one more chance to write the MP4.
const EXPO_DEVICE_HUB_SIGTERM_FINALIZE_DEADLINE_MS = 60_000;
const EXPO_DEVICE_HUB_EXIT_LEEWAY_MS = 10_000;
const RECORDING_STOP_GRACE_PERIOD_MS =
EXPO_DEVICE_HUB_SIGTERM_FINALIZE_DEADLINE_MS + EXPO_DEVICE_HUB_EXIT_LEEWAY_MS;
const HOST_OUTPUT_TAIL_CHARS = 8_000;
export function websiteOrigin(env: BuildStepEnv): string {
return env.EXPO_LOCAL
? 'https://expo.test'
: env.EXPO_STAGING
? 'https://staging.expo.dev'
: 'https://expo.dev';
}
export function simulatorPreviewPageUrl(env: BuildStepEnv, subdomainId: string): string {
return new URL(`/simulator-preview/${subdomainId}`, websiteOrigin(env)).toString();
}
// Website dev servers on expo.test use staging sessions, and CORS matches ports exactly.
const WEBSITE_DEV_ORIGINS = [
'https://expo.test',
'https://expo.test:13001',
...Array.from({ length: 16 }, (_, index) => `https://expo.test:${13200 + index}`),
];
export function websiteOriginServeSimArgs(env: BuildStepEnv): string[] {
const origins = new Set([websiteOrigin(env)]);
if (!env.EXPO_LOCAL && env.EXPO_STAGING) {
origins.add('https://*.expo.dev');
}
if (env.EXPO_LOCAL || env.EXPO_STAGING) {
for (const origin of WEBSITE_DEV_ORIGINS) {
origins.add(origin);
}
}
return [...origins].flatMap(origin => ['--cors-origin', origin, '--frame-ancestor', origin]);
}
function createServeSimPackageSpec(packageVersion: string | undefined): string {
return `${SERVE_SIM_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
}
function createExpoDeviceHubPackageSpec(packageVersion: string | undefined): string {
return `${EXPO_DEVICE_HUB_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
}
export function createServeSimArgs({
port,
turnArgs = [],
websiteArgs = [],
shareUrl,
packageVersion,
launchAppIdentifier,
launchArgs = [],
openUrl,
}: {
port: number;
turnArgs?: string[];
websiteArgs?: string[];
shareUrl?: string;
packageVersion?: string;
} & ServeSimLaunchOptions): string[] {
return [
createServeSimPackageSpec(packageVersion),
'--port',
String(port),
'--host',
WEB_PREVIEW_HOST,
'--require-token',
'--transport',
'webrtc',
'--webrtc-codec',
'h264',
'--max-dimension',
SERVE_SIM_MAX_DIMENSION,
'--mjpeg-quality',
SERVE_SIM_MJPEG_QUALITY,
'--video-bitrate',
SERVE_SIM_VIDEO_BITRATE,
'--video-fps',
SERVE_SIM_VIDEO_FPS,
...turnArgs,
...websiteArgs,
...(shareUrl ? ['--share-url', shareUrl] : []),
...(launchAppIdentifier ? ['--launch-app-identifier', launchAppIdentifier] : []),
...launchArgs.flatMap(argument => ['--launch-arg', argument]),
...(openUrl ? ['--open-url', openUrl] : []),
];
}
export function createExpoDeviceHubArgs({
port,
turnArgs = [],
packageVersion,
recordingDirectory,
}: {
port: number;
turnArgs?: string[];
packageVersion?: string;
recordingDirectory?: string;
}): string[] {
return [
createExpoDeviceHubPackageSpec(packageVersion),
'--port',
String(port),
'--host',
WEB_PREVIEW_HOST,
'--platform',
'android',
'--transport',
'webrtc',
'--webrtc-codec',
'h264',
'--webrtc-ice-policy',
'all',
'--max-dimension',
EXPO_DEVICE_HUB_MAX_DIMENSION,
'--video-bitrate',
EXPO_DEVICE_HUB_VIDEO_BITRATE,
'--video-fps',
EXPO_DEVICE_HUB_VIDEO_FPS,
'--hide-sidebar',
'--hide-boot-device',
...(recordingDirectory ? ['--android-recording-directory', recordingDirectory] : []),
...turnArgs,
];
}
const WebPreviewReadyResponseSchema = z.object({
status: z.literal('ready'),
device: z.string(),
});
export async function waitForWebPreviewReadyAsync({
previewServer,
serverName,
port,
timeoutMs,
}: {
previewServer: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
serverName: string;
port: number;
timeoutMs: number;
}): Promise<string> {
const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`;
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
while (Date.now() < deadline) {
if (previewServer.pid !== undefined && !isProcessRunning(previewServer.pid)) {
throw new SystemError(
`${serverName} exited before becoming ready. Last output:\n${
previewServer.getOutput() || '<empty>'
}`
);
}
try {
const response = await turtleFetch(readyUrl, 'GET', {
retries: 0,
timeout: 2_000,
});
const ready = WebPreviewReadyResponseSchema.parse(await response.json());
return ready.device;
} catch (error) {
lastError = error;
}
await sleepAsync(1_000);
}
throw new SystemError(
`Timed out waiting for ${serverName} readiness at ${readyUrl}${
lastError instanceof Error ? `: ${lastError.message}` : ''
}. Last output:\n${previewServer.getOutput() || '<empty>'}`
);
}
export async function readServeSimPreviewTokenAsync(
udid: string,
stateDir: string = SERVE_SIM_STATE_DIR
): Promise<string | undefined> {
const servers = await readServeSimServersAsync(stateDir);
return servers.find(server => server.udid === udid)?.token;
}
export type DeviceWebPreview = {
previewPageUrl: string;
apiUrl: string;
previewToken?: string;
/** Closes this tunnel only. The session host and recording remain running. */
closeAsync(): Promise<void>;
};
export type DeviceSessionHost = {
openPreviewAsync(options: { baseDomain: string }): Promise<DeviceWebPreview>;
/**
* Terminal and idempotent. Never rejects: a failed finalization, host stop or upload is logged
* and reported, so callers need no error handling around it.
*/
finishAsync(): Promise<void>;
};
type AndroidSessionRecording = {
deviceRunSessionId: string;
directory: string;
controlToken: string;
env: BuildStepEnv;
};
export async function startDeviceSessionHostAsync(
ctx: CustomBuildContext,
{
runtimePlatform,
env,
logger,
timeoutMs,
packageVersion,
launchAppIdentifier,
launchArgs,
openUrl,
}: {
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
packageVersion?: string;
} & ServeSimLaunchOptions
): Promise<DeviceSessionHost> {
const isAndroid = runtimePlatform === BuildRuntimePlatform.LINUX;
// Unreachable from the step functions, which reject a non-Darwin launch while parsing.
// Kept because this function is exported and expo-device-hub cannot launch.
if (isAndroid && launchAppIdentifier) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
`Cannot launch ${launchAppIdentifier}: an application launch runs through serve-sim on an iOS simulator, and this session runs expo-device-hub on ${runtimePlatform}.`
);
}
if (isAndroid) {
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
}
const recording: AndroidSessionRecording | null = isAndroid
? {
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
directory: await fs.promises.mkdtemp(path.join(os.tmpdir(), 'android-session-recordings-')),
controlToken: randomBytes(32).toString('hex'),
env,
}
: null;
const subdomainId = randomBytes(16).toString('hex');
const previewPageUrl = simulatorPreviewPageUrl(env, subdomainId);
const port = await findAvailablePortAsync();
const serverName = isAndroid ? 'expo-device-hub' : 'serve-sim';
const packageSpec = isAndroid
? createExpoDeviceHubPackageSpec(packageVersion)
: createServeSimPackageSpec(packageVersion);
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
const previewExec = resolvePackageExec(
resolveConfiguredPackageManager(env, PackageManager.NPM),
isAndroid
? createExpoDeviceHubArgs({
port,
turnArgs,
packageVersion,
recordingDirectory: recording?.directory,
})
: createServeSimArgs({
port,
turnArgs,
packageVersion,
websiteArgs: websiteOriginServeSimArgs(env),
shareUrl: previewPageUrl,
launchAppIdentifier,
launchArgs,
openUrl,
})
);
logger.info(
`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port} via ${previewExec.command}.`
);
const previewServer = spawnDetached({
command: previewExec.command,
args: previewExec.args,
env: recording
? { ...env, EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN: recording.controlToken }
: env,
stopGracePeriodMs: recording ? RECORDING_STOP_GRACE_PERIOD_MS : undefined,
});
let previewToken: string | undefined;
let previewTask: Promise<DeviceWebPreview> | null = null;
let finishTask: Promise<void> | null = null;
let hostReady = false;
const host: DeviceSessionHost = {
openPreviewAsync({ baseDomain }) {
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: getNgrokAuthtokenOrThrow(env),
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;
return {
previewPageUrl,
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(() => {
if (previewTask === opening) {
previewTask = null;
}
});
return opening;
},
finishAsync() {
return (finishTask ??= finishDeviceSessionHostAsync(ctx, {
previewTask,
previewServer,
serverName,
port,
// A host that never answered /readyz has nothing to finalize or upload.
recording: hostReady ? recording : null,
logger,
}));
},
};
try {
logger.info(`Waiting for ${serverName} to become ready.`);
const device = await waitForWebPreviewReadyAsync({
previewServer,
serverName,
port,
timeoutMs,
});
hostReady = true;
if (!isAndroid) {
previewToken = await readServeSimPreviewTokenAsync(device);
if (!previewToken) {
throw new SystemError(
`serve-sim became ready but wrote no session token for device ${device}. The preview is ` +
'on a public tunnel and would be reachable without one, so the session cannot continue. ' +
'This usually means the state file was not written as expected; retry the session, and ' +
'report it if it repeats.'
);
}
}
return host;
} catch (error) {
await host.finishAsync();
throw error;
}
}
async function finishDeviceSessionHostAsync(
ctx: CustomBuildContext,
{
previewTask,
previewServer,
serverName,
port,
recording,
logger,
}: {
previewTask: Promise<DeviceWebPreview> | null;
previewServer: DetachedProcessHandle;
serverName: string;
port: number;
recording: AndroidSessionRecording | null;
logger: bunyan;
}
): Promise<void> {
// 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();
}
).catch(err => {
logger.warn({ err }, `Could not close the ${serverName} preview tunnel within its deadline.`);
});
let finalization: AndroidRecordingFinalization | null = null;
if (recording) {
// stopAsync signals the whole process group, including capture's encoder.
// Finalize the MP4 first; the token protects this route on the preview server.
finalization = await finalizeAndroidRecordingAsync({
port,
controlToken: recording.controlToken,
logger,
});
}
let hostStopped = false;
try {
await previewServer.stopAsync();
if (previewServer.pid !== undefined && isProcessRunning(previewServer.pid)) {
throw new Error('Session host is still running after shutdown.');
}
hostStopped = true;
} catch (err) {
logger.warn({ err }, `Could not stop the ${serverName} session host.`);
}
await retirePreview;
// A Hub that never recorded has logged its reason and left nothing to upload.
const captured = finalization !== 'not-recording';
let uploaded = false;
if (recording && captured && hostStopped) {
uploaded = await uploadFinishedAndroidRecordingAsync(ctx, { recording, logger });
}
if (recording && captured && (finalization === 'failed' || !uploaded)) {
// The Hub reports capture failures on stderr; the stop route answers with only a summary.
logger.warn(
{ hostOutput: previewServer.getOutput().slice(-HOST_OUTPUT_TAIL_CHARS) || '<empty>' },
'Session host output around the recording failure.'
);
}
}
type AndroidRecordingFinalization = 'finalized' | 'not-recording' | 'failed';
async function finalizeAndroidRecordingAsync({
port,
controlToken,
logger,
}: {
port: number;
controlToken: string;
logger: bunyan;
}): Promise<AndroidRecordingFinalization> {
try {
const response = await withDeviceRunSessionTimeoutAsync(
{ name: 'Android recording finalization', timeoutMs: 60_000 },
async signal =>
await turtleFetch(
`http://${WEB_PREVIEW_HOST}:${port}/_eas/android-recording/stop`,
'POST',
{
headers: { Authorization: `Bearer ${controlToken}` },
retries: 0,
shouldThrowOnNotOk: false,
signal,
}
)
);
if (response.status === 409) {
// The Hub answers 409 with the reason nothing was recorded, such as the emulator count.
logger.warn(`Android recording was not captured: ${await response.text()}`);
return 'not-recording';
}
if (!response.ok) {
throw new SystemError(
`Android recording finalization returned HTTP ${response.status}: ${await response.text()}`
);
}
return 'finalized';
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not finalize Android recording before shutdown', error, {
level: 'warning',
});
logger.warn({ err: error }, 'Could not finalize Android recording before shutdown.');
return 'failed';
}
}
async function uploadFinishedAndroidRecordingAsync(
ctx: CustomBuildContext,
{ recording, logger }: { recording: AndroidSessionRecording; logger: bunyan }
): Promise<boolean> {
try {
let recordings = parseDeviceScreenRecordings(
JSON.parse(
await fs.promises.readFile(path.join(recording.directory, 'recordings.json'), 'utf8')
)
);
if (recordings.length === 0) {
// recordings.json is written at finish; a killed Hub leaves what it had on disk unlisted.
recordings = await findUnlistedDeviceScreenRecordingsAsync({
root: recording.directory,
env: recording.env,
logger,
});
}
for (const item of recordings) {
if (
path.dirname(path.resolve(item.directory)) !== recording.directory ||
(await fs.promises.lstat(item.directory)).isSymbolicLink()
) {
throw new Error('Recording directory is not an owned session child.');
}
}
const uploaded = await uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
logger,
deviceRunSessionId: recording.deviceRunSessionId,
recordings,
});
if (recordings.length > 0 && uploaded) {
await fs.promises.rm(recording.directory, { recursive: true });
return true;
}
return false;
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not upload the Android session recording', error, { level: 'warning' });
logger.warn(
{ err: error, recordingDirectory: recording.directory },
'Could not upload the Android session recording.'
);
return false;
}
}
@@ -1,66 +0,0 @@
import { createLogger } from '@expo/logger';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { v4 as uuid } from 'uuid';
import { distributionCertificate } from '../__tests__/fixtures';
import Keychain from '../keychain';
const mockLogger = createLogger({ name: 'mock-logger' });
jest.setTimeout(60 * 1000);
// Those are in fact "real" tests in that they really execute the `fastlane` commands.
// We need the JS code to modify the file system, so we need to mock it.
jest.unmock('fs');
describe('Keychain class', () => {
describe('ensureCertificateImported method', () => {
let keychain: Keychain;
const certificatePath = path.join(os.tmpdir(), `cert-${uuid()}.p12`);
beforeAll(async () => {
await fs.writeFile(
certificatePath,
new Uint8Array(Buffer.from(distributionCertificate.dataBase64, 'base64'))
);
});
afterAll(async () => {
await fs.remove(certificatePath);
});
beforeEach(async () => {
keychain = new Keychain();
await keychain.create(mockLogger);
});
afterEach(async () => {
await keychain.destroy(mockLogger);
});
it("should throw an error if the certificate hasn't been imported", async () => {
await expect(
keychain.ensureCertificateImported(
distributionCertificate.teamId,
distributionCertificate.fingerprint
)
).rejects.toThrowError(/hasn't been imported successfully/);
});
it("shouldn't throw any error if the certificate has been imported successfully", async () => {
await keychain.importCertificate(
mockLogger,
certificatePath,
distributionCertificate.password
);
await expect(
keychain.ensureCertificateImported(
distributionCertificate.teamId,
distributionCertificate.fingerprint
)
).resolves.not.toThrow();
});
});
});
@@ -1,7 +1,7 @@
import { createLogger } from '@expo/logger';
import { provisioningProfile } from './fixtures';
import Keychain from '../keychain';
import Keychain from '../../../../../ios/credentials/keychain';
import ProvisioningProfile from '../provisioningProfile';
const mockLogger = createLogger({ name: 'mock-logger' });
@@ -18,11 +18,11 @@ describe('ProvisioningProfile class', () => {
beforeAll(async () => {
keychain = new Keychain();
await keychain.create(mockLogger);
await keychain.create({ logger: mockLogger });
});
afterAll(async () => {
await keychain.destroy(mockLogger);
await keychain.destroy({ logger: mockLogger });
});
it("shouldn't throw any error if the provisioning profile and distribution certificate match", async () => {
@@ -1,112 +0,0 @@
import { bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import os from 'os';
import path from 'path';
import { v4 as uuid } from 'uuid';
import { runFastlane } from '../fastlane';
export default class Keychain {
private readonly keychainPath: string;
private readonly keychainPassword: string;
private created = false;
private destroyed = false;
constructor() {
this.keychainPath = path.join(os.tmpdir(), `eas-build-${uuid()}.keychain`);
this.keychainPassword = uuid();
}
get data(): { path: string; password: string } {
return {
path: this.keychainPath,
password: this.keychainPassword,
};
}
public async create(logger: bunyan): Promise<void> {
logger.debug(`Creating keychain - ${this.keychainPath}`);
await runFastlane([
'run',
'create_keychain',
`path:${this.keychainPath}`,
`password:${this.keychainPassword}`,
'unlock:true',
'timeout:360000',
]);
this.created = true;
}
public async importCertificate(
logger: bunyan,
certPath: string,
certPassword: string
): Promise<void> {
if (!this.created) {
throw new Error('You must create a keychain first.');
}
logger.debug(`Importing certificate ${certPath} into keychain ${this.keychainPath}`);
await runFastlane([
'run',
'import_certificate',
`certificate_path:${certPath}`,
`certificate_password:${certPassword}`,
`keychain_path:${this.keychainPath}`,
`keychain_password:${this.keychainPassword}`,
]);
}
public async ensureCertificateImported(teamId: string, fingerprint: string): Promise<void> {
const identities = await this.findIdentitiesByTeamId(teamId);
if (!identities.includes(fingerprint)) {
throw new Error(
`Distribution certificate with fingerprint ${fingerprint} hasn't been imported successfully`
);
}
}
public async destroy(logger: bunyan, keychainPath?: string): Promise<void> {
if (!keychainPath && !this.created) {
logger.warn("There is nothing to destroy, a keychain hasn't been created yet.");
return;
}
if (this.destroyed) {
logger.warn('The keychain has been already destroyed');
return;
}
const keychainToDeletePath = keychainPath ?? this.keychainPath;
logger.info(`Destroying keychain - ${keychainToDeletePath}`);
try {
await runFastlane(['run', 'delete_keychain', `keychain_path:${keychainToDeletePath}`]);
this.destroyed = true;
} catch (err) {
logger.error({ err }, 'Failed to delete the keychain\n');
throw err;
}
}
public async cleanUpKeychains(logger: bunyan): Promise<void> {
const { stdout } = await spawn('security', ['list-keychains'], { stdio: 'pipe' });
const keychainList = (/"(.*)"/g.exec(stdout) ?? ([] as string[])).map(i =>
i.slice(1, i.length - 1)
);
const turtleKeychainList = keychainList.filter(keychain =>
/eas-build-[\w-]+\.keychain$/.exec(keychain)
);
for (const turtleKeychainPath of turtleKeychainList) {
await this.destroy(logger, turtleKeychainPath);
}
}
private async findIdentitiesByTeamId(teamId: string): Promise<string> {
const { output } = await spawn(
'security',
['find-identity', '-v', '-s', `(${teamId})`, this.keychainPath],
{
stdio: 'pipe',
}
);
return output.join('');
}
}
@@ -8,7 +8,7 @@ import path from 'path';
import { v4 as uuid } from 'uuid';
import * as distributionCertificateUtils from './distributionCertificate';
import Keychain from './keychain';
import Keychain from '../../../../ios/credentials/keychain';
import ProvisioningProfile, {
DistributionType,
ProvisioningProfileData,
@@ -36,7 +36,7 @@ export default class IosCredentialsManager {
logger.info('Creating keychain');
this.keychain = new Keychain();
await this.keychain.create(logger);
await this.keychain.create({ logger });
const targets = Object.keys(this.buildCredentials);
const targetProvisioningProfiles: TargetProvisioningProfiles = {};
@@ -70,7 +70,7 @@ export default class IosCredentialsManager {
}
if (this.keychain) {
await this.keychain.destroy(logger);
await this.keychain.destroy({ logger });
}
if (this.provisioningProfiles) {
for (const provisioningProfile of this.provisioningProfiles) {
@@ -109,11 +109,11 @@ export default class IosCredentialsManager {
);
logger.info('Importing distribution certificate into the keychain');
await this.keychain.importCertificate(
await this.keychain.importCertificate({
logger,
distCertPath,
targetCredentials.distributionCertificate.password
);
certPath: distCertPath,
certPassword: targetCredentials.distributionCertificate.password,
});
logger.info('Initializing provisioning profile');
const provisioningProfile = new ProvisioningProfile(
@@ -125,10 +125,10 @@ export default class IosCredentialsManager {
await provisioningProfile.init(logger);
logger.info('Validating whether distribution certificate has been imported successfully');
await this.keychain.ensureCertificateImported(
provisioningProfile.data.teamId,
certificateFingerprint
);
await this.keychain.ensureCertificateImported({
teamId: provisioningProfile.data.teamId,
fingerprint: certificateFingerprint,
});
logger.info('Verifying whether the distribution certificate and provisioning profile match');
provisioningProfile.verifyCertificate(certificateFingerprint);
@@ -1,7 +1,13 @@
import { SystemError } from '@expo/eas-build-job';
import { SystemError, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { BuildRuntimePlatform, BuildStepEnv, spawnAsync } from '@expo/steps';
import {
BuildRuntimePlatform,
BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
spawnAsync,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import * as ngrok from '@ngrok/ngrok';
import { graphql } from 'gql.tada';
@@ -14,22 +20,16 @@ import { clearTimeout, setTimeout } from 'node:timers';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import { CustomBuildContext } from '../../customBuildContext';
import {
parseLaunchArgsInput,
parseNonEmptyStringInput,
parseOpenUrlInput,
} from '../functions/launchApplication';
import { Sentry } from '../../sentry';
import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const WEB_PREVIEW_HOST = '127.0.0.1';
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
const SERVE_SIM_MAX_DIMENSION = '960';
const SERVE_SIM_MJPEG_QUALITY = '0.55';
const SERVE_SIM_VIDEO_BITRATE = '6000000';
const SERVE_SIM_VIDEO_FPS = '60';
const EXPO_DEVICE_HUB_PACKAGE_NAME = 'expo-device-hub';
const EXPO_DEVICE_HUB_MAX_DIMENSION = '960';
const EXPO_DEVICE_HUB_VIDEO_BITRATE = '6000000';
const EXPO_DEVICE_HUB_VIDEO_FPS = '60';
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
@@ -531,10 +531,11 @@ export type DetachedProcessHandle = {
/** PID of the directly spawned process, if the OS assigned one. */
pid: number | undefined;
getOutput: () => string;
getExitError: () => Error | undefined;
stopAsync: () => Promise<void>;
};
function isProcessRunning(pid: number): boolean {
export function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
@@ -543,13 +544,16 @@ function isProcessRunning(pid: number): boolean {
}
}
async function stopDetachedProcessAsync(pid: number | undefined): Promise<void> {
async function stopDetachedProcessAsync(
pid: number | undefined,
gracePeriodMs = 5_000
): Promise<void> {
if (pid === undefined || !isProcessRunning(pid)) {
return;
}
try {
// spawnDetached creates a dedicated process group. Signaling the group also
// terminates npx/bunx descendants instead of leaving the actual daemon alive.
// terminates npx/bun descendants instead of leaving the actual daemon alive.
process.kill(-pid, 'SIGTERM');
} catch {
try {
@@ -559,7 +563,7 @@ async function stopDetachedProcessAsync(pid: number | undefined): Promise<void>
}
}
const deadline = Date.now() + 5_000;
const deadline = Date.now() + gracePeriodMs;
while (Date.now() < deadline && isProcessRunning(pid)) {
await sleepAsync(100);
}
@@ -573,6 +577,43 @@ async function stopDetachedProcessAsync(pid: number | undefined): Promise<void>
process.kill(pid, 'SIGKILL');
} catch {}
}
// kill(pid, 0) succeeds on the zombie until libuv reaps it on a later loop turn.
const killDeadline = Date.now() + 5_000;
while (Date.now() < killDeadline && isProcessRunning(pid)) {
await sleepAsync(100);
}
}
/**
* Runs every named teardown to completion and logs each failure. The first failure is rethrown
* only when the session body succeeded, so a teardown error cannot replace the error that ended it.
*/
export async function finishRemoteSessionAsync({
teardown,
sessionFailed,
logger,
}: {
teardown: [name: string, task: Promise<unknown> | undefined][];
sessionFailed: boolean;
logger: bunyan;
}): Promise<void> {
const results = await Promise.allSettled(teardown.map(([, task]) => task));
const failures = results.flatMap((result, index) =>
result.status === 'rejected' ? [{ name: teardown[index][0], err: result.reason }] : []
);
for (const { name, err } of failures) {
logger.warn({ err }, `Could not stop the ${name} during remote session teardown.`);
if (sessionFailed) {
// The session error is what the step reports, so a swallowed teardown failure goes to Sentry.
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture(`Could not stop the ${name} after the remote session failed`, error, {
level: 'warning',
});
}
}
if (!sessionFailed && failures.length > 0) {
throw failures[0].err;
}
}
export function spawnDetached({
@@ -580,11 +621,13 @@ export function spawnDetached({
args,
cwd,
env,
stopGracePeriodMs,
}: {
command: string;
args: string[];
cwd?: string;
env: BuildStepEnv;
stopGracePeriodMs?: number;
}): DetachedProcessHandle {
const promise = spawn(command, args, {
cwd,
@@ -592,9 +635,24 @@ export function spawnDetached({
stdio: ['ignore', 'pipe', 'pipe'],
detached: true,
});
// We don't await the process — it should outlive this step. Failures show
// up in the captured output; suppress unhandled rejections here.
promise.catch(() => {});
// Observe completion without rejecting in the background. Startup callers can
// distinguish a dead process from one that is still preparing its state file.
let exitError: Error | undefined;
// The spawn promise waits for stdio to close. Descendants may keep those
// pipes open after the launcher exits, so observe the exit itself as well.
promise.child.once('exit', (code, signal) => {
exitError = new Error(
signal ? `Process exited with signal ${signal}.` : `Process exited with code ${code}.`
);
});
void promise.then(
() => {
exitError ??= new Error('Process exited with code 0.');
},
error => {
exitError ??= error instanceof Error ? error : new Error(String(error));
}
);
promise.child.unref();
let output = '';
@@ -608,103 +666,83 @@ export function spawnDetached({
return {
pid,
getOutput: () => output,
stopAsync: async () => await stopDetachedProcessAsync(pid),
getExitError: () => exitError,
stopAsync: async () => await stopDetachedProcessAsync(pid, stopGracePeriodMs),
};
}
export function metricsCorsOriginToServeSimArgs(env: BuildStepEnv): string[] {
const origin = env.EAS_SIMULATOR_METRICS_CORS_ORIGIN;
if (!origin) {
return [];
}
const args: string[] = [];
for (const value of origin.split(',')) {
const trimmed = value.trim();
if (trimmed) {
args.push('--metrics-cors-origin', trimmed);
}
}
return args;
export interface ServeSimLaunchOptions {
launchAppIdentifier?: string;
launchArgs?: string[];
openUrl?: string;
}
function createServeSimPackageSpec(packageVersion: string | undefined): string {
return `${SERVE_SIM_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
}
function createExpoDeviceHubPackageSpec(packageVersion: string | undefined): string {
return `${EXPO_DEVICE_HUB_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
}
export function createServeSimArgs({
port,
turnArgs = [],
metricsCorsArgs = [],
packageVersion,
}: {
port: number;
turnArgs?: string[];
metricsCorsArgs?: string[];
packageVersion?: string;
}): string[] {
/**
* serve-sim performs the launch so the application starts under its instrumentation.
*/
export function createServeSimLaunchInputProviders(): ReturnType<
typeof BuildStepInput.createProvider
>[] {
return [
'--yes',
createServeSimPackageSpec(packageVersion),
'--port',
String(port),
'--host',
WEB_PREVIEW_HOST,
'--require-token',
'--transport',
'webrtc',
'--webrtc-codec',
'vp8',
'--max-dimension',
SERVE_SIM_MAX_DIMENSION,
'--mjpeg-quality',
SERVE_SIM_MJPEG_QUALITY,
'--video-bitrate',
SERVE_SIM_VIDEO_BITRATE,
'--video-fps',
SERVE_SIM_VIDEO_FPS,
...turnArgs,
...metricsCorsArgs,
BuildStepInput.createProvider({
id: 'launch_app_identifier',
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,
}),
];
}
export function createExpoDeviceHubArgs({
port,
turnArgs = [],
packageVersion,
}: {
port: number;
turnArgs?: string[];
packageVersion?: string;
}): string[] {
return [
'--yes',
createExpoDeviceHubPackageSpec(packageVersion),
'--port',
String(port),
'--host',
WEB_PREVIEW_HOST,
'--platform',
'android',
'--transport',
'webrtc',
'--webrtc-codec',
'h264',
'--webrtc-ice-policy',
'all',
'--max-dimension',
EXPO_DEVICE_HUB_MAX_DIMENSION,
'--video-bitrate',
EXPO_DEVICE_HUB_VIDEO_BITRATE,
'--video-fps',
EXPO_DEVICE_HUB_VIDEO_FPS,
'--hide-sidebar',
'--hide-boot-device',
...turnArgs,
];
export function parseServeSimLaunchInputs(
{
launchAppIdentifier: rawLaunchAppIdentifier,
launchArgs: rawLaunchArgs,
openUrl: rawOpenUrl,
}: { launchAppIdentifier?: unknown; launchArgs?: unknown; openUrl?: unknown },
{ runtimePlatform }: { runtimePlatform: BuildRuntimePlatform }
): ServeSimLaunchOptions {
const launchAppIdentifier =
rawLaunchAppIdentifier === undefined
? undefined
: parseNonEmptyStringInput(rawLaunchAppIdentifier, 'launch_app_identifier');
const launchArgs = parseLaunchArgsInput(rawLaunchArgs);
const openUrl = rawOpenUrl === undefined ? undefined : parseOpenUrlInput(rawOpenUrl);
if (!launchAppIdentifier && (launchArgs.length > 0 || openUrl)) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
'Inputs "launch_args" and "open_url" only work with an application launch. Pass "launch_app_identifier", or remove them.'
);
}
if (launchAppIdentifier && runtimePlatform !== BuildRuntimePlatform.DARWIN) {
throw new UserError(
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
`Input "launch_app_identifier" launches an application on an iOS simulator, and this session runs on ${runtimePlatform}. Run the session on an iOS simulator, or drop the launch inputs.`
);
}
return { launchAppIdentifier, launchArgs, openUrl };
}
export function describeServeSimLaunch({
launchAppIdentifier,
launchArgs = [],
openUrl,
}: ServeSimLaunchOptions): string | null {
if (!launchAppIdentifier) {
return null;
}
const withArguments =
launchArgs.length > 0 ? ` with arguments ${JSON.stringify(launchArgs)}` : '';
const thenOpen = openUrl ? `, then open ${openUrl}` : '';
return `serve-sim will launch ${launchAppIdentifier}${withArguments}${thenOpen}.`;
}
export async function findAvailablePortAsync(): Promise<number> {
@@ -712,7 +750,7 @@ export async function findAvailablePortAsync(): Promise<number> {
server.unref();
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, WEB_PREVIEW_HOST, () => resolve());
server.listen(0, '127.0.0.1', () => resolve());
});
const address = server.address();
await new Promise<void>((resolve, reject) => {
@@ -724,239 +762,16 @@ export async function findAvailablePortAsync(): Promise<number> {
return address.port;
}
const WebPreviewReadyResponseSchema = z.object({
status: z.literal('ready'),
device: z.string(),
});
export async function waitForWebPreviewReadyAsync({
previewServer,
serverName,
port,
timeoutMs,
}: {
previewServer: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
serverName: string;
port: number;
timeoutMs: number;
}): Promise<string> {
const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`;
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
while (Date.now() < deadline) {
if (previewServer.pid !== undefined && !isProcessRunning(previewServer.pid)) {
throw new SystemError(
`${serverName} exited before becoming ready. Last output:\n${
previewServer.getOutput() || '<empty>'
}`
);
}
try {
const response = await turtleFetch(readyUrl, 'GET', {
retries: 0,
timeout: 2_000,
});
const ready = WebPreviewReadyResponseSchema.parse(await response.json());
return ready.device;
} catch (error) {
lastError = error;
}
await sleepAsync(1_000);
}
throw new SystemError(
`Timed out waiting for ${serverName} readiness at ${readyUrl}${
lastError instanceof Error ? `: ${lastError.message}` : ''
}. Last output:\n${previewServer.getOutput() || '<empty>'}`
);
}
export type DeviceWebPreviewHandle = {
previewUrl: string;
/** Session token gating the preview. Only serve-sim mints one. */
previewToken?: string;
stopAsync: () => Promise<void>;
};
export type ServeSimPreviewHandle = DeviceWebPreviewHandle;
async function startWebPreviewWithTunnelAsync(
ctx: CustomBuildContext,
{
baseDomain,
env,
logger,
timeoutMs,
serverName,
packageSpec,
createArgs,
readPreviewTokenAsync,
}: {
baseDomain: string;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
serverName: string;
packageSpec: string;
createArgs: (port: number, turnArgs: string[]) => string[];
readPreviewTokenAsync?: (device: string) => Promise<string>;
}
): Promise<DeviceWebPreviewHandle> {
const port = await findAvailablePortAsync();
logger.info(`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port}.`);
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
const previewServer = spawnDetached({
command: 'npx',
args: createArgs(port, turnArgs),
env,
});
try {
logger.info(`Waiting for ${serverName} to become ready.`);
const device = await waitForWebPreviewReadyAsync({
previewServer,
serverName,
port,
timeoutMs,
});
const previewToken = await readPreviewTokenAsync?.(device);
const tunnel = await startNgrokTunnelAsync({
port,
subdomainPrefix: 'web-preview',
baseDomain,
authtoken: getNgrokAuthtokenOrThrow(env),
logger,
});
return {
previewUrl: tunnel.url,
previewToken,
stopAsync: async () => {
const results = await Promise.allSettled([tunnel.stopAsync(), previewServer.stopAsync()]);
for (const result of results) {
if (result.status === 'rejected') {
logger.warn({ err: result.reason }, `Could not stop a ${serverName} preview resource.`);
}
}
},
};
} catch (error) {
await previewServer.stopAsync();
throw error;
}
}
export async function readServeSimPreviewTokenAsync(
udid: string,
stateDir: string = SERVE_SIM_STATE_DIR
): Promise<string | undefined> {
const servers = await readServeSimServersAsync(stateDir);
return servers.find(server => server.udid === udid)?.token;
}
export async function startServeSimWithTunnelAsync(
ctx: CustomBuildContext,
{
baseDomain,
env,
logger,
timeoutMs,
packageVersion,
}: {
baseDomain: string;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
packageVersion?: string;
}
): Promise<ServeSimPreviewHandle> {
const metricsCorsArgs = metricsCorsOriginToServeSimArgs(env);
return await startWebPreviewWithTunnelAsync(ctx, {
baseDomain,
env,
logger,
timeoutMs,
serverName: 'serve-sim',
packageSpec: createServeSimPackageSpec(packageVersion),
createArgs: (port, turnArgs) =>
createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }),
readPreviewTokenAsync: async device => {
const previewToken = await readServeSimPreviewTokenAsync(device);
if (!previewToken) {
// A serve-sim that does not know --require-token fails earlier, in the readiness check, so
// reaching here means it started and left no token in its state file.
throw new SystemError(
`serve-sim became ready but wrote no session token for device ${device}. The preview is ` +
'on a public tunnel and would be reachable without one, so the session cannot continue. ' +
'This usually means the state file was not written as expected; retry the session, and ' +
'report it if it repeats.'
);
}
return previewToken;
},
});
}
export async function startExpoDeviceHubWithTunnelAsync(
ctx: CustomBuildContext,
{
runtimePlatform,
baseDomain,
env,
logger,
timeoutMs,
packageVersion,
}: {
runtimePlatform: BuildRuntimePlatform;
baseDomain: string;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
packageVersion?: string;
}
): Promise<DeviceWebPreviewHandle> {
if (runtimePlatform === BuildRuntimePlatform.LINUX) {
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
}
return await startWebPreviewWithTunnelAsync(ctx, {
baseDomain,
env,
logger,
timeoutMs,
serverName: 'expo-device-hub',
packageSpec: createExpoDeviceHubPackageSpec(packageVersion),
createArgs: (port, turnArgs) => createExpoDeviceHubArgs({ port, turnArgs, packageVersion }),
});
}
export async function startDeviceWebPreviewWithTunnelAsync(
ctx: CustomBuildContext,
{
runtimePlatform,
...options
}: {
runtimePlatform: BuildRuntimePlatform;
baseDomain: string;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
packageVersion?: string;
}
): Promise<DeviceWebPreviewHandle> {
switch (runtimePlatform) {
case BuildRuntimePlatform.DARWIN:
return await startServeSimWithTunnelAsync(ctx, options);
case BuildRuntimePlatform.LINUX:
return await startExpoDeviceHubWithTunnelAsync(ctx, { ...options, runtimePlatform });
}
}
export type NgrokTunnelHandle = {
url: string;
subdomainId: string;
stopAsync: () => Promise<void>;
};
export async function startNgrokTunnelAsync({
port,
subdomainPrefix,
subdomainId: subdomainIdArg,
baseDomain,
authtoken,
rewriteHostHeader,
@@ -964,12 +779,14 @@ export async function startNgrokTunnelAsync({
}: {
port: number;
subdomainPrefix: string;
subdomainId?: string;
baseDomain: string;
authtoken: string;
rewriteHostHeader?: boolean;
logger: bunyan;
}): Promise<NgrokTunnelHandle> {
const domain = `${subdomainPrefix}-${randomBytes(16).toString('hex')}.${baseDomain}`;
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.
@@ -987,6 +804,7 @@ export async function startNgrokTunnelAsync({
let stopped = false;
return {
url,
subdomainId,
stopAsync: async () => {
if (stopped) {
return;
@@ -0,0 +1,61 @@
import {
type SandboxDaemonCommandParams,
type SandboxDaemonCommandResult,
type SandboxDaemonMethod,
} from '@expo/eas-build-job';
import { performance } from 'node:perf_hooks';
import { ShellSessionManager } from './shellSessionManager';
const DEFAULT_EXEC_YIELD_TIME_MS = 10_000;
const DEFAULT_WRITE_YIELD_TIME_MS = 250;
export type SandboxDaemonCommandImplementations = {
[Method in SandboxDaemonMethod]: (
params: SandboxDaemonCommandParams<Method>
) => Promise<SandboxDaemonCommandResult<Method>>;
};
export function createSandboxCommandImplementations({
workingDirectory,
env,
signal,
}: {
workingDirectory: string;
env: NodeJS.ProcessEnv;
signal: AbortSignal;
}): {
commandImplementations: SandboxDaemonCommandImplementations;
stoppedPromise: Promise<void>;
} {
const sessions = new ShellSessionManager({ workingDirectory, env, signal });
return {
commandImplementations: {
async execCommand(params) {
const callStartedAt = performance.now();
const sessionId = await sessions.startAsync({
cmd: params.cmd,
workdir: params.workdir,
tty: params.tty,
});
const result = await sessions.readAsync(
sessionId,
params.yieldTimeMs ?? DEFAULT_EXEC_YIELD_TIME_MS
);
return { ...result, wallTimeSeconds: (performance.now() - callStartedAt) / 1_000 };
},
async writeStdin(params) {
const callStartedAt = performance.now();
if (params.chars !== undefined) {
sessions.write(params.sessionId, params.chars);
}
const result = await sessions.readAsync(
params.sessionId,
params.yieldTimeMs ?? DEFAULT_WRITE_YIELD_TIME_MS
);
return { ...result, wallTimeSeconds: (performance.now() - callStartedAt) / 1_000 };
},
},
stoppedPromise: sessions.stoppedPromise,
};
}
@@ -1,14 +1,28 @@
import { SystemError } from '@expo/eas-build-job';
import {
type SandboxDaemonCommandResult,
SandboxDaemonCommands,
type SandboxDaemonMethod,
SandboxDaemonRequestZ,
type SandboxDaemonResponse,
SystemError,
} from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import WebSocket from 'ws';
import {
type SandboxDaemonCommandImplementations,
createSandboxCommandImplementations,
} from './sandboxCommandImplementations';
export interface SandboxDaemonOptions {
credential: string;
serverUrl: string;
reconnectDelayMs: number;
logger: bunyan;
signal?: AbortSignal;
workingDirectory: string;
env: NodeJS.ProcessEnv;
}
export interface SandboxDaemon {
@@ -25,6 +39,12 @@ export async function startSandboxDaemonAsync(
let hasConnected = false;
let resolveConnected!: () => void;
let rejectConnected!: (error: Error) => void;
const { commandImplementations, stoppedPromise: commandsStoppedPromise } =
createSandboxCommandImplementations({
workingDirectory: options.workingDirectory,
env: options.env,
signal: abortController.signal,
});
const connected = new Promise<void>((resolve, reject) => {
resolveConnected = resolve;
rejectConnected = reject;
@@ -38,15 +58,39 @@ export async function startSandboxDaemonAsync(
const connectionLoop = (async () => {
while (!abortController.signal.aborted) {
try {
socket = new WebSocket(new URL('/sandbox/connect', options.serverUrl), {
const connectedSocket = new WebSocket(new URL('/sandbox/connect', options.serverUrl), {
handshakeTimeout: 10_000,
headers: { Authorization: `Bearer ${options.credential}` },
});
await waitForOpen(socket);
socket = connectedSocket;
await waitForOpen(connectedSocket);
options.logger.info('Sandbox MCP server connected.');
connectedSocket.on('message', async message => {
try {
const response = await handleMessageAsync(commandImplementations, message.toString());
// MCP fails pending calls on disconnect. Responses belong to this socket only.
if (connectedSocket.readyState !== WebSocket.OPEN) {
options.logger.warn(
'Sandbox command response was lost after disconnect. The command may have run and output may have been consumed.'
);
return;
}
await new Promise<void>((resolve, reject) => {
connectedSocket.send(JSON.stringify(response), error => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
} catch (error) {
options.logger.warn({ err: error }, 'Could not send sandbox command response.');
}
});
hasConnected = true;
resolveConnected();
await waitForClose(socket);
await waitForClose(connectedSocket);
} catch (error: any) {
if (!hasConnected) {
const message = `Sandbox MCP server connection failed: ${error?.message ?? 'unknown error'}`;
@@ -82,6 +126,7 @@ export async function startSandboxDaemonAsync(
async stopAsync(): Promise<void> {
options.signal?.removeEventListener('abort', stop);
stop();
await commandsStoppedPromise;
await connectionLoop;
},
};
@@ -104,3 +149,48 @@ function waitForClose(socket: WebSocket): Promise<void> {
socket.once('error', reject);
});
}
async function handleMessageAsync(
commandImplementations: SandboxDaemonCommandImplementations,
message: string
): Promise<SandboxDaemonResponse> {
let rawRequest: unknown;
try {
rawRequest = JSON.parse(message);
} catch {
return { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } };
}
const request = SandboxDaemonRequestZ.safeParse(rawRequest);
if (!request.success) {
return { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid request' } };
}
const { id, method, params } = request.data;
if (!Object.hasOwn(SandboxDaemonCommands, method)) {
return { jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } };
}
const commandMethod = method as SandboxDaemonMethod;
const parsedParams = SandboxDaemonCommands[commandMethod].params.safeParse(params);
if (!parsedParams.success) {
return { jsonrpc: '2.0', id, error: { code: -32602, message: 'Invalid params' } };
}
try {
return {
jsonrpc: '2.0',
id,
result: await (
commandImplementations[commandMethod] as (
params: typeof parsedParams.data
) => Promise<SandboxDaemonCommandResult<typeof commandMethod>>
)(parsedParams.data),
};
} catch (error) {
return {
jsonrpc: '2.0',
id,
error: { code: -32603, message: error instanceof Error ? error.message : 'Internal error' },
};
}
}
@@ -0,0 +1,293 @@
import * as pty from 'node-pty';
import { type ChildProcess, spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import { constants as osConstants } from 'node:os';
import path from 'node:path';
import { killProcessGroup } from '../../utils/processes';
const PROCESS_STOP_GRACE_PERIOD_MS = 1_000;
const SIGNAL_NAMES = new Map(
Object.entries(osConstants.signals)
.reverse()
.map(([name, number]) => [number, name])
);
export class ShellSessionManager {
// Keep completed sessions so callers can still read their output and exit status.
private readonly sessions = new Map<number, CommandSession>();
private nextSessionId = 1;
public readonly stoppedPromise: Promise<void>;
public constructor(
private readonly options: {
workingDirectory: string;
env: NodeJS.ProcessEnv;
signal: AbortSignal;
}
) {
this.stoppedPromise = (async () => {
if (!options.signal.aborted) {
await new Promise<void>(resolve => {
options.signal.addEventListener('abort', () => resolve(), { once: true });
});
}
await this.stopAsync();
})();
// Cleanup can fail before the owner awaits it.
this.stoppedPromise.catch(() => {});
}
public async startAsync({
cmd,
workdir,
tty,
}: {
cmd: string;
workdir?: string;
tty?: boolean;
}): Promise<number> {
this.throwIfStopped();
const workingDirectory = workdir
? path.resolve(this.options.workingDirectory, workdir)
: this.options.workingDirectory;
await validateWorkingDirectoryAsync(workingDirectory);
this.throwIfStopped();
const sessionId = this.nextSessionId++;
const session = tty
? startPtyCommand({ command: cmd, workingDirectory, env: this.options.env })
: startPipeCommand({ command: cmd, workingDirectory, env: this.options.env });
this.sessions.set(sessionId, session);
return sessionId;
}
public write(sessionId: number, chars: string): void {
this.throwIfStopped();
const session = this.getSession(sessionId);
if (!session.isCompleted && !session.error) {
session.write(chars);
}
}
public async readAsync(
sessionId: number,
yieldTimeMs: number
): Promise<
{ output: string } & (
| { exitCode: number }
| { terminationSignal: string }
| { sessionId: number }
)
> {
this.throwIfStopped();
const session = this.getSession(sessionId);
await waitForSessionsAsync([session], yieldTimeMs);
if (session.error) {
throw session.error;
}
const output = session.output;
session.output = '';
const result = {
output,
};
if (session.terminationSignal !== undefined) {
return { ...result, terminationSignal: session.terminationSignal };
}
if (session.exitCode !== undefined) {
return { ...result, exitCode: session.exitCode };
}
return { ...result, sessionId };
}
private async stopAsync(): Promise<void> {
const sessions = [...this.sessions.values()];
// Only signal groups while their leaders are alive. After a leader exits, its PID may
// be reused. Any surviving descendants are left to VM teardown.
for (const session of sessions) {
if (!session.hasLeaderExited) {
killProcessGroup(session.process, 'SIGTERM');
}
}
await waitForSessionsAsync(sessions, PROCESS_STOP_GRACE_PERIOD_MS);
for (const session of sessions) {
if (!session.hasLeaderExited) {
killProcessGroup(session.process, 'SIGKILL');
}
}
await waitForSessionsAsync(sessions, PROCESS_STOP_GRACE_PERIOD_MS);
this.sessions.clear();
}
private throwIfStopped(): void {
this.options.signal.throwIfAborted();
}
private getSession(sessionId: number): CommandSession {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Command session ${sessionId} does not exist.`);
}
return session;
}
}
type CommandSession = {
process: ChildProcess | pty.IPty;
completed: Promise<void>;
resolveCompleted: () => void;
isCompleted: boolean;
hasLeaderExited: boolean;
// Unread output is intentionally unlimited for now and can exhaust memory. Reading clears it.
output: string;
exitCode?: number;
terminationSignal?: string;
error?: Error;
write(chars: string): void;
};
function startPipeCommand({
command,
workingDirectory,
env,
}: {
command: string;
workingDirectory: string;
env: NodeJS.ProcessEnv;
}): CommandSession {
const child = spawn(command, {
cwd: workingDirectory,
env,
shell: env.SHELL ?? true,
detached: true,
});
const session = createSession(child);
session.write = (chars: string): void => {
child.stdin.write(chars);
};
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', data => {
session.output += data;
});
child.stderr.on('data', data => {
session.output += data;
});
child.stdin.on('error', () => {});
child.once('exit', () => {
session.hasLeaderExited = true;
});
child.once('error', error => {
if (!session.isCompleted) {
session.hasLeaderExited = true;
session.isCompleted = true;
session.error = error;
session.resolveCompleted();
}
});
child.once('close', (exitCode, signal) => {
if (!session.isCompleted) {
session.isCompleted = true;
if (signal) {
session.terminationSignal = signal;
} else {
session.exitCode = exitCode ?? 1;
}
session.resolveCompleted();
}
});
return session;
}
function startPtyCommand({
command,
workingDirectory,
env,
}: {
command: string;
workingDirectory: string;
env: NodeJS.ProcessEnv;
}): CommandSession {
const shell = env.SHELL ?? '/bin/sh';
const terminal = pty.spawn(shell, ['-c', command], {
cwd: workingDirectory,
env,
name: 'xterm-256color',
cols: 80,
rows: 24,
});
const session = createSession(terminal);
session.write = (chars: string): void => {
terminal.write(chars);
};
terminal.onData(data => {
session.output += data;
});
terminal.onExit(({ exitCode, signal }) => {
session.hasLeaderExited = true;
session.isCompleted = true;
if (signal) {
session.terminationSignal = SIGNAL_NAMES.get(signal) ?? `SIGNAL_${signal}`;
} else {
session.exitCode = exitCode;
}
session.resolveCompleted();
});
return session;
}
function createSession(process: CommandSession['process']): CommandSession {
let resolveCompleted!: () => void;
const completed = new Promise<void>(resolve => {
resolveCompleted = resolve;
});
return {
process,
completed,
resolveCompleted,
isCompleted: false,
hasLeaderExited: false,
output: '',
write: () => {},
};
}
async function validateWorkingDirectoryAsync(workingDirectory: string): Promise<void> {
let stats;
try {
stats = await fs.stat(workingDirectory);
} catch (error: any) {
if (error?.code === 'ENOENT') {
throw new Error(`Working directory does not exist: ${workingDirectory}`);
}
if (error?.code === 'EACCES') {
throw new Error(`Working directory is not accessible: ${workingDirectory}`);
}
throw error;
}
if (!stats.isDirectory()) {
throw new Error(`Working directory is not a directory: ${workingDirectory}`);
}
try {
await fs.access(workingDirectory, fs.constants.R_OK | fs.constants.X_OK);
} catch (error: any) {
if (error?.code === 'EACCES') {
throw new Error(`Working directory is not accessible: ${workingDirectory}`);
}
throw error;
}
}
async function waitForSessionsAsync(sessions: CommandSession[], timeoutMs: number): Promise<void> {
let timer: NodeJS.Timeout | undefined;
try {
await Promise.race([
Promise.all(sessions.map(session => session.completed)),
new Promise<void>(resolve => {
timer = setTimeout(resolve, timeoutMs);
}),
]);
} finally {
clearTimeout(timer);
}
}
@@ -0,0 +1,30 @@
import { graphqlAbortContext } from '../graphqlAbort';
const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
});
it('returns nothing without a signal', () => {
expect(graphqlAbortContext(undefined)).toBeUndefined();
});
it('aborts the request when either the caller or urql aborts', async () => {
const seen: AbortSignal[] = [];
globalThis.fetch = jest.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
seen.push(init!.signal!);
return new Response('{}');
}) as typeof fetch;
const caller = new AbortController();
const urql = new AbortController();
const context = graphqlAbortContext(caller.signal)!;
await context.fetch!('https://api.test/graphql', { method: 'POST', signal: urql.signal });
await context.fetch!('https://api.test/graphql', { method: 'POST' });
expect(seen).toHaveLength(2);
expect(seen.every(signal => !signal.aborted)).toBe(true);
urql.abort(new Error('urql gave up'));
expect(seen[0].aborted).toBe(true);
expect(seen[1].aborted).toBe(false);
caller.abort(new Error('caller gave up'));
expect(seen[1].aborted).toBe(true);
});
@@ -9,6 +9,12 @@ import {
PackageManager,
findPackagerRootDir,
getPackageVersionFromPackageJson,
resolveConfiguredPackageManager,
resolveFallbackPackageManager,
resolveOverridePackageManager,
resolvePackageAdd,
resolvePackageExec,
resolvePackageInstall,
resolvePackageManager,
resolvePackageVersionAsync,
shouldUseFrozenLockfile,
@@ -117,6 +123,176 @@ describe(resolvePackageManager, () => {
});
});
describe(resolveFallbackPackageManager, () => {
it('returns undefined when unset or empty', () => {
expect(resolveFallbackPackageManager({})).toBeUndefined();
expect(resolveFallbackPackageManager({ EAS_FALLBACK_PACKAGE_MANAGER: '' })).toBeUndefined();
});
it('returns bun when set', () => {
expect(resolveFallbackPackageManager({ EAS_FALLBACK_PACKAGE_MANAGER: 'bun' })).toBe(
PackageManager.BUN
);
});
it('throws a UserError on an unsupported value', () => {
expect(() => resolveFallbackPackageManager({ EAS_FALLBACK_PACKAGE_MANAGER: 'bunn' })).toThrow(
errors.UserError
);
});
});
describe(resolveOverridePackageManager, () => {
it('returns undefined when unset or empty', () => {
expect(resolveOverridePackageManager({})).toBeUndefined();
expect(resolveOverridePackageManager({ EAS_OVERRIDE_PACKAGE_MANAGER: '' })).toBeUndefined();
});
it('returns bun when set', () => {
expect(resolveOverridePackageManager({ EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' })).toBe(
PackageManager.BUN
);
});
it('throws a UserError on an unsupported value', () => {
expect(() => resolveOverridePackageManager({ EAS_OVERRIDE_PACKAGE_MANAGER: 'bunn' })).toThrow(
errors.UserError
);
});
});
describe(resolveConfiguredPackageManager, () => {
it('uses the unset default', () => {
expect(resolveConfiguredPackageManager({}, PackageManager.NPM)).toBe(PackageManager.NPM);
});
it('uses EAS_FALLBACK_PACKAGE_MANAGER when no override is set', () => {
expect(
resolveConfiguredPackageManager({ EAS_FALLBACK_PACKAGE_MANAGER: 'bun' }, PackageManager.NPM)
).toBe(PackageManager.BUN);
});
it('prefers EAS_OVERRIDE_PACKAGE_MANAGER over the fallback', () => {
expect(
resolveConfiguredPackageManager(
{
EAS_OVERRIDE_PACKAGE_MANAGER: 'npm',
EAS_FALLBACK_PACKAGE_MANAGER: 'bun',
},
PackageManager.BUN
)
).toBe(PackageManager.NPM);
});
});
describe(resolvePackageExec, () => {
const packageArgs = ['@expo/serve-sim@latest', '--port', '1'];
it('maps npm to npx --yes', () => {
expect(resolvePackageExec(PackageManager.NPM, packageArgs)).toEqual({
command: 'npx',
args: ['--yes', ...packageArgs],
});
});
it('maps bun to bun x', () => {
expect(resolvePackageExec(PackageManager.BUN, packageArgs)).toEqual({
command: 'bun',
args: ['x', ...packageArgs],
});
});
it('maps yarn to npx --yes', () => {
expect(resolvePackageExec(PackageManager.YARN, packageArgs)).toEqual({
command: 'npx',
args: ['--yes', ...packageArgs],
});
});
it('maps pnpm to pnpm dlx', () => {
expect(resolvePackageExec(PackageManager.PNPM, packageArgs)).toEqual({
command: 'pnpm',
args: ['dlx', ...packageArgs],
});
});
});
describe(resolvePackageAdd, () => {
it('maps npm to npm install --no-audit', () => {
expect(resolvePackageAdd(PackageManager.NPM, 'appium@^3')).toEqual({
command: 'npm',
args: ['install', '--no-audit', 'appium@^3'],
});
});
it('maps bun to bun add', () => {
expect(resolvePackageAdd(PackageManager.BUN, 'agent-device@latest')).toEqual({
command: 'bun',
args: ['add', 'agent-device@latest'],
});
});
it('maps yarn to yarn add', () => {
expect(resolvePackageAdd(PackageManager.YARN, 'appium@^3')).toEqual({
command: 'yarn',
args: ['add', 'appium@^3'],
});
});
it('maps pnpm to pnpm add', () => {
expect(resolvePackageAdd(PackageManager.PNPM, 'appium@^3')).toEqual({
command: 'pnpm',
args: ['add', 'appium@^3'],
});
});
});
describe(resolvePackageInstall, () => {
it('maps bun production install', () => {
expect(resolvePackageInstall(PackageManager.BUN, { production: true })).toEqual({
command: 'bun',
args: ['install', '--production'],
});
});
it('maps npm production install without audit', () => {
expect(resolvePackageInstall(PackageManager.NPM, { production: true })).toEqual({
command: 'npm',
args: ['install', '--no-audit', '--omit=dev'],
});
});
it('maps yarn and pnpm production installs', () => {
expect(resolvePackageInstall(PackageManager.YARN, { production: true })).toEqual({
command: 'yarn',
args: ['install', '--production'],
});
expect(resolvePackageInstall(PackageManager.PNPM, { production: true })).toEqual({
command: 'pnpm',
args: ['install', '--prod'],
});
});
it('maps non-production installs', () => {
expect(resolvePackageInstall(PackageManager.NPM)).toEqual({
command: 'npm',
args: ['install', '--no-audit'],
});
expect(resolvePackageInstall(PackageManager.BUN)).toEqual({
command: 'bun',
args: ['install'],
});
expect(resolvePackageInstall(PackageManager.YARN)).toEqual({
command: 'yarn',
args: ['install'],
});
expect(resolvePackageInstall(PackageManager.PNPM)).toEqual({
command: 'pnpm',
args: ['install'],
});
});
});
describe(resolvePackageVersionAsync, () => {
const logger = {
debug: jest.fn(),
@@ -0,0 +1,21 @@
import type { OperationContext } from '@urql/core';
/**
* urql replaces `fetchOptions.signal` with its own, so a caller's signal has to be joined into
* fetch after urql has resolved auth and its signal. Returns nothing without a signal, so the
* client's default fetch stays in use.
*/
export function graphqlAbortContext(
signal: AbortSignal | undefined
): Pick<OperationContext, 'fetch'> | undefined {
if (!signal) {
return undefined;
}
return {
fetch: (input, init) =>
fetch(input, {
...init,
signal: init?.signal ? AbortSignal.any([init.signal, signal]) : signal,
}),
};
}
@@ -30,19 +30,100 @@ export function resolvePackageManager(
}
} catch {}
const fallback = env.EAS_FALLBACK_PACKAGE_MANAGER;
if (fallback) {
const parsed = z.enum(PackageManager).safeParse(fallback);
if (parsed.success) {
return parsed.data;
}
const allowed = Object.values(PackageManager).join(', ');
throw new errors.UserError(
'EAS_INVALID_FALLBACK_PACKAGE_MANAGER',
`Invalid EAS_FALLBACK_PACKAGE_MANAGER value "${fallback}" (expected one of: ${allowed}).`
);
return resolveFallbackPackageManager(env) ?? PackageManager.YARN;
}
function parsePackageManagerEnvValue(
value: string | undefined,
envName: string,
errorCode: string
): PackageManager | undefined {
if (!value) {
return undefined;
}
const parsed = z.enum(PackageManager).safeParse(value);
if (parsed.success) {
return parsed.data;
}
const allowed = Object.values(PackageManager).join(', ');
throw new errors.UserError(
errorCode,
`Invalid ${envName} value "${value}" (expected one of: ${allowed}).`
);
}
export function resolveFallbackPackageManager(env: BuildStepEnv): PackageManager | undefined {
return parsePackageManagerEnvValue(
env.EAS_FALLBACK_PACKAGE_MANAGER,
'EAS_FALLBACK_PACKAGE_MANAGER',
'EAS_INVALID_FALLBACK_PACKAGE_MANAGER'
);
}
export function resolveOverridePackageManager(env: BuildStepEnv): PackageManager | undefined {
return parsePackageManagerEnvValue(
env.EAS_OVERRIDE_PACKAGE_MANAGER,
'EAS_OVERRIDE_PACKAGE_MANAGER',
'EAS_INVALID_OVERRIDE_PACKAGE_MANAGER'
);
}
export function resolveConfiguredPackageManager(
env: BuildStepEnv,
whenUnset: PackageManager
): PackageManager {
return resolveOverridePackageManager(env) ?? resolveFallbackPackageManager(env) ?? whenUnset;
}
export function resolvePackageExec(
manager: PackageManager,
args: string[]
): { command: string; args: string[] } {
switch (manager) {
case PackageManager.NPM:
return { command: 'npx', args: ['--yes', ...args] };
case PackageManager.BUN:
return { command: 'bun', args: ['x', ...args] };
case PackageManager.YARN:
return { command: 'npx', args: ['--yes', ...args] };
case PackageManager.PNPM:
return { command: 'pnpm', args: ['dlx', ...args] };
}
}
export function resolvePackageAdd(
manager: PackageManager,
packageSpec: string
): { command: string; args: string[] } {
switch (manager) {
case PackageManager.NPM:
return { command: 'npm', args: ['install', '--no-audit', packageSpec] };
case PackageManager.BUN:
return { command: 'bun', args: ['add', packageSpec] };
case PackageManager.YARN:
return { command: 'yarn', args: ['add', packageSpec] };
case PackageManager.PNPM:
return { command: 'pnpm', args: ['add', packageSpec] };
}
}
export function resolvePackageInstall(
manager: PackageManager,
{ production }: { production?: boolean } = {}
): { command: string; args: string[] } {
switch (manager) {
case PackageManager.NPM:
return {
command: 'npm',
args: production ? ['install', '--no-audit', '--omit=dev'] : ['install', '--no-audit'],
};
case PackageManager.BUN:
return { command: 'bun', args: production ? ['install', '--production'] : ['install'] };
case PackageManager.YARN:
return { command: 'yarn', args: production ? ['install', '--production'] : ['install'] };
case PackageManager.PNPM:
return { command: 'pnpm', args: production ? ['install', '--prod'] : ['install'] };
}
return PackageManager.YARN;
}
/**
+10 -4
View File
@@ -10,14 +10,20 @@ export function isChildProcessAlive(child: ChildProcess): boolean {
* Kill a detached spawn's process group. Negated pid targets the group so bash/sleep
* children cannot survive after the parent is gone (e.g. across upterm redial).
*/
export function killProcessGroup(child: ChildProcess): void {
export function killProcessGroup(
child: { pid?: number; kill(signal?: NodeJS.Signals): void },
signal: NodeJS.Signals = 'SIGTERM'
): void {
if (child.pid == null) {
return;
}
try {
process.kill(-child.pid, 'SIGTERM');
} catch {
child.kill();
process.kill(-child.pid, signal);
} catch (error: any) {
// ESRCH means the process group no longer exists, so there is nothing left to stop.
if (error?.code !== 'ESRCH') {
child.kill(signal);
}
}
}
+1
View File
@@ -1,6 +1,7 @@
{
"compilerOptions": {
"moduleResolution": "node",
"ignoreDeprecations": "6.0",
"target": "es2022",
"module": "commonjs",
"strict": true,
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "24.3.0",
"version": "24.8.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,13 +35,13 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "24.3.0"
"@expo/steps": "24.8.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
"@jest/globals": "^29.7.0",
"@types/jest": "^29.5.12",
"@types/node": "20.14.2",
"@types/node": "20.19.43",
"@types/prompts": "^2.4.9",
"@vercel/ncc": "^0.38.1",
"arg": "^5.0.2",
@@ -52,7 +52,7 @@
"prompts": "^2.4.2",
"rimraf": "3.0.2",
"ts-node": "^10.9.2",
"typescript": "^5.5.4",
"typescript": "^6.0.3",
"update-check": "^1.5.4"
}
}
@@ -11,8 +11,8 @@
},
"devDependencies": {
"@expo/steps": "^1.0.119",
"@types/node": "20.14.11",
"@types/node": "20.19.43",
"@vercel/ncc": "^0.38.1",
"typescript": "^5.5.3"
"typescript": "^6.0.3"
}
}
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"module": "node16",
"sourceMap": true,
"inlineSources": true,
"strict": true,
@@ -16,7 +16,7 @@
"outDir": "build",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"moduleResolution": "node",
"moduleResolution": "node16",
"rootDir": "src",
"declaration": false,
"composite": false
@@ -2,6 +2,7 @@
"include": ["src/**/*.ts"],
"compilerOptions": {
"moduleResolution": "node",
"ignoreDeprecations": "6.0",
"target": "es2022",
"module": "commonjs",
"strict": true,
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "24.0.0",
"version": "24.8.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -31,10 +31,10 @@
"devDependencies": {
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12",
"@types/node": "20.14.2",
"@types/node": "20.19.43",
"jest": "^29.7.0",
"rimraf": "3.0.2",
"ts-jest": "^29.1.4",
"typescript": "^5.5.4"
"ts-jest": "^29.4.12",
"typescript": "^6.0.3"
}
}
+2
View File
@@ -1,6 +1,7 @@
{
"compilerOptions": {
"moduleResolution": "node",
"ignoreDeprecations": "6.0",
"target": "es2022",
"module": "commonjs",
"strict": true,
@@ -8,6 +9,7 @@
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"types": ["node", "jest"],
"composite": true,
"rootDir": "./src",
"outDir": "./dist"
@@ -1,6 +1,6 @@
{
"name": "eas-build-cache-provider",
"version": "24.0.0",
"version": "24.8.0",
"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",
@@ -51,7 +51,7 @@
"jest": "29.7.0",
"memfs": "3.4.13",
"rimraf": "3.0.2",
"typescript": "5.5.4"
"typescript": "6.0.3"
},
"engines": {
"node": ">=20.0.0"
@@ -5,6 +5,7 @@
"importHelpers": true,
"module": "commonjs",
"moduleResolution": "node",
"ignoreDeprecations": "6.0",
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "24.3.0",
"version": "24.8.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -25,19 +25,19 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "24.0.0",
"@expo/logger": "24.8.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "24.0.0",
"@expo/turtle-spawn": "24.8.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
},
"devDependencies": {
"@types/jest": "^29.5.12",
"@types/node": "20.14.2",
"@types/node": "20.19.43",
"jest": "^29.7.0",
"rimraf": "3.0.2",
"ts-jest": "^29.1.4",
"typescript": "^5.5.4"
"ts-jest": "^29.4.12",
"typescript": "^6.0.3"
}
}
+5 -2
View File
@@ -75,8 +75,11 @@ export const SandboxDaemonResponseZ = z.union([
z.strictObject({ jsonrpc: z.literal('2.0'), id: z.string(), result: z.unknown().nonoptional() }),
z.strictObject({
jsonrpc: z.literal('2.0'),
id: z.string(),
error: z.strictObject({ code: z.number().int(), message: z.string() }),
id: z.string().nullable(),
error: z.strictObject({
code: z.number().int(),
message: z.string(),
}),
}),
]);
+2
View File
@@ -1,6 +1,7 @@
{
"compilerOptions": {
"moduleResolution": "node",
"ignoreDeprecations": "6.0",
"target": "es2022",
"module": "commonjs",
"strict": true,
@@ -8,6 +9,7 @@
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"types": ["node", "jest"],
"composite": true,
"outDir": "./dist",
"rootDir": "./src"
+455 -246
View File
File diff suppressed because it is too large. Load diff
+1510 -39
View File
File diff suppressed because it is too large. Load diff
+8 -7
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "24.4.2",
"version": "24.8.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -48,11 +48,11 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "24.3.0",
"@expo/eas-json": "24.3.0",
"@expo/eas-build-job": "24.8.0",
"@expo/eas-json": "24.8.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "24.0.0",
"@expo/logger": "24.8.0",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -64,7 +64,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "24.3.0",
"@expo/steps": "24.8.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -95,7 +95,7 @@
"gradle-to-js": "2.0.1",
"graphql": "16.8.1",
"graphql-tag": "2.12.6",
"https-proxy-agent": "5.0.1",
"https-proxy-agent": "7.0.6",
"ignore": "5.3.0",
"indent-string": "4.0.0",
"invariant": "^2.2.2",
@@ -152,6 +152,7 @@
"@types/getenv": "^1.0.0",
"@types/invariant": "^2.2.37",
"@types/mime": "3.0.1",
"@types/node": "20.19.43",
"@types/node-fetch": "2.6.12",
"@types/node-forge": "1.3.1",
"@types/pngjs": "6.0.4",
@@ -172,7 +173,7 @@
"rimraf": "3.0.2",
"ts-mockito": "2.6.1",
"ts-node": "10.9.2",
"typescript": "5.5.4"
"typescript": "6.0.3"
},
"oclif": {
"additionalHelpFlags": [
@@ -3,7 +3,7 @@ const path = require('path');
const LLM_CONFIGS_EXPO_APP_TEMPLATE_BASE_URL =
'https://raw.githubusercontent.com/expo/llm-configs/main/expo-app';
const AGENT_TEMPLATE_FILE_NAMES = ['AGENTS.md', 'CLAUDE.md'];
const AGENT_TEMPLATE_FILE_NAMES = ['AGENTS.md'];
async function fetchTemplateAsync(fileName) {
const url = `${LLM_CONFIGS_EXPO_APP_TEMPLATE_BASE_URL}/${fileName}`;
@@ -11,8 +11,8 @@ import { detectProjectSdkVersionAsync } from '../../project/detectProjectSdkVers
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
import { uploadAccountScopedFileAsync } from '../../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../../project/uploadAccountScopedProjectSourceAsync';
import { ensureActorHasPrimaryAccount } from '../../user/actions';
import { mockTestCommand } from './utils';
import { UserQuery } from '../../graphql/queries/UserQuery';
jest.mock('@expo/config', () => ({
...jest.requireActual('@expo/config'),
@@ -45,8 +45,8 @@ jest.mock('fs-extra', () => ({
writeFile: jest.fn().mockResolvedValue(undefined),
remove: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../../user/actions');
jest.mock('../../graphql/queries/WorkflowRunQuery');
jest.mock('../../graphql/queries/UserQuery');
jest.mock('../../graphql/mutations/WorkflowRunMutation');
jest.mock('../../project/uploadAccountScopedFileAsync');
jest.mock('../../project/uploadAccountScopedProjectSourceAsync');
@@ -110,7 +110,9 @@ const mockActor = {
describe('Go command', () => {
beforeEach(() => {
jest.mocked(ensureActorHasPrimaryAccount).mockReturnValue(mockAccount as any);
jest
.mocked(UserQuery.requireCurrentUserPrimaryAccountAsync)
.mockReturnValue(mockAccount as any);
jest.mocked(WorkflowRunQuery.expoGoRepackConfigurationAsync).mockResolvedValue({
files: [],
sdkVersion: '55.0.0',
@@ -8,7 +8,7 @@ import {
mockProjectId,
mockTestCommand,
} from './utils';
import { fetchRawLogsForJobAsync } from '../../commandUtils/workflow/logs/fetchLogs';
import { fetchRawLogsForJobAsync } from '../../commandUtils/workflow/fetchLogs';
import WorkflowLogView from '../../commands/workflow/logs';
import { AppPlatform, BuildPriority, BuildStatus } from '../../graphql/generated';
import { AppQuery } from '../../graphql/queries/AppQuery';
@@ -35,7 +35,7 @@ jest.mock('fs');
jest.mock('../../log');
jest.mock('../../prompts');
jest.mock('../../utils/json');
jest.mock('../../commandUtils/workflow/logs/fetchLogs');
jest.mock('../../commandUtils/workflow/fetchLogs');
describe(WorkflowLogView, () => {
beforeEach(() => {
@@ -0,0 +1,71 @@
import { EventEmitter } from 'events';
import type { ora as oraType } from '../ora';
jest.unmock('ora');
class FakeTTYStream extends EventEmitter {
isTTY = true;
columns = 200;
clearLineCalls = 0;
write(): boolean {
return true;
}
clearLine(): boolean {
this.clearLineCalls++;
return true;
}
cursorTo(): boolean {
return true;
}
moveCursor(): boolean {
return true;
}
}
describe('ora', () => {
const originalEnv = process.env;
const originalIsTTY = process.stdin.isTTY;
let ora: typeof oraType;
beforeEach(() => {
jest.useFakeTimers();
process.env = { ...originalEnv };
delete process.env.CI;
delete process.env.EXPO_DEBUG;
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true });
jest.isolateModules(() => {
ora = require('../ora').ora;
});
});
afterEach(() => {
jest.useRealTimers();
process.env = originalEnv;
Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, writable: true });
});
it('clears every wrapped line after the terminal gets narrower', () => {
const stream = new FakeTTYStream();
const spinner = ora({
text: 'x'.repeat(150),
stream: stream as unknown as NodeJS.WritableStream,
discardStdin: false,
}).start();
stream.columns = 80;
stream.emit('resize');
spinner.render();
stream.clearLineCalls = 0;
spinner.render();
expect(stream.clearLineCalls).toBe(2);
spinner.stop();
expect(stream.listenerCount('resize')).toBe(0);
});
});
@@ -0,0 +1,233 @@
import { BuildPhase } from '@expo/eas-build-job';
import { v4 as uuid } from 'uuid';
import { groupLogLinesIntoSteps } from '../../commandUtils/logs/parseLogs';
import { JobLogs, RawLogLine } from '../../commandUtils/logs/types';
import fetch from '../../fetch';
import {
AppPlatform,
BuildFragment,
BuildPriority,
BuildStatus,
RealtimeLogsTargetType,
} from '../../graphql/generated';
import {
formatActiveBuildText,
formatActiveBuildsText,
isBuildCompleted,
logSourceForBuild,
} from '../logs';
jest.mock('../../fetch');
function createBuildFragment(overrides: Partial<BuildFragment> = {}): BuildFragment {
return {
id: 'build-id',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
platform: AppPlatform.Android,
logFiles: [],
priority: BuildPriority.Normal,
app: {
__typename: 'App',
slug: 'test-project',
id: uuid(),
name: 'test-project',
ownerAccount: {
__typename: 'Account',
id: uuid(),
name: 'test-account',
},
},
status: BuildStatus.InProgress,
isForIosSimulator: false,
...overrides,
};
}
function phaseLines(phase: string, count: number): RawLogLine[] {
return Array.from({ length: count }, (_, index) => ({ phase, msg: `line${index}` }));
}
function logsForPhase(phase: string, count: number): JobLogs {
return groupLogLinesIntoSteps(phaseLines(phase, count));
}
describe(logSourceForBuild, () => {
it('keys the source by the build id', () => {
expect(logSourceForBuild(createBuildFragment({ id: 'some-build-id' })).key).toBe(
'some-build-id'
);
});
it('targets the build itself for realtime logs', () => {
expect(logSourceForBuild(createBuildFragment({ id: 'some-build-id' })).realtimeTarget).toEqual({
type: RealtimeLogsTargetType.Build,
id: 'some-build-id',
});
});
it('is in progress only while the build is in progress', () => {
expect(
logSourceForBuild(createBuildFragment({ status: BuildStatus.InProgress })).isInProgress
).toBe(true);
for (const status of [
BuildStatus.New,
BuildStatus.InQueue,
BuildStatus.PendingCancel,
BuildStatus.Canceled,
BuildStatus.Errored,
BuildStatus.Finished,
]) {
expect(logSourceForBuild(createBuildFragment({ status })).isInProgress).toBe(false);
}
});
describe('fetchRawLogLinesAsync', () => {
afterEach(() => {
jest.mocked(fetch).mockReset();
});
it('parses the first log file of the build', async () => {
jest.mocked(fetch).mockResolvedValueOnce({
text: async () =>
[
'{"logId":"1","phase":"INSTALL_DEPENDENCIES","msg":"npm ci"}',
'{"logId":"2","phase":"INSTALL_DEPENDENCIES","msg":"done"}',
].join('\n'),
} as any);
const source = logSourceForBuild(
createBuildFragment({ logFiles: ['https://logs.test/first', 'https://logs.test/second'] })
);
await expect(source.fetchRawLogLinesAsync()).resolves.toEqual([
{ logId: '1', phase: 'INSTALL_DEPENDENCIES', msg: 'npm ci' },
{ logId: '2', phase: 'INSTALL_DEPENDENCIES', msg: 'done' },
]);
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith('https://logs.test/first', {
timeout: expect.any(Number),
});
});
it('returns null when the build has no log file', async () => {
await expect(
logSourceForBuild(createBuildFragment({ logFiles: [] })).fetchRawLogLinesAsync()
).resolves.toBeNull();
expect(fetch).not.toHaveBeenCalled();
});
});
});
describe(isBuildCompleted, () => {
it('reports FINISHED as completed', () => {
expect(isBuildCompleted(BuildStatus.Finished)).toBe(true);
});
it('reports ERRORED as completed', () => {
expect(isBuildCompleted(BuildStatus.Errored)).toBe(true);
});
it('reports CANCELED as completed', () => {
expect(isBuildCompleted(BuildStatus.Canceled)).toBe(true);
});
it('reports NEW as not completed', () => {
expect(isBuildCompleted(BuildStatus.New)).toBe(false);
});
it('reports IN_QUEUE as not completed', () => {
expect(isBuildCompleted(BuildStatus.InQueue)).toBe(false);
});
it('reports IN_PROGRESS as not completed', () => {
expect(isBuildCompleted(BuildStatus.InProgress)).toBe(false);
});
it('reports PENDING_CANCEL as not completed', () => {
expect(isBuildCompleted(BuildStatus.PendingCancel)).toBe(false);
});
});
describe(formatActiveBuildText, () => {
it('returns the base text alone when there are no logs', () => {
expect(formatActiveBuildText('Build in progress...', new Map())).toBe('Build in progress...');
});
it('shows the display name of a raw build phase', () => {
const output = formatActiveBuildText(
'Build in progress...',
logsForPhase(BuildPhase.INSTALL_DEPENDENCIES, 1)
);
expect(output).toContain('Current phase');
expect(output).toContain('Install dependencies');
expect(output).not.toContain('INSTALL_DEPENDENCIES');
});
it('passes a build step display name through untouched', () => {
const output = formatActiveBuildText(
'Build in progress...',
groupLogLinesIntoSteps([
{ buildStepId: 'step-id-1', buildStepDisplayName: 'Run fastlane build', msg: 'line0' },
])
);
expect(output).toContain('Run fastlane build');
expect(output).not.toContain('step-id-1');
});
it('keeps the base text and five trailing log lines', () => {
const output = formatActiveBuildText(
'Build in progress...',
logsForPhase(BuildPhase.RUN_GRADLEW, 10)
);
expect(output.startsWith('Build in progress...\n')).toBe(true);
expect(output).toContain('line5');
expect(output).toContain('line9');
expect(output).not.toContain('line4');
});
});
describe(formatActiveBuildsText, () => {
it('returns the base text alone when no build has logs', () => {
expect(
formatActiveBuildsText('Waiting for builds to complete.', [
{ build: createBuildFragment(), logs: new Map() },
])
).toBe('Waiting for builds to complete.');
});
it('labels each build with its platform emoji and display name', () => {
const output = formatActiveBuildsText('Waiting for builds to complete.', [
{
build: createBuildFragment({ id: 'android-build', platform: AppPlatform.Android }),
logs: logsForPhase(BuildPhase.RUN_GRADLEW, 1),
},
{
build: createBuildFragment({ id: 'ios-build', platform: AppPlatform.Ios }),
logs: logsForPhase(BuildPhase.RUN_FASTLANE, 1),
},
]);
expect(output).toContain('🤖 Android');
expect(output).toContain('Run gradlew');
expect(output).toContain('🍏 iOS');
expect(output).toContain('Run fastlane');
});
it('keeps three trailing log lines per build', () => {
const output = formatActiveBuildsText('Waiting for builds to complete.', [
{
build: createBuildFragment({ id: 'android-build', platform: AppPlatform.Android }),
logs: logsForPhase(BuildPhase.RUN_GRADLEW, 10),
},
]);
expect(output).toContain('line7');
expect(output).toContain('line9');
expect(output).not.toContain('line6');
});
});
@@ -0,0 +1,163 @@
import { BuildPhase } from '@expo/eas-build-job';
import { v4 as uuid } from 'uuid';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { LogsState } from '../../commandUtils/logs/state';
import { groupLogLinesIntoSteps } from '../../commandUtils/logs/parseLogs';
import { JobLogs } from '../../commandUtils/logs/types';
import { LogsWatcher } from '../../commandUtils/logs/watcher';
import {
AppPlatform,
BuildFragment,
BuildPriority,
BuildStatus,
} from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import { Ora, isSpinnerEnabled, ora } from '../../ora';
import { createRealtimeLogsClient } from '../../utils/centrifuge';
import { sleepAsync } from '../../utils/promise';
import { waitForBuildEndAsync } from '../build';
jest.mock('../../ora', () => ({
...jest.requireActual('../../ora'),
ora: jest.fn(),
isSpinnerEnabled: jest.fn(),
}));
jest.mock('../../commandUtils/logs/watcher', () => ({
LogsWatcher: jest.fn(),
}));
jest.mock('../../utils/centrifuge', () => ({
createRealtimeLogsClient: jest.fn(),
}));
jest.mock('../../graphql/queries/BuildQuery', () => ({
BuildQuery: {
byIdAsync: jest.fn(),
},
}));
jest.mock('../../utils/promise', () => ({
...jest.requireActual('../../utils/promise'),
sleepAsync: jest.fn(),
}));
const graphqlClient = {} as unknown as ExpoGraphqlClient;
function createBuildFragment(status: BuildStatus): BuildFragment {
return {
id: 'build-id',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
platform: AppPlatform.Android,
logFiles: [],
priority: BuildPriority.Normal,
app: {
__typename: 'App',
slug: 'test-project',
id: uuid(),
name: 'test-project',
ownerAccount: {
__typename: 'Account',
id: uuid(),
name: 'test-account',
},
},
status,
isForIosSimulator: false,
};
}
function createSpinner(): Ora {
const spinner: Record<string, unknown> = {
text: '',
prefixText: '',
isSpinning: true,
start: jest.fn(() => spinner),
stop: jest.fn(() => spinner),
stopAndPersist: jest.fn(() => spinner),
succeed: jest.fn(() => spinner),
fail: jest.fn(() => spinner),
warn: jest.fn(() => spinner),
};
return spinner as unknown as Ora;
}
function setBuildStatuses(statuses: BuildStatus[]): void {
const mocked = jest.mocked(BuildQuery.byIdAsync);
for (const status of statuses) {
mocked.mockResolvedValueOnce(createBuildFragment(status));
}
}
describe(waitForBuildEndAsync, () => {
let spinner: Ora;
let publish: () => void;
let watcherClose: jest.Mock;
let logs: JobLogs;
beforeEach(() => {
jest.clearAllMocks();
spinner = createSpinner();
logs = new Map();
publish = () => {};
watcherClose = jest.fn();
jest.mocked(ora).mockReturnValue(spinner);
jest.mocked(LogsWatcher).mockImplementation(((
_createRealtimeLogsClient: unknown,
onRealtimeLogs: () => void
) => {
publish = onRealtimeLogs;
return {
syncAsync: jest.fn(async () =>
new Map<string, LogsState>([
['build-id', { getLogs: () => logs, markCompleted: jest.fn() } as unknown as LogsState],
])
),
close: watcherClose,
};
}) as unknown as () => LogsWatcher);
jest.mocked(sleepAsync).mockImplementation(async () => {
publish();
});
});
it('does not create a watcher when the spinner is disabled', async () => {
jest.mocked(isSpinnerEnabled).mockReturnValue(false);
setBuildStatuses([BuildStatus.Finished]);
await waitForBuildEndAsync(graphqlClient, { buildIds: ['build-id'], accountName: 'acct' });
expect(LogsWatcher).not.toHaveBeenCalled();
expect(createRealtimeLogsClient).not.toHaveBeenCalled();
});
it('renders the current phase and its log tail on publication while in progress', async () => {
jest.mocked(isSpinnerEnabled).mockReturnValue(true);
setBuildStatuses([BuildStatus.InProgress, BuildStatus.Finished]);
jest.mocked(sleepAsync).mockImplementationOnce(async () => {
logs = groupLogLinesIntoSteps([
{ phase: BuildPhase.INSTALL_DEPENDENCIES, msg: 'npm ci' },
{ phase: BuildPhase.INSTALL_DEPENDENCIES, msg: 'added 1 package' },
]);
publish();
});
await waitForBuildEndAsync(graphqlClient, { buildIds: ['build-id'], accountName: 'acct' });
expect(LogsWatcher).toHaveBeenCalledTimes(1);
expect(spinner.text).toContain('Build in progress...');
expect(spinner.text).toContain('Install dependencies');
expect(spinner.text).toContain('added 1 package');
expect(watcherClose).toHaveBeenCalledTimes(1);
});
it('ignores a publication that arrives while the build is queued', async () => {
jest.mocked(isSpinnerEnabled).mockReturnValue(true);
setBuildStatuses([BuildStatus.InProgress, BuildStatus.InQueue, BuildStatus.Finished]);
logs = groupLogLinesIntoSteps([{ phase: BuildPhase.INSTALL_DEPENDENCIES, msg: 'npm ci' }]);
await waitForBuildEndAsync(graphqlClient, { buildIds: ['build-id'], accountName: 'acct' });
expect(jest.mocked(sleepAsync).mock.calls.length).toBe(2);
expect(spinner.text).toBe('Build queued...');
});
});
+114 -21
View File
@@ -30,6 +30,12 @@ import {
} from './errors';
import { transformMetadata } from './graphql';
import { LocalBuildMode, runLocalBuildAsync } from './local';
import {
formatActiveBuildText,
formatActiveBuildsText,
isBuildCompleted,
logSourceForBuild,
} from './logs';
import { collectMetadataAsync } from './metadata';
import { printDeprecationWarnings } from './utils/printBuildInfo';
import {
@@ -46,6 +52,8 @@ import { getExpoWebsiteBaseUrl } from '../api';
import { formatStarterSubscribeCommand } from '../billing/plans';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { EasCommandError } from '../commandUtils/errors';
import { LogsState } from '../commandUtils/logs/state';
import { LogsWatcher } from '../commandUtils/logs/watcher';
import { createFingerprintAsync } from '../fingerprint/cli';
import {
AppPlatform,
@@ -60,7 +68,7 @@ import {
import { BuildMutation, BuildResult } from '../graphql/mutations/BuildMutation';
import { BuildQuery } from '../graphql/queries/BuildQuery';
import Log, { learnMore, link } from '../log';
import { Ora, ora } from '../ora';
import { Ora, isSpinnerEnabled, ora, updateSpinnerText } from '../ora';
import {
RequestedPlatform,
appPlatformDisplayNames,
@@ -70,6 +78,7 @@ import {
import { maybeUploadFingerprintAsync } from '../project/maybeUploadFingerprintAsync';
import { resolveRuntimeVersionAsync } from '../project/resolveRuntimeVersionAsync';
import { uploadFileAtPathToGCSAsync } from '../uploads';
import { createRealtimeLogsClient } from '../utils/centrifuge';
import { formatBytes } from '../utils/files';
import { printJsonOnlyOutput } from '../utils/json';
import { createProgressTracker } from '../utils/progress';
@@ -538,19 +547,64 @@ export async function waitForBuildEndAsync(
originalSpinnerText = 'Waiting for builds to complete. You can press Ctrl+C to exit.';
spinner = ora('Waiting for builds to complete. You can press Ctrl+C to exit.').start();
}
while (true) {
const builds = await getBuildsSafelyAsync(graphqlClient, buildIds);
const { refetch } =
builds.length === 1
? await handleSingleBuildProgressAsync({ build: builds[0], accountName }, { spinner })
: await handleMultipleBuildsProgressAsync({ builds }, { spinner, originalSpinnerText });
if (!refetch) {
return builds;
let render = (): void => {};
const watcher = isSpinnerEnabled()
? new LogsWatcher(
() => createRealtimeLogsClient(graphqlClient),
() => {
render();
}
)
: null;
try {
while (true) {
render = (): void => {};
const builds = await getBuildsSafelyAsync(graphqlClient, buildIds);
const logsStates = await syncBuildLogsAsync(watcher, builds);
const installRender = (nextRender: () => void): void => {
render = nextRender;
};
const { refetch } =
builds.length === 1
? await handleSingleBuildProgressAsync(
{ build: builds[0], accountName },
{ spinner, logsStates, installRender }
)
: await handleMultipleBuildsProgressAsync(
{ builds },
{ spinner, originalSpinnerText, logsStates, installRender }
);
if (!refetch) {
return builds;
}
await sleepAsync(intervalSec * 1000);
}
await sleepAsync(intervalSec * 1000);
} finally {
watcher?.close();
}
}
async function syncBuildLogsAsync(
watcher: LogsWatcher | null,
builds: MaybeBuildFragment[]
): Promise<Map<string, LogsState>> {
if (!watcher) {
return new Map();
}
const existingBuilds = builds.filter(isBuildFragment);
const logsStates = await watcher.syncAsync(existingBuilds.map(build => logSourceForBuild(build)));
for (const build of existingBuilds) {
if (isBuildCompleted(build.status)) {
nullthrows(
logsStates.get(build.id),
'syncAsync must have been called before markCompleted'
).markCompleted();
}
}
return logsStates;
}
async function getBuildsSafelyAsync(
graphqlClient: ExpoGraphqlClient,
buildIds: string[]
@@ -570,6 +624,11 @@ interface BuildProgressResult {
refetch: boolean;
}
type BuildLogsProgressOptions = {
logsStates: Map<string, LogsState>;
installRender: (render: () => void) => void;
};
let queueProgressBarStarted = false;
const queueProgressBar = new cliProgress.SingleBar(
{ format: '|{bar}| {estimatedWaitTime}' },
@@ -587,10 +646,12 @@ async function handleSingleBuildProgressAsync(
build: MaybeBuildFragment;
accountName: string;
},
{ spinner }: { spinner: Ora }
{ spinner, logsStates, installRender }: { spinner: Ora } & BuildLogsProgressOptions
): Promise<BuildProgressResult> {
if (build === null) {
spinner.text = 'Could not fetch the build status. Check your network connection.';
updateSpinnerText(spinner, {
text: 'Could not fetch the build status. Check your network connection.',
});
return { refetch: true };
}
@@ -615,16 +676,18 @@ async function handleSingleBuildProgressAsync(
statusNewSetAt ??= now;
const newStatusDurationMs = now - statusNewSetAt;
if (newStatusDurationMs < NEW_STATUS_GRACE_PERIOD_MS) {
spinner.text = 'Waiting for build to get enqueued…';
updateSpinnerText(spinner, { text: 'Waiting for build to get enqueued…' });
} else {
spinner.text = `Build concurrency limit reached for your account. Build will enter queue once a concurrency becomes available. Add additional concurrencies at ${link(
formatAccountBillingUrl(accountName)
)}.`;
updateSpinnerText(spinner, {
text: `Build concurrency limit reached for your account. Build will enter queue once a concurrency becomes available. Add additional concurrencies at ${link(
formatAccountBillingUrl(accountName)
)}.`,
});
}
break;
}
case BuildStatus.InQueue: {
spinner.text = 'Build queued...';
updateSpinnerText(spinner, { text: 'Build queued...' });
const progressBarPayload =
typeof build.estimatedWaitTimeLeftSeconds === 'number'
? { estimatedWaitTime: formatEstimatedWaitTime(build.estimatedWaitTimeLeftSeconds) }
@@ -661,9 +724,17 @@ async function handleSingleBuildProgressAsync(
case BuildStatus.Canceled:
spinner.fail('Build canceled');
return { refetch: false };
case BuildStatus.InProgress:
spinner.text = 'Build in progress...';
case BuildStatus.InProgress: {
const logsState = logsStates.get(build.id);
const render = (): void => {
updateSpinnerText(spinner, {
text: formatActiveBuildText('Build in progress...', logsState?.getLogs() ?? new Map()),
});
};
installRender(render);
render();
break;
}
case BuildStatus.Errored:
spinner.fail('Build failed');
if (build.error) {
@@ -698,7 +769,12 @@ const platforms = [AppPlatform.Android, AppPlatform.Ios];
async function handleMultipleBuildsProgressAsync(
{ builds: maybeBuilds }: { builds: MaybeBuildFragment[] },
{ spinner, originalSpinnerText }: { spinner: Ora; originalSpinnerText: string }
{
spinner,
originalSpinnerText,
logsStates,
installRender,
}: { spinner: Ora; originalSpinnerText: string } & BuildLogsProgressOptions
): Promise<BuildProgressResult> {
const buildCount = maybeBuilds.length;
const builds = maybeBuilds.filter<BuildFragment>(isBuildFragment);
@@ -732,7 +808,24 @@ async function handleMultipleBuildsProgressAsync(
someNew &&
statusNewSetAt !== null &&
Date.now() - statusNewSetAt >= NEW_STATUS_GRACE_PERIOD_MS;
spinner.text = formatPendingBuildsText(originalSpinnerText, builds, showConcurrencyWarning);
const pendingBuildsText = formatPendingBuildsText(
originalSpinnerText,
builds,
showConcurrencyWarning
);
const inProgressBuilds = builds.filter(build => build.status === BuildStatus.InProgress);
const render = (): void => {
const text = formatActiveBuildsText(
pendingBuildsText,
inProgressBuilds.map(build => ({
build,
logs: logsStates.get(build.id)?.getLogs() ?? new Map(),
}))
);
updateSpinnerText(spinner, { text });
};
installRender(render);
render();
return { refetch: true };
}
}
+1 -2
View File
@@ -87,8 +87,7 @@ export async function createBuildContextAsync<T extends Platform>({
buildProfile.developmentClient ??
(platform === Platform.ANDROID
? (buildProfile as BuildProfile<Platform.ANDROID>)?.gradleCommand === ':app:assembleDebug'
: (buildProfile as BuildProfile<Platform.IOS>)?.buildConfiguration === 'Debug') ??
false;
: (buildProfile as BuildProfile<Platform.IOS>)?.buildConfiguration === 'Debug');
const requiredPackageManager = resolvePackageManager(projectDir);
+113
View File
@@ -0,0 +1,113 @@
import { BuildPhase, buildPhaseDisplayName } from '@expo/eas-build-job';
import { choicesFromJobLogs, stepLogTail } from '../commandUtils/logs/format';
import { parseLogLines } from '../commandUtils/logs/parseLogs';
import { JobLogs, RawLogLine } from '../commandUtils/logs/types';
import { LogSource } from '../commandUtils/logs/watcher';
import fetch from '../fetch';
import { BuildFragment, BuildStatus, RealtimeLogsTargetType } from '../graphql/generated';
import Log from '../log';
import { appPlatformDisplayNames, appPlatformEmojis } from '../platform';
import formatFields, { FormatFieldsItem } from '../utils/formatFields';
const LOGS_FETCH_TIMEOUT_MS = 5_000;
const SINGLE_BUILD_MAX_LOG_LINES = 5;
const MULTIPLE_BUILDS_MAX_LOG_LINES = 3;
async function fetchAndParseLogsForBuildAsync(build: BuildFragment): Promise<RawLogLine[] | null> {
const logFileUrl = build.logFiles[0];
if (!logFileUrl) {
return null;
}
const response = await fetch(logFileUrl, { timeout: LOGS_FETCH_TIMEOUT_MS });
const rawLogs = await response.text();
if (!rawLogs) {
return null;
}
const { logLines, errors } = parseLogLines(rawLogs);
for (const error of errors) {
Log.debug(`Failed to parse a log line: ${error.message}`);
}
return logLines;
}
export function logSourceForBuild(build: BuildFragment): LogSource {
return {
key: build.id,
realtimeTarget: { type: RealtimeLogsTargetType.Build, id: build.id },
isInProgress: build.status === BuildStatus.InProgress,
fetchRawLogLinesAsync: async () => await fetchAndParseLogsForBuildAsync(build),
};
}
export function isBuildCompleted(status: BuildStatus): boolean {
switch (status) {
case BuildStatus.Finished:
case BuildStatus.Errored:
case BuildStatus.Canceled:
return true;
case BuildStatus.New:
case BuildStatus.InQueue:
case BuildStatus.InProgress:
case BuildStatus.PendingCancel:
return false;
}
}
function stepDisplayName(label: string): string {
return Object.hasOwn(buildPhaseDisplayName, label)
? buildPhaseDisplayName[label as BuildPhase]
: label;
}
function activeBuildFields(
logs: JobLogs,
maxLogLines: number,
buildLabel: string | null
): FormatFieldsItem[] {
const steps = choicesFromJobLogs(logs);
if (steps.length === 0) {
return [];
}
const currentStep = steps[steps.length - 1];
const fields: FormatFieldsItem[] = [{ label: '', value: '' }];
if (buildLabel !== null) {
fields.push({ label: ' Build', value: buildLabel });
}
fields.push({ label: ' Current phase', value: stepDisplayName(currentStep.name) });
if (currentStep.logLines?.length) {
fields.push({ label: ' Current logs', value: '' });
for (const logLine of stepLogTail(currentStep, maxLogLines)) {
fields.push({ label: '', value: logLine });
}
}
return fields;
}
function appendFields(baseText: string, fields: FormatFieldsItem[]): string {
if (fields.length === 0) {
return baseText;
}
return `${baseText}\n${formatFields(fields)}`;
}
export function formatActiveBuildText(baseText: string, logs: JobLogs): string {
return appendFields(baseText, activeBuildFields(logs, SINGLE_BUILD_MAX_LOG_LINES, null));
}
export function formatActiveBuildsText(
baseText: string,
buildsWithLogs: { build: BuildFragment; logs: JobLogs }[]
): string {
return appendFields(
baseText,
buildsWithLogs.flatMap(({ build, logs }) =>
activeBuildFields(
logs,
MULTIPLE_BUILDS_MAX_LOG_LINES,
`${appPlatformEmojis[build.platform]} ${appPlatformDisplayNames[build.platform]}`
)
)
);
}
@@ -504,7 +504,10 @@ async function prepareAndStartBuildAsync({
if (easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE) {
validateAppConfigForRemoteVersionSource(buildCtx.exp, buildProfile.platform);
}
if (buildCtx.workflow === Workflow.MANAGED) {
// Custom builds run the steps from the profile's config file instead of the
// standard managed pipeline, so the managed-workflow checks do not apply.
const isCustomBuild = !!buildProfile.profile.config;
if (buildCtx.workflow === Workflow.MANAGED && !isCustomBuild) {
if (!sdkVersionChecked) {
await checkExpoSdkIsSupportedAsync(buildCtx);
sdkVersionChecked = true;
@@ -45,11 +45,6 @@ describe(getProjectIdAsync, () => {
id: 'user_id',
email: '[email protected]',
username: 'notnotbrent',
primaryAccount: {
id: 'account_id_1',
name: 'notnotbrent',
viewerUserPermission: { role: Role.Owner },
},
accounts: [
{
id: 'account_id_1',
@@ -420,11 +415,6 @@ describe(getProjectIdAsync, () => {
id: 'user_id',
email: '[email protected]',
username: 'notnotbrent',
primaryAccount: {
id: 'account_id_1',
name: 'notnotbrent',
viewerUserPermission: { role: Role.Owner },
},
accounts: [
{
id: 'account_id_1',
@@ -524,11 +514,6 @@ describe(getProjectIdAsync, () => {
id: 'user_id',
email: '[email protected]',
username: 'notnotbrent',
primaryAccount: {
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
},
accounts: [
{
id: 'account_id_1',
@@ -1,7 +1,7 @@
import { groupLogLinesIntoSteps, mergeLogLines, parseLogLines } from '../parseLogs';
import { WorkflowRawLogLine } from '../../types';
import { RawLogLine } from '../types';
function logLine(overrides: Partial<WorkflowRawLogLine> = {}): WorkflowRawLogLine {
function logLine(overrides: Partial<RawLogLine> = {}): RawLogLine {
return { msg: 'a message', ...overrides };
}
@@ -0,0 +1,110 @@
import { LogsState } from '../state';
import { RawLogLine } from '../types';
function fileLine(logId: string, msg: string, buildStepId = 'install'): RawLogLine {
return { logId, buildStepId, msg };
}
function stepMessages(logsState: LogsState): string[] {
return Array.from(logsState.getLogs().values()).flatMap(group =>
group.logLines.map(logLine => logLine.msg)
);
}
describe(LogsState, () => {
it('hides realtime lines until the file logs reach them', () => {
const logsState = new LogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('5', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first']);
});
it('shows realtime lines once a published logId also appears in the file', () => {
const logsState = new LogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
logsState.ingestFileLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
logsState.ingestRealtimeLogLines([fileLine('3', 'newer')]);
expect(stepMessages(logsState)).toEqual(['first', 'pushed', 'newer']);
});
it('shows realtime logs when a publication repeats a logId already in the file', () => {
const logsState = new LogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
});
it('shows buffered realtime lines once the job is completed', () => {
const logsState = new LogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('9', 'pushed')]);
logsState.markCompleted();
logsState.markCompleted();
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
});
it('folds later publications once completion showed them', () => {
const logsState = new LogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('9', 'buffered')]);
logsState.markCompleted();
logsState.ingestRealtimeLogLines([fileLine('10', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first', 'buffered', 'pushed']);
});
it('replaces the file snapshot instead of accumulating it', () => {
const logsState = new LogsState();
const keyless: RawLogLine = { buildStepId: 'install', msg: 'submission log' };
logsState.ingestFileLogLines([keyless]);
logsState.ingestFileLogLines([keyless]);
expect(stepMessages(logsState)).toEqual(['submission log']);
});
it('drops a buffered realtime line once it lands in the file', () => {
const logsState = new LogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
logsState.ingestFileLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
});
it('ignores publications that are not arrays of log lines', () => {
const logsState = new LogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
expect(logsState.ingestRealtimeLogLines({ logId: '2', msg: 'not an array' })).toBe(false);
expect(logsState.ingestRealtimeLogLines(['a string', 42, null])).toBe(false);
expect(logsState.ingestRealtimeLogLines([{ msg: 'no logId' }])).toBe(false);
expect(stepMessages(logsState)).toEqual(['first']);
});
it('deduplicates a line republished on the same channel', () => {
const logsState = new LogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
});
it('deduplicates a line republished later', () => {
const logsState = new LogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
logsState.ingestRealtimeLogLines([fileLine('3', 'newer')]);
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first', 'pushed', 'newer']);
});
});
@@ -0,0 +1,153 @@
import { RealtimeLogsTargetType } from '../../../graphql/generated';
import { RealtimeLogsClient } from '../../../utils/centrifuge';
import { LogSource, LogsWatcher } from '../watcher';
function createFakeClient(): {
client: RealtimeLogsClient;
publish: (data: unknown) => void;
subscribeCalls: unknown[];
closeCount: () => number;
} {
const listeners: ((data: unknown) => void)[] = [];
const subscribeCalls: unknown[] = [];
let closed = 0;
return {
subscribeCalls,
closeCount: () => closed,
publish: data => {
listeners.forEach(listener => {
listener(data);
});
},
client: {
subscribeAsync: async (args, onPublication) => {
subscribeCalls.push(args);
listeners.push(onPublication);
return { close: () => closed++ };
},
close: () => closed++,
},
};
}
describe(LogsWatcher, () => {
let fetchRawLogLinesAsync: jest.Mock;
beforeEach(() => {
fetchRawLogLinesAsync = jest
.fn()
.mockResolvedValue([{ logId: '1', buildStepId: 'install', msg: 'from the file' }]);
});
function inProgressSource(overrides: Partial<LogSource> = {}): LogSource {
return {
key: 'job1',
realtimeTarget: { type: RealtimeLogsTargetType.JobRun, id: 'job-run-id' },
isInProgress: true,
fetchRawLogLinesAsync,
...overrides,
};
}
it('subscribes once per in-progress source', async () => {
const fake = createFakeClient();
const watcher = new LogsWatcher(
() => fake.client,
() => {}
);
await watcher.syncAsync([inProgressSource()]);
await watcher.syncAsync([inProgressSource()]);
expect(fake.subscribeCalls).toEqual([
{ target: { type: RealtimeLogsTargetType.JobRun, id: 'job-run-id' } },
]);
});
it('reports realtime logs as they arrive', async () => {
const fake = createFakeClient();
const onRealtimeLogs = jest.fn();
const watcher = new LogsWatcher(() => fake.client, onRealtimeLogs);
await watcher.syncAsync([inProgressSource()]);
fake.publish([{ logId: '2', buildStepId: 'install', msg: 'pushed' }]);
expect(onRealtimeLogs).toHaveBeenCalledTimes(1);
});
it('does not report a publication that carries no usable log lines', async () => {
const fake = createFakeClient();
const onRealtimeLogs = jest.fn();
const watcher = new LogsWatcher(() => fake.client, onRealtimeLogs);
await watcher.syncAsync([inProgressSource()]);
fake.publish([{ msg: 'no logId' }]);
expect(onRealtimeLogs).not.toHaveBeenCalled();
});
it('still fetches logs when the realtime logs client is unavailable', async () => {
const watcher = new LogsWatcher(
() => null,
() => {}
);
await watcher.syncAsync([inProgressSource()]);
expect(fetchRawLogLinesAsync).toHaveBeenCalledTimes(1);
});
it('fetches logs on every sync while in progress, and stops once the source is not', async () => {
const fake = createFakeClient();
const watcher = new LogsWatcher(
() => fake.client,
() => {}
);
await watcher.syncAsync([inProgressSource()]);
await watcher.syncAsync([inProgressSource()]);
expect(fetchRawLogLinesAsync).toHaveBeenCalledTimes(2);
await watcher.syncAsync([inProgressSource({ isInProgress: false })]);
expect(fetchRawLogLinesAsync).toHaveBeenCalledTimes(2);
});
it('does not fetch logs for a source that is not in progress', async () => {
const fake = createFakeClient();
const watcher = new LogsWatcher(
() => fake.client,
() => {}
);
await watcher.syncAsync([inProgressSource({ isInProgress: false })]);
expect(fetchRawLogLinesAsync).not.toHaveBeenCalled();
});
it('closes the subscription when a source leaves in-progress', async () => {
const fake = createFakeClient();
const watcher = new LogsWatcher(
() => fake.client,
() => {}
);
await watcher.syncAsync([inProgressSource()]);
expect(fake.closeCount()).toBe(0);
await watcher.syncAsync([inProgressSource({ isInProgress: false })]);
expect(fake.closeCount()).toBe(1);
});
it('closes the client and its subscriptions', async () => {
const fake = createFakeClient();
const watcher = new LogsWatcher(
() => fake.client,
() => {}
);
await watcher.syncAsync([inProgressSource()]);
watcher.close();
expect(fake.closeCount()).toBe(2);
});
});
@@ -0,0 +1,25 @@
import { JobLogs, LogLine } from './types';
import { Choice } from '../../prompts';
export function choicesFromJobLogs(
logs: JobLogs
): (Choice & { name: string; status: string; logLines: LogLine[] | undefined })[] {
return Array.from(logs.values())
.map(({ key, label, result, logLines }) => ({
title: `${label} - ${result ?? ''}`,
name: label,
status: result ?? '',
value: key,
logLines,
}))
.filter(step => step.status !== 'skipped');
}
export function stepLogTail(
step: { logLines?: LogLine[] },
maxLogLines: number // -1 means no limit
): string[] {
const logLines = step.logLines ?? [];
const tail = maxLogLines === -1 ? logLines : logLines.slice(-maxLogLines);
return tail.map(line => line.msg);
}
@@ -0,0 +1,56 @@
import { JobLogs, RawLogLine } from './types';
import uniqBy from '../../utils/expodash/uniqBy';
export function parseLogLines(rawLogs: string): {
logLines: RawLogLine[];
errors: Error[];
} {
const logLines: RawLogLine[] = [];
const errors: Error[] = [];
for (const rawLogLine of rawLogs.split('\n')) {
if (!rawLogLine) {
continue;
}
try {
logLines.push(JSON.parse(rawLogLine));
} catch (err) {
errors.push(err as Error);
}
}
return { logLines, errors };
}
export function mergeLogLines<T extends RawLogLine>(...logLineGroups: T[][]): T[] {
return uniqBy(logLineGroups.flat().reverse(), logLine => logLine.logId ?? Symbol()).reverse();
}
export function groupLogLinesIntoSteps(
logLines: RawLogLine[],
accumulator: JobLogs = new Map()
): JobLogs {
for (const logLine of logLines) {
const { buildStepDisplayName, buildStepId, phase, time, msg, result, marker, err } = logLine;
const stepKey = buildStepId ?? buildStepDisplayName ?? phase;
const stepLabel = buildStepDisplayName ?? buildStepId ?? phase;
if (!stepKey || !stepLabel || !msg) {
continue;
}
let logGroup = accumulator.get(stepKey);
if (!logGroup) {
logGroup = { key: stepKey, label: stepLabel, logLines: [] };
accumulator.set(stepKey, logGroup);
}
if (buildStepDisplayName) {
logGroup.label = buildStepDisplayName;
}
if (logGroup.result === undefined && (marker === 'end-step' || marker === 'END_PHASE')) {
logGroup.result = result ?? '';
}
logGroup.logLines.push({ time, msg, result, marker, err });
}
return accumulator;
}
@@ -0,0 +1,94 @@
import { groupLogLinesIntoSteps, mergeLogLines } from './parseLogs';
import { JobLogs, RawLogLine } from './types';
type RealtimeLogLine = RawLogLine & { logId: string };
function isRealtimeLogLine(entry: unknown): entry is RealtimeLogLine {
return (
typeof entry === 'object' &&
entry !== null &&
typeof (entry as Partial<RawLogLine>).logId === 'string'
);
}
export class LogsState {
private fileLogIds = new Set<string>();
private realtimeLogLines: RealtimeLogLine[] = [];
private realtimeLogIds = new Set<string>();
private haveFileLogsCaughtUp = false;
private realtimeLogsRevealed = false;
private jobLogs: JobLogs = new Map();
public ingestFileLogLines(logLines: RawLogLine[]): void {
this.fileLogIds = new Set(logLines.flatMap(logLine => (logLine.logId ? [logLine.logId] : [])));
if (
!this.haveFileLogsCaughtUp &&
this.realtimeLogLines.some(logLine => this.fileLogIds.has(logLine.logId))
) {
this.haveFileLogsCaughtUp = true;
}
this.realtimeLogLines = this.realtimeLogLines.filter(
logLine => !this.fileLogIds.has(logLine.logId)
);
this.realtimeLogIds = new Set(this.realtimeLogLines.map(logLine => logLine.logId));
if (this.haveFileLogsCaughtUp) {
this.realtimeLogsRevealed = true;
}
this.jobLogs = groupLogLinesIntoSteps(
mergeLogLines(logLines, this.realtimeLogsRevealed ? this.realtimeLogLines : [])
);
}
public ingestRealtimeLogLines(data: unknown): boolean {
if (!Array.isArray(data)) {
return false;
}
const publishedLogLines = data.filter(isRealtimeLogLine);
if (publishedLogLines.length === 0) {
return false;
}
if (
!this.haveFileLogsCaughtUp &&
publishedLogLines.some(logLine => this.fileLogIds.has(logLine.logId))
) {
this.haveFileLogsCaughtUp = true;
}
const newLogLines = publishedLogLines.filter(
logLine => !this.fileLogIds.has(logLine.logId) && !this.realtimeLogIds.has(logLine.logId)
);
for (const logLine of newLogLines) {
this.realtimeLogLines.push(logLine);
this.realtimeLogIds.add(logLine.logId);
}
if (!this.realtimeLogsRevealed) {
if (this.haveFileLogsCaughtUp) {
this.revealRealtimeLogs();
}
} else if (newLogLines.length > 0) {
groupLogLinesIntoSteps(newLogLines, this.jobLogs);
}
return true;
}
public markCompleted(): void {
this.revealRealtimeLogs();
}
public getLogs(): JobLogs {
return this.jobLogs;
}
private revealRealtimeLogs(): void {
if (this.realtimeLogsRevealed) {
return;
}
this.realtimeLogsRevealed = true;
groupLogLinesIntoSteps(this.realtimeLogLines, this.jobLogs);
}
}
@@ -0,0 +1,23 @@
export type RawLogLine = {
logId?: string;
buildStepId?: string;
buildStepDisplayName?: string;
phase?: string;
time?: string;
msg?: string;
result?: string;
marker?: string;
err?: any;
};
export type LogLine = Pick<RawLogLine, 'time' | 'result' | 'marker' | 'err'> &
Required<Pick<RawLogLine, 'msg'>>;
export type StepLogs = {
key: string;
label: string;
result?: string;
logLines: LogLine[];
};
export type JobLogs = Map<string, StepLogs>;
@@ -0,0 +1,102 @@
import { LogsState } from './state';
import { RawLogLine } from './types';
import { RealtimeLogsTargetInput } from '../../graphql/generated';
import Log from '../../log';
import { RealtimeLogsClient, RealtimeLogsSubscription } from '../../utils/centrifuge';
import nullthrows from 'nullthrows';
export type LogSource = {
key: string;
realtimeTarget: RealtimeLogsTargetInput | null;
isInProgress: boolean;
fetchRawLogLinesAsync: () => Promise<RawLogLine[] | null>;
};
type TrackedSource = {
logsState: LogsState;
subscription: RealtimeLogsSubscription | null;
};
export class LogsWatcher {
private readonly trackedSources = new Map<string, TrackedSource>();
private realtimeLogsClient?: RealtimeLogsClient | null;
constructor(
private readonly createRealtimeLogsClient: () => RealtimeLogsClient | null,
private readonly onRealtimeLogs: () => void
) {}
public async syncAsync(sources: LogSource[]): Promise<Map<string, LogsState>> {
await Promise.all(sources.map(source => this.syncSourceAsync(source)));
return new Map(
sources.map(source => [
source.key,
nullthrows(
this.trackedSources.get(source.key),
'source has to be in tracked sources after it was synced'
).logsState,
])
);
}
public close(): void {
for (const trackedSource of this.trackedSources.values()) {
trackedSource.subscription?.close();
trackedSource.subscription = null;
}
this.realtimeLogsClient?.close();
}
private async syncSourceAsync(source: LogSource): Promise<void> {
let trackedSource = this.trackedSources.get(source.key);
if (!trackedSource) {
trackedSource = { logsState: new LogsState(), subscription: null };
this.trackedSources.set(source.key, trackedSource);
}
if (!source.isInProgress) {
trackedSource.subscription?.close();
trackedSource.subscription = null;
return;
}
await Promise.all([
trackedSource.subscription ? Promise.resolve() : this.subscribeAsync(source, trackedSource),
this.fetchLogsAsync(source, trackedSource),
]);
}
private async fetchLogsAsync(source: LogSource, trackedSource: TrackedSource): Promise<void> {
try {
const logLines = await source.fetchRawLogLinesAsync();
if (logLines) {
trackedSource.logsState.ingestFileLogLines(logLines);
}
} catch (err: any) {
Log.debug(`Failed to fetch logs for job ${source.key}: ${err.message}`);
}
}
private getRealtimeLogsClient(): RealtimeLogsClient | null {
if (this.realtimeLogsClient === undefined) {
this.realtimeLogsClient = this.createRealtimeLogsClient();
}
return this.realtimeLogsClient;
}
private async subscribeAsync(source: LogSource, trackedSource: TrackedSource): Promise<void> {
const target = source.realtimeTarget;
if (!target) {
return;
}
const realtimeLogsClient = this.getRealtimeLogsClient();
if (!realtimeLogsClient) {
return;
}
trackedSource.subscription = await realtimeLogsClient.subscribeAsync({ target }, data => {
if (trackedSource.logsState.ingestRealtimeLogLines(data)) {
this.onRealtimeLogs();
}
});
}
}
@@ -1 +0,0 @@
@AGENTS.md
@@ -1,12 +1,19 @@
import { CombinedError } from '@urql/core';
import chalk from 'chalk';
import {
SupabaseAdvisorLintData,
SupabaseAdvisorLintLevel,
SupabaseAdvisorType,
SupabaseConnectionData,
SupabaseOrganizationData,
SupabaseProjectData,
} from '../graphql/types/SupabaseConnection';
import Log, { link } from '../log';
export const SUPABASE_REAUTHORIZATION_REQUIRED_ERROR_CODE =
'SUPABASE_REAUTHORIZATION_REQUIRED_ERROR';
export function getSupabaseProjectDashboardUrl(
project: Pick<SupabaseProjectData, 'supabaseProjectRef'>
): string {
@@ -94,3 +101,72 @@ export function formatSupabaseProject(project: SupabaseProjectData): string {
export function logNoSupabaseProject(projectName: string): void {
Log.warn(`No Supabase project is linked to Expo app ${chalk.bold(projectName)} on EAS.`);
}
export function getSupabaseAdvisorsDashboardUrl(
project: Pick<SupabaseProjectData, 'supabaseProjectRef'>,
type: SupabaseAdvisorType
): string {
return `${getSupabaseProjectDashboardUrl(project)}/advisors/${type.toLowerCase()}`;
}
export function isSupabaseReauthorizationRequiredError(error: unknown): boolean {
return (
error instanceof CombinedError &&
error.graphQLErrors.some(
graphQLError =>
graphQLError.extensions?.errorCode === SUPABASE_REAUTHORIZATION_REQUIRED_ERROR_CODE
)
);
}
const ADVISOR_LINT_LEVEL_LABELS: Record<
SupabaseAdvisorLintLevel,
[singular: string, plural: string]
> = {
[SupabaseAdvisorLintLevel.Error]: ['error', 'errors'],
[SupabaseAdvisorLintLevel.Warn]: ['warning', 'warnings'],
[SupabaseAdvisorLintLevel.Info]: ['info', 'info'],
};
const ADVISOR_LINT_LEVEL_MARKERS: Record<SupabaseAdvisorLintLevel, string> = {
[SupabaseAdvisorLintLevel.Error]: chalk.red('✖'),
[SupabaseAdvisorLintLevel.Warn]: chalk.yellow('▲'),
[SupabaseAdvisorLintLevel.Info]: chalk.blue('●'),
};
export function summarizeSupabaseAdvisorLints(lints: readonly SupabaseAdvisorLintData[]): string {
if (lints.length === 0) {
return 'No unresolved findings';
}
return (Object.keys(ADVISOR_LINT_LEVEL_LABELS) as SupabaseAdvisorLintLevel[])
.map(level => [level, lints.filter(lint => lint.level === level).length] as const)
.filter(([, count]) => count > 0)
.map(([level, count]) => `${count} ${ADVISOR_LINT_LEVEL_LABELS[level][count === 1 ? 0 : 1]}`)
.join(', ');
}
function formatInlineCode(text: string): string {
return text
.replaceAll('\\`', '`')
.split('`')
.map((part, index) => (index % 2 === 1 ? chalk.cyan(part) : part))
.join('');
}
export function formatSupabaseAdvisorLints(
project: Pick<SupabaseProjectData, 'supabaseProjectRef'>,
type: SupabaseAdvisorType,
lints: readonly SupabaseAdvisorLintData[]
): string {
const heading = `${chalk.bold(type === SupabaseAdvisorType.Security ? 'Security' : 'Performance')} · ${summarizeSupabaseAdvisorLints(lints)}`;
const dashboardUrl = getSupabaseAdvisorsDashboardUrl(project, type);
const rows = lints.map(lint =>
[
` ${ADVISOR_LINT_LEVEL_MARKERS[lint.level]} ${lint.level === SupabaseAdvisorLintLevel.Warn ? 'WARNING' : lint.level} ${chalk.bold(lint.title)}`,
...(lint.entity ? [` ${chalk.dim(lint.entity)}`] : []),
` ${formatInlineCode(lint.detail)}`,
` ${link(`${dashboardUrl}?id=${encodeURIComponent(lint.cacheKey)}`, { text: chalk.underline('View in Supabase ↗'), dim: false })}`,
].join('\n')
);
return [heading, ...rows].join('\n\n');
}
Loaded 100 of 209 files, more files were not shown because too many files have changed in this diff. Show more